From d4fa4cd991d22e393aa86ed0f25d95fd23743d05 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Mar 2026 17:51:38 +0100 Subject: [PATCH 01/36] sharing: further review --- radicale/sharing/__init__.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 28de14a5..cb45c78b 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -328,7 +328,7 @@ class BaseSharing: if not self.sharing_collection_by_map: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not active") - return [{}] + return [] # retrieve collections depending on filter shared_collection_list = self.database_list_sharing( @@ -356,7 +356,7 @@ class BaseSharing: else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/token: not active") - return None + pass if self.sharing_collection_by_map: result = self.sharing_collection_by_map_resolver(path, user) @@ -494,8 +494,12 @@ class BaseSharing: Status in JSON/TEXT (TEXT can be parsed by shell) """ + # initial log prefix + api_info = "Sharing/API/POST" + if not self._enabled: # API is not enabled + logger.warning(api_info + ": API is not enabled") return httputils.NOT_FOUND if user == "": @@ -504,6 +508,7 @@ class BaseSharing: # supported API version check if not path.startswith("/.sharing/v1/"): + logger.warning(api_info + ": leading part of path not matching supported API version") return httputils.NOT_FOUND # split into ShareType and action @@ -517,6 +522,9 @@ class BaseSharing: ShareType = match.group(1) action = match.group(2) + # append ShareType + api_info = api_info + "/" + ShareType + # check for valid ShareTypes if ShareType: if ShareType not in SHARE_TYPES: @@ -539,6 +547,9 @@ class BaseSharing: logger.debug("TRACE/sharing/API: action not whitelisted: %r", action) return httputils.NOT_FOUND + # append action + api_info = api_info + "/" + action + if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/API: called by authenticated user: %r", user) # read POST data @@ -551,9 +562,6 @@ class BaseSharing: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT - # 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: @@ -776,6 +784,7 @@ class BaseSharing: with self._storage.acquire_lock("r", user, path=PathMapped): item = next(iter(self._storage.discover(PathMapped)), None) if not item: + logger.warning(api_info + ": cannot find PathMapped=%r", PathMapped) return httputils.NOT_FOUND if not isinstance(item, storage.BaseCollection): return httputils.METHOD_NOT_ALLOWED From cb80a13d39acfafd113a6e26e944d23c6a2a2e37 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Mar 2026 17:53:42 +0100 Subject: [PATCH 02/36] sharing: bugfix --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index cb45c78b..4e1ddd15 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -365,7 +365,7 @@ class BaseSharing: else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not active") - return None + pass return None From e5584c8f6c1d7abe7a4e2ae2747eba1f5ad3553e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Mar 2026 17:55:50 +0100 Subject: [PATCH 03/36] sharing: reorg/log --- radicale/sharing/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 4e1ddd15..66f30e77 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -533,12 +533,14 @@ class BaseSharing: return httputils.NOT_FOUND # check for enabled ShareTypes - if not self.sharing_collection_by_map and ShareType == "map": - # API "map" is not enabled - return httputils.NOT_FOUND - if not self.sharing_collection_by_token and ShareType == "token": # API "token" is not enabled + logger.warning(api_info + ": not enabled by config (collection_by_token)") + return httputils.NOT_FOUND + + if not self.sharing_collection_by_map and ShareType == "map": + # API "map" is not enabled + logger.warning(api_info + ": not enabled by config (collection_by_map)") return httputils.NOT_FOUND # check for valid API hooks From 0e107bbc2e28c93dad275297ed006eb82d5d359a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Mar 2026 17:57:35 +0100 Subject: [PATCH 04/36] sharing: add logging --- radicale/sharing/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 66f30e77..028637aa 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -741,6 +741,7 @@ class BaseSharing: if not self.sharing_collection_by_map and not self.sharing_collection_by_token: if not action == 'info': # API is not enabled + logger.warning(api_info + ": API is not enabled") return httputils.NOT_FOUND # action: list From ac035677048f41b1a9aaedf063001e8b3af23b47 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 10 Mar 2026 07:47:22 +0100 Subject: [PATCH 05/36] sharing/propfind: announce share permissions --- radicale/app/propfind.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 98ee950b..bfae5ed6 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -230,6 +230,16 @@ def xml_propfind_response( xmlutils.make_clark("D:unauthenticated"))) elif tag == xmlutils.make_clark("D:current-user-privilege-set"): privileges = ["D:read"] + if share: + if write: + privileges.append("D:write-content") + else: + if self._sharing.permit_properties_overlay: + if share['Permissions'] and not "p" in share['Permissions']: + privileges.append("D:write-properties") + else: + if share['Permissions'] and not "P" in share['Permissions']: + privileges.append("D:write-properties") if write: privileges.append("D:all") privileges.append("D:write") From 78bfcc0b6a9ced5cbe3b792682c9cb695f95844a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 12:47:36 +0100 Subject: [PATCH 06/36] sharing: extend tests with propfind priv checks --- radicale/tests/test_sharing.py | 88 +++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 6bac36e4..3a93f22d 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -71,7 +71,24 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept) return _, headers, answer - def _propfind_calendar_color(self, path, login): + def _propfind_allprop(self, path: str, login) -> dict: + propfind_allprop = get_file_content("allprop.xml") + _, responses = self.propfind(path=path, data=propfind_allprop, login=login) + logging.info("response: %r", responses) + response = responses[path] + assert not isinstance(response, int) + return response + + def _propfind_priviledges(self, path: str, login) -> list[str]: + response = self._propfind_allprop(path, login) + status, prop = response["D:current-user-privilege-set"] + logging.debug("prop: %r", prop) + priviledges = prop.findall(xmlutils.make_clark("D:privilege")) + assert len(priviledges) >= 1 + priviledges_list = [xmlutils.make_human_tag(priviledge.findall("*")[0].tag) for priviledge in priviledges] + return priviledges_list + + def _propfind_calendar_color(self, path, login) -> Union[str | None]: propfind_calendar_color = get_file_content("propfind_calendar_color.xml") _, responses = self.propfind(path=path, data=propfind_calendar_color, login=login) logging.info("response: %r", responses) @@ -2825,14 +2842,16 @@ permissions: RrWw""") "collection_by_map": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "False", - "response_content_on_debug": "False", + "response_content_on_debug": "True", "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) json_dict: dict - path_user1 = "/user1/calendarPGu1.ics/" path_owner1 = "/owner1/calendarPGo1.ics/" + path_user1_r = "/user1/calendarPGu1-r.ics/" + path_user1_rw = "/user1/calendarPGu1-rw.ics/" + path_user1_RrWw = "/user1/calendarPGu1-RrWw.ics/" logging.info("\n*** prepare") self.mkcalendar(path_owner1, login="owner1:owner1pw") @@ -2844,14 +2863,21 @@ permissions: RrWw""") # create map self.configure({"sharing": {"default_permissions_create_map": "r"}}) + logging.info("\n*** create map user1/owner1 r -> 200") json_dict = {} json_dict['User'] = "user1" json_dict['PathMapped'] = path_owner1 - - logging.info("\n*** create map user1/owner1 r -> 200") - json_dict['PathOrToken'] = path_user1 + "r" + json_dict['PathOrToken'] = path_user1_r + json_dict['Enabled'] = True + json_dict['Hidden'] = False _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + # enable map by user + logging.info("\n*** enable map by user1") + json_dict = {} + json_dict['PathOrToken'] = path_user1_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user1:user1pw", json_dict=json_dict) + logging.info("\n*** list (json->json)") _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) answer_dict = json.loads(answer) @@ -2859,9 +2885,23 @@ permissions: RrWw""") assert answer_dict['Lines'] == 1 assert answer_dict['Content'][0]['Permissions'] == "r" + # check PROPFIND/priviledges item as user + logging.info("\n*** PROPFIND/priviledges item as user") + priviledges_list = self._propfind_priviledges(path_user1_r, login="user1:user1pw") + assert "D:read" in priviledges_list + assert "D:write-content" not in priviledges_list + assert "D:write-properties" not in priviledges_list + assert "D:write" not in priviledges_list + assert "D:all" not in priviledges_list + logging.info("\n*** create map user1/owner1 rw -> 200") - json_dict['PathOrToken'] = path_user1 + "rw" + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user1_rw json_dict['Permissions'] = "rw" + json_dict['Enabled'] = True + json_dict['Hidden'] = False _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** list (json->json)") @@ -2871,10 +2911,27 @@ permissions: RrWw""") assert answer_dict['Lines'] == 1 assert answer_dict['Content'][0]['Permissions'] == "rw" + # enable map by user + logging.info("\n*** enable map by user1") + json_dict = {} + json_dict['PathOrToken'] = path_user1_rw + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user1:user1pw", json_dict=json_dict) + + # check PROPFIND/priviledges item as user + logging.info("\n*** PROPFIND/priviledges item as user") + priviledges_list = self._propfind_priviledges(path_user1_rw, login="user1:user1pw") + assert "D:read" in priviledges_list + assert "D:write-content" in priviledges_list + assert "D:write-properties" not in priviledges_list + assert "D:write" not in priviledges_list + assert "D:all" not in priviledges_list + logging.info("\n*** create map user1/owner1 with adjusted default permissions -> 200") self.configure({"sharing": {"default_permissions_create_map": "RrWw"}}) - json_dict['PathOrToken'] = path_user1 + "RrRw" - del json_dict['Permissions'] + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user1_RrWw _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** list (json->json)") @@ -3485,8 +3542,8 @@ permissions: RrWw""") "htpasswd_encryption": "plain"}, "sharing": { "type": "csv", - "permit_create_map": True, - "permit_create_token": True, + "permit_create_map": "True", + "permit_create_token": "True", "collection_by_map": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "False", @@ -3553,6 +3610,15 @@ 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) + # check PROPFIND/priviledges item as user + logging.info("\n*** PROPFIND/priviledges item as user -> calendar") + priviledges_list = self._propfind_priviledges(path_shared_r, login="user:userpw") + assert "D:read" in priviledges_list + assert "D:write-content" not in priviledges_list + assert "D:write-properties" in priviledges_list + assert "D:write" not in priviledges_list + assert "D:all" not in priviledges_list + # verify PROPPATCH as user logging.info("\n*** PROPFIND collection user -> ok") color = self._propfind_calendar_color(path_shared_r, login="user:userpw") From b27aac78e5103fdb4339a0e6d86c279b011afa24 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 12:52:33 +0100 Subject: [PATCH 07/36] sharing/propfind: fix/align priv output --- radicale/app/propfind.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index bfae5ed6..7d0ee59f 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -232,15 +232,11 @@ def xml_propfind_response( privileges = ["D:read"] if share: if write: - privileges.append("D:write-content") - else: - if self._sharing.permit_properties_overlay: - if share['Permissions'] and not "p" in share['Permissions']: - privileges.append("D:write-properties") - else: - if share['Permissions'] and not "P" in share['Permissions']: - privileges.append("D:write-properties") - if write: + if "P" in share['Permissions']: + privileges.append("D:write-properties") + if "w" in share['Permissions']: + privileges.append("D:write-content") + elif write: privileges.append("D:all") privileges.append("D:write") privileges.append("D:write-properties") From 9e66112f86d7060063afeb6ac67aee4a3bee6585 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 17:45:45 +0100 Subject: [PATCH 08/36] sharing: add additional test cases related to missing trailing / --- radicale/tests/test_sharing.py | 63 +++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 3a93f22d..8c3d9171 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -494,15 +494,15 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** test: %s", db_type) self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** create token without PathMapped (form) -> should fail") + logging.info("\n*** create token without PathMapped (form) -> 400") form_array = [] _, headers, answer = self._sharing_api_form("token", "create", 400, login="owner:ownerpw", form_array=form_array) - logging.info("\n*** create token without PathMapped (json) -> should fail") + logging.info("\n*** create token without PathMapped (json) -> 400") json_dict = {} _, headers, answer = self._sharing_api_json("token", "create", 400, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** create token#1 without existing collection (form->text)") + logging.info("\n*** create token#1 without existing collection (form->text) -> 404") form_array = ["PathMapped=" + path_base1] _, headers, answer = self._sharing_api_form("token", "create", check=404, login="owner:ownerpw", form_array=form_array) @@ -510,7 +510,11 @@ class TestSharingApiSanity(BaseTest): 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)") + logging.info("\n*** create token#1 with existing collection (form->text) but no trailing / -> 400") + form_array = ["PathMapped=" + path_base1.rstrip('/')] + _, headers, answer = self._sharing_api_form("token", "create", check=400, login="owner:ownerpw", form_array=form_array) + + logging.info("\n*** create token#1 with existing collection (form->text) -> 200") 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 @@ -806,24 +810,67 @@ class TestSharingApiSanity(BaseTest): json_dict: dict + path_owner = "/owner/calendar.ics/" + path_user = "/user/calendar-owner.ics/" + self.mkcalendar(path_owner, login="owner:ownerpw") + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + self.configure({"sharing": {"permit_create_map": "False"}}) + logging.info("\n*** test: %s", db_type) self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** create map without PathMapped (json) -> should fail") + logging.info("\n*** create map without PathMapped (json) -> 400") json_dict = {} _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** create map without PathMapped but User (json) -> should fail") + logging.info("\n*** create map without PathMapped but User (json) -> 400") json_dict = {'User': "user"} _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** create map without PathMapped but User and PathOrToken (json) -> should fail") + logging.info("\n*** create map without PathMapped but User and PathOrToken (json) -> 400") json_dict = {} json_dict['User'] = "user" - json_dict['PathOrToken'] = "/owner/calendar.ics" + json_dict['PathOrToken'] = path_user _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** create map with PathMapped, User, PathOrToken without trailing / (json) -> 400") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathOrToken'] = path_user + json_dict['PathMapped'] = path_owner.rstrip('/') + _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** create map with PathMapped without trailing /, User, PathOrToken (json) -> 400") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathOrToken'] = path_user.rstrip('/') + json_dict['PathMapped'] = path_owner + _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** create map with PathMapped, User, PathOrToken - not permitted (json) -> 403") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathOrToken'] = path_user + json_dict['PathMapped'] = path_owner + _, headers, answer = self._sharing_api_json("map", "create", 403, login="owner:ownerpw", json_dict=json_dict) + + self.configure({"sharing": {"permit_create_map": "True"}}) + + logging.info("\n*** create map with PathMapped, User, PathOrToken (json) -> 200") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathOrToken'] = path_user + json_dict['PathMapped'] = path_owner + _, headers, answer = self._sharing_api_json("map", "create", 200, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** create map with PathMapped, User, PathOrToken=PathOwner (json) -> 409") + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathOrToken'] = path_owner + json_dict['PathMapped'] = path_owner + _, headers, answer = self._sharing_api_json("map", "create", 409, login="owner:ownerpw", json_dict=json_dict) + def test_sharing_api_map_usage(self) -> None: """share-by-map API usage tests.""" self.configure({"auth": {"type": "htpasswd", From b77328f8112f18aeef3c7cd67d3f8929526f8555 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 17:47:02 +0100 Subject: [PATCH 09/36] sharing: enforce trailing / --- radicale/sharing/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 028637aa..d279df08 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -668,12 +668,12 @@ class BaseSharing: if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]): logger.warning(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for PathOrToken") - elif ShareType == "map": + else: if not re.search('^' + PATH_PATTERN + '$', request_data[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 /") + if 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.warning(api_info + ": unsupported " + key) From 2984de16a723260659405e70338a7a8b999c1268 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 17:59:12 +0100 Subject: [PATCH 10/36] sharing:fix test_sharing_api_permissions_rights --- radicale/tests/test_sharing.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 8c3d9171..cd19aa95 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -2795,22 +2795,22 @@ permissions: RrWw""") logging.info("\n*** create map user1/owner1, globally disabled / not granted M -> 403") json_dict['PathMapped'] = path_owner1_M - json_dict['PathOrToken'] = path_user1 + "dM-uc" + db_type + json_dict['PathOrToken'] = path_user1.replace(".ics", "dM-uc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** create map user1/owner1, globally disabled / not granted T -> 403") json_dict['PathMapped'] = path_owner1_T - json_dict['PathOrToken'] = path_user1 + "dT-uc" + db_type + json_dict['PathOrToken'] = path_user1.replace(".ics", "dT-uc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** create map user1/owner1, globally disabled / not granted t -> 403") json_dict['PathMapped'] = path_owner1_t - json_dict['PathOrToken'] = path_user1 + "dt-lc" + db_type + json_dict['PathOrToken'] = path_user1.replace(".ics", "dt-lc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** create map user1/owner1, globally disabled / granted m -> 200") json_dict['PathMapped'] = path_owner1_m - json_dict['PathOrToken'] = path_user1 + "dm-lc" + db_type + json_dict['PathOrToken'] = path_user1.replace(".ics", "dm-lc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** create map user1/owner1, globally enabled") @@ -2818,22 +2818,22 @@ permissions: RrWw""") logging.info("\n*** create map user1/owner1, globally enabled / not granted M -> 403") json_dict['PathMapped'] = path_owner1_M - json_dict['PathOrToken'] = path_user1 + "eM-uc" + db_type + json_dict['PathOrToken'] = path_user1.replace(".ics", "eM-uc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** create map user1/owner1, globally enabled / ignore T -> 200") json_dict['PathMapped'] = path_owner1_T - json_dict['PathOrToken'] = path_user1 + "eT-uc" + db_type + json_dict['PathOrToken'] = path_user1.replace(".ics", "eT-uc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** create map user1/owner1, globally enabled / ignore t -> 200") json_dict['PathMapped'] = path_owner1_t - json_dict['PathOrToken'] = path_user1 + "et-lc" + db_type + json_dict['PathOrToken'] = path_user1.replace(".ics", "et-lc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** create map user1/owner1, globally enabled / ignore m -> 200") json_dict['PathMapped'] = path_owner1_m - json_dict['PathOrToken'] = path_user1 + "em-lc" + db_type + json_dict['PathOrToken'] = path_user1.replace(".ics", "em-lc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) # create token From 119c3361adbe329e0dcfb90d94c8f42fabc61ca1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 21:36:36 +0100 Subject: [PATCH 11/36] sharing: improve resolving function --- radicale/sharing/__init__.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d279df08..550f82e2 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -346,28 +346,33 @@ class BaseSharing: # 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""" + share = None + if self.sharing_collection_by_token: - result = self.sharing_collection_by_token_resolver(path) - if result is not None: - return result - else: - # check for map - pass + if share is None: + share = self.sharing_collection_by_token_resolver(path) else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/token: not active") - pass if self.sharing_collection_by_map: - result = self.sharing_collection_by_map_resolver(path, user) - if result is not None: - return result + if share is None: + share = self.sharing_collection_by_map_resolver(path, user) else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not active") - pass - return None + if share is not None: + if self.permit_properties_overlay: + if share['Permissions'] and "p" not in share['Permissions']: + # add permit permission + share['Permissions'] += "P" + else: + if share['Permissions'] and "P" not in share['Permissions']: + # add deny permission + share['Permissions'] += "p" + + return share # adjust a share def sharing_collection_update(self, ShareType: str, PathOrToken: str, OwnerOrUser: str, Properties: dict) -> None: From ba7baab79f2680b09fe520ede03937c8ca0a2369 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 07:24:24 +0100 Subject: [PATCH 12/36] webui/cosmetics: round collection size to %.2f --- radicale/web/internal_data/js/utils/misc.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/web/internal_data/js/utils/misc.js b/radicale/web/internal_data/js/utils/misc.js index 6433a17a..cb5e37e0 100644 --- a/radicale/web/internal_data/js/utils/misc.js +++ b/radicale/web/internal_data/js/utils/misc.js @@ -106,5 +106,5 @@ export function bytesToHumanReadable(bytes) { const units = ['b', 'kb', 'mb', 'gb', 'tb']; let i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024)); i = Math.min(i, units.length - 1); - return (bytes / Math.pow(1024, i)) + ' ' + units[i]; -} \ No newline at end of file + return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + units[i]; +} From 7874dc62eac95674483836b7e19f6f6ac4763138 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 07:25:26 +0100 Subject: [PATCH 13/36] sharing/property-overlay: permit and replace displayname --- radicale/app/propfind.py | 4 ++++ radicale/sharing/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 7d0ee59f..d5260d5c 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -312,6 +312,8 @@ def xml_propfind_response( elif tag == xmlutils.make_clark("RADICALE:displayname"): # Only for internal use by the web interface displayname = collection.get_meta("D:displayname") + if share and share['Properties'] and share['Properties']["D:displayname"]: + displayname = share['Properties']["D:displayname"] if displayname is not None: element.text = displayname else: @@ -324,6 +326,8 @@ def xml_propfind_response( is404 = True elif tag == xmlutils.make_clark("D:displayname"): displayname = collection.get_meta("D:displayname") + if share and share['Properties'] and share['Properties']["D:displayname"]: + displayname = share['Properties']["D:displayname"] if not displayname and is_leaf: displayname = collection.path if displayname is not None: diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 550f82e2..2bd5df89 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -111,7 +111,7 @@ PATH_PATTERN: str = "([a-zA-Z0-9/.\\-]+)" # TODO: extend or find better source USER_PATTERN: str = "([a-zA-Z0-9@]+)" # TODO: extend or find better source -OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color") +OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname") def load(configuration: "config.Configuration") -> "BaseSharing": From c963c19c94f701d20f9ad04be99b9258ce09a811 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 07:26:32 +0100 Subject: [PATCH 14/36] sharing: do not try to map / --- radicale/sharing/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 2bd5df89..fafaf369 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -348,6 +348,10 @@ class BaseSharing: """ returning dict with PathMapped, Owner, Permissions or None if not found""" share = None + if path == "/": + # not supported + return None + if self.sharing_collection_by_token: if share is None: share = self.sharing_collection_by_token_resolver(path) From 7723fb222f53cd148373e289e1587d2455a736e3 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 07:26:48 +0100 Subject: [PATCH 15/36] sharing: silently discard unsupported properties on overlay --- radicale/sharing/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index fafaf369..b9e0bd67 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -382,10 +382,19 @@ class BaseSharing: 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) + # Filter properies for permitted ones + properties_filtered: dict = {} + for prop in Properties: + if prop in OVERLAY_PROPERTIES_WHITELIST: + properties_filtered[prop] = Properties[prop] + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/collection_update: silent discard unsupported property: %r", prop) + self.database_update_sharing(ShareType=ShareType, PathOrToken=PathOrToken, OwnerOrUser=OwnerOrUser, - Properties=Properties) + Properties=properties_filtered) # *** internal sharing functions *** # resolves a token "path" to a share From 593881be83be60bb6f3cf7f2c9a49881012149ee Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 12:37:31 +0100 Subject: [PATCH 16/36] sharing/report: add test case --- radicale/tests/test_sharing.py | 90 ++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index cd19aa95..40807956 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -3038,6 +3038,96 @@ permissions: RrWw""") assert answer_dict['Lines'] == 1 assert answer_dict['Content'][0]['Permissions'] == "RrWw" + def test_sharing_api_map_report_base(self) -> None: + """share-by-map API usage tests related to report.""" + 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"}}) + + json_dict: dict + + path_mapped = "/owner/abook1.vcf/" + path_shared_r = "/user/abook-shared-by-owner.vcf/" + + logging.info("\n*** prepare and test access") + self.create_addressbook(path_mapped, login="owner:ownerpw") + contact = get_file_content("contact1.vcf") + path_mapped_item = path_mapped + "contact.vcf" + path_shared_item = path_shared_r + "contact.vcf" + self.put(path_mapped_item, contact, 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}}) + + # 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) + + # check REPORT as owner + logging.info("\n*** REPORT collection owner -> ok") + _, responses = self.report(path_mapped, """\ + + + + + + + """ + path_mapped_item + """ +""", login="owner:ownerpw") + assert len(responses) == 1 + logging.info("response: %r", responses) + response = responses[path_mapped_item] + assert isinstance(response, dict) + status, prop = response["D:getetag"] + assert status == 200 and prop.text + + # check REPORT as user + logging.info("\n*** REPORT collection user -> ok") + _, responses = self.report(path_shared_r, """\ + + + + + + + """ + path_shared_r + """ +""", login="user:userpw") + assert len(responses) == 1 + logging.info("response: %r", responses) + response = responses[path_shared_item] + assert isinstance(response, dict) + status, prop = response["D:getetag"] + assert status == 200 and prop.text + def test_sharing_api_map_propfind_overlay_api_base(self) -> None: """share-by-map API usage tests related to proppatch.""" self.configure({"auth": {"type": "htpasswd", From f8a1eaf91af6fdd00f3121923ac63197fddabebc Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 12:38:21 +0100 Subject: [PATCH 17/36] sharing/tests: cosmetics --- radicale/tests/test_sharing.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 40807956..a0c8753c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -1833,7 +1833,7 @@ class TestSharingApiSanity(BaseTest): response = responses[path_shared] assert isinstance(response, dict) - def test_sharing_api_map_propfind(self) -> None: + def test_sharing_api_map_propfind_base(self) -> None: """share-by-map API usage tests related to propfind.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, @@ -3231,7 +3231,7 @@ permissions: RrWw""") _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # verify PROPFIND as user - logging.info("\n*** PROPFIND collection user -> ok") + logging.info("\n*** PROPFIND collection owner -> ok") propfind_calendar_color = get_file_content("propfind_multiple.xml") _, responses = self.propfind(path_mapped, propfind_calendar_color, login="owner:ownerpw") logging.info("response: %r", responses) @@ -3757,7 +3757,7 @@ permissions: RrWw""") assert "D:all" not in priviledges_list # verify PROPPATCH as user - logging.info("\n*** PROPFIND collection user -> ok") + logging.info("\n*** PROPFIND collection user -> color #AAAAAA") color = self._propfind_calendar_color(path_shared_r, login="user:userpw") assert color == "#AAAAAA" @@ -3973,7 +3973,7 @@ permissions: RrWw""") _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # verify PROPPATCH as user - logging.info("\n*** PROPFIND color collection collection user -> ok") + logging.info("\n*** PROPFIND color collection user -> #AAAAAA") color = self._propfind_calendar_color(path_shared_r, login="user:userpw") assert color == "#AAAAAA" @@ -4012,7 +4012,7 @@ permissions: RrWw""") assert answer_dict['Content'][0]['Properties']['C:calendar-description'] == "USER" # verify PROPPATCH as user - logging.info("\n*** PROPFIND color collection collection user -> ok") + logging.info("\n*** PROPFIND color collection user -> #BBBBBB") color = self._propfind_calendar_color(path_shared_r, login="user:userpw") assert color == "#BBBBBB" From edc64b5ba791e76e27f62e52b4ad74e959964bd8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 12:39:21 +0100 Subject: [PATCH 18/36] sharing/report: add missing backmap --- radicale/app/report.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index b940fc92..fe81af44 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -255,7 +255,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], # Retrieve everything required for finishing the request. retrieved_items = list(retrieve_items( - base_prefix, path, collection, hreferences, main_filters, multistatus)) + base_prefix, path, collection, hreferences, main_filters, multistatus, share)) collection_tag = collection.tag # !!! Don't access storage after this !!! unlock_storage_fn() @@ -743,19 +743,22 @@ def xml_item_response(base_prefix: str, href: str, def retrieve_items( base_prefix: str, path: str, collection: storage.BaseCollection, hreferences: Iterable[str], filters: Sequence[ET.Element], - multistatus: ET.Element) -> Iterator[Tuple[radicale_item.Item, bool]]: + multistatus: ET.Element, share: Union[dict | None]) -> Iterator[Tuple[radicale_item.Item, bool]]: """Retrieves all items that are referenced in ``hreferences`` from ``collection`` and adds 404 responses for missing and invalid items to ``multistatus``.""" collection_requested = False - def get_names() -> Iterator[str]: + def get_names(share: Union[dict | None]) -> Iterator[str]: """Extracts all names from references in ``hreferences`` and adds 404 responses for invalid references to ``multistatus``. If the whole collections is referenced ``collection_requested`` gets set to ``True``.""" nonlocal collection_requested for hreference in hreferences: + if share: + # map back to owner + hreference = hreference.replace(share['PathOrToken'], share['PathMapped']) try: name = pathutils.name_from_path(hreference, collection) except ValueError as e: @@ -772,7 +775,7 @@ def retrieve_items( # Reference is a collection collection_requested = True - for name, item in collection.get_multi(get_names()): + for name, item in collection.get_multi(get_names(share)): if not item: uri = pathutils.unstrip_path(posixpath.join(collection.path, name)) response = xml_item_response(base_prefix, uri, found_item=False) From aa5d59a48d34222a7e1719c76ed5f96132e62fe1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 12:40:30 +0100 Subject: [PATCH 19/36] sharing/propfind: bugfix related to backmap in case of listing --- radicale/app/propfind.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index d5260d5c..480b0ec4 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -25,8 +25,7 @@ import posixpath import socket import xml.etree.ElementTree as ET from http import client -from typing import (Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, - Union) +from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple from radicale import (httputils, pathutils, rights, storage, types, utils, xmlutils) @@ -37,7 +36,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, share: Union[dict, None] = None) -> Optional[ET.Element]: + user: str, encoding: str, max_resource_size: int, shares: dict = {}) -> Optional[ET.Element]: """Read and answer PROPFIND requests. Read rfc4918-9.1 for info. @@ -74,7 +73,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, share=share)) + allprop=allprop, propname=propname, max_resource_size=max_resource_size, shares=shares)) return multistatus @@ -82,7 +81,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, share: Union[dict, None] = None) -> ET.Element: + propname: bool = False, allprop: bool = False, shares: dict = {}) -> 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,6 +101,10 @@ 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 uri in shares: + share = shares[uri] + else: + share = None if share: # backmap uri = uri.replace(share['PathMapped'], share['PathOrToken']) @@ -436,7 +439,7 @@ class ApplicationPartPropfind(ApplicationBase): """Manage PROPFIND request.""" http_depth = environ.get("HTTP_DEPTH", "0") permissions_filter = None - share = None + shares: dict = {} if self._sharing._enabled: # Sharing by token or map (if enabled) share = self._sharing.sharing_collection_resolver(path, user) @@ -445,6 +448,7 @@ class ApplicationPartPropfind(ApplicationBase): path = share['PathMapped'] user = share['Owner'] permissions_filter = share['Permissions'] + shares[share['PathMapped']] = share access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED @@ -497,10 +501,11 @@ class ApplicationPartPropfind(ApplicationBase): c_items_iter = iter(self._storage.discover(c_path, "0")) c_allowed_items = list(self._collect_allowed_items(c_items_iter, c_user)) allowed_items = allowed_items + c_allowed_items + shares[c_path] = share 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, share=share) + allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, shares=shares) if xml_answer is None: return httputils.NOT_ALLOWED return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) From 3cad0491096dc7f4adc3cb6a0993709966a231be Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 15:12:15 +0100 Subject: [PATCH 20/36] logging/limit_content: new option --- DOCUMENTATION.md | 8 ++++++++ config | 3 +++ radicale/config.py | 6 +++++- radicale/utils.py | 6 +++++- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 9e910026..d1864280 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1679,6 +1679,14 @@ Available levels are: Default: `warning` _(< 3.2.0)_ / `info` _(>= 3.2.0)_ +##### limit_content + +_(> 3.7.0)_ + +Limit content of wrapped text (chars) + +Default: `3000` + ##### trace_on_debug _(> 3.5.4)_ diff --git a/config b/config index 86363049..399d6d9e 100644 --- a/config +++ b/config @@ -362,6 +362,9 @@ # Value: debug | info | warning | error | critical #level = info +# Limit content of wrapped text (chars) +#limit_content = 3000 + # do not filter debug messages starting with 'TRACE' #trace_on_debug = False diff --git a/radicale/config.py b/radicale/config.py index 76e4fdbd..0d50dc7b 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -37,7 +37,7 @@ from configparser import RawConfigParser from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) -from radicale import auth, hook, rights, sharing, storage, types, web +from radicale import auth, hook, rights, sharing, storage, types, utils, web from radicale.hook import email from radicale.item import check_and_sanitize_props @@ -614,6 +614,10 @@ This is an automated message. Please do not reply.""", "value": "info", "help": "threshold for the logger", "type": logging_level}), + ("limit_content", { + "value": str(utils.DEFAULT_LIMIT_CONTENT), + "help": "limit content of wrapped text (chars)", + "type": positive_int}), ("trace_on_debug", { "value": "False", "help": "do not filter debug messages starting with 'TRACE'", diff --git a/radicale/utils.py b/radicale/utils.py index 9e59dc7b..d25e08e0 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -66,6 +66,10 @@ UNIT_M: int = (1024 * 1024) UNIT_K: int = (1024) +# Limits +DEFAULT_LIMIT_CONTENT: int = 3000 + + def load_plugin(internal_types: Sequence[str], module_name: str, class_name: str, base_class: Type[_T_co], configuration: "config.Configuration") -> _T_co: @@ -385,7 +389,7 @@ def limit_str(content: str, limit: int) -> str: return content -def textwrap_str(content: str, limit: int = 3000) -> str: +def textwrap_str(content: str, limit: int = DEFAULT_LIMIT_CONTENT) -> str: # TODO: add support for config option and prefix return textwrap.indent(limit_str(content, limit), " ", lambda line: True) From 4c3037b4ac4618cf63a2db72feffd63414819446 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 15:12:46 +0100 Subject: [PATCH 21/36] logging/limit_content: use --- radicale/app/__init__.py | 9 ++++++--- radicale/httputils.py | 10 +++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index e52934dd..b64e2c4b 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -83,6 +83,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _extra_headers: Mapping[str, str] _profiling_per_request: bool = False _profiling_per_request_method: bool = False + _limit_content: int profiler_per_request_method: dict[str, cProfile.Profile] = {} profiler_per_request_method_counter: dict[str, int] = {} profiler_per_request_method_starttime: datetime.datetime @@ -119,6 +120,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead, logger.debug("log request content on debug: %s", self._request_content_on_debug) logger.debug("log response header on debug: %s", self._response_header_on_debug) logger.debug("log response content on debug: %s", self._response_content_on_debug) + self._limit_content = configuration.get("logging", "limit_content") + logger.debug("log limit for content: %d", self._limit_content) self._auth_delay = configuration.get("auth", "delay") self._auth_type = configuration.get("auth", "type") self._web_type = configuration.get("web", "type") @@ -258,7 +261,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, if isinstance(answer, str): if self._response_content_on_debug: if logger.isEnabledFor(logging.DEBUG): - logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer)) + logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer, self._limit_content)) else: if logger.isEnabledFor(logging.DEBUG): logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug") @@ -283,7 +286,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, if self._response_header_on_debug: if logger.isEnabledFor(logging.DEBUG): - logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers))) + logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers), self._limit_content)) else: if logger.isEnabledFor(logging.DEBUG): logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug") @@ -397,7 +400,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, remote_host, remote_useragent, https_info) if self._request_header_on_debug: logger.debug("Request header:\n%s", - utils.textwrap_str(pprint.pformat(self._scrub_headers(environ)))) + utils.textwrap_str(pprint.pformat(self._scrub_headers(environ)), self._limit_content)) else: logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug") diff --git a/radicale/httputils.py b/radicale/httputils.py index d2829ea8..1d9d08b6 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -154,12 +154,12 @@ def read_request_body(configuration: "config.Configuration", environ: types.WSGIEnviron) -> str: content = decode_request(configuration, environ, read_raw_request_body(configuration, environ)) - if configuration.get("logging", "request_content_on_debug"): - if logger.isEnabledFor(logging.DEBUG): + if logger.isEnabledFor(logging.DEBUG): + if configuration.get("logging", "request_content_on_debug"): + _limit_content = configuration.get("logging", "limit_content") logger.debug("Request content (sha256sum): %s", utils.sha256_str(content)) - logger.debug("Request content:\n%s", utils.textwrap_str(content)) - else: - if logger.isEnabledFor(logging.DEBUG): + logger.debug("Request content:\n%s", utils.textwrap_str(content, _limit_content)) + else: logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug") return content From a002c29f524ba174e07914d1fd77f6cdbc9df4bf Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 15:12:55 +0100 Subject: [PATCH 22/36] logging/limit_content: changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c20bd9c..9de58012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Performance: improve `path_to_filesystem()` * Performance: preload access rights from file * Add: [server] delay_on_error option +* Add: [logging] limit_content option ## 3.6.1 From 6f8fc147ed4fdd079af4483c5968a709469bd2da Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 15:18:26 +0100 Subject: [PATCH 23/36] logging/limit_content: add missing one --- radicale/app/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index 44f064e7..fafc1af0 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -83,7 +83,7 @@ class ApplicationBase: if logger.isEnabledFor(logging.DEBUG): if self._response_content_on_debug: logger.debug("Response content (XML):\n%s", - utils.textwrap_str(xmlutils.pretty_xml(xml_content))) + utils.textwrap_str(xmlutils.pretty_xml(xml_content), self._limit_content)) else: logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug") f = io.BytesIO() From 76fb3d5ebf3fff5f1c5ce8d71cc24ef66abdfd89 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 16:51:27 +0100 Subject: [PATCH 24/36] logging/limit_content: fix --- radicale/app/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/radicale/app/base.py b/radicale/app/base.py index fafc1af0..cff58ec1 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -57,6 +57,7 @@ class ApplicationBase: self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content") self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") self._request_content_on_debug = configuration.get("logging", "request_content_on_debug") + self._limit_content = configuration.get("logging", "limit_content") self._hook = hook.load(configuration) def _read_xml_request_body(self, environ: types.WSGIEnviron From c1b46a8b7a365cc00857918a336136d3bf7433aa Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 16:57:49 +0100 Subject: [PATCH 25/36] sharing/propfind: fix Properties overlay --- radicale/app/propfind.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 480b0ec4..6d3a1454 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -315,7 +315,7 @@ def xml_propfind_response( elif tag == xmlutils.make_clark("RADICALE:displayname"): # Only for internal use by the web interface displayname = collection.get_meta("D:displayname") - if share and share['Properties'] and share['Properties']["D:displayname"]: + if share and 'Properties' in share and share['Properties'] is not None and "D:displayname" in share['Properties']: displayname = share['Properties']["D:displayname"] if displayname is not None: element.text = displayname @@ -329,7 +329,7 @@ def xml_propfind_response( is404 = True elif tag == xmlutils.make_clark("D:displayname"): displayname = collection.get_meta("D:displayname") - if share and share['Properties'] and share['Properties']["D:displayname"]: + if share and 'Properties' in share and share['Properties'] is not None and "D:displayname" in share['Properties']: displayname = share['Properties']["D:displayname"] if not displayname and is_leaf: displayname = collection.path @@ -357,12 +357,10 @@ def xml_propfind_response( else: human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) - if share: + if share and 'Properties' in share and share['Properties'] is not None and human_tag in share['Properties']: # 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 share['Properties'][human_tag] is not None: + tag_text = share['Properties'][human_tag] if tag_text is not None: element.text = tag_text else: From a0733a83e33612d59ec5b44872f7d4f2291ee31a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 17:24:55 +0100 Subject: [PATCH 26/36] sharing/collection_list: rename function --- radicale/app/mkcalendar.py | 6 +++--- radicale/app/mkcol.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index 02a821e1..bbcd2636 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -56,9 +56,9 @@ class ApplicationPartMkcalendar(ApplicationBase): return httputils.BAD_REQUEST if self._sharing._enabled: # check for shared collections (all) - collections_share_map = self._sharing.sharing_collection_map_list() - if collections_share_map: - for share in collections_share_map: + collections_share_list = self._sharing.sharing_collection_list() + if collections_share_list: + for share in collections_share_list: if share['PathOrToken'] == path: return httputils.CONFLICT # TODO: use this? diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 66b0fd15..f0a5a131 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -63,9 +63,9 @@ class ApplicationPartMkcol(ApplicationBase): return httputils.NOT_ALLOWED if self._sharing._enabled: # check for shared collections (all) - collections_share_map = self._sharing.sharing_collection_map_list() - if collections_share_map: - for share in collections_share_map: + collections_share_list = self._sharing.sharing_collection_list() + if collections_share_list: + for share in collections_share_list: if share['PathOrToken'] == path: return httputils.CONFLICT with self._storage.acquire_lock("w", user, path=path, request="MKCOL"): From 65982e26cb06b13fc919573cdb8007df42782551 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 17:28:07 +0100 Subject: [PATCH 27/36] sharing/propfind: fix/reorg collection listing --- radicale/app/propfind.py | 52 +++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 6d3a1454..271975f4 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -25,7 +25,8 @@ import posixpath import socket import xml.etree.ElementTree as ET from http import client -from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple +from typing import (Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, + Union) from radicale import (httputils, pathutils, rights, storage, types, utils, xmlutils) @@ -35,7 +36,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]], + allowed_items: Iterable[Tuple[types.CollectionOrItem, str, str]], user: str, encoding: str, max_resource_size: int, shares: dict = {}) -> Optional[ET.Element]: """Read and answer PROPFIND requests. @@ -69,11 +70,14 @@ def xml_propfind(base_prefix: str, path: str, # Writing answer multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) - for item, permission in allowed_items: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND/xml_propfind: shares=%r", shares) + + for item, permission, sharetype in allowed_items: 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, shares=shares)) + allprop=allprop, propname=propname, max_resource_size=max_resource_size, shares=shares, sharetype=sharetype)) return multistatus @@ -81,7 +85,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, shares: dict = {}) -> ET.Element: + propname: bool = False, allprop: bool = False, shares: dict = {}, sharetype: Union[str | 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") @@ -101,10 +105,22 @@ 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 uri in shares: - share = shares[uri] - else: - share = None + + # lookup share + share = None + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND/xml_propfind: sharetype=%r item.path=%r", sharetype, uri) + for entry in shares: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND/xml_propfind: entry=%r", entry) + if entry is not None: + if shares[entry]['PathMapped'] == uri: + if sharetype is None or shares[entry]['ShareType'] == sharetype: + share = shares[entry] + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND/xml_propfind: share=%r", share) + break + if share: # backmap uri = uri.replace(share['PathMapped'], share['PathOrToken']) @@ -438,6 +454,7 @@ class ApplicationPartPropfind(ApplicationBase): http_depth = environ.get("HTTP_DEPTH", "0") permissions_filter = None shares: dict = {} + allowed_items: list = [] if self._sharing._enabled: # Sharing by token or map (if enabled) share = self._sharing.sharing_collection_resolver(path, user) @@ -446,7 +463,7 @@ class ApplicationPartPropfind(ApplicationBase): path = share['PathMapped'] user = share['Owner'] permissions_filter = share['Permissions'] - shares[share['PathMapped']] = share + shares[share['PathOrToken']] = share access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED @@ -473,15 +490,16 @@ class ApplicationPartPropfind(ApplicationBase): return httputils.NOT_ALLOWED # put item back items_iter = itertools.chain([item], items_iter) - allowed_items = list(self._collect_allowed_items(items_iter, user)) + for item, permission in list(self._collect_allowed_items(items_iter, user)): + allowed_items.append((item, permission, None)) if self._sharing._enabled: if http_depth == "1": if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND: get shared collections") # 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: + collections_share_list = self._sharing.sharing_collection_list(User=user, Enabled=True, Hidden=False) + if collections_share_list: + for share in collections_share_list: c_share = share['PathOrToken'] c_path = share['PathMapped'] c_user = share['Owner'] @@ -498,8 +516,10 @@ class ApplicationPartPropfind(ApplicationBase): with self._storage.acquire_lock("r", c_user): c_items_iter = iter(self._storage.discover(c_path, "0")) c_allowed_items = list(self._collect_allowed_items(c_items_iter, c_user)) - allowed_items = allowed_items + c_allowed_items - shares[c_path] = share + for item, permission in c_allowed_items: + allowed_items.append((item, permission, share['ShareType'])) + shares[c_share] = share + headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} xml_answer = xml_propfind(base_prefix, path, xml_content, From 08612e3c337b852f7c567d10a8a828ab0869e9f0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 17:33:03 +0100 Subject: [PATCH 28/36] sharing/collection-list: rename function --- radicale/sharing/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index b9e0bd67..a81e048a 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -322,16 +322,17 @@ class BaseSharing: 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]: + # list sharings + def sharing_collection_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""" + sharing_collection_list = [] + 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( + else: + # retrieve collections depending on filter + sharing_collection_list += self.database_list_sharing( ShareType="map", OwnerOrUser=User, User=User, @@ -340,8 +341,7 @@ class BaseSharing: HiddenByOwner=Hidden, HiddenByUser=Hidden) - # final - return shared_collection_list + return sharing_collection_list # resolves a path to a share def sharing_collection_resolver(self, path: str, user: str) -> Union[dict, None]: From dac2c9c538954de518e2af93ab5ea009def9f540 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 20:21:39 +0100 Subject: [PATCH 29/36] sharing/report: pass share --- radicale/app/report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index fe81af44..f0fa63a8 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -765,7 +765,7 @@ def retrieve_items( logger.warning("Skipping invalid path %r in REPORT request on " "%r: %s", hreference, path, e) response = xml_item_response(base_prefix, hreference, - found_item=False) + found_item=False, share=share) multistatus.append(response) continue if name: @@ -778,7 +778,7 @@ def retrieve_items( for name, item in collection.get_multi(get_names(share)): if not item: uri = pathutils.unstrip_path(posixpath.join(collection.path, name)) - response = xml_item_response(base_prefix, uri, found_item=False) + response = xml_item_response(base_prefix, uri, found_item=False, share=share) multistatus.append(response) else: yield item, False From bc70be2fd7a6a4fa240860e1dc24a1258d533e91 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 22:48:57 +0100 Subject: [PATCH 30/36] sharing/code: 3.9 --- radicale/app/propfind.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 271975f4..4af86d2b 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -85,7 +85,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, shares: dict = {}, sharetype: Union[str | None] = None) -> ET.Element: + propname: bool = False, allprop: bool = False, shares: dict = {}, sharetype: Union[str, 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") From a6432dc0f12911883d8ef72ea5db190c6fbeb4d8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 22:49:17 +0100 Subject: [PATCH 31/36] sharing/code: 3.9 --- radicale/app/report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index f0fa63a8..ef18cfd8 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -743,13 +743,13 @@ def xml_item_response(base_prefix: str, href: str, def retrieve_items( base_prefix: str, path: str, collection: storage.BaseCollection, hreferences: Iterable[str], filters: Sequence[ET.Element], - multistatus: ET.Element, share: Union[dict | None]) -> Iterator[Tuple[radicale_item.Item, bool]]: + multistatus: ET.Element, share: Union[dict, None]) -> Iterator[Tuple[radicale_item.Item, bool]]: """Retrieves all items that are referenced in ``hreferences`` from ``collection`` and adds 404 responses for missing and invalid items to ``multistatus``.""" collection_requested = False - def get_names(share: Union[dict | None]) -> Iterator[str]: + def get_names(share: Union[dict, None]) -> Iterator[str]: """Extracts all names from references in ``hreferences`` and adds 404 responses for invalid references to ``multistatus``. If the whole collections is referenced ``collection_requested`` From e9d713ec2c02d496e9bd6d5110d31f123d48a84c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 22:49:33 +0100 Subject: [PATCH 32/36] sharing/code: 3.9 --- radicale/tests/test_sharing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index a0c8753c..2ed781e3 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -88,7 +88,7 @@ class TestSharingApiSanity(BaseTest): priviledges_list = [xmlutils.make_human_tag(priviledge.findall("*")[0].tag) for priviledge in priviledges] return priviledges_list - def _propfind_calendar_color(self, path, login) -> Union[str | None]: + def _propfind_calendar_color(self, path, login) -> Union[str, None]: propfind_calendar_color = get_file_content("propfind_calendar_color.xml") _, responses = self.propfind(path=path, data=propfind_calendar_color, login=login) logging.info("response: %r", responses) From 6d520f3b429f8c71fd94ae81cd38d47a219951c6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 07:04:50 +0100 Subject: [PATCH 33/36] sharing/create: do not allow additional share with PathMapped for same User --- radicale/sharing/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index a81e048a..6a6ee954 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -899,6 +899,12 @@ class BaseSharing: else: User = str(User) + # lookup existing shares with requested PathMapped for same User + shares = self.database_list_sharing(ShareType=ShareType, PathMapped=PathMapped, User=User) + if len(shares) > 0: + logger.warning(api_info + ": share already exists with PathMapped=%r User=%r", PathMapped, User) + return httputils.CONFLICT + # check access Permissions access = Access(self._rights, user, PathMapped, None) # PathMapped is mandatory if not access.check("r") and "i" not in access.permissions: From 79d48eebd541d389fe005a04529f179b4d45eb44 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 07:05:48 +0100 Subject: [PATCH 34/36] sharing/create: do not allow additional share with PathMapped for same User - testcase --- radicale/tests/test_sharing.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 2ed781e3..cdf60d70 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -812,6 +812,7 @@ class TestSharingApiSanity(BaseTest): path_owner = "/owner/calendar.ics/" path_user = "/user/calendar-owner.ics/" + path_user2 = "/user/calendar-owner2.ics/" self.mkcalendar(path_owner, login="owner:ownerpw") for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): @@ -864,6 +865,13 @@ class TestSharingApiSanity(BaseTest): json_dict['PathMapped'] = path_owner _, headers, answer = self._sharing_api_json("map", "create", 200, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** create map with PathMapped, User, PathOrToken2 (json) -> 409") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathOrToken'] = path_user2 + json_dict['PathMapped'] = path_owner + _, headers, answer = self._sharing_api_json("map", "create", 409, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** create map with PathMapped, User, PathOrToken=PathOwner (json) -> 409") json_dict = {} json_dict['User'] = "owner" From f4b2f5a04e41c9deb4505a91f0d08731fa8d2ac6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 07:33:23 +0100 Subject: [PATCH 35/36] sharing/create: do not allow additional share with PathMapped for same User - fix existing test cases --- radicale/tests/test_sharing.py | 110 +++++++++++++++++---------------- 1 file changed, 58 insertions(+), 52 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index cdf60d70..9de2734d 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -1420,8 +1420,8 @@ class TestSharingApiSanity(BaseTest): json_dict: dict path_shared_r = "/user/calendar-shared-by-owner-r.ics/" - path_shared_w = "/user/calendar-shared-by-owner-w.ics/" - path_shared_rw = "/user/calendar-shared-by-owner-rw.ics/" + path_shared_w = "/user1/calendar-shared-by-owner-w.ics/" + path_shared_rw = "/user2/calendar-shared-by-owner-rw.ics/" path_mapped = "/owner/calendar.ics/" logging.info("\n*** prepare and test access") @@ -1459,9 +1459,9 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.info("\n*** create map user/owner:w -> ok") + logging.info("\n*** create map user1/owner:w -> ok") json_dict = {} - json_dict['User'] = "user" + json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_w json_dict['Permissions'] = "w" @@ -1470,9 +1470,9 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.info("\n*** create map user/owner:rw -> ok") + logging.info("\n*** create map user2/owner:rw -> ok") json_dict = {} - json_dict['User'] = "user" + json_dict['User'] = "user2" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_rw json_dict['Permissions'] = "rw" @@ -1491,10 +1491,10 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") logging.info("\n*** fetch collection via map:w -> n/a") - _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") + _, headers, answer = self.request("GET", path_shared_w, check=404, login="user1:user1pw") logging.info("\n*** fetch collection via map:rw -> n/a") - _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") + _, headers, answer = self.request("GET", path_shared_rw, check=404, login="user2:user2pw") # enable maps by user logging.info("\n*** enable map by user:r") @@ -1502,15 +1502,15 @@ class TestSharingApiSanity(BaseTest): json_dict['PathOrToken'] = path_shared_r _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - logging.info("\n*** enable map by user:w") + logging.info("\n*** enable map by user1:w") json_dict = {} json_dict['PathOrToken'] = path_shared_w - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user1:user1pw", json_dict=json_dict) - logging.info("\n*** enable map by user:rw") + logging.info("\n*** enable map by user2:rw") json_dict = {} json_dict['PathOrToken'] = path_shared_rw - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user2:user2pw", json_dict=json_dict) # list adjusted maps logging.info("\n*** list (json->text)") @@ -1522,10 +1522,10 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self.request("GET", path_shared_r, check=200, login="user:userpw") logging.info("\n*** fetch collection via map:w -> fail") - _, headers, answer = self.request("GET", path_shared_w, check=403, login="user:userpw") + _, headers, answer = self.request("GET", path_shared_w, check=403, login="user1:user1pw") logging.info("\n*** fetch collection via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw, check=200, login="user:userpw") + _, headers, answer = self.request("GET", path_shared_rw, check=200, login="user2:user2pw") # list adjusted maps logging.info("\n*** list (json->text)") @@ -1538,10 +1538,10 @@ class TestSharingApiSanity(BaseTest): path = path_shared_r + "/event2.ics" self.put(path, event, check=403, login="user:userpw") - logging.info("\n*** put to collection by user via map:w -> ok") + logging.info("\n*** put to collection by user1 via map:w -> ok") event = get_file_content("event2.ics") path = path_shared_w + "event2.ics" - self.put(path, event, check=201, login="user:userpw") + self.put(path, event, check=201, login="user1:user1pw") # check result logging.info("\n*** fetch event via map:r -> ok") @@ -1550,10 +1550,10 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** fetch event as owner -> ok") _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="owner:ownerpw") - logging.info("\n*** put to collection by user via map:rw -> ok") + logging.info("\n*** put to collection by user2 via map:rw -> ok") event = get_file_content("event3.ics") path = path_shared_rw + "event3.ics" - self.put(path, event, check=201, login="user:userpw") + self.put(path, event, check=201, login="user2:user2pw") # check result logging.info("\n*** fetch event via map:r -> ok") @@ -1563,10 +1563,10 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self.request("GET", path_shared_r + "event3.ics", check=200, login="user:userpw") logging.info("\n*** fetch event via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw + "event2.ics", check=200, login="user:userpw") + _, headers, answer = self.request("GET", path_shared_rw + "event2.ics", check=200, login="user2:user2pw") logging.info("\n*** fetch event via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw + "event3.ics", check=200, login="user:userpw") + _, headers, answer = self.request("GET", path_shared_rw + "event3.ics", check=200, login="user2:user2pw") logging.info("\n*** fetch event as owner -> ok") _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") @@ -1582,10 +1582,10 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self.request("DELETE", path_shared_r + "event1.ics", check=403, login="user:userpw") logging.info("\n*** DELETE from collection by user via map:rw -> ok") - _, headers, answer = self.request("DELETE", path_shared_rw + "event2.ics", check=200, login="user:userpw") + _, headers, answer = self.request("DELETE", path_shared_rw + "event2.ics", check=200, login="user2:user2pw") logging.info("\n*** DELETE from collection by user via map:w -> ok") - _, headers, answer = self.request("DELETE", path_shared_w + "event3.ics", check=200, login="user:userpw") + _, headers, answer = self.request("DELETE", path_shared_w + "event3.ics", check=200, login="user1:user1pw") # check results logging.info("\n*** fetch event as owner -> ok") @@ -1961,8 +1961,8 @@ class TestSharingApiSanity(BaseTest): path_mapped = "/owner/calendarPP.ics/" path_shared_r = "/user/calendarPP-shared-by-owner-r.ics/" - path_shared_w = "/user/calendarPP-shared-by-owner-w.ics/" - path_shared_rw = "/user/calendarPP-shared-by-owner-rw.ics/" + path_shared_w = "/user1/calendarPP-shared-by-owner-w.ics/" + path_shared_rw = "/user2/calendarPP-shared-by-owner-rw.ics/" logging.info("\n*** prepare and test access") self.mkcalendar(path_mapped, login="owner:ownerpw") @@ -2009,8 +2009,8 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** PROPPATCH collection as user -> 404") proppatch = get_file_content("proppatch_remove_calendar_color.xml") _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=404) - _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw", check=404) - _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_w, proppatch, login="user1:user1pw", check=404) + _, responses = self.proppatch(path_shared_rw, proppatch, login="user2:user2pw", check=404) # create map logging.info("\n*** create map user/owner:r -> ok") @@ -2025,9 +2025,9 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.info("\n*** create map user/owner:w -> ok") + logging.info("\n*** create map user1/owner:w -> ok") json_dict = {} - json_dict['User'] = "user" + json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_w json_dict['Permissions'] = "w" @@ -2037,9 +2037,9 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.info("\n*** create map user/owner:rw -> ok") + logging.info("\n*** create map user2/owner:rw -> ok") json_dict = {} - json_dict['User'] = "user" + json_dict['User'] = "user2" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_rw json_dict['Permissions'] = "rw" @@ -2053,30 +2053,27 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** PROPPATCH collection as user -> 403") proppatch = get_file_content("proppatch_set_calendar_color.xml") _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=404) - _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw", check=404) - _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_w, proppatch, login="user1:user1pw", check=404) + _, responses = self.proppatch(path_shared_rw, proppatch, login="user2:user2pw", check=404) # 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) - logging.info("\n*** enable map by user") + logging.info("\n*** enable map by user1") json_dict = {} - json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_w - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user1:user1pw", json_dict=json_dict) - logging.info("\n*** enable map by user") + logging.info("\n*** enable map by user2") json_dict = {} - json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_rw - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user2:user2pw", json_dict=json_dict) # check PROPPATCH as user proppatch = get_file_content("proppatch_remove_calendar_color.xml") @@ -2084,11 +2081,11 @@ class TestSharingApiSanity(BaseTest): _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=403) logging.info("\n*** PROPPATCH collection as user:w -> ok") - _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw") + _, responses = self.proppatch(path_shared_w, proppatch, login="user1:user1pw") logging.info("response: %r", responses) logging.info("\n*** PROPPATCH collection as user:rw -> ok") - _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw") + _, responses = self.proppatch(path_shared_rw, proppatch, login="user2:user2pw") logging.info("response: %r", responses) # check PROPFIND as owner @@ -2129,6 +2126,7 @@ class TestSharingApiSanity(BaseTest): path_user = "/user/calendarM.ics/" path_mapped1 = "/owner/calendar1M.ics/" + path_mapped1r = "/owner/calendar1MR.ics/" path_mapped2 = "/owner/calendar2M.ics/" path_shared1_r = "/user/calendar1M-shared-by-owner-r.ics/" path_shared1_rw = "/user/calendar1M-shared-by-owner-rw.ics/" @@ -2139,6 +2137,8 @@ class TestSharingApiSanity(BaseTest): event = get_file_content("event1.ics") self.put(os.path.join(path_mapped1, "event1.ics"), event, login="owner:ownerpw") + self.mkcalendar(path_mapped1r, login="owner:ownerpw") + self.mkcalendar(path_mapped2, login="owner:ownerpw") event = get_file_content("event2.ics") self.put(os.path.join(path_mapped2, "event2.ics"), event, login="owner:ownerpw") @@ -2178,7 +2178,7 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** create map user/owner:r -> ok") json_dict = {} json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped1 + json_dict['PathMapped'] = path_mapped1r json_dict['PathOrToken'] = path_shared1_r json_dict['Permissions'] = "r" json_dict['Enabled'] = True @@ -2225,21 +2225,18 @@ class TestSharingApiSanity(BaseTest): # enable map by user logging.info("\n*** enable map shared1_r by user") json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped1 + json_dict['PathMapped'] = path_mapped1r json_dict['PathOrToken'] = path_shared1_r _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) logging.info("\n*** enable map shared1_rw by user") json_dict = {} - json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_shared1_rw _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) logging.info("\n*** enable map shared2_rw by user") json_dict = {} - json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped2 json_dict['PathOrToken'] = path_shared2_rw _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) @@ -2276,13 +2273,13 @@ class TestSharingApiSanity(BaseTest): HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_rw, "event1.ics")) # check GET as user - logging.info("\n*** GET event1 as user -> ok") - _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=200, login="user:userpw") + logging.info("\n*** GET event1 from r as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="user:userpw") - logging.info("\n*** GET event1 as user -> ok") + logging.info("\n*** GET event1 from 1/rw as user -> ok") _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=200, login="user:userpw") - logging.info("\n*** GET event1 as user -> 404") + logging.info("\n*** GET event1 from 2/rw as user -> 404") _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=404, login="user:userpw") # check MOVE as user between shares and own calendar @@ -2821,6 +2818,11 @@ permissions: RrWw""") json_dict['PathOrToken'] = path_user1.replace(".ics", "dm-lc" + db_type + ".ics") _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + logging.info("\n*** deletee map user1/owner1, globally disabled / granted m -> 200") + json_dict['PathMapped'] = path_owner1_m + json_dict['PathOrToken'] = path_user1.replace(".ics", "dm-lc" + db_type + ".ics") + _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner1:owner1pw", json_dict=json_dict) + logging.info("\n*** create map user1/owner1, globally enabled") self.configure({"sharing": {"permit_create_map": "True"}}) @@ -2904,12 +2906,16 @@ permissions: RrWw""") json_dict: dict path_owner1 = "/owner1/calendarPGo1.ics/" + path_owner1_rw = "/owner1/calendarPGo1rw.ics/" + path_owner1_RrWw = "/owner1/calendarPGo1RrWw.ics/" path_user1_r = "/user1/calendarPGu1-r.ics/" path_user1_rw = "/user1/calendarPGu1-rw.ics/" path_user1_RrWw = "/user1/calendarPGu1-RrWw.ics/" logging.info("\n*** prepare") self.mkcalendar(path_owner1, login="owner1:owner1pw") + self.mkcalendar(path_owner1_rw, login="owner1:owner1pw") + self.mkcalendar(path_owner1_RrWw, login="owner1:owner1pw") for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): logging.info("\n*** test: %s", db_type) @@ -2952,7 +2958,7 @@ permissions: RrWw""") logging.info("\n*** create map user1/owner1 rw -> 200") json_dict = {} json_dict['User'] = "user1" - json_dict['PathMapped'] = path_owner1 + json_dict['PathMapped'] = path_owner1_rw json_dict['PathOrToken'] = path_user1_rw json_dict['Permissions'] = "rw" json_dict['Enabled'] = True @@ -2985,7 +2991,7 @@ permissions: RrWw""") self.configure({"sharing": {"default_permissions_create_map": "RrWw"}}) json_dict = {} json_dict['User'] = "user1" - json_dict['PathMapped'] = path_owner1 + json_dict['PathMapped'] = path_owner1_RrWw json_dict['PathOrToken'] = path_user1_RrWw _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) From 75571b85ce264af3064cacc70238cd9f1cec146a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 07:36:14 +0100 Subject: [PATCH 36/36] sharing/create: add note to doc --- SHARING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/SHARING.md b/SHARING.md index c359e123..df9853f2 100644 --- a/SHARING.md +++ b/SHARING.md @@ -322,6 +322,7 @@ Create a share by mapping a collection of an `Owner` to an `User`. * Authorization * `PathMapped` is existing and a collection + * `PathMapped` is not existing already as a share target for same `User` * 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`