Merge pull request #1998 from pbiering/feature-sharing-post1

Feature sharing post-merge #1
This commit is contained in:
Peter Bieringer
2026-02-24 21:30:11 +01:00
committed by GitHub
4 changed files with 290 additions and 3 deletions

View File

@@ -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

285
SHARING.md Normal file
View File

@@ -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/<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/<ShareType>/<Hook>`
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
```

View File

@@ -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

View File

@@ -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")