diff --git a/zerver/lib/compatibility.py b/zerver/lib/compatibility.py new file mode 100644 index 0000000000..3859981682 --- /dev/null +++ b/zerver/lib/compatibility.py @@ -0,0 +1,141 @@ +import datetime +import os +import re +from typing import List, Optional, Tuple + +import pytz +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.datetime.strptime(timestamp, "%Y-%m-%d-%H-%M-%S").replace( + tzinfo=pytz.utc + ) +else: + LAST_SERVER_UPGRADE_TIME = timezone_now() + + +def is_outdated_server(user_profile: Optional[UserProfile]) -> 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.datetime.utcfromtimestamp( + os.path.getmtime(git_version_path) + ).replace(tzinfo=pytz.utc) + + version_no_newer_than = min(LAST_SERVER_UPGRADE_TIME, release_build_time) + deadline = version_no_newer_than + datetime.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 = deadline + datetime.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.X) + 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) -> Optional[bool]: + """ + 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) -> Optional[str]: + if re.search(r"\b Android \b", user_agent, re.I | re.X): + return "android" + if re.search(r"\b(?: iOS | iPhone\ OS )\b", user_agent, re.I | re.X): + 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, Optional[str]]: + browser_name = get_device_browser(user_agent) + if browser_name == "Internet Explorer": + return (True, browser_name) + return (False, browser_name) diff --git a/zerver/lib/home.py b/zerver/lib/home.py index 5743a722e3..b49da05cd8 100644 --- a/zerver/lib/home.py +++ b/zerver/lib/home.py @@ -1,17 +1,14 @@ import calendar -import datetime -import os import time from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple -import pytz from django.conf import settings from django.http import HttpRequest from django.utils import translation -from django.utils.timezone import now as timezone_now from two_factor.utils import default_device +from zerver.lib.compatibility import is_outdated_server from zerver.lib.events import do_events_register from zerver.lib.i18n import ( get_and_set_request_language, @@ -39,41 +36,6 @@ class UserPermissionInfo: show_webathena: bool -# 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.datetime.strptime(timestamp, "%Y-%m-%d-%H-%M-%S").replace( - tzinfo=pytz.utc - ) -else: - LAST_SERVER_UPGRADE_TIME = timezone_now() - - -def is_outdated_server(user_profile: Optional[UserProfile]) -> 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.datetime.utcfromtimestamp( - os.path.getmtime(git_version_path) - ).replace(tzinfo=pytz.utc) - - version_no_newer_than = min(LAST_SERVER_UPGRADE_TIME, release_build_time) - deadline = version_no_newer_than + datetime.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 = deadline + datetime.timedelta(days=30) - - if timezone_now() > deadline: - return True - return False - - def get_furthest_read_time(user_profile: Optional[UserProfile]) -> Optional[float]: if user_profile is None: return time.time() diff --git a/zerver/tests/test_compatibility.py b/zerver/tests/test_compatibility.py index abe6a9ec28..9c77f3625f 100644 --- a/zerver/tests/test_compatibility.py +++ b/zerver/tests/test_compatibility.py @@ -1,7 +1,7 @@ from unittest import mock +from zerver.lib.compatibility import find_mobile_os, is_outdated_desktop_app, version_lt from zerver.lib.test_classes import ZulipTestCase -from zerver.views.compatibility import find_mobile_os, is_outdated_desktop_app, version_lt class VersionTest(ZulipTestCase): @@ -109,8 +109,8 @@ class CompatibilityTest(ZulipTestCase): else: assert False # nocoverage - @mock.patch("zerver.views.compatibility.DESKTOP_MINIMUM_VERSION", "5.0.0") - @mock.patch("zerver.views.compatibility.DESKTOP_WARNING_VERSION", "5.2.0") + @mock.patch("zerver.lib.compatibility.DESKTOP_MINIMUM_VERSION", "5.0.0") + @mock.patch("zerver.lib.compatibility.DESKTOP_WARNING_VERSION", "5.2.0") def test_insecure_desktop_app(self) -> None: self.assertEqual(is_outdated_desktop_app("ZulipDesktop/0.5.2 (Mac)"), (True, True, True)) self.assertEqual( @@ -134,7 +134,7 @@ class CompatibilityTest(ZulipTestCase): ) # Verify what happens if DESKTOP_MINIMUM_VERSION < v < DESKTOP_WARNING_VERSION - with mock.patch("zerver.views.compatibility.DESKTOP_MINIMUM_VERSION", "4.0.3"): + with mock.patch("zerver.lib.compatibility.DESKTOP_MINIMUM_VERSION", "4.0.3"): self.assertEqual( is_outdated_desktop_app( "ZulipElectron/4.0.3 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Zulip/4.0.3 Chrome/66.0.3359.181 Electron/3.1.10 Safari/537.36" diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 19cb133f73..603541d46c 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -17,8 +17,9 @@ from zerver.lib.actions import ( do_change_plan_type, do_create_user, ) +from zerver.lib.compatibility import LAST_SERVER_UPGRADE_TIME, is_outdated_server from zerver.lib.events import add_realm_logo_fields -from zerver.lib.home import LAST_SERVER_UPGRADE_TIME, get_furthest_read_time, is_outdated_server +from zerver.lib.home import get_furthest_read_time from zerver.lib.soft_deactivation import do_soft_deactivate_users from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_helpers import get_user_messages, override_settings, queries_captured @@ -896,17 +897,17 @@ class HomeTest(ZulipTestCase): hamlet = self.example_user("hamlet") iago = self.example_user("iago") now = LAST_SERVER_UPGRADE_TIME.replace(tzinfo=pytz.utc) - with patch("zerver.lib.home.timezone_now", return_value=now + timedelta(days=10)): + with patch("zerver.lib.compatibility.timezone_now", return_value=now + timedelta(days=10)): self.assertEqual(is_outdated_server(iago), False) self.assertEqual(is_outdated_server(hamlet), False) self.assertEqual(is_outdated_server(None), False) - with patch("zerver.lib.home.timezone_now", return_value=now + timedelta(days=397)): + with patch("zerver.lib.compatibility.timezone_now", return_value=now + timedelta(days=397)): self.assertEqual(is_outdated_server(iago), True) self.assertEqual(is_outdated_server(hamlet), True) self.assertEqual(is_outdated_server(None), True) - with patch("zerver.lib.home.timezone_now", return_value=now + timedelta(days=380)): + with patch("zerver.lib.compatibility.timezone_now", return_value=now + timedelta(days=380)): self.assertEqual(is_outdated_server(iago), True) self.assertEqual(is_outdated_server(hamlet), False) self.assertEqual(is_outdated_server(None), False) diff --git a/zerver/views/compatibility.py b/zerver/views/compatibility.py index 81e171f830..2e94e27a01 100644 --- a/zerver/views/compatibility.py +++ b/zerver/views/compatibility.py @@ -1,73 +1,9 @@ -import re -from typing import List, Optional, Tuple - from django.http import HttpRequest, HttpResponse from django.utils.translation import gettext as _ -from version import DESKTOP_MINIMUM_VERSION, DESKTOP_WARNING_VERSION +from zerver.lib.compatibility import find_mobile_os, version_lt from zerver.lib.response import json_error, json_success from zerver.lib.user_agent import parse_user_agent -from zerver.signals import get_device_browser - - -def pop_numerals(ver: str) -> Tuple[List[int], str]: - match = re.search(r"^( \d+ (?: \. \d+ )* ) (.*)", ver, re.X) - 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) -> Optional[bool]: - """ - 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) -> Optional[str]: - if re.search(r"\b Android \b", user_agent, re.I | re.X): - return "android" - if re.search(r"\b(?: iOS | iPhone\ OS )\b", user_agent, re.I | re.X): - return "ios" - return None - # Zulip Mobile release 16.2.96 was made 2018-08-22. It fixed a # bug in our Android code that causes spammy, obviously-broken @@ -91,38 +27,3 @@ def check_global_compatibility(request: HttpRequest) -> HttpResponse: if user_os == "android" and version_lt(user_agent["version"], android_min_app_version): return json_error(legacy_compatibility_error_message) return json_success() - - -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, Optional[str]]: - browser_name = get_device_browser(user_agent) - if browser_name == "Internet Explorer": - return (True, browser_name) - return (False, browser_name) diff --git a/zerver/views/home.py b/zerver/views/home.py index e78b5ef217..893bddbf5e 100644 --- a/zerver/views/home.py +++ b/zerver/views/home.py @@ -13,6 +13,7 @@ from zerver.context_processors import get_valid_realm_from_request from zerver.decorator import web_public_view, zulip_login_required from zerver.forms import ToSForm from zerver.lib.actions import do_change_tos_version, realm_user_count +from zerver.lib.compatibility import is_outdated_desktop_app, is_unsupported_browser from zerver.lib.home import ( build_page_params_for_home_page_load, get_billing_info, @@ -24,7 +25,6 @@ from zerver.lib.subdomains import get_subdomain from zerver.lib.users import compute_show_invites_and_add_streams from zerver.lib.utils import statsd from zerver.models import PreregistrationUser, Realm, Stream, UserProfile -from zerver.views.compatibility import is_outdated_desktop_app, is_unsupported_browser from zerver.views.portico import hello_view