From 6848e852ca3d0825fb6d01cb90d78d0f1b193a10 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 5 Mar 2026 07:40:14 +0100 Subject: [PATCH 01/15] proper quote text/plain output --- SHARING.md | 89 ++++++++++++++++------------------ radicale/sharing/__init__.py | 19 +++++++- radicale/tests/test_sharing.py | 68 +++++++++++++------------- 3 files changed, 94 insertions(+), 82 deletions(-) diff --git a/SHARING.md b/SHARING.md index daebf668..3568dbad 100644 --- a/SHARING.md +++ b/SHARING.md @@ -145,7 +145,7 @@ Shows what kind of ShareTypes are supported ``` curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info ApiVersion=1 -Status=success +Status='success' FeatureEnabledCollectionByMap=True PermittedCreateCollectionByMap=True FeatureEnabledCollectionByToken=True @@ -183,29 +183,28 @@ Authenticated user as `Owner` has at least read access to `PathMapped` * Input -| Parameter | Owner | -| - | - | -| Owner | implicit(by authentication) | -| PathMapped | mandatory | -| User | optional(default:owner) | -| Permissions | optional(default:r) | -| Enabled | optional(owner) | -| Hidden | optional(owner) | -| Properties | optional | +| Parameter | Type | Requirement | +| - | - | - | +| PathMapped | str | mandatory | +| User | str | optional(default:owner) | +| Permissions | str | optional(default:r) | +| Enabled | bool | optional(owner/default:False) | +| Hidden | bool | optional(owner/default:True) | +| Properties | str | optional | * Output -| Parameter | Value | +| Parameter | Type | Value | | - | - | -| PathOrToken | (autogenerated token) | +| PathOrToken | str | (autogenerated token) | * Example: TEXT ``` curl -u user:pass -d "PathMapped=/user/testcalendar1/" http://localhost:5232/.sharing/v1/token/create ApiVersion=1 -Status=success -PathOrToken=v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw= +Status='success' +PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' ``` ###### API Hook "map/create" @@ -220,15 +219,14 @@ Provided `User` has at least read access to `PathOrToken` * Input -| Parameter | Value | +| Parameter | Type | Requirement | | - | - | -| Owner | implicit(by authentication) | -| PathOrToken | mandatory | -| PathMapped | mandatory | -| User | mandatory | -| Permissions | optional(default:r) | -| Enabled | optional(owner) | -| Hidden | optional(owner) | +| PathOrToken | str | mandatory | +| PathMapped | str | mandatory | +| User | str | mandatory | +| Permissions | str | optional(default:r) | +| Enabled | bool | optional(owner/default:False) | +| Hidden | bool | optional(owner/default:True) | | Properties | optional | * Output: result status @@ -252,12 +250,10 @@ Authenticated user as `Owner` or `User` * Input -| Parameter | Filter | -| - | - | -| Owner | implicit(by authentication) | -| User | implicit(by authentication) | -| PathOrToken | optional | -| PathMapped | optional | +| Parameter | Type | Used for | +| - | - | - | +| PathOrToken | str | optional | +| PathMapped | str | optional | * Output: plain/csv/json @@ -265,8 +261,8 @@ Authenticated user as `Owner` or `User` ``` curl -H "accept: text/csv" -u owner:pass -d "" http://localhost:5232/.sharing/v1/map/list -ShareType,PathOrToken,PathMapped,Owner,User,Permissions,EnabledByOwner,EnabledByUser,HiddenByOwner,HiddenByUser,TimestampCreated,TimestampUpdated -map,/user/cal1-from-owner/,/owner/cal1/,owner,user,r,False,False,True,True,1771962120,1771962120 +ShareType,PathOrToken,PathMapped,Owner,User,Permissions,EnabledByOwner,EnabledByUser,HiddenByOwner,HiddenByUser,TimestampCreated,TimestampUpdated,Properties +map,/user/cal1-from-owner/,/owner/cal1/,owner,user,r,False,False,True,True,1771962120,1771962120,{} ``` @@ -280,9 +276,9 @@ Authenticated user is `Owner` * Input -| Parameter | Type | Owner | User | -| - | - | - | - | -| PathOrToken | selector | mandatory | not-permitted | +| Parameter | Type | Used for | as Owner | as User | +| - | - | - | - | - | +| PathOrToken | str | selection | mandatory | not-permitted | * Output: result status @@ -298,16 +294,15 @@ Authenticated user is `Owner` or `User` * Input -| Parameter | Type | Owner | User | -| - | - | - | - | -| PathOrToken | selector | mandatory | mandatory | -| Owner | by authentication | not-permitted | not-permitted | -| PathMapped | adjustable | optional | not-permitted | -| User | adjustable | optional | not-permitted | -| Permissions | adjustable | optional | not-permitted | -| Enabled | adjustable | optional(owner) | optional(user) | -| Hidden | adjustable | optional(owner) | optional(user) | -| Properties | adjustable | optional | optional | +| Parameter | Type | Used for | Owner | User | +| - | - | - | - | - | +| PathOrToken | str | selection | mandatory | mandatory | +| PathMapped | str | adjust | optional | not-permitted | +| User | str | adjust | optional | not-permitted | +| Permissions | str | adjust | optional | not-permitted | +| Enabled | bool | adjust | optional(owner) | optional(user) | +| Hidden | bool | adjust | optional(owner) | optional(user) | +| Properties | str | adjust | optional | optional | * Output: result status @@ -321,9 +316,9 @@ Authenticated user is `Owner` or `User` * Input -| Parameter | Type | Owner | User | -| - | - | - | - | -| PathOrToken | selector | mandatory | mandatory | +| Parameter | Type | Used for | Owner | User | +| - | - | - | - | - | +| PathOrToken | selection | mandatory | mandatory | * Output: result status @@ -340,7 +335,7 @@ Status=success ``` curl -u owner:pass -d '{"PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/cal1/", "User": "user"} http://localhost:5232/.sharing/v1/map/unhide ApiVersion=1 -Status=success +Status='success' ``` ## Properties Overlay diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 8dba8314..6390af0e 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -72,6 +72,20 @@ API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'un API_SHARE_TOGGLES_V1: Sequence[str] = ('hide', 'unhide', 'enable', 'disable') +API_TYPES_V1: dict[str, type] = { + "ApiVersion": int, + "Status": str, + "Lines": int, + "ShareType": str, + "PathOrToken": str, + "PathMapped:": str, + "Owner": str, + "User": str, + "Permissions": str, + "Enabled": bool, + "Hidden": bool, + "Properties": str} + TOKEN_PATTERN_V1: str = "(v1/[a-zA-Z0-9_=\\-]{44})" PATH_PATTERN: str = "([a-zA-Z0-9/.\\-]+)" # TODO: extend or find better source @@ -1056,7 +1070,10 @@ class BaseSharing: if output_format == "txt": for key in answer: if key != 'Content': - answer_array.append(key + '=' + str(answer[key])) + if API_TYPES_V1[key] is bool or API_TYPES_V1[key] is int: + answer_array.append(key + '=' + str(answer[key])) + else: + answer_array.append(key + "='" + str(answer[key]) + "'") if 'Content' in answer and answer['Content'] is not None: csv = io.StringIO() writer = DictWriter(csv, fieldnames=DB_FIELDS_V1, delimiter=';') diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index aef27c29..41670242 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -319,13 +319,13 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** list (form->csv)") form_array = [] _, headers, answer = self._sharing_api_form(sharing_type, "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=not-found" in answer + assert "Status='not-found'" in answer assert "Lines=0" in answer logging.info("\n*** list (json->text)") json_dict = {} _, headers, answer = self._sharing_api_json(sharing_type, "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") - assert "Status=not-found" in answer + assert "Status='not-found'" in answer assert "Lines=0" in answer logging.info("\n*** list (json->json)") @@ -338,10 +338,10 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** create a token -> 200") form_array = ["PathMapped=/owner/collectionL1/"] _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "PathOrToken=" in answer + assert "Status='success'" in answer + assert "PathOrToken='" in answer # extract token - match = re.search('PathOrToken=(.+)', answer) + match = re.search("PathOrToken='(.+)'", answer) if match: token = match.group(1) logging.info("received token %r", token) @@ -360,20 +360,20 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** list/all (form->csv)") form_array = [] _, headers, answer = self._sharing_api_form("all", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=2" in answer logging.info("\n*** delete token -> 200") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer logging.info("\n*** delete share -> 200") form_array = [] form_array.append("PathOrToken=/user/collectionL2-shared-by-owner/") form_array.append("PathMapped=/owner/collectionL2/") _, headers, answer = self._sharing_api_form("map", "delete", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer def test_sharing_api_token_basic(self) -> None: """share-by-token API tests.""" @@ -409,10 +409,10 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** create token#1 (form->text)") form_array = ["PathMapped=/owner/collection1/"] _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "PathOrToken=" in answer + assert "Status='success'" in answer + assert "PathOrToken='" in answer # extract token - match = re.search('PathOrToken=(.+)', answer) + match = re.search("PathOrToken='(.+)'", answer) if match: token1 = match.group(1) logging.info("received token %r", token1) @@ -422,10 +422,10 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** create token#2 (json->text)") json_dict = {'PathMapped': "/owner/collection2/"} _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") - assert "Status=success" in answer + assert "Status='success'" in answer assert "Token=" in answer # extract token - match = re.search('Token=(.+)', answer) + match = re.search("Token='(.+)'", answer) if match: token2 = match.group(1) logging.info("received token %r", token2) @@ -435,14 +435,14 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** lookup token#1 (form->text)") form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=1" in answer assert "/owner/collection1/" in answer logging.info("\n*** lookup token#2 (json->text") json_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=1" in answer assert "/owner/collection2/" in answer @@ -457,7 +457,7 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** lookup tokens (form->text)") form_array = [] _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=2" in answer assert "/owner/collection1/" in answer assert "/owner/collection2/" in answer @@ -465,7 +465,7 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** lookup tokens (form->csv)") form_array = [] _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array, accept="text/csv") - assert "Status=success" not in answer + assert "Status='success'" not in answer assert "Lines=2" not in answer assert ";".join(sharing.DB_FIELDS_V1) in answer assert "/owner/collection1/" in answer @@ -474,24 +474,24 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** delete token#1 (form->text)") form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer logging.info("\n*** lookup token#1 (form->text) -> should not be there anymore") form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=not-found" in answer + assert "Status='not-found'" in answer assert "Lines=0" in answer logging.info("\n*** lookup tokens (form->text) -> still one should be there") form_array = [] _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=1" in answer logging.info("\n*** disable token#2 as owner (form->text)") form_array = ["PathOrToken=" + token2] _, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer logging.info("\n*** lookup token#2 (json->json) -> check for not enabled") json_dict = {'PathOrToken': token2} @@ -512,7 +512,7 @@ class TestSharingApiSanity(BaseTest): form_array = [] form_array.append("PathOrToken=" + token2) _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=1" in answer assert "True;True;True;True" in answer @@ -520,13 +520,13 @@ class TestSharingApiSanity(BaseTest): form_array = [] form_array.append("PathOrToken=" + token2) _, headers, answer = self._sharing_api_form("token", "hide", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer logging.info("\n*** lookup token#2 (form->text) -> check for hidden") form_array = [] form_array.append("PathOrToken=" + token2) _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=1" in answer assert "True;True;True;True" in answer @@ -606,10 +606,10 @@ class TestSharingApiSanity(BaseTest): form_array = [] form_array.append("PathMapped=" + path_base) _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "PathOrToken=" in answer # extract token - match = re.search('PathOrToken=(.+)', answer) + match = re.search("PathOrToken='(.+)'", answer) if match: token = match.group(1) logging.info("received token %r", token) @@ -620,10 +620,10 @@ class TestSharingApiSanity(BaseTest): form_array = [] form_array.append("PathMapped=" + path_base2) _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "PathOrToken=" in answer # extract token - match = re.search('PathOrToken=(.+)', answer) + match = re.search("PathOrToken='(.+)'", answer) if match: token2 = match.group(1) logging.info("received token %r", token2) @@ -633,7 +633,7 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** enable token (form->text)") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer logging.info("\n*** fetch collection using invalid token (without credentials)") _, headers, answer = self.request("GET", path_token + "v1/invalidtoken", check=401) @@ -645,7 +645,7 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** disable token (form->text)") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer logging.info("\n*** fetch collection using disabled token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=401) @@ -653,7 +653,7 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** enable token (form->text)") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer logging.info("\n*** fetch collection using token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=200) @@ -2207,7 +2207,7 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** list/all (form->csv)") form_array = [] _, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=1" in answer # read collection @@ -2227,7 +2227,7 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** list/all (form->csv)") form_array = [] _, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer assert "Lines=1" in answer # read collection @@ -2987,7 +2987,7 @@ permissions: RrWw""") form_array.append("Properties='C:calendar-description'='ICAL-USER-NEW'") form_array.append("Properties='ICAL:calendar-color'='#CCCCCC'") _, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array) - assert "Status=success" in answer + assert "Status='success'" in answer # verify overlay as user logging.info("\n*** PROPFIND collection user (overlay) -> ok") From bef0727b944c2977e03035ee7cd8ea2d44a40f22 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 5 Mar 2026 08:50:59 +0100 Subject: [PATCH 02/15] cosmetics, fix mk* conflict incl. testcases --- SHARING.md | 107 +++++++++++++++++++++++++++++++++ radicale/app/delete.py | 10 +-- radicale/app/get.py | 10 +-- radicale/app/mkcalendar.py | 10 +-- radicale/app/mkcol.py | 10 +-- radicale/app/move.py | 20 +++--- radicale/app/propfind.py | 56 ++++++++--------- radicale/app/proppatch.py | 56 ++++++++--------- radicale/app/put.py | 10 +-- radicale/app/report.py | 24 ++++---- radicale/tests/test_sharing.py | 31 +++++++++- 11 files changed, 239 insertions(+), 105 deletions(-) diff --git a/SHARING.md b/SHARING.md index 3568dbad..8200f1e6 100644 --- a/SHARING.md +++ b/SHARING.md @@ -5,6 +5,113 @@ Static collection sharing without permissions filter using soft-links (Unix-only With 3.7.0 a major extension was implemented using internal mapping configuration stored in a database and a management API. +## Sharing Implementation + +Implemenation of sharing collections is done in case entry exists in sharing database by replacing provided data on request and adjust if required data in responses. + +Permissions are filtered by provided `Permissions`. + +### CxDAV requests + +#### CxDav request "(DELETE|GET|HEAD|PUT)" + + * Actions + * map + * Lookup by + * `path` (provided in request) + * `user` (authenticated) + * Replace + * `user` by `Owner` + * `path` by `PathMapped` + * Activate + * `permissions_filter` by `Permissions` + +#### CxDav request "REPORT" + + * Actions + * map + * back-map response + * Lookup by + * `path` (provided in request) + * `user` (authenticated) + * Replace + * `user` by `Owner` + * `path` by `PathMapped` + * Activate + * `permissions_filter` by `Permissions` + +#### CxDav request "PROPFIND" without HTTP_DEPTH=1 + + * Actions + * map + * back-map response + * overwrite `Properties` if provided + * Lookup by + * `path` (provided in request) + * `user` (authenticated) + * Replace + * `user` by `Owner` + * `path` by `PathMapped` + * Overlay + * `Properties` if provided + * Activate + * `permissions_filter` by `Permissions` + +#### CxDav request "PROPFIND" with HTTP_DEPTH=1 + + * Actions + * extend list + * Lookup for active shares for `user` in sharing database + * Extend list if conditions are met + * `permissions_filter` by `Permissions` + +#### CxDav request "PROPPATCH" + + * Actions + * map + * adjust properties of a collection + * Lookup by + * `path` (provided in request) + * `user` (authenticated) + * Replace + * `user` by `Owner` + * `path` by `PathMapped` + * Activate + * `permissions_filter` by `Permissions` + * Depending on `permissions_filter`, global options and `Permissions` + * adjust properties of collection + * adjust whitelisted properties in `Properties` for overlay (see OVERLAY_PROPERTIES_WHITELIST) + +#### CxDav request "(MKCALENDAR|MKCOL)" + + * Action + * check for conflicts + * Lookup by + * `user` (authenticated) + * Verify for non-existence as `PathOrToken` in sharing database + * `path` (provided in request) + +#### CxDav request "(MOVE)" + + + * Action + * map source + * map destination + * Lookup by + * `path` (provided in request) + * `user` (authenticated) + * `to_path` (provided in request) + * `to_user` (same as `user`) + * Replace + * `user` by `Owner` (of `path`) + * `path` by `PathMapped` (of path) + * `to_user` by `Owner` (of `to_path`) + * `to_path` by `PathMapped` (of `to_path`) + * Activate + * `permissions_filter` by `Permissions` (of `to_path`) + * `to_permissions_filter` by `Permissions` (of `to_path`) + + ## Sharing Configuration Store Types of supported sharing configuration: diff --git a/radicale/app/delete.py b/radicale/app/delete.py index ff22e0c5..3401755a 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -60,12 +60,12 @@ class ApplicationPartDelete(ApplicationBase): permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: + share = self._sharing.sharing_collection_resolver(path, user) + if share: # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] + path = share['PathMapped'] + user = share['Owner'] + permissions_filter = share['Permissions'] access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED diff --git a/radicale/app/get.py b/radicale/app/get.py index cea26f31..fa4158ad 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -79,12 +79,12 @@ class ApplicationPartGet(ApplicationBase): permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: + share = self._sharing.sharing_collection_resolver(path, user) + if share: # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] + path = share['PathMapped'] + user = share['Owner'] + permissions_filter = share['Permissions'] access = Access(self._rights, user, path, permissions_filter) if not access.check("r") and "i" not in access.permissions: return httputils.NOT_ALLOWED diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index b892aa6b..b2825075 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -55,11 +55,11 @@ class ApplicationPartMkcalendar(ApplicationBase): "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST if self._sharing._enabled: - # check for shared collections (active or inactive) - collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False) - if collections_shared_map: - for sharing in collections_shared_map: - if sharing['PathOrToken'] == path: + # check for shared collections (all users / active or inactive) + collections_share_map = self._sharing.sharing_collection_map_list(user=None, active=False) + if collections_share_map: + for share in collections_share_map: + if share['PathOrToken'] == path: return httputils.CONFLICT # TODO: use this? # timezone = props.get("C:calendar-timezone") diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 6670349f..d3bef110 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -62,11 +62,11 @@ class ApplicationPartMkcol(ApplicationBase): logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'") return httputils.NOT_ALLOWED if self._sharing._enabled: - # check for shared collections (active or inactive) - collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False) - if collections_shared_map: - for sharing in collections_shared_map: - if sharing['PathOrToken'] == path: + # check for shared collections (all users, active or inactive) + collections_share_map = self._sharing.sharing_collection_map_list(user=None, active=False) + if collections_share_map: + for share in collections_share_map: + if share['PathOrToken'] == path: return httputils.CONFLICT with self._storage.acquire_lock("w", user, path=path, request="MKCOL"): item = next(iter(self._storage.discover(path)), None) diff --git a/radicale/app/move.py b/radicale/app/move.py index b093aa25..9af6e378 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -72,12 +72,12 @@ class ApplicationPartMove(ApplicationBase): permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: + share = self._sharing.sharing_collection_resolver(path, user) + if share: # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] + path = share['PathMapped'] + user = share['Owner'] + permissions_filter = share['Permissions'] access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED @@ -89,12 +89,12 @@ class ApplicationPartMove(ApplicationBase): to_path = to_path[len(base_prefix):] if self._sharing._enabled: # Sharing by token or map (if enabled) - sharing = self._sharing.sharing_collection_resolver(to_path, to_user) - if sharing: + share = self._sharing.sharing_collection_resolver(to_path, to_user) + if share: # overwrite and run through extended permission check - to_path = sharing['PathMapped'] - to_user = sharing['Owner'] - to_permissions_filter = sharing['Permissions'] + to_path = share['PathMapped'] + to_user = share['Owner'] + to_permissions_filter = share['Permissions'] to_access = Access(self._rights, to_user, to_path, to_permissions_filter) to_access = Access(self._rights, to_user, to_path, to_permissions_filter) if not to_access.check("w"): diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 64cb45f8..16db8f3c 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -37,7 +37,7 @@ from radicale.log import logger def xml_propfind(base_prefix: str, path: str, xml_request: Optional[ET.Element], allowed_items: Iterable[Tuple[types.CollectionOrItem, str]], - user: str, encoding: str, max_resource_size: int, sharing: Union[dict, None] = None) -> Optional[ET.Element]: + user: str, encoding: str, max_resource_size: int, share: Union[dict, None] = None) -> Optional[ET.Element]: """Read and answer PROPFIND requests. Read rfc4918-9.1 for info. @@ -74,7 +74,7 @@ def xml_propfind(base_prefix: str, path: str, write = permission == "w" multistatus.append(xml_propfind_response( base_prefix, path, item, props, user, encoding, write=write, - allprop=allprop, propname=propname, max_resource_size=max_resource_size, sharing=sharing)) + allprop=allprop, propname=propname, max_resource_size=max_resource_size, share=share)) return multistatus @@ -82,7 +82,7 @@ def xml_propfind(base_prefix: str, path: str, def xml_propfind_response( base_prefix: str, path: str, item: types.CollectionOrItem, props: Sequence[str], user: str, encoding: str, max_resource_size: int, write: bool = False, - propname: bool = False, allprop: bool = False, sharing: Union[dict, None] = None) -> ET.Element: + propname: bool = False, allprop: bool = False, share: Union[dict, None] = None) -> ET.Element: """Build and return a PROPFIND response.""" if propname and allprop or (props and (propname or allprop)): raise ValueError("Only use one of props, propname and allprops") @@ -102,9 +102,9 @@ def xml_propfind_response( collection.path, item.href)) response = ET.Element(xmlutils.make_clark("D:response")) href = ET.Element(xmlutils.make_clark("D:href")) - if sharing: + if share: # backmap - uri = uri.replace(sharing['PathMapped'], sharing['PathOrToken']) + uri = uri.replace(share['PathMapped'], share['PathOrToken']) href.text = xmlutils.make_href(base_prefix, uri) response.append(href) @@ -183,9 +183,9 @@ def xml_propfind_response( is_collection and collection.is_principal): child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href(base_prefix, path) - if sharing: + if share: # backmap - child_element.text = child_element.text.replace(sharing['PathMapped'], sharing['PathOrToken']) + child_element.text = child_element.text.replace(share['PathMapped'], share['PathOrToken']) element.append(child_element) elif tag == xmlutils.make_clark("C:supported-calendar-component-set"): human_tag = xmlutils.make_human_tag(tag) @@ -221,9 +221,9 @@ def xml_propfind_response( child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href( base_prefix, "/%s/" % user) - if sharing: + if share: # backmap - child_element.text = child_element.text.replace(sharing['Owner'], sharing['User']) + child_element.text = child_element.text.replace(share['Owner'], share['User']) element.append(child_element) else: element.append(ET.Element( @@ -345,12 +345,12 @@ def xml_propfind_response( human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) if tag_text is not None: - if sharing: + if share: # map from overlay - if sharing['Properties']: - if human_tag in sharing['Properties']: - if sharing['Properties'][human_tag] is not None: - tag_text = sharing['Properties'][human_tag] + if share['Properties']: + if human_tag in share['Properties']: + if share['Properties'][human_tag] is not None: + tag_text = share['Properties'][human_tag] element.text = tag_text else: is404 = True @@ -426,15 +426,15 @@ class ApplicationPartPropfind(ApplicationBase): """Manage PROPFIND request.""" http_depth = environ.get("HTTP_DEPTH", "0") permissions_filter = None - sharing = None + share = None if self._sharing._enabled: # Sharing by token or map (if enabled) - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: + share = self._sharing.sharing_collection_resolver(path, user) + if share: # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] + path = share['PathMapped'] + user = share['Owner'] + permissions_filter = share['Permissions'] access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED @@ -467,13 +467,13 @@ class ApplicationPartPropfind(ApplicationBase): if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND: get shared collections") # check for shared collections - collections_shared_map = self._sharing.sharing_collection_map_list(user) - if collections_shared_map: - for sharing in collections_shared_map: - c_share = sharing['PathOrToken'] - c_path = sharing['PathMapped'] - c_user = sharing['Owner'] - c_permissions_filter = sharing['Permissions'] + collections_share_map = self._sharing.sharing_collection_map_list(user) + if collections_share_map: + for share in collections_share_map: + c_share = share['PathOrToken'] + c_path = share['PathMapped'] + c_user = share['Owner'] + c_permissions_filter = share['Permissions'] if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND: test shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s", c_share, c_path, c_user, c_permissions_filter) c_access = Access(self._rights, c_user, c_path, c_permissions_filter) @@ -490,7 +490,7 @@ class ApplicationPartPropfind(ApplicationBase): headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} xml_answer = xml_propfind(base_prefix, path, xml_content, - allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, sharing=sharing) + allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, share=share) if xml_answer is None: return httputils.NOT_ALLOWED return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 59d56c83..768a075f 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -37,7 +37,7 @@ from radicale.log import logger def xml_proppatch(base_prefix: str, path: str, xml_request: Optional[ET.Element], - collection: Union[storage.BaseCollection, None], sharing: Union[dict, None] = None, sharing_overlay: bool = False, _sharing: Union[sharing.BaseSharing, None] = None) -> ET.Element: + collection: Union[storage.BaseCollection, None], share: Union[dict, None] = None, share_overlay: bool = False, _sharing: Union[sharing.BaseSharing, None] = None) -> ET.Element: """Read and answer PROPPATCH requests. Read rfc4918-9.2 for info. @@ -48,9 +48,9 @@ def xml_proppatch(base_prefix: str, path: str, multistatus.append(response) href = ET.Element(xmlutils.make_clark("D:href")) href.text = xmlutils.make_href(base_prefix, path) - if sharing: + if share: # backmap - href.text = href.text.replace(sharing['PathMapped'], sharing['PathOrToken']) + href.text = href.text.replace(share['PathMapped'], share['PathOrToken']) response.append(href) # Create D:propstat element for props with status 200 OK propstat = ET.Element(xmlutils.make_clark("D:propstat")) @@ -62,27 +62,27 @@ def xml_proppatch(base_prefix: str, path: str, response.append(propstat) props_with_remove = xmlutils.props_from_request(xml_request) - if sharing and sharing_overlay: + if share and share_overlay: # PROPPATCH overlay adjustment - logger.debug("TRACE/PROPPATCH/xml_proppatch: sharing+sharing_overlay is active: %r", sharing) - if sharing['Properties'] is not None: - all_props_with_remove = cast(Dict[str, Optional[str]], radicale_item.check_and_sanitize_props(sharing['Properties'])) + logger.debug("TRACE/PROPPATCH/xml_proppatch: share+share_overlay is active: %r", share) + if share['Properties'] is not None: + all_props_with_remove = cast(Dict[str, Optional[str]], radicale_item.check_and_sanitize_props(share['Properties'])) else: all_props_with_remove = {} all_props_with_remove.update(props_with_remove) all_props = radicale_item.check_and_sanitize_props(all_props_with_remove) - logger.debug("TRACE/PROPPATCH/xml_proppatch: sharing+sharing_overlay result: %r", all_props) + logger.debug("TRACE/PROPPATCH/xml_proppatch: share+share_overlay result: %r", all_props) else: if collection is not None: # always the case, but makes mypy happy all_props_with_remove = cast(Dict[str, Optional[str]], dict(collection.get_meta())) all_props_with_remove.update(props_with_remove) all_props = radicale_item.check_and_sanitize_props(all_props_with_remove) - if sharing and sharing_overlay and _sharing is not None: + if share and share_overlay and _sharing is not None: # _sharing is not None: always the case, but makes mypy happy - _sharing.database_update_sharing(ShareType=sharing['ShareType'], - PathOrToken=sharing['PathOrToken'], - OwnerOrUser=sharing['User'], + _sharing.database_update_sharing(ShareType=share['ShareType'], + PathOrToken=share['PathOrToken'], + OwnerOrUser=share['User'], Properties=cast(Dict[str, str], all_props)) else: if collection is not None: @@ -100,21 +100,21 @@ class ApplicationPartProppatch(ApplicationBase): path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" permissions_filter = None - sharing = None - sharing_overlay = False + share = None + share_overlay = False path_orig = path if self._sharing._enabled: # Sharing by token or map (if enabled) - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: + share = self._sharing.sharing_collection_resolver(path, user) + if share: # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] + path = share['PathMapped'] + user = share['Owner'] + permissions_filter = share['Permissions'] access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): logger.debug("TRACE/PROPPATCH/xml_proppatch: no write-access: %r", path) - if sharing: + if share: # no write access -> use properties overlay if self._sharing.permit_properties_overlay: if permissions_filter is not None and "p" in permissions_filter: @@ -122,11 +122,11 @@ class ApplicationPartProppatch(ApplicationBase): return httputils.NOT_ALLOWED else: logger.info("PROPPATCH request on shared %r: no write-permissions, overlay permitted by option", path_orig) - sharing_overlay = True + share_overlay = True else: if permissions_filter is not None and "P" in permissions_filter: logger.info("PROPPATCH request on shared %r: no write-permissions, overlay denied, but granted by permission 'P'", path_orig) - sharing_overlay = True + share_overlay = True else: logger.info("PROPPATCH request on shared %r: no write-permissions and overlay denied by option", path_orig) return httputils.NOT_ALLOWED @@ -134,18 +134,18 @@ class ApplicationPartProppatch(ApplicationBase): return httputils.NOT_ALLOWED else: logger.debug("TRACE/PROPPATCH/xml_proppatch: write-access: %r", path) - if sharing: + if share: # write access -> check for enforced properties overlay logger.debug("TRACE/PROPPATCH/xml_proppatch: write-access/sharing: %r", path_orig) if self._sharing.enforce_properties_overlay: if permissions_filter is not None and "e" in permissions_filter: logger.info("PROPPATCH request on shared %r: write-permissions, overlay enforced, but disabled by permission 'e'", path_orig) else: - sharing_overlay = True + share_overlay = True else: if permissions_filter is not None and "E" in permissions_filter: logger.info("PROPPATCH request on shared %r: write-permissions, overlay not enforced, but enforced by permission 'E'", path_orig) - sharing_overlay = True + share_overlay = True try: xml_content = self._read_xml_request_body(environ) except RuntimeError as e: @@ -156,13 +156,13 @@ class ApplicationPartProppatch(ApplicationBase): logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT - if sharing_overlay: + if share_overlay: # call API function internally and no not trigger any hook headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} try: xml_answer = xml_proppatch(base_prefix, path, xml_content, - None, sharing, sharing_overlay, self._sharing) + None, share, share_overlay, self._sharing) if xml_content is not None: content = DefusedET.tostring( xml_content, @@ -199,7 +199,7 @@ class ApplicationPartProppatch(ApplicationBase): "Content-Type": "text/xml; charset=%s" % self._encoding} try: xml_answer = xml_proppatch(base_prefix, path, xml_content, - item, sharing) + item, share) if xml_content is not None: content = DefusedET.tostring( xml_content, diff --git a/radicale/app/put.py b/radicale/app/put.py index 4e62ec1d..b9b764c8 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -184,12 +184,12 @@ class ApplicationPartPut(ApplicationBase): permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: + share = self._sharing.sharing_collection_resolver(path, user) + if share: # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] + path = share['PathMapped'] + user = share['Owner'] + permissions_filter = share['Permissions'] access = Access(self._rights, user, path, permissions_filter) access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): diff --git a/radicale/app/report.py b/radicale/app/report.py index 35e9237e..b940fc92 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -151,7 +151,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection, encoding: str, unlock_storage_fn: Callable[[], None], max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "", - sharing: Union[dict, None] = None) -> Tuple[int, ET.Element]: + share: Union[dict, None] = None) -> Tuple[int, ET.Element]: """Read and answer REPORT requests that return XML. Read rfc3253-3.6 for info. @@ -360,7 +360,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], if found_props or not_found_props: multistatus.append(xml_item_response( base_prefix, uri, found_props=found_props, - not_found_props=not_found_props, found_item=True, sharing=sharing)) + not_found_props=not_found_props, found_item=True, share=share)) return client.MULTI_STATUS, multistatus @@ -711,13 +711,13 @@ def _find_overridden( def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), - found_item: bool = True, sharing: Union[dict, None] = None) -> ET.Element: + found_item: bool = True, share: Union[dict, None] = None) -> ET.Element: response = ET.Element(xmlutils.make_clark("D:response")) href_element = ET.Element(xmlutils.make_clark("D:href")) href_element.text = xmlutils.make_href(base_prefix, href) - if sharing: - href_element.text = href_element.text.replace(sharing['PathMapped'], sharing['PathOrToken']) + if share: + href_element.text = href_element.text.replace(share['PathMapped'], share['PathOrToken']) response.append(href_element) if found_item: @@ -820,15 +820,15 @@ class ApplicationPartReport(ApplicationBase): path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" permissions_filter = None - sharing = None + share = None if self._sharing._enabled: # Sharing by token or map (if enabled) - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: + share = self._sharing.sharing_collection_resolver(path, user) + if share: # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] + path = share['PathMapped'] + user = share['Owner'] + permissions_filter = share['Permissions'] access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED @@ -871,7 +871,7 @@ class ApplicationPartReport(ApplicationBase): try: status, xml_answer = xml_report( base_prefix, path, xml_content, collection, self._encoding, - lock_stack.close, max_occurrence, user, remote_host, remote_useragent, sharing=sharing) + lock_stack.close, max_occurrence, user, remote_host, remote_useragent, share=share) except ValueError as e: logger.warning( "Bad REPORT request on %r: %s", path, e, exc_info=True) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 41670242..303f3a79 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -2415,8 +2415,23 @@ class TestSharingApiSanity(BaseTest): "collection_by_token": "True"}, "logging": {"request_header_on_debug": "False", "response_content_on_debug": "False", + "rights_rule_doesnt_match_on_debug": "True", "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) + + rights_file_path = os.path.join(self.colpath, "rights") + with open(rights_file_path, "w") as f: + f.write("""\ +[default-collection] +user: .+ +collection: .+ +permissions: RrWw +[default] +user: .+ +collection: {user}(/.*)? +permissions: RrWw""") + self.configure({"rights": {"file": rights_file_path}}) + json_dict: dict path_user1 = "/user1/calendarCCu1.ics/" @@ -2440,6 +2455,9 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** test: %s", db_type) self.configure({"sharing": {"type": db_type}}) + # owner_only + self.configure({"rights": {"type": "owner_only"}}) + # create map logging.info("\n*** create map user1/owner1 -> ok") json_dict = {} @@ -2453,7 +2471,7 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.info("\n*** mkcalendar user1 for shared -> conflict") + logging.info("\n*** mkcalendar as user1 for user1/shared1 -> conflict") self.mkcalendar(path_user1_shared1, login="user1:user1pw", check=409) # create map @@ -2469,7 +2487,7 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.info("\n*** mkcol user2 for shared -> conflict") + logging.info("\n*** mkcol as user2 for user2/shared1 -> conflict") self.mkcalendar(path_user2_shared1, login="user2:user2pw", check=409) # create map @@ -2483,6 +2501,15 @@ class TestSharingApiSanity(BaseTest): json_dict['Hidden'] = False _, headers, answer = self._sharing_api_json("map", "create", check=409, login="owner1:owner1pw", json_dict=json_dict) + # from_file + self.configure({"rights": {"type": "from_file"}}) + + logging.info("\n*** mkcalendar as user1 for user2/shared1 with rights from file -> conflict") + self.mkcalendar(path_user2_shared1, login="user1:user1pw", check=409) + + logging.info("\n*** mkcol as user1 for user2/shared1 with rights from file -> conflict") + self.mkcol(path_user2_shared1, login="user1:user1pw", check=409) + def test_sharing_api_permissions_global(self) -> None: """sharing API usage tests related to global permissions.""" self.configure({"auth": {"type": "htpasswd", From 6070fe67dad7d007c5a104a6f0d20d56ce1af984 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 5 Mar 2026 21:31:32 +0100 Subject: [PATCH 03/15] sharing: cosmetics, check existence of collection before create --- SHARING.md | 7 +++---- radicale/sharing/__init__.py | 9 +++++++++ radicale/tests/test_sharing.py | 35 ++++++++++++++++++++++++---------- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/SHARING.md b/SHARING.md index 8200f1e6..42867ce6 100644 --- a/SHARING.md +++ b/SHARING.md @@ -3,7 +3,7 @@ Static collection sharing without permissions filter using soft-links (Unix-only) is supported since storage type `multifilesystem` was implemented, see (Wiki: Sharing Collections)[https://github.com/Kozea/Radicale/wiki/Sharing-Collections] -With 3.7.0 a major extension was implemented using internal mapping configuration stored in a database and a management API. +With 3.7.0 major extension was implemented using internal mapping configuration stored in a database and a management API. ## Sharing Implementation @@ -93,7 +93,6 @@ Permissions are filtered by provided `Permissions`. #### CxDav request "(MOVE)" - * Action * map source * map destination @@ -111,7 +110,6 @@ Permissions are filtered by provided `Permissions`. * `permissions_filter` by `Permissions` (of `to_path`) * `to_permissions_filter` by `Permissions` (of `to_path`) - ## Sharing Configuration Store Types of supported sharing configuration: @@ -181,7 +179,6 @@ In case share should be visible using PROPFIND * unhide map as owner (can be combined with "create") * unhide map as user (explicit required to avoid sudden visible share) - ### Sharing Access via Tokens (_>= 3.7.0_) @@ -190,6 +187,8 @@ Token-based sharing can be accessed after retrieving the token via Token-URI: `/.token/` +Note: requests to not enabled or not even defined tokens will resul tin _401 Not Authorized_ + #### Permission Control * `permit_create_token` diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 6390af0e..b6b5d8a4 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -685,6 +685,14 @@ class BaseSharing: logger.error(api_info + ": missing PathMapped") return httputils.bad_request("Missing PathMapped") + ## check whether collection exists + with self._storage.acquire_lock("r", user, path=PathMapped): + item = next(iter(self._storage.discover(PathMapped)), None) + if not item: + return httputils.NOT_FOUND + if not isinstance(item, storage.BaseCollection): + return httputils.METHOD_NOT_ALLOWED + if Permissions is None: if ShareType == "token": Permissions = self.default_permissions_create_token @@ -733,6 +741,7 @@ class BaseSharing: else: User = user + # v1: create uuid token with 2x 32 bytes = 256 bit token = "v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 303f3a79..b2111771 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -394,6 +394,9 @@ class TestSharingApiSanity(BaseTest): form_array: Sequence[str] json_dict: dict + path_base1 = "/owner/collection1.ics/" + path_base2 = "/owner/collection2.ics/" + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): logging.info("\n*** test: %s", db_type) self.configure({"sharing": {"type": db_type}}) @@ -406,8 +409,16 @@ class TestSharingApiSanity(BaseTest): json_dict = {} _, headers, answer = self._sharing_api_json("token", "create", 400, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** create token#1 (form->text)") - form_array = ["PathMapped=/owner/collection1/"] + logging.info("\n*** create token#1 without existing collection (form->text)") + form_array = ["PathMapped=" + path_base1] + _, headers, answer = self._sharing_api_form("token", "create", check=404, login="owner:ownerpw", form_array=form_array) + + logging.info("\n*** create collection*") + self.mkcalendar(path_base1, login="owner:ownerpw") + self.mkcalendar(path_base2, login="owner:ownerpw") + + logging.info("\n*** create token#1 with existing collection (form->text)") + form_array = ["PathMapped=" + path_base1] _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) assert "Status='success'" in answer assert "PathOrToken='" in answer @@ -420,7 +431,7 @@ class TestSharingApiSanity(BaseTest): assert False logging.info("\n*** create token#2 (json->text)") - json_dict = {'PathMapped': "/owner/collection2/"} + json_dict = {'PathMapped': path_base2} _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") assert "Status='success'" in answer assert "Token=" in answer @@ -437,14 +448,14 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status='success'" in answer assert "Lines=1" in answer - assert "/owner/collection1/" in answer + assert path_base1 in answer logging.info("\n*** lookup token#2 (json->text") json_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") assert "Status='success'" in answer assert "Lines=1" in answer - assert "/owner/collection2/" in answer + assert path_base2 in answer logging.info("\n*** lookup token#2 (json->json)") json_dict = {'PathOrToken': token2} @@ -452,15 +463,15 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['PathMapped'] == "/owner/collection2/" + assert answer_dict['Content'][0]['PathMapped'] == path_base2 logging.info("\n*** lookup tokens (form->text)") form_array = [] _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status='success'" in answer assert "Lines=2" in answer - assert "/owner/collection1/" in answer - assert "/owner/collection2/" in answer + assert path_base1 in answer + assert path_base2 in answer logging.info("\n*** lookup tokens (form->csv)") form_array = [] @@ -468,8 +479,8 @@ class TestSharingApiSanity(BaseTest): assert "Status='success'" not in answer assert "Lines=2" not in answer assert ";".join(sharing.DB_FIELDS_V1) in answer - assert "/owner/collection1/" in answer - assert "/owner/collection2/" in answer + assert path_base1 in answer + assert path_base2 in answer logging.info("\n*** delete token#1 (form->text)") form_array = ["PathOrToken=" + token1] @@ -561,6 +572,10 @@ class TestSharingApiSanity(BaseTest): assert answer_dict['Status'] == "not-found" assert answer_dict['Lines'] == 0 + logging.info("\n*** delete collection*") + self.delete(path_base1, login="owner:ownerpw") + self.delete(path_base2, login="owner:ownerpw") + def test_sharing_api_token_usage(self) -> None: """share-by-token API tests - real usage.""" self.configure({"auth": {"type": "htpasswd", From 46780a48b04aac7ec60dc8ccbe3fd0e6a75fd2fc Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 5 Mar 2026 22:22:46 +0100 Subject: [PATCH 04/15] sharing: fixes, doc update, alignment --- SHARING.md | 175 +++++++++++++++++++++++++-------- radicale/sharing/__init__.py | 17 ++-- radicale/tests/test_sharing.py | 5 + 3 files changed, 148 insertions(+), 49 deletions(-) diff --git a/SHARING.md b/SHARING.md index 42867ce6..757bb480 100644 --- a/SHARING.md +++ b/SHARING.md @@ -246,7 +246,10 @@ Can be selected by `HTTP_ACCEPT` Shows what kind of ShareTypes are supported -* Example: TEXT + * Output: text/plain|application/json + + * Examples + * form->text ``` curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info @@ -258,7 +261,7 @@ FeatureEnabledCollectionByToken=True PermittedCreateCollectionByToken=True ``` -* Example: JSON + * json->json, parsed with `jq` ``` curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq @@ -272,7 +275,6 @@ curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost: } ``` - ##### API Hook "(token|map)/create" * Authorization @@ -284,8 +286,10 @@ Authenticated user is `Owner` Create a share by mapping a collection of an `Owner` to a token. * Authorization - -Authenticated user as `Owner` has at least read access to `PathMapped` + * `PathMapped` is existing and a collection + * Authenticated user as `Owner` has at least read access to `PathMapped` + * Global permitted by `permit_create_token = True` or `rights` permission `t` + * Global denied by `permit_create_token = False` or `rights` permission `T` * Input @@ -298,19 +302,27 @@ Authenticated user as `Owner` has at least read access to `PathMapped` | Hidden | bool | optional(owner/default:True) | | Properties | str | optional | - * Output + * Output: text/plain|application/json | Parameter | Type | Value | | - | - | | PathOrToken | str | (autogenerated token) | -* Example: TEXT + * Examples: + * form->text ``` -curl -u user:pass -d "PathMapped=/user/testcalendar1/" http://localhost:5232/.sharing/v1/token/create +curl -u user:pass -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create ApiVersion=1 Status='success' PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' +``` + + * json->json + +``` +curl -u user:pass -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create +{"ApiVersion": 1, "Status": "success", "PathOrToken": "v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw="} ``` ###### API Hook "map/create" @@ -318,10 +330,11 @@ PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' Create a share by mapping a collection of an `Owner` to an `User`. * Authorization - -Authenticated user as `Owner` has at least read access to `PathMapped` - -Provided `User` has at least read access to `PathOrToken` + * `PathMapped` is existing and a collection + * Authenticated user as `Owner` has at least read access to `PathMapped` + * Provided `User` has at least read access to `PathOrToken` + * Global permitted by `permit_create_map = True` or `rights` permission `m` + * Global denied by `permit_create_map = False` or `rights` permission `M` * Input @@ -335,24 +348,30 @@ Provided `User` has at least read access to `PathOrToken` | Hidden | bool | optional(owner/default:True) | | Properties | optional | - * Output: result status + * Output: text/plain|application/json - * Example: TEXT + * Examples: + * form->text ``` -curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/cal1/" -d "User=user" http://localhost:5232/.sharing/v1/map/create +curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create ApiVersion=1 -Status=success +Status='success' ``` + * json->json + +``` +curl -u owner:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create +{"ApiVersion": 1, "Status": "success"} +``` ##### API Hook "(map|token|all)/list" List shares (optional with filter) either owned or assigned as user. * Authorization - -Authenticated user as `Owner` or `User` + * Authenticated user as `Owner` or `User` * Input @@ -361,14 +380,58 @@ Authenticated user as `Owner` or `User` | PathOrToken | str | optional | | PathMapped | str | optional | - * Output: plain/csv/json + * Output: text/plain|text/csv|application/json - * Example: CSV + * Examples + * form->csv ("map" only) ``` -curl -H "accept: text/csv" -u owner:pass -d "" http://localhost:5232/.sharing/v1/map/list -ShareType,PathOrToken,PathMapped,Owner,User,Permissions,EnabledByOwner,EnabledByUser,HiddenByOwner,HiddenByUser,TimestampCreated,TimestampUpdated,Properties -map,/user/cal1-from-owner/,/owner/cal1/,owner,user,r,False,False,True,True,1771962120,1771962120,{} +curl -H "accept: text/csv" -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list +ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties +map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;True;1772747277;1772747277; +``` + + * json->json ("all"), parsed with `jq` + +``` +curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhost:5232/.sharing/v1/all/list | jq +{ + "ApiVersion": 1, + "Lines": 2, + "Status": "success", + "Content": [ + { + "ShareType": "map", + "PathOrToken": "/user/cal1-from-owner/", + "PathMapped": "/owner/testcalendar1/", + "Owner": "owner", + "User": "user", + "Permissions": "r", + "EnabledByOwner": true, + "EnabledByUser": false, + "HiddenByOwner": false, + "HiddenByUser": true, + "TimestampCreated": 1772747277, + "TimestampUpdated": 1772747277, + "Properties": "" + }, + { + "ShareType": "token", + "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc=", + "PathMapped": "/user/testcalendar1/", + "Owner": "user", + "User": "user", + "Permissions": "r", + "EnabledByOwner": true, + "EnabledByUser": false, + "HiddenByOwner": false, + "HiddenByUser": true, + "TimestampCreated": 1772747371, + "TimestampUpdated": 1772747371, + "Properties": "" + } + 7] +} ``` @@ -377,8 +440,8 @@ map,/user/cal1-from-owner/,/owner/cal1/,owner,user,r,False,False,True,True,17719 Delete a share selected by `PathOrToken`. * Authorization - -Authenticated user is `Owner` + * Authenticated user is `Owner` + * Share is existing and owned * Input @@ -386,7 +449,23 @@ Authenticated user is `Owner` | - | - | - | - | - | | PathOrToken | str | selection | mandatory | not-permitted | - * Output: result status + * Output: text/plain|application/json + + * Examples: + * form->text + +``` +curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete +ApiVersion=1 +Status='success' +``` + + * json->json + +``` +curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete +{"ApiVersion": 1, "Status": "success"} +``` ##### API Hook "(token|map)/update" @@ -395,8 +474,7 @@ Update a share selected by `PathOrToken`. Execute delete+create in case `PathOrToken` needs to be changed. * Authorization - -Authenticated user is `Owner` or `User` + * Authenticated user is `Owner` or `User` * Input @@ -410,15 +488,29 @@ Authenticated user is `Owner` or `User` | Hidden | bool | adjust | optional(owner) | optional(user) | | Properties | str | adjust | optional | optional | - * Output: result status + * Output: text/plain|application/json + + * form->text +``` +curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update +ApiVersion=1 +Status='success' +``` + + * json->json + +``` +curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update +{"ApiVersion": 1, "Status": "success"} +``` ##### API Hooks "(map|token)/(enable|disable|hide|unhide)" Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `PathOrToken` * Authorization - -Authenticated user is `Owner` or `User` + * Authenticated user is `Owner` or `User` + * `PathOrToken` is existing and either owned or assigned to user * Input @@ -426,22 +518,21 @@ Authenticated user is `Owner` or `User` | - | - | - | - | - | | PathOrToken | selection | mandatory | mandatory | - * Output: result status + * Output: text/plain|application/json - * Example: TEXT (enable) - -``` -curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/cal1/" -d "User=user" http://localhost:5232/.sharing/v1/map/enable -ApiVersion=1 -Status=success -``` + * form->text - * Example: JSON (unhide) - ``` -curl -u owner:pass -d '{"PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/cal1/", "User": "user"} http://localhost:5232/.sharing/v1/map/unhide +curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable ApiVersion=1 Status='success' +``` + + * json->json + +``` +curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide +{"ApiVersion": 1, "Status": "success"} ``` ## Properties Overlay diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index b6b5d8a4..31f01daf 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -290,7 +290,7 @@ class BaseSharing: return None # list sharings of type "map" - def sharing_collection_map_list(self, user: str, active: bool = True) -> list[dict]: + def sharing_collection_map_list(self, user: Union[str, None], active: bool = True) -> list[dict]: """ returning dict with shared collections (active==True: enabled and unhidden) or None if not found""" if not self.sharing_collection_by_map: if logger.isEnabledFor(logging.DEBUG): @@ -488,6 +488,7 @@ class BaseSharing: # parse body according to content-type content_type = environ.get("CONTENT_TYPE", "") if 'application/json' in content_type: + output_format = "json" # default try: request_data = json.loads(request_body) except json.JSONDecodeError: @@ -501,6 +502,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + " (json): %r", f"{request_data}") elif 'application/x-www-form-urlencoded' in content_type: + output_format = "txt" # default request_parsed = parse_qs(request_body) # convert arrays into single value request_data = {} @@ -539,8 +541,11 @@ class BaseSharing: output_format = "json" elif 'text/csv' in accept: output_format = "csv" - else: + elif 'text/plain' in accept: output_format = "txt" + else: + # default from input type + pass if output_format == "csv": if not action == "list": @@ -555,7 +560,6 @@ class BaseSharing: # parameters default PathOrToken: Union[str, None] = None PathMapped: Union[str, None] = None - Owner: str = user User: Union[str, None] = None Permissions: Union[str, None] = None # no permissions by default Enabled: Union[bool, None] = None @@ -685,7 +689,7 @@ class BaseSharing: logger.error(api_info + ": missing PathMapped") return httputils.bad_request("Missing PathMapped") - ## check whether collection exists + # check whether collection exists with self._storage.acquire_lock("r", user, path=PathMapped): item = next(iter(self._storage.discover(PathMapped)), None) if not item: @@ -741,7 +745,6 @@ class BaseSharing: else: User = user - # v1: create uuid token with 2x 32 bytes = 256 bit token = "v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') @@ -784,9 +787,9 @@ class BaseSharing: User = str(User) # check access Permissions - access = Access(self._rights, Owner, PathMapped, None) # PathMapped is mandatory + access = Access(self._rights, user, PathMapped, None) # PathMapped is mandatory if not access.check("r") and "i" not in access.permissions: - logger.info("Add sharing-by-map: access to path(mapped) %r not allowed for owner %r", PathMapped, Owner) + logger.info("Add sharing-by-map: access to path(mapped) %r not allowed for owner %r", PathMapped, user) return httputils.NOT_ALLOWED if self.permit_create_map is False: diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index b2111771..8fb19fb3 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -306,6 +306,9 @@ class TestSharingApiSanity(BaseTest): form_array: Sequence[str] json_dict: dict + self.mkcalendar("/owner/collectionL1/", login="owner:ownerpw") + self.mkcalendar("/owner/collectionL2/", login="owner:ownerpw") + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): logging.info("\n*** test: %s", db_type) self.configure({"sharing": {"type": db_type}}) @@ -605,6 +608,8 @@ class TestSharingApiSanity(BaseTest): path = path_base + "/event1.ics" self.put(path, event, login="owner:ownerpw") + self.mkcalendar(path_base2, login="owner:ownerpw") + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): logging.info("\n*** test: %s", db_type) self.configure({"sharing": {"type": db_type}}) From 6a53b409d7e59dd7d695a35a4b1a8035f553b920 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 6 Mar 2026 05:55:58 +0100 Subject: [PATCH 05/15] sharing: text/plain provides now also Fields in output, doc update --- SHARING.md | 72 ++++++++++++++++++---------------- radicale/sharing/__init__.py | 7 +++- radicale/tests/test_sharing.py | 12 ++++++ 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/SHARING.md b/SHARING.md index 757bb480..6e5dbde3 100644 --- a/SHARING.md +++ b/SHARING.md @@ -3,7 +3,7 @@ Static collection sharing without permissions filter using soft-links (Unix-only) is supported since storage type `multifilesystem` was implemented, see (Wiki: Sharing Collections)[https://github.com/Kozea/Radicale/wiki/Sharing-Collections] -With 3.7.0 major extension was implemented using internal mapping configuration stored in a database and a management API. +With _3.7.0_ major extension was implemented using internal mapping configuration stored in a database and a management API. ## Sharing Implementation @@ -143,24 +143,18 @@ Types of supported sharing configuration: #### CSV -(_>= 3.7.0_) - One CSV file containing one row per sharing config, separated by `;` and containing header with columns from above. If given, properties are stored in JSON format in CSV. #### Files -(_>= 3.7.0_) - File-based configuration store is using encoded `PathOrToken` as filename for each config. File contains the data stored as "dict" in binary Python "pickle" format (same is also used for item cache files). ## Sharing Access ### Sharing Access via Maps -(_>= 3.7.0_) - Map-based sharing can be accessed as usual after authentication and authorization. #### Permission Control @@ -181,8 +175,6 @@ In case share should be visible using PROPFIND ### Sharing Access via Tokens -(_>= 3.7.0_) - Token-based sharing can be accessed after retrieving the token via Token-URI: `/.token/` @@ -204,8 +196,6 @@ Note: requests to not enabled or not even defined tokens will resul tin _401 Not ### Sharing Configuration Management API version 1 -(_>= 3.7.0_) - Type: POST API Base-URI: `/.sharing/v1//` @@ -218,16 +208,16 @@ See also test cases in `radicale/tests/test_sharing.py` Parsing be controlled by `CONTENT_TYPE` - * application/x-www-form-urlencoded (_>= 3.7.0_) - * application/json (_>= 3.7.0_) + * application/x-www-form-urlencoded + * application/json ##### Output Data Format -Can be selected by `HTTP_ACCEPT` +Can be selected by `HTTP_ACCEPT` - default is equal to provided `CONTENT_TYPE` - * text/plain (_>= 3.7.0_) - * text/csv (_>= 3.7.0_) - only for "list" - * application/json (_>= 3.7.0_) + * text/plain + * text/csv (only for "list") + * application/json ##### Accepted Input Data Fields @@ -264,6 +254,7 @@ PermittedCreateCollectionByToken=True * json->json, parsed with `jq` ``` +bash curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq { "ApiVersion": 1, @@ -311,7 +302,7 @@ Create a share by mapping a collection of an `Owner` to a token. * Examples: * form->text -``` +```bash curl -u user:pass -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create ApiVersion=1 Status='success' @@ -320,7 +311,7 @@ PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' * json->json -``` +```bash curl -u user:pass -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create {"ApiVersion": 1, "Status": "success", "PathOrToken": "v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw="} ``` @@ -353,7 +344,7 @@ Create a share by mapping a collection of an `Owner` to an `User`. * Examples: * form->text -``` +```bash curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create ApiVersion=1 Status='success' @@ -361,7 +352,7 @@ Status='success' * json->json -``` +```bash curl -u owner:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create {"ApiVersion": 1, "Status": "success"} ``` @@ -383,9 +374,21 @@ List shares (optional with filter) either owned or assigned as user. * Output: text/plain|text/csv|application/json * Examples + + * form->text ("all") + +```bash +curl -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list +ApiVersion=1 +Lines=1 +Status='success' +Fields="ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties" +Content[0]="map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;True;False;False;1772748001;1772748163; +``` + * form->csv ("map" only) -``` +```bash curl -H "accept: text/csv" -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;True;1772747277;1772747277; @@ -393,7 +396,7 @@ map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;T * json->json ("all"), parsed with `jq` -``` +```bash curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhost:5232/.sharing/v1/all/list | jq { "ApiVersion": 1, @@ -454,7 +457,7 @@ Delete a share selected by `PathOrToken`. * Examples: * form->text -``` +```bash curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete ApiVersion=1 Status='success' @@ -462,7 +465,7 @@ Status='success' * json->json -``` +```bash curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete {"ApiVersion": 1, "Status": "success"} ``` @@ -491,7 +494,8 @@ Execute delete+create in case `PathOrToken` needs to be changed. * Output: text/plain|application/json * form->text -``` + +```bash curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update ApiVersion=1 Status='success' @@ -499,7 +503,7 @@ Status='success' * json->json -``` +```bash curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update {"ApiVersion": 1, "Status": "success"} ``` @@ -522,15 +526,15 @@ Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `P * form->text -``` +```bash curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable ApiVersion=1 Status='success' -``` +```bash * json->json -``` +```bash curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide {"ApiVersion": 1, "Status": "success"} ``` @@ -541,10 +545,10 @@ Owner or user can define per share a set of properties to overlay on PROPFIND re Whitelisted ones are defined in `OVERLAY_PROPERTIES_WHITELIST` in `radicale/sharing/__init__.py`: - * `C:calendar-description` (_>= 3.7.0_) - * `ICAL:calendar-color` (_>= 3.7.0_) - * `CR:addressbook-description` (_>= 3.7.0_) - * `INF:addressbook-color` (_>= 3.7.0_) + * `C:calendar-description` + * `ICAL:calendar-color` + * `CR:addressbook-description` + * `INF:addressbook-color` ### Properties Overlay Control Options diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 31f01daf..dfabeab9 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -1091,6 +1091,8 @@ class BaseSharing: writer = DictWriter(csv, fieldnames=DB_FIELDS_V1, delimiter=';') if output_format == "csv": writer.writeheader() + elif output_format == "txt": + writer.writeheader() for entry in answer['Content']: # TODO: Argument 1 to "writerow" of "DictWriter" has incompatible type "str"; expected "Mapping[str, Any]" [arg-type] writer.writerow(entry) # type: ignore[arg-type] @@ -1100,7 +1102,10 @@ class BaseSharing: index = 0 for line in csv.getvalue().splitlines(): # create a shell array with content lines - answer_array.append('Content[' + str(index) + ']="' + line.replace('"', '\\"') + '"') + if index == 0: + answer_array.append('Fields="' + line + '"') + else: + answer_array.append('Content[' + str(index - 1) + ']="' + line.replace('"', '\\"') + '"') index += 1 headers = { "Content-Type": "text/csv" diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 8fb19fb3..6dbf2d56 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -362,9 +362,21 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** list/all (form->csv)") form_array = [] + _, headers, answer = self._sharing_api_form("all", "list", check=200, login="owner:ownerpw", form_array=form_array, accept="text/csv") + assert "Status=" not in answer + assert "Line=" not in answer + assert "ShareType" in answer + assert "token" in answer + assert "map" in answer + + logging.info("\n*** list/all (form->text)") + form_array = [] _, headers, answer = self._sharing_api_form("all", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status='success'" in answer assert "Lines=2" in answer + assert "Fields=" in answer + assert "Content[0]=" in answer + assert "Content[1]=" in answer logging.info("\n*** delete token -> 200") form_array = ["PathOrToken=" + token] From 6c7e9c5ef5c63ae29b393cf8fa9654d0ff36e56c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 6 Mar 2026 06:08:44 +0100 Subject: [PATCH 06/15] sharing: map_list code improvement --- radicale/app/mkcalendar.py | 4 ++-- radicale/app/mkcol.py | 4 ++-- radicale/app/propfind.py | 4 ++-- radicale/sharing/__init__.py | 29 +++++++++++------------------ radicale/sharing/csv.py | 2 +- radicale/sharing/files.py | 2 +- 6 files changed, 19 insertions(+), 26 deletions(-) diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index b2825075..02a821e1 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -55,8 +55,8 @@ class ApplicationPartMkcalendar(ApplicationBase): "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST if self._sharing._enabled: - # check for shared collections (all users / active or inactive) - collections_share_map = self._sharing.sharing_collection_map_list(user=None, active=False) + # check for shared collections (all) + collections_share_map = self._sharing.sharing_collection_map_list() if collections_share_map: for share in collections_share_map: if share['PathOrToken'] == path: diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index d3bef110..66b0fd15 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -62,8 +62,8 @@ class ApplicationPartMkcol(ApplicationBase): logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'") return httputils.NOT_ALLOWED if self._sharing._enabled: - # check for shared collections (all users, active or inactive) - collections_share_map = self._sharing.sharing_collection_map_list(user=None, active=False) + # check for shared collections (all) + collections_share_map = self._sharing.sharing_collection_map_list() if collections_share_map: for share in collections_share_map: if share['PathOrToken'] == path: diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 16db8f3c..da6106a4 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -466,8 +466,8 @@ class ApplicationPartPropfind(ApplicationBase): if http_depth == "1": if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND: get shared collections") - # check for shared collections - collections_share_map = self._sharing.sharing_collection_map_list(user) + # check for shared collections related to user, Enabled and not Hidden + collections_share_map = self._sharing.sharing_collection_map_list(User=user, Enabled=True, Hidden=False) if collections_share_map: for share in collections_share_map: c_share = share['PathOrToken'] diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index dfabeab9..322bf46d 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -290,29 +290,22 @@ class BaseSharing: return None # list sharings of type "map" - def sharing_collection_map_list(self, user: Union[str, None], active: bool = True) -> list[dict]: - """ returning dict with shared collections (active==True: enabled and unhidden) or None if not found""" + def sharing_collection_map_list(self, User: Union[str, None] = None, Enabled: Union[bool, None] = None, Hidden: Union[bool, None] = None) -> list[dict]: + """ returning dict with shared collections by filter(User/Enabled/Hidden) or None if not found""" if not self.sharing_collection_by_map: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not active") return [{}] - # retrieve collections which are enabled and not hidden by owner+user - if active: - shared_collection_list = self.database_list_sharing( - ShareType="map", - OwnerOrUser=user, - User=user, - EnabledByOwner=True, - EnabledByUser=True, - HiddenByOwner=False, - HiddenByUser=False) - else: - # unconditional - shared_collection_list = self.database_list_sharing( - ShareType="map", - OwnerOrUser=user, - User=user) + # retrieve collections depending on filter + shared_collection_list = self.database_list_sharing( + ShareType="map", + OwnerOrUser=User, + User=User, + EnabledByOwner=Enabled, + EnabledByUser=Enabled, + HiddenByOwner=Hidden, + HiddenByUser=Hidden) # final return shared_collection_list diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index cf761923..5dfc45a4 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -159,7 +159,7 @@ class Sharing(sharing.BaseSharing): result = [] if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, HiddenByOwner, HiddenByUser) + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) for row in self._sharing_cache: if index == 0: diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index 3a79730f..fba8bdd6 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -155,7 +155,7 @@ class Sharing(sharing.BaseSharing): result = [] if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, HiddenByOwner, HiddenByUser) + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) for _ShareType in sharing.SHARE_TYPES_V1: if ShareType is not None and _ShareType != ShareType: From 5233cc3f1362a3e60a7929741ded55952fecab0c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 6 Mar 2026 06:26:08 +0100 Subject: [PATCH 07/15] cosmetics: cosmetics, abstract a function --- radicale/app/proppatch.py | 8 ++--- radicale/sharing/__init__.py | 62 +++++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 768a075f..4c07edc7 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -80,10 +80,10 @@ def xml_proppatch(base_prefix: str, path: str, all_props = radicale_item.check_and_sanitize_props(all_props_with_remove) if share and share_overlay and _sharing is not None: # _sharing is not None: always the case, but makes mypy happy - _sharing.database_update_sharing(ShareType=share['ShareType'], - PathOrToken=share['PathOrToken'], - OwnerOrUser=share['User'], - Properties=cast(Dict[str, str], all_props)) + _sharing.sharing_collection_update(ShareType=share['ShareType'], + PathOrToken=share['PathOrToken'], + OwnerOrUser=share['User'], + Properties=cast(Dict[str, str], all_props)) else: if collection is not None: # always the case, but makes mypy happy diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 322bf46d..9d476476 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -162,7 +162,7 @@ class BaseSharing: else: logger.info("sharing database info: (not provided)") - # overloadable database functions + # *** overloadable database functions *** def database_init(self) -> bool: """ initialize db """ return False @@ -230,7 +230,7 @@ class BaseSharing: """ delete sharing """ return {"status": "not-implemented"} - # sharing functions called by request methods + # *** functions called by cli *** def verify(self) -> bool: """ verify database """ logger.info("sharing database verification begin") @@ -263,6 +263,29 @@ class BaseSharing: logger.info("sharing database verification content successful") return True + # *** sharing functions called by request methods *** + # list sharings of type "map" + def sharing_collection_map_list(self, User: Union[str, None] = None, Enabled: Union[bool, None] = None, Hidden: Union[bool, None] = None) -> list[dict]: + """ returning dict with shared collections by filter(User/Enabled/Hidden) or None if not found""" + if not self.sharing_collection_by_map: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map: not active") + return [{}] + + # retrieve collections depending on filter + shared_collection_list = self.database_list_sharing( + ShareType="map", + OwnerOrUser=User, + User=User, + EnabledByOwner=Enabled, + EnabledByUser=Enabled, + HiddenByOwner=Hidden, + HiddenByUser=Hidden) + + # final + return shared_collection_list + + # resolves a path to a share def sharing_collection_resolver(self, path: str, user: str) -> Union[dict, None]: """ returning dict with PathMapped, Owner, Permissions or None if not found""" if self.sharing_collection_by_token: @@ -286,31 +309,19 @@ class BaseSharing: logger.debug("TRACE/sharing/map: not active") return None - # final return None - # list sharings of type "map" - def sharing_collection_map_list(self, User: Union[str, None] = None, Enabled: Union[bool, None] = None, Hidden: Union[bool, None] = None) -> list[dict]: - """ returning dict with shared collections by filter(User/Enabled/Hidden) or None if not found""" - if not self.sharing_collection_by_map: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/map: not active") - return [{}] + # adjust a share + def sharing_collection_update(self, ShareType: str, PathOrToken: str, OwnerOrUser: str, Properties: dict) -> None: + """ returning dict with PathMapped, Owner, Permissions or None if not found""" + logger.info("Sharing/collection/update: ShareType=%r PathOrToken=%r OwnerOrUser=%r", ShareType, PathOrToken, OwnerOrUser) + self.database_update_sharing(ShareType=ShareType, + PathOrToken=PathOrToken, + OwnerOrUser=OwnerOrUser, + Properties=Properties) - # retrieve collections depending on filter - shared_collection_list = self.database_list_sharing( - ShareType="map", - OwnerOrUser=User, - User=User, - EnabledByOwner=Enabled, - EnabledByUser=Enabled, - HiddenByOwner=Hidden, - HiddenByUser=Hidden) - - # final - return shared_collection_list - - # internal sharing functions + # *** internal sharing functions *** + # resolves a token "path" to a share def sharing_collection_by_token_resolver(self, path) -> Union[dict, None]: """ returning dict with PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_token: @@ -339,6 +350,7 @@ class BaseSharing: logger.debug("TRACE/sharing/token: not active") return None + # resolves a map "path" to a share def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict, None]: """ returning dict with PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_map: @@ -373,7 +385,7 @@ class BaseSharing: logger.debug("TRACE/sharing/map: not active") return None - # POST API + # *** POST API *** def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: # Late import to avoid circular dependency in config from radicale.app.base import Access From 7878e612fbbf5ce8196692bb0efe197ba71f35d7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 6 Mar 2026 07:02:00 +0100 Subject: [PATCH 08/15] sharing: text/info fix and log cosmetics --- radicale/sharing/__init__.py | 55 +++++++++++++++++++++------------- radicale/tests/test_sharing.py | 11 ++++++- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 9d476476..b3b2fc51 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -57,8 +57,6 @@ SHARE_TYPES_V1: Sequence[str] = ('token', 'map') # map : share by mapping collection of one user to another as virtual # all : only supported for "list" and "info" -OUTPUT_TYPES: Sequence[str] = ('csv', 'json', 'txt') - API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable', 'info') # list : list sharings (optional filtered) # create : create share by token or map @@ -76,6 +74,10 @@ API_TYPES_V1: dict[str, type] = { "ApiVersion": int, "Status": str, "Lines": int, + "FeatureEnabledCollectionByMap": bool, + "FeatureEnabledCollectionByToken": bool, + "PermittedCreateCollectionByMap": bool, + "PermittedCreateCollectionByToken": bool, "ShareType": str, "PathOrToken": str, "PathMapped:": str, @@ -488,11 +490,13 @@ class BaseSharing: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT - api_info = "sharing/API/POST/" + ShareType + "/" + action + # initial log prefix + api_info = "Sharing/API/POST/" + ShareType + "/" + action # parse body according to content-type content_type = environ.get("CONTENT_TYPE", "") if 'application/json' in content_type: + input_format = "json" output_format = "json" # default try: request_data = json.loads(request_body) @@ -507,7 +511,8 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + " (json): %r", f"{request_data}") elif 'application/x-www-form-urlencoded' in content_type: - output_format = "txt" # default + input_format = "form" + output_format = "text" # default request_parsed = parse_qs(request_body) # convert arrays into single value request_data = {} @@ -547,7 +552,7 @@ class BaseSharing: elif 'text/csv' in accept: output_format = "csv" elif 'text/plain' in accept: - output_format = "txt" + output_format = "text" else: # default from input type pass @@ -557,11 +562,14 @@ class BaseSharing: return httputils.bad_request("CSV output format is only allowed for list action") elif output_format == "json": pass - elif output_format == "txt": + elif output_format == "text": pass else: return httputils.bad_request("Output format not supported") + # extend log prefix + api_info = api_info + "(" + input_format + "->" + output_format + ")" + # parameters default PathOrToken: Union[str, None] = None PathMapped: Union[str, None] = None @@ -685,6 +693,8 @@ class BaseSharing: answer['Status'] = "success" answer['Content'] = result_array + logger.info(api_info + ": " + answer['Status']) + # action: create elif action == "create": if logger.isEnabledFor(logging.DEBUG): @@ -732,16 +742,16 @@ class BaseSharing: # check access Permissions access = Access(self._rights, user, PathMapped) if not access.check("r"): - logger.info("Add sharing-by-token: access to %r not allowed for user %r", PathMapped, user) + logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) return httputils.NOT_ALLOWED if self.permit_create_token is False: if "t" not in access.permissions: - logger.info("Add sharing-by-token: access to %r not allowed for user %r (permit=False but explict grant misses 't')", PathMapped, user) + logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explict grant misses 't')", PathMapped, user) return httputils.NOT_ALLOWED else: if "T" in access.permissions: - logger.info("Add sharing-by-token: access to %r not allowed for user %r (permit=True but denied by 'T')", PathMapped, user) + logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'T')", PathMapped, user) return httputils.NOT_ALLOWED if User is not None: @@ -783,7 +793,7 @@ class BaseSharing: # retrieve existing share share = self.database_get_sharing(ShareType=ShareType, PathOrToken=PathOrToken, OnlyEnabled=False) if share is not None: - logger.error("Sharing/create/%s: already exists: %r", ShareType, PathOrToken) + logger.error(api_info + ": share already exists PathOrToken=%r", PathOrToken) return httputils.CONFLICT if User is None: @@ -794,34 +804,34 @@ class BaseSharing: # check access Permissions access = Access(self._rights, user, PathMapped, None) # PathMapped is mandatory if not access.check("r") and "i" not in access.permissions: - logger.info("Add sharing-by-map: access to path(mapped) %r not allowed for owner %r", PathMapped, user) + logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) return httputils.NOT_ALLOWED if self.permit_create_map is False: if "m" not in access.permissions: - logger.info("Add sharing-by-map: access to %r not allowed for user %r (permit=False but explicit grant misses 'm')", PathMapped, user) + logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user) return httputils.NOT_ALLOWED else: if "M" in access.permissions: - logger.info("Add sharing-by-map: access to %r not allowed for user %r (permit=True but denied by 'M')", PathMapped, user) + logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) return httputils.NOT_ALLOWED access = Access(self._rights, User, PathOrToken) if not access.check("r"): - logger.info("Add sharing-by-map: access to path %r not allowed for user %r", PathOrToken, User) + logger.error(api_info + ": access to PathOrToken=%r not allowed for User=%r", PathOrToken, User) return httputils.NOT_ALLOWED # check whether share is already existing as real collection - with self._storage.acquire_lock("r", user, path=PathOrToken): + with self._storage.acquire_lock("r", User, path=PathOrToken): item = next(iter(self._storage.discover(PathOrToken)), None) if not item: pass else: - logger.info("Add sharing-by-map: path %r already exists as real collection for user %r", PathOrToken, user) + logger.error(api_info + ": PathOrToken=%r already exists as real collection for User=%r", PathOrToken, User) return httputils.CONFLICT if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r user=%r)", PathMapped, Permissions, PathOrToken, User) + logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r Owner=%r User=%r)", PathMapped, Permissions, PathOrToken, user, User) result = self.database_create_sharing( ShareType=ShareType, @@ -853,9 +863,11 @@ class BaseSharing: return httputils.bad_request("Internal failure") if ShareType == "token": - logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token) + PathOrToken = token answer['PathOrToken'] = token + logger.info(api_info + " success: PathMapped=%r Permissions=%r PathOrToken=%r", PathMapped, Permissions, token) + # action: update elif action == "update": if logger.isEnabledFor(logging.DEBUG): @@ -991,6 +1003,7 @@ class BaseSharing: # action: info elif action == "info": + logger.info(api_info + ": success") answer['Status'] = "success" if ShareType in ["all", "map"]: answer['FeatureEnabledCollectionByMap'] = self.sharing_collection_by_map @@ -1082,9 +1095,9 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/API/POST output format: %r", output_format) logger.debug("TRACE/sharing/API/POST answer: %r", answer) - if output_format == "csv" or output_format == "txt": + if output_format == "csv" or output_format == "text": answer_array = [] - if output_format == "txt": + if output_format == "text": for key in answer: if key != 'Content': if API_TYPES_V1[key] is bool or API_TYPES_V1[key] is int: @@ -1096,7 +1109,7 @@ class BaseSharing: writer = DictWriter(csv, fieldnames=DB_FIELDS_V1, delimiter=';') if output_format == "csv": writer.writeheader() - elif output_format == "txt": + elif output_format == "text": writer.writeheader() for entry in answer['Content']: # TODO: Argument 1 to "writerow" of "DictWriter" has incompatible type "str"; expected "Mapping[str, Any]" [arg-type] diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 6dbf2d56..be18f3a0 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -231,7 +231,16 @@ class TestSharingApiSanity(BaseTest): _, headers, _ = self.request("POST", path, check=404, login="owner:ownerpw") # check info hook - logging.info("\n*** check API hook: info/all") + logging.info("\n*** check API hook: info/all (text)") + form_array = [] + _, headers, answer = self._sharing_api_form("all", "info", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status='success'" in answer + assert "PermittedCreateCollectionByMap=False" in answer + assert "PermittedCreateCollectionByToken=False" in answer + assert "FeatureEnabledCollectionByMap=True" in answer + assert "FeatureEnabledCollectionByToken=False" in answer + + logging.info("\n*** check API hook: info/all (json)") json_dict = {} _, headers, answer = self._sharing_api_json("all", "info", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) From 6c1af39dff84ef3795738a20e29018d0937c59d7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Mar 2026 05:53:01 +0100 Subject: [PATCH 09/15] sharing: review logging, doc/test cosmetics/fixes --- SHARING.md | 11 ++-- radicale/sharing/__init__.py | 97 ++++++++++++++++------------------ radicale/tests/test_sharing.py | 1 + 3 files changed, 54 insertions(+), 55 deletions(-) diff --git a/SHARING.md b/SHARING.md index 6e5dbde3..7ece76f5 100644 --- a/SHARING.md +++ b/SHARING.md @@ -239,9 +239,10 @@ Shows what kind of ShareTypes are supported * Output: text/plain|application/json * Examples + * form->text -``` +```bash curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info ApiVersion=1 Status='success' @@ -269,8 +270,7 @@ curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost: ##### API Hook "(token|map)/create" * Authorization - -Authenticated user is `Owner` + * Authenticated user is `Owner` ###### API Hook "token/create" @@ -455,6 +455,7 @@ Delete a share selected by `PathOrToken`. * Output: text/plain|application/json * Examples: + * form->text ```bash @@ -493,7 +494,9 @@ Execute delete+create in case `PathOrToken` needs to be changed. * Output: text/plain|application/json - * form->text + * Examples: + + * form->text ```bash curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index b3b2fc51..13107b39 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -506,13 +506,13 @@ class BaseSharing: # convert JSON boolean if key in request_data: if type(request_data[key]) is not bool: - logger.error(api_info + ": unsupported (non-boolean) " + key + ": " + request_data[key]) + logger.warning(api_info + ": unsupported (non-boolean) " + key + ": " + request_data[key]) return httputils.bad_request("Invalid non-boolean value for " + key + ": " + request_data[key]) if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + " (json): %r", f"{request_data}") elif 'application/x-www-form-urlencoded' in content_type: input_format = "form" - output_format = "text" # default + output_format = "plain" # default request_parsed = parse_qs(request_body) # convert arrays into single value request_data = {} @@ -534,7 +534,7 @@ class BaseSharing: try: request_data[key] = config._convert_to_bool(request_parsed[key][0]) except ValueError: - logger.error(api_info + ": unsupported (non-boolean) " + key + ": " + request_parsed[key][0]) + logger.warning(api_info + ": unsupported (non-boolean) " + key + ": " + request_parsed[key][0]) return httputils.bad_request("Invalid non-boolean value for " + key + ": " + request_parsed[key][0]) else: request_data[key] = request_parsed[key][0] @@ -552,7 +552,7 @@ class BaseSharing: elif 'text/csv' in accept: output_format = "csv" elif 'text/plain' in accept: - output_format = "text" + output_format = "plain" else: # default from input type pass @@ -562,7 +562,7 @@ class BaseSharing: return httputils.bad_request("CSV output format is only allowed for list action") elif output_format == "json": pass - elif output_format == "text": + elif output_format == "plain": pass else: return httputils.bad_request("Output format not supported") @@ -588,23 +588,23 @@ class BaseSharing: elif key == "PathOrToken": if ShareType == "token": if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]): - logger.error(api_info + ": unsupported " + key) + logger.warning(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for PathOrToken") elif ShareType == "map": if not re.search('^' + PATH_PATTERN + '$', request_data[key]): - logger.error(api_info + ": unsupported " + key) + logger.warning(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for PathOrToken") elif not request_data[key].endswith("/"): return httputils.bad_request("PathOrToken not ending with /") elif key == "PathMapped": if not re.search('^' + PATH_PATTERN + '$', request_data[key]): - logger.error(api_info + ": unsupported " + key) + logger.warning(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for PathMapped") elif not request_data[key].endswith("/"): return httputils.bad_request("PathMapped not ending with /") elif key == "User": if not re.search('^' + USER_PATTERN + '$', request_data[key]): - logger.error(api_info + ": unsupported " + key) + logger.warning(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for User") # check for optional parameters @@ -617,7 +617,7 @@ class BaseSharing: # ignored pass elif action not in ['list', 'create']: - logger.error(api_info + ": missing PathOrToken") + logger.warning(api_info + ": missing PathOrToken") return httputils.bad_request("Missing PathOrToken") else: # PathOrToken is optional @@ -625,7 +625,7 @@ class BaseSharing: else: if action == "create" and ShareType == "token": # not supported - logger.error(api_info + ": PathOrToken found but not supported") + logger.warning(api_info + ": PathOrToken found but not supported") return httputils.bad_request("PathOrToken not supported") PathOrToken = request_data['PathOrToken'] @@ -701,7 +701,7 @@ class BaseSharing: logger.debug("TRACE/" + api_info + ": start") if PathMapped is None: - logger.error(api_info + ": missing PathMapped") + logger.warning(api_info + ": missing PathMapped") return httputils.bad_request("Missing PathMapped") # check whether collection exists @@ -742,16 +742,16 @@ class BaseSharing: # check access Permissions access = Access(self._rights, user, PathMapped) if not access.check("r"): - logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) return httputils.NOT_ALLOWED if self.permit_create_token is False: if "t" not in access.permissions: - logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explict grant misses 't')", PathMapped, user) + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explict grant misses 't')", PathMapped, user) return httputils.NOT_ALLOWED else: if "T" in access.permissions: - logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'T')", PathMapped, user) + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'T')", PathMapped, user) return httputils.NOT_ALLOWED if User is not None: @@ -793,7 +793,7 @@ class BaseSharing: # retrieve existing share share = self.database_get_sharing(ShareType=ShareType, PathOrToken=PathOrToken, OnlyEnabled=False) if share is not None: - logger.error(api_info + ": share already exists PathOrToken=%r", PathOrToken) + logger.warning(api_info + ": share already exists PathOrToken=%r", PathOrToken) return httputils.CONFLICT if User is None: @@ -804,21 +804,21 @@ class BaseSharing: # check access Permissions access = Access(self._rights, user, PathMapped, None) # PathMapped is mandatory if not access.check("r") and "i" not in access.permissions: - logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) return httputils.NOT_ALLOWED if self.permit_create_map is False: if "m" not in access.permissions: - logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user) + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user) return httputils.NOT_ALLOWED else: if "M" in access.permissions: - logger.error(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) return httputils.NOT_ALLOWED access = Access(self._rights, User, PathOrToken) if not access.check("r"): - logger.error(api_info + ": access to PathOrToken=%r not allowed for User=%r", PathOrToken, User) + logger.warning(api_info + ": access to PathOrToken=%r not allowed for User=%r", PathOrToken, User) return httputils.NOT_ALLOWED # check whether share is already existing as real collection @@ -827,7 +827,7 @@ class BaseSharing: if not item: pass else: - logger.error(api_info + ": PathOrToken=%r already exists as real collection for User=%r", PathOrToken, User) + logger.warning(api_info + ": PathOrToken=%r already exists as real collection for User=%r", PathOrToken, User) return httputils.CONFLICT if logger.isEnabledFor(logging.DEBUG): @@ -848,7 +848,7 @@ class BaseSharing: Properties=Properties) else: - logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": result=%r", result) @@ -860,13 +860,14 @@ class BaseSharing: elif result['status'] == "success": answer['Status'] = "success" else: - return httputils.bad_request("Internal failure") + logger.warning(api_info + ": %r by user %r not successful", PathMapped, request_data['User']) + return httputils.bad_request("Internal Error") if ShareType == "token": PathOrToken = token answer['PathOrToken'] = token - logger.info(api_info + " success: PathMapped=%r Permissions=%r PathOrToken=%r", PathMapped, Permissions, token) + logger.info(api_info + " success: PathMapped=%r Permissions=%r PathOrToken=%r", PathMapped, Permissions, PathOrToken) # action: update elif action == "update": @@ -874,7 +875,7 @@ class BaseSharing: logger.debug("TRACE/" + api_info + ": start") if ShareType not in ["token", "map"]: - logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") if PathOrToken is None: @@ -892,7 +893,7 @@ class BaseSharing: # check access Permissions access = Access(self._rights, user, str(PathMapped), None) if not access.check("r") and "i" not in access.permissions: - logger.warning("Update sharing: access to PathMapped %r not allowed for user %r", PathMapped, user) + logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED result = self.database_update_sharing( @@ -910,22 +911,22 @@ class BaseSharing: elif user == share['User']: # User is only allowed to update Properties if PathMapped is not None or Permissions is not None or User is not None: - logger.info("Update sharing: access to %r not allowed for user %r to adjust anything beside: %s", PathOrToken, user, " ".join(DB_FIELDS_V1_USER_PERMITTED)) + logger.warning(api_info + ": access to %r not allowed for user %r to adjust anything beside: %s", PathOrToken, user, " ".join(DB_FIELDS_V1_USER_PERMITTED)) return httputils.NOT_ALLOWED if Properties is not None: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/API/update: permit_properties_overlay=%s Permissions=%r", self.permit_properties_overlay, share['Permissions']) if self.permit_properties_overlay: if share['Permissions'] is not None and "p" in str(share['Permissions']): - logger.info("Update on shared %r: overlay permitted, but denied by permission 'p'", PathOrToken) + logger.warning(api_info + ": %r overlay permitted, but denied by permission 'p'", PathOrToken) return httputils.NOT_ALLOWED else: - logger.info("Update on shared %r: overlay permitted by option", PathOrToken) + logger.info(api_info + ": %r overlay permitted by option", PathOrToken) else: if share['Permissions'] is not None and "P" in str(share['Permissions']): - logger.info("Update on shared %r: overlay denied, but granted by permission 'P'", PathOrToken) + logger.info(api_info + ": %r overlay denied, but granted by permission 'P'", PathOrToken) else: - logger.info("Update on shared %r: overlay denied by option", PathOrToken) + logger.warning(api_info + ": %r overlay denied by option", PathOrToken) return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED @@ -940,7 +941,7 @@ class BaseSharing: else: # neither owner nor user matches - logger.warning("Update sharing of %r not permitted for user %r", PathOrToken, user) + logger.warning(api_info + ": sharing of %r not permitted for user %r", PathOrToken, user) return httputils.NOT_ALLOWED # result handling @@ -952,11 +953,8 @@ class BaseSharing: answer['Status'] = "success" pass else: - if ShareType == "token": - logger.info("Update of sharing-by-token: %r not successful", request_data['PathOrToken']) - elif ShareType == "map": - logger.info("Update of sharing-by-map: %r not successful", request_data['PathOrToken']) - return httputils.bad_request("Invalid share type") + logger.warning(api_info + ": %r not successful", request_data['PathOrToken']) + return httputils.bad_request("Internal Error") # action: delete elif action == "delete": @@ -964,7 +962,7 @@ class BaseSharing: logger.debug("TRACE/" + api_info + ": start") if ShareType not in ["token", "map"]: - logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") if PathOrToken is None: @@ -983,7 +981,7 @@ class BaseSharing: PathOrToken=PathOrToken) # verification above that it is not None else: # only owner is permitted to delete a share - logger.warning("Delete sharing of %r not permitted for user %r", PathOrToken, user) + logger.warning(api_info + ": %r not permitted for user %r", PathOrToken, user) return httputils.NOT_ALLOWED # result handling @@ -995,11 +993,8 @@ class BaseSharing: answer['Status'] = "success" pass else: - if ShareType == "token": - logger.info("Delete sharing-by-token: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) - elif ShareType == "map": - logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) - return httputils.bad_request("Invalid share type") + logger.warning(api_info + ": %r by user %r not successful", request_data['PathOrToken'], request_data['User']) + return httputils.bad_request("Internal Error") # action: info elif action == "info": @@ -1018,7 +1013,7 @@ class BaseSharing: logger.debug("TRACE/sharing/API/POST/" + action) if ShareType not in ["token", "map"]: - logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") if PathOrToken is None: @@ -1071,7 +1066,7 @@ class BaseSharing: else: # neither owner nor user matches - logger.warning("Toggle sharing of %r not permitted for user %r", PathOrToken, user) + logger.warning(api_info + ": %r by user %r not permitted", PathOrToken, user) return httputils.NOT_ALLOWED if result: @@ -1083,21 +1078,21 @@ class BaseSharing: answer['Status'] = "success" pass else: - logger.error("Toggle sharing: %r of user %s not successful", request_data['PathOrToken'], user) + logger.warning(api_info + ": %r by user %s not successful", request_data['PathOrToken'], user) return httputils.bad_request("Internal Error") else: # default - logger.error(api_info + ": unsupported action=%r", action) + logger.warning(api_info + ": unsupported action=%r", action) return httputils.bad_request("Invalid action") # output handler if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/API/POST output format: %r", output_format) logger.debug("TRACE/sharing/API/POST answer: %r", answer) - if output_format == "csv" or output_format == "text": + if output_format == "csv" or output_format == "plain": answer_array = [] - if output_format == "text": + if output_format == "plain": for key in answer: if key != 'Content': if API_TYPES_V1[key] is bool or API_TYPES_V1[key] is int: @@ -1109,7 +1104,7 @@ class BaseSharing: writer = DictWriter(csv, fieldnames=DB_FIELDS_V1, delimiter=';') if output_format == "csv": writer.writeheader() - elif output_format == "text": + elif output_format == "plain": writer.writeheader() for entry in answer['Content']: # TODO: Argument 1 to "writerow" of "DictWriter" has incompatible type "str"; expected "Mapping[str, Any]" [arg-type] diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index be18f3a0..f12b0124 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -191,6 +191,7 @@ class TestSharingApiSanity(BaseTest): "collection_by_token": "True"}, "rights": {"type": "owner_only"}}) + form_array: Sequence[str] json_dict: dict for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): From a5de2bf28ffba2ed290fd25eb74c07e955f67e74 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Mar 2026 06:41:09 +0100 Subject: [PATCH 10/15] sharing: adjust logging --- radicale/sharing/__init__.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 13107b39..e83bf70d 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -340,9 +340,12 @@ class BaseSharing: # TODO add token validity checks if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/token: supported token found in path: %r (token=%r)", path, match[1]) - return self.database_get_sharing( + result = self.database_get_sharing( ShareType="token", PathOrToken=match[1]) + if result is not None: + logger.info("Sharing/%s: resolved %r->%r, user ->%r, permissions %r" , "token", path, result['PathMapped'], result['Owner'], result['Permissions']) + return result else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/token: no supported prefix found in path: %r", path) @@ -363,7 +366,7 @@ class BaseSharing: PathOrToken=path, User=user) if result: - return result + pass else: # fallback to parent path parent_path = pathutils.parent_path(path) @@ -377,11 +380,13 @@ class BaseSharing: result['PathMapped'] = path.replace(parent_path, result['PathMapped']) if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map/resolver: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path) - return result else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not found") return None + + logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, permissions %r" , "map", path, result['PathMapped'], user, result['Owner'], result['Permissions']) + return result else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not active") @@ -918,15 +923,15 @@ class BaseSharing: logger.debug("TRACE/sharing/API/update: permit_properties_overlay=%s Permissions=%r", self.permit_properties_overlay, share['Permissions']) if self.permit_properties_overlay: if share['Permissions'] is not None and "p" in str(share['Permissions']): - logger.warning(api_info + ": %r overlay permitted, but denied by permission 'p'", PathOrToken) + logger.warning(api_info + ": %r properties overlay permitted by option, but denied by permission 'p'", PathOrToken) return httputils.NOT_ALLOWED else: - logger.info(api_info + ": %r overlay permitted by option", PathOrToken) + logger.info(api_info + ": %r properties overlay permitted by option", PathOrToken) else: if share['Permissions'] is not None and "P" in str(share['Permissions']): - logger.info(api_info + ": %r overlay denied, but granted by permission 'P'", PathOrToken) + logger.info(api_info + ": %r properties overlay denied by option, but granted by permission 'P'", PathOrToken) else: - logger.warning(api_info + ": %r overlay denied by option", PathOrToken) + logger.warning(api_info + ": %r properties overlay denied by option", PathOrToken) return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED From ffc5184b11720c76e9b00d9be8a32b04d67cbb6b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Mar 2026 07:54:37 +0100 Subject: [PATCH 11/15] sharing: add support for clearing properties, doc update, fixes --- SHARING.md | 64 +++++++++++++++++ radicale/app/propfind.py | 12 ++-- radicale/sharing/__init__.py | 19 ++++- radicale/tests/test_base.py | 6 +- radicale/tests/test_sharing.py | 124 ++++++++++++++++++++++++++++++++- 5 files changed, 214 insertions(+), 11 deletions(-) diff --git a/SHARING.md b/SHARING.md index 7ece76f5..c359e123 100644 --- a/SHARING.md +++ b/SHARING.md @@ -559,3 +559,67 @@ Whitelisted ones are defined in `OVERLAY_PROPERTIES_WHITELIST` in `radicale/shar * supported *share* permissions: `Pp` * `enforce_properties_overlay` * supported *share* permissions: `Ee` + +### Properties Overlay Example + +#### Requirements + + * sharing / permit_properties_overlay = True + +#### Test sequence + + * Prepare XML statements + +```bash +## PROPFIND color +xml_pfc=' + + + + +' + +## PROPPATCH color +xml_ppc=' + + + + #DDDDDD + + +' +``` + + * Tests + +```bash +## Retrieve collection color of owner (no color set) +curl -u owner:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/ + +## Create read-only share for user +curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create + +## Accept (enable+unhide) share by user +curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update + +## Retrieve collection color of share by user (no color set) +curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ + +## Set property overlay by user +curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update + +## Retrieve collection color of share by user (color set) +curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ + +## Delete property overlay by user +curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update + +## Retrieve collection color of share by user (no color set) +curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ + +## Add property overlay by user using PROPPATCH +curl -u user:pass -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/ + +## Retrieve collection color of share by user (color set) +curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +``` diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index da6106a4..98ee950b 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -344,13 +344,13 @@ def xml_propfind_response( else: human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) + if share: + # map/add from overlay + if share['Properties']: + if human_tag in share['Properties']: + if share['Properties'][human_tag] is not None: + tag_text = share['Properties'][human_tag] if tag_text is not None: - if share: - # map from overlay - if share['Properties']: - if human_tag in share['Properties']: - if share['Properties'][human_tag] is not None: - tag_text = share['Properties'][human_tag] element.text = tag_text else: is404 = True diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index e83bf70d..f540fe86 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -518,7 +518,7 @@ class BaseSharing: elif 'application/x-www-form-urlencoded' in content_type: input_format = "form" output_format = "plain" # default - request_parsed = parse_qs(request_body) + request_parsed = parse_qs(request_body, keep_blank_values=True) # convert arrays into single value request_data = {} for key in request_parsed: @@ -526,6 +526,10 @@ class BaseSharing: # Properties key value parser properties_dict: dict = {} for entry in request_parsed[key]: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/API: parse property %r", entry) + if entry == "": + continue m = re.search('^([^=]+)=([^=]+)$', entry) if not m: return httputils.bad_request("Invalid properties format in form") @@ -535,6 +539,9 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/API: converted Properties from form into dict: %r", properties_dict) request_data[key] = properties_dict + if len(request_data[key]) == 0: + # empty + request_data[key] = {} elif key in ["Enabled", "Hidden"]: try: request_data[key] = config._convert_to_bool(request_parsed[key][0]) @@ -901,6 +908,10 @@ class BaseSharing: logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED + if 'Properties' in request_data and Properties is None: + # clear properties + Properties = {} + result = self.database_update_sharing( ShareType=ShareType, PathMapped=PathMapped, @@ -918,7 +929,7 @@ class BaseSharing: if PathMapped is not None or Permissions is not None or User is not None: logger.warning(api_info + ": access to %r not allowed for user %r to adjust anything beside: %s", PathOrToken, user, " ".join(DB_FIELDS_V1_USER_PERMITTED)) return httputils.NOT_ALLOWED - if Properties is not None: + if 'Properties' in request_data: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/API/update: permit_properties_overlay=%s Permissions=%r", self.permit_properties_overlay, share['Permissions']) if self.permit_properties_overlay: @@ -935,6 +946,10 @@ class BaseSharing: return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED + if 'Properties' in request_data and Properties is None: + # clear properties + Properties = {} + # limited update as user result = self.database_update_sharing( ShareType=ShareType, diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index f50cf0be..4eee5c05 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -72,7 +72,11 @@ user: .* collection: .* permissions: RrWw""") self.configure({"rights": {"file": rights_file_path, - "type": "from_file"}}) + "type": "from_file"}, + "logging": {"request_header_on_debug": "True", + "request_content_on_debug": "True", + "response_header_on_debug": "True", + "response_content_on_debug": "True"}}) def test_root(self) -> None: """GET request at "/".""" diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index f12b0124..8be2575c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -2985,7 +2985,7 @@ permissions: RrWw""") json_dict['PathOrToken'] = path_shared_r _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - # verify PROPPATCH as user + # verify PROPFIND as user logging.info("\n*** PROPFIND collection user -> ok") propfind_calendar_color = get_file_content("propfind_multiple.xml") _, responses = self.propfind(path_mapped, propfind_calendar_color, login="owner:ownerpw") @@ -3073,12 +3073,132 @@ permissions: RrWw""") assert status == 200 and prop.text == "#CCCCCC" # update map by user - logging.info("\n*** update map by user (form)") + logging.info("\n*** update properties with buggyy ones by user (form)") form_array = ["User=" + "user"] form_array.append("PathOrToken=" + path_shared_r) form_array.append("Properties=BUGGYENTRY=BUGGYVALUE") _, headers, answer = self._sharing_api_form("map", "update", check=400, login="user:userpw", form_array=form_array) + def test_sharing_api_map_propfind_overlay_api_delete(self) -> None: + """share-by-map API usage tests related to proppatch.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_create_token": True, + "permit_properties_overlay": True, + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + form_array: Sequence[str] + json_dict: dict + + path_mapped = "/owner/calendarPFD.ics/" + path_shared_r = "/user/calendarPFD-shared-by-owner-r.ics/" + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped, login="owner:ownerpw") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + + + +""", login="owner:ownerpw") + logging.info("response: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) and len(response) == 1 + status, prop = response["D:current-user-principal"] + assert status == 200 and len(prop) == 1 + element = prop.find(xmlutils.make_clark("D:href")) + assert element is not None and element.text == "/owner/" + + # create map + logging.info("\n*** create map user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Permissions'] = "r" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # set properties by user + logging.info("\n*** set properties by user (form)") + form_array = [] + form_array.append("PathOrToken=" + path_shared_r) + form_array.append("Properties='ICAL:calendar-color'='#CCCCCC'") + _, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array) + + # check that properties are existing in map + logging.info("\n*** list and check for properties (json->json)") + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user:userpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == '#CCCCCC' + + # verify overlay as user + logging.info("\n*** PROPFIND collection user (overlay) -> ok") + propfind_calendar_color = get_file_content("propfind_calendar_color.xml") + _, responses = self.propfind(path_shared_r, propfind_calendar_color, login="user:userpw") + logging.info("response: %r", responses) + response = responses[path_shared_r] + assert not isinstance(response, int) + status, prop = response["ICAL:calendar-color"] + logging.debug("calendar-color: %r", prop.text) + assert status == 200 and prop.text == "#CCCCCC" + + # clear properties by user + logging.info("\n*** clear properties by user (form)") + form_array = [] + form_array.append("PathOrToken=" + path_shared_r) + form_array.append("Properties=") + _, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array) + + # check that properties are cleared + logging.info("\n*** list and check for cleared properties (json->json)") + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user:userpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['Properties'] is not None + + # verify overlay as user + logging.info("\n*** PROPFIND collection user (overlay no longer exists) -> ok") + propfind_calendar_color = get_file_content("propfind_calendar_color.xml") + _, responses = self.propfind(path_shared_r, propfind_calendar_color, login="user:userpw") + logging.info("response: %r", responses) + response = responses[path_shared_r] + assert not isinstance(response, int) + status, prop = response["ICAL:calendar-color"] + logging.debug("calendar-color: %r", prop.text) + assert status == 404 + def test_sharing_api_map_propfind_overlay_api_permissions(self) -> None: """share-by-map API usage tests related to proppatch.""" self.configure({"auth": {"type": "htpasswd", From b22bc3f327a2d0ca61c6fb0d18661bd4058fff6e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Mar 2026 07:58:21 +0100 Subject: [PATCH 12/15] sharing: flake8 fixes --- radicale/sharing/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index f540fe86..6ae96830 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -344,7 +344,7 @@ class BaseSharing: ShareType="token", PathOrToken=match[1]) if result is not None: - logger.info("Sharing/%s: resolved %r->%r, user ->%r, permissions %r" , "token", path, result['PathMapped'], result['Owner'], result['Permissions']) + logger.info("Sharing/%s: resolved %r->%r, user ->%r, permissions %r", "token", path, result['PathMapped'], result['Owner'], result['Permissions']) return result else: if logger.isEnabledFor(logging.DEBUG): @@ -385,7 +385,7 @@ class BaseSharing: logger.debug("TRACE/sharing/map: not found") return None - logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, permissions %r" , "map", path, result['PathMapped'], user, result['Owner'], result['Permissions']) + logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, permissions %r", "map", path, result['PathMapped'], user, result['Owner'], result['Permissions']) return result else: if logger.isEnabledFor(logging.DEBUG): @@ -909,8 +909,8 @@ class BaseSharing: return httputils.NOT_ALLOWED if 'Properties' in request_data and Properties is None: - # clear properties - Properties = {} + # clear properties + Properties = {} result = self.database_update_sharing( ShareType=ShareType, @@ -947,8 +947,8 @@ class BaseSharing: return httputils.NOT_ALLOWED if 'Properties' in request_data and Properties is None: - # clear properties - Properties = {} + # clear properties + Properties = {} # limited update as user result = self.database_update_sharing( From 29ddf28d0b23f25568799a1bd128cb1d2f30424f Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Mar 2026 08:06:19 +0100 Subject: [PATCH 13/15] sharing/csv: improve locking --- radicale/sharing/csv.py | 313 ++++++++++++++++++++-------------------- 1 file changed, 157 insertions(+), 156 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 5dfc45a4..62c8a9dc 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -30,7 +30,7 @@ class Sharing(sharing.BaseSharing): _sharing_cache: list[dict] = [] _sharing_db_file: str - # Overloaded functions + # *** Overloaded functions *** def database_init(self) -> bool: logger.debug("sharing database initialization for type 'csv'") sharing_db_file = self.configuration.get("sharing", "database_path") @@ -158,48 +158,49 @@ class Sharing(sharing.BaseSharing): index = 0 result = [] - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) + with self._storage.acquire_lock("r", path=self._sharing_db_file): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) - for row in self._sharing_cache: - if index == 0: - # skip fieldnames - pass - else: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/row: test: %r", row) - if ShareType is not None and row['ShareType'] != ShareType: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/row: skip by ShareType") - pass - elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): - pass - elif User is not None and row['User'] != User: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/row: skip by User") - pass - elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/row: skip by PathOrToken") - pass - elif PathMapped is not None and row['PathMapped'] != PathMapped: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/row: skip by PathMapped") - pass - elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: - pass - elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: - pass - elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: - pass - elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: + for row in self._sharing_cache: + if index == 0: + # skip fieldnames pass else: if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/row: add : %r", row) - result.append(row) - index += 1 - return result + logger.debug("TRACE/sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: skip by ShareType") + pass + elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): + pass + elif User is not None and row['User'] != User: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: skip by User") + pass + elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: skip by PathOrToken") + pass + elif PathMapped is not None and row['PathMapped'] != PathMapped: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: skip by PathMapped") + pass + elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: + pass + elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: + pass + elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: + pass + elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: + pass + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: add : %r", row) + result.append(row) + index += 1 + return result def database_create_sharing(self, ShareType: str, @@ -213,54 +214,55 @@ class Sharing(sharing.BaseSharing): """ create sharing """ row: dict - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing: ShareType=%r", ShareType) - if ShareType == "token": + with self._storage.acquire_lock("w", path=self._sharing_db_file): if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/token/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) - # check for duplicate token entry - for row in self._sharing_cache: - if row['ShareType'] != "token": - continue - if row['PathOrToken'] == PathOrToken: - # must be unique systemwide - logger.error("sharing/token/create: PathOrToken already exists: PathOrToken=%r", PathOrToken) - return {"status": "conflict"} - elif ShareType == "map": + logger.debug("TRACE/sharing: ShareType=%r", ShareType) + if ShareType == "token": + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/token/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) + # check for duplicate token entry + for row in self._sharing_cache: + if row['ShareType'] != "token": + continue + if row['PathOrToken'] == PathOrToken: + # must be unique systemwide + logger.error("sharing/token/create: PathOrToken already exists: PathOrToken=%r", PathOrToken) + return {"status": "conflict"} + elif ShareType == "map": + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) + # check for duplicate map entry + for row in self._sharing_cache: + if row['ShareType'] != "map": + continue + if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken: + # must be unique systemwide + logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) + return {"status": "conflict"} + else: + return {"status": "error"} + + row = {"ShareType": ShareType, + "PathOrToken": PathOrToken, + "PathMapped": PathMapped, + "Owner": Owner, + "User": User, + "Permissions": Permissions, + "EnabledByOwner": EnabledByOwner, + "EnabledByUser": EnabledByUser, + "HiddenByOwner": HiddenByOwner, + "HiddenByUser": HiddenByUser, + "TimestampCreated": Timestamp, + "TimestampUpdated": Timestamp} if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/map/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) - # check for duplicate map entry - for row in self._sharing_cache: - if row['ShareType'] != "map": - continue - if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken: - # must be unique systemwide - logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) - return {"status": "conflict"} - else: - return {"status": "error"} + logger.debug("TRACE/sharing/*/create: add row: %r", row) + self._sharing_cache.append(row) - row = {"ShareType": ShareType, - "PathOrToken": PathOrToken, - "PathMapped": PathMapped, - "Owner": Owner, - "User": User, - "Permissions": Permissions, - "EnabledByOwner": EnabledByOwner, - "EnabledByUser": EnabledByUser, - "HiddenByOwner": HiddenByOwner, - "HiddenByUser": HiddenByUser, - "TimestampCreated": Timestamp, - "TimestampUpdated": Timestamp} - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/*/create: add row: %r", row) - self._sharing_cache.append(row) - - with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file): if self._write_csv(self._sharing_db_file): if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/create: write CSV done", ShareType) return {"status": "success"} + logger.error("sharing/%s/create: cannot update CSV database", ShareType) return {"status": "error"} @@ -281,61 +283,59 @@ class Sharing(sharing.BaseSharing): if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: PathOrToken=%r OwnerOrUser=%r PathMapped=%r Properties=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, PathOrToken, OwnerOrUser, PathMapped, Properties, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) - # lookup token - found = False - index = 0 - for row in self._sharing_cache: - if index == 0: - # skip fieldnames - pass - if row['ShareType'] != ShareType: - pass - elif row['PathOrToken'] != PathOrToken: - pass - else: - found = True - break - index += 1 + with self._storage.acquire_lock("w", path=self._sharing_db_file): + # lookup token + found = False + index = 0 + for row in self._sharing_cache: + if index == 0: + # skip fieldnames + pass + if row['ShareType'] != ShareType: + pass + elif row['PathOrToken'] != PathOrToken: + pass + else: + found = True + break + index += 1 - if found: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/%s/update: found index=%d", ShareType, index) + if found: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: orig row[%d]=%r", ShareType, index, row) - # CSV: remove+adjust+readd - if PathMapped is not None: - row["PathMapped"] = PathMapped - if Permissions is not None: - row["Permissions"] = Permissions - if User is not None: - row["User"] = User - if EnabledByOwner is not None: - row["EnabledByOwner"] = EnabledByOwner - if EnabledByUser is not None: - row["EnabledByUser"] = EnabledByUser - if HiddenByOwner is not None: - row["HiddenByOwner"] = HiddenByOwner - if HiddenByUser is not None: - row["HiddenByUser"] = HiddenByUser - if Properties is not None: - row["Properties"] = Properties - # update timestamp - row["TimestampUpdated"] = Timestamp + # CSV: remove+adjust+readd + if PathMapped is not None: + self._sharing_cache[index]["PathMapped"] = PathMapped + if Permissions is not None: + self._sharing_cache[index]["Permissions"] = Permissions + if User is not None: + self._sharing_cache[index]["User"] = User + if EnabledByOwner is not None: + self._sharing_cache[index]["EnabledByOwner"] = EnabledByOwner + if EnabledByUser is not None: + self._sharing_cache[index]["EnabledByUser"] = EnabledByUser + if HiddenByOwner is not None: + self._sharing_cache[index]["HiddenByOwner"] = HiddenByOwner + if HiddenByUser is not None: + self._sharing_cache[index]["HiddenByUser"] = HiddenByUser + if Properties is not None: + self._sharing_cache[index]["Properties"] = Properties + # update timestamp + self._sharing_cache[index]["TimestampUpdated"] = Timestamp - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/%s/update: adj row[%d]=%r", ShareType, index, row) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: adj row[%d]=%r", ShareType, index, self._sharing_cache[index]) - # replace row - self._sharing_cache[index] = row - - with self._storage.acquire_lock("w", OwnerOrUser, path=self._sharing_db_file): if self._write_csv(self._sharing_db_file): if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: write CSV done", ShareType) return {"status": "success"} - logger.error("sharing/%s/update: cannot update CSV database", ShareType) - return {"status": "error"} - else: - return {"status": "not-found"} + + logger.error("sharing/%s/update: cannot update CSV database", ShareType) + return {"status": "error"} + else: + return {"status": "not-found"} def database_delete_sharing(self, ShareType: str, @@ -344,42 +344,43 @@ class Sharing(sharing.BaseSharing): if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r", ShareType, PathOrToken) - # lookup token - found = False - index = 0 - for row in self._sharing_cache: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/%s/delete: check: %r", ShareType, row) - if index == 0: - # skip fieldnames - pass - if row['ShareType'] != ShareType: - pass - elif row['PathOrToken'] != PathOrToken: - pass - else: - found = True - break - index += 1 + with self._storage.acquire_lock("w", path=self._sharing_db_file): + # lookup token + found = False + index = 0 + for row in self._sharing_cache: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/delete: check: %r", ShareType, row) + if index == 0: + # skip fieldnames + pass + if row['ShareType'] != ShareType: + pass + elif row['PathOrToken'] != PathOrToken: + pass + else: + found = True + break + index += 1 - if found: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/%s/delete: found index=%d", ShareType, index) - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r index=%d", ShareType, PathOrToken, row['Owner'], index) - self._sharing_cache.pop(index) + if found: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/delete: found index=%d", ShareType, index) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r index=%d", ShareType, PathOrToken, row['Owner'], index) + self._sharing_cache.pop(index) - with self._storage.acquire_lock("w", row['Owner'], path=self._sharing_db_file): if self._write_csv(self._sharing_db_file): if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing_by_token: write CSV done") return {"status": "success"} - logger.error("sharing/%s/delete: cannot update CSV database", ShareType) - return {"status": "error"} - else: - return {"status": "not-found"} - # local functions + logger.error("sharing/%s/delete: cannot update CSV database", ShareType) + return {"status": "error"} + else: + return {"status": "not-found"} + + # *** local functions *** def _create_empty_csv(self, file: str) -> bool: with self._storage.acquire_lock("w", None, path=file): with open(file, 'w', newline='') as csvfile: From 1d133a750ff4ae8d94d9a4aa5a7c0fd5dfe89264 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Mar 2026 08:32:59 +0100 Subject: [PATCH 14/15] sharing/verify: add check --- radicale/sharing/__init__.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 6ae96830..801f6719 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -109,6 +109,7 @@ class BaseSharing: _enabled: bool = False default_permissions_create_token: str default_permissions_create_map: str + sharing_db_type: str def __init__(self, configuration: "config.Configuration") -> None: """Initialize Sharing. @@ -139,6 +140,10 @@ class BaseSharing: logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay) logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay) + # database tasks + self.sharing_db_type = configuration.get("sharing", "type") + logger.info("sharing.database_type: %s", self.sharing_db_type) + if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False)): logger.info("sharing disabled as no feature is enabled") self._enabled = False @@ -146,15 +151,17 @@ class BaseSharing: else: self._enabled = True - # database tasks - self.sharing_db_type = configuration.get("sharing", "type") - logger.info("sharing.database_type: %s", self.sharing_db_type) + if not self._init_db(): + return + def _init_db(self) -> bool: + """Initialize Sharing Database + """ try: if self.database_init() is False: logger.info("sharing disabled as no database is active") self._enabled = False - return + return False except Exception as e: logger.error("sharing database cannot be initialized: %r", e) exit(1) @@ -163,6 +170,7 @@ class BaseSharing: logger.info("sharing database info: %r", database_info) else: logger.info("sharing database info: (not provided)") + return True # *** overloadable database functions *** def database_init(self) -> bool: @@ -236,6 +244,10 @@ class BaseSharing: def verify(self) -> bool: """ verify database """ logger.info("sharing database verification begin") + + if not self._init_db(): + return False + logger.info("sharing database verification call: %s", self.sharing_db_type) result = self.database_verify() if result is not True: @@ -261,7 +273,16 @@ class BaseSharing: return False else: pass - # TODO: check PathMapped exists + + # check PathMapped exists + with self._storage.acquire_lock("r", path=entry['PathMapped']): + item = next(iter(self._storage.discover(entry['PathMapped'])), None) + if not item: + logger.error("PathMapped is not existing: %r", entry['PathMapped']) + return False + else: + logger.debug("PathMapped exists(ok): %r", entry['PathMapped']) + logger.info("sharing database verification content successful") return True From 86081d7c6a12182cac14b2e5d803caacef55b373 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Mar 2026 08:33:14 +0100 Subject: [PATCH 15/15] sharing: add doc link --- DOCUMENTATION.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c08c9d90..afaa5daf 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2063,6 +2063,8 @@ Default: 10000 _(>= 3.7.0)_ +See also [Collecition Sharing](https://github.com/Kozea/Radicale/blob/master/SHARING.md). + ##### type _(>= 3.7.0)_