Merge pull request #2015 from pbiering/sharing-review-2

Sharing review 2
This commit is contained in:
Peter Bieringer
2026-03-07 08:58:25 +01:00
committed by GitHub
16 changed files with 1078 additions and 531 deletions

View File

@@ -2063,6 +2063,8 @@ Default: 10000
_(>= 3.7.0)_
See also [Collecition Sharing](https://github.com/Kozea/Radicale/blob/master/SHARING.md).
##### type
_(>= 3.7.0)_

View File

@@ -3,7 +3,112 @@
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.
With _3.7.0_ major extension was implemented using internal mapping configuration stored in a database and a management API.
## Sharing Implementation
Implemenation of sharing collections is done in case entry exists in sharing database by replacing provided data on request and adjust if required data in responses.
Permissions are filtered by provided `Permissions`.
### CxDAV requests
#### CxDav request "(DELETE|GET|HEAD|PUT)"
* Actions
* map
* Lookup by
* `path` (provided in request)
* `user` (authenticated)
* Replace
* `user` by `Owner`
* `path` by `PathMapped`
* Activate
* `permissions_filter` by `Permissions`
#### CxDav request "REPORT"
* Actions
* map
* back-map response
* Lookup by
* `path` (provided in request)
* `user` (authenticated)
* Replace
* `user` by `Owner`
* `path` by `PathMapped`
* Activate
* `permissions_filter` by `Permissions`
#### CxDav request "PROPFIND" without HTTP_DEPTH=1
* Actions
* map
* back-map response
* overwrite `Properties` if provided
* Lookup by
* `path` (provided in request)
* `user` (authenticated)
* Replace
* `user` by `Owner`
* `path` by `PathMapped`
* Overlay
* `Properties` if provided
* Activate
* `permissions_filter` by `Permissions`
#### CxDav request "PROPFIND" with HTTP_DEPTH=1
* Actions
* extend list
* Lookup for active shares for `user` in sharing database
* Extend list if conditions are met
* `permissions_filter` by `Permissions`
#### CxDav request "PROPPATCH"
* Actions
* map
* adjust properties of a collection
* Lookup by
* `path` (provided in request)
* `user` (authenticated)
* Replace
* `user` by `Owner`
* `path` by `PathMapped`
* Activate
* `permissions_filter` by `Permissions`
* Depending on `permissions_filter`, global options and `Permissions`
* adjust properties of collection
* adjust whitelisted properties in `Properties` for overlay (see OVERLAY_PROPERTIES_WHITELIST)
#### CxDav request "(MKCALENDAR|MKCOL)"
* Action
* check for conflicts
* Lookup by
* `user` (authenticated)
* Verify for non-existence as `PathOrToken` in sharing database
* `path` (provided in request)
#### CxDav request "(MOVE)"
* Action
* map source
* map destination
* Lookup by
* `path` (provided in request)
* `user` (authenticated)
* `to_path` (provided in request)
* `to_user` (same as `user`)
* Replace
* `user` by `Owner` (of `path`)
* `path` by `PathMapped` (of path)
* `to_user` by `Owner` (of `to_path`)
* `to_path` by `PathMapped` (of `to_path`)
* Activate
* `permissions_filter` by `Permissions` (of `to_path`)
* `to_permissions_filter` by `Permissions` (of `to_path`)
## Sharing Configuration Store
@@ -38,24 +143,18 @@ Types of supported sharing configuration:
#### CSV
(_>= 3.7.0_)
One CSV file containing one row per sharing config, separated by `;` and containing header with columns from above.
If given, properties are stored in JSON format in CSV.
#### 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.
#### Permission Control
@@ -74,15 +173,14 @@ 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>`
Note: requests to not enabled or not even defined tokens will resul tin _401 Not Authorized_
#### Permission Control
* `permit_create_token`
@@ -98,8 +196,6 @@ Token-URI: `/.token/<Token>`
### Sharing Configuration Management API version 1
(_>= 3.7.0_)
Type: POST API
Base-URI: `/.sharing/v1/<ShareType>/<Hook>`
@@ -112,16 +208,16 @@ See also test cases in `radicale/tests/test_sharing.py`
Parsing be controlled by `CONTENT_TYPE`
* application/x-www-form-urlencoded (_>= 3.7.0_)
* application/json (_>= 3.7.0_)
* application/x-www-form-urlencoded
* application/json
##### Output Data Format
Can be selected by `HTTP_ACCEPT`
Can be selected by `HTTP_ACCEPT` - default is equal to provided `CONTENT_TYPE`
* text/plain (_>= 3.7.0_)
* text/csv (_>= 3.7.0_) - only for "list"
* application/json (_>= 3.7.0_)
* text/plain
* text/csv (only for "list")
* application/json
##### Accepted Input Data Fields
@@ -140,21 +236,26 @@ Can be selected by `HTTP_ACCEPT`
Shows what kind of ShareTypes are supported
* Example: TEXT
* Output: text/plain|application/json
```
* Examples
* form->text
```bash
curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info
ApiVersion=1
Status=success
Status='success'
FeatureEnabledCollectionByMap=True
PermittedCreateCollectionByMap=True
FeatureEnabledCollectionByToken=True
PermittedCreateCollectionByToken=True
```
* Example: JSON
* json->json, parsed with `jq`
```
bash
curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq
{
"ApiVersion": 1,
@@ -166,46 +267,53 @@ curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:
}
```
##### API Hook "(token|map)/create"
* Authorization
Authenticated user is `Owner`
* Authenticated user is `Owner`
###### API Hook "token/create"
Create a share by mapping a collection of an `Owner` to a token.
* Authorization
Authenticated user as `Owner` has at least read access to `PathMapped`
* `PathMapped` is existing and a collection
* Authenticated user as `Owner` has at least read access to `PathMapped`
* Global permitted by `permit_create_token = True` or `rights` permission `t`
* Global denied by `permit_create_token = False` or `rights` permission `T`
* Input
| Parameter | Owner |
| Parameter | Type | Requirement |
| - | - | - |
| PathMapped | str | mandatory |
| User | str | optional(default:owner) |
| Permissions | str | optional(default:r) |
| Enabled | bool | optional(owner/default:False) |
| Hidden | bool | optional(owner/default:True) |
| Properties | str | optional |
* Output: text/plain|application/json
| Parameter | Type | Value |
| - | - |
| Owner | implicit(by authentication) |
| PathMapped | mandatory |
| User | optional(default:owner) |
| Permissions | optional(default:r) |
| Enabled | optional(owner) |
| Hidden | optional(owner) |
| Properties | optional |
| PathOrToken | str | (autogenerated token) |
* Output
* Examples:
* form->text
| Parameter | Value |
| - | - |
| PathOrToken | (autogenerated token) |
* Example: TEXT
```
curl -u user:pass -d "PathMapped=/user/testcalendar1/" http://localhost:5232/.sharing/v1/token/create
```bash
curl -u user:pass -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=
Status='success'
PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw='
```
* json->json
```bash
curl -u user:pass -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="}
```
###### API Hook "map/create"
@@ -213,60 +321,120 @@ PathOrToken=v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=
Create a share by mapping a collection of an `Owner` to an `User`.
* Authorization
Authenticated user as `Owner` has at least read access to `PathMapped`
Provided `User` has at least read access to `PathOrToken`
* `PathMapped` is existing and a collection
* Authenticated user as `Owner` has at least read access to `PathMapped`
* Provided `User` has at least read access to `PathOrToken`
* Global permitted by `permit_create_map = True` or `rights` permission `m`
* Global denied by `permit_create_map = False` or `rights` permission `M`
* Input
| Parameter | Value |
| Parameter | Type | Requirement |
| - | - |
| Owner | implicit(by authentication) |
| PathOrToken | mandatory |
| PathMapped | mandatory |
| User | mandatory |
| Permissions | optional(default:r) |
| Enabled | optional(owner) |
| Hidden | optional(owner) |
| PathOrToken | str | mandatory |
| PathMapped | str | mandatory |
| User | str | mandatory |
| Permissions | str | optional(default:r) |
| Enabled | bool | optional(owner/default:False) |
| Hidden | bool | optional(owner/default:True) |
| Properties | optional |
* Output: result status
* Output: text/plain|application/json
* Example: TEXT
* Examples:
* form->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
```bash
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
ApiVersion=1
Status=success
Status='success'
```
* json->json
```bash
curl -u owner:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create
{"ApiVersion": 1, "Status": "success"}
```
##### API Hook "(map|token|all)/list"
List shares (optional with filter) either owned or assigned as user.
* Authorization
Authenticated user as `Owner` or `User`
* Authenticated user as `Owner` or `User`
* Input
| Parameter | Filter |
| - | - |
| Owner | implicit(by authentication) |
| User | implicit(by authentication) |
| PathOrToken | optional |
| PathMapped | optional |
| Parameter | Type | Used for |
| - | - | - |
| PathOrToken | str | optional |
| PathMapped | str | optional |
* Output: plain/csv/json
* Output: text/plain|text/csv|application/json
* Example: CSV
* Examples
* form->text ("all")
```bash
curl -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
ApiVersion=1
Lines=1
Status='success'
Fields="ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties"
Content[0]="map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;True;False;False;1772748001;1772748163;
```
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
* form->csv ("map" only)
```bash
curl -H "accept: text/csv" -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties
map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;True;1772747277;1772747277;
```
* json->json ("all"), parsed with `jq`
```bash
curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhost:5232/.sharing/v1/all/list | jq
{
"ApiVersion": 1,
"Lines": 2,
"Status": "success",
"Content": [
{
"ShareType": "map",
"PathOrToken": "/user/cal1-from-owner/",
"PathMapped": "/owner/testcalendar1/",
"Owner": "owner",
"User": "user",
"Permissions": "r",
"EnabledByOwner": true,
"EnabledByUser": false,
"HiddenByOwner": false,
"HiddenByUser": true,
"TimestampCreated": 1772747277,
"TimestampUpdated": 1772747277,
"Properties": ""
},
{
"ShareType": "token",
"PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc=",
"PathMapped": "/user/testcalendar1/",
"Owner": "user",
"User": "user",
"Permissions": "r",
"EnabledByOwner": true,
"EnabledByUser": false,
"HiddenByOwner": false,
"HiddenByUser": true,
"TimestampCreated": 1772747371,
"TimestampUpdated": 1772747371,
"Properties": ""
}
7]
}
```
@@ -275,16 +443,33 @@ map,/user/cal1-from-owner/,/owner/cal1/,owner,user,r,False,False,True,True,17719
Delete a share selected by `PathOrToken`.
* Authorization
Authenticated user is `Owner`
* Authenticated user is `Owner`
* Share is existing and owned
* Input
| Parameter | Type | Owner | User |
| - | - | - | - |
| PathOrToken | selector | mandatory | not-permitted |
| Parameter | Type | Used for | as Owner | as User |
| - | - | - | - | - |
| PathOrToken | str | selection | mandatory | not-permitted |
* Output: result status
* Output: text/plain|application/json
* Examples:
* form->text
```bash
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete
ApiVersion=1
Status='success'
```
* json->json
```bash
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete
{"ApiVersion": 1, "Status": "success"}
```
##### API Hook "(token|map)/update"
@@ -293,54 +478,68 @@ Update a share selected by `PathOrToken`.
Execute delete+create in case `PathOrToken` needs to be changed.
* Authorization
Authenticated user is `Owner` or `User`
* Authenticated user is `Owner` or `User`
* Input
| Parameter | Type | Owner | User |
| - | - | - | - |
| PathOrToken | selector | mandatory | mandatory |
| Owner | by authentication | not-permitted | not-permitted |
| PathMapped | adjustable | optional | not-permitted |
| User | adjustable | optional | not-permitted |
| Permissions | adjustable | optional | not-permitted |
| Enabled | adjustable | optional(owner) | optional(user) |
| Hidden | adjustable | optional(owner) | optional(user) |
| Properties | adjustable | optional | optional |
| Parameter | Type | Used for | Owner | User |
| - | - | - | - | - |
| PathOrToken | str | selection | mandatory | mandatory |
| PathMapped | str | adjust | optional | not-permitted |
| User | str | adjust | optional | not-permitted |
| Permissions | str | adjust | optional | not-permitted |
| Enabled | bool | adjust | optional(owner) | optional(user) |
| Hidden | bool | adjust | optional(owner) | optional(user) |
| Properties | str | adjust | optional | optional |
* Output: result status
* Output: text/plain|application/json
* Examples:
* form->text
```bash
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
ApiVersion=1
Status='success'
```
* json->json
```bash
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update
{"ApiVersion": 1, "Status": "success"}
```
##### API Hooks "(map|token)/(enable|disable|hide|unhide)"
Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `PathOrToken`
* Authorization
Authenticated user is `Owner` or `User`
* Authenticated user is `Owner` or `User`
* `PathOrToken` is existing and either owned or assigned to user
* Input
| Parameter | Type | Owner | User |
| - | - | - | - |
| PathOrToken | selector | mandatory | mandatory |
| Parameter | Type | Used for | Owner | User |
| - | - | - | - | - |
| PathOrToken | selection | mandatory | mandatory |
* Output: result status
* Output: text/plain|application/json
* 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
```
* form->text
* 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
```bash
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable
ApiVersion=1
Status=success
Status='success'
```bash
* json->json
```bash
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide
{"ApiVersion": 1, "Status": "success"}
```
## Properties Overlay
@@ -349,10 +548,10 @@ Owner or user can define per share a set of properties to overlay on PROPFIND re
Whitelisted ones are defined in `OVERLAY_PROPERTIES_WHITELIST` in `radicale/sharing/__init__.py`:
* `C:calendar-description` (_>= 3.7.0_)
* `ICAL:calendar-color` (_>= 3.7.0_)
* `CR:addressbook-description` (_>= 3.7.0_)
* `INF:addressbook-color` (_>= 3.7.0_)
* `C:calendar-description`
* `ICAL:calendar-color`
* `CR:addressbook-description`
* `INF:addressbook-color`
### Properties Overlay Control Options
@@ -360,3 +559,67 @@ Whitelisted ones are defined in `OVERLAY_PROPERTIES_WHITELIST` in `radicale/shar
* supported *share* permissions: `Pp`
* `enforce_properties_overlay`
* supported *share* permissions: `Ee`
### Properties Overlay Example
#### Requirements
* sharing / permit_properties_overlay = True
#### Test sequence
* Prepare XML statements
```bash
## PROPFIND color
xml_pfc='<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:ICAL="http://apple.com/ns/ical/">
<prop>
<ICAL:calendar-color />
</prop>
</propfind>'
## PROPPATCH color
xml_ppc='<?xml version="1.0"?>
<D:propertyupdate xmlns:D="DAV:">
<D:set>
<D:prop>
<I:calendar-color xmlns:I="http://apple.com/ns/ical/">#DDDDDD</I:calendar-color>
</D:prop>
</D:set>
</D:propertyupdate>'
```
* Tests
```bash
## Retrieve collection color of owner (no color set)
curl -u owner:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/
## Create read-only share for user
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
## Accept (enable+unhide) share by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (no color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Set property overlay by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Delete property overlay by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (no color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Add property overlay by user using PROPPATCH
curl -u user:pass -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/
## Retrieve collection color of share by user (color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
```

View File

@@ -60,12 +60,12 @@ class ApplicationPartDelete(ApplicationBase):
permissions_filter = None
if self._sharing._enabled:
# Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(path, user)
if sharing:
share = self._sharing.sharing_collection_resolver(path, user)
if share:
# overwrite and run through extended permission check
path = sharing['PathMapped']
user = sharing['Owner']
permissions_filter = sharing['Permissions']
path = share['PathMapped']
user = share['Owner']
permissions_filter = share['Permissions']
access = Access(self._rights, user, path, permissions_filter)
if not access.check("w"):
return httputils.NOT_ALLOWED

View File

@@ -79,12 +79,12 @@ class ApplicationPartGet(ApplicationBase):
permissions_filter = None
if self._sharing._enabled:
# Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(path, user)
if sharing:
share = self._sharing.sharing_collection_resolver(path, user)
if share:
# overwrite and run through extended permission check
path = sharing['PathMapped']
user = sharing['Owner']
permissions_filter = sharing['Permissions']
path = share['PathMapped']
user = share['Owner']
permissions_filter = share['Permissions']
access = Access(self._rights, user, path, permissions_filter)
if not access.check("r") and "i" not in access.permissions:
return httputils.NOT_ALLOWED

View File

@@ -55,11 +55,11 @@ class ApplicationPartMkcalendar(ApplicationBase):
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
if self._sharing._enabled:
# check for shared collections (active or inactive)
collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False)
if collections_shared_map:
for sharing in collections_shared_map:
if sharing['PathOrToken'] == path:
# check for shared collections (all)
collections_share_map = self._sharing.sharing_collection_map_list()
if collections_share_map:
for share in collections_share_map:
if share['PathOrToken'] == path:
return httputils.CONFLICT
# TODO: use this?
# timezone = props.get("C:calendar-timezone")

View File

@@ -62,11 +62,11 @@ class ApplicationPartMkcol(ApplicationBase):
logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'")
return httputils.NOT_ALLOWED
if self._sharing._enabled:
# check for shared collections (active or inactive)
collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False)
if collections_shared_map:
for sharing in collections_shared_map:
if sharing['PathOrToken'] == path:
# check for shared collections (all)
collections_share_map = self._sharing.sharing_collection_map_list()
if collections_share_map:
for share in collections_share_map:
if share['PathOrToken'] == path:
return httputils.CONFLICT
with self._storage.acquire_lock("w", user, path=path, request="MKCOL"):
item = next(iter(self._storage.discover(path)), None)

View File

@@ -72,12 +72,12 @@ class ApplicationPartMove(ApplicationBase):
permissions_filter = None
if self._sharing._enabled:
# Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(path, user)
if sharing:
share = self._sharing.sharing_collection_resolver(path, user)
if share:
# overwrite and run through extended permission check
path = sharing['PathMapped']
user = sharing['Owner']
permissions_filter = sharing['Permissions']
path = share['PathMapped']
user = share['Owner']
permissions_filter = share['Permissions']
access = Access(self._rights, user, path, permissions_filter)
if not access.check("w"):
return httputils.NOT_ALLOWED
@@ -89,12 +89,12 @@ class ApplicationPartMove(ApplicationBase):
to_path = to_path[len(base_prefix):]
if self._sharing._enabled:
# Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(to_path, to_user)
if sharing:
share = self._sharing.sharing_collection_resolver(to_path, to_user)
if share:
# overwrite and run through extended permission check
to_path = sharing['PathMapped']
to_user = sharing['Owner']
to_permissions_filter = sharing['Permissions']
to_path = share['PathMapped']
to_user = share['Owner']
to_permissions_filter = share['Permissions']
to_access = Access(self._rights, to_user, to_path, to_permissions_filter)
to_access = Access(self._rights, to_user, to_path, to_permissions_filter)
if not to_access.check("w"):

View File

@@ -37,7 +37,7 @@ from radicale.log import logger
def xml_propfind(base_prefix: str, path: str,
xml_request: Optional[ET.Element],
allowed_items: Iterable[Tuple[types.CollectionOrItem, str]],
user: str, encoding: str, max_resource_size: int, sharing: Union[dict, None] = None) -> Optional[ET.Element]:
user: str, encoding: str, max_resource_size: int, share: Union[dict, None] = None) -> Optional[ET.Element]:
"""Read and answer PROPFIND requests.
Read rfc4918-9.1 for info.
@@ -74,7 +74,7 @@ def xml_propfind(base_prefix: str, path: str,
write = permission == "w"
multistatus.append(xml_propfind_response(
base_prefix, path, item, props, user, encoding, write=write,
allprop=allprop, propname=propname, max_resource_size=max_resource_size, sharing=sharing))
allprop=allprop, propname=propname, max_resource_size=max_resource_size, share=share))
return multistatus
@@ -82,7 +82,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, sharing: Union[dict, None] = None) -> ET.Element:
propname: bool = False, allprop: bool = False, share: Union[dict, 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")
@@ -102,9 +102,9 @@ def xml_propfind_response(
collection.path, item.href))
response = ET.Element(xmlutils.make_clark("D:response"))
href = ET.Element(xmlutils.make_clark("D:href"))
if sharing:
if share:
# backmap
uri = uri.replace(sharing['PathMapped'], sharing['PathOrToken'])
uri = uri.replace(share['PathMapped'], share['PathOrToken'])
href.text = xmlutils.make_href(base_prefix, uri)
response.append(href)
@@ -183,9 +183,9 @@ def xml_propfind_response(
is_collection and collection.is_principal):
child_element = ET.Element(xmlutils.make_clark("D:href"))
child_element.text = xmlutils.make_href(base_prefix, path)
if sharing:
if share:
# backmap
child_element.text = child_element.text.replace(sharing['PathMapped'], sharing['PathOrToken'])
child_element.text = child_element.text.replace(share['PathMapped'], share['PathOrToken'])
element.append(child_element)
elif tag == xmlutils.make_clark("C:supported-calendar-component-set"):
human_tag = xmlutils.make_human_tag(tag)
@@ -221,9 +221,9 @@ def xml_propfind_response(
child_element = ET.Element(xmlutils.make_clark("D:href"))
child_element.text = xmlutils.make_href(
base_prefix, "/%s/" % user)
if sharing:
if share:
# backmap
child_element.text = child_element.text.replace(sharing['Owner'], sharing['User'])
child_element.text = child_element.text.replace(share['Owner'], share['User'])
element.append(child_element)
else:
element.append(ET.Element(
@@ -344,13 +344,13 @@ def xml_propfind_response(
else:
human_tag = xmlutils.make_human_tag(tag)
tag_text = collection.get_meta(human_tag)
if share:
# map/add from overlay
if share['Properties']:
if human_tag in share['Properties']:
if share['Properties'][human_tag] is not None:
tag_text = share['Properties'][human_tag]
if tag_text is not None:
if sharing:
# map from overlay
if sharing['Properties']:
if human_tag in sharing['Properties']:
if sharing['Properties'][human_tag] is not None:
tag_text = sharing['Properties'][human_tag]
element.text = tag_text
else:
is404 = True
@@ -426,15 +426,15 @@ class ApplicationPartPropfind(ApplicationBase):
"""Manage PROPFIND request."""
http_depth = environ.get("HTTP_DEPTH", "0")
permissions_filter = None
sharing = None
share = None
if self._sharing._enabled:
# Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(path, user)
if sharing:
share = self._sharing.sharing_collection_resolver(path, user)
if share:
# overwrite and run through extended permission check
path = sharing['PathMapped']
user = sharing['Owner']
permissions_filter = sharing['Permissions']
path = share['PathMapped']
user = share['Owner']
permissions_filter = share['Permissions']
access = Access(self._rights, user, path, permissions_filter)
if not access.check("r"):
return httputils.NOT_ALLOWED
@@ -466,14 +466,14 @@ class ApplicationPartPropfind(ApplicationBase):
if http_depth == "1":
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/PROPFIND: get shared collections")
# check for shared collections
collections_shared_map = self._sharing.sharing_collection_map_list(user)
if collections_shared_map:
for sharing in collections_shared_map:
c_share = sharing['PathOrToken']
c_path = sharing['PathMapped']
c_user = sharing['Owner']
c_permissions_filter = sharing['Permissions']
# check for shared collections related to user, Enabled and not Hidden
collections_share_map = self._sharing.sharing_collection_map_list(User=user, Enabled=True, Hidden=False)
if collections_share_map:
for share in collections_share_map:
c_share = share['PathOrToken']
c_path = share['PathMapped']
c_user = share['Owner']
c_permissions_filter = share['Permissions']
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/PROPFIND: test shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s", c_share, c_path, c_user, c_permissions_filter)
c_access = Access(self._rights, c_user, c_path, c_permissions_filter)
@@ -490,7 +490,7 @@ class ApplicationPartPropfind(ApplicationBase):
headers = {"DAV": httputils.DAV_HEADERS,
"Content-Type": "text/xml; charset=%s" % self._encoding}
xml_answer = xml_propfind(base_prefix, path, xml_content,
allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, sharing=sharing)
allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, share=share)
if xml_answer is None:
return httputils.NOT_ALLOWED
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)

View File

@@ -37,7 +37,7 @@ from radicale.log import logger
def xml_proppatch(base_prefix: str, path: str,
xml_request: Optional[ET.Element],
collection: Union[storage.BaseCollection, None], sharing: Union[dict, None] = None, sharing_overlay: bool = False, _sharing: Union[sharing.BaseSharing, None] = None) -> ET.Element:
collection: Union[storage.BaseCollection, None], share: Union[dict, None] = None, share_overlay: bool = False, _sharing: Union[sharing.BaseSharing, None] = None) -> ET.Element:
"""Read and answer PROPPATCH requests.
Read rfc4918-9.2 for info.
@@ -48,9 +48,9 @@ def xml_proppatch(base_prefix: str, path: str,
multistatus.append(response)
href = ET.Element(xmlutils.make_clark("D:href"))
href.text = xmlutils.make_href(base_prefix, path)
if sharing:
if share:
# backmap
href.text = href.text.replace(sharing['PathMapped'], sharing['PathOrToken'])
href.text = href.text.replace(share['PathMapped'], share['PathOrToken'])
response.append(href)
# Create D:propstat element for props with status 200 OK
propstat = ET.Element(xmlutils.make_clark("D:propstat"))
@@ -62,28 +62,28 @@ def xml_proppatch(base_prefix: str, path: str,
response.append(propstat)
props_with_remove = xmlutils.props_from_request(xml_request)
if sharing and sharing_overlay:
if share and share_overlay:
# PROPPATCH overlay adjustment
logger.debug("TRACE/PROPPATCH/xml_proppatch: sharing+sharing_overlay is active: %r", sharing)
if sharing['Properties'] is not None:
all_props_with_remove = cast(Dict[str, Optional[str]], radicale_item.check_and_sanitize_props(sharing['Properties']))
logger.debug("TRACE/PROPPATCH/xml_proppatch: share+share_overlay is active: %r", share)
if share['Properties'] is not None:
all_props_with_remove = cast(Dict[str, Optional[str]], radicale_item.check_and_sanitize_props(share['Properties']))
else:
all_props_with_remove = {}
all_props_with_remove.update(props_with_remove)
all_props = radicale_item.check_and_sanitize_props(all_props_with_remove)
logger.debug("TRACE/PROPPATCH/xml_proppatch: sharing+sharing_overlay result: %r", all_props)
logger.debug("TRACE/PROPPATCH/xml_proppatch: share+share_overlay result: %r", all_props)
else:
if collection is not None:
# always the case, but makes mypy happy
all_props_with_remove = cast(Dict[str, Optional[str]], dict(collection.get_meta()))
all_props_with_remove.update(props_with_remove)
all_props = radicale_item.check_and_sanitize_props(all_props_with_remove)
if sharing and sharing_overlay and _sharing is not None:
if share and share_overlay and _sharing is not None:
# _sharing is not None: always the case, but makes mypy happy
_sharing.database_update_sharing(ShareType=sharing['ShareType'],
PathOrToken=sharing['PathOrToken'],
OwnerOrUser=sharing['User'],
Properties=cast(Dict[str, str], all_props))
_sharing.sharing_collection_update(ShareType=share['ShareType'],
PathOrToken=share['PathOrToken'],
OwnerOrUser=share['User'],
Properties=cast(Dict[str, str], all_props))
else:
if collection is not None:
# always the case, but makes mypy happy
@@ -100,21 +100,21 @@ class ApplicationPartProppatch(ApplicationBase):
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
"""Manage PROPPATCH request."""
permissions_filter = None
sharing = None
sharing_overlay = False
share = None
share_overlay = False
path_orig = path
if self._sharing._enabled:
# Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(path, user)
if sharing:
share = self._sharing.sharing_collection_resolver(path, user)
if share:
# overwrite and run through extended permission check
path = sharing['PathMapped']
user = sharing['Owner']
permissions_filter = sharing['Permissions']
path = share['PathMapped']
user = share['Owner']
permissions_filter = share['Permissions']
access = Access(self._rights, user, path, permissions_filter)
if not access.check("w"):
logger.debug("TRACE/PROPPATCH/xml_proppatch: no write-access: %r", path)
if sharing:
if share:
# no write access -> use properties overlay
if self._sharing.permit_properties_overlay:
if permissions_filter is not None and "p" in permissions_filter:
@@ -122,11 +122,11 @@ class ApplicationPartProppatch(ApplicationBase):
return httputils.NOT_ALLOWED
else:
logger.info("PROPPATCH request on shared %r: no write-permissions, overlay permitted by option", path_orig)
sharing_overlay = True
share_overlay = True
else:
if permissions_filter is not None and "P" in permissions_filter:
logger.info("PROPPATCH request on shared %r: no write-permissions, overlay denied, but granted by permission 'P'", path_orig)
sharing_overlay = True
share_overlay = True
else:
logger.info("PROPPATCH request on shared %r: no write-permissions and overlay denied by option", path_orig)
return httputils.NOT_ALLOWED
@@ -134,18 +134,18 @@ class ApplicationPartProppatch(ApplicationBase):
return httputils.NOT_ALLOWED
else:
logger.debug("TRACE/PROPPATCH/xml_proppatch: write-access: %r", path)
if sharing:
if share:
# write access -> check for enforced properties overlay
logger.debug("TRACE/PROPPATCH/xml_proppatch: write-access/sharing: %r", path_orig)
if self._sharing.enforce_properties_overlay:
if permissions_filter is not None and "e" in permissions_filter:
logger.info("PROPPATCH request on shared %r: write-permissions, overlay enforced, but disabled by permission 'e'", path_orig)
else:
sharing_overlay = True
share_overlay = True
else:
if permissions_filter is not None and "E" in permissions_filter:
logger.info("PROPPATCH request on shared %r: write-permissions, overlay not enforced, but enforced by permission 'E'", path_orig)
sharing_overlay = True
share_overlay = True
try:
xml_content = self._read_xml_request_body(environ)
except RuntimeError as e:
@@ -156,13 +156,13 @@ class ApplicationPartProppatch(ApplicationBase):
logger.debug("Client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT
if sharing_overlay:
if share_overlay:
# call API function internally and no not trigger any hook
headers = {"DAV": httputils.DAV_HEADERS,
"Content-Type": "text/xml; charset=%s" % self._encoding}
try:
xml_answer = xml_proppatch(base_prefix, path, xml_content,
None, sharing, sharing_overlay, self._sharing)
None, share, share_overlay, self._sharing)
if xml_content is not None:
content = DefusedET.tostring(
xml_content,
@@ -199,7 +199,7 @@ class ApplicationPartProppatch(ApplicationBase):
"Content-Type": "text/xml; charset=%s" % self._encoding}
try:
xml_answer = xml_proppatch(base_prefix, path, xml_content,
item, sharing)
item, share)
if xml_content is not None:
content = DefusedET.tostring(
xml_content,

View File

@@ -184,12 +184,12 @@ class ApplicationPartPut(ApplicationBase):
permissions_filter = None
if self._sharing._enabled:
# Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(path, user)
if sharing:
share = self._sharing.sharing_collection_resolver(path, user)
if share:
# overwrite and run through extended permission check
path = sharing['PathMapped']
user = sharing['Owner']
permissions_filter = sharing['Permissions']
path = share['PathMapped']
user = share['Owner']
permissions_filter = share['Permissions']
access = Access(self._rights, user, path, permissions_filter)
access = Access(self._rights, user, path, permissions_filter)
if not access.check("w"):

View File

@@ -151,7 +151,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
collection: storage.BaseCollection, encoding: str,
unlock_storage_fn: Callable[[], None],
max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "",
sharing: Union[dict, None] = None) -> Tuple[int, ET.Element]:
share: Union[dict, None] = None) -> Tuple[int, ET.Element]:
"""Read and answer REPORT requests that return XML.
Read rfc3253-3.6 for info.
@@ -360,7 +360,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
if found_props or not_found_props:
multistatus.append(xml_item_response(
base_prefix, uri, found_props=found_props,
not_found_props=not_found_props, found_item=True, sharing=sharing))
not_found_props=not_found_props, found_item=True, share=share))
return client.MULTI_STATUS, multistatus
@@ -711,13 +711,13 @@ def _find_overridden(
def xml_item_response(base_prefix: str, href: str,
found_props: Sequence[ET.Element] = (),
not_found_props: Sequence[ET.Element] = (),
found_item: bool = True, sharing: Union[dict, None] = None) -> ET.Element:
found_item: bool = True, share: Union[dict, None] = None) -> ET.Element:
response = ET.Element(xmlutils.make_clark("D:response"))
href_element = ET.Element(xmlutils.make_clark("D:href"))
href_element.text = xmlutils.make_href(base_prefix, href)
if sharing:
href_element.text = href_element.text.replace(sharing['PathMapped'], sharing['PathOrToken'])
if share:
href_element.text = href_element.text.replace(share['PathMapped'], share['PathOrToken'])
response.append(href_element)
if found_item:
@@ -820,15 +820,15 @@ class ApplicationPartReport(ApplicationBase):
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
"""Manage REPORT request."""
permissions_filter = None
sharing = None
share = None
if self._sharing._enabled:
# Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(path, user)
if sharing:
share = self._sharing.sharing_collection_resolver(path, user)
if share:
# overwrite and run through extended permission check
path = sharing['PathMapped']
user = sharing['Owner']
permissions_filter = sharing['Permissions']
path = share['PathMapped']
user = share['Owner']
permissions_filter = share['Permissions']
access = Access(self._rights, user, path, permissions_filter)
if not access.check("r"):
return httputils.NOT_ALLOWED
@@ -871,7 +871,7 @@ class ApplicationPartReport(ApplicationBase):
try:
status, xml_answer = xml_report(
base_prefix, path, xml_content, collection, self._encoding,
lock_stack.close, max_occurrence, user, remote_host, remote_useragent, sharing=sharing)
lock_stack.close, max_occurrence, user, remote_host, remote_useragent, share=share)
except ValueError as e:
logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)

View File

@@ -57,8 +57,6 @@ SHARE_TYPES_V1: Sequence[str] = ('token', 'map')
# map : share by mapping collection of one user to another as virtual
# all : only supported for "list" and "info"
OUTPUT_TYPES: Sequence[str] = ('csv', 'json', 'txt')
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
@@ -72,6 +70,24 @@ API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'un
API_SHARE_TOGGLES_V1: Sequence[str] = ('hide', 'unhide', 'enable', 'disable')
API_TYPES_V1: dict[str, type] = {
"ApiVersion": int,
"Status": str,
"Lines": int,
"FeatureEnabledCollectionByMap": bool,
"FeatureEnabledCollectionByToken": bool,
"PermittedCreateCollectionByMap": bool,
"PermittedCreateCollectionByToken": bool,
"ShareType": str,
"PathOrToken": str,
"PathMapped:": str,
"Owner": str,
"User": str,
"Permissions": str,
"Enabled": bool,
"Hidden": bool,
"Properties": str}
TOKEN_PATTERN_V1: str = "(v1/[a-zA-Z0-9_=\\-]{44})"
PATH_PATTERN: str = "([a-zA-Z0-9/.\\-]+)" # TODO: extend or find better source
@@ -93,6 +109,7 @@ class BaseSharing:
_enabled: bool = False
default_permissions_create_token: str
default_permissions_create_map: str
sharing_db_type: str
def __init__(self, configuration: "config.Configuration") -> None:
"""Initialize Sharing.
@@ -123,6 +140,10 @@ class BaseSharing:
logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay)
logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay)
# database tasks
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)):
logger.info("sharing disabled as no feature is enabled")
self._enabled = False
@@ -130,15 +151,17 @@ class BaseSharing:
else:
self._enabled = True
# database tasks
self.sharing_db_type = configuration.get("sharing", "type")
logger.info("sharing.database_type: %s", self.sharing_db_type)
if not self._init_db():
return
def _init_db(self) -> bool:
"""Initialize Sharing Database
"""
try:
if self.database_init() is False:
logger.info("sharing disabled as no database is active")
self._enabled = False
return
return False
except Exception as e:
logger.error("sharing database cannot be initialized: %r", e)
exit(1)
@@ -147,8 +170,9 @@ class BaseSharing:
logger.info("sharing database info: %r", database_info)
else:
logger.info("sharing database info: (not provided)")
return True
# overloadable database functions
# *** overloadable database functions ***
def database_init(self) -> bool:
""" initialize db """
return False
@@ -216,10 +240,14 @@ class BaseSharing:
""" delete sharing """
return {"status": "not-implemented"}
# sharing functions called by request methods
# *** functions called by cli ***
def verify(self) -> bool:
""" verify database """
logger.info("sharing database verification begin")
if not self._init_db():
return False
logger.info("sharing database verification call: %s", self.sharing_db_type)
result = self.database_verify()
if result is not True:
@@ -245,10 +273,42 @@ class BaseSharing:
return False
else:
pass
# TODO: check PathMapped exists
# check PathMapped exists
with self._storage.acquire_lock("r", path=entry['PathMapped']):
item = next(iter(self._storage.discover(entry['PathMapped'])), None)
if not item:
logger.error("PathMapped is not existing: %r", entry['PathMapped'])
return False
else:
logger.debug("PathMapped exists(ok): %r", entry['PathMapped'])
logger.info("sharing database verification content successful")
return True
# *** sharing functions called by request methods ***
# list sharings of type "map"
def sharing_collection_map_list(self, User: Union[str, None] = None, Enabled: Union[bool, None] = None, Hidden: Union[bool, None] = None) -> list[dict]:
""" returning dict with shared collections by filter(User/Enabled/Hidden) or None if not found"""
if not self.sharing_collection_by_map:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/map: not active")
return [{}]
# retrieve collections depending on filter
shared_collection_list = self.database_list_sharing(
ShareType="map",
OwnerOrUser=User,
User=User,
EnabledByOwner=Enabled,
EnabledByUser=Enabled,
HiddenByOwner=Hidden,
HiddenByUser=Hidden)
# final
return shared_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"""
if self.sharing_collection_by_token:
@@ -272,38 +332,19 @@ class BaseSharing:
logger.debug("TRACE/sharing/map: not active")
return None
# final
return None
# list sharings of type "map"
def sharing_collection_map_list(self, user: str, active: bool = True) -> list[dict]:
""" returning dict with shared collections (active==True: enabled and unhidden) or None if not found"""
if not self.sharing_collection_by_map:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/map: not active")
return [{}]
# adjust a share
def sharing_collection_update(self, ShareType: str, PathOrToken: str, OwnerOrUser: str, Properties: dict) -> None:
""" returning dict with PathMapped, Owner, Permissions or None if not found"""
logger.info("Sharing/collection/update: ShareType=%r PathOrToken=%r OwnerOrUser=%r", ShareType, PathOrToken, OwnerOrUser)
self.database_update_sharing(ShareType=ShareType,
PathOrToken=PathOrToken,
OwnerOrUser=OwnerOrUser,
Properties=Properties)
# retrieve collections which are enabled and not hidden by owner+user
if active:
shared_collection_list = self.database_list_sharing(
ShareType="map",
OwnerOrUser=user,
User=user,
EnabledByOwner=True,
EnabledByUser=True,
HiddenByOwner=False,
HiddenByUser=False)
else:
# unconditional
shared_collection_list = self.database_list_sharing(
ShareType="map",
OwnerOrUser=user,
User=user)
# final
return shared_collection_list
# internal sharing functions
# *** internal sharing functions ***
# resolves a token "path" to a share
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:
@@ -320,9 +361,12 @@ class BaseSharing:
# 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])
return self.database_get_sharing(
result = self.database_get_sharing(
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'])
return result
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/token: no supported prefix found in path: %r", path)
@@ -332,6 +376,7 @@ class BaseSharing:
logger.debug("TRACE/sharing/token: not active")
return None
# resolves a map "path" to a share
def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict, None]:
""" returning dict with PathMapped, Owner, Permissions or None if invalid"""
if self.sharing_collection_by_map:
@@ -342,7 +387,7 @@ class BaseSharing:
PathOrToken=path,
User=user)
if result:
return result
pass
else:
# fallback to parent path
parent_path = pathutils.parent_path(path)
@@ -356,17 +401,19 @@ class BaseSharing:
result['PathMapped'] = path.replace(parent_path, result['PathMapped'])
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/map/resolver: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path)
return result
else:
if logger.isEnabledFor(logging.DEBUG):
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'])
return result
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/map: not active")
return None
# POST API
# *** 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
from radicale.app.base import Access
@@ -469,11 +516,14 @@ class BaseSharing:
logger.debug("Client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT
api_info = "sharing/API/POST/" + ShareType + "/" + action
# initial log prefix
api_info = "Sharing/API/POST/" + ShareType + "/" + action
# parse body according to content-type
content_type = environ.get("CONTENT_TYPE", "")
if 'application/json' in content_type:
input_format = "json"
output_format = "json" # default
try:
request_data = json.loads(request_body)
except json.JSONDecodeError:
@@ -482,12 +532,14 @@ class BaseSharing:
# convert JSON boolean
if key in request_data:
if type(request_data[key]) is not bool:
logger.error(api_info + ": unsupported (non-boolean) " + key + ": " + request_data[key])
logger.warning(api_info + ": unsupported (non-boolean) " + key + ": " + request_data[key])
return httputils.bad_request("Invalid non-boolean value for " + key + ": " + request_data[key])
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + " (json): %r", f"{request_data}")
elif 'application/x-www-form-urlencoded' in content_type:
request_parsed = parse_qs(request_body)
input_format = "form"
output_format = "plain" # default
request_parsed = parse_qs(request_body, keep_blank_values=True)
# convert arrays into single value
request_data = {}
for key in request_parsed:
@@ -495,6 +547,10 @@ class BaseSharing:
# Properties key value parser
properties_dict: dict = {}
for entry in request_parsed[key]:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/API: parse property %r", entry)
if entry == "":
continue
m = re.search('^([^=]+)=([^=]+)$', entry)
if not m:
return httputils.bad_request("Invalid properties format in form")
@@ -504,11 +560,14 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/API: converted Properties from form into dict: %r", properties_dict)
request_data[key] = properties_dict
if len(request_data[key]) == 0:
# empty
request_data[key] = {}
elif key in ["Enabled", "Hidden"]:
try:
request_data[key] = config._convert_to_bool(request_parsed[key][0])
except ValueError:
logger.error(api_info + ": unsupported (non-boolean) " + key + ": " + request_parsed[key][0])
logger.warning(api_info + ": unsupported (non-boolean) " + key + ": " + request_parsed[key][0])
return httputils.bad_request("Invalid non-boolean value for " + key + ": " + request_parsed[key][0])
else:
request_data[key] = request_parsed[key][0]
@@ -525,23 +584,28 @@ class BaseSharing:
output_format = "json"
elif 'text/csv' in accept:
output_format = "csv"
elif 'text/plain' in accept:
output_format = "plain"
else:
output_format = "txt"
# default from input type
pass
if output_format == "csv":
if not action == "list":
return httputils.bad_request("CSV output format is only allowed for list action")
elif output_format == "json":
pass
elif output_format == "txt":
elif output_format == "plain":
pass
else:
return httputils.bad_request("Output format not supported")
# extend log prefix
api_info = api_info + "(" + input_format + "->" + output_format + ")"
# parameters default
PathOrToken: Union[str, None] = None
PathMapped: Union[str, None] = None
Owner: str = user
User: Union[str, None] = None
Permissions: Union[str, None] = None # no permissions by default
Enabled: Union[bool, None] = None
@@ -557,23 +621,23 @@ class BaseSharing:
elif key == "PathOrToken":
if ShareType == "token":
if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]):
logger.error(api_info + ": unsupported " + key)
logger.warning(api_info + ": unsupported " + key)
return httputils.bad_request("Invalid value for PathOrToken")
elif ShareType == "map":
if not re.search('^' + PATH_PATTERN + '$', request_data[key]):
logger.error(api_info + ": unsupported " + key)
logger.warning(api_info + ": unsupported " + key)
return httputils.bad_request("Invalid value for PathOrToken")
elif not request_data[key].endswith("/"):
return httputils.bad_request("PathOrToken not ending with /")
elif key == "PathMapped":
if not re.search('^' + PATH_PATTERN + '$', request_data[key]):
logger.error(api_info + ": unsupported " + key)
logger.warning(api_info + ": unsupported " + key)
return httputils.bad_request("Invalid value for PathMapped")
elif not request_data[key].endswith("/"):
return httputils.bad_request("PathMapped not ending with /")
elif key == "User":
if not re.search('^' + USER_PATTERN + '$', request_data[key]):
logger.error(api_info + ": unsupported " + key)
logger.warning(api_info + ": unsupported " + key)
return httputils.bad_request("Invalid value for User")
# check for optional parameters
@@ -586,7 +650,7 @@ class BaseSharing:
# ignored
pass
elif action not in ['list', 'create']:
logger.error(api_info + ": missing PathOrToken")
logger.warning(api_info + ": missing PathOrToken")
return httputils.bad_request("Missing PathOrToken")
else:
# PathOrToken is optional
@@ -594,7 +658,7 @@ class BaseSharing:
else:
if action == "create" and ShareType == "token":
# not supported
logger.error(api_info + ": PathOrToken found but not supported")
logger.warning(api_info + ": PathOrToken found but not supported")
return httputils.bad_request("PathOrToken not supported")
PathOrToken = request_data['PathOrToken']
@@ -662,15 +726,25 @@ class BaseSharing:
answer['Status'] = "success"
answer['Content'] = result_array
logger.info(api_info + ": " + answer['Status'])
# action: create
elif action == "create":
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": start")
if PathMapped is None:
logger.error(api_info + ": missing PathMapped")
logger.warning(api_info + ": missing PathMapped")
return httputils.bad_request("Missing PathMapped")
# check whether collection exists
with self._storage.acquire_lock("r", user, path=PathMapped):
item = next(iter(self._storage.discover(PathMapped)), None)
if not item:
return httputils.NOT_FOUND
if not isinstance(item, storage.BaseCollection):
return httputils.METHOD_NOT_ALLOWED
if Permissions is None:
if ShareType == "token":
Permissions = self.default_permissions_create_token
@@ -701,16 +775,16 @@ class BaseSharing:
# check access Permissions
access = Access(self._rights, user, PathMapped)
if not access.check("r"):
logger.info("Add sharing-by-token: access to %r not allowed for user %r", PathMapped, user)
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user)
return httputils.NOT_ALLOWED
if self.permit_create_token is False:
if "t" not in access.permissions:
logger.info("Add sharing-by-token: access to %r not allowed for user %r (permit=False but explict grant misses 't')", PathMapped, user)
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explict grant misses 't')", PathMapped, user)
return httputils.NOT_ALLOWED
else:
if "T" in access.permissions:
logger.info("Add sharing-by-token: access to %r not allowed for user %r (permit=True but denied by 'T')", PathMapped, user)
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'T')", PathMapped, user)
return httputils.NOT_ALLOWED
if User is not None:
@@ -752,7 +826,7 @@ class BaseSharing:
# retrieve existing share
share = self.database_get_sharing(ShareType=ShareType, PathOrToken=PathOrToken, OnlyEnabled=False)
if share is not None:
logger.error("Sharing/create/%s: already exists: %r", ShareType, PathOrToken)
logger.warning(api_info + ": share already exists PathOrToken=%r", PathOrToken)
return httputils.CONFLICT
if User is None:
@@ -761,36 +835,36 @@ class BaseSharing:
User = str(User)
# check access Permissions
access = Access(self._rights, Owner, PathMapped, None) # PathMapped is mandatory
access = Access(self._rights, user, PathMapped, None) # PathMapped is mandatory
if not access.check("r") and "i" not in access.permissions:
logger.info("Add sharing-by-map: access to path(mapped) %r not allowed for owner %r", PathMapped, Owner)
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user)
return httputils.NOT_ALLOWED
if self.permit_create_map is False:
if "m" not in access.permissions:
logger.info("Add sharing-by-map: access to %r not allowed for user %r (permit=False but explicit grant misses 'm')", PathMapped, user)
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.info("Add sharing-by-map: access to %r not allowed for user %r (permit=True but denied by 'M')", PathMapped, user)
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"):
logger.info("Add sharing-by-map: access to path %r not allowed for user %r", PathOrToken, User)
logger.warning(api_info + ": access to PathOrToken=%r not allowed for User=%r", PathOrToken, User)
return httputils.NOT_ALLOWED
# check whether share is already existing as real collection
with self._storage.acquire_lock("r", user, path=PathOrToken):
with self._storage.acquire_lock("r", User, path=PathOrToken):
item = next(iter(self._storage.discover(PathOrToken)), None)
if not item:
pass
else:
logger.info("Add sharing-by-map: path %r already exists as real collection for user %r", PathOrToken, user)
logger.warning(api_info + ": PathOrToken=%r already exists as real collection for User=%r", PathOrToken, User)
return httputils.CONFLICT
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r user=%r)", PathMapped, Permissions, PathOrToken, User)
logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r Owner=%r User=%r)", PathMapped, Permissions, PathOrToken, user, User)
result = self.database_create_sharing(
ShareType=ShareType,
@@ -807,7 +881,7 @@ class BaseSharing:
Properties=Properties)
else:
logger.error(api_info + ": unsupported for ShareType=%r", ShareType)
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type")
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": result=%r", result)
@@ -819,19 +893,22 @@ class BaseSharing:
elif result['status'] == "success":
answer['Status'] = "success"
else:
return httputils.bad_request("Internal failure")
logger.warning(api_info + ": %r by user %r not successful", PathMapped, request_data['User'])
return httputils.bad_request("Internal Error")
if ShareType == "token":
logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token)
PathOrToken = token
answer['PathOrToken'] = token
logger.info(api_info + " success: PathMapped=%r Permissions=%r PathOrToken=%r", PathMapped, Permissions, PathOrToken)
# action: update
elif action == "update":
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": start")
if ShareType not in ["token", "map"]:
logger.error(api_info + ": unsupported for ShareType=%r", ShareType)
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type")
if PathOrToken is None:
@@ -849,9 +926,13 @@ class BaseSharing:
# check access Permissions
access = Access(self._rights, user, str(PathMapped), None)
if not access.check("r") and "i" not in access.permissions:
logger.warning("Update sharing: access to PathMapped %r not allowed for user %r", PathMapped, user)
logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user)
return httputils.NOT_ALLOWED
if 'Properties' in request_data and Properties is None:
# clear properties
Properties = {}
result = self.database_update_sharing(
ShareType=ShareType,
PathMapped=PathMapped,
@@ -867,25 +948,29 @@ class BaseSharing:
elif user == share['User']:
# User is only allowed to update Properties
if PathMapped is not None or Permissions is not None or User is not None:
logger.info("Update sharing: access to %r not allowed for user %r to adjust anything beside: %s", PathOrToken, user, " ".join(DB_FIELDS_V1_USER_PERMITTED))
logger.warning(api_info + ": access to %r not allowed for user %r to adjust anything beside: %s", PathOrToken, user, " ".join(DB_FIELDS_V1_USER_PERMITTED))
return httputils.NOT_ALLOWED
if Properties is not None:
if 'Properties' in request_data:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/API/update: permit_properties_overlay=%s Permissions=%r", self.permit_properties_overlay, share['Permissions'])
if self.permit_properties_overlay:
if share['Permissions'] is not None and "p" in str(share['Permissions']):
logger.info("Update on shared %r: overlay permitted, but denied by permission 'p'", PathOrToken)
logger.warning(api_info + ": %r properties overlay permitted by option, but denied by permission 'p'", PathOrToken)
return httputils.NOT_ALLOWED
else:
logger.info("Update on shared %r: overlay permitted by option", PathOrToken)
logger.info(api_info + ": %r properties overlay permitted by option", PathOrToken)
else:
if share['Permissions'] is not None and "P" in str(share['Permissions']):
logger.info("Update on shared %r: overlay denied, but granted by permission 'P'", PathOrToken)
logger.info(api_info + ": %r properties overlay denied by option, but granted by permission 'P'", PathOrToken)
else:
logger.info("Update on shared %r: overlay denied by option", PathOrToken)
logger.warning(api_info + ": %r properties overlay denied by option", PathOrToken)
return httputils.NOT_ALLOWED
return httputils.NOT_ALLOWED
if 'Properties' in request_data and Properties is None:
# clear properties
Properties = {}
# limited update as user
result = self.database_update_sharing(
ShareType=ShareType,
@@ -897,7 +982,7 @@ class BaseSharing:
else:
# neither owner nor user matches
logger.warning("Update sharing of %r not permitted for user %r", PathOrToken, user)
logger.warning(api_info + ": sharing of %r not permitted for user %r", PathOrToken, user)
return httputils.NOT_ALLOWED
# result handling
@@ -909,11 +994,8 @@ class BaseSharing:
answer['Status'] = "success"
pass
else:
if ShareType == "token":
logger.info("Update of sharing-by-token: %r not successful", request_data['PathOrToken'])
elif ShareType == "map":
logger.info("Update of sharing-by-map: %r not successful", request_data['PathOrToken'])
return httputils.bad_request("Invalid share type")
logger.warning(api_info + ": %r not successful", request_data['PathOrToken'])
return httputils.bad_request("Internal Error")
# action: delete
elif action == "delete":
@@ -921,7 +1003,7 @@ class BaseSharing:
logger.debug("TRACE/" + api_info + ": start")
if ShareType not in ["token", "map"]:
logger.error(api_info + ": unsupported for ShareType=%r", ShareType)
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type")
if PathOrToken is None:
@@ -940,7 +1022,7 @@ class BaseSharing:
PathOrToken=PathOrToken) # verification above that it is not None
else:
# only owner is permitted to delete a share
logger.warning("Delete sharing of %r not permitted for user %r", PathOrToken, user)
logger.warning(api_info + ": %r not permitted for user %r", PathOrToken, user)
return httputils.NOT_ALLOWED
# result handling
@@ -952,14 +1034,12 @@ class BaseSharing:
answer['Status'] = "success"
pass
else:
if ShareType == "token":
logger.info("Delete sharing-by-token: %r of user %r not successful", request_data['PathOrToken'], request_data['User'])
elif ShareType == "map":
logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User'])
return httputils.bad_request("Invalid share type")
logger.warning(api_info + ": %r by user %r not successful", request_data['PathOrToken'], request_data['User'])
return httputils.bad_request("Internal Error")
# action: info
elif action == "info":
logger.info(api_info + ": success")
answer['Status'] = "success"
if ShareType in ["all", "map"]:
answer['FeatureEnabledCollectionByMap'] = self.sharing_collection_by_map
@@ -974,7 +1054,7 @@ class BaseSharing:
logger.debug("TRACE/sharing/API/POST/" + action)
if ShareType not in ["token", "map"]:
logger.error(api_info + ": unsupported for ShareType=%r", ShareType)
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type")
if PathOrToken is None:
@@ -1027,7 +1107,7 @@ class BaseSharing:
else:
# neither owner nor user matches
logger.warning("Toggle sharing of %r not permitted for user %r", PathOrToken, user)
logger.warning(api_info + ": %r by user %r not permitted", PathOrToken, user)
return httputils.NOT_ALLOWED
if result:
@@ -1039,29 +1119,34 @@ class BaseSharing:
answer['Status'] = "success"
pass
else:
logger.error("Toggle sharing: %r of user %s not successful", request_data['PathOrToken'], user)
logger.warning(api_info + ": %r by user %s not successful", request_data['PathOrToken'], user)
return httputils.bad_request("Internal Error")
else:
# default
logger.error(api_info + ": unsupported action=%r", action)
logger.warning(api_info + ": unsupported action=%r", action)
return httputils.bad_request("Invalid action")
# output handler
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/API/POST output format: %r", output_format)
logger.debug("TRACE/sharing/API/POST answer: %r", answer)
if output_format == "csv" or output_format == "txt":
if output_format == "csv" or output_format == "plain":
answer_array = []
if output_format == "txt":
if output_format == "plain":
for key in answer:
if key != 'Content':
answer_array.append(key + '=' + str(answer[key]))
if API_TYPES_V1[key] is bool or API_TYPES_V1[key] is int:
answer_array.append(key + '=' + str(answer[key]))
else:
answer_array.append(key + "='" + str(answer[key]) + "'")
if 'Content' in answer and answer['Content'] is not None:
csv = io.StringIO()
writer = DictWriter(csv, fieldnames=DB_FIELDS_V1, delimiter=';')
if output_format == "csv":
writer.writeheader()
elif output_format == "plain":
writer.writeheader()
for entry in answer['Content']:
# TODO: Argument 1 to "writerow" of "DictWriter" has incompatible type "str"; expected "Mapping[str, Any]" [arg-type]
writer.writerow(entry) # type: ignore[arg-type]
@@ -1071,7 +1156,10 @@ class BaseSharing:
index = 0
for line in csv.getvalue().splitlines():
# create a shell array with content lines
answer_array.append('Content[' + str(index) + ']="' + line.replace('"', '\\"') + '"')
if index == 0:
answer_array.append('Fields="' + line + '"')
else:
answer_array.append('Content[' + str(index - 1) + ']="' + line.replace('"', '\\"') + '"')
index += 1
headers = {
"Content-Type": "text/csv"

View File

@@ -30,7 +30,7 @@ class Sharing(sharing.BaseSharing):
_sharing_cache: list[dict] = []
_sharing_db_file: str
# Overloaded functions
# *** Overloaded functions ***
def database_init(self) -> bool:
logger.debug("sharing database initialization for type 'csv'")
sharing_db_file = self.configuration.get("sharing", "database_path")
@@ -158,48 +158,49 @@ class Sharing(sharing.BaseSharing):
index = 0
result = []
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, HiddenByOwner, HiddenByUser)
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)
for row in self._sharing_cache:
if index == 0:
# skip fieldnames
pass
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: test: %r", row)
if ShareType is not None and row['ShareType'] != ShareType:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: skip by ShareType")
pass
elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser):
pass
elif User is not None and row['User'] != User:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: skip by User")
pass
elif PathOrToken is not None and row['PathOrToken'] != PathOrToken:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: skip by PathOrToken")
pass
elif PathMapped is not None and row['PathMapped'] != PathMapped:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: skip by PathMapped")
pass
elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner:
pass
elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser:
pass
elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner:
pass
elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser:
for row in self._sharing_cache:
if index == 0:
# skip fieldnames
pass
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: add : %r", row)
result.append(row)
index += 1
return result
logger.debug("TRACE/sharing/list/row: test: %r", row)
if ShareType is not None and row['ShareType'] != ShareType:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: skip by ShareType")
pass
elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser):
pass
elif User is not None and row['User'] != User:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: skip by User")
pass
elif PathOrToken is not None and row['PathOrToken'] != PathOrToken:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: skip by PathOrToken")
pass
elif PathMapped is not None and row['PathMapped'] != PathMapped:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: skip by PathMapped")
pass
elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner:
pass
elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser:
pass
elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner:
pass
elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser:
pass
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: add : %r", row)
result.append(row)
index += 1
return result
def database_create_sharing(self,
ShareType: str,
@@ -213,54 +214,55 @@ class Sharing(sharing.BaseSharing):
""" create sharing """
row: dict
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing: ShareType=%r", ShareType)
if ShareType == "token":
with self._storage.acquire_lock("w", path=self._sharing_db_file):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/token/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions)
# check for duplicate token entry
for row in self._sharing_cache:
if row['ShareType'] != "token":
continue
if row['PathOrToken'] == PathOrToken:
# must be unique systemwide
logger.error("sharing/token/create: PathOrToken already exists: PathOrToken=%r", PathOrToken)
return {"status": "conflict"}
elif ShareType == "map":
logger.debug("TRACE/sharing: ShareType=%r", ShareType)
if ShareType == "token":
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/token/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions)
# check for duplicate token entry
for row in self._sharing_cache:
if row['ShareType'] != "token":
continue
if row['PathOrToken'] == PathOrToken:
# must be unique systemwide
logger.error("sharing/token/create: PathOrToken already exists: PathOrToken=%r", PathOrToken)
return {"status": "conflict"}
elif ShareType == "map":
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/map/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'] != "map":
continue
if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken:
# must be unique systemwide
logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User)
return {"status": "conflict"}
else:
return {"status": "error"}
row = {"ShareType": ShareType,
"PathOrToken": PathOrToken,
"PathMapped": PathMapped,
"Owner": Owner,
"User": User,
"Permissions": Permissions,
"EnabledByOwner": EnabledByOwner,
"EnabledByUser": EnabledByUser,
"HiddenByOwner": HiddenByOwner,
"HiddenByUser": HiddenByUser,
"TimestampCreated": Timestamp,
"TimestampUpdated": Timestamp}
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/map/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'] != "map":
continue
if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken:
# must be unique systemwide
logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User)
return {"status": "conflict"}
else:
return {"status": "error"}
logger.debug("TRACE/sharing/*/create: add row: %r", row)
self._sharing_cache.append(row)
row = {"ShareType": ShareType,
"PathOrToken": PathOrToken,
"PathMapped": PathMapped,
"Owner": Owner,
"User": User,
"Permissions": Permissions,
"EnabledByOwner": EnabledByOwner,
"EnabledByUser": EnabledByUser,
"HiddenByOwner": HiddenByOwner,
"HiddenByUser": HiddenByUser,
"TimestampCreated": Timestamp,
"TimestampUpdated": Timestamp}
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/*/create: add row: %r", row)
self._sharing_cache.append(row)
with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file):
if self._write_csv(self._sharing_db_file):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/create: write CSV done", ShareType)
return {"status": "success"}
logger.error("sharing/%s/create: cannot update CSV database", ShareType)
return {"status": "error"}
@@ -281,61 +283,59 @@ class Sharing(sharing.BaseSharing):
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)
# lookup token
found = False
index = 0
for row in self._sharing_cache:
if index == 0:
# skip fieldnames
pass
if row['ShareType'] != ShareType:
pass
elif row['PathOrToken'] != PathOrToken:
pass
else:
found = True
break
index += 1
with self._storage.acquire_lock("w", path=self._sharing_db_file):
# lookup token
found = False
index = 0
for row in self._sharing_cache:
if index == 0:
# skip fieldnames
pass
if row['ShareType'] != ShareType:
pass
elif row['PathOrToken'] != PathOrToken:
pass
else:
found = True
break
index += 1
if found:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/update: found index=%d", ShareType, index)
if found:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/update: orig row[%d]=%r", ShareType, index, row)
# CSV: remove+adjust+readd
if PathMapped is not None:
row["PathMapped"] = PathMapped
if Permissions is not None:
row["Permissions"] = Permissions
if User is not None:
row["User"] = User
if EnabledByOwner is not None:
row["EnabledByOwner"] = EnabledByOwner
if EnabledByUser is not None:
row["EnabledByUser"] = EnabledByUser
if HiddenByOwner is not None:
row["HiddenByOwner"] = HiddenByOwner
if HiddenByUser is not None:
row["HiddenByUser"] = HiddenByUser
if Properties is not None:
row["Properties"] = Properties
# update timestamp
row["TimestampUpdated"] = Timestamp
# CSV: remove+adjust+readd
if PathMapped is not None:
self._sharing_cache[index]["PathMapped"] = PathMapped
if Permissions is not None:
self._sharing_cache[index]["Permissions"] = Permissions
if User is not None:
self._sharing_cache[index]["User"] = User
if EnabledByOwner is not None:
self._sharing_cache[index]["EnabledByOwner"] = EnabledByOwner
if EnabledByUser is not None:
self._sharing_cache[index]["EnabledByUser"] = EnabledByUser
if HiddenByOwner is not None:
self._sharing_cache[index]["HiddenByOwner"] = HiddenByOwner
if HiddenByUser is not None:
self._sharing_cache[index]["HiddenByUser"] = HiddenByUser
if Properties is not None:
self._sharing_cache[index]["Properties"] = Properties
# update timestamp
self._sharing_cache[index]["TimestampUpdated"] = Timestamp
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/update: adj row[%d]=%r", ShareType, index, row)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/update: adj row[%d]=%r", ShareType, index, self._sharing_cache[index])
# replace row
self._sharing_cache[index] = row
with self._storage.acquire_lock("w", OwnerOrUser, path=self._sharing_db_file):
if self._write_csv(self._sharing_db_file):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/update: write CSV done", ShareType)
return {"status": "success"}
logger.error("sharing/%s/update: cannot update CSV database", ShareType)
return {"status": "error"}
else:
return {"status": "not-found"}
logger.error("sharing/%s/update: cannot update CSV database", ShareType)
return {"status": "error"}
else:
return {"status": "not-found"}
def database_delete_sharing(self,
ShareType: str,
@@ -344,42 +344,43 @@ class Sharing(sharing.BaseSharing):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r", ShareType, PathOrToken)
# lookup token
found = False
index = 0
for row in self._sharing_cache:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/delete: check: %r", ShareType, row)
if index == 0:
# skip fieldnames
pass
if row['ShareType'] != ShareType:
pass
elif row['PathOrToken'] != PathOrToken:
pass
else:
found = True
break
index += 1
with self._storage.acquire_lock("w", path=self._sharing_db_file):
# lookup token
found = False
index = 0
for row in self._sharing_cache:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/delete: check: %r", ShareType, row)
if index == 0:
# skip fieldnames
pass
if row['ShareType'] != ShareType:
pass
elif row['PathOrToken'] != PathOrToken:
pass
else:
found = True
break
index += 1
if found:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/delete: found index=%d", ShareType, index)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r index=%d", ShareType, PathOrToken, row['Owner'], index)
self._sharing_cache.pop(index)
if found:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/delete: found index=%d", ShareType, index)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r index=%d", ShareType, PathOrToken, row['Owner'], index)
self._sharing_cache.pop(index)
with self._storage.acquire_lock("w", row['Owner'], path=self._sharing_db_file):
if self._write_csv(self._sharing_db_file):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing_by_token: write CSV done")
return {"status": "success"}
logger.error("sharing/%s/delete: cannot update CSV database", ShareType)
return {"status": "error"}
else:
return {"status": "not-found"}
# local functions
logger.error("sharing/%s/delete: cannot update CSV database", ShareType)
return {"status": "error"}
else:
return {"status": "not-found"}
# *** local functions ***
def _create_empty_csv(self, file: str) -> bool:
with self._storage.acquire_lock("w", None, path=file):
with open(file, 'w', newline='') as csvfile:

View File

@@ -155,7 +155,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 HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, 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", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser)
for _ShareType in sharing.SHARE_TYPES_V1:
if ShareType is not None and _ShareType != ShareType:

View File

@@ -72,7 +72,11 @@ user: .*
collection: .*
permissions: RrWw""")
self.configure({"rights": {"file": rights_file_path,
"type": "from_file"}})
"type": "from_file"},
"logging": {"request_header_on_debug": "True",
"request_content_on_debug": "True",
"response_header_on_debug": "True",
"response_content_on_debug": "True"}})
def test_root(self) -> None:
"""GET request at "/"."""

View File

@@ -191,6 +191,7 @@ class TestSharingApiSanity(BaseTest):
"collection_by_token": "True"},
"rights": {"type": "owner_only"}})
form_array: Sequence[str]
json_dict: dict
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
@@ -231,7 +232,16 @@ class TestSharingApiSanity(BaseTest):
_, headers, _ = self.request("POST", path, check=404, login="owner:ownerpw")
# check info hook
logging.info("\n*** check API hook: info/all")
logging.info("\n*** check API hook: info/all (text)")
form_array = []
_, headers, answer = self._sharing_api_form("all", "info", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status='success'" in answer
assert "PermittedCreateCollectionByMap=False" in answer
assert "PermittedCreateCollectionByToken=False" in answer
assert "FeatureEnabledCollectionByMap=True" in answer
assert "FeatureEnabledCollectionByToken=False" in answer
logging.info("\n*** check API hook: info/all (json)")
json_dict = {}
_, headers, answer = self._sharing_api_json("all", "info", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
@@ -306,6 +316,9 @@ class TestSharingApiSanity(BaseTest):
form_array: Sequence[str]
json_dict: dict
self.mkcalendar("/owner/collectionL1/", login="owner:ownerpw")
self.mkcalendar("/owner/collectionL2/", 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}})
@@ -319,13 +332,13 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** list (form->csv)")
form_array = []
_, headers, answer = self._sharing_api_form(sharing_type, "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=not-found" in answer
assert "Status='not-found'" in answer
assert "Lines=0" in answer
logging.info("\n*** list (json->text)")
json_dict = {}
_, headers, answer = self._sharing_api_json(sharing_type, "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain")
assert "Status=not-found" in answer
assert "Status='not-found'" in answer
assert "Lines=0" in answer
logging.info("\n*** list (json->json)")
@@ -338,10 +351,10 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** create a token -> 200")
form_array = ["PathMapped=/owner/collectionL1/"]
_, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "PathOrToken=" in answer
assert "Status='success'" in answer
assert "PathOrToken='" in answer
# extract token
match = re.search('PathOrToken=(.+)', answer)
match = re.search("PathOrToken='(.+)'", answer)
if match:
token = match.group(1)
logging.info("received token %r", token)
@@ -359,21 +372,33 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** list/all (form->csv)")
form_array = []
_, headers, answer = self._sharing_api_form("all", "list", check=200, login="owner:ownerpw", form_array=form_array, accept="text/csv")
assert "Status=" not in answer
assert "Line=" not in answer
assert "ShareType" in answer
assert "token" in answer
assert "map" in answer
logging.info("\n*** list/all (form->text)")
form_array = []
_, headers, answer = self._sharing_api_form("all", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=2" in answer
assert "Fields=" in answer
assert "Content[0]=" in answer
assert "Content[1]=" in answer
logging.info("\n*** delete token -> 200")
form_array = ["PathOrToken=" + token]
_, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
logging.info("\n*** delete share -> 200")
form_array = []
form_array.append("PathOrToken=/user/collectionL2-shared-by-owner/")
form_array.append("PathMapped=/owner/collectionL2/")
_, headers, answer = self._sharing_api_form("map", "delete", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
def test_sharing_api_token_basic(self) -> None:
"""share-by-token API tests."""
@@ -394,6 +419,9 @@ class TestSharingApiSanity(BaseTest):
form_array: Sequence[str]
json_dict: dict
path_base1 = "/owner/collection1.ics/"
path_base2 = "/owner/collection2.ics/"
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}})
@@ -406,13 +434,21 @@ class TestSharingApiSanity(BaseTest):
json_dict = {}
_, headers, answer = self._sharing_api_json("token", "create", 400, login="owner:ownerpw", json_dict=json_dict)
logging.info("\n*** create token#1 (form->text)")
form_array = ["PathMapped=/owner/collection1/"]
logging.info("\n*** create token#1 without existing collection (form->text)")
form_array = ["PathMapped=" + path_base1]
_, headers, answer = self._sharing_api_form("token", "create", check=404, login="owner:ownerpw", form_array=form_array)
logging.info("\n*** create collection*")
self.mkcalendar(path_base1, login="owner:ownerpw")
self.mkcalendar(path_base2, login="owner:ownerpw")
logging.info("\n*** create token#1 with existing collection (form->text)")
form_array = ["PathMapped=" + path_base1]
_, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "PathOrToken=" in answer
assert "Status='success'" in answer
assert "PathOrToken='" in answer
# extract token
match = re.search('PathOrToken=(.+)', answer)
match = re.search("PathOrToken='(.+)'", answer)
if match:
token1 = match.group(1)
logging.info("received token %r", token1)
@@ -420,12 +456,12 @@ class TestSharingApiSanity(BaseTest):
assert False
logging.info("\n*** create token#2 (json->text)")
json_dict = {'PathMapped': "/owner/collection2/"}
json_dict = {'PathMapped': path_base2}
_, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain")
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Token=" in answer
# extract token
match = re.search('Token=(.+)', answer)
match = re.search("Token='(.+)'", answer)
if match:
token2 = match.group(1)
logging.info("received token %r", token2)
@@ -435,16 +471,16 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** lookup token#1 (form->text)")
form_array = ["PathOrToken=" + token1]
_, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=1" in answer
assert "/owner/collection1/" in answer
assert path_base1 in answer
logging.info("\n*** lookup token#2 (json->text")
json_dict = {'PathOrToken': token2}
_, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain")
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=1" in answer
assert "/owner/collection2/" in answer
assert path_base2 in answer
logging.info("\n*** lookup token#2 (json->json)")
json_dict = {'PathOrToken': token2}
@@ -452,46 +488,46 @@ class TestSharingApiSanity(BaseTest):
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
assert answer_dict['Lines'] == 1
assert answer_dict['Content'][0]['PathMapped'] == "/owner/collection2/"
assert answer_dict['Content'][0]['PathMapped'] == path_base2
logging.info("\n*** lookup tokens (form->text)")
form_array = []
_, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=2" in answer
assert "/owner/collection1/" in answer
assert "/owner/collection2/" in answer
assert path_base1 in answer
assert path_base2 in answer
logging.info("\n*** lookup tokens (form->csv)")
form_array = []
_, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array, accept="text/csv")
assert "Status=success" not in answer
assert "Status='success'" not in answer
assert "Lines=2" not in answer
assert ";".join(sharing.DB_FIELDS_V1) in answer
assert "/owner/collection1/" in answer
assert "/owner/collection2/" in answer
assert path_base1 in answer
assert path_base2 in answer
logging.info("\n*** delete token#1 (form->text)")
form_array = ["PathOrToken=" + token1]
_, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
logging.info("\n*** lookup token#1 (form->text) -> should not be there anymore")
form_array = ["PathOrToken=" + token1]
_, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=not-found" in answer
assert "Status='not-found'" in answer
assert "Lines=0" in answer
logging.info("\n*** lookup tokens (form->text) -> still one should be there")
form_array = []
_, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=1" in answer
logging.info("\n*** disable token#2 as owner (form->text)")
form_array = ["PathOrToken=" + token2]
_, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
logging.info("\n*** lookup token#2 (json->json) -> check for not enabled")
json_dict = {'PathOrToken': token2}
@@ -512,7 +548,7 @@ class TestSharingApiSanity(BaseTest):
form_array = []
form_array.append("PathOrToken=" + token2)
_, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=1" in answer
assert "True;True;True;True" in answer
@@ -520,13 +556,13 @@ class TestSharingApiSanity(BaseTest):
form_array = []
form_array.append("PathOrToken=" + token2)
_, headers, answer = self._sharing_api_form("token", "hide", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
logging.info("\n*** lookup token#2 (form->text) -> check for hidden")
form_array = []
form_array.append("PathOrToken=" + token2)
_, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=1" in answer
assert "True;True;True;True" in answer
@@ -561,6 +597,10 @@ class TestSharingApiSanity(BaseTest):
assert answer_dict['Status'] == "not-found"
assert answer_dict['Lines'] == 0
logging.info("\n*** delete collection*")
self.delete(path_base1, login="owner:ownerpw")
self.delete(path_base2, login="owner:ownerpw")
def test_sharing_api_token_usage(self) -> None:
"""share-by-token API tests - real usage."""
self.configure({"auth": {"type": "htpasswd",
@@ -590,6 +630,8 @@ class TestSharingApiSanity(BaseTest):
path = path_base + "/event1.ics"
self.put(path, event, login="owner:ownerpw")
self.mkcalendar(path_base2, 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}})
@@ -606,10 +648,10 @@ class TestSharingApiSanity(BaseTest):
form_array = []
form_array.append("PathMapped=" + path_base)
_, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "PathOrToken=" in answer
# extract token
match = re.search('PathOrToken=(.+)', answer)
match = re.search("PathOrToken='(.+)'", answer)
if match:
token = match.group(1)
logging.info("received token %r", token)
@@ -620,10 +662,10 @@ class TestSharingApiSanity(BaseTest):
form_array = []
form_array.append("PathMapped=" + path_base2)
_, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "PathOrToken=" in answer
# extract token
match = re.search('PathOrToken=(.+)', answer)
match = re.search("PathOrToken='(.+)'", answer)
if match:
token2 = match.group(1)
logging.info("received token %r", token2)
@@ -633,7 +675,7 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** enable token (form->text)")
form_array = ["PathOrToken=" + token]
_, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
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)
@@ -645,7 +687,7 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** disable token (form->text)")
form_array = ["PathOrToken=" + token]
_, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
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)
@@ -653,7 +695,7 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** enable token (form->text)")
form_array = ["PathOrToken=" + token]
_, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
logging.info("\n*** fetch collection using token (without credentials)")
_, headers, answer = self.request("GET", path_token + token, check=200)
@@ -2207,7 +2249,7 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** list/all (form->csv)")
form_array = []
_, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=1" in answer
# read collection
@@ -2227,7 +2269,7 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** list/all (form->csv)")
form_array = []
_, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
assert "Lines=1" in answer
# read collection
@@ -2415,8 +2457,23 @@ class TestSharingApiSanity(BaseTest):
"collection_by_token": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "False",
"rights_rule_doesnt_match_on_debug": "True",
"request_content_on_debug": "True"},
"rights": {"type": "owner_only"}})
rights_file_path = os.path.join(self.colpath, "rights")
with open(rights_file_path, "w") as f:
f.write("""\
[default-collection]
user: .+
collection: .+
permissions: RrWw
[default]
user: .+
collection: {user}(/.*)?
permissions: RrWw""")
self.configure({"rights": {"file": rights_file_path}})
json_dict: dict
path_user1 = "/user1/calendarCCu1.ics/"
@@ -2440,6 +2497,9 @@ class TestSharingApiSanity(BaseTest):
logging.info("\n*** test: %s", db_type)
self.configure({"sharing": {"type": db_type}})
# owner_only
self.configure({"rights": {"type": "owner_only"}})
# create map
logging.info("\n*** create map user1/owner1 -> ok")
json_dict = {}
@@ -2453,7 +2513,7 @@ class TestSharingApiSanity(BaseTest):
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
logging.info("\n*** mkcalendar user1 for shared -> conflict")
logging.info("\n*** mkcalendar as user1 for user1/shared1 -> conflict")
self.mkcalendar(path_user1_shared1, login="user1:user1pw", check=409)
# create map
@@ -2469,7 +2529,7 @@ class TestSharingApiSanity(BaseTest):
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
logging.info("\n*** mkcol user2 for shared -> conflict")
logging.info("\n*** mkcol as user2 for user2/shared1 -> conflict")
self.mkcalendar(path_user2_shared1, login="user2:user2pw", check=409)
# create map
@@ -2483,6 +2543,15 @@ class TestSharingApiSanity(BaseTest):
json_dict['Hidden'] = False
_, headers, answer = self._sharing_api_json("map", "create", check=409, login="owner1:owner1pw", json_dict=json_dict)
# from_file
self.configure({"rights": {"type": "from_file"}})
logging.info("\n*** mkcalendar as user1 for user2/shared1 with rights from file -> conflict")
self.mkcalendar(path_user2_shared1, login="user1:user1pw", check=409)
logging.info("\n*** mkcol as user1 for user2/shared1 with rights from file -> conflict")
self.mkcol(path_user2_shared1, login="user1:user1pw", check=409)
def test_sharing_api_permissions_global(self) -> None:
"""sharing API usage tests related to global permissions."""
self.configure({"auth": {"type": "htpasswd",
@@ -2916,7 +2985,7 @@ 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)
# verify PROPPATCH as user
# verify PROPFIND as user
logging.info("\n*** PROPFIND collection user -> ok")
propfind_calendar_color = get_file_content("propfind_multiple.xml")
_, responses = self.propfind(path_mapped, propfind_calendar_color, login="owner:ownerpw")
@@ -2987,7 +3056,7 @@ permissions: RrWw""")
form_array.append("Properties='C:calendar-description'='ICAL-USER-NEW'")
form_array.append("Properties='ICAL:calendar-color'='#CCCCCC'")
_, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array)
assert "Status=success" in answer
assert "Status='success'" in answer
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
@@ -3004,12 +3073,132 @@ permissions: RrWw""")
assert status == 200 and prop.text == "#CCCCCC"
# update map by user
logging.info("\n*** update map by user (form)")
logging.info("\n*** update properties with buggyy ones by user (form)")
form_array = ["User=" + "user"]
form_array.append("PathOrToken=" + path_shared_r)
form_array.append("Properties=BUGGYENTRY=BUGGYVALUE")
_, headers, answer = self._sharing_api_form("map", "update", check=400, login="user:userpw", form_array=form_array)
def test_sharing_api_map_propfind_overlay_api_delete(self) -> None:
"""share-by-map API usage tests related to proppatch."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_map": True,
"permit_create_token": True,
"permit_properties_overlay": True,
"collection_by_map": "True",
"collection_by_token": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True",
"request_content_on_debug": "True"},
"rights": {"type": "owner_only"}})
form_array: Sequence[str]
json_dict: dict
path_mapped = "/owner/calendarPFD.ics/"
path_shared_r = "/user/calendarPFD-shared-by-owner-r.ics/"
logging.info("\n*** prepare and test access")
self.mkcalendar(path_mapped, 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}})
# check PROPFIND as owner
logging.info("\n*** PROPFIND collection owner -> ok")
_, responses = self.propfind(path_mapped, """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<current-user-principal />
</prop>
</propfind>""", login="owner:ownerpw")
logging.info("response: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int) and len(response) == 1
status, prop = response["D:current-user-principal"]
assert status == 200 and len(prop) == 1
element = prop.find(xmlutils.make_clark("D:href"))
assert element is not None and element.text == "/owner/"
# create map
logging.info("\n*** create map user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "r"
json_dict['Enabled'] = True
json_dict['Hidden'] = False
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
# enable map by user
logging.info("\n*** enable map by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
# set properties by user
logging.info("\n*** set properties by user (form)")
form_array = []
form_array.append("PathOrToken=" + path_shared_r)
form_array.append("Properties='ICAL:calendar-color'='#CCCCCC'")
_, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array)
# check that properties are existing in map
logging.info("\n*** list and check for properties (json->json)")
_, 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
assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == '#CCCCCC'
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
propfind_calendar_color = get_file_content("propfind_calendar_color.xml")
_, responses = self.propfind(path_shared_r, propfind_calendar_color, login="user:userpw")
logging.info("response: %r", responses)
response = responses[path_shared_r]
assert not isinstance(response, int)
status, prop = response["ICAL:calendar-color"]
logging.debug("calendar-color: %r", prop.text)
assert status == 200 and prop.text == "#CCCCCC"
# clear properties by user
logging.info("\n*** clear properties by user (form)")
form_array = []
form_array.append("PathOrToken=" + path_shared_r)
form_array.append("Properties=")
_, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array)
# check that properties are cleared
logging.info("\n*** list and check for cleared properties (json->json)")
_, 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
assert answer_dict['Content'][0]['Properties'] is not None
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay no longer exists) -> ok")
propfind_calendar_color = get_file_content("propfind_calendar_color.xml")
_, responses = self.propfind(path_shared_r, propfind_calendar_color, login="user:userpw")
logging.info("response: %r", responses)
response = responses[path_shared_r]
assert not isinstance(response, int)
status, prop = response["ICAL:calendar-color"]
logging.debug("calendar-color: %r", prop.text)
assert status == 404
def test_sharing_api_map_propfind_overlay_api_permissions(self) -> None:
"""share-by-map API usage tests related to proppatch."""
self.configure({"auth": {"type": "htpasswd",