From 372a10626ab3a78629f0e31c5aa421aaaf5f418d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 20:04:01 +0100 Subject: [PATCH 1/5] changelog for https://github.com/Kozea/Radicale/pull/1995 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5100dc53..8757d837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 3.7.0.dev +* Feature: collection sharing by map or token incl. management API + ## 3.6.1 * Fix: MOVE failing with URL-encoded destination header From 615dd53ac76b15d0b4411df22d64b3fbefd549b4 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 20:04:33 +0100 Subject: [PATCH 2/5] sharing: initial doc --- SHARING.md | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 SHARING.md diff --git a/SHARING.md b/SHARING.md new file mode 100644 index 00000000..4432802d --- /dev/null +++ b/SHARING.md @@ -0,0 +1,285 @@ + +# Collection Sharing + +Static collection sharing without permissions filter using soft-links (Unix-only) is supported since storage type `multifilesystem` was implemented, see (Wiki: Sharing Collections)[https://github.com/Kozea/Radicale/wiki/Sharing-Collections] + +With 3.7.0 a major extension was implemented using internal mapping configuration stored in a database and a management API. + +## Sharing Configuration Store + +Types of supported sharing configuration: + + * csv (_>= 3.7.0_) + * files (_>= 3.7.0_) + +### Sharing Configuration Entry Data + + * `ShareType`: type of share + * `token`: token-based share (do not require user authentication) + * `map`: map-based share (requires user authentication) + * `PathOrToken`: token or "virtual" collection, has to be unique (PRIMARY KEY) + * `PathMapped`: target collection + * `Owner`: owner of the share + * `User`: user of the share + * `Permissions`: effective permission of the share + * `EnabledByOwner`: control by owner + * `EnabledByUser`: control by user + * `HiddenByOwner`: control by owner + * `HiddenByUser`: control by user + * `TimestampCreated`: unixtime of creation + * `TimestampUpdated`: unixtime of last update + +`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 + +### Sharing Configuration Entry Storage + +#### CSV + +(_>= 3.7.0_) + +One CSV file containing one row per sharing config, separated by `,` and containing header with columns from above. + +#### Files + +(_>= 3.7.0_) + +File-based configuration store is using encoded `PathOrToken` as filename for each config. File contains the data stored as "dict" in binary Python "pickle" format (same is also used for item cache files). + +## Sharing Access + +### Sharing Access via Maps + +(_>= 3.7.0_) + +Map-based sharing can be accessed as usual after authentication and authorization. + +#### Workflow + +* create map as owner +* enable map as owner (can be combined with "create") +* enable map as user (explicit required to avoid sudden available share) + +In case share should be visible using PROPFIND + +* unhide map as owner (can be combined with "create") +* unhide map as user (explicit required to avoid sudden visible share) + + +### Sharing Access via Tokens + +(_>= 3.7.0_) + +Token-based sharing can be accessed after retrieving the token via + +Token-URI: `/.token/` + +#### Workflow + +* create token as owner +* enable token as owner (can be combined with "create") +* handover URI with token to client + + +## Sharing Configuration Management API + +### Sharing Configuration Management API version 1 + +(_>= 3.7.0_) + +Type: POST API + +Base-URI: `/.sharing/v1//` + +See also test cases in `radicale/tests/test_sharing.py` + +#### Data Format + +##### Input Data Format + +Parsing be controlled by `CONTENT_TYPE` + + * application/x-www-form-urlencoded (_>= 3.7.0_) + * application/json (_>= 3.7.0_) + +##### Output Data Format + +Can be selected by `HTTP_ACCEPT` + + * text/plain (_>= 3.7.0_) + * text/csv (_>= 3.7.0_) - only for "list" + * application/json (_>= 3.7.0_) + +##### Accepted Input Data Fields + + * `PathOrToken`: token or "virtual" collection + * `PathMapped`: target collection + * `Owner`: owner of the share + * `User`: user of the share + * `Permissions`: effective permission of the share + * `Enabled`: owner/user selected by authentication + * `Hidden`: owner/user selected by authentication + +#### API Hooks + +##### API Hook "info" + +Shows what kind of ShareTypes are supported + +* Example: TEXT + +``` +curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info +ApiVersion=1 +Status=success +FeatureEnabledCollectionByMap=True +PermittedCreateCollectionByMap=True +FeatureEnabledCollectionByToken=True +PermittedCreateCollectionByToken=True +``` + +* Example: JSON + +``` +curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq +{ + "ApiVersion": "1", + "Status": "success", + "FeatureEnabledCollectionByMap": true, + "PermittedCreateCollectionByMap": true, + "FeatureEnabledCollectionByToken": true, + "PermittedCreateCollectionByToken": true +} +``` + + +##### API Hook "*/create" + +Create a share + +###### API Hook "token/create" + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathMapped | yes | | +| Permissions | no | r + + * Output + +| Parameter | Value | +| - | - | +| PathOrToken | (autogenerated token) | + +* Example: TEXT + +``` +curl -u user:pass -d "PathMapped=/user/testcalendar1/" http://localhost:5232/.sharing/v1/token/create +ApiVersion=1 +Status=success +PathOrToken=v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw= +``` + + +###### API Hook "map/create" + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathOrToken | yes | | +| PathMapped | yes | | +| Permissions | no | r +| User | yes | | + + * Output: result status + + * Example: TEXT + +``` +curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/cal1/" -d "User=user" http://localhost:5232/.sharing/v1/map/create +ApiVersion=1 +Status=success +``` + + +##### API Hook "*/list" + +List shares (optional with filter) + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathMapped | no | (all) | +| Owner | no | (owned ones) | +| User | no | (filtered) | + + * Output: plain/csv/json + + * Example: CSV + +``` +curl -H "accept: text/csv" -u owner:pass -d "" http://localhost:5232/.sharing/v1/map/list +ShareType,PathOrToken,PathMapped,Owner,User,Permissions,EnabledByOwner,EnabledByUser,HiddenByOwner,HiddenByUser,TimestampCreated,TimestampUpdated +map,/user/cal1-from-owner/,/owner/cal1/,owner,user,r,False,False,True,True,1771962120,1771962120 +``` + + +##### API Hook "*/delete" + +Delete a share selected by `PathOrToken` + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathOrToken | yes | | + + * Output: result status + +##### API Hook "*/update" + +Update a share selected by `PathOrToken` + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathOrToken | yes | n/a | +| PathMapped | no | | +| User | no | | + + * Output: result status + +##### API Hooks "*/(enable|disable|hide|unhide)" + +Enable|disable|hide|unhide a share selected by `PathOrToken` + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathOrToken | yes | n/a | + + * Output: result status + + * Example: TEXT (enable) + +``` +curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/cal1/" -d "User=user" http://localhost:5232/.sharing/v1/map/enable +ApiVersion=1 +Status=success +``` + + * Example: JSON (unhide) + +``` +curl -u owner:pass -d '{"PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/cal1/", "User": "user"} http://localhost:5232/.sharing/v1/map/unhide +ApiVersion=1 +Status=success +``` + + From 14c7c17917adb52da701fb089bc29947e94ea025 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 20:36:22 +0100 Subject: [PATCH 3/5] change ApiVersion to numeric --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index ff3317fb..71e072b7 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -598,7 +598,7 @@ class BaseSharing: answer: dict = {} result: dict = {} result_array: list[dict] - answer['ApiVersion'] = "1" + answer['ApiVersion'] = 1 Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) # action: list From 866b87009262f9d380427e0dea14f860c96eedea Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 20:36:39 +0100 Subject: [PATCH 4/5] sharing: update doc related to numeric API version --- SHARING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SHARING.md b/SHARING.md index 4432802d..62750c61 100644 --- a/SHARING.md +++ b/SHARING.md @@ -144,7 +144,7 @@ PermittedCreateCollectionByToken=True ``` curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq { - "ApiVersion": "1", + "ApiVersion": 1, "Status": "success", "FeatureEnabledCollectionByMap": true, "PermittedCreateCollectionByMap": true, From b1dbae274efa0c578337cc9e396ff50353cd74ba Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 21:08:15 +0100 Subject: [PATCH 5/5] sharing: adjust apiversion testcase --- radicale/tests/test_sharing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 5bcb89b6..febe4732 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -554,14 +554,14 @@ class TestSharingApiSanity(BaseTest): json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) - assert answer_dict['ApiVersion'] == "1" + assert answer_dict['ApiVersion'] == 1 assert answer_dict['Status'] == "success" logging.info("\n*** delete token (json->json)") json_dict = {'PathOrToken': token} _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) - assert answer_dict['ApiVersion'] == "1" + assert answer_dict['ApiVersion'] == 1 assert answer_dict['Status'] == "success" logging.info("\n*** delete token (form->text) -> no longer available")