diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c08c9d90..afaa5daf 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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)_ diff --git a/SHARING.md b/SHARING.md index daebf668..c359e123 100644 --- a/SHARING.md +++ b/SHARING.md @@ -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/` +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/` ### Sharing Configuration Management API version 1 -(_>= 3.7.0_) - Type: POST API Base-URI: `/.sharing/v1//` @@ -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=' + + + + +' + +## PROPPATCH color +xml_ppc=' + + + + #DDDDDD + + +' +``` + + * 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/ +``` diff --git a/radicale/app/delete.py b/radicale/app/delete.py index ff22e0c5..3401755a 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -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 diff --git a/radicale/app/get.py b/radicale/app/get.py index cea26f31..fa4158ad 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -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 diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index b892aa6b..02a821e1 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -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") diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 6670349f..66b0fd15 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -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) diff --git a/radicale/app/move.py b/radicale/app/move.py index b093aa25..9af6e378 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -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"): diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 64cb45f8..98ee950b 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -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) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 59d56c83..4c07edc7 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -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, diff --git a/radicale/app/put.py b/radicale/app/put.py index 4e62ec1d..b9b764c8 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -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"): diff --git a/radicale/app/report.py b/radicale/app/report.py index 35e9237e..b940fc92 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -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) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 8dba8314..801f6719 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -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" diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index cf761923..62c8a9dc 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -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: diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index 3a79730f..fba8bdd6 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -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: diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index f50cf0be..4eee5c05 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -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 "/".""" diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index aef27c29..8be2575c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -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, """\ + + + + + +""", 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",