From f76f1f21722435ea27c92446821b92dab0ee847b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 07:54:07 +0100 Subject: [PATCH 1/6] sharing: fix Content-Disposition for share --- radicale/app/get.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index e42f27ff..712c99ee 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -20,6 +20,7 @@ import posixpath from http import client +from typing import Union from urllib.parse import quote from radicale import httputils, pathutils, storage, types, xmlutils @@ -27,18 +28,24 @@ from radicale.app.base import Access, ApplicationBase from radicale.log import logger -def propose_filename(collection: storage.BaseCollection) -> str: +def propose_filename(collection: storage.BaseCollection, share: Union[dict, None] = None) -> str: """Propose a filename for a collection.""" - if collection.tag == "VADDRESSBOOK": + share_bday_automap = False + if share and share['ShareType'] == "bday": + share_bday_automap = True + if collection.tag == "VADDRESSBOOK" and not share_bday_automap: fallback_title = "Address book" suffix = ".vcf" - elif collection.tag == "VCALENDAR": + elif collection.tag == "VCALENDAR" or share_bday_automap: fallback_title = "Calendar" suffix = ".ics" else: fallback_title = posixpath.basename(collection.path) suffix = "" - title = collection.get_meta("D:displayname") or fallback_title + if share and 'Properties' in share and share['Properties'] and "D:displayname" in share['Properties']: + title = share['Properties']["D:displayname"] or fallback_title + else: + title = collection.get_meta("D:displayname") or fallback_title if title and not title.lower().endswith(suffix.lower()): title += suffix return title @@ -77,6 +84,7 @@ class ApplicationPartGet(ApplicationBase): # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) permissions_filter = None + share = None if self._sharing._enabled: # Sharing by token or map (if enabled) share = self._sharing.sharing_collection_resolver(path, user) @@ -102,9 +110,12 @@ class ApplicationPartGet(ApplicationBase): if not item.tag: return (httputils.NOT_ALLOWED if limited_access else httputils.DIRECTORY_LISTING) - content_type = xmlutils.MIMETYPES[item.tag] + if self._sharing._enabled and share and share['ShareType'] == "bday": + content_type = xmlutils.MIMETYPES["VCALENDAR"] + else: + content_type = xmlutils.MIMETYPES[item.tag] content_disposition = self._content_disposition_attachment( - propose_filename(item)) + propose_filename(item, share)) elif limited_access: return httputils.NOT_ALLOWED else: From 1c007ab5d279f542b52eb7e43721f9316f13adf9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 07:55:17 +0100 Subject: [PATCH 2/6] sharing: test cases for fix Content-Disposition for share --- radicale/tests/test_sharing.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index b1d5cc21..b3cae613 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -903,6 +903,8 @@ class TestSharingApiSanity(BaseTest): "collection_by_map": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "False", + "response_header_on_debug": "True", + "response_content_on_debug": "True", "request_content_on_debug": "False"}, "rights": {"type": "owner_only"}}) @@ -983,6 +985,9 @@ class TestSharingApiSanity(BaseTest): logging.info("\n*** fetch collection (with credentials) as owner") _, headers, answer = self.request("GET", path_mapped, check=200, login="owner:ownerpw") assert "UID:event" in answer + assert 'Content-Disposition' in headers + # fallback title + assert 'Calendar.ics' in headers['Content-Disposition'] logging.info("\n*** fetch item (with credentials) as owner") _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="owner:ownerpw") @@ -995,6 +1000,9 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self.request("GET", path_shared, check=200, login="user:userpw") assert "UID:event1" in answer assert "UID:event2" in answer + assert 'Content-Disposition' in headers + # title from Properties + assert 'Test.ics' in headers['Content-Disposition'] logging.info("\n*** fetch item via map (with credentials) as user") _, headers, answer = self.request("GET", path_shared_item1, check=200, login="user:userpw") @@ -4197,6 +4205,7 @@ permissions: RrWw""") "collection_by_bday": "True"}, "logging": {"request_header_on_debug": "False", "response_content_on_debug": "True", + "response_header_on_debug": "True", "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) @@ -4238,9 +4247,12 @@ permissions: RrWw""") # execute GET as owner logging.info("\n*** GET VCF collection owner -> ok") - _, answer = self.get(path_mapped, login="owner:ownerpw") + _, headers, answer = self.request("GET", path_mapped, login="owner:ownerpw") assert "contact1" in answer assert "contact2" in answer + assert 'Content-Disposition' in headers + # title from fallback + assert 'Address%20book.vcf' in headers['Content-Disposition'] # create map logging.info("\n*** create bday user/owner:r -> ok") @@ -4251,6 +4263,7 @@ permissions: RrWw""") json_dict['Permissions'] = "r" json_dict['Enabled'] = True json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY"} _, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -4283,7 +4296,7 @@ permissions: RrWw""") # verify content as user logging.info("\n*** GET collection user -> ok") - _, answer = self.get(path_shared_r, login="user:userpw") + _, headers, answer = self.request("GET", path_shared_r, login="user:userpw") assert "BEGIN:VCARD" not in answer assert "BEGIN:VCALENDAR" in answer assert "RRULE:FREQ=YEARLY" in answer @@ -4291,6 +4304,12 @@ permissions: RrWw""") assert "DTEND;VALUE=DATE:19700102" in answer assert "TRANSP:TRANSPARENT" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer + # content type must be adjusted + assert 'Content-Type' in headers + assert 'text/calendar' in headers['Content-Type'] + # title from Properties + assert 'Content-Disposition' in headers + assert 'Test-BDAY.ics' in headers['Content-Disposition'] # verify report as user logging.info("\n*** REPORT collection user -> ok") From f144aacc570d9dcd80c3cc57448c90726addf6f1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 08:06:21 +0100 Subject: [PATCH 3/6] sharing/get: adjust Content-Disposition default for bday share --- radicale/app/get.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 712c99ee..2ada5c26 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -43,7 +43,9 @@ def propose_filename(collection: storage.BaseCollection, share: Union[dict, None fallback_title = posixpath.basename(collection.path) suffix = "" if share and 'Properties' in share and share['Properties'] and "D:displayname" in share['Properties']: - title = share['Properties']["D:displayname"] or fallback_title + title = share['Properties']["D:displayname"] + elif share_bday_automap: + title = fallback_title else: title = collection.get_meta("D:displayname") or fallback_title if title and not title.lower().endswith(suffix.lower()): From 70cc6d4065e996598a38bd699f05a942e94b9a7c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 08:16:37 +0100 Subject: [PATCH 4/6] sharing: code review --- radicale/app/get.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 2ada5c26..9accb8ce 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -112,7 +112,7 @@ class ApplicationPartGet(ApplicationBase): if not item.tag: return (httputils.NOT_ALLOWED if limited_access else httputils.DIRECTORY_LISTING) - if self._sharing._enabled and share and share['ShareType'] == "bday": + if share and share['ShareType'] == "bday": content_type = xmlutils.MIMETYPES["VCALENDAR"] else: content_type = xmlutils.MIMETYPES[item.tag] From acf5741a89386a680f72d400ee0ee04d172a16de Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 08:22:10 +0100 Subject: [PATCH 5/6] sharing: cosmetics --- 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 b3cae613..89fc1b93 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4250,8 +4250,8 @@ permissions: RrWw""") _, headers, answer = self.request("GET", path_mapped, login="owner:ownerpw") assert "contact1" in answer assert "contact2" in answer - assert 'Content-Disposition' in headers # title from fallback + assert 'Content-Disposition' in headers assert 'Address%20book.vcf' in headers['Content-Disposition'] # create map From 9c4779619bab90f9d4023a20cbe920553d6fc92a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 08:22:19 +0100 Subject: [PATCH 6/6] sharing/get: check default Content-Disposition --- radicale/tests/test_sharing.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 89fc1b93..e46ac795 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4568,6 +4568,7 @@ permissions: RrWw""") "enforce_properties_overlay": "True", "collection_by_bday": "True"}, "logging": {"request_header_on_debug": "False", + "response_header_on_debug": "True", "response_content_on_debug": "True", "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) @@ -4693,3 +4694,12 @@ permissions: RrWw""") assert "D:sync-token" not in response assert "C:supported-calendar-component-set" in response assert "D:current-user-privilege-set" in response + + # verify content as owner + logging.info("\n*** GET collection owner -> ok") + _, headers, answer = self.request("GET", path_shared, login="owner:ownerpw") + assert 'Content-Type' in headers + assert 'text/calendar' in headers['Content-Type'] + # title from default + assert 'Content-Disposition' in headers + assert 'Calendar.ics' in headers['Content-Disposition']