From 374c52825ce3b0c214970cd57abb7883c51fdf9f Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 8 Feb 2021 16:31:57 +0100 Subject: [PATCH 01/27] Remove no longer used apiv1's BackendOrganizationClaimUserAddr&BackendOrganizationClaimDeviceAddr classes --- parsec/core/gui/validators.py | 24 ---- parsec/core/types/__init__.py | 4 - parsec/core/types/backend_address.py | 164 +-------------------------- tests/core/test_backend_address.py | 63 ++-------- tests/test_types.py | 130 +-------------------- 5 files changed, 14 insertions(+), 371 deletions(-) diff --git a/parsec/core/gui/validators.py b/parsec/core/gui/validators.py index f5ddabd9ad0..96ba4a1418d 100644 --- a/parsec/core/gui/validators.py +++ b/parsec/core/gui/validators.py @@ -9,8 +9,6 @@ BackendActionAddr, BackendOrganizationAddr, BackendOrganizationBootstrapAddr, - BackendOrganizationClaimUserAddr, - BackendOrganizationClaimDeviceAddr, ) @@ -68,28 +66,6 @@ def validate(self, string, pos): return QValidator.Intermediate, string, pos -class BackendOrganizationClaimUserAddrValidator(QValidator): - def validate(self, string, pos): - try: - if len(string) == 0: - return QValidator.Intermediate, string, pos - BackendOrganizationClaimUserAddr.from_url(string) - return QValidator.Acceptable, string, pos - except ValueError: - return QValidator.Intermediate, string, pos - - -class BackendOrganizationClaimDeviceAddrValidator(QValidator): - def validate(self, string, pos): - try: - if len(string) == 0: - return QValidator.Intermediate, string, pos - BackendOrganizationClaimDeviceAddr.from_url(string) - return QValidator.Acceptable, string, pos - except ValueError: - return QValidator.Intermediate, string, pos - - class BackendActionAddrValidator(QValidator): def validate(self, string, pos): try: diff --git a/parsec/core/types/__init__.py b/parsec/core/types/__init__.py index bb8099324ac..b8de870bde9 100644 --- a/parsec/core/types/__init__.py +++ b/parsec/core/types/__init__.py @@ -11,8 +11,6 @@ BackendOrganizationAddr, BackendActionAddr, BackendOrganizationBootstrapAddr, - BackendOrganizationClaimUserAddr, - BackendOrganizationClaimDeviceAddr, BackendOrganizationAddrField, BackendOrganizationFileLinkAddr, BackendInvitationAddr, @@ -64,8 +62,6 @@ "BackendOrganizationAddr", "BackendActionAddr", "BackendOrganizationBootstrapAddr", - "BackendOrganizationClaimUserAddr", - "BackendOrganizationClaimDeviceAddr", "BackendOrganizationAddrField", "BackendOrganizationFileLinkAddr", "BackendInvitationAddr", diff --git a/parsec/core/types/backend_address.py b/parsec/core/types/backend_address.py index d53b4b09813..8bdc6306ff9 100644 --- a/parsec/core/types/backend_address.py +++ b/parsec/core/types/backend_address.py @@ -7,7 +7,7 @@ from parsec.serde import fields from parsec.crypto import VerifyKey, export_root_verify_key, import_root_verify_key -from parsec.api.protocol import OrganizationID, UserID, DeviceID, InvitationType +from parsec.api.protocol import OrganizationID, InvitationType from parsec.api.data import EntryID from parsec.core.types.base import FsPath @@ -211,8 +211,6 @@ def from_url(cls, url: str): else: for type in ( BackendOrganizationBootstrapAddr, - BackendOrganizationClaimUserAddr, - BackendOrganizationClaimDeviceAddr, BackendOrganizationFileLinkAddr, BackendInvitationAddr, ): @@ -299,166 +297,6 @@ def token(self) -> str: return self._token if self._token is not None else "" -class BackendOrganizationClaimUserAddr(OrganizationParamsFixture, BackendActionAddr): - """ - Represent the URL to bootstrap claim a user - (e.g. ``parsec://parsec.example.com/my_org?action=claim_user&user_id=John&token=1234ABCD&rvk=P25GRG3XPSZKBEKXYQFBOLERWQNEDY3AO43MVNZCLPXPKN63JRYQssss``) - """ - - __slots__ = ("_user_id", "_token") - - def __init__(self, user_id: UserID, token: Optional[str], **kwargs): - super().__init__(**kwargs) - self._user_id = user_id - self._token = token - - @classmethod - def _from_url_parse_and_consume_params(cls, params): - kwargs = super()._from_url_parse_and_consume_params(params) - - value = params.pop("action", ()) - if len(value) != 1: - raise ValueError("Missing mandatory `action` param") - if value[0] != "claim_user": - raise ValueError("Expected `action=claim_user` value") - - value = params.pop("user_id", ()) - if len(value) != 1: - raise ValueError("Missing mandatory `user_id` param") - try: - kwargs["user_id"] = UserID(value[0]) - except ValueError as exc: - raise ValueError("Invalid `user_id` param value") from exc - - value = params.pop("token", ()) - if len(value) > 1: - raise ValueError("Multiple values for param `token`") - elif value and value[0]: - kwargs["token"] = value[0] - else: - kwargs["token"] = None - - return kwargs - - def _to_url_get_params(self): - params = [("action", "claim_user"), ("user_id", self._user_id)] - if self._token: - params.append(("token", self._token)) - return [*params, *super()._to_url_get_params()] - - @classmethod - def build( - cls, - organization_addr: BackendOrganizationAddr, - user_id: UserID, - token: Optional[str] = None, - ) -> "BackendOrganizationClaimUserAddr": - return cls( - hostname=organization_addr.hostname, - port=organization_addr.port, - use_ssl=organization_addr.use_ssl, - organization_id=organization_addr.organization_id, - root_verify_key=organization_addr.root_verify_key, - user_id=user_id, - token=token, - ) - - def to_organization_addr(self) -> BackendOrganizationAddr: - return BackendOrganizationAddr.build( - backend_addr=self, - organization_id=self.organization_id, - root_verify_key=self.root_verify_key, - ) - - @property - def user_id(self) -> UserID: - return self._user_id - - @property - def token(self) -> Optional[str]: - return self._token - - -class BackendOrganizationClaimDeviceAddr(OrganizationParamsFixture, BackendActionAddr): - """ - Represent the URL to bootstrap claim a device - (e.g. ``parsec://parsec.example.com/my_org?action=claim_device&device_id=John%40pc&token=1234ABCD&rvk=P25GRG3XPSZKBEKXYQFBOLERWQNEDY3AO43MVNZCLPXPKN63JRYQssss``) - """ - - __slots__ = ("_device_id", "_token") - - def __init__(self, device_id: DeviceID, token: Optional[str], **kwargs): - super().__init__(**kwargs) - self._device_id = device_id - self._token = token - - @classmethod - def _from_url_parse_and_consume_params(cls, params): - kwargs = super()._from_url_parse_and_consume_params(params) - - value = params.pop("action", ()) - if len(value) != 1: - raise ValueError("Missing mandatory `action` param") - if value[0] != "claim_device": - raise ValueError("Expected `action=claim_device` value") - - value = params.pop("device_id", ()) - if len(value) != 1: - raise ValueError("Missing mandatory `device_id` param") - try: - kwargs["device_id"] = DeviceID(value[0]) - except ValueError as exc: - raise ValueError("Invalid `device_id` param value") from exc - - value = params.pop("token", ()) - if len(value) > 1: - raise ValueError("Multiple values for param `token`") - elif value and value[0]: - kwargs["token"] = value[0] - else: - kwargs["token"] = None - - return kwargs - - def _to_url_get_params(self): - params = [("action", "claim_device"), ("device_id", self._device_id)] - if self._token: - params.append(("token", self._token)) - return [*params, *super()._to_url_get_params()] - - @classmethod - def build( - cls, - organization_addr: BackendOrganizationAddr, - device_id: DeviceID, - token: Optional[str] = None, - ) -> "BackendOrganizationClaimDeviceAddr": - return cls( - hostname=organization_addr.hostname, - port=organization_addr.port, - use_ssl=organization_addr.use_ssl, - organization_id=organization_addr.organization_id, - root_verify_key=organization_addr.root_verify_key, - device_id=device_id, - token=token, - ) - - def to_organization_addr(self) -> BackendOrganizationAddr: - return BackendOrganizationAddr.build( - backend_addr=self, - organization_id=self.organization_id, - root_verify_key=self.root_verify_key, - ) - - @property - def device_id(self) -> DeviceID: - return self._device_id - - @property - def token(self) -> Optional[str]: - return self._token - - class BackendOrganizationFileLinkAddr(OrganizationParamsFixture, BackendActionAddr): """ Represent the URL to share a file link diff --git a/tests/core/test_backend_address.py b/tests/core/test_backend_address.py index 5d75f643f6d..12d1488beeb 100644 --- a/tests/core/test_backend_address.py +++ b/tests/core/test_backend_address.py @@ -7,32 +7,21 @@ BackendAddr, BackendOrganizationAddr, BackendOrganizationBootstrapAddr, - BackendOrganizationClaimUserAddr, - BackendOrganizationClaimDeviceAddr, BackendOrganizationFileLinkAddr, BackendInvitationAddr, ) -ORG = "MyOrg" -RVK = "P25GRG3XPSZKBEKXYQFBOLERWQNEDY3AO43MVNZCLPXPKN63JRYQssss" -TOKEN = "a0000000000000000000000000000001" -DOMAIN = "parsec.cloud.com" -USER_ID = "John" -DEVICE_ID = "John%40Dev42" -PATH = "%2Fdir%2Ffile" -WORKSPACE_ID = "2d4ded12-7406-4608-833b-7f57f01156e2" -INVITATION_TYPE = "claim_user" DEFAULT_ARGS = { - "ORG": ORG, - "RVK": RVK, - "TOKEN": TOKEN, - "DOMAIN": DOMAIN, - "USER_ID": USER_ID, - "DEVICE_ID": DEVICE_ID, - "PATH": PATH, - "WORKSPACE_ID": WORKSPACE_ID, - "INVITATION_TYPE": INVITATION_TYPE, + "ORG": "MyOrg", + "RVK": "P25GRG3XPSZKBEKXYQFBOLERWQNEDY3AO43MVNZCLPXPKN63JRYQssss", + "TOKEN": "a0000000000000000000000000000001", + "DOMAIN": "parsec.cloud.com", + "USER_ID": "John", + "DEVICE_ID": "John%40Dev42", + "PATH": "%2Fdir%2Ffile", + "WORKSPACE_ID": "2d4ded12-7406-4608-833b-7f57f01156e2", + "INVITATION_TYPE": "claim_user", } @@ -66,26 +55,6 @@ def generate_url(self, **kwargs): BackendOrganizationBootstrapAddr, "parsec://{DOMAIN}/{ORG}?action=bootstrap_organization&token={TOKEN}", ) -BackendOrganizationClaimUserAddrTestbed = AddrTestbed( - "org_claim_user_addr", - BackendOrganizationClaimUserAddr, - "parsec://{DOMAIN}/{ORG}?action=claim_user&user_id={USER_ID}&token={TOKEN}&rvk={RVK}", -) -BackendOrganizationClaimDeviceAddrTestbed = AddrTestbed( - "org_claim_device_addr", - BackendOrganizationClaimDeviceAddr, - "parsec://{DOMAIN}/{ORG}?action=claim_device&device_id={DEVICE_ID}&token={TOKEN}&rvk={RVK}", -) -BackendOrganizationClaimUserAddrNoTokenTestbed = AddrTestbed( - "org_claim_user_addr_no_token", - BackendOrganizationClaimUserAddr, - "parsec://{DOMAIN}/{ORG}?action=claim_user&user_id={USER_ID}&rvk={RVK}", -) -BackendOrganizationClaimDeviceAddrNoTokenTestbed = AddrTestbed( - "org_claim_device_addr_no_token", - BackendOrganizationClaimDeviceAddr, - "parsec://{DOMAIN}/{ORG}?action=claim_device&device_id={DEVICE_ID}&rvk={RVK}", -) BackendOrganizationFileLinkAddrTestbed = AddrTestbed( "org_file_link_addr", BackendOrganizationFileLinkAddr, @@ -103,10 +72,6 @@ def generate_url(self, **kwargs): BackendAddrTestbed, BackendOrganizationAddrTestbed, BackendOrganizationBootstrapAddrTestbed, - BackendOrganizationClaimUserAddrTestbed, - BackendOrganizationClaimDeviceAddrTestbed, - BackendOrganizationClaimUserAddrNoTokenTestbed, - BackendOrganizationClaimDeviceAddrNoTokenTestbed, BackendOrganizationFileLinkAddrTestbed, BackendInvitationAddrTestbed, ] @@ -119,10 +84,6 @@ def addr_testbed(request): params=[ BackendOrganizationAddrTestbed, BackendOrganizationBootstrapAddrTestbed, - BackendOrganizationClaimUserAddrTestbed, - BackendOrganizationClaimDeviceAddrTestbed, - BackendOrganizationClaimUserAddrNoTokenTestbed, - BackendOrganizationClaimDeviceAddrNoTokenTestbed, BackendOrganizationFileLinkAddrTestbed, BackendInvitationAddrTestbed, ] @@ -134,8 +95,6 @@ def addr_with_org_testbed(request): @pytest.fixture( params=[ BackendOrganizationBootstrapAddrTestbed, - BackendOrganizationClaimUserAddrTestbed, - BackendOrganizationClaimDeviceAddrTestbed, # BackendInvitationAddrTestbed token format is different from apiv1's token ] ) @@ -300,5 +259,7 @@ def test_invitation_addr_to_http_url(addr_invitation_testbed, no_ssl): addr = addr_invitation_testbed.cls.from_url(url) http_url = addr.to_http_redirection_url() assert ( - http_url == f"{http_scheme}{DOMAIN}/redirect/{ORG}?action={INVITATION_TYPE}&token={TOKEN}" + http_url + == http_scheme + + "{DOMAIN}/redirect/{ORG}?action={INVITATION_TYPE}&token={TOKEN}".format(**DEFAULT_ARGS) ) diff --git a/tests/test_types.py b/tests/test_types.py index e472d393497..f271aaed617 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -4,13 +4,7 @@ from parsec.api.protocol import DeviceID, UserID, DeviceName, OrganizationID from parsec.crypto import SigningKey, PrivateKey, SecretKey, export_root_verify_key -from parsec.core.types import ( - BackendAddr, - BackendOrganizationAddr, - BackendOrganizationBootstrapAddr, - BackendOrganizationClaimUserAddr, - BackendOrganizationClaimDeviceAddr, -) +from parsec.core.types import BackendAddr, BackendOrganizationAddr, BackendOrganizationBootstrapAddr @pytest.mark.parametrize("raw", ["foo42", "FOO", "f", "f-o-o", "f_o_o", "x" * 32, "三国"]) @@ -308,128 +302,6 @@ def organization_addr(exported_verify_key): return BackendOrganizationAddr.from_url(url) -@pytest.mark.parametrize( - "user_id,token", [(UserID("alice"), "123"), (UserID("alice"), None)] # Token is not mandatory -) -def test_backend_organization_claim_user_addr_good(organization_addr, user_id, token): - addr = BackendOrganizationClaimUserAddr.build(organization_addr, user_id, token) - - assert addr.hostname == organization_addr.hostname - assert addr.port == organization_addr.port - assert addr.use_ssl == organization_addr.use_ssl - assert addr.organization_id == organization_addr.organization_id - assert addr.root_verify_key == organization_addr.root_verify_key - - assert isinstance(addr.user_id, UserID) - assert addr.user_id == user_id - assert addr.token == token - - addr2 = BackendOrganizationClaimUserAddr.from_url(addr.to_url()) - assert isinstance(addr2.user_id, UserID) - assert addr == addr2 - - -@pytest.mark.parametrize( - "url,exc_msg", - [ - ( - # bad parsing in unknown param - "parsec://foo:42/org?action=claim_user&user_id=alice&token=123&rvk=&dummy", - "bad query field: 'dummy'", - ), - ( - # missing mandatory user_id param - "parsec://foo:42/org?action=claim_user&token=123&rvk=", - "Missing mandatory `user_id` param", - ), - ( - # bad user_id param - "parsec://foo:42/org?action=claim_user&user_id=&token=123&rvk=", - "Invalid `user_id` param value", - ), - ( - # bad user_id param - "parsec://foo:42/org?action=claim_user&user_id=~Foo&token=123&rvk=", - "Invalid `user_id` param value", - ), - ( - # bad rvk param - "parsec://foo:42/org?action=claim_user&user_id=alice&token=123&rvk=dummy", - "Invalid `rvk` param value", - ), - ], -) -def test_backend_organization_claim_user_addr_bad_value(url, exc_msg, exported_verify_key): - url = url.replace("", exported_verify_key) - with pytest.raises(ValueError) as exc: - BackendOrganizationClaimUserAddr.from_url(url) - assert str(exc.value) == exc_msg - - -@pytest.mark.parametrize( - "device_id,token", - [(DeviceID("alice@dev"), "123"), (DeviceID("alice@dev"), None)], # Token is not mandatory -) -def test_backend_organization_claim_device_addr_good(organization_addr, device_id, token): - addr = BackendOrganizationClaimDeviceAddr.build(organization_addr, device_id, token) - - assert addr.hostname == organization_addr.hostname - assert addr.port == organization_addr.port - assert addr.use_ssl == organization_addr.use_ssl - assert addr.organization_id == organization_addr.organization_id - assert addr.root_verify_key == organization_addr.root_verify_key - - assert isinstance(addr.device_id, DeviceID) - assert addr.device_id == device_id - assert addr.token == token - - addr2 = BackendOrganizationClaimDeviceAddr.from_url(addr.to_url()) - assert isinstance(addr2.device_id, DeviceID) - assert addr == addr2 - - -@pytest.mark.parametrize( - "url,exc_msg", - [ - ( - # bad parsing in unknown param - "parsec://foo:42/org?action=claim_device&device_id=alice%40dev&token=123&rvk=&dummy", - "bad query field: 'dummy'", - ), - ( - # missing mandatory device_id param - "parsec://foo:42/org?action=claim_device&token=123&rvk=", - "Missing mandatory `device_id` param", - ), - ( - # bad device_id param - "parsec://foo:42/org?action=claim_device&device_id=&token=123&rvk=", - "Invalid `device_id` param value", - ), - ( - # bad device_id param - "parsec://foo:42/org?action=claim_device&device_id=~Foo%40dev&token=123&rvk=", - "Invalid `device_id` param value", - ), - ( - # bad device_id param - "parsec://foo:42/org?action=claim_device&device_id=alice&token=123&rvk=", - "Invalid `device_id` param value", - ), - ( - # bad rvk param - "parsec://foo:42/org?action=claim_device&device_id=alice%40dev&token=123&rvk=dummy", - "Invalid `rvk` param value", - ), - ], -) -def test_backend_organization_claim_device_addr_bad_value(url, exc_msg, exported_verify_key): - url = url.replace("", exported_verify_key) - with pytest.raises(ValueError) as exc: - BackendOrganizationClaimDeviceAddr.from_url(url) - assert str(exc.value) == exc_msg - - @pytest.mark.parametrize("key_type", (SigningKey, PrivateKey, SecretKey)) def test_keys_dont_leak_on_repr(key_type): key = key_type.generate() From ab5e53780387fada4f8a11b57383c0b5ec994423 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 8 Feb 2021 16:39:18 +0100 Subject: [PATCH 02/27] Remove rvk param from BackendOrganizationAddr --- parsec/core/types/backend_address.py | 40 ++++++++++--------- tests/core/test_backend_address.py | 57 +++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/parsec/core/types/backend_address.py b/parsec/core/types/backend_address.py index 8bdc6306ff9..69e8f861bb4 100644 --- a/parsec/core/types/backend_address.py +++ b/parsec/core/types/backend_address.py @@ -137,7 +137,12 @@ def use_ssl(self): return self._use_ssl -class OrganizationParamsFixture(BackendAddr): +class BackendOrganizationAddr(BackendAddr): + """ + Represent the URL to access an organization within a backend + (e.g. ``parsec://parsec.example.com/MyOrg?rvk=7NFDS4VQLP3XPCMTSEN34ZOXKGGIMTY2W2JI2SPIHB2P3M6K4YWAssss``) + """ + __slots__ = ("_root_verify_key", "_organization_id") def __init__(self, organization_id: OrganizationID, root_verify_key: VerifyKey, **kwargs): @@ -180,13 +185,6 @@ def organization_id(self) -> OrganizationID: def root_verify_key(self) -> VerifyKey: return self._root_verify_key - -class BackendOrganizationAddr(OrganizationParamsFixture, BackendAddr): - """ - Represent the URL to access an organization within a backend - (e.g. ``parsec://parsec.example.com/MyOrg?rvk=7NFDS4VQLP3XPCMTSEN34ZOXKGGIMTY2W2JI2SPIHB2P3M6K4YWAssss``) - """ - @classmethod def build( cls, backend_addr: BackendAddr, organization_id: OrganizationID, root_verify_key: VerifyKey @@ -297,19 +295,26 @@ def token(self) -> str: return self._token if self._token is not None else "" -class BackendOrganizationFileLinkAddr(OrganizationParamsFixture, BackendActionAddr): +class BackendOrganizationFileLinkAddr(BackendActionAddr): """ Represent the URL to share a file link (e.g. ``parsec://parsec.example.com/my_org?action=file_link&workspace_id=xx&path=yy``) """ - __slots__ = ("_workspace_id", "_path") + __slots__ = ("_organization_id", "_workspace_id", "_path") - def __init__(self, workspace_id: EntryID, path: FsPath, **kwargs): + def __init__( + self, organization_id: OrganizationID, workspace_id: EntryID, path: FsPath, **kwargs + ): super().__init__(**kwargs) + self._organization_id = organization_id self._workspace_id = workspace_id self._path = path + @classmethod + def _from_url_parse_path(cls, path): + return {"organization_id": OrganizationID(path[1:])} + @classmethod def _from_url_parse_and_consume_params(cls, params): kwargs = super()._from_url_parse_and_consume_params(params) @@ -338,6 +343,9 @@ def _from_url_parse_and_consume_params(cls, params): return kwargs + def _to_url_get_path(self): + return str(self.organization_id) + def _to_url_get_params(self): params = [ ("action", "file_link"), @@ -355,17 +363,13 @@ def build( port=organization_addr.port, use_ssl=organization_addr.use_ssl, organization_id=organization_addr.organization_id, - root_verify_key=organization_addr.root_verify_key, workspace_id=workspace_id, path=path, ) - def to_organization_addr(self) -> BackendOrganizationAddr: - return BackendOrganizationAddr.build( - backend_addr=self, - organization_id=self.organization_id, - root_verify_key=self.root_verify_key, - ) + @property + def organization_id(self) -> OrganizationID: + return self._organization_id @property def workspace_id(self) -> EntryID: diff --git a/tests/core/test_backend_address.py b/tests/core/test_backend_address.py index 12d1488beeb..f6282bfb536 100644 --- a/tests/core/test_backend_address.py +++ b/tests/core/test_backend_address.py @@ -1,9 +1,12 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) AGPLv3 2016-2021 Scille SAS import pytest +from uuid import UUID -from parsec.api.protocol import InvitationType +from parsec.crypto import SigningKey +from parsec.api.protocol import InvitationType, OrganizationID from parsec.core.types import ( + EntryID, BackendAddr, BackendOrganizationAddr, BackendOrganizationBootstrapAddr, @@ -58,7 +61,7 @@ def generate_url(self, **kwargs): BackendOrganizationFileLinkAddrTestbed = AddrTestbed( "org_file_link_addr", BackendOrganizationFileLinkAddr, - "parsec://{DOMAIN}/{ORG}?action=file_link&workspace_id={WORKSPACE_ID}&path={PATH}&rvk={RVK}", + "parsec://{DOMAIN}/{ORG}?action=file_link&workspace_id={WORKSPACE_ID}&path={PATH}", ) BackendInvitationAddrTestbed = AddrTestbed( "org_invitation_addr", @@ -263,3 +266,53 @@ def test_invitation_addr_to_http_url(addr_invitation_testbed, no_ssl): == http_scheme + "{DOMAIN}/redirect/{ORG}?action={INVITATION_TYPE}&token={TOKEN}".format(**DEFAULT_ARGS) ) + + +def test_build_addrs(): + backend_addr = BackendAddr.from_url(BackendAddrTestbed.url) + assert backend_addr.hostname == "parsec.cloud.com" + assert backend_addr.port == 443 + assert backend_addr.use_ssl is True + + organization_id = OrganizationID("MyOrg") + root_verify_key = SigningKey.generate().verify_key + + organization_addr = BackendOrganizationAddr.build( + backend_addr=backend_addr, organization_id=organization_id, root_verify_key=root_verify_key + ) + assert organization_addr.organization_id == organization_id + assert organization_addr.root_verify_key == root_verify_key + + organization_bootstrap_addr = BackendOrganizationBootstrapAddr.build( + backend_addr=backend_addr, + organization_id=organization_id, + token="a0000000000000000000000000000001", + ) + assert organization_bootstrap_addr.token == "a0000000000000000000000000000001" + assert organization_bootstrap_addr.organization_id == organization_id + + organization_bootstrap_addr2 = BackendOrganizationBootstrapAddr.build( + backend_addr=backend_addr, organization_id=organization_id, token=None + ) + assert organization_bootstrap_addr2.organization_id == organization_id + assert organization_bootstrap_addr2.token == "" + + organization_file_link_addr = BackendOrganizationFileLinkAddr.build( + organization_addr=organization_addr, + workspace_id=EntryID("2d4ded12-7406-4608-833b-7f57f01156e2"), + path="/foo/bar", + ) + assert organization_file_link_addr.workspace_id == EntryID( + "2d4ded12-7406-4608-833b-7f57f01156e2" + ) + assert organization_file_link_addr.path == "/foo/bar" + + invitation_addr = BackendInvitationAddr.build( + backend_addr=backend_addr, + organization_id=organization_id, + invitation_type=InvitationType.USER, + token=UUID("a0000000000000000000000000000001"), + ) + assert invitation_addr.organization_id == organization_id + assert invitation_addr.token == UUID("a0000000000000000000000000000001") + assert invitation_addr.invitation_type == InvitationType.USER From d535ad4d4024c8be7d0af1a974421c45f345fc0a Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 8 Feb 2021 17:07:19 +0100 Subject: [PATCH 03/27] Add newsfragment for #1637 --- newsfragments/1637.removal.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 newsfragments/1637.removal.rst diff --git a/newsfragments/1637.removal.rst b/newsfragments/1637.removal.rst new file mode 100644 index 00000000000..38f6705bbd2 --- /dev/null +++ b/newsfragments/1637.removal.rst @@ -0,0 +1,3 @@ +Remove `rkv` parameter from the file link url. +This change comes with backward compatibility (old url works with new version of Parsec) +but not forward compatibility (older versions of Parsec don't work with new url). From b2c0104501f71ffef93af477bd4d9e99ee116a91 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 22 Mar 2021 19:04:14 +0100 Subject: [PATCH 04/27] Extract from export/import_root_verify_key binary_urlsafe_encode/decode --- parsec/crypto.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/parsec/crypto.py b/parsec/crypto.py index b6c6e438c26..1811c1fbc62 100644 --- a/parsec/crypto.py +++ b/parsec/crypto.py @@ -157,15 +157,37 @@ def encrypt_for_self(self, data: bytes) -> bytes: # Helpers +# Binary encoder/decoder for url use. +# Notes: +# - We replace padding char `=` by a simple `s` (which is not part of +# the base32 table so no risk of collision) to avoid copy/paste errors +# and silly escaping issues when carrying the key around. +# - We could be using base64url (see RFC 4648) which would be more efficient, +# but backward compatibility prevent us from doing it :'( + + +def binary_urlsafe_encode(data: bytes) -> str: + """ + Raises: + ValueError + """ + return b32encode(data).decode("utf8").replace("=", "s") + + +def binary_urlsafe_decode(data: str) -> bytes: + """ + Raises: + ValueError + """ + return b32decode(data.replace("s", "=").encode("utf8")) + + def export_root_verify_key(key: VerifyKey) -> str: """ Raises: ValueError """ - # Note we replace padding char `=` by a simple `s` (which is not part of - # the base32 table so no risk of collision) to avoid copy/paste errors - # and silly escaping issues when carrying the key around. - return b32encode(key.encode()).decode("utf8").replace("=", "s") + return binary_urlsafe_encode(key.encode()) def import_root_verify_key(raw: str) -> VerifyKey: @@ -173,11 +195,9 @@ def import_root_verify_key(raw: str) -> VerifyKey: Raises: ValueError """ - if isinstance(raw, VerifyKey): - # Useful during tests - return raw + raw_binary = binary_urlsafe_decode(raw) try: - return VerifyKey(b32decode(raw.replace("s", "=").encode("utf8"))) + return VerifyKey(raw_binary) except CryptoError as exc: raise ValueError("Invalid verify key") from exc From 051de6737de9ebe1b34c7f0537e467c74ea3afd3 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 22 Mar 2021 19:05:07 +0100 Subject: [PATCH 05/27] Add typing to FsPath.__init__ --- parsec/core/types/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsec/core/types/base.py b/parsec/core/types/base.py index 4756e16d4fa..0875bc17a0a 100644 --- a/parsec/core/types/base.py +++ b/parsec/core/types/base.py @@ -1,7 +1,7 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) AGPLv3 2016-2021 Scille SAS import attr -from typing import Tuple, Union +from typing import Tuple, Iterable, Union from parsec.serde import BaseSchema, MsgpackSerializer from parsec.api.data import BaseData, EntryName @@ -27,7 +27,7 @@ class FsPath: be resolved. """ - def __init__(self, raw): + def __init__(self, raw: Union["FsPath", str, Iterable[EntryName]]): if isinstance(raw, FsPath): parts = raw.parts elif isinstance(raw, (list, tuple)): From ffaa3b9df948b318ab07c97a8e87d1fe5534c1e7 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 22 Mar 2021 19:09:51 +0100 Subject: [PATCH 06/27] Replace path field in BackendOrganizationFileLinkAddr by encrypted_path --- parsec/core/types/backend_address.py | 36 ++++++++++++++++++---------- tests/core/test_backend_address.py | 20 +++++++++++----- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/parsec/core/types/backend_address.py b/parsec/core/types/backend_address.py index 69e8f861bb4..417ca300bc2 100644 --- a/parsec/core/types/backend_address.py +++ b/parsec/core/types/backend_address.py @@ -6,10 +6,15 @@ from marshmallow import ValidationError from parsec.serde import fields -from parsec.crypto import VerifyKey, export_root_verify_key, import_root_verify_key +from parsec.crypto import ( + VerifyKey, + export_root_verify_key, + import_root_verify_key, + binary_urlsafe_decode, + binary_urlsafe_encode, +) from parsec.api.protocol import OrganizationID, InvitationType from parsec.api.data import EntryID -from parsec.core.types.base import FsPath PARSEC_SCHEME = "parsec" @@ -301,15 +306,19 @@ class BackendOrganizationFileLinkAddr(BackendActionAddr): (e.g. ``parsec://parsec.example.com/my_org?action=file_link&workspace_id=xx&path=yy``) """ - __slots__ = ("_organization_id", "_workspace_id", "_path") + __slots__ = ("_organization_id", "_workspace_id", "_encrypted_path") def __init__( - self, organization_id: OrganizationID, workspace_id: EntryID, path: FsPath, **kwargs + self, + organization_id: OrganizationID, + workspace_id: EntryID, + encrypted_path: bytes, + **kwargs, ): super().__init__(**kwargs) self._organization_id = organization_id self._workspace_id = workspace_id - self._path = path + self._encrypted_path = encrypted_path @classmethod def _from_url_parse_path(cls, path): @@ -337,7 +346,7 @@ def _from_url_parse_and_consume_params(cls, params): if len(value) != 1: raise ValueError("Missing mandatory `path` param") try: - kwargs["path"] = FsPath(value[0]) + kwargs["encrypted_path"] = binary_urlsafe_decode(value[0]) except ValueError as exc: raise ValueError("Invalid `path` param value") from exc @@ -349,14 +358,17 @@ def _to_url_get_path(self): def _to_url_get_params(self): params = [ ("action", "file_link"), - ("workspace_id", str(self._workspace_id)), - ("path", str(self._path)), + ("workspace_id", self._workspace_id.hex), + ("path", binary_urlsafe_encode(self._encrypted_path)), ] return [*params, *super()._to_url_get_params()] @classmethod def build( - cls, organization_addr: BackendOrganizationAddr, workspace_id: EntryID, path: FsPath + cls, + organization_addr: BackendOrganizationAddr, + workspace_id: EntryID, + encrypted_path: bytes, ) -> "BackendOrganizationFileLinkAddr": return cls( hostname=organization_addr.hostname, @@ -364,7 +376,7 @@ def build( use_ssl=organization_addr.use_ssl, organization_id=organization_addr.organization_id, workspace_id=workspace_id, - path=path, + encrypted_path=encrypted_path, ) @property @@ -376,8 +388,8 @@ def workspace_id(self) -> EntryID: return self._workspace_id @property - def path(self) -> FsPath: - return self._path + def encrypted_path(self) -> bytes: + return self._encrypted_path class BackendOrganizationAddrField(fields.Field): diff --git a/tests/core/test_backend_address.py b/tests/core/test_backend_address.py index f6282bfb536..f2138cd57b3 100644 --- a/tests/core/test_backend_address.py +++ b/tests/core/test_backend_address.py @@ -22,7 +22,7 @@ "DOMAIN": "parsec.cloud.com", "USER_ID": "John", "DEVICE_ID": "John%40Dev42", - "PATH": "%2Fdir%2Ffile", + "ENCRYPTED_PATH": "HRSW4Y3SPFYHIZLEL5YGC6LMN5QWIPQs", "WORKSPACE_ID": "2d4ded12-7406-4608-833b-7f57f01156e2", "INVITATION_TYPE": "claim_user", } @@ -61,7 +61,7 @@ def generate_url(self, **kwargs): BackendOrganizationFileLinkAddrTestbed = AddrTestbed( "org_file_link_addr", BackendOrganizationFileLinkAddr, - "parsec://{DOMAIN}/{ORG}?action=file_link&workspace_id={WORKSPACE_ID}&path={PATH}", + "parsec://{DOMAIN}/{ORG}?action=file_link&workspace_id={WORKSPACE_ID}&path={ENCRYPTED_PATH}", ) BackendInvitationAddrTestbed = AddrTestbed( "org_invitation_addr", @@ -218,13 +218,21 @@ def test_file_link_addr_invalid_workspace(addr_file_link_testbed, invalid_worksp addr_file_link_testbed.cls.from_url(url) -@pytest.mark.parametrize("invalid_path", [None, "dir/path"]) +@pytest.mark.parametrize("invalid_path", [None, "__notbase32__"]) def test_file_link_addr_invalid_path(addr_file_link_testbed, invalid_path): - url = addr_file_link_testbed.generate_url(PATH=invalid_path) + url = addr_file_link_testbed.generate_url(ENCRYPTED_PATH=invalid_path) with pytest.raises(ValueError): addr_file_link_testbed.cls.from_url(url) +def test_file_link_addr_get_encrypted_path(addr_file_link_testbed): + serialized_encrypted_path = "HRSW4Y3SPFYHIZLEL5YGC6LMN5QWIPQs" + encrypted_path = b"" + url = addr_file_link_testbed.generate_url(ENCRYPTED_PATH=serialized_encrypted_path) + addr = addr_file_link_testbed.cls.from_url(url) + assert addr.encrypted_path == encrypted_path + + @pytest.mark.parametrize("invalid_type", [None, "claim", "claim_foo"]) def test_invitation_addr_invalid_type(addr_invitation_testbed, invalid_type): url = addr_invitation_testbed.generate_url(INVITATION_TYPE=invalid_type) @@ -300,12 +308,12 @@ def test_build_addrs(): organization_file_link_addr = BackendOrganizationFileLinkAddr.build( organization_addr=organization_addr, workspace_id=EntryID("2d4ded12-7406-4608-833b-7f57f01156e2"), - path="/foo/bar", + encrypted_path=b"", ) assert organization_file_link_addr.workspace_id == EntryID( "2d4ded12-7406-4608-833b-7f57f01156e2" ) - assert organization_file_link_addr.path == "/foo/bar" + assert organization_file_link_addr.encrypted_path == b"" invitation_addr = BackendInvitationAddr.build( backend_addr=backend_addr, From fe58a84f7d1b248bc661e5c680f0217609e9511a Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 22 Mar 2021 19:12:52 +0100 Subject: [PATCH 07/27] Add WorkspaceFS.generate_file_link/decrypt_file_link_path given file path is now encrypted within BackendOrganizationFileLinkAddr --- parsec/core/fs/workspacefs/workspacefs.py | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/parsec/core/fs/workspacefs/workspacefs.py b/parsec/core/fs/workspacefs/workspacefs.py index 5a416b9feed..cb7be5dea7b 100644 --- a/parsec/core/fs/workspacefs/workspacefs.py +++ b/parsec/core/fs/workspacefs/workspacefs.py @@ -6,6 +6,7 @@ from typing import List, Dict, Tuple, AsyncIterator, cast, Pattern, Callable, Optional, Awaitable from pendulum import DateTime, now as pendulum_now +from parsec.crypto import CryptoError from parsec.event_bus import EventBus from parsec.api.data import BaseManifest as BaseRemoteManifest from parsec.api.data import FileManifest as RemoteFileManifest @@ -24,6 +25,7 @@ RemoteWorkspaceManifest, RemoteFolderishManifests, DEFAULT_BLOCK_SIZE, + BackendOrganizationFileLinkAddr, ) from parsec.core.remote_devices_manager import RemoteDevicesManager from parsec.core.backend_connection import ( @@ -746,3 +748,27 @@ async def rec(entry_id: EntryID) -> Dict[str, object]: return result return await rec(self.workspace_id) + + def generate_file_link(self, path: AnyPath) -> BackendOrganizationFileLinkAddr: + """ + Raises: Nothing + """ + workspace_entry = self.get_workspace_entry() + encrypted_path = workspace_entry.key.encrypt(str(FsPath(path)).encode("utf-8")) + return BackendOrganizationFileLinkAddr.build( + organization_addr=self.device.organization_addr, + workspace_id=workspace_entry.id, + encrypted_path=encrypted_path, + ) + + def decrypt_file_link_path(self, addr: BackendOrganizationFileLinkAddr) -> FsPath: + """ + Raises: ValueError + """ + workspace_entry = self.get_workspace_entry() + try: + raw_path = workspace_entry.key.decrypt(addr.encrypted_path) + except CryptoError: + raise ValueError("Cannot decrypt path") + # FsPath raises ValueError, decode() raises UnicodeDecodeError which is a subclass of ValueError + return FsPath(raw_path.decode("utf-8")) From 5ced93e9d31114c1a8eec5f53edd39bccbaaf77b Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 22 Mar 2021 19:15:31 +0100 Subject: [PATCH 08/27] Correct GUI to use the new BackendOrganizationFileLinkAddr with encrypted path (and also improve typing) --- parsec/core/gui/app.py | 10 +- parsec/core/gui/central_widget.py | 133 +++++++++++----- parsec/core/gui/files_widget.py | 11 +- parsec/core/gui/instance_widget.py | 26 +-- parsec/core/gui/main_window.py | 227 +++++++++++++++------------ parsec/core/gui/mount_widget.py | 2 +- parsec/core/gui/workspaces_widget.py | 13 +- tests/core/gui/test_main_window.py | 34 ++-- 8 files changed, 259 insertions(+), 197 deletions(-) diff --git a/parsec/core/gui/app.py b/parsec/core/gui/app.py index 76021438dbe..c7e2e294abe 100644 --- a/parsec/core/gui/app.py +++ b/parsec/core/gui/app.py @@ -1,21 +1,19 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) AGPLv3 2016-2021 Scille SAS -from parsec.core.core_events import CoreEvent - import sys import signal from queue import Queue +from typing import Optional from contextlib import contextmanager from enum import Enum - import trio from structlog import get_logger - from PyQt5.QtCore import QTimer, Qt from PyQt5.QtWidgets import QApplication -from parsec.core.config import CoreConfig from parsec.event_bus import EventBus +from parsec.core.core_events import CoreEvent +from parsec.core.config import CoreConfig from parsec.core.ipcinterface import ( run_ipc_server, send_to_ipc_server, @@ -133,7 +131,7 @@ def log_except(etype, exception, traceback): sys.excepthook = previous_hook -def run_gui(config: CoreConfig, start_arg: str = None, diagnose: bool = False): +def run_gui(config: CoreConfig, start_arg: Optional[str] = None, diagnose: bool = False): logger.info("Starting UI") # Needed for High DPI usage of QIcons, otherwise only QImages are well scaled diff --git a/parsec/core/gui/central_widget.py b/parsec/core/gui/central_widget.py index 224956ed5fe..cab9a4722d6 100644 --- a/parsec/core/gui/central_widget.py +++ b/parsec/core/gui/central_widget.py @@ -1,35 +1,41 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) AGPLv3 2016-2021 Scille SAS -from parsec.core.core_events import CoreEvent +from typing import Optional from PyQt5.QtCore import pyqtSignal, QTimer from PyQt5.QtGui import QPixmap, QColor, QIcon from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QWidget, QMenu -from parsec.core.gui.mount_widget import MountWidget -from parsec.core.gui.users_widget import UsersWidget -from parsec.core.gui.devices_widget import DevicesWidget -from parsec.core.gui.menu_widget import MenuWidget -from parsec.core.gui.password_change_widget import PasswordChangeWidget -from parsec.core.gui.lang import translate as _ -from parsec.core.gui.custom_widgets import Pixmap -from parsec.core.gui.custom_dialogs import show_error -from parsec.core.gui.ui.central_widget import Ui_CentralWidget -from parsec.core.fs import FSWorkspaceNotFoundError -from parsec.core.gui.trio_thread import JobResultError, ThreadSafeQtSignal, QtToTrioJob - -from parsec.core.backend_connection import BackendConnectionError, BackendNotAvailable - +from parsec.event_bus import EventBus from parsec.api.protocol import ( HandshakeAPIVersionError, HandshakeRevokedDevice, HandshakeOrganizationExpired, ) -from parsec.core.backend_connection import BackendConnStatus +from parsec.core.core_events import CoreEvent +from parsec.core.logged_core import LoggedCore +from parsec.core.types import UserInfo, BackendOrganizationFileLinkAddr +from parsec.core.backend_connection import ( + BackendConnectionError, + BackendNotAvailable, + BackendConnStatus, +) +from parsec.core.fs import FSWorkspaceNotFoundError from parsec.core.fs import ( FSWorkspaceNoReadAccess, FSWorkspaceNoWriteAccess, FSWorkspaceInMaintenance, ) +from parsec.core.gui.trio_thread import QtToTrioJobScheduler +from parsec.core.gui.mount_widget import MountWidget +from parsec.core.gui.users_widget import UsersWidget +from parsec.core.gui.devices_widget import DevicesWidget +from parsec.core.gui.menu_widget import MenuWidget +from parsec.core.gui.password_change_widget import PasswordChangeWidget +from parsec.core.gui.lang import translate as _ +from parsec.core.gui.custom_widgets import Pixmap +from parsec.core.gui.custom_dialogs import show_error +from parsec.core.gui.ui.central_widget import Ui_CentralWidget +from parsec.core.gui.trio_thread import JobResultError, ThreadSafeQtSignal, QtToTrioJob async def _do_get_organization_stats(core): @@ -41,6 +47,22 @@ async def _do_get_organization_stats(core): raise JobResultError("error") from exc +class GoToFileLinkError(Exception): + pass + + +class GoToFileLinkBadOrgazationIDError(Exception): + pass + + +class GoToFileLinkBadWorkspaceIDError(Exception): + pass + + +class GoToFileLinkPathDecryptionError(Exception): + pass + + class CentralWidget(QWidget, Ui_CentralWidget): NOTIFICATION_EVENTS = [ CoreEvent.BACKEND_CONNECTION_CHANGED, @@ -55,12 +77,20 @@ class CentralWidget(QWidget, Ui_CentralWidget): organization_stats_error = pyqtSignal(QtToTrioJob) connection_state_changed = pyqtSignal(object, object) - vlobs_updated_qt = pyqtSignal(object, object) + vlobs_updated_qt = pyqtSignal() logout_requested = pyqtSignal() new_notification = pyqtSignal(str, str) RESET_TIMER_STATS = 5000 # ms - def __init__(self, core, jobs_ctx, event_bus, systray_notification, action_addr=None, **kwargs): + def __init__( + self, + core: LoggedCore, + jobs_ctx: QtToTrioJobScheduler, + event_bus: EventBus, + systray_notification: pyqtSignal, + file_link_addr: Optional[BackendOrganizationFileLinkAddr] = None, + **kwargs, + ): super().__init__(**kwargs) self.setupUi(self) @@ -135,14 +165,14 @@ def __init__(self, core, jobs_ctx, event_bus, systray_notification, action_addr= self._on_connection_state_changed( self.core.backend_status, self.core.backend_status_exc, allow_systray=False ) - if action_addr is not None: + if file_link_addr is not None: try: - self.go_to_file_link(action_addr.workspace_id, action_addr.path) + self.go_to_file_link(file_link_addr) except FSWorkspaceNotFoundError: show_error( self, _("TEXT_FILE_LINK_WORKSPACE_NOT_FOUND_organization").format( - organization=action_addr.organization_id + organization=file_link_addr.organization_id ), ) @@ -150,20 +180,20 @@ def __init__(self, core, jobs_ctx, event_bus, systray_notification, action_addr= else: self.show_mount_widget() - def _show_user_menu(self): + def _show_user_menu(self) -> None: self.button_user.showMenu() - def set_user_info(self): + def set_user_info(self) -> None: org = self.core.device.organization_id username = self.core.device.short_user_display user_text = f"{org}\n{username}" self.button_user.setText(user_text) self.button_user.setToolTip(self.core.device.organization_addr.to_url()) - def change_password(self): + def change_password(self) -> None: PasswordChangeWidget.show_modal(core=self.core, parent=self, on_finished=None) - def _on_folder_changed(self, workspace_name, path): + def _on_folder_changed(self, workspace_name: Optional[str], path: Optional[str]) -> None: if workspace_name and path: self.widget_title2.show() self.label_title2.setText(workspace_name) @@ -174,7 +204,7 @@ def _on_folder_changed(self, workspace_name, path): self.icon_title3.hide() self.label_title3.setText("") - def handle_event(self, event, **kwargs): + def handle_event(self, event: CoreEvent, **kwargs) -> None: if event == CoreEvent.BACKEND_CONNECTION_CHANGED: self.connection_state_changed.emit(kwargs["status"], kwargs["status_exc"]) elif event == CoreEvent.MOUNTPOINT_STOPPED: @@ -223,7 +253,7 @@ def handle_event(self, event, **kwargs): "WARNING", _("NOTIF_WARN_SYNC_CONFLICT_{}").format(kwargs["path"]) ) - def _get_organization_stats(self): + def _get_organization_stats(self) -> None: self.jobs_ctx.submit_job( ThreadSafeQtSignal(self, "organization_stats_success", QtToTrioJob), ThreadSafeQtSignal(self, "organization_stats_error", QtToTrioJob), @@ -231,15 +261,17 @@ def _get_organization_stats(self): core=self.core, ) - def _on_vlobs_updated_trio(self, event, workspace_id=None, id=None, *args, **kwargs): - self.vlobs_updated_qt.emit(event, id) + def _on_vlobs_updated_trio(self, *args, **kwargs) -> None: + self.vlobs_updated_qt.emit() - def _on_vlobs_updated_qt(self, event, uuid): + def _on_vlobs_updated_qt(self) -> None: if not self.organization_stats_timer.isActive(): self.organization_stats_timer.start() self._get_organization_stats() - def _on_connection_state_changed(self, status, status_exc, allow_systray=True): + def _on_connection_state_changed( + self, status: BackendConnStatus, status_exc: Optional[Exception], allow_systray: bool = True + ) -> None: text = None icon = None tooltip = None @@ -299,7 +331,7 @@ def _on_connection_state_changed(self, status, status_exc, allow_systray=True): 5000, ) - def _on_organization_stats_success(self, job): + def _on_organization_stats_success(self, job: QtToTrioJob) -> None: assert job.is_finished() assert job.status == "ok" @@ -308,44 +340,59 @@ def _on_organization_stats_success(self, job): organization_id=self.core.device.organization_id, organization_stats=organization_stats ) - def _on_organization_stats_error(self, job): + def _on_organization_stats_error(self, job: QtToTrioJob) -> None: assert job.is_finished() assert job.status != "ok" self.menu.label_organization_name.hide() self.menu.label_organization_size.clear() - def on_new_notification(self, notif_type, msg): + def on_new_notification(self, notif_type: str, msg: str) -> None: if notif_type in ["REVOKED", "EXPIRED"]: show_error(self, msg) - def go_to_file_link(self, workspace_id, path, mount=False): + def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr, mount: bool = True) -> None: + """ + Raises: + GoToFileLinkBadOrgazationIDError + GoToFileLinkBadWorkspaceIDError + GoToFileLinkPathDecryptionError + """ + if addr.organization_id != self.core.device.organization_id: + raise GoToFileLinkBadOrgazationIDError + try: + workspace = self.core.user_fs.get_workspace(addr.workspace_id) + except FSWorkspaceNotFoundError as exc: + raise GoToFileLinkBadWorkspaceIDError from exc + try: + path = workspace.decrypt_file_link_path(addr) + except ValueError as exc: + raise GoToFileLinkPathDecryptionError from exc + self.show_mount_widget() - self.mount_widget.show_files_widget( - self.core.user_fs.get_workspace(workspace_id), path, selected=True, mount_it=True - ) + self.mount_widget.show_files_widget(workspace, path, selected=True, mount_it=mount) - def show_mount_widget(self, user_info=None): + def show_mount_widget(self, user_info: Optional[UserInfo] = None) -> None: self.clear_widgets() self.menu.activate_files() self.label_title.setText(_("ACTION_MENU_DOCUMENTS")) if user_info is not None: self.mount_widget.workspaces_widget.set_user_info(user_info) self.mount_widget.show() - self.mount_widget.show_workspaces_widget(user_info=user_info) + self.mount_widget.show_workspaces_widget() - def show_users_widget(self): + def show_users_widget(self) -> None: self.clear_widgets() self.menu.activate_users() self.label_title.setText(_("ACTION_MENU_USERS")) self.users_widget.show() - def show_devices_widget(self): + def show_devices_widget(self) -> None: self.clear_widgets() self.menu.activate_devices() self.label_title.setText(_("ACTION_MENU_DEVICES")) self.devices_widget.show() - def clear_widgets(self): + def clear_widgets(self) -> None: self.widget_title2.hide() self.icon_title3.hide() self.label_title3.setText("") diff --git a/parsec/core/gui/files_widget.py b/parsec/core/gui/files_widget.py index 2bdaae0a39c..ed3cac246aa 100644 --- a/parsec/core/gui/files_widget.py +++ b/parsec/core/gui/files_widget.py @@ -10,7 +10,7 @@ from PyQt5.QtCore import Qt, pyqtSignal, QTimer from PyQt5.QtWidgets import QFileDialog, QWidget -from parsec.core.types import FsPath, WorkspaceEntry, WorkspaceRole, BackendOrganizationFileLinkAddr +from parsec.core.types import FsPath, WorkspaceEntry, WorkspaceRole from parsec.core.fs import WorkspaceFS, WorkspaceFSTimestamped from parsec.core.fs.exceptions import ( FSRemoteManifestNotFound, @@ -395,12 +395,9 @@ def on_get_file_path_clicked(self): files = self.table_files.selected_files() if len(files) != 1: return - url = BackendOrganizationFileLinkAddr.build( - self.core.device.organization_addr, - self.workspace_fs.workspace_id, - self.current_directory / files[0].name, - ) - desktop.copy_to_clipboard(str(url)) + path = self.current_directory / files[0].name + addr = self.workspace_fs.generate_file_link(path) + desktop.copy_to_clipboard(addr.to_url()) show_info(self, _("TEXT_FILE_LINK_COPIED_TO_CLIPBOARD")) def on_copy_clicked(self): diff --git a/parsec/core/gui/instance_widget.py b/parsec/core/gui/instance_widget.py index b8204524b35..74946d64512 100644 --- a/parsec/core/gui/instance_widget.py +++ b/parsec/core/gui/instance_widget.py @@ -1,15 +1,16 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) AGPLv3 2016-2021 Scille SAS +from parsec.core.config import CoreConfig +from typing import Optional from parsec.core.core_events import CoreEvent import trio - from structlog import get_logger - from PyQt5.QtCore import pyqtSignal, pyqtSlot from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication -from parsec.core import logged_core_factory +from parsec.event_bus import EventBus from parsec.api.protocol import HandshakeRevokedDevice +from parsec.core import logged_core_factory from parsec.core.local_device import LocalDeviceError, load_device_with_password from parsec.core.mountpoint import ( MountpointConfigurationError, @@ -53,7 +54,14 @@ class InstanceWidget(QWidget): join_organization_clicked = pyqtSignal() create_organization_clicked = pyqtSignal() - def __init__(self, jobs_ctx, event_bus, config, systray_notification, **kwargs): + def __init__( + self, + jobs_ctx: QtToTrioJobScheduler, + event_bus: EventBus, + config: CoreConfig, + systray_notification: pyqtSignal, + **kwargs + ): super().__init__(**kwargs) self.jobs_ctx = jobs_ctx self.event_bus = event_bus @@ -222,11 +230,11 @@ def show_central_widget(self): if core is None or core_jobs_ctx is None: return central_widget = CentralWidget( - core, - core_jobs_ctx, - core.event_bus, - action_addr=self.workspace_path, + core=core, + jobs_ctx=core_jobs_ctx, + event_bus=core.event_bus, systray_notification=self.systray_notification, + file_link_addr=self.workspace_path, parent=self, ) self.layout().addWidget(central_widget) @@ -246,7 +254,7 @@ def show_login_widget(self): login_widget.login_canceled.connect(self.reset_workspace_path) login_widget.show() - def get_central_widget(self): + def get_central_widget(self) -> Optional[CentralWidget]: item = self.layout().itemAt(0) if item: if isinstance(item.widget(), CentralWidget): diff --git a/parsec/core/gui/main_window.py b/parsec/core/gui/main_window.py index e7e4dfcb25d..816927e9d56 100644 --- a/parsec/core/gui/main_window.py +++ b/parsec/core/gui/main_window.py @@ -1,8 +1,9 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) AGPLv3 2016-2021 Scille SAS +from parsec.core.types.local_device import LocalDevice from parsec.core.core_events import CoreEvent import sys -from typing import Optional +from typing import Callable, Optional from structlog import get_logger from distutils.version import LooseVersion @@ -11,9 +12,9 @@ from PyQt5.QtWidgets import QMainWindow, QApplication, QMenu, QShortcut from parsec import __version__ as PARSEC_VERSION - +from parsec.event_bus import EventBus from parsec.core.local_device import list_available_devices, get_key_file -from parsec.core.config import save_config +from parsec.core.config import CoreConfig, save_config from parsec.core.types import ( BackendActionAddr, BackendInvitationAddr, @@ -21,6 +22,7 @@ BackendOrganizationFileLinkAddr, ) from parsec.api.protocol import InvitationType +from parsec.core.gui.trio_thread import QtToTrioJobScheduler from parsec.core.gui.lang import translate as _ from parsec.core.gui.instance_widget import InstanceWidget from parsec.core.gui.parsec_application import ParsecApp @@ -44,6 +46,11 @@ from parsec.core.gui.custom_widgets import Button, ensure_string_size from parsec.core.gui.create_org_widget import CreateOrgWidget from parsec.core.gui.ui.main_window import Ui_MainWindow +from parsec.core.gui.central_widget import ( + GoToFileLinkBadOrgazationIDError, + GoToFileLinkBadWorkspaceIDError, + GoToFileLinkPathDecryptionError, +) logger = get_logger() @@ -58,7 +65,14 @@ class MainWindow(QMainWindow, Ui_MainWindow): TAB_NOT_SELECTED_COLOR = QColor(123, 132, 163) TAB_SELECTED_COLOR = QColor(12, 65, 159) - def __init__(self, jobs_ctx, event_bus, config, minimize_on_close: bool = False, **kwargs): + def __init__( + self, + jobs_ctx: QtToTrioJobScheduler, + event_bus: EventBus, + config: CoreConfig, + minimize_on_close: bool = False, + **kwargs, + ): super().__init__(**kwargs) self.setupUi(self) @@ -108,7 +122,7 @@ def __init__(self, jobs_ctx, event_bus, config, minimize_on_close: bool = False, self._define_shortcuts() self.ensurePolished() - def _define_shortcuts(self): + def _define_shortcuts(self) -> None: self.shortcut_close = QShortcut(QKeySequence(QKeySequence.Close), self) self.shortcut_close.activated.connect(self._shortcut_proxy(self.close_current_tab)) self.shortcut_new_tab = QShortcut(QKeySequence(QKeySequence.AddTab), self) @@ -132,16 +146,16 @@ def _define_shortcuts(self): shortcut = QShortcut(QKeySequence(QKeySequence.PreviousChild), self) shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(-1))) - def _shortcut_proxy(self, funct): - def _inner_proxy(): + def _shortcut_proxy(self, funct: Callable[[], None]) -> Callable[[], None]: + def _inner_proxy() -> None: if ParsecApp.has_active_modal(): return funct() return _inner_proxy - def _cycle_tabs(self, offset): - def _inner_cycle_tabs(): + def _cycle_tabs(self, offset: int) -> Callable[[], None]: + def _inner_cycle_tabs() -> None: idx = self.tab_center.currentIndex() idx += offset if idx >= self.tab_center.count(): @@ -152,20 +166,20 @@ def _inner_cycle_tabs(): return _inner_cycle_tabs - def _toggle_add_tab_button(self): + def _toggle_add_tab_button(self) -> None: if self._get_login_tab_index() == -1: self.add_tab_button.setDisabled(False) else: self.add_tab_button.setDisabled(True) - def resizeEvent(self, event): + def resizeEvent(self, event) -> None: super().resizeEvent(event) for win in self.children(): if win.objectName() == "GreyedDialog": win.resize(event.size()) win.move(0, 0) - def _show_menu(self): + def _show_menu(self) -> None: menu = QMenu(self) menu.setObjectName("MainMenu") action = None @@ -224,53 +238,56 @@ def _show_menu(self): menu.exec_(pos) menu.setParent(None) - def _show_about(self): + def _show_about(self) -> None: w = AboutWidget() d = GreyedDialog(w, title="", parent=self, width=1000) d.exec_() - def _show_license(self): + def _show_license(self) -> None: w = LicenseWidget() d = GreyedDialog(w, title=_("TEXT_LICENSE_TITLE"), parent=self, width=1000) d.exec_() - def _show_changelog(self): + def _show_changelog(self) -> None: w = ChangelogWidget() d = GreyedDialog(w, title=_("TEXT_CHANGELOG_TITLE"), parent=self, width=1000) d.exec_() - def _show_settings(self): + def _show_settings(self) -> None: w = SettingsWidget(self.config, self.jobs_ctx, self.event_bus) d = GreyedDialog(w, title=_("TEXT_SETTINGS_TITLE"), parent=self, width=1000) d.exec_() - def _on_manage_keys(self): + def _on_manage_keys(self) -> None: w = KeysWidget(config=self.config, parent=self) w.key_imported.connect(self.reload_login_devices) d = GreyedDialog(w, title=_("TEXT_KEYS_DIALOG"), parent=self, width=800) d.exec() - def _on_show_doc_clicked(self): + def _on_show_doc_clicked(self) -> None: desktop.open_doc_link() - def _on_send_feedback_clicked(self): + def _on_send_feedback_clicked(self) -> None: desktop.open_feedback_link() - def _on_add_instance_clicked(self): + def _on_add_instance_clicked(self) -> None: self.add_instance() - def _on_create_org_clicked(self, addr=None): + def _on_create_org_clicked( + self, addr: Optional[BackendOrganizationBootstrapAddr] = None + ) -> None: def _on_finished(ret): if ret is None: return self.reload_login_devices() - self.try_login(ret[0], ret[1]) + device, password = ret + self.try_login(device, password) CreateOrgWidget.show_modal( self.jobs_ctx, self.config, self, on_finished=_on_finished, start_addr=addr ) - def _on_join_org_clicked(self): + def _on_join_org_clicked(self) -> None: url = get_text_input( parent=self, title=_("TEXT_JOIN_ORG_URL_TITLE"), @@ -304,16 +321,16 @@ def _on_join_org_clicked(self): show_error(self, _("TEXT_INVALID_URL")) return - def _on_claim_user_clicked(self, action_addr): + def _on_claim_user_clicked(self, action_addr: BackendInvitationAddr) -> None: widget = None def _on_finished(): nonlocal widget if not widget.status: return - login, password = widget.status + device, password = widget.status self.reload_login_devices() - self.try_login(login, password) + self.try_login(device, password) widget = ClaimUserWidget.show_modal( jobs_ctx=self.jobs_ctx, @@ -323,16 +340,16 @@ def _on_finished(): on_finished=_on_finished, ) - def _on_claim_device_clicked(self, action_addr): + def _on_claim_device_clicked(self, action_addr: BackendInvitationAddr) -> None: widget = None def _on_finished(): nonlocal widget if not widget.status: return - login, password = widget.status + device, password = widget.status self.reload_login_devices() - self.try_login(login, password) + self.try_login(device, password) widget = ClaimDeviceWidget.show_modal( jobs_ctx=self.jobs_ctx, @@ -342,7 +359,7 @@ def _on_finished(): on_finished=_on_finished, ) - def try_login(self, device, password): + def try_login(self, device: LocalDevice, password: str) -> None: idx = self._get_login_tab_index() tab = None if idx == -1: @@ -352,7 +369,7 @@ def try_login(self, device, password): kf = get_key_file(self.config.config_dir, device) tab.login_with_password(kf, password) - def reload_login_devices(self): + def reload_login_devices(self) -> None: idx = self._get_login_tab_index() if idx == -1: return @@ -361,7 +378,7 @@ def reload_login_devices(self): return w.show_login_widget() - def on_current_tab_changed(self, index): + def on_current_tab_changed(self, index: int) -> None: for i in range(self.tab_center.tabBar().count()): if i != index: if self.tab_center.tabBar().tabTextColor(i) != MainWindow.TAB_NOTIFICATION_COLOR: @@ -369,19 +386,21 @@ def on_current_tab_changed(self, index): else: self.tab_center.tabBar().setTabTextColor(i, MainWindow.TAB_SELECTED_COLOR) - def _on_foreground_needed(self): + def _on_foreground_needed(self) -> None: self.show_top() - def _on_new_instance_needed(self, start_arg): + def _on_new_instance_needed(self, start_arg: Optional[str]) -> None: self.add_instance(start_arg) self.show_top() - def on_config_updated(self, event, **kwargs): + def on_config_updated(self, event: CoreEvent, **kwargs) -> None: self.config = self.config.evolve(**kwargs) save_config(self.config) telemetry.init(self.config) - def show_window(self, skip_dialogs=False, invitation_link=""): + def show_window( + self, skip_dialogs: bool = False, invitation_link: Optional[str] = None + ) -> None: try: if not self.restoreGeometry(self.config.gui_geometry): self.showMaximized() @@ -448,13 +467,13 @@ def show_window(self, skip_dialogs=False, invitation_link=""): elif r == _("ACTION_NO_DEVICE_CREATE_ORGANIZATION"): self._on_create_org_clicked() - def show_top(self): + def show_top(self) -> None: self.activateWindow() self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive) self.raise_() self.show() - def on_tab_state_changed(self, tab, state): + def on_tab_state_changed(self, tab: InstanceWidget, state: str) -> None: idx = self.tab_center.indexOf(tab) if idx == -1: return @@ -487,20 +506,20 @@ def on_tab_state_changed(self, tab, state): self.tab_center.setTabsClosable(False) self._toggle_add_tab_button() - def on_tab_notification(self, widget, event): - idx = self.tab_center.indexOf(widget) + def on_tab_notification(self, tab: InstanceWidget, event: CoreEvent) -> None: + idx = self.tab_center.indexOf(tab) if idx == -1 or idx == self.tab_center.currentIndex(): return if event == CoreEvent.SHARING_UPDATED: self.tab_center.tabBar().setTabTextColor(idx, MainWindow.TAB_NOTIFICATION_COLOR) - def _get_login_tab_index(self): + def _get_login_tab_index(self) -> int: for idx in range(self.tab_center.count()): if self.tab_center.tabText(idx) == _("TEXT_TAB_TITLE_LOG_IN_SCREEN"): return idx return -1 - def add_new_tab(self): + def add_new_tab(self) -> InstanceWidget: tab = InstanceWidget(self.jobs_ctx, self.event_bus, self.config, self.systray_notification) tab.join_organization_clicked.connect(self._on_join_org_clicked) tab.create_organization_clicked.connect(self._on_create_org_clicked) @@ -513,102 +532,102 @@ def add_new_tab(self): self.tab_center.setTabsClosable(False) return tab - def switch_to_tab(self, idx): + def switch_to_tab(self, idx: int) -> None: if not ParsecApp.has_active_modal(): self.tab_center.setCurrentIndex(idx) - def _find_device_from_addr(self, action_addr, display_error=False): - device = None - for available_device in list_available_devices(self.config.config_dir): - if available_device.organization_id == action_addr.organization_id: - device = available_device - break - if device is None: - show_error( - self, - _("TEXT_FILE_LINK_NOT_IN_ORG_organization").format( - organization=action_addr.organization_id - ), - ) - return device - - def switch_to_login_tab(self, action_addr=None): + def switch_to_login_tab( + self, file_link_addr: Optional[BackendOrganizationFileLinkAddr] = None + ) -> None: + # Retrieve the login tab idx = self._get_login_tab_index() if idx != -1: self.switch_to_tab(idx) else: + # No loging tab, create one tab = self.add_new_tab() tab.show_login_widget() self.on_tab_state_changed(tab, "login") idx = self.tab_center.count() - 1 self.switch_to_tab(idx) - if action_addr is not None: - device = self._find_device_from_addr(action_addr, display_error=True) - instance_widget = self.tab_center.widget(idx) - instance_widget.set_workspace_path(action_addr) - login_w = self.tab_center.widget(idx).get_login_widget() - login_w._on_account_clicked(device) - - def go_to_file_link(self, action_addr): - found_org = self._find_device_from_addr(action_addr, display_error=True) is not None - if not found_org: - self.switch_to_login_tab() - return + if file_link_addr: + for available_device in list_available_devices(self.config.config_dir): + if available_device.organization_id == file_link_addr.organization_id: + instance_widget = self.tab_center.widget(idx) + instance_widget.set_workspace_path(file_link_addr) + login_w = self.tab_center.widget(idx).get_login_widget() + login_w._on_account_clicked(available_device) + show_info( + self, + _("TEXT_FILE_LINK_PLEASE_LOG_IN_organization").format( + organization=file_link_addr.organization_id + ), + ) + break + else: + show_error( + self, + _("TEXT_FILE_LINK_NOT_IN_ORG_organization").format( + organization=file_link_addr.organization_id + ), + ) + def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr) -> None: + # Try to use the file link on the already logged in cores for idx in range(self.tab_center.count()): if self.tab_center.tabText(idx) == _("TEXT_TAB_TITLE_LOG_IN_SCREEN"): continue + w = self.tab_center.widget(idx) if ( not w or not w.core - or w.core.device.organization_addr.organization_id != action_addr.organization_id + or w.core.device.organization_addr.organization_id != addr.organization_id ): continue - user_manifest = w.core.user_fs.get_user_manifest() - found_workspace = False - for wk in user_manifest.workspaces: - if not wk.role: - continue - if wk.id == action_addr.workspace_id: - found_workspace = True - central_widget = w.get_central_widget() - try: - central_widget.go_to_file_link(wk.id, action_addr.path) - self.switch_to_tab(idx) - except AttributeError: - logger.exception("Central widget is not available") - return - if not found_workspace: + + central_widget = w.get_central_widget() + if not central_widget: + continue + + try: + central_widget.go_to_file_link(addr) + + except GoToFileLinkBadOrgazationIDError: + continue + except GoToFileLinkBadWorkspaceIDError: show_error( self, _("TEXT_FILE_LINK_WORKSPACE_NOT_FOUND_organization").format( - organization=action_addr.organization_id + organization=addr.organization_id ), ) return - show_info( - self, - _("TEXT_FILE_LINK_PLEASE_LOG_IN_organization").format( - organization=action_addr.organization_id - ), - ) - self.switch_to_login_tab(action_addr) + except GoToFileLinkPathDecryptionError: + show_error(self, _("TEXT_INVALID_URL")) + return + else: + self.switch_to_tab(idx) + return + + # The file link is from an organization we'r not currently logged in + # or we don't have any device related to + self.switch_to_login_tab(addr) - def show_create_org_widget(self, action_addr): + def show_create_org_widget(self, action_addr: BackendOrganizationBootstrapAddr) -> None: self.switch_to_login_tab() self._on_create_org_clicked(action_addr) - def show_claim_user_widget(self, action_addr): + def show_claim_user_widget(self, action_addr: BackendInvitationAddr) -> None: self.switch_to_login_tab() self._on_claim_user_clicked(action_addr) - def show_claim_device_widget(self, action_addr): + def show_claim_device_widget(self, action_addr: BackendInvitationAddr) -> None: self.switch_to_login_tab() self._on_claim_device_clicked(action_addr) - def add_instance(self, start_arg: Optional[str] = None): + def add_instance(self, start_arg: Optional[str] = None) -> None: action_addr = None if start_arg: try: @@ -636,24 +655,24 @@ def add_instance(self, start_arg: Optional[str] = None): else: show_error(self, _("TEXT_INVALID_URL")) - def close_current_tab(self, force=False): + def close_current_tab(self, force: bool = False) -> None: if self.tab_center.count() == 1: self.close_app() else: idx = self.tab_center.currentIndex() self.close_tab(idx, force=force) - def close_app(self, force=False): + def close_app(self, force: bool = False) -> None: self.show_top() self.need_close = True self.force_close = force self.close() - def close_all_tabs(self): + def close_all_tabs(self) -> None: for idx in range(self.tab_center.count()): self.close_tab(idx, force=True) - def close_tab(self, index, force=False): + def close_tab(self, index: int, force: bool = False) -> None: tab = self.tab_center.widget(index) if not force: r = _("ACTION_TAB_CLOSE_CONFIRM") @@ -677,7 +696,7 @@ def close_tab(self, index, force=False): self.tab_center.setTabsClosable(False) self._toggle_add_tab_button() - def closeEvent(self, event): + def closeEvent(self, event) -> None: if self.minimize_on_close and not self.need_close: self.hide() event.ignore() diff --git a/parsec/core/gui/mount_widget.py b/parsec/core/gui/mount_widget.py index ce4b5a61b6b..19ac1673c26 100644 --- a/parsec/core/gui/mount_widget.py +++ b/parsec/core/gui/mount_widget.py @@ -54,7 +54,7 @@ def show_files_widget(self, workspace_fs, default_path, selected=False, mount_it self.files_widget.show() - def show_workspaces_widget(self, user_info=None): + def show_workspaces_widget(self): self.folder_changed.emit(None, None) self.files_widget.hide() self.workspaces_widget.show() diff --git a/parsec/core/gui/workspaces_widget.py b/parsec/core/gui/workspaces_widget.py index e25e21ef9ba..3d9ce4c5274 100644 --- a/parsec/core/gui/workspaces_widget.py +++ b/parsec/core/gui/workspaces_widget.py @@ -315,17 +315,22 @@ def goto_file_clicked(self): if not file_link: return - url = None try: - url = BackendOrganizationFileLinkAddr.from_url(file_link) + addr = BackendOrganizationFileLinkAddr.from_url(file_link) except ValueError as exc: show_error(self, _("TEXT_WORKSPACE_GOTO_FILE_LINK_INVALID_LINK"), exception=exc) return - button = self.get_workspace_button(url.workspace_id) + button = self.get_workspace_button(addr.workspace_id) if button is not None: - self.load_workspace(button.workspace_fs, path=url.path, selected=True) + try: + path = widget.workspace_fs.decrypt_file_link_path(addr) + except ValueError as exc: + show_error(self, _("TEXT_WORKSPACE_GOTO_FILE_LINK_INVALID_LINK"), exception=exc) + return + self.load_workspace(button.workspace_fs, path=path, selected=True) return + show_error(self, _("TEXT_WORKSPACE_GOTO_FILE_LINK_WORKSPACE_NOT_FOUND")) def on_workspace_filter(self, pattern): diff --git a/tests/core/gui/test_main_window.py b/tests/core/gui/test_main_window.py index 4d42bc2e4ef..51c9f99e198 100644 --- a/tests/core/gui/test_main_window.py +++ b/tests/core/gui/test_main_window.py @@ -12,7 +12,7 @@ _save_device_with_password, save_device_with_password, ) -from parsec.core.types import EntryID, FsPath +from parsec.core.types import EntryID from parsec.core.types import ( BackendInvitationAddr, BackendOrganizationBootstrapAddr, @@ -162,9 +162,7 @@ async def test_link_file( bob_available_device, ): logged_gui, w_w, f_w = logged_gui_with_files - url = BackendOrganizationFileLinkAddr.build( - f_w.core.device.organization_addr, f_w.workspace_fs.workspace_id, f_w.current_directory - ) + url = f_w.workspace_fs.generate_file_link(f_w.current_directory) monkeypatch.setattr( "parsec.core.gui.main_window.list_available_devices", @@ -192,7 +190,6 @@ async def test_link_file_unmounted( running_backend, backend, autoclose_dialog, - # logged_gui, logged_gui_with_files, bob, monkeypatch, @@ -201,9 +198,7 @@ async def test_link_file_unmounted( logged_gui, w_w, f_w = logged_gui_with_files core = logged_gui.test_get_core() - url = BackendOrganizationFileLinkAddr.build( - f_w.core.device.organization_addr, f_w.workspace_fs.workspace_id, f_w.current_directory - ) + url = f_w.workspace_fs.generate_file_link(f_w.current_directory) monkeypatch.setattr( "parsec.core.gui.main_window.list_available_devices", @@ -252,9 +247,7 @@ async def test_link_file_invalid_path( bob_available_device, ): logged_gui, w_w, f_w = logged_gui_with_files - url = BackendOrganizationFileLinkAddr.build( - f_w.core.device.organization_addr, f_w.workspace_fs.workspace_id, "/not_a_valid_path" - ) + url = f_w.workspace_fs.generate_file_link("/unknown") monkeypatch.setattr( "parsec.core.gui.main_window.list_available_devices", @@ -285,16 +278,15 @@ async def test_link_file_invalid_workspace( bob_available_device, ): logged_gui, w_w, f_w = logged_gui_with_files - url = BackendOrganizationFileLinkAddr.build( - f_w.core.device.organization_addr, "not_a_workspace", "/dir1" - ) + org_addr = f_w.core.device.organization_addr + url = f"parsec://{org_addr.netloc}/{org_addr.organization_id}?action=file_link&workspace_id=not_a_uuid&path=HRSW4Y3SPFYHIZLEL5YGC6LMN5QWIPQs" monkeypatch.setattr( "parsec.core.gui.main_window.list_available_devices", lambda *args, **kwargs: [bob_available_device], ) - await aqtbot.run(logged_gui.add_instance, str(url)) + await aqtbot.run(logged_gui.add_instance, url) def _assert_dialogs(): assert len(autoclose_dialog.dialogs) == 1 @@ -316,9 +308,7 @@ async def test_link_file_disconnected( bob_available_device, ): gui, w_w, f_w = logged_gui_with_files - url = BackendOrganizationFileLinkAddr.build( - f_w.core.device.organization_addr, f_w.workspace_fs.workspace_id, "/dir1" - ) + addr = f_w.workspace_fs.generate_file_link("/dir1") monkeypatch.setattr( "parsec.core.gui.main_window.list_available_devices", @@ -327,7 +317,7 @@ async def test_link_file_disconnected( # Log out and send link await gui.test_logout_and_switch_to_login_widget() - await aqtbot.run(gui.add_instance, str(url)) + await aqtbot.run(gui.add_instance, addr.to_url()) def _assert_dialogs(): assert len(autoclose_dialog.dialogs) == 1 @@ -389,9 +379,7 @@ async def test_link_file_disconnected_cancel_login( bob_available_device, ): gui, w_w, f_w = logged_gui_with_files - url = BackendOrganizationFileLinkAddr.build( - f_w.core.device.organization_addr, f_w.workspace_fs.workspace_id, "/dir1" - ) + url = f_w.workspace_fs.generate_file_link("/dir1") monkeypatch.setattr( "parsec.core.gui.main_window.list_available_devices", @@ -694,7 +682,7 @@ async def test_link_file_unknown_org( ) file_link = BackendOrganizationFileLinkAddr.build( - org_addr, EntryID.new(), FsPath("/doesntmattereither") + organization_addr=org_addr, workspace_id=EntryID.new(), encrypted_path=b"" ) gui = await gui_factory(core_config=core_config, start_arg=file_link.to_url()) From a28d0415cbacf1b6caafda51e338d6443d9179a1 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 23 Mar 2021 10:21:22 +0100 Subject: [PATCH 09/27] Fix tests on BackendOrganizationFileLinkAddr --- tests/core/test_backend_address.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/core/test_backend_address.py b/tests/core/test_backend_address.py index f2138cd57b3..6f45bb3fbfb 100644 --- a/tests/core/test_backend_address.py +++ b/tests/core/test_backend_address.py @@ -23,7 +23,7 @@ "USER_ID": "John", "DEVICE_ID": "John%40Dev42", "ENCRYPTED_PATH": "HRSW4Y3SPFYHIZLEL5YGC6LMN5QWIPQs", - "WORKSPACE_ID": "2d4ded12-7406-4608-833b-7f57f01156e2", + "WORKSPACE_ID": "2d4ded1274064608833b7f57f01156e2", "INVITATION_TYPE": "claim_user", } @@ -77,7 +77,14 @@ def generate_url(self, **kwargs): BackendOrganizationBootstrapAddrTestbed, BackendOrganizationFileLinkAddrTestbed, BackendInvitationAddrTestbed, - ] + ], + ids=[ + "backend_addr", + "backend_organization_addr", + "backend_organization_bootstrap_addr", + "backend_organization_file_link_addr", + "backend_invitation_addr", + ], ) def addr_testbed(request): return request.param @@ -89,7 +96,13 @@ def addr_testbed(request): BackendOrganizationBootstrapAddrTestbed, BackendOrganizationFileLinkAddrTestbed, BackendInvitationAddrTestbed, - ] + ], + ids=[ + "backend_organization_addr", + "backend_organization_bootstrap_addr", + "backend_organization_file_link_addr", + "backend_invitation_addr", + ], ) def addr_with_org_testbed(request): return request.param @@ -99,7 +112,8 @@ def addr_with_org_testbed(request): params=[ BackendOrganizationBootstrapAddrTestbed, # BackendInvitationAddrTestbed token format is different from apiv1's token - ] + ], + ids=["backend_organization_bootstrap_addr"], ) def addr_with_token_testbed(request): return request.param From 6be2316127970e3ecb47adeeeeada2f3c823a0e8 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 23 Mar 2021 11:08:18 +0100 Subject: [PATCH 10/27] Improve parsec.api.data.manifest.UserManifest typing --- parsec/api/data/manifest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/parsec/api/data/manifest.py b/parsec/api/data/manifest.py index bfe4e9eff9c..fbcde21a825 100644 --- a/parsec/api/data/manifest.py +++ b/parsec/api/data/manifest.py @@ -301,7 +301,6 @@ def make_obj(self, data: Dict[str, Any]) -> "UserManifest": created: DateTime updated: DateTime last_processed_message: int - workspaces: Tuple[WorkspaceEntry, ...] = attr.ib(converter=tuple) def get_workspace_entry(self, workspace_id: EntryID) -> Optional[WorkspaceEntry]: From 888aac212ee6a7fc95e498c0ae03be42c3b1f6b8 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 23 Mar 2021 11:09:32 +0100 Subject: [PATCH 11/27] Enable mypy checking for parsec.core.gui.central_widget --- mypy.ini | 12 ++++++++++++ parsec/core/gui/central_widget.py | 26 +++++++++++++++----------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/mypy.ini b/mypy.ini index 12b91a5e530..b96bf972334 100644 --- a/mypy.ini +++ b/mypy.ini @@ -30,6 +30,18 @@ disallow_subclassing_any=True [mypy-parsec.core.fs.workspacefs.versioning_helpers] disallow_untyped_defs=False +[mypy-parsec.core.gui.*] +# Qt doesn't provide typing +disallow_any_unimported = False + +[mypy-parsec.core.gui.central_widget] +ignore_errors = False +disallow_untyped_defs=True +disallow_any_decorated=True +disallow_any_explicit=True +disallow_any_generics=True +disallow_subclassing_any=True + [mypy-parsec.api.*] ignore_errors = False disallow_untyped_defs=True diff --git a/parsec/core/gui/central_widget.py b/parsec/core/gui/central_widget.py index cab9a4722d6..f155c58ffe1 100644 --- a/parsec/core/gui/central_widget.py +++ b/parsec/core/gui/central_widget.py @@ -1,5 +1,6 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) AGPLv3 2016-2021 Scille SAS +from parsec.api.data.manifest import WorkspaceEntry from typing import Optional from PyQt5.QtCore import pyqtSignal, QTimer from PyQt5.QtGui import QPixmap, QColor, QIcon @@ -12,7 +13,7 @@ HandshakeOrganizationExpired, ) from parsec.core.core_events import CoreEvent -from parsec.core.logged_core import LoggedCore +from parsec.core.logged_core import LoggedCore, OrganizationStats from parsec.core.types import UserInfo, BackendOrganizationFileLinkAddr from parsec.core.backend_connection import ( BackendConnectionError, @@ -38,7 +39,7 @@ from parsec.core.gui.trio_thread import JobResultError, ThreadSafeQtSignal, QtToTrioJob -async def _do_get_organization_stats(core): +async def _do_get_organization_stats(core: LoggedCore) -> OrganizationStats: try: return await core.get_organization_stats() except BackendNotAvailable as exc: @@ -63,7 +64,7 @@ class GoToFileLinkPathDecryptionError(Exception): pass -class CentralWidget(QWidget, Ui_CentralWidget): +class CentralWidget(QWidget, Ui_CentralWidget): # type: ignore[misc] NOTIFICATION_EVENTS = [ CoreEvent.BACKEND_CONNECTION_CHANGED, CoreEvent.MOUNTPOINT_STOPPED, @@ -89,7 +90,7 @@ def __init__( event_bus: EventBus, systray_notification: pyqtSignal, file_link_addr: Optional[BackendOrganizationFileLinkAddr] = None, - **kwargs, + **kwargs: object, ): super().__init__(**kwargs) @@ -204,7 +205,7 @@ def _on_folder_changed(self, workspace_name: Optional[str], path: Optional[str]) self.icon_title3.hide() self.label_title3.setText("") - def handle_event(self, event: CoreEvent, **kwargs) -> None: + def handle_event(self, event: CoreEvent, **kwargs: object) -> None: if event == CoreEvent.BACKEND_CONNECTION_CHANGED: self.connection_state_changed.emit(kwargs["status"], kwargs["status_exc"]) elif event == CoreEvent.MOUNTPOINT_STOPPED: @@ -232,10 +233,10 @@ def handle_event(self, event: CoreEvent, **kwargs) -> None: ), ) elif event == CoreEvent.SHARING_UPDATED: - new_entry = kwargs["new_entry"] - previous_entry = kwargs["previous_entry"] - new_role = getattr(new_entry, "role", None) - previous_role = getattr(previous_entry, "role", None) + new_entry: WorkspaceEntry = kwargs["new_entry"] # type: ignore + previous_entry: Optional[WorkspaceEntry] = kwargs["previous_entry"] # type: ignore + new_role = new_entry.role + previous_role = previous_entry.role if previous_entry is not None else None if new_role is not None and previous_role is None: self.new_notification.emit( "INFO", _("NOTIF_INFO_WORKSPACE_SHARED_{}").format(new_entry.name) @@ -245,8 +246,9 @@ def handle_event(self, event: CoreEvent, **kwargs) -> None: "INFO", _("NOTIF_INFO_WORKSPACE_ROLE_UPDATED_{}").format(new_entry.name) ) elif new_role is None and previous_role is not None: + name = previous_entry.name # type: ignore self.new_notification.emit( - "INFO", _("NOTIF_INFO_WORKSPACE_UNSHARED_{}").format(previous_entry.name) + "INFO", _("NOTIF_INFO_WORKSPACE_UNSHARED_{}").format(name) ) elif event == CoreEvent.FS_ENTRY_FILE_UPDATE_CONFLICTED: self.new_notification.emit( @@ -261,7 +263,7 @@ def _get_organization_stats(self) -> None: core=self.core, ) - def _on_vlobs_updated_trio(self, *args, **kwargs) -> None: + def _on_vlobs_updated_trio(self, *args: object, **kwargs: object) -> None: self.vlobs_updated_qt.emit() def _on_vlobs_updated_qt(self) -> None: @@ -293,6 +295,7 @@ def _on_connection_state_changed( elif status == BackendConnStatus.REFUSED: disconnected = True + assert isinstance(status_exc, Exception) cause = status_exc.__cause__ if isinstance(cause, HandshakeAPIVersionError): tooltip = _("TEXT_BACKEND_STATE_API_MISMATCH_versions").format( @@ -313,6 +316,7 @@ def _on_connection_state_changed( notif = ("WARNING", tooltip) elif status == BackendConnStatus.CRASHED: + assert isinstance(status_exc, Exception) text = _("TEXT_BACKEND_STATE_DISCONNECTED") tooltip = _("TEXT_BACKEND_STATE_CRASHED_cause").format(cause=str(status_exc.__cause__)) icon = QPixmap(":/icons/images/material/cloud_off.svg") From 424ea37fa4ef53ba0302e212fc6f81f7cb9f5803 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 23 Mar 2021 11:25:45 +0100 Subject: [PATCH 12/27] Enable mypy checking for parsec.core.gui.main_window --- mypy.ini | 8 ++++++++ parsec/core/gui/main_window.py | 25 ++++++++++++------------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/mypy.ini b/mypy.ini index b96bf972334..2a4ec1b8270 100644 --- a/mypy.ini +++ b/mypy.ini @@ -42,6 +42,14 @@ disallow_any_explicit=True disallow_any_generics=True disallow_subclassing_any=True +[mypy-parsec.core.gui.main_window] +ignore_errors = False +disallow_untyped_defs=True +disallow_any_decorated=True +disallow_any_explicit=True +disallow_any_generics=True +disallow_subclassing_any=True + [mypy-parsec.api.*] ignore_errors = False disallow_untyped_defs=True diff --git a/parsec/core/gui/main_window.py b/parsec/core/gui/main_window.py index 816927e9d56..3a55bc83f42 100644 --- a/parsec/core/gui/main_window.py +++ b/parsec/core/gui/main_window.py @@ -3,12 +3,12 @@ from parsec.core.types.local_device import LocalDevice from parsec.core.core_events import CoreEvent import sys -from typing import Callable, Optional +from typing import Callable, Optional, Tuple from structlog import get_logger from distutils.version import LooseVersion from PyQt5.QtCore import QCoreApplication, pyqtSignal, Qt, QSize -from PyQt5.QtGui import QColor, QIcon, QKeySequence +from PyQt5.QtGui import QColor, QIcon, QKeySequence, QResizeEvent, QCloseEvent from PyQt5.QtWidgets import QMainWindow, QApplication, QMenu, QShortcut from parsec import __version__ as PARSEC_VERSION @@ -56,7 +56,7 @@ logger = get_logger() -class MainWindow(QMainWindow, Ui_MainWindow): +class MainWindow(QMainWindow, Ui_MainWindow): # type: ignore[misc] foreground_needed = pyqtSignal() new_instance_needed = pyqtSignal(object) systray_notification = pyqtSignal(str, str, int) @@ -71,7 +71,7 @@ def __init__( event_bus: EventBus, config: CoreConfig, minimize_on_close: bool = False, - **kwargs, + **kwargs: object, ): super().__init__(**kwargs) self.setupUi(self) @@ -172,7 +172,7 @@ def _toggle_add_tab_button(self) -> None: else: self.add_tab_button.setDisabled(True) - def resizeEvent(self, event) -> None: + def resizeEvent(self, event: QResizeEvent) -> None: super().resizeEvent(event) for win in self.children(): if win.objectName() == "GreyedDialog": @@ -276,7 +276,7 @@ def _on_add_instance_clicked(self) -> None: def _on_create_org_clicked( self, addr: Optional[BackendOrganizationBootstrapAddr] = None ) -> None: - def _on_finished(ret): + def _on_finished(ret: Optional[Tuple[LocalDevice, str]]) -> None: if ret is None: return self.reload_login_devices() @@ -322,9 +322,9 @@ def _on_join_org_clicked(self) -> None: return def _on_claim_user_clicked(self, action_addr: BackendInvitationAddr) -> None: - widget = None + widget: ClaimDeviceWidget - def _on_finished(): + def _on_finished() -> None: nonlocal widget if not widget.status: return @@ -341,9 +341,9 @@ def _on_finished(): ) def _on_claim_device_clicked(self, action_addr: BackendInvitationAddr) -> None: - widget = None + widget: ClaimDeviceWidget - def _on_finished(): + def _on_finished() -> None: nonlocal widget if not widget.status: return @@ -361,7 +361,6 @@ def _on_finished(): def try_login(self, device: LocalDevice, password: str) -> None: idx = self._get_login_tab_index() - tab = None if idx == -1: tab = self.add_new_tab() else: @@ -393,7 +392,7 @@ def _on_new_instance_needed(self, start_arg: Optional[str]) -> None: self.add_instance(start_arg) self.show_top() - def on_config_updated(self, event: CoreEvent, **kwargs) -> None: + def on_config_updated(self, event: CoreEvent, **kwargs: object) -> None: self.config = self.config.evolve(**kwargs) save_config(self.config) telemetry.init(self.config) @@ -696,7 +695,7 @@ def close_tab(self, index: int, force: bool = False) -> None: self.tab_center.setTabsClosable(False) self._toggle_add_tab_button() - def closeEvent(self, event) -> None: + def closeEvent(self, event: QCloseEvent) -> None: if self.minimize_on_close and not self.need_close: self.hide() event.ignore() From a6163195240389778d0dfa5cbac948301919afe3 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 23 Mar 2021 11:42:55 +0100 Subject: [PATCH 13/27] Add test of legacy file link url format in tests/core/gui/test_main_window.py --- tests/core/gui/test_main_window.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/core/gui/test_main_window.py b/tests/core/gui/test_main_window.py index 51c9f99e198..83f3a22da17 100644 --- a/tests/core/gui/test_main_window.py +++ b/tests/core/gui/test_main_window.py @@ -267,7 +267,8 @@ def _assert_dialogs(): @pytest.mark.gui @pytest.mark.trio -async def test_link_file_invalid_workspace( +@pytest.mark.parametrize("kind", ["bad_workspace_id", "legacy_url_format"]) +async def test_link_file_invalid_url( aqtbot, running_backend, backend, @@ -276,10 +277,16 @@ async def test_link_file_invalid_workspace( bob, monkeypatch, bob_available_device, + kind, ): logged_gui, w_w, f_w = logged_gui_with_files org_addr = f_w.core.device.organization_addr - url = f"parsec://{org_addr.netloc}/{org_addr.organization_id}?action=file_link&workspace_id=not_a_uuid&path=HRSW4Y3SPFYHIZLEL5YGC6LMN5QWIPQs" + if kind == "bad_workspace_id": + url = f"parsec://{org_addr.netloc}/{org_addr.organization_id}?action=file_link&workspace_id=not_a_uuid&path=HRSW4Y3SPFYHIZLEL5YGC6LMN5QWIPQs" + elif kind == "legacy_url_format": + url = f"parsec://{org_addr.netloc}/{org_addr.organization_id}?action=file_link&workspace_id=449977b2-889a-4a62-bc54-f89c26175e90&path=%2Fbar.txt&no_ssl=true&rvk=ZY3JDUOCOKTLCXWS6CJTAELDZSMZYFK5QLNJAVY6LFJV5IRJWAIAssss" + else: + assert False monkeypatch.setattr( "parsec.core.gui.main_window.list_available_devices", From 59afb0c732ee276f39ff25575d62b3439813822d Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 23 Mar 2021 12:36:33 +0100 Subject: [PATCH 14/27] Remove unused fixtures in tests/ core/gui/test_main_window.py --- tests/core/gui/test_main_window.py | 155 +++-------------------------- 1 file changed, 15 insertions(+), 140 deletions(-) diff --git a/tests/core/gui/test_main_window.py b/tests/core/gui/test_main_window.py index 83f3a22da17..4a5494fa843 100644 --- a/tests/core/gui/test_main_window.py +++ b/tests/core/gui/test_main_window.py @@ -151,24 +151,10 @@ def _device_widget_ready(): @pytest.mark.gui @pytest.mark.trio -async def test_link_file( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui_with_files, - bob, - monkeypatch, - bob_available_device, -): +async def test_link_file(aqtbot, logged_gui_with_files): logged_gui, w_w, f_w = logged_gui_with_files url = f_w.workspace_fs.generate_file_link(f_w.current_directory) - monkeypatch.setattr( - "parsec.core.gui.main_window.list_available_devices", - lambda *args, **kwargs: [bob_available_device], - ) - await aqtbot.run(logged_gui.add_instance, str(url)) def _folder_ready(): @@ -185,26 +171,12 @@ def _folder_ready(): @pytest.mark.gui @pytest.mark.trio -async def test_link_file_unmounted( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui_with_files, - bob, - monkeypatch, - bob_available_device, -): +async def test_link_file_unmounted(aqtbot, logged_gui_with_files): logged_gui, w_w, f_w = logged_gui_with_files core = logged_gui.test_get_core() url = f_w.workspace_fs.generate_file_link(f_w.current_directory) - monkeypatch.setattr( - "parsec.core.gui.main_window.list_available_devices", - lambda *args, **kwargs: [bob_available_device], - ) - await aqtbot.run(logged_gui.add_instance, str(url)) def _folder_ready(): @@ -236,24 +208,10 @@ def _unmounted(): @pytest.mark.gui @pytest.mark.trio -async def test_link_file_invalid_path( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui_with_files, - bob, - monkeypatch, - bob_available_device, -): +async def test_link_file_invalid_path(aqtbot, autoclose_dialog, logged_gui_with_files): logged_gui, w_w, f_w = logged_gui_with_files url = f_w.workspace_fs.generate_file_link("/unknown") - monkeypatch.setattr( - "parsec.core.gui.main_window.list_available_devices", - lambda *args, **kwargs: [bob_available_device], - ) - await aqtbot.run(logged_gui.add_instance, str(url)) def _assert_dialogs(): @@ -268,17 +226,7 @@ def _assert_dialogs(): @pytest.mark.gui @pytest.mark.trio @pytest.mark.parametrize("kind", ["bad_workspace_id", "legacy_url_format"]) -async def test_link_file_invalid_url( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui_with_files, - bob, - monkeypatch, - bob_available_device, - kind, -): +async def test_link_file_invalid_url(aqtbot, autoclose_dialog, logged_gui_with_files, kind): logged_gui, w_w, f_w = logged_gui_with_files org_addr = f_w.core.device.organization_addr if kind == "bad_workspace_id": @@ -288,11 +236,6 @@ async def test_link_file_invalid_url( else: assert False - monkeypatch.setattr( - "parsec.core.gui.main_window.list_available_devices", - lambda *args, **kwargs: [bob_available_device], - ) - await aqtbot.run(logged_gui.add_instance, url) def _assert_dialogs(): @@ -305,14 +248,7 @@ def _assert_dialogs(): @pytest.mark.gui @pytest.mark.trio async def test_link_file_disconnected( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui_with_files, - bob, - monkeypatch, - bob_available_device, + aqtbot, autoclose_dialog, logged_gui_with_files, bob, monkeypatch, bob_available_device ): gui, w_w, f_w = logged_gui_with_files addr = f_w.workspace_fs.generate_file_link("/dir1") @@ -376,14 +312,7 @@ def _folder_ready(): @pytest.mark.gui @pytest.mark.trio async def test_link_file_disconnected_cancel_login( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui_with_files, - bob, - monkeypatch, - bob_available_device, + aqtbot, autoclose_dialog, logged_gui_with_files, bob, monkeypatch, bob_available_device ): gui, w_w, f_w = logged_gui_with_files url = f_w.workspace_fs.generate_file_link("/dir1") @@ -440,15 +369,7 @@ def _folder_ready(): @pytest.mark.gui @pytest.mark.trio async def test_link_organization( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui, - bob, - monkeypatch, - catch_create_org_widget, - invitation_organization_link, + aqtbot, logged_gui, catch_create_org_widget, invitation_organization_link ): await aqtbot.run(logged_gui.add_instance, invitation_organization_link) co_w = await catch_create_org_widget() @@ -459,15 +380,7 @@ async def test_link_organization( @pytest.mark.gui @pytest.mark.trio async def test_link_organization_disconnected( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui, - bob, - monkeypatch, - catch_create_org_widget, - invitation_organization_link, + aqtbot, logged_gui, catch_create_org_widget, invitation_organization_link ): await logged_gui.test_logout_and_switch_to_login_widget() await aqtbot.run(logged_gui.add_instance, invitation_organization_link) @@ -479,15 +392,7 @@ async def test_link_organization_disconnected( @pytest.mark.gui @pytest.mark.trio async def test_link_claim_device( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui, - bob, - monkeypatch, - catch_claim_device_widget, - invitation_device_link, + aqtbot, logged_gui, catch_claim_device_widget, invitation_device_link ): await aqtbot.run(logged_gui.add_instance, invitation_device_link) cd_w = await catch_claim_device_widget() @@ -498,15 +403,7 @@ async def test_link_claim_device( @pytest.mark.gui @pytest.mark.trio async def test_link_claim_device_disconnected( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui, - bob, - monkeypatch, - catch_claim_device_widget, - invitation_device_link, + aqtbot, logged_gui, catch_claim_device_widget, invitation_device_link ): await logged_gui.test_logout_and_switch_to_login_widget() await aqtbot.run(logged_gui.add_instance, invitation_device_link) @@ -517,17 +414,7 @@ async def test_link_claim_device_disconnected( @pytest.mark.gui @pytest.mark.trio -async def test_link_claim_user( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui, - bob, - monkeypatch, - catch_claim_user_widget, - invitation_user_link, -): +async def test_link_claim_user(aqtbot, logged_gui, catch_claim_user_widget, invitation_user_link): await aqtbot.run(logged_gui.add_instance, invitation_user_link) cd_w = await catch_claim_user_widget() assert cd_w @@ -537,15 +424,7 @@ async def test_link_claim_user( @pytest.mark.gui @pytest.mark.trio async def test_link_claim_user_disconnected( - aqtbot, - running_backend, - backend, - autoclose_dialog, - logged_gui, - bob, - monkeypatch, - catch_claim_user_widget, - invitation_user_link, + aqtbot, logged_gui, catch_claim_user_widget, invitation_user_link ): await logged_gui.test_logout_and_switch_to_login_widget() await aqtbot.run(logged_gui.add_instance, invitation_user_link) @@ -556,9 +435,7 @@ async def test_link_claim_user_disconnected( @pytest.mark.gui @pytest.mark.trio -async def test_tab_login_logout( - aqtbot, running_backend, gui_factory, autoclose_dialog, core_config, alice, monkeypatch -): +async def test_tab_login_logout(gui_factory, core_config, alice, monkeypatch): password = "P@ssw0rd" save_device_with_password(core_config.config_dir, alice, password) gui = await gui_factory() @@ -588,9 +465,7 @@ async def test_tab_login_logout( @pytest.mark.gui @pytest.mark.trio -async def test_tab_login_logout_two_tabs( - aqtbot, running_backend, gui_factory, autoclose_dialog, core_config, alice, monkeypatch -): +async def test_tab_login_logout_two_tabs(aqtbot, gui_factory, core_config, alice, monkeypatch): password = "P@ssw0rd" save_device_with_password(core_config.config_dir, alice, password) gui = await gui_factory() @@ -629,7 +504,7 @@ def _logged_tab_displayed(): @pytest.mark.gui @pytest.mark.trio async def test_tab_login_logout_two_tabs_logged_in( - aqtbot, running_backend, gui_factory, autoclose_dialog, core_config, alice, bob, monkeypatch + aqtbot, gui_factory, core_config, alice, bob, monkeypatch ): password = "P@ssw0rd" save_device_with_password(core_config.config_dir, alice, password) From f1a3a08e80cb01e3fe1ed4cba2626e0aa3680ce7 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 23 Mar 2021 12:38:40 +0100 Subject: [PATCH 15/27] Add simple copy&use file link tests --- tests/core/gui/test_files.py | 58 ++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/tests/core/gui/test_files.py b/tests/core/gui/test_files.py index 2bdf1c84fe2..22afbd85ba9 100644 --- a/tests/core/gui/test_files.py +++ b/tests/core/gui/test_files.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from PyQt5 import QtCore, QtWidgets, QtGui +from PyQt5.QtGui import QGuiApplication from parsec.core.types import WorkspaceRole, FsPath @@ -621,9 +622,7 @@ def _import_error_shown(): @pytest.mark.gui @pytest.mark.trio -async def test_open_file_failed( - monkeypatch, tmpdir, aqtbot, autoclose_dialog, files_widget_testbed -): +async def test_open_file_failed(monkeypatch, aqtbot, autoclose_dialog, files_widget_testbed): tb = files_widget_testbed f_w = files_widget_testbed.files_widget @@ -666,3 +665,56 @@ def _open_multiple_files_error_shown(): assert autoclose_dialog.dialogs == [("Error", _("TEXT_FILE_OPEN_MULTIPLE_ERROR"))] await aqtbot.wait_until(_open_multiple_files_error_shown) + + +@pytest.mark.gui +@pytest.mark.trio +async def test_copy_file_link(aqtbot, autoclose_dialog, files_widget_testbed): + tb = files_widget_testbed + f_w = files_widget_testbed.files_widget + + # Populate the workspace + await tb.workspace_fs.mkdir("/foo") + await tb.workspace_fs.touch("/foo/bar.txt") + await tb.check_files_view(path="/", expected_entries=["foo/"]) + + await tb.cd("foo") + await tb.apply_selection("bar.txt") + + f_w.table_files.file_path_clicked.emit() + + def _file_link_copied_dialog(): + assert autoclose_dialog.dialogs == [("", _("TEXT_FILE_LINK_COPIED_TO_CLIPBOARD"))] + url = QGuiApplication.clipboard().text() + assert url.startswith("parsec://") + + await aqtbot.wait_until(_file_link_copied_dialog) + + +@pytest.mark.gui +@pytest.mark.trio +async def test_use_file_link(aqtbot, autoclose_dialog, files_widget_testbed): + tb = files_widget_testbed + f_w = files_widget_testbed.files_widget + + # Populate the workspace + await tb.workspace_fs.mkdir("/foo") + await tb.workspace_fs.touch("/foo/bar.txt") + await tb.check_files_view(path="/", expected_entries=["foo/"]) + + # Create and use file link + url = f_w.workspace_fs.generate_file_link("/foo/bar.txt") + await aqtbot.run(tb.logged_gui.add_instance, str(url)) + + def _selection_on_file(): + assert tb.pwd() == "/foo" + selected_files = f_w.table_files.selected_files() + assert len(selected_files) == 1 + selected_files[0].name == "bar.txt" + # No new tab has been created + assert tb.logged_gui.tab_center.count() == 1 + + await aqtbot.wait_until(_selection_on_file) + + +# Note: other file link tests are in test_main_window.py From cc6c1f48b8e7fd0a4d14f5c13a2636aba5b6c3bf Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 23 Mar 2021 15:32:34 +0100 Subject: [PATCH 16/27] Update newsfragment for #1637 (no backward compatibility after all !) --- newsfragments/1637.removal.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/newsfragments/1637.removal.rst b/newsfragments/1637.removal.rst index 38f6705bbd2..2a12c4b728e 100644 --- a/newsfragments/1637.removal.rst +++ b/newsfragments/1637.removal.rst @@ -1,3 +1,2 @@ -Remove `rkv` parameter from the file link url. -This change comes with backward compatibility (old url works with new version of Parsec) -but not forward compatibility (older versions of Parsec don't work with new url). +Change the file link URL format so that file path is encrypted. +This change breaks compatibility with previous file url format. From d52137ea7061dabd2fc6369a19a9116f7ddf3448 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 26 Apr 2021 17:08:29 +0200 Subject: [PATCH 17/27] Fix naming typo in parsec/core/gui/central_widget.py --- parsec/core/gui/central_widget.py | 6 +++--- parsec/core/gui/main_window.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/parsec/core/gui/central_widget.py b/parsec/core/gui/central_widget.py index f155c58ffe1..f15718e13f7 100644 --- a/parsec/core/gui/central_widget.py +++ b/parsec/core/gui/central_widget.py @@ -52,7 +52,7 @@ class GoToFileLinkError(Exception): pass -class GoToFileLinkBadOrgazationIDError(Exception): +class GoToFileLinkBadOrganizationIDError(Exception): pass @@ -357,12 +357,12 @@ def on_new_notification(self, notif_type: str, msg: str) -> None: def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr, mount: bool = True) -> None: """ Raises: - GoToFileLinkBadOrgazationIDError + GoToFileLinkBadOrganizationIDError GoToFileLinkBadWorkspaceIDError GoToFileLinkPathDecryptionError """ if addr.organization_id != self.core.device.organization_id: - raise GoToFileLinkBadOrgazationIDError + raise GoToFileLinkBadOrganizationIDError try: workspace = self.core.user_fs.get_workspace(addr.workspace_id) except FSWorkspaceNotFoundError as exc: diff --git a/parsec/core/gui/main_window.py b/parsec/core/gui/main_window.py index 3a55bc83f42..2e1a813e156 100644 --- a/parsec/core/gui/main_window.py +++ b/parsec/core/gui/main_window.py @@ -47,7 +47,7 @@ from parsec.core.gui.create_org_widget import CreateOrgWidget from parsec.core.gui.ui.main_window import Ui_MainWindow from parsec.core.gui.central_widget import ( - GoToFileLinkBadOrgazationIDError, + GoToFileLinkBadOrganizationIDError, GoToFileLinkBadWorkspaceIDError, GoToFileLinkPathDecryptionError, ) @@ -593,7 +593,7 @@ def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr) -> None: try: central_widget.go_to_file_link(addr) - except GoToFileLinkBadOrgazationIDError: + except GoToFileLinkBadOrganizationIDError: continue except GoToFileLinkBadWorkspaceIDError: show_error( From 2e01b726aabd754a0783f40bd44a2990958ad698 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 25 May 2021 13:00:40 +0200 Subject: [PATCH 18/27] Add HTTP redirection URL support in `BackendAddr.from_url` --- parsec/core/types/backend_address.py | 38 +++++++++++++++++------- tests/core/test_backend_address.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/parsec/core/types/backend_address.py b/parsec/core/types/backend_address.py index 417ca300bc2..0b73494448c 100644 --- a/parsec/core/types/backend_address.py +++ b/parsec/core/types/backend_address.py @@ -63,13 +63,14 @@ def __repr__(self): return f"{type(self).__name__}(url={self.to_url()})" @classmethod - def from_url(cls, url: str): + def from_url(cls, url: str, *, allow_http_redirection: bool = False): + """ + Use `allow_http_redirection` to accept backend redirection URL, + for instance `http://example.com/redirect/myOrg?token=123` will be + converted into `parsec://example.com/myOrg?token=123&no_ssl=True`. + """ split = urlsplit(url) - - if split.scheme != PARSEC_SCHEME: - raise ValueError(f"Must start with `{PARSEC_SCHEME}://`") - if not split.hostname: - raise ValueError("Missing mandatory hostname") + path = unquote_plus(split.path) if split.query: # Note `parse_qs` takes care of percent-encoding @@ -83,7 +84,24 @@ def from_url(cls, url: str): else: params = {} - path = unquote_plus(split.path) + if allow_http_redirection and split.scheme in ("http", "https"): + # `no_ssl` is defined by http/https scheme and shouldn't be + # overwritten by the query part of the url + params["no_ssl"] = ["true" if split.scheme == "http" else "false"] + # Remove the `/redirect/` path prefix + split_path = path.split("/", 2) + if split_path[:2] != ["", "redirect"]: + raise ValueError("HTTP to Parsec redirection URL must have a `/redirect/...` path") + try: + path = f"/{split_path[2]}" + except IndexError: + path = "" + + elif split.scheme != PARSEC_SCHEME: + raise ValueError(f"Must start with `{PARSEC_SCHEME}://`") + + if not split.hostname: + raise ValueError("Missing mandatory hostname") kwargs = { **cls._from_url_parse_and_consume_params(params), @@ -207,9 +225,9 @@ class BackendActionAddr(BackendAddr): __slots__ = () @classmethod - def from_url(cls, url: str): + def from_url(cls, url: str, **kwargs): if cls is not BackendActionAddr: - return BackendAddr.from_url.__func__(cls, url) + return BackendAddr.from_url.__func__(cls, url, **kwargs) else: for type in ( @@ -218,7 +236,7 @@ def from_url(cls, url: str): BackendInvitationAddr, ): try: - return BackendAddr.from_url.__func__(type, url) + return BackendAddr.from_url.__func__(type, url, **kwargs) except ValueError: pass diff --git a/tests/core/test_backend_address.py b/tests/core/test_backend_address.py index 6f45bb3fbfb..661598b5d13 100644 --- a/tests/core/test_backend_address.py +++ b/tests/core/test_backend_address.py @@ -2,6 +2,7 @@ import pytest from uuid import UUID +import re from parsec.crypto import SigningKey from parsec.api.protocol import InvitationType, OrganizationID @@ -169,6 +170,48 @@ def test_good_addr_with_unknown_field(addr_testbed): assert url2 == url +def test_good_addr_with_http_redirection(addr_testbed): + redirection_url = re.sub(r"^parsec://([^/]*)(.*)$", r"https://\1/redirect\2", addr_testbed.url) + addr = addr_testbed.cls.from_url(redirection_url, allow_http_redirection=True) + assert addr.to_url() == addr_testbed.url + + # Also try http redirection + addr_from_redirection = addr_testbed.cls.from_url( + add_args_to_url(addr_testbed.url, "no_ssl=true") + ) + redirection_url = re.sub(r"^parsec://([^/]*)(.*)$", r"http://\1/redirect\2", addr_testbed.url) + addr = addr_testbed.cls.from_url(redirection_url, allow_http_redirection=True) + assert addr_from_redirection.to_url() == addr.to_url() + + +def test_good_addr_with_http_redirection_overwritting_no_ssl(addr_testbed): + # no_ssl param should be ignored given it is already provided in the scheme + redirection_url = re.sub(r"^parsec://([^/]*)(.*)$", r"https://\1/redirect\2", addr_testbed.url) + redirection_url = add_args_to_url(redirection_url, "no_ssl=true") + addr_from_redirection = addr_testbed.cls.from_url(redirection_url, allow_http_redirection=True) + assert addr_from_redirection.to_url() == addr_testbed.url + + +def test_addr_with_http_redirection_not_enabled(addr_testbed): + # no_ssl param should be ignored given it is already provided in the scheme + redirection_url = re.sub(r"^parsec://([^/]*)(.*)$", r"https://\1/redirect\2", addr_testbed.url) + # HTTP redirection handling is disabled by default + with pytest.raises(ValueError): + addr_testbed.cls.from_url(redirection_url) + + +def test_bad_addr_with_http_redirection(addr_testbed): + # Missing `/redirect` path prefix + bad_url = addr_testbed.url.replace("parsec://", "https://") + with pytest.raises(ValueError): + addr_testbed.cls.from_url(bad_url) + + # Bad scheme + bad_url = re.sub(r"^parsec://([^/]*)(.*)$", r"dummy://\1/redirect\2", addr_testbed.url) + with pytest.raises(ValueError): + addr_testbed.cls.from_url(bad_url) + + def test_good_addr_with_unicode_org_name(addr_with_org_testbed): orgname = "康熙帝" orgname_percent_quoted = "%E5%BA%B7%E7%86%99%E5%B8%9D" From 14540e3b71fd03f1cf7d2bbd4132a89217467e4f Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 25 May 2021 13:21:49 +0200 Subject: [PATCH 19/27] Fix typing for parsec/core/gui/main_window.py&parsec/core/gui/central_widget.py --- parsec/core/gui/central_widget.py | 6 +++--- parsec/core/gui/main_window.py | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/parsec/core/gui/central_widget.py b/parsec/core/gui/central_widget.py index f15718e13f7..5d029f9c2ae 100644 --- a/parsec/core/gui/central_widget.py +++ b/parsec/core/gui/central_widget.py @@ -1,12 +1,12 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) AGPLv3 2016-2021 Scille SAS from parsec.api.data.manifest import WorkspaceEntry -from typing import Optional +from typing import Optional, cast from PyQt5.QtCore import pyqtSignal, QTimer from PyQt5.QtGui import QPixmap, QColor, QIcon from PyQt5.QtWidgets import QGraphicsDropShadowEffect, QWidget, QMenu -from parsec.event_bus import EventBus +from parsec.event_bus import EventBus, EventCallback from parsec.api.protocol import ( HandshakeAPIVersionError, HandshakeRevokedDevice, @@ -104,7 +104,7 @@ def __init__( self.widget_menu.layout().addWidget(self.menu) for e in self.NOTIFICATION_EVENTS: - self.event_bus.connect(e, self.handle_event) + self.event_bus.connect(e, cast(EventCallback, self.handle_event)) self.event_bus.connect(CoreEvent.FS_ENTRY_SYNCED, self._on_vlobs_updated_trio) self.event_bus.connect(CoreEvent.BACKEND_REALM_VLOBS_UPDATED, self._on_vlobs_updated_trio) diff --git a/parsec/core/gui/main_window.py b/parsec/core/gui/main_window.py index 2e1a813e156..f921e3e9d1b 100644 --- a/parsec/core/gui/main_window.py +++ b/parsec/core/gui/main_window.py @@ -3,7 +3,7 @@ from parsec.core.types.local_device import LocalDevice from parsec.core.core_events import CoreEvent import sys -from typing import Callable, Optional, Tuple +from typing import Callable, Optional, Tuple, cast from structlog import get_logger from distutils.version import LooseVersion @@ -12,7 +12,7 @@ from PyQt5.QtWidgets import QMainWindow, QApplication, QMenu, QShortcut from parsec import __version__ as PARSEC_VERSION -from parsec.event_bus import EventBus +from parsec.event_bus import EventBus, EventCallback from parsec.core.local_device import list_available_devices, get_key_file from parsec.core.config import CoreConfig, save_config from parsec.core.types import ( @@ -85,7 +85,9 @@ def __init__( self.minimize_on_close_notif_already_send = False self.force_close = False self.need_close = False - self.event_bus.connect(CoreEvent.GUI_CONFIG_CHANGED, self.on_config_updated) + self.event_bus.connect( + CoreEvent.GUI_CONFIG_CHANGED, cast(EventCallback, self.on_config_updated) + ) self.setWindowTitle(_("TEXT_PARSEC_WINDOW_TITLE_version").format(version=PARSEC_VERSION)) self.foreground_needed.connect(self._on_foreground_needed) self.new_instance_needed.connect(self._on_new_instance_needed) From e70275d5a35718d0c0d2380ce2c63e92ac283163 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 25 May 2021 13:22:05 +0200 Subject: [PATCH 20/27] Enable HTTP redirection URL handling in GUI (so https://.../redirect/... is accepted where parsec:// is expected) --- parsec/core/gui/create_org_widget.py | 2 +- parsec/core/gui/main_window.py | 4 ++-- parsec/core/gui/validators.py | 8 ++++---- parsec/core/gui/workspaces_widget.py | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/parsec/core/gui/create_org_widget.py b/parsec/core/gui/create_org_widget.py index 1328ed6852d..60875fa2d72 100644 --- a/parsec/core/gui/create_org_widget.py +++ b/parsec/core/gui/create_org_widget.py @@ -110,7 +110,7 @@ def check_infos(self, _=None): @property def backend_addr(self): return ( - BackendAddr.from_url(self.line_edit_backend_addr.text()) + BackendAddr.from_url(self.line_edit_backend_addr.text(), allow_http_redirection=True) if self.radio_use_custom.isChecked() else None ) diff --git a/parsec/core/gui/main_window.py b/parsec/core/gui/main_window.py index f921e3e9d1b..6b3fa08d31b 100644 --- a/parsec/core/gui/main_window.py +++ b/parsec/core/gui/main_window.py @@ -304,7 +304,7 @@ def _on_join_org_clicked(self) -> None: action_addr = None try: - action_addr = BackendActionAddr.from_url(url) + action_addr = BackendActionAddr.from_url(url, allow_http_redirection=True) except ValueError as exc: show_error(self, _("TEXT_INVALID_URL"), exception=exc) return @@ -632,7 +632,7 @@ def add_instance(self, start_arg: Optional[str] = None) -> None: action_addr = None if start_arg: try: - action_addr = BackendActionAddr.from_url(start_arg) + action_addr = BackendActionAddr.from_url(start_arg, allow_http_redirection=True) except ValueError as exc: show_error(self, _("TEXT_INVALID_URL"), exception=exc) diff --git a/parsec/core/gui/validators.py b/parsec/core/gui/validators.py index 96ba4a1418d..54d0c23a471 100644 --- a/parsec/core/gui/validators.py +++ b/parsec/core/gui/validators.py @@ -38,7 +38,7 @@ def validate(self, string, pos): try: if len(string) == 0: return QValidator.Intermediate, string, pos - BackendAddr.from_url(string) + BackendAddr.from_url(string, allow_http_redirection=True) return QValidator.Acceptable, string, pos except ValueError: return QValidator.Invalid, string, pos @@ -49,7 +49,7 @@ def validate(self, string, pos): try: if len(string) == 0: return QValidator.Intermediate, string, pos - BackendOrganizationAddr.from_url(string) + BackendOrganizationAddr.from_url(string, allow_http_redirection=True) return QValidator.Acceptable, string, pos except ValueError: return QValidator.Intermediate, string, pos @@ -60,7 +60,7 @@ def validate(self, string, pos): try: if len(string) == 0: return QValidator.Intermediate, string, pos - BackendOrganizationBootstrapAddr.from_url(string) + BackendOrganizationBootstrapAddr.from_url(string, allow_http_redirection=True) return QValidator.Acceptable, string, pos except ValueError: return QValidator.Intermediate, string, pos @@ -71,7 +71,7 @@ def validate(self, string, pos): try: if len(string) == 0: return QValidator.Intermediate, string, pos - BackendActionAddr.from_url(string) + BackendActionAddr.from_url(string, allow_http_redirection=True) return QValidator.Acceptable, string, pos except ValueError: return QValidator.Intermediate, string, pos diff --git a/parsec/core/gui/workspaces_widget.py b/parsec/core/gui/workspaces_widget.py index 3d9ce4c5274..4558d31e679 100644 --- a/parsec/core/gui/workspaces_widget.py +++ b/parsec/core/gui/workspaces_widget.py @@ -316,7 +316,7 @@ def goto_file_clicked(self): return try: - addr = BackendOrganizationFileLinkAddr.from_url(file_link) + addr = BackendOrganizationFileLinkAddr.from_url(file_link, allow_http_redirection=True) except ValueError as exc: show_error(self, _("TEXT_WORKSPACE_GOTO_FILE_LINK_INVALID_LINK"), exception=exc) return @@ -324,7 +324,7 @@ def goto_file_clicked(self): button = self.get_workspace_button(addr.workspace_id) if button is not None: try: - path = widget.workspace_fs.decrypt_file_link_path(addr) + path = button.workspace_fs.decrypt_file_link_path(addr) except ValueError as exc: show_error(self, _("TEXT_WORKSPACE_GOTO_FILE_LINK_INVALID_LINK"), exception=exc) return From 1856016137e6dd58669ae86a9944adb297ca137b Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Tue, 25 May 2021 15:29:29 +0200 Subject: [PATCH 21/27] Test http redirection url handling in GUI new instance --- tests/core/gui/test_main_window.py | 70 +++++++++++++++++------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/tests/core/gui/test_main_window.py b/tests/core/gui/test_main_window.py index 4a5494fa843..4072f3d546d 100644 --- a/tests/core/gui/test_main_window.py +++ b/tests/core/gui/test_main_window.py @@ -40,42 +40,38 @@ def catch_claim_user_widget(widget_catcher_factory): @pytest.fixture -async def invitation_organization_link(running_backend): +async def organization_bootstrap_addr(running_backend): org_id = OrganizationID("ShinraElectricPowerCompany") org_token = "123" await running_backend.backend.organization.create(org_id, org_token) - return str(BackendOrganizationBootstrapAddr.build(running_backend.addr, org_id, org_token)) + return BackendOrganizationBootstrapAddr.build(running_backend.addr, org_id, org_token) @pytest.fixture -async def invitation_device_link(backend, bob): +async def device_invitation_addr(backend, bob): invitation = await backend.invite.new_for_device( organization_id=bob.organization_id, greeter_user_id=bob.user_id ) - return str( - BackendInvitationAddr.build( - backend_addr=bob.organization_addr, - organization_id=bob.organization_id, - invitation_type=InvitationType.DEVICE, - token=invitation.token, - ) + return BackendInvitationAddr.build( + backend_addr=bob.organization_addr, + organization_id=bob.organization_id, + invitation_type=InvitationType.DEVICE, + token=invitation.token, ) @pytest.fixture -async def invitation_user_link(backend, bob): +async def user_invitation_addr(backend, bob): invitation = await backend.invite.new_for_user( organization_id=bob.organization_id, greeter_user_id=bob.user_id, claimer_email="billy@billy.corp", ) - return str( - BackendInvitationAddr.build( - backend_addr=bob.organization_addr, - organization_id=bob.organization_id, - invitation_type=InvitationType.USER, - token=invitation.token, - ) + return BackendInvitationAddr.build( + backend_addr=bob.organization_addr, + organization_id=bob.organization_id, + invitation_type=InvitationType.USER, + token=invitation.token, ) @@ -369,9 +365,9 @@ def _folder_ready(): @pytest.mark.gui @pytest.mark.trio async def test_link_organization( - aqtbot, logged_gui, catch_create_org_widget, invitation_organization_link + aqtbot, logged_gui, catch_create_org_widget, organization_bootstrap_addr ): - await aqtbot.run(logged_gui.add_instance, invitation_organization_link) + await aqtbot.run(logged_gui.add_instance, organization_bootstrap_addr.to_url()) co_w = await catch_create_org_widget() assert co_w assert logged_gui.tab_center.count() == 2 @@ -380,10 +376,10 @@ async def test_link_organization( @pytest.mark.gui @pytest.mark.trio async def test_link_organization_disconnected( - aqtbot, logged_gui, catch_create_org_widget, invitation_organization_link + aqtbot, logged_gui, catch_create_org_widget, organization_bootstrap_addr ): await logged_gui.test_logout_and_switch_to_login_widget() - await aqtbot.run(logged_gui.add_instance, invitation_organization_link) + await aqtbot.run(logged_gui.add_instance, organization_bootstrap_addr.to_url()) co_w = await catch_create_org_widget() assert co_w assert logged_gui.tab_center.count() == 1 @@ -391,10 +387,16 @@ async def test_link_organization_disconnected( @pytest.mark.gui @pytest.mark.trio +@pytest.mark.parametrize("http_redirection_url", (True, False)) async def test_link_claim_device( - aqtbot, logged_gui, catch_claim_device_widget, invitation_device_link + aqtbot, logged_gui, catch_claim_device_widget, device_invitation_addr, http_redirection_url ): - await aqtbot.run(logged_gui.add_instance, invitation_device_link) + if http_redirection_url: + url = device_invitation_addr.to_http_redirection_url() + else: + url = device_invitation_addr.to_url() + + await aqtbot.run(logged_gui.add_instance, url) cd_w = await catch_claim_device_widget() assert cd_w assert logged_gui.tab_center.count() == 2 @@ -403,10 +405,10 @@ async def test_link_claim_device( @pytest.mark.gui @pytest.mark.trio async def test_link_claim_device_disconnected( - aqtbot, logged_gui, catch_claim_device_widget, invitation_device_link + aqtbot, logged_gui, catch_claim_device_widget, device_invitation_addr ): await logged_gui.test_logout_and_switch_to_login_widget() - await aqtbot.run(logged_gui.add_instance, invitation_device_link) + await aqtbot.run(logged_gui.add_instance, device_invitation_addr.to_url()) cd_w = await catch_claim_device_widget() assert cd_w assert logged_gui.tab_center.count() == 1 @@ -414,8 +416,16 @@ async def test_link_claim_device_disconnected( @pytest.mark.gui @pytest.mark.trio -async def test_link_claim_user(aqtbot, logged_gui, catch_claim_user_widget, invitation_user_link): - await aqtbot.run(logged_gui.add_instance, invitation_user_link) +@pytest.mark.parametrize("http_redirection_url", (True, False)) +async def test_link_claim_user( + aqtbot, logged_gui, catch_claim_user_widget, user_invitation_addr, http_redirection_url +): + if http_redirection_url: + url = user_invitation_addr.to_http_redirection_url() + else: + url = user_invitation_addr.to_url() + + await aqtbot.run(logged_gui.add_instance, url) cd_w = await catch_claim_user_widget() assert cd_w assert logged_gui.tab_center.count() == 2 @@ -424,10 +434,10 @@ async def test_link_claim_user(aqtbot, logged_gui, catch_claim_user_widget, invi @pytest.mark.gui @pytest.mark.trio async def test_link_claim_user_disconnected( - aqtbot, logged_gui, catch_claim_user_widget, invitation_user_link + aqtbot, logged_gui, catch_claim_user_widget, user_invitation_addr ): await logged_gui.test_logout_and_switch_to_login_widget() - await aqtbot.run(logged_gui.add_instance, invitation_user_link) + await aqtbot.run(logged_gui.add_instance, user_invitation_addr.to_url()) cd_w = await catch_claim_user_widget() assert cd_w assert logged_gui.tab_center.count() == 1 From 2998f404799db49c44c3274b1ca8428cb7bec7a8 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 26 May 2021 16:10:43 +0200 Subject: [PATCH 22/27] Remove not longer used CoreEvent.FS_ENTRY_FILE_UPDATE_CONFLICTED event in core --- parsec/core/core_events.py | 1 - parsec/core/gui/central_widget.py | 5 ----- 2 files changed, 6 deletions(-) diff --git a/parsec/core/core_events.py b/parsec/core/core_events.py index b360dd55ee9..98b2b2dc458 100644 --- a/parsec/core/core_events.py +++ b/parsec/core/core_events.py @@ -21,7 +21,6 @@ class CoreEvent(Enum): FS_ENTRY_CONFINED = "fs.entry.confined" FS_ENTRY_UPDATED = "fs.entry.updated" FS_ENTRY_FILE_CONFLICT_RESOLVED = "fs.entry.file_conflict_resolved" - FS_ENTRY_FILE_UPDATE_CONFLICTED = "fs.entry.file_update_conflicted" FS_WORKSPACE_CREATED = "fs.workspace.created" # Gui GUI_CONFIG_CHANGED = "gui.config.changed" diff --git a/parsec/core/gui/central_widget.py b/parsec/core/gui/central_widget.py index 5d029f9c2ae..7696cf5af19 100644 --- a/parsec/core/gui/central_widget.py +++ b/parsec/core/gui/central_widget.py @@ -71,7 +71,6 @@ class CentralWidget(QWidget, Ui_CentralWidget): # type: ignore[misc] CoreEvent.MOUNTPOINT_REMOTE_ERROR, CoreEvent.MOUNTPOINT_UNHANDLED_ERROR, CoreEvent.SHARING_UPDATED, - CoreEvent.FS_ENTRY_FILE_UPDATE_CONFLICTED, ] organization_stats_success = pyqtSignal(QtToTrioJob) @@ -250,10 +249,6 @@ def handle_event(self, event: CoreEvent, **kwargs: object) -> None: self.new_notification.emit( "INFO", _("NOTIF_INFO_WORKSPACE_UNSHARED_{}").format(name) ) - elif event == CoreEvent.FS_ENTRY_FILE_UPDATE_CONFLICTED: - self.new_notification.emit( - "WARNING", _("NOTIF_WARN_SYNC_CONFLICT_{}").format(kwargs["path"]) - ) def _get_organization_stats(self) -> None: self.jobs_ctx.submit_job( From 8170da294c716b0e815aa50bb8ffdfd211623a5a Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 26 May 2021 16:11:49 +0200 Subject: [PATCH 23/27] Improve typing in parsec.core.gui.central_widget --- parsec/core/gui/central_widget.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/parsec/core/gui/central_widget.py b/parsec/core/gui/central_widget.py index 7696cf5af19..7154dad035f 100644 --- a/parsec/core/gui/central_widget.py +++ b/parsec/core/gui/central_widget.py @@ -206,12 +206,16 @@ def _on_folder_changed(self, workspace_name: Optional[str], path: Optional[str]) def handle_event(self, event: CoreEvent, **kwargs: object) -> None: if event == CoreEvent.BACKEND_CONNECTION_CHANGED: + assert isinstance(kwargs["status"], BackendConnStatus) + assert kwargs["status_exc"] is None or isinstance(kwargs["status_exc"], Exception) self.connection_state_changed.emit(kwargs["status"], kwargs["status_exc"]) elif event == CoreEvent.MOUNTPOINT_STOPPED: self.new_notification.emit("WARNING", _("NOTIF_WARN_MOUNTPOINT_UNMOUNTED")) elif event == CoreEvent.MOUNTPOINT_REMOTE_ERROR: - exc = kwargs["exc"] - path = kwargs["path"] + assert isinstance(kwargs["exc"], Exception) + assert isinstance(kwargs["path"], str) + exc: Exception = kwargs["exc"] + path: str = kwargs["path"] if isinstance(exc, FSWorkspaceNoReadAccess): msg = _("NOTIF_WARN_WORKSPACE_READ_ACCESS_LOST_{}").format(path) elif isinstance(exc, FSWorkspaceNoWriteAccess): @@ -222,18 +226,22 @@ def handle_event(self, event: CoreEvent, **kwargs: object) -> None: msg = _("NOTIF_WARN_MOUNTPOINT_REMOTE_ERROR_{}_{}").format(path, str(exc)) self.new_notification.emit("WARNING", msg) elif event == CoreEvent.MOUNTPOINT_UNHANDLED_ERROR: - exc = kwargs["exc"] - path = kwargs["path"] - operation = kwargs["operation"] + assert isinstance(kwargs["exc"], Exception) + assert isinstance(kwargs["path"], str) + assert isinstance(kwargs["operation"], str) self.new_notification.emit( "ERROR", _("NOTIF_ERR_MOUNTPOINT_UNEXPECTED_ERROR_{}_{}_{}").format( - operation, path, str(exc) + kwargs["operation"], kwargs["path"], str(kwargs["exc"]) ), ) elif event == CoreEvent.SHARING_UPDATED: - new_entry: WorkspaceEntry = kwargs["new_entry"] # type: ignore - previous_entry: Optional[WorkspaceEntry] = kwargs["previous_entry"] # type: ignore + assert isinstance(kwargs["new_entry"], WorkspaceEntry) + assert kwargs["previous_entry"] is None or isinstance( + kwargs["previous_entry"], WorkspaceEntry + ) + new_entry: WorkspaceEntry = kwargs["new_entry"] + previous_entry: Optional[WorkspaceEntry] = kwargs["previous_entry"] new_role = new_entry.role previous_role = previous_entry.role if previous_entry is not None else None if new_role is not None and previous_role is None: From 1730ee59eb0d6d7fa0709160188c9568e1733bb4 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 2 Jun 2021 15:35:27 +0200 Subject: [PATCH 24/27] Use run_sync for WorkspaceFS.generate_file_link used in files widget GUI --- parsec/core/gui/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsec/core/gui/files_widget.py b/parsec/core/gui/files_widget.py index ed3cac246aa..442d9061295 100644 --- a/parsec/core/gui/files_widget.py +++ b/parsec/core/gui/files_widget.py @@ -396,7 +396,7 @@ def on_get_file_path_clicked(self): if len(files) != 1: return path = self.current_directory / files[0].name - addr = self.workspace_fs.generate_file_link(path) + addr = self.jobs_ctx.run_sync(self.workspace_fs.generate_file_link, path) desktop.copy_to_clipboard(addr.to_url()) show_info(self, _("TEXT_FILE_LINK_COPIED_TO_CLIPBOARD")) From 0b5636d75d4ba4bbec8122eb7de85987ed8860f5 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 7 Jun 2021 16:28:45 +0200 Subject: [PATCH 25/27] Use run_sync for UserFS.get_workspace used in central widget GUI --- parsec/core/gui/central_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsec/core/gui/central_widget.py b/parsec/core/gui/central_widget.py index 7154dad035f..cf006b4d51a 100644 --- a/parsec/core/gui/central_widget.py +++ b/parsec/core/gui/central_widget.py @@ -367,7 +367,7 @@ def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr, mount: bool = T if addr.organization_id != self.core.device.organization_id: raise GoToFileLinkBadOrganizationIDError try: - workspace = self.core.user_fs.get_workspace(addr.workspace_id) + workspace = self.jobs_ctx.run_sync(self.core.user_fs.get_workspace, addr.workspace_id) except FSWorkspaceNotFoundError as exc: raise GoToFileLinkBadWorkspaceIDError from exc try: From eca6e2db274f6235ab11da9c4088af67efd1099a Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 7 Jun 2021 16:30:24 +0200 Subject: [PATCH 26/27] Improve link handling code readability in GUI MainWindow.switch_to_login_tab --- parsec/core/gui/main_window.py | 55 +++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/parsec/core/gui/main_window.py b/parsec/core/gui/main_window.py index 6b3fa08d31b..458f0747503 100644 --- a/parsec/core/gui/main_window.py +++ b/parsec/core/gui/main_window.py @@ -552,27 +552,40 @@ def switch_to_login_tab( idx = self.tab_center.count() - 1 self.switch_to_tab(idx) - if file_link_addr: - for available_device in list_available_devices(self.config.config_dir): - if available_device.organization_id == file_link_addr.organization_id: - instance_widget = self.tab_center.widget(idx) - instance_widget.set_workspace_path(file_link_addr) - login_w = self.tab_center.widget(idx).get_login_widget() - login_w._on_account_clicked(available_device) - show_info( - self, - _("TEXT_FILE_LINK_PLEASE_LOG_IN_organization").format( - organization=file_link_addr.organization_id - ), - ) - break - else: - show_error( - self, - _("TEXT_FILE_LINK_NOT_IN_ORG_organization").format( - organization=file_link_addr.organization_id - ), - ) + if not file_link_addr: + # We're done here + return + + # Find the device corresponding to the organization in the link + for available_device in list_available_devices(self.config.config_dir): + if available_device.organization_id == file_link_addr.organization_id: + break + + else: + # Cannot reach this organization with our available devices + show_error( + self, + _("TEXT_FILE_LINK_NOT_IN_ORG_organization").format( + organization=file_link_addr.organization_id + ), + ) + return + + # Pre-select the corresponding device + login_w = self.tab_center.widget(idx).get_login_widget() + login_w._on_account_clicked(available_device) + + # Set the path + instance_widget = self.tab_center.widget(idx) + instance_widget.set_workspace_path(file_link_addr) + + # Prompt the user for the need to log in first + show_info( + self, + _("TEXT_FILE_LINK_PLEASE_LOG_IN_organization").format( + organization=file_link_addr.organization_id + ), + ) def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr) -> None: # Try to use the file link on the already logged in cores From 65f43a091ee0d6e0a4cd8b37c57bf5f5705f75bb Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Mon, 7 Jun 2021 16:37:55 +0200 Subject: [PATCH 27/27] Fix missing tab switching before displaying file link error in GUI --- parsec/core/gui/main_window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/parsec/core/gui/main_window.py b/parsec/core/gui/main_window.py index 458f0747503..ecc6a946de3 100644 --- a/parsec/core/gui/main_window.py +++ b/parsec/core/gui/main_window.py @@ -611,6 +611,8 @@ def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr) -> None: except GoToFileLinkBadOrganizationIDError: continue except GoToFileLinkBadWorkspaceIDError: + # Switch tab so user understand where the error comes from + self.switch_to_tab(idx) show_error( self, _("TEXT_FILE_LINK_WORKSPACE_NOT_FOUND_organization").format( @@ -619,6 +621,8 @@ def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr) -> None: ) return except GoToFileLinkPathDecryptionError: + # Switch tab so user understand where the error comes from + self.switch_to_tab(idx) show_error(self, _("TEXT_INVALID_URL")) return else: