import os import re from datetime import datetime, timedelta, timezone from django.conf import settings from django.utils.timezone import now as timezone_now from version import DESKTOP_MINIMUM_VERSION, DESKTOP_WARNING_VERSION from zerver.lib.user_agent import parse_user_agent from zerver.models import UserProfile from zerver.signals import get_device_browser # LAST_SERVER_UPGRADE_TIME is the last time the server had a version deployed. if settings.PRODUCTION: # nocoverage timestamp = os.path.basename(os.path.abspath(settings.DEPLOY_ROOT)) LAST_SERVER_UPGRADE_TIME = datetime.strptime(timestamp, "%Y-%m-%d-%H-%M-%S").replace( tzinfo=timezone.utc ) else: LAST_SERVER_UPGRADE_TIME = timezone_now() def is_outdated_server(user_profile: UserProfile | None) -> bool: # Release tarballs are unpacked via `tar -xf`, which means the # `mtime` on files in them is preserved from when the release # tarball was built. Checking this allows us to catch cases where # someone has upgraded in the last year but to a release more than # a year old. git_version_path = os.path.join(settings.DEPLOY_ROOT, "version.py") release_build_time = datetime.fromtimestamp(os.path.getmtime(git_version_path), timezone.utc) version_no_newer_than = min(LAST_SERVER_UPGRADE_TIME, release_build_time) deadline = version_no_newer_than + timedelta(days=settings.SERVER_UPGRADE_NAG_DEADLINE_DAYS) if user_profile is None or not user_profile.is_realm_admin: # Administrators get warned at the deadline; all users 30 days later. deadline += timedelta(days=30) if timezone_now() > deadline: return True return False def pop_numerals(ver: str) -> tuple[list[int], str]: match = re.search(r"^( \d+ (?: \. \d+ )* ) (.*)", ver, re.VERBOSE) if match is None: return [], ver numerals, rest = match.groups() numbers = [int(n) for n in numerals.split(".")] return numbers, rest def version_lt(ver1: str, ver2: str) -> bool | None: """ Compare two Zulip-style version strings. Versions are dot-separated sequences of decimal integers, followed by arbitrary trailing decoration. Comparison is lexicographic on the integer sequences, and refuses to guess how any trailing decoration compares to any other, to further numerals, or to nothing. Returns: True if ver1 < ver2 False if ver1 >= ver2 None if can't tell. """ num1, rest1 = pop_numerals(ver1) num2, rest2 = pop_numerals(ver2) if not num1 or not num2: return None common_len = min(len(num1), len(num2)) common_num1, rest_num1 = num1[:common_len], num1[common_len:] common_num2, rest_num2 = num2[:common_len], num2[common_len:] # Leading numbers win. if common_num1 != common_num2: return common_num1 < common_num2 # More numbers beats end-of-string, but ??? vs trailing text. # (NB at most one of rest_num1, rest_num2 is nonempty.) if not rest1 and rest_num2: return True if rest_num1 and not rest2: return False if rest_num1 or rest_num2: return None # Trailing text we can only compare for equality. if rest1 == rest2: return False return None def find_mobile_os(user_agent: str) -> str | None: if re.search(r"\b Android \b", user_agent, re.IGNORECASE | re.VERBOSE): return "android" if re.search(r"\b(?: iOS | iPhone\ OS )\b", user_agent, re.IGNORECASE | re.VERBOSE): return "ios" return None def is_outdated_desktop_app(user_agent_str: str) -> tuple[bool, bool, bool]: # Returns (insecure, banned, auto_update_broken) user_agent = parse_user_agent(user_agent_str) if user_agent["name"] == "ZulipDesktop": # The deprecated QT/webkit based desktop app, last updated in ~2016. return (True, True, True) if user_agent["name"] != "ZulipElectron": return (False, False, False) if version_lt(user_agent["version"], "4.0.0"): # Version 2.3.82 and older (aka <4.0.0) of the modern # Electron-based Zulip desktop app with known security issues. # won't auto-update; we may want a special notice to # distinguish those from modern releases. return (True, True, True) if version_lt(user_agent["version"], DESKTOP_MINIMUM_VERSION): # Below DESKTOP_MINIMUM_VERSION, we reject access as well. return (True, True, False) if version_lt(user_agent["version"], DESKTOP_WARNING_VERSION): # Other insecure versions should just warn. return (True, False, False) return (False, False, False) def is_unsupported_browser(user_agent: str) -> tuple[bool, str | None]: browser_name = get_device_browser(user_agent) if browser_name == "Internet Explorer": return (True, browser_name) return (False, browser_name) def is_pronouns_field_type_supported(user_agent_str: str | None) -> bool: # In order to avoid users having a bad experience with these # custom profile fields disappearing after applying migration # 0421_migrate_pronouns_custom_profile_fields, we provide this # compatibility shim to show such custom profile fields as # SHORT_TEXT to older mobile app clients. # # TODO/compatibility(7.0): Because this is a relatively minor # detail, we can remove this compatibility hack once most users # have upgraded to a sufficiently new mobile client. if user_agent_str is None: return True user_agent = parse_user_agent(user_agent_str) if user_agent["name"] != "ZulipMobile": return True FIRST_VERSION_TO_SUPPORT_PRONOUNS_FIELD = "27.192" if version_lt(user_agent["version"], FIRST_VERSION_TO_SUPPORT_PRONOUNS_FIELD): return False return True