From 45a6990352295662e5bba87a55bd022fd35f89f9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 21:24:04 +0100 Subject: [PATCH 01/18] sharing: change token format to absolut URL --- SHARING.md | 4 ++-- radicale/sharing/__init__.py | 10 +++++----- radicale/tests/test_sharing.py | 11 +++++------ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/SHARING.md b/SHARING.md index 0638eed7..42fd2d55 100644 --- a/SHARING.md +++ b/SHARING.md @@ -323,14 +323,14 @@ Create a share by mapping a collection of an `Owner` to a token. curl -u user:$userpw -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create ApiVersion=1 Status='success' -PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' +PathOrToken='/.token/v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw0/' ``` * json->json ```bash curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create -{"ApiVersion": 1, "Status": "success", "PathOrToken": "v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw="} +{"ApiVersion": 1, "Status": "success", "PathOrToken": "/.token/v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw0/"} ``` ###### API Hook "(map|bday)/create" diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 7deefa09..068a336b 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -108,7 +108,7 @@ API_TYPES_V1: dict[str, type] = { "Hidden": bool, "Properties": dict} -TOKEN_PATTERN_V1: str = "(v1/[a-zA-Z0-9_=\\-]{44})" +TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}" PATH_PATTERN: str = "([a-zA-Z0-9/.\\-]+)" # TODO: extend or find better source @@ -432,7 +432,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/token: check path: %r", path) if path.startswith("/.token/"): - pattern = re.compile('^/\\.token/' + TOKEN_PATTERN_V1 + '$') + pattern = re.compile('^(/\\.token/' + TOKEN_PATTERN_V1 + '/)$') match = pattern.match(path) if not match: if logger.isEnabledFor(logging.DEBUG): @@ -753,7 +753,7 @@ class BaseSharing: return httputils.bad_request("Invalid value for Permissions") elif key == "PathOrToken": if ShareType == "token": - if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]): + if not re.search('^/.token/' + TOKEN_PATTERN_V1 + '/$', request_data[key]): logger.warning(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for PathOrToken") else: @@ -928,8 +928,8 @@ class BaseSharing: else: User = user - # v1: create uuid token with 2x 32 bytes = 256 bit - token = "v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') + # v1: create uuid token with 2x 32 bytes = 256 bit with base64 encoding but replace '=' with '0' to avoid any additional encoding issues issues + token = "/.token/v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8').replace('=', '0') + "/" if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": %r (Permissions=%r token=%r)", PathMapped, Permissions, token) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index e46ac795..50cba39f 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -703,7 +703,6 @@ class TestSharingApiSanity(BaseTest): form_array: Sequence[str] json_dict: dict - path_token = "/.token/" path_base = "/owner/calendar.ics/" path_base2 = "/owner/calendar2.ics/" @@ -761,10 +760,10 @@ class TestSharingApiSanity(BaseTest): assert "Status='success'" in answer logging.info("\n*** fetch collection using invalid token (without credentials)") - _, headers, answer = self.request("GET", path_token + "v1/invalidtoken", check=401) + _, headers, answer = self.request("GET", "/.token/v1/invalidtoken/", check=401) logging.info("\n*** fetch collection using token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=200) + _, headers, answer = self.request("GET", token, check=200) assert "UID:event" in answer logging.info("\n*** disable token (form->text)") @@ -773,7 +772,7 @@ class TestSharingApiSanity(BaseTest): assert "Status='success'" in answer logging.info("\n*** fetch collection using disabled token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=401) + _, headers, answer = self.request("GET", token, check=401) logging.info("\n*** enable token (form->text)") form_array = ["PathOrToken=" + token] @@ -781,7 +780,7 @@ class TestSharingApiSanity(BaseTest): assert "Status='success'" in answer logging.info("\n*** fetch collection using token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=200) + _, headers, answer = self.request("GET", token, check=200) assert "UID:event" in answer logging.info("\n*** delete token#2 (json->json)") @@ -804,7 +803,7 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self._sharing_api_form("token", "delete", check=404, login="owner:ownerpw", form_array=form_array) logging.info("\n*** fetch collection using deleted token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=401) + _, headers, answer = self.request("GET", token, check=401) def test_sharing_api_map_basic(self) -> None: """share-by-map API basic tests.""" From 62a81899177498adf4c71a53a204997225e1b16a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 21:28:55 +0100 Subject: [PATCH 02/18] contrib/apache,caddy: extend URI for no-auth --- contrib/apache/radicale.conf | 18 +++++++++--------- contrib/caddy/radicale.caddyfile | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/contrib/apache/radicale.conf b/contrib/apache/radicale.conf index 385ee159..205cc975 100644 --- a/contrib/apache/radicale.conf +++ b/contrib/apache/radicale.conf @@ -50,8 +50,8 @@ RewriteCond %{REQUEST_METHOD} GET RewriteRule ^/radicale/$ /radicale/.web/ [R,L] - - # Internal WebUI does not need authentication at all + + # Internal WebUI or Token does not need authentication at all RequestHeader set X-Script-Name /radicale RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" @@ -69,7 +69,7 @@ - + RequestHeader set X-Script-Name /radicale RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" @@ -133,8 +133,8 @@ WSGIScriptAlias /radicale /usr/share/radicale/radicale.wsgi - # Internal WebUI does not need authentication at all - + # Internal WebUI or Token does not need authentication at all + RequestHeader set X-Script-Name /radicale Require local @@ -143,7 +143,7 @@ - + RequestHeader set X-Script-Name /radicale @@ -221,7 +221,7 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" RewriteCond %{REQUEST_METHOD} GET RewriteRule ^/$ /.web/ [R,L] - + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME} @@ -237,7 +237,7 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" - + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME} @@ -292,7 +292,7 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" WSGIScriptAlias / /usr/share/radicale/radicale.wsgi - + ## User authentication handled by "radicale" Require local diff --git a/contrib/caddy/radicale.caddyfile b/contrib/caddy/radicale.caddyfile index b578b383..deaf558b 100644 --- a/contrib/caddy/radicale.caddyfile +++ b/contrib/caddy/radicale.caddyfile @@ -11,13 +11,13 @@ caldav.example.com { redir @get-root /.web/ - # Do not auth on /.web/* - @not-webui { - not path /.web/* + # Do not auth on /.web/* or /.token/* + @not-auth { + not path /.web/* /.token/* } # disable this in case authentication is handled by Radicale - basic_auth @not-webui { + basic_auth @not-auth { USER HASH } From f6d0b0b07c50b57ba78ea920dd62b8aa170e5e89 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 21:32:12 +0100 Subject: [PATCH 03/18] sharing: extend DB with Conversion and Actions(reserved) --- radicale/app/get.py | 6 +-- radicale/app/propfind.py | 4 +- radicale/sharing/__init__.py | 77 +++++++++++++++++++++++++++--------- radicale/sharing/csv.py | 34 +++++++++++++--- radicale/sharing/files.py | 34 +++++++++++++--- 5 files changed, 122 insertions(+), 33 deletions(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 9accb8ce..b1e46540 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -31,7 +31,7 @@ from radicale.log import logger def propose_filename(collection: storage.BaseCollection, share: Union[dict, None] = None) -> str: """Propose a filename for a collection.""" share_bday_automap = False - if share and share['ShareType'] == "bday": + if share and share['Conversion'] == "bday": share_bday_automap = True if collection.tag == "VADDRESSBOOK" and not share_bday_automap: fallback_title = "Address book" @@ -112,7 +112,7 @@ class ApplicationPartGet(ApplicationBase): if not item.tag: return (httputils.NOT_ALLOWED if limited_access else httputils.DIRECTORY_LISTING) - if share and share['ShareType'] == "bday": + if share and share['Conversion'] == "bday": content_type = xmlutils.MIMETYPES["VCALENDAR"] else: content_type = xmlutils.MIMETYPES[item.tag] @@ -130,7 +130,7 @@ class ApplicationPartGet(ApplicationBase): "ETag": item.etag} if content_disposition: headers["Content-Disposition"] = content_disposition - if isinstance(item, storage.BaseCollection) and self._sharing._enabled and share and share['ShareType'] == "bday": + if isinstance(item, storage.BaseCollection) and share and share['Conversion'] == "bday": # convert VCF to ICS answer = item.serialize(vcf_to_ics=True) else: diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 66edf99a..90c89a83 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -124,7 +124,7 @@ def xml_propfind_response( break share_bday_automap = False - if share and share['ShareType'] == "bday": + if share and share['Conversion'] == "bday": share_bday_automap = True if share: @@ -546,7 +546,7 @@ class ApplicationPartPropfind(ApplicationBase): items_iter = itertools.chain([item], items_iter) for item, permission in list(self._collect_allowed_items(items_iter, user)): if self._sharing._enabled and share: - if share['ShareType'] == "bday" and not isinstance(item, storage.BaseCollection): + if share['Conversion'] == "bday" and not isinstance(item, storage.BaseCollection): if not item.convert_vcf_to_ics(): continue allowed_items.append((item, permission, share['ShareType'])) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 068a336b..8ed49526 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -34,7 +34,7 @@ from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") -DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated', 'Properties') +DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated', 'Properties', 'Conversion', 'Actions') # ShareType: # PathOrToken: [PrimaryKey] # PathMapped: @@ -48,6 +48,10 @@ DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner' # TimestampCreated: (when created) # TimestampUpdated: (last update) # Properties: Overlay of collection properties in JSON +# Conversion: None|bday +# bday: check VCARD(vcf) for BDAY and convert to reoccuring VEVENT(ics) +# Actions: Actions structure in JSON +# (future reserved for e.g. "filter", "filter_pre", "filter_post" or anything else, implemented on request) DB_TYPES_V1: dict[str, type] = { "ShareType": str, @@ -62,7 +66,9 @@ DB_TYPES_V1: dict[str, type] = { "HiddenByUser": bool, "TimestampCreated": int, "TimestampUpdated": int, - "Properties": dict + "Properties": dict, + "Conversion": str, + "Actions": dict, } DB_FIELDS_V1_USER_PERMITTED: Sequence[str] = ('EnabledByUser', 'HiddenByUser', 'Properties') @@ -106,7 +112,11 @@ API_TYPES_V1: dict[str, type] = { "Permissions": str, "Enabled": bool, "Hidden": bool, - "Properties": dict} + "Properties": dict, + "Conversion": str, + "Actions": dict, +} + TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}" @@ -116,6 +126,8 @@ 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", "D:displayname") +CONVERSIONS_WHITELIST: Sequence[str] = ("bday") + def load(configuration: "config.Configuration") -> "BaseSharing": """Load the sharing database module chosen in configuration.""" @@ -216,9 +228,11 @@ class BaseSharing: PathMapped: Union[str, None] = None, User: Union[str, None] = None, EnabledByOwner: Union[bool, None] = None, - EnabledByUser: Union[bool, None] = None, - HiddenByOwner: Union[bool, None] = None, - HiddenByUser: Union[bool, None] = None) -> list[dict]: + EnabledByUser: Union[bool, None] = None, + HiddenByOwner: Union[bool, None] = None, + HiddenByUser: Union[bool, None] = None, + Conversion: Union[str, None] = None, + ) -> list[dict]: """ retrieve sharing """ return [] @@ -238,7 +252,10 @@ class BaseSharing: EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0, - Properties: Union[dict, None] = None) -> dict: + Properties: Union[dict, None] = None, + Conversion: Union[str, None] = None, + Actions: Union[dict, None] = None, + ) -> dict: """ create sharing """ return {"status": "not-implemented"} @@ -254,7 +271,10 @@ class BaseSharing: HiddenByOwner: Union[bool, None] = None, HiddenByUser: Union[bool, None] = None, Timestamp: int = 0, - Properties: Union[dict, None] = None) -> dict: + Properties: Union[dict, None] = None, + Conversion: Union[str, None] = None, + Actions: Union[dict, None] = None, + ) -> dict: """ update sharing """ return {"status": "not-implemented"} @@ -446,7 +466,7 @@ class BaseSharing: ShareType="token", PathOrToken=match[1]) if result is not None: - logger.info("Sharing/%s: resolved %r->%r, user ->%r, permissions %r", "token", path, result['PathMapped'], result['Owner'], result['Permissions']) + logger.info("Sharing/%s: resolved %r->%r, User=%r, Permissions=%r Conversion=%r", "token", path, result['PathMapped'], result['Owner'], result['Permissions'], result['Conversion']) return result else: if logger.isEnabledFor(logging.DEBUG): @@ -487,7 +507,7 @@ class BaseSharing: logger.debug("TRACE/sharing/map: not found") return None - logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, permissions %r", "map", path, result['PathMapped'], user, result['Owner'], result['Permissions']) + logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r", "map", path, result['PathMapped'], user, result['Owner'], result['Permissions'], result['Conversion']) return result else: if logger.isEnabledFor(logging.DEBUG): @@ -524,7 +544,9 @@ class BaseSharing: logger.debug("TRACE/sharing/bday: not found") return None - logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, permissions %r", "bday", path, result['PathMapped'], user, result['Owner'], result['Permissions']) + if not result['Conversion']: + result['Conversion'] = "bday" + logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r", "bday", path, result['PathMapped'], user, result['Owner'], result['Permissions'], result['Conversion']) return result else: if logger.isEnabledFor(logging.DEBUG): @@ -738,12 +760,14 @@ class BaseSharing: # parameters default PathOrToken: Union[str, None] = None - PathMapped: Union[str, None] = None - User: Union[str, None] = None + PathMapped: Union[str, None] = None + User: Union[str, None] = None Permissions: Union[str, None] = None # no permissions by default - Enabled: Union[bool, None] = None - Hidden: Union[bool, None] = None - Properties: Union[dict, None] = None + Enabled: Union[bool, None] = None + Hidden: Union[bool, None] = None + Properties: Union[dict, None] = None + Conversion: Union[str, None] = None + Actions: Union[dict, None] = None # reserved so far # parameters sanity check for key in request_data: @@ -805,6 +829,16 @@ class BaseSharing: return httputils.bad_request("Property not supported to overlay: %r" % entry) Properties = request_data['Properties'] + if 'Conversion' in request_data: + # verify against whitelist + for entry in request_data['Conversion']: + if entry not in CONVERSIONS_WHITELIST: + return httputils.bad_request("Conversion not supported: %r" % entry) + Conversion = request_data['Conversion'] + + if 'Actions' in request_data: + return httputils.bad_request("Actions currently not supported (reserved for future needs)") + if 'Enabled' in request_data: Enabled = request_data['Enabled'] else: @@ -946,7 +980,10 @@ class BaseSharing: HiddenByOwner=Hidden, HiddenByUser=HiddenByUser, Timestamp=Timestamp, - Properties=Properties) + Properties=Properties, + Conversion=Conversion, + Actions=Actions, + ) if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": result=%r", result) @@ -992,6 +1029,7 @@ class BaseSharing: return httputils.NOT_ALLOWED elif ShareType == "bday": + Conversion = "bday" if self.permit_create_bday is False: if "b" not in access.permissions: logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'b')", PathMapped, user) @@ -1030,7 +1068,10 @@ class BaseSharing: HiddenByOwner=Hidden, HiddenByUser=HiddenByUser, Timestamp=Timestamp, - Properties=Properties) + Properties=Properties, + Conversion=Conversion, + Actions=Actions, + ) else: logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 05d2d9ae..adb1ca1e 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -131,8 +131,14 @@ class Sharing(sharing.BaseSharing): Permissions = row['Permissions'] Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) Properties: Union[dict, None] = None + Conversion: Union[str, None] = None + Actions: Union[dict, None] = None if 'Properties' in row: Properties = row['Properties'] + if 'Conversion' in row: + Conversion = row['Conversion'] + if 'Actions' in row: + Actions = row['Actions'] return { "mapped": True, "ShareType": ShareType, @@ -142,7 +148,10 @@ class Sharing(sharing.BaseSharing): "User": UserShare, "Hidden": Hidden, "Permissions": Permissions, - "Properties": Properties} + "Properties": Properties, + "Conversion": Conversion, + "Actions": Actions, + } return None def database_list_sharing(self, @@ -154,7 +163,9 @@ class Sharing(sharing.BaseSharing): EnabledByOwner: Union[bool, None] = None, EnabledByUser: Union[bool, None] = None, HiddenByOwner: Union[bool, None] = None, - HiddenByUser: Union[bool, None] = None) -> list[dict]: + HiddenByUser: Union[bool, None] = None, + Conversion: Union[str, None] = None, + ) -> list[dict]: """ retrieve sharing """ row: dict index = 0 @@ -212,7 +223,10 @@ class Sharing(sharing.BaseSharing): EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0, - Properties: Union[dict, None] = None) -> dict: + Properties: Union[dict, None] = None, + Conversion: Union[str, None] = None, + Actions: Union[dict, None] = None, + ) -> dict: """ create sharing """ row: dict @@ -267,7 +281,10 @@ class Sharing(sharing.BaseSharing): "HiddenByUser": HiddenByUser, "TimestampCreated": Timestamp, "TimestampUpdated": Timestamp, - "Properties": Properties} + "Properties": Properties, + "Conversion": Conversion, + "Actions": Actions, + } if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/*/create: add row: %r", row) @@ -293,7 +310,10 @@ class Sharing(sharing.BaseSharing): HiddenByOwner: Union[bool, None] = None, HiddenByUser: Union[bool, None] = None, Timestamp: int = 0, - Properties: Union[dict, None] = None) -> dict: + Properties: Union[dict, None] = None, + Conversion: Union[str, None] = None, + Actions: Union[dict, None] = None, + ) -> dict: """ update sharing """ if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: PathOrToken=%r OwnerOrUser=%r PathMapped=%r Properties=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, PathOrToken, OwnerOrUser, PathMapped, Properties, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) @@ -336,6 +356,10 @@ class Sharing(sharing.BaseSharing): self._sharing_cache[index]["HiddenByUser"] = HiddenByUser if Properties is not None: self._sharing_cache[index]["Properties"] = Properties + if Conversion is not None: + self._sharing_cache[index]["Conversion"] = Conversion + if Actions is not None: + self._sharing_cache[index]["Actions"] = Actions # update timestamp self._sharing_cache[index]["TimestampUpdated"] = Timestamp diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index a9c1413f..50fee2e7 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -124,8 +124,14 @@ class Sharing(sharing.BaseSharing): Permissions = row['Permissions'] Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) Properties: Union[dict, None] = None + Conversion: Union[str, None] = None + Actions: Union[dict, None] = None if 'Properties' in row: Properties = row['Properties'] + if 'Conversion' in row: + Conversion = row['Conversion'] + if 'Actions' in row: + Actions = row['Actions'] if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s Properties=%r)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden, Properties) return { @@ -137,7 +143,10 @@ class Sharing(sharing.BaseSharing): "User": UserShare, "Hidden": Hidden, "Permissions": Permissions, - "Properties": Properties} + "Properties": Properties, + "Conversion": Conversion, + "Actions": Actions, + } return None @@ -150,7 +159,9 @@ class Sharing(sharing.BaseSharing): EnabledByOwner: Union[bool, None] = None, EnabledByUser: Union[bool, None] = None, HiddenByOwner: Union[bool, None] = None, - HiddenByUser: Union[bool, None] = None) -> list[dict]: + HiddenByUser: Union[bool, None] = None, + Conversion: Union[str, None] = None, + ) -> list[dict]: """ retrieve sharing """ result = [] @@ -221,7 +232,10 @@ class Sharing(sharing.BaseSharing): EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0, - Properties: Union[dict, None] = None) -> dict: + Properties: Union[dict, None] = None, + Conversion: Union[str, None] = None, + Actions: Union[dict, None] = None, + ) -> dict: """ create sharing """ row: dict @@ -245,7 +259,10 @@ class Sharing(sharing.BaseSharing): "HiddenByUser": HiddenByUser, "TimestampCreated": Timestamp, "TimestampUpdated": Timestamp, - "Properties": Properties} + "Properties": Properties, + "Conversion": Conversion, + "Actions": Actions, + } version = DB_VERSION @@ -275,7 +292,10 @@ class Sharing(sharing.BaseSharing): HiddenByOwner: Union[bool, None] = None, HiddenByUser: Union[bool, None] = None, Timestamp: int = 0, - Properties: Union[dict, None] = None) -> dict: + Properties: Union[dict, None] = None, + Conversion: Union[str, None] = None, + Actions: Union[dict, None] = None, + ) -> dict: """ update sharing """ if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: PathOrToken=%r OwnerOrUser=%r User=%r Properties=%r", ShareType, PathOrToken, OwnerOrUser, User, Properties) @@ -316,6 +336,10 @@ class Sharing(sharing.BaseSharing): row["HiddenByUser"] = HiddenByUser if Properties is not None: row["Properties"] = Properties + if Conversion is not None: + row["Conversion"] = Conversion + if Actions is not None: + row["Actions"] = Actions # update timestamp row["TimestampUpdated"] = Timestamp From 15054a4913845384f4f1001d2021b00a04b0ef65 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 21:34:44 +0100 Subject: [PATCH 04/18] sharing: update doc --- SHARING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SHARING.md b/SHARING.md index 42fd2d55..8fe480f0 100644 --- a/SHARING.md +++ b/SHARING.md @@ -43,6 +43,8 @@ Types of supported sharing configuration: * `TimestampCreated`: unixtime of creation * `TimestampUpdated`: unixtime of last update * `Properties`: overlay properties (limited set whitelisted) + * `Conversion`: conversion method + * `Actions`: (reserved for future usage) `Enabled*`: owner AND user have to enable a share to become usable From 6cb050077dd37d28566a36e9aeeea7bda989f787 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 21:35:27 +0100 Subject: [PATCH 05/18] sharing/test: do not require login by default --- 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 50cba39f..b21ec373 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -71,7 +71,7 @@ class TestSharingApiSanity(BaseTest): _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept) return _, headers, answer - def _propfind_allprop(self, path: str, login) -> dict: + def _propfind_allprop(self, path: str, login: str = "") -> dict: propfind_allprop = get_file_content("allprop.xml") _, responses = self.propfind(path=path, data=propfind_allprop, login=login) logging.info("response: %r", responses) From dc8e69c70aca57533593621baedaf423782b303e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Mar 2026 21:35:53 +0100 Subject: [PATCH 06/18] sharing/token: add support for bday conversion --- radicale/tests/test_sharing.py | 101 +++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index b21ec373..654a1bcd 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4702,3 +4702,104 @@ permissions: RrWw""") # title from default assert 'Content-Disposition' in headers assert 'Calendar.ics' in headers['Content-Disposition'] + + def test_sharing_api_bday_token(self) -> None: + """share-by-bday to a token tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_bday": True, + "permit_create_token": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_token": "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"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + 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}}) + + path_mapped = "/owner/adressbook-" + db_type + ".vcf/" + self.create_addressbook(path_mapped, login="owner:ownerpw") + + contact = get_file_content("contact1.vcf") + path = path_mapped + "/contact1.vcf" + self.put(path, contact, login="owner:ownerpw") + + contact = get_file_content("contact2-with-bday.vcf") + path = path_mapped + "/contact2-with-bday.vcf" + self.put(path, contact, login="owner:ownerpw") + + contact = get_file_content("contact3-with-bday.vcf") + path = path_mapped + "/contact3-with-bday.vcf" + self.put(path, contact, login="owner:ownerpw") + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + +""", login="owner:ownerpw") + logging.info("response: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) + assert "CR:supported-address-data" in response + + # execute GET as owner + logging.info("\n*** GET VCF collection owner -> ok") + _, answer = self.get(path_mapped, login="owner:ownerpw") + assert "contact1" in answer + assert "contact2" in answer + assert "NICKNAME-C3" in answer + + # create map + logging.info("\n*** create token with bday conversion -> ok") + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathMapped'] = path_mapped + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Conversion'] = "bday" + _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert "Status" in answer_dict + assert "PathOrToken" in answer_dict + Token = answer_dict["PathOrToken"] + path_shared = Token + + # execute GET with token + logging.info("\n*** GET bday with token") + _, answer = self.get(path_shared) + assert "VCARD" not in answer + assert "Test-FN-C3 (BDAY)" in answer + assert "Test-FN (BDAY)" in answer + + # check PROPFIND item with token + logging.info("\n*** PROPFIND item with token -> calendar") + response = self._propfind_allprop(path_shared) + logging.debug("response: %r", response) + assert "CR:supported-address-data" not in response + 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) + 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'] From 392cc2e0ff9b5c956245e8b1eb4d31cf7e42bda9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 12:15:42 +0100 Subject: [PATCH 07/18] sharing/doc: replace type bday by conversion --- SHARING.md | 86 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/SHARING.md b/SHARING.md index 8fe480f0..01133c38 100644 --- a/SHARING.md +++ b/SHARING.md @@ -30,12 +30,12 @@ Types of supported sharing configuration: * `ShareType`: type of share * `token`: token-based share (do not require user authentication) * `map`: map-based share (requires user authentication) - * `bday`: map-based share (requires user authentication) with on-the-fly auto-conversion * `PathOrToken`: token or "virtual" collection, has to be unique (PRIMARY KEY) * `PathMapped`: target collection + * `Conversion`: conversion method * `Owner`: owner of the share * `User`: user of the share - * `Permissions`: effective permission of the share (*bday* is always read-only) + * `Permissions`: effective permission of the share * `EnabledByOwner`: control by owner * `EnabledByUser`: control by user * `HiddenByOwner`: control by owner @@ -43,13 +43,18 @@ Types of supported sharing configuration: * `TimestampCreated`: unixtime of creation * `TimestampUpdated`: unixtime of last update * `Properties`: overlay properties (limited set whitelisted) - * `Conversion`: conversion method * `Actions`: (reserved for future usage) `Enabled*`: owner AND user have to enable a share to become usable `Hidden*`: owner AND user have to disable a share to become visible in PROPFIND +#### Supported Conversions + + * `none`: no conversion + * `bday`: auto-mapping on-the-fly a VADDRESSBOOK to a VCALENDAR of all entries containing a `BDAY` + * Permissions enforced to read-only + ### Sharing Configuration Entry Storage #### CSV @@ -170,14 +175,11 @@ File-based configuration store is using encoded `PathOrToken` as filename for ea Map-based sharing can be accessed as usual after authentication and authorization. * *map* is a standard sharing of one collection to another user - * *bday* is special sharing auto-mapping on-the-fly a VADDRESSBOOK to a VCALENDAR of all entries containing a `BDAY` #### Permission Control * `permit_create_map` * supported *rights* permissions: `Mm` - * `permit_create_bday` - * supported *rights* permissions: `Bb` #### Workflow @@ -266,7 +268,8 @@ Status='success' FeatureEnabledCollectionByMap=True PermittedCreateCollectionByMap=True FeatureEnabledCollectionByToken=True -PermittedCreateCollectionByToken=True +PermittedCreateCollectionByToken=True +SupportedConversions=(bday none) ``` * json->json, parsed with `jq` @@ -281,12 +284,11 @@ curl -u user:$userpw --silent -H "accept: application/json" -d "" http://localho "PermittedCreateCollectionByMap": true, "FeatureEnabledCollectionByToken": true, "PermittedCreateCollectionByToken": true, - "FeatureEnabledCollectionByBday": true, - "PermittedCreateCollectionByBday": true + "SupportedConversions": ["bday", "none"] } ``` -##### API Hook "(token|map|bday)/create" +##### API Hook "(token|map)/create" * Authorization * Authenticated user is `Owner` @@ -306,6 +308,7 @@ Create a share by mapping a collection of an `Owner` to a token. | Parameter | Type | Requirement | | - | - | - | | PathMapped | str | mandatory | +| Conversion | str | optional(default:none) | | User | str | optional(default:owner) | | Permissions | str | optional(default:r) | | Enabled | bool | optional(owner/default:False) | @@ -335,7 +338,7 @@ curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathMapped": "/u {"ApiVersion": 1, "Status": "success", "PathOrToken": "/.token/v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw0/"} ``` -###### API Hook "(map|bday)/create" +###### API Hook "map/create" Create a share by mapping a collection of an `Owner` to an `User`. @@ -344,13 +347,8 @@ Create a share by mapping a collection of an `Owner` to an `User`. * `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` - * *map* - * Global permitted by `permit_create_map = True` or `rights` permission `m` - * Global denied by `permit_create_map = False` or `rights` permission `M` - * *bday* - * Global permitted by `permit_create_map = True` or `rights` permission `b` - * Global denied by `permit_create_map = False` or `rights` permission `B` - * `PathMapped` is a VCALENDAR collection + * Global permitted by `permit_create_map = True` or `rights` permission `m` + * Global denied by `permit_create_map = False` or `rights` permission `M` * Input @@ -358,6 +356,7 @@ Create a share by mapping a collection of an `Owner` to an `User`. | - | - | | PathOrToken | str | mandatory | | PathMapped | str | mandatory | +| Conversion | str | optional(default:none) | | User | str | mandatory | | Permissions | str | optional(default:r) | | Enabled | bool | optional(owner/default:False) | @@ -382,7 +381,7 @@ curl -u owner:$ownerpw -H "Content-Type: application/json" -d '{ "PathOrToken": {"ApiVersion": 1, "Status": "success"} ``` -##### API Hook "(all|token|map|bday)/list" +##### API Hook "(all|token|map)/list" List shares (optional with filter) either owned or assigned as user. @@ -463,7 +462,7 @@ curl -s -H "Content-Type: application/json" -u user:$userpw -d "{}" http://local ``` -##### API Hook "(token|map|bday)/delete" +##### API Hook "(token|map)/delete" Delete a share selected by `PathOrToken`. @@ -496,7 +495,7 @@ curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "v {"ApiVersion": 1, "Status": "success"} ``` -##### API Hook "(token|map|bday)/update" +##### API Hook "(token|map)/update" Update a share selected by `PathOrToken`. @@ -536,7 +535,7 @@ curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/ {"ApiVersion": 1, "Status": "success"} ``` -##### API Hooks "(token|map|bday)/(enable|disable|hide|unhide)" +##### API Hooks "(token|map)/(enable|disable|hide|unhide)" Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `PathOrToken` @@ -661,17 +660,16 @@ Preconditions: * Collection with type *adressbook* is existing * Config options enabled in section `sharing`: - * `collection_by_bday` - * `permit_create_bday` + * `collection_by_map` + * `permit_create_map` #### Examples using API - * Create + * Create as *map* ```bash -## Create sharing of type "bday" -curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" -d "PathMapped=/owner/addressbook/" -d "User=owner" http://localhost:5232/.sharing/v1/bday/create - +## Create sharing of type *map* with conversion *bday* +curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" -d "PathMapped=/owner/addressbook/" -d "User=owner" -d "Conversion=bday" http://localhost:5232/.sharing/v1/map/create ## Enable curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/enable @@ -680,6 +678,34 @@ curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://local curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/unhide ``` - * Check + * Fetch *map* -Use e.g. WebUI, an additional (virtual) calendar collection appears +```bash +## Fetch VCALENDAR auto-created from VADDRESSBOOK +curl -u owner:$ownerpw http://localhost:5232/owner/bday-of-addressbook/ +BEGIN:VCALENDAR +... +END:VCALENDAR +``` + +Via WebUI an additional (virtual) calendar collection appears + + * Create as *token* + +```bash +## Create sharing of type *token* with conversion *bday* (shown PathOrToken is an example) +curl -u owner:$ownerpw -d "Enabled=true" -d "Hidden=false" -d "PathMapped=/owner/addressbook/" -d "User=owner" -d "Conversion=bday" http://localhost:5232/.sharing/v1/token/create +ApiVersion=1 +Status='success' +PathOrToken='/.token/v1/lqqwqhZYTGi9uSPsixien_8G5jiSK0FfhNFRGG_t8UA0/' +``` + + * Fetch *map* + +```bash +## Fetch VCALENDAR auto-created from VADDRESSBOOK +curl http://localhost:5232/.token/v1/lqqwqhZYTGi9uSPsixien_8G5jiSK0FfhNFRGG_t8UA0/ +BEGIN:VCALENDAR +... +END:VCALENDAR +``` From 65acbbc12a32c9248e72b07519493ba2e38165dc Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 12:20:53 +0100 Subject: [PATCH 08/18] sharing/db: conversion and enable --- radicale/sharing/csv.py | 19 ++++++------------- radicale/sharing/files.py | 8 ++++++-- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index adb1ca1e..3c3d3c18 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -147,6 +147,8 @@ class Sharing(sharing.BaseSharing): "Owner": Owner, "User": UserShare, "Hidden": Hidden, + "EnabledByOwner": row['EnabledByOwner'], + "EnabledByUser": row['EnabledByUser'], "Permissions": Permissions, "Properties": Properties, "Conversion": Conversion, @@ -173,7 +175,7 @@ class Sharing(sharing.BaseSharing): with self._storage.acquire_lock("r", path=self._sharing_db_file): if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s Conversion=%r", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser, Conversion) for row in self._sharing_cache: if index == 0: @@ -208,6 +210,8 @@ class Sharing(sharing.BaseSharing): pass elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: pass + elif Conversion is not None and row['Conversion'] != Conversion: + pass else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/row: add : %r", row) @@ -218,13 +222,13 @@ class Sharing(sharing.BaseSharing): def database_create_sharing(self, ShareType: str, PathOrToken: str, PathMapped: str, + Conversion: str, Owner: str, User: str, Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0, Properties: Union[dict, None] = None, - Conversion: Union[str, None] = None, Actions: Union[dict, None] = None, ) -> dict: """ create sharing """ @@ -255,17 +259,6 @@ class Sharing(sharing.BaseSharing): # must be unique systemwide logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) return {"status": "conflict"} - elif ShareType == "bday": - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/bday/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) - # check for duplicate map entry - for row in self._sharing_cache: - if row['ShareType'] != "bday": - continue - if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken: - # must be unique systemwide - logger.error("sharing/bday/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) - return {"status": "conflict"} else: return {"status": "error"} diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index 50fee2e7..13ca15a6 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -142,6 +142,8 @@ class Sharing(sharing.BaseSharing): "Owner": Owner, "User": UserShare, "Hidden": Hidden, + "EnabledByOwner": row['EnabledByOwner'], + "EnabledByUser": row['EnabledByUser'], "Permissions": Permissions, "Properties": Properties, "Conversion": Conversion, @@ -166,7 +168,7 @@ class Sharing(sharing.BaseSharing): result = [] if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s Conversion=%r", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser, Conversion) for _ShareType in sharing.SHARE_TYPES_V1: if ShareType is not None and _ShareType != ShareType: @@ -217,6 +219,8 @@ class Sharing(sharing.BaseSharing): pass elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: pass + elif Conversion is not None and row['Conversion'] != Conversion: + pass else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/row: add: %r", row) @@ -227,13 +231,13 @@ class Sharing(sharing.BaseSharing): def database_create_sharing(self, ShareType: str, PathOrToken: str, PathMapped: str, + Conversion: str, Owner: str, User: str, Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0, Properties: Union[dict, None] = None, - Conversion: Union[str, None] = None, Actions: Union[dict, None] = None, ) -> dict: """ create sharing """ From 5038a70e9f7f9b2287fee1c8c7eadd32049fb71b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 12:22:14 +0100 Subject: [PATCH 09/18] sharing/report,propfind: change from type bday to conversion --- radicale/app/propfind.py | 14 +++++++------- radicale/app/report.py | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 90c89a83..68145e23 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -73,11 +73,11 @@ def xml_propfind(base_prefix: str, path: str, if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND/xml_propfind: shares=%r", shares) - for item, permission, sharetype in allowed_items: + for item, permission, conversion 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, sharetype=sharetype)) + allprop=allprop, propname=propname, max_resource_size=max_resource_size, shares=shares, conversion=conversion)) return multistatus @@ -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 = {}, conversion: 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") @@ -109,7 +109,7 @@ def xml_propfind_response( # lookup share share = None if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/PROPFIND/xml_propfind: sharetype=%r item.path=%r", sharetype, uri) + logger.debug("TRACE/PROPFIND/xml_propfind: conversion=%r item.path=%r", conversion, uri) for entry in shares: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND/xml_propfind: check entry=%r", entry) @@ -117,7 +117,7 @@ def xml_propfind_response( if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND/xml_propfind: PathMapped=%r uri=%r", shares[entry]['PathMapped'], uri) if uri.startswith(shares[entry]['PathMapped']): - if sharetype is not None and shares[entry]['ShareType'] == sharetype: + if conversion is not None and shares[entry]['Conversion'] == conversion: share = shares[entry] if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND/xml_propfind: found share=%r", share) @@ -549,7 +549,7 @@ class ApplicationPartPropfind(ApplicationBase): if share['Conversion'] == "bday" and not isinstance(item, storage.BaseCollection): if not item.convert_vcf_to_ics(): continue - allowed_items.append((item, permission, share['ShareType'])) + allowed_items.append((item, permission, share['Conversion'])) else: allowed_items.append((item, permission, None)) if self._sharing._enabled: @@ -577,7 +577,7 @@ 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)) for item, permission in c_allowed_items: - allowed_items.append((item, permission, share['ShareType'])) + allowed_items.append((item, permission, share['Conversion'])) shares[c_share] = share headers = {"DAV": httputils.DAV_HEADERS, diff --git a/radicale/app/report.py b/radicale/app/report.py index c2184f4c..cd51a41f 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -161,7 +161,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path) share_bday_automap = False - if share and share['ShareType'] == "bday": + if share and share['Conversion'] == "bday": share_bday_automap = True if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/REPORT/xml_report(1): share=%r", share) @@ -263,7 +263,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection_tag = collection.tag # !!! Don't access storage after this !!! unlock_storage_fn() - if share and share['ShareType'] == "bday": + if share and share['Conversion'] == "bday": collection_tag = "VCALENDAR" # autoconvert retrieved_items_vcf_to_ics = [] @@ -740,7 +740,7 @@ def xml_item_response(base_prefix: str, href: str, logger.debug("TRACE/REPORT/xml_item_response: found=%s share=%r", found_item, share) share_bday_automap = False - if share and share['ShareType'] == "bday": + if share and share['Conversion'] == "bday": share_bday_automap = True href_element = ET.Element(xmlutils.make_clark("D:href")) @@ -793,7 +793,7 @@ def retrieve_items( if share: # map back to owner hreference = hreference.replace(share['PathOrToken'], share['PathMapped']) - if share['ShareType'] == "bday": + if share['Conversion'] == "bday": if not hreference.endswith('/'): hreference = hreference.rstrip(".ics") + ".vcf" try: From 6a162e3882b86532ee076a4b29b0b6d1dee5d0d6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 12:23:48 +0100 Subject: [PATCH 10/18] sharing: replace type bday by conversion --- radicale/sharing/__init__.py | 275 +++++++++++++++++------------------ 1 file changed, 134 insertions(+), 141 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 8ed49526..25db31d8 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -19,6 +19,7 @@ import base64 import io import json import logging +import os # TODO: remove/3.7.0-final import re import socket import uuid @@ -34,7 +35,7 @@ from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") -DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated', 'Properties', 'Conversion', 'Actions') +DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Conversion', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated', 'Properties', 'Actions') # ShareType: # PathOrToken: [PrimaryKey] # PathMapped: @@ -48,8 +49,8 @@ DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner' # TimestampCreated: (when created) # TimestampUpdated: (last update) # Properties: Overlay of collection properties in JSON -# Conversion: None|bday -# bday: check VCARD(vcf) for BDAY and convert to reoccuring VEVENT(ics) +# Conversion: none|bday +# bday: check VADDRESSBOOK VCARD(vcf) entries for BDAY and convert to VCALENDAR reoccuring VEVENT(ics) # Actions: Actions structure in JSON # (future reserved for e.g. "filter", "filter_pre", "filter_post" or anything else, implemented on request) @@ -73,14 +74,17 @@ DB_TYPES_V1: dict[str, type] = { DB_FIELDS_V1_USER_PERMITTED: Sequence[str] = ('EnabledByUser', 'HiddenByUser', 'Properties') -SHARE_TYPES: Sequence[str] = ('token', 'map', 'bday', 'all') - -SHARE_TYPES_V1: Sequence[str] = ('token', 'map', 'bday') +SHARE_TYPES: Sequence[str] = ('token', 'map', 'all') # token: share by secret token (does not require authentication) # map : share by mapping collection of one user to another as virtual -# bday : share by mapping addressbook-collection of one user to another as virtual calendar-collection # all : only supported for "list" and "info" +SHARE_TYPES_V1: Sequence[str] = ('token', 'map') + +if "SHARING_NO_LEGACY" not in os.environ: # TODO: remove/3.7.0-final + SHARE_TYPES: Sequence[str] = ('token', 'map', 'bday', 'all') # type: ignore[no-redef] # TODO: remove/3.7.0-final + SHARE_TYPES_V1: Sequence[str] = ('token', 'map', 'bday') # type: ignore[no-redef] # TODO: remove/3.7.0-final + API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable', 'info') # list : list sharings (optional filtered) # create : create share by token or map @@ -115,6 +119,7 @@ API_TYPES_V1: dict[str, type] = { "Properties": dict, "Conversion": str, "Actions": dict, + "SupportedConversions": list, } @@ -126,7 +131,7 @@ 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", "D:displayname") -CONVERSIONS_WHITELIST: Sequence[str] = ("bday") +CONVERSIONS_WHITELIST: Sequence[str] = ("bday", "none") def load(configuration: "config.Configuration") -> "BaseSharing": @@ -157,20 +162,21 @@ class BaseSharing: # Sharing self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map") self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token") - self.sharing_collection_by_bday = configuration.get("sharing", "collection_by_bday") + if "SHARING_NO_LEGACY" not in os.environ: # TODO: remove/3.7.0-final + self.sharing_collection_by_map = self.sharing_collection_by_map or configuration.get("sharing", "collection_by_bday") # TODO: remove/3.7.0-final self.permit_create_token = configuration.get("sharing", "permit_create_token") self.permit_create_map = configuration.get("sharing", "permit_create_map") - self.permit_create_bday = configuration.get("sharing", "permit_create_bday") + if "SHARING_NO_LEGACY" not in os.environ: # TODO: remove/3.7.0-final + self.permit_create_map = self.permit_create_map or configuration.get("sharing", "permit_create_bday") # TODO: remove/3.7.0-final self.default_permissions_create_token = configuration.get("sharing", "default_permissions_create_token") self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map") self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay") self.enforce_properties_overlay = configuration.get("sharing", "enforce_properties_overlay") + logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) - logger.info("sharing.collection_by_bday : %s", self.sharing_collection_by_bday) logger.info("sharing.permit_create_token: %s", self.permit_create_token) logger.info("sharing.permit_create_map : %s", self.permit_create_map) - logger.info("sharing.permit_create_bday : %s", self.permit_create_bday) logger.info("sharing.default_permissions_create_token: %r", self.default_permissions_create_token) logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map) logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay) @@ -180,7 +186,7 @@ class BaseSharing: self.sharing_db_type = configuration.get("sharing", "type") logger.info("sharing.database_type: %s", self.sharing_db_type) - if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False) and (self.sharing_collection_by_bday is False)): + if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False)): logger.info("sharing disabled as no feature is enabled") self._enabled = False return @@ -195,7 +201,7 @@ class BaseSharing: """ try: if self.database_init() is False: - logger.info("sharing disabled as no database is active") + logger.warning("sharing disabled as no database is active") self._enabled = False return False except Exception as e: @@ -247,13 +253,13 @@ class BaseSharing: def database_create_sharing(self, ShareType: str, PathOrToken: str, PathMapped: str, + Conversion: str, Owner: str, User: str, Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0, Properties: Union[dict, None] = None, - Conversion: Union[str, None] = None, Actions: Union[dict, None] = None, ) -> dict: """ create sharing """ @@ -350,7 +356,12 @@ class BaseSharing: # *** sharing functions called by request methods *** # list sharings - def sharing_collection_list(self, User: Union[str, None] = None, Enabled: Union[bool, None] = None, Hidden: Union[bool, None] = None) -> list[dict]: + def sharing_collection_list(self, + User: Union[str, None] = None, + Enabled: Union[bool, None] = None, + Hidden: Union[bool, None] = None, + Conversion: Union[str, None] = None, + ) -> list[dict]: """ returning dict with shared collections by filter(User/Enabled/Hidden) or None if not found""" sharing_collection_list = [] @@ -366,27 +377,16 @@ class BaseSharing: EnabledByOwner=Enabled, EnabledByUser=Enabled, HiddenByOwner=Hidden, - HiddenByUser=Hidden) - - if not self.sharing_collection_by_bday: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/bday: not active") - else: - # retrieve collections depending on filter - sharing_collection_list += self.database_list_sharing( - ShareType="bday", - OwnerOrUser=User, - User=User, - EnabledByOwner=Enabled, - EnabledByUser=Enabled, - HiddenByOwner=Hidden, - HiddenByUser=Hidden) + HiddenByUser=Hidden, + Conversion=Conversion, + ) return sharing_collection_list # resolves a path to a share def sharing_collection_resolver(self, path: str, user: str) -> Union[dict, None]: """ returning dict with PathMapped, Owner, Permissions or None if not found""" + logger.debug("TRACE/sharing/resolver: lookup path=%r user=%r", path, user) share = None if path == "/": @@ -396,6 +396,8 @@ class BaseSharing: if self.sharing_collection_by_token: if share is None: share = self.sharing_collection_by_token_resolver(path) + if share is not None and 'error' in share: + return None else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/token: not active") @@ -403,17 +405,12 @@ class BaseSharing: if self.sharing_collection_by_map: if share is None: share = self.sharing_collection_by_map_resolver(path, user) + if share is not None and 'error' in share: + return None else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not active") - if self.sharing_collection_by_bday: - if share is None: - share = self.sharing_collection_by_bday_resolver(path, user) - else: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/bday: not active") - if share is not None: if self.permit_properties_overlay: if share['Permissions'] and "p" not in share['Permissions']: @@ -446,31 +443,44 @@ class BaseSharing: # *** internal sharing functions *** # resolves a token "path" to a share + # dict: share + # None: not supported + # False: supported but not found def sharing_collection_by_token_resolver(self, path) -> Union[dict, None]: """ returning dict with PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_token: if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/token: check path: %r", path) + logger.debug("TRACE/sharing/token/resolver: check path: %r", path) if path.startswith("/.token/"): pattern = re.compile('^(/\\.token/' + TOKEN_PATTERN_V1 + '/)$') match = pattern.match(path) if not match: if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/token: unsupported token: %r", path) - return None + logger.debug("TRACE/sharing/token/resolver: unsupported token: %r", path) + return {'error': 'token-not-supported'} else: # TODO add token validity checks if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/token: supported token found in path: %r (token=%r)", path, match[1]) + logger.debug("TRACE/sharing/token/resolver: supported token: %r", path) result = self.database_get_sharing( ShareType="token", + OnlyEnabled=False, PathOrToken=match[1]) - if result is not None: - logger.info("Sharing/%s: resolved %r->%r, User=%r, Permissions=%r Conversion=%r", "token", path, result['PathMapped'], result['Owner'], result['Permissions'], result['Conversion']) + + if result is None: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/token/resolver: supported token not found: %r", path) + return {'error': 'token-not-found'} + + if result['EnabledByOwner'] is not True: + logger.info("Sharing/%s: resolved path %r->%r, User=%r not enabled by owner", "token", path, result['PathMapped'], result['Owner']) + return {'error': 'token-not-enabled'} + + logger.info("Sharing/%s: resolved %r->%r, User=%r, Permissions=%r Conversion=%r", "token", path, result['PathMapped'], result['Owner'], result['Permissions'], result['Conversion']) return result else: if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/token: no supported prefix found in path: %r", path) + logger.debug("TRACE/sharing/token/resolver: no supported prefix found in path: %r", path) return None else: if logger.isEnabledFor(logging.DEBUG): @@ -483,13 +493,14 @@ class BaseSharing: if self.sharing_collection_by_map: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map/resolver: check path: %r", path) + result = self.database_get_sharing( - ShareType="map", - PathOrToken=path, - User=user) - if result: - pass - else: + ShareType="map", + PathOrToken=path, + OnlyEnabled=False, + User=user) + + if not result: # fallback to parent path parent_path = pathutils.parent_path(path) if logger.isEnabledFor(logging.DEBUG): @@ -497,6 +508,7 @@ class BaseSharing: result = self.database_get_sharing( ShareType="map", PathOrToken=parent_path, + OnlyEnabled=False, User=user) if result: result['PathMapped'] = path.replace(parent_path, result['PathMapped']) @@ -504,55 +516,26 @@ class BaseSharing: logger.debug("TRACE/sharing/map/resolver: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path) else: if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/map: not found") + logger.debug("TRACE/sharing/map/resolver: not found") return None - logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r", "map", path, result['PathMapped'], user, result['Owner'], result['Permissions'], result['Conversion']) - return result + if result: + if result['EnabledByOwner'] is not True: + logger.info("Sharing/%s: resolved path %r->%r, user %r->%r not enabled by owner", "map", path, result['PathMapped'], user, result['Owner']) + return {'error': 'map-not-enabled'} + if result['EnabledByUser'] is not True: + logger.info("Sharing/%s: resolved path %r->%r, user %r->%r not enabled by user", "map", path, result['PathMapped'], user, result['Owner']) + return {'error': 'map-not-enabled'} + + logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r", "map", path, result['PathMapped'], user, result['Owner'], result['Permissions'], result['Conversion']) + return result + + return None else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not active") return None - # resolves a bday "path" to a share - def sharing_collection_by_bday_resolver(self, path: str, user: str) -> Union[dict, None]: - """ returning dict with PathMapped, Owner, Permissions or None if invalid""" - if self.sharing_collection_by_bday: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/bday/resolver: check path: %r", path) - result = self.database_get_sharing( - ShareType="bday", - PathOrToken=path, - User=user) - if result: - pass - else: - # fallback to parent path - parent_path = pathutils.parent_path(path) - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/bday/resolver: check parent path: %r", parent_path) - result = self.database_get_sharing( - ShareType="bday", - PathOrToken=parent_path, - User=user) - if result: - result['PathMapped'] = path.replace(parent_path, result['PathMapped']) - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/bday/resolver: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path) - else: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/bday: not found") - return None - - if not result['Conversion']: - result['Conversion'] = "bday" - logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r", "bday", path, result['PathMapped'], user, result['Owner'], result['Permissions'], result['Conversion']) - return result - else: - if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/bday: not active") - return None - # *** POST API *** def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: # Late import to avoid circular dependency in config @@ -567,27 +550,28 @@ class BaseSharing: ``user`` is empty for anonymous users. Request: - action: (token|map|bday)/list + action: (token|map)/list PathOrToken: (optional for filter) - action: (token|map|bday)/create + action: (token|map)/create PathMapped: (mandatory) Permissions: (default: r) token -> returns - map|bday + map PathOrToken: (mandatory) User: (mandatory) + Conversion: None|bday (optional) - action: (token|map|bday)/update + action: (token|map)/update - action: (token|map|bday)/(delete|disable|enable|hide|unhide) + action: (token|map)/(delete|disable|enable|hide|unhide) PathOrToken: (mandatory) token - map|bday + map PathMapped: (mandatory) User: @@ -648,11 +632,6 @@ class BaseSharing: logger.warning(api_info + ": not enabled by config (collection_by_map)") return httputils.NOT_FOUND - if not self.sharing_collection_by_bday and ShareType == "bday": - # API "token" is not enabled - logger.warning(api_info + ": not enabled by config (collection_by_bday)") - return httputils.NOT_FOUND - # check for valid API hooks if action not in API_HOOKS_V1: if logger.isEnabledFor(logging.DEBUG): @@ -830,11 +809,10 @@ class BaseSharing: Properties = request_data['Properties'] if 'Conversion' in request_data: - # verify against whitelist - for entry in request_data['Conversion']: - if entry not in CONVERSIONS_WHITELIST: - return httputils.bad_request("Conversion not supported: %r" % entry) Conversion = request_data['Conversion'] + # verify against whitelist + if Conversion not in CONVERSIONS_WHITELIST: + return httputils.bad_request("Conversion not supported: %r" % Conversion) if 'Actions' in request_data: return httputils.bad_request("Actions currently not supported (reserved for future needs)") @@ -860,7 +838,12 @@ class BaseSharing: answer['ApiVersion'] = 1 Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) - if not self.sharing_collection_by_map and not self.sharing_collection_by_token and not self.sharing_collection_by_bday: + if "SHARING_NO_LEGACY" not in os.environ: # TODO: remove/3.7.0-final + if ShareType == "bday": # TODO: remove/3.7.0-final + ShareType = "map" # TODO: remove/3.7.0-final + Conversion = "bday" # TODO: remove/3.7.0-final + + 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") @@ -880,12 +863,16 @@ class BaseSharing: ShareType=ShareType, OwnerOrUser=user, PathMapped=PathMapped, - PathOrToken=PathOrToken) + PathOrToken=PathOrToken, + Conversion=Conversion, + ) else: result_array = self.database_list_sharing( OwnerOrUser=user, PathMapped=PathMapped, - PathOrToken=PathOrToken) + PathOrToken=PathOrToken, + Conversion=Conversion, + ) answer['Lines'] = len(result_array) if len(result_array) == 0: @@ -905,6 +892,9 @@ class BaseSharing: logger.warning(api_info + ": missing PathMapped") return httputils.bad_request("Missing PathMapped") + if Conversion is None: + Conversion = "none" + # check whether collection exists with self._storage.acquire_lock("r", user, path=PathMapped): item = next(iter(self._storage.discover(PathMapped)), None) @@ -912,7 +902,12 @@ class BaseSharing: logger.warning(api_info + ": cannot find PathMapped=%r", PathMapped) return httputils.NOT_FOUND if not isinstance(item, storage.BaseCollection): + logger.warning(api_info + ": PathMapped=%r is not a collection", PathMapped) return httputils.METHOD_NOT_ALLOWED + if Conversion == "bday": + if item.tag != "VADDRESSBOOK": + logger.warning(api_info + ": PathMapped=%r is not a VADDRESSBOOK collection (mandatory for Conversion=%r)", PathMapped, Conversion) + return httputils.METHOD_NOT_ALLOWED if Permissions is None: if ShareType == "token": @@ -924,6 +919,12 @@ class BaseSharing: Permissions = "r" else: Permissions = str(Permissions) + if Conversion == "bday": + # bday is read-only + for permission in Permissions: + if permission not in "r": + logger.warning(api_info + ": PathMapped=%r Permissions=%r not supported for Conversion=%r", PathMapped, Permissions, Conversion) + return httputils.METHOD_NOT_ALLOWED if Enabled is None: Enabled = False # security by default @@ -988,7 +989,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": result=%r", result) - elif ShareType in ["map", "bday"]: + elif ShareType == "map": # check preconditions if PathOrToken is None: return httputils.bad_request("Missing PathOrToken") @@ -1006,10 +1007,10 @@ 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) + # lookup existing shares with requested PathMapped for same User and same Conversion + shares = self.database_list_sharing(ShareType=ShareType, PathMapped=PathMapped, User=User, Conversion=Conversion) if len(shares) > 0: - logger.warning(api_info + ": share already exists with PathMapped=%r User=%r", PathMapped, User) + logger.warning(api_info + ": share already exists with PathMapped=%r User=%r Conversion=%r", PathMapped, User, Conversion) return httputils.CONFLICT # check access Permissions @@ -1018,26 +1019,14 @@ class BaseSharing: logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) return httputils.NOT_ALLOWED - if ShareType == "map": - if self.permit_create_map is False: - if "m" not in access.permissions: - logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user) - return httputils.NOT_ALLOWED - else: - if "M" in access.permissions: - logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) - return httputils.NOT_ALLOWED - - elif ShareType == "bday": - Conversion = "bday" - if self.permit_create_bday is False: - if "b" not in access.permissions: - logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'b')", PathMapped, user) - return httputils.NOT_ALLOWED - else: - if "B" in access.permissions: - logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'B')", PathMapped, user) - return httputils.NOT_ALLOWED + if self.permit_create_map is False: + if "m" not in access.permissions: + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user) + return httputils.NOT_ALLOWED + else: + if "M" in access.permissions: + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) + return httputils.NOT_ALLOWED access = Access(self._rights, User, PathOrToken) if not access.check("r"): @@ -1100,7 +1089,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": start") - if ShareType not in ["token", "map", "bday"]: + if ShareType not in SHARE_TYPES_V1: logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") @@ -1226,7 +1215,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": start") - if ShareType not in ["token", "map", "bday"]: + if ShareType not in SHARE_TYPES_V1: logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") @@ -1268,19 +1257,21 @@ class BaseSharing: if ShareType in ["all", "map"]: answer['FeatureEnabledCollectionByMap'] = self.sharing_collection_by_map answer['PermittedCreateCollectionByMap'] = self.permit_create_map + if "SHARING_NO_LEGACY" not in os.environ: # TODO: remove/3.7.0-final + answer['FeatureEnabledCollectionByBday'] = self.sharing_collection_by_map # TODO: remove/3.7.0-final + answer['PermittedCreateCollectionByBday'] = self.permit_create_map # TODO: remove/3.7.0-final if ShareType in ["all", "token"]: answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token answer['PermittedCreateCollectionByToken'] = self.permit_create_token - if ShareType in ["all", "bday"]: - answer['FeatureEnabledCollectionByBday'] = self.sharing_collection_by_bday - answer['PermittedCreateCollectionByBday'] = self.permit_create_bday + if ShareType in ["all", "map", "token"]: + answer['SupportedConversions'] = CONVERSIONS_WHITELIST # action: TOGGLE elif action in API_SHARE_TOGGLES_V1: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/API/POST/" + action) - if ShareType not in ["token", "map", "bday"]: + if ShareType not in SHARE_TYPES_V1: logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") @@ -1365,6 +1356,8 @@ class BaseSharing: if key != 'Content': if API_TYPES_V1[key] is bool or API_TYPES_V1[key] is int: answer_array.append(key + '=' + str(answer[key])) + elif API_TYPES_V1[key] is list: + answer_array.append(key + '=(' + str(" ".join(answer[key])) + ')') else: answer_array.append(key + "='" + str(answer[key]) + "'") if 'Content' in answer and answer['Content'] is not None: From 9485e58081d2bca9e6ee9b9c656e1d65ea05071b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 12:24:07 +0100 Subject: [PATCH 11/18] sharing/config: mark legacy config options --- radicale/config.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/radicale/config.py b/radicale/config.py index 81504364..b223ff3c 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -487,10 +487,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "false", "help": "enable sharing of collection by map", "type": bool}), - ("collection_by_bday", { - "value": "false", - "help": "enable sharing of collection by bday (conversion on-the-fly)", - "type": bool}), + ("collection_by_bday", { # TODO: remove/3.7.0-final + "value": "false", # TODO: remove/3.7.0-final + "help": "enable sharing of collection by bday (conversion on-the-fly)", # TODO: remove/3.7.0-final + "type": bool}), # TODO: remove/3.7.0-final ("permit_create_token", { "value": "false", "help": "permit create of token-based sharing", @@ -499,10 +499,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "false", "help": "permit create of map-based sharing", "type": bool}), - ("permit_create_bday", { - "value": "false", - "help": "permit create of bday-based sharing", - "type": bool}), + ("permit_create_bday", { # TODO: remove/3.7.0-final + "value": "false", # TODO: remove/3.7.0-final + "help": "permit create of bday-based sharing", # TODO: remove/3.7.0-final + "type": bool}), # TODO: remove/3.7.0-final ("permit_properties_overlay", { "value": "false", "help": "permit properties overlay", From 31354ed25cba5f64f8f54105562805643d7cf008 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 12:24:40 +0100 Subject: [PATCH 12/18] sharing/rights: remove no longer required permissions --- radicale/rights/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index 7a2d881e..2e51fe0b 100644 --- a/radicale/rights/__init__.py +++ b/radicale/rights/__init__.py @@ -35,8 +35,6 @@ Permissions: - t: deny create of token-based sharing of collection in case permit_create_token=True (>= 3.7.0) - M: permit create of map-based sharing of collection in case permit_create_map=False (>= 3.7.0) - m: deny create of map-based sharing of collection in case permit_create_map=True (>= 3.7.0) - - B: permit create of bday-based sharing of addressbook collection in case permit_create_bday=False (>= 3.7.0) - - b: deny create of bday-based sharing of addressbook collection in case permit_create_bday=True (>= 3.7.0) Permissions only supported so far in share permissions: - P: permit properties overlay in case permit_properties_overlay=False (>= 3.7.0) @@ -55,7 +53,7 @@ from radicale import config, utils INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only", "from_file") -INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEeBb" +INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEe" def load(configuration: "config.Configuration") -> "BaseRights": From 0fae7c5a7c8933f6ead77512dd95f03dc4aa2f42 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 12:25:54 +0100 Subject: [PATCH 13/18] sharing/tests: replace type bday by conversion --- radicale/tests/__init__.py | 1 + radicale/tests/test_sharing.py | 130 ++++++++++++++++++++++++--------- 2 files changed, 95 insertions(+), 36 deletions(-) diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index 30e8999d..fb40af26 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -152,6 +152,7 @@ class BaseTest: prop_responses[human_tag] = (status_code, element) status = response.find(xmlutils.make_clark("D:status")) if status is not None: + logging.debug("test: prop_responses") assert not prop_responses assert status.text.startswith("HTTP/1.1 ") status_code = int(status.text.split(" ")[1]) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 654a1bcd..b8769e29 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -256,7 +256,6 @@ class TestSharingApiSanity(BaseTest): "htpasswd_encryption": "plain"}, "sharing": { "collection_by_map": "True", - "collection_by_bday": "True", "collection_by_token": "True"}, "rights": {"type": "owner_only"}}) @@ -334,7 +333,6 @@ class TestSharingApiSanity(BaseTest): # path with valid API and hook and all enabled self.configure({"sharing": { "collection_by_map": "True", - "collection_by_bday": "True", "collection_by_token": "True"} }) for sharetype in sharing.SHARE_TYPES: @@ -357,10 +355,8 @@ class TestSharingApiSanity(BaseTest): # When turning on permission to create self.configure({"sharing": { "collection_by_map": "True", - "collection_by_bday": "True", "collection_by_token": "True", "permit_create_map": "True", - "permit_create_bday": "True", "permit_create_token": "True"} }) logging.info("\n*** check API hook: info/all") @@ -369,10 +365,9 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['FeatureEnabledCollectionByMap'] is True, f'FeatureEnabledCollectionByMap {db_type}' assert answer_dict['FeatureEnabledCollectionByToken'] is True, f'FeatureEnabledCollectionByToken {db_type}' - assert answer_dict['FeatureEnabledCollectionByBday'] is True, f'FeatureEnabledCollectionByBday {db_type}' assert answer_dict['PermittedCreateCollectionByMap'] is True, f'PermittedCreateCollectionByMap {db_type}' assert answer_dict['PermittedCreateCollectionByToken'] is True, f'PermittedCreateCollectionByToken {db_type}' - assert answer_dict['PermittedCreateCollectionByBday'] is True, f'PermittedCreateCollectionByBday {db_type}' + assert answer_dict['SupportedConversions'] == ["bday", "none"] def test_sharing_api_list_with_auth(self) -> None: """POST/list with authentication.""" @@ -4191,17 +4186,17 @@ permissions: RrWw""") assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties'] assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties'] - def test_sharing_api_bday_basic(self) -> None: - """share-by-bday basic tests.""" + def test_sharing_api_map_vcf_bday_basic(self) -> None: + """share-by-map with conversion=bday basic tests.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, "sharing": { "type": "csv", - "permit_create_bday": True, + "permit_create_map": True, "permit_properties_overlay": "True", "enforce_properties_overlay": "True", - "collection_by_bday": "True"}, + "collection_by_map": "True"}, "logging": {"request_header_on_debug": "False", "response_content_on_debug": "True", "response_header_on_debug": "True", @@ -4253,27 +4248,40 @@ permissions: RrWw""") assert 'Content-Disposition' in headers assert 'Address%20book.vcf' in headers['Content-Disposition'] - # create map - logging.info("\n*** create bday user/owner:r -> ok") + # create map with unsupported permissions + logging.info("\n*** create map(bday) user/owner:r -> fail") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r - json_dict['Permissions'] = "r" + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rw" 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) + _, headers, answer = self._sharing_api_json("map", "create", check=405, login="owner:ownerpw", json_dict=json_dict) + + # create map + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Conversion'] = "bday" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY"} + _, 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 bday by user") + logging.info("\n*** enable map(bday) 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("bday", "enable", check=200, login="user:userpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # check PROPFIND item as user logging.info("\n*** PROPFIND item as user -> calendar") @@ -4383,19 +4391,17 @@ permissions: RrWw""") status, prop = response["D:getcontenttype"] assert "text/calendar" in str(prop.text) - def test_sharing_api_bday_complex(self) -> None: - """share-by-bday complex tests.""" + def test_sharing_api_map_vcf_bday_complex(self) -> None: + """share-by-map with conversion=bday complex tests.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, "sharing": { "type": "csv", - "permit_create_bday": True, "permit_create_map": True, "permit_properties_overlay": "True", "enforce_properties_overlay": "True", - "collection_by_map": "True", - "collection_by_bday": "True"}, + "collection_by_map": "True"}, "logging": {"request_header_on_debug": "False", "response_content_on_debug": "True", "request_content_on_debug": "True"}, @@ -4446,33 +4452,33 @@ permissions: RrWw""") assert "contact2" in answer # create bday - logging.info("\n*** create bday user/owner:r -> ok") + logging.info("\n*** create map(bday) user/owner:r -> ok") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_bday - json_dict['Permissions'] = "r" + json_dict['Conversion'] = "bday" json_dict['Enabled'] = True json_dict['Hidden'] = False - _, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + _, 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 bday by user - logging.info("\n*** enable bday by user") + logging.info("\n*** enable map(bday) by user") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_bday - _, headers, answer = self._sharing_api_json("bday", "enable", check=200, login="user:userpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # unhide bday by user - logging.info("\n*** unhide bday by user") + logging.info("\n*** unhide map(bday) by user") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_bday - _, headers, answer = self._sharing_api_json("bday", "unhide", check=200, login="user:userpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "unhide", check=200, login="user:userpw", json_dict=json_dict) # create map logging.info("\n*** create map user/owner:r -> ok") @@ -4521,7 +4527,7 @@ permissions: RrWw""") """, login="user:userpw", HTTP_DEPTH="1") - # logging.debug("responses: %r", responses) + logging.debug("responses: %r", responses) response = responses[path_shared_map] assert not isinstance(response, int) logging.debug("response %r: %r", path_shared_map, response) @@ -4555,17 +4561,17 @@ permissions: RrWw""") status, prop = response["RADICALE:getcontentcount"] assert int(str(prop.text)) == 2 - def test_sharing_api_bday_self(self) -> None: - """share-by-bday to self tests.""" + def test_sharing_api_map_vcf_bday_self(self) -> None: + """share-by-map with conversion=bday to self tests.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, "sharing": { "type": "csv", - "permit_create_bday": True, + "permit_create_map": True, "permit_properties_overlay": "True", "enforce_properties_overlay": "True", - "collection_by_bday": "True"}, + "collection_by_map": "True"}, "logging": {"request_header_on_debug": "False", "response_header_on_debug": "True", "response_content_on_debug": "True", @@ -4623,9 +4629,10 @@ permissions: RrWw""") json_dict['User'] = "owner" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared + json_dict['Conversion'] = "bday" json_dict['Enabled'] = False json_dict['Hidden'] = True - _, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) # check PROPFIND item as owner logging.info("\n*** PROPFIND all as owner") @@ -4658,7 +4665,7 @@ permissions: RrWw""") json_dict['PathOrToken'] = path_shared json_dict['Enabled'] = True json_dict['Hidden'] = False - _, headers, answer = self._sharing_api_json("bday", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) # check PROPFIND item as owner logging.info("\n*** PROPFIND all as owner") @@ -4703,7 +4710,7 @@ permissions: RrWw""") assert 'Content-Disposition' in headers assert 'Calendar.ics' in headers['Content-Disposition'] - def test_sharing_api_bday_token(self) -> None: + def test_sharing_api_token_vcf_bday(self) -> None: """share-by-bday to a token tests.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, @@ -4803,3 +4810,54 @@ permissions: RrWw""") # title from default assert 'Content-Disposition' in headers assert 'Calendar.ics' in headers['Content-Disposition'] + + # create map + logging.info("\n*** create token with bday conversion but unsupported permissions -> fail") + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathMapped'] = path_mapped + json_dict['Enabled'] = True + json_dict['Permissions'] = "rw" + json_dict['Hidden'] = False + json_dict['Conversion'] = "bday" + _, headers, answer = self._sharing_api_json("token", "create", check=405, login="owner:ownerpw", json_dict=json_dict) + + def test_sharing_api_token_ics_bday(self) -> None: + """share-by-token ics with bday conversion (has to fail).""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_bday": True, + "permit_create_token": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_token": "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"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + 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}}) + + path_mapped = "/owner/calendar-" + db_type + ".ics/" + self.mkcalendar(path_mapped, login="owner:ownerpw") + + # create map + logging.info("\n*** create token of calendar with bday conversion -> fail") + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathMapped'] = path_mapped + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Conversion'] = "bday" + _, headers, answer = self._sharing_api_json("token", "create", check=405, login="owner:ownerpw", json_dict=json_dict) From d0106c1a6b4c3fa533508bed1725a6f22a9ceec3 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 12:26:18 +0100 Subject: [PATCH 14/18] sharing/via-proxy: add support and test cases --- radicale/sharing/__init__.py | 11 ++++- radicale/tests/__init__.py | 3 ++ radicale/tests/test_sharing.py | 87 ++++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 25db31d8..0ba0f091 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -779,7 +779,11 @@ class BaseSharing: # check for optional parameters if 'PathMapped' in request_data: # used by create or list(filter) - PathMapped = request_data['PathMapped'] + if base_prefix: + PathMapped = request_data['PathMapped'].removeprefix(base_prefix) + logger.debug(api_info + ": remove base_prefix PathMapped=%r->%r", request_data['PathMapped'], PathMapped) + else: + PathMapped = request_data['PathMapped'] if 'PathOrToken' not in request_data: if action == 'info': @@ -1080,7 +1084,10 @@ class BaseSharing: if ShareType == "token": PathOrToken = token - answer['PathOrToken'] = token + if base_prefix: + answer['PathOrToken'] = base_prefix + token + else: + answer['PathOrToken'] = token logger.info(api_info + " success: PathMapped=%r Permissions=%r PathOrToken=%r", PathMapped, Permissions, PathOrToken) diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index fb40af26..4920fe37 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -90,6 +90,7 @@ class BaseTest: remote_useragent = kwargs.pop("remote_useragent", None) remote_host = kwargs.pop("remote_host", None) content_type = kwargs.pop("content_type", None) + x_forwarded_for = kwargs.pop("x_forwarded_for", None) accept = kwargs.pop("accept", None) environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()} for k, v in environ.items(): @@ -108,6 +109,8 @@ class BaseTest: environ["REMOTE_ADDR"] = remote_host if content_type: environ["CONTENT_TYPE"] = content_type + if x_forwarded_for: + environ["HTTP_X_FORWARDED_FOR"] = x_forwarded_for if accept: environ["HTTP_ACCEPT"] = accept environ["REQUEST_METHOD"] = method.upper() diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index b8769e29..3e1f9f8c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -49,31 +49,39 @@ class TestSharingApiSanity(BaseTest): f.write(htpasswd_content) # Helper functions - def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str, None], data: str, content_type: str, accept: Union[str, None]) -> Tuple[int, Dict[str, str], str]: + def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str, None], data: str, content_type: str, accept: Union[str, None], prefix: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: path_base = "/.sharing/v1/" + sharing_type + "/" - _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept) + if prefix is not None: + path_base = prefix + path_base + _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept, x_forwarded_for="127.0.0.2") + else: + _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept) logging.info("received answer:\n%s", "\n".join(answer.splitlines())) return _, headers, answer - def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str, None], form_array: Sequence[str], accept: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str, None], form_array: Sequence[str], accept: Union[str, None] = None, prefix: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: data = "&".join(form_array) content_type = "application/x-www-form-urlencoded" if accept is None: accept = "text/plain" - _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept) + _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept, prefix=prefix) return _, headers, answer - def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str, None], json_dict: dict, accept: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str, None], json_dict: dict, accept: Union[str, None] = None, prefix: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: data = json.dumps(json_dict) content_type = "application/json" if accept is None: accept = "application/json" - _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept) + _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept, prefix=prefix) return _, headers, answer - def _propfind_allprop(self, path: str, login: str = "") -> dict: + def _propfind_allprop(self, path: str, login: str = "", prefix: Union[str, None] = None) -> dict: propfind_allprop = get_file_content("allprop.xml") - _, responses = self.propfind(path=path, data=propfind_allprop, login=login) + if prefix is not None: + path = prefix + path + _, responses = self.propfind(path=path, data=propfind_allprop, login=login, x_forwarded_for="127.0.0.2") + else: + _, responses = self.propfind(path=path, data=propfind_allprop, login=login) logging.info("response: %r", responses) response = responses[path] assert not isinstance(response, int) @@ -679,6 +687,69 @@ class TestSharingApiSanity(BaseTest): self.delete(path_base1, login="owner:ownerpw") self.delete(path_base2, login="owner:ownerpw") + def test_sharing_api_token_usage_proxy(self) -> None: + """share-by-token API tests simulating a reverse proxy - real usage.""" + script_name = "/radicale" + + 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, + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "True", + "response_content_on_debug": "True", + "request_content_on_debug": "True"}, + "server": {"script_name": script_name}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + path_base = "/owner/calendar.ics/" + event = get_file_content("event1.ics") + path = path_base + "/event1.ics" + + logging.info("\n*** prepare") + self.mkcalendar(path_base, login="owner:ownerpw") + self.put(path, event, 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}}) + + logging.info("\n*** test access to collection") + _, headers, answer = self.request("GET", path_base, check=200, login="owner:ownerpw") + assert "UID:event" in answer + + logging.info("\n*** test access to item") + _, headers, answer = self.request("GET", path, check=200, login="owner:ownerpw") + assert "UID:event" in answer + + logging.info("\n*** create token") + json_dict = {} + json_dict["PathMapped"] = script_name + path_base + json_dict["Enabled"] = True + json_dict["Hidden"] = False + _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, prefix=script_name) + answer_dict = json.loads(answer) + assert "Status" in answer_dict + assert "PathOrToken" in answer_dict + Token = answer_dict["PathOrToken"] + logging.debug("Token: %r", Token) + assert Token.startswith(script_name) is True + path_shared = Token + + # check PROPFIND item as owner (remove prefix again as added later) + logging.info("\n*** PROPFIND item as owner -> calendar") + response = self._propfind_allprop(path_shared.removeprefix(script_name), login="owner:ownerpw", prefix=script_name) + logging.debug("response: %r", response) + assert "CR:supported-address-data" not in response + assert "C:supported-calendar-component-set" in response + assert "D:current-user-privilege-set" in response + def test_sharing_api_token_usage(self) -> None: """share-by-token API tests - real usage.""" self.configure({"auth": {"type": "htpasswd", From 8b8315ba9357e6b76c933e5f57d5c6cf34be4f4e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 17:51:23 +0100 Subject: [PATCH 15/18] sharing: improve backmapping --- radicale/app/propfind.py | 14 +++++++++----- radicale/app/report.py | 12 ++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 68145e23..3d674c96 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -129,7 +129,8 @@ def xml_propfind_response( if share: # backmap - uri = uri.replace(share['PathMapped'], share['PathOrToken']) + if uri.startswith(share['PathMapped']): + uri = share['PathOrToken'] + uri.removeprefix(share['PathMapped']) if share_bday_automap and not uri.endswith("/"): uri = uri.rstrip(".vcf") + ".ics" @@ -217,7 +218,8 @@ def xml_propfind_response( child_element.text = xmlutils.make_href(base_prefix, path) if share: # backmap - child_element.text = child_element.text.replace(share['PathMapped'], share['PathOrToken']) + if child_element.text.startswith(share['PathMapped']): + child_element.text = share['PathOrToken'] + child_element.text.removeprefix(share['PathMapped']) if share_bday_automap: child_element.text = child_element.text.rstrip(".vcf") + ".ics" element.append(child_element) @@ -256,13 +258,15 @@ def xml_propfind_response( elif tag == xmlutils.make_clark("D:current-user-principal"): if user: child_element = ET.Element(xmlutils.make_clark("D:href")) - child_element.text = xmlutils.make_href( - base_prefix, "/%s/" % user) if share: # backmap - child_element.text = child_element.text.replace(share['Owner'], share['User']) + child_element.text = xmlutils.make_href( + base_prefix, "/%s/" % share['User']) if share_bday_automap: child_element.text = child_element.text.rstrip(".vcf") + ".ics" + else: + child_element.text = xmlutils.make_href( + base_prefix, "/%s/" % user) element.append(child_element) else: element.append(ET.Element( diff --git a/radicale/app/report.py b/radicale/app/report.py index cd51a41f..1c853cdb 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -749,9 +749,12 @@ def xml_item_response(base_prefix: str, href: str, logger.debug("TRACE/REPORT/xml_report: href=%r", href_element.text) if share: # backmap - href_element.text = href_element.text.replace(share['PathMapped'], share['PathOrToken']) + if href_element.text.startswith(share['PathMapped']): + href_element.text = share['PathOrToken'] + href_element.text.removeprefix(share['PathMapped']) if share_bday_automap: href_element.text = href_element.text.rstrip(".vcf") + ".ics" + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: href=%r (backmapped)", href_element.text) response.append(href_element) if found_item: @@ -790,12 +793,17 @@ def retrieve_items( gets set to ``True``.""" nonlocal collection_requested for hreference in hreferences: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: hreference=%r", hreference) if share: # map back to owner - hreference = hreference.replace(share['PathOrToken'], share['PathMapped']) + if hreference.startswith(share['PathOrToken']): + hreference = share['PathMapped'] + hreference.removeprefix(share['PathOrToken']) if share['Conversion'] == "bday": if not hreference.endswith('/'): hreference = hreference.rstrip(".ics") + ".vcf" + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: hreference=%r (backmapped)", hreference) try: name = pathutils.name_from_path(hreference, collection) except ValueError as e: From eae5566865b0dfba05f9cb416d3e8c894f66b460 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Mar 2026 18:22:12 +0100 Subject: [PATCH 16/18] sharing: bugfix on replacement --- radicale/app/propfind.py | 12 ++++++------ radicale/app/report.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 3d674c96..1e5dc005 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -130,7 +130,7 @@ def xml_propfind_response( if share: # backmap if uri.startswith(share['PathMapped']): - uri = share['PathOrToken'] + uri.removeprefix(share['PathMapped']) + uri = str(share['PathOrToken']) + uri.removeprefix(share['PathMapped']) if share_bday_automap and not uri.endswith("/"): uri = uri.rstrip(".vcf") + ".ics" @@ -219,9 +219,9 @@ def xml_propfind_response( if share: # backmap if child_element.text.startswith(share['PathMapped']): - child_element.text = share['PathOrToken'] + child_element.text.removeprefix(share['PathMapped']) - if share_bday_automap: - child_element.text = child_element.text.rstrip(".vcf") + ".ics" + child_element.text = str(share['PathOrToken']) + child_element.text.removeprefix(share['PathMapped']) + if share_bday_automap and child_element.text.endswith(".vcf"): + child_element.text = child_element.text.removesuffix(".vcf") + ".ics" element.append(child_element) elif tag == xmlutils.make_clark("C:supported-calendar-component-set"): human_tag = xmlutils.make_human_tag(tag) @@ -262,8 +262,8 @@ def xml_propfind_response( # backmap child_element.text = xmlutils.make_href( base_prefix, "/%s/" % share['User']) - if share_bday_automap: - child_element.text = child_element.text.rstrip(".vcf") + ".ics" + if share_bday_automap and child_element.text.endswith(".vcf"): + child_element.text = child_element.text.removesuffix(".vcf") + ".ics" else: child_element.text = xmlutils.make_href( base_prefix, "/%s/" % user) diff --git a/radicale/app/report.py b/radicale/app/report.py index 1c853cdb..dba007be 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -750,9 +750,9 @@ def xml_item_response(base_prefix: str, href: str, if share: # backmap if href_element.text.startswith(share['PathMapped']): - href_element.text = share['PathOrToken'] + href_element.text.removeprefix(share['PathMapped']) - if share_bday_automap: - href_element.text = href_element.text.rstrip(".vcf") + ".ics" + href_element.text = str(share['PathOrToken']) + href_element.text.removeprefix(share['PathMapped']) + if share_bday_automap and href_element.text.endswith(".vcf"): + href_element.text = href_element.text.removesuffix(".vcf") + ".ics" if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/REPORT/xml_report: href=%r (backmapped)", href_element.text) response.append(href_element) @@ -798,10 +798,10 @@ def retrieve_items( if share: # map back to owner if hreference.startswith(share['PathOrToken']): - hreference = share['PathMapped'] + hreference.removeprefix(share['PathOrToken']) + hreference = str(share['PathMapped']) + hreference.removeprefix(share['PathOrToken']) if share['Conversion'] == "bday": - if not hreference.endswith('/'): - hreference = hreference.rstrip(".ics") + ".vcf" + if hreference.endswith(".ics"): + hreference = hreference.removesuffix(".ics") + ".vcf" if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/REPORT/xml_report: hreference=%r (backmapped)", hreference) try: From 0dab5ffcf8d337f9f71485a6d777f95f075b4390 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Wed, 25 Mar 2026 06:53:26 +0100 Subject: [PATCH 17/18] sharing/test: remove leftovers --- radicale/tests/test_sharing.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 3e1f9f8c..e4d69fbb 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -385,10 +385,8 @@ class TestSharingApiSanity(BaseTest): "sharing": { "type": "csv", "permit_create_map": True, - "permit_create_bday": True, "permit_create_token": True, "collection_by_map": "True", - "collection_by_bday": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "true", "request_content_on_debug": "True"}, @@ -491,7 +489,6 @@ class TestSharingApiSanity(BaseTest): "permit_create_map": True, "permit_create_token": True, "collection_by_map": "True", - "collection_by_bday": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "False", "response_content_on_debug": "True", From a923ccf2d30b85196991a3f33c710a7d2a638c55 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Wed, 25 Mar 2026 06:55:56 +0100 Subject: [PATCH 18/18] sharing/legacy bday: fallback for list --- radicale/sharing/__init__.py | 9 +++++++++ radicale/tests/test_sharing.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 0ba0f091..f054ea3f 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -16,6 +16,7 @@ # along with Radicale. If not, see . import base64 +import copy # TODO: remove/3.7.0-final import io import json import logging @@ -878,6 +879,14 @@ class BaseSharing: Conversion=Conversion, ) + if "SHARING_NO_LEGACY" not in os.environ: # TODO: remove/3.7.0-final + # check and change to legacy ShareType # TODO: remove/3.7.0-final + result_array_adj = copy.deepcopy(result_array) # TODO: remove/3.7.0-final + for index in range(0, len(result_array_adj)): # TODO: remove/3.7.0-final + if result_array_adj[index]['Conversion'] == "bday": # TODO: remove/3.7.0-final + result_array_adj[index]['ShareType'] = "bday" # TODO: remove/3.7.0-final + result_array = result_array_adj + answer['Lines'] = len(result_array) if len(result_array) == 0: answer['Status'] = "not-found" diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index e4d69fbb..6da17d10 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4351,6 +4351,20 @@ 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) + # list by user + logging.info("\n*** list by user") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user:userpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + row = answer_dict['Content'][0] + if "SHARING_NO_LEGACY" not in os.environ: # TODO: remove/3.7.0-final + assert row['ShareType'] == "bday" # TODO: remove/3.7.0-final + else: # TODO: remove/3.7.0-final + assert row['ShareType'] == "map" + assert row['Conversion'] == "bday" + # check PROPFIND item as user logging.info("\n*** PROPFIND item as user -> calendar") response = self._propfind_allprop(path_shared_r, login="user:userpw")