diff --git a/SHARING.md b/SHARING.md index 42867ce6..757bb480 100644 --- a/SHARING.md +++ b/SHARING.md @@ -246,7 +246,10 @@ Can be selected by `HTTP_ACCEPT` Shows what kind of ShareTypes are supported -* Example: TEXT + * Output: text/plain|application/json + + * Examples + * form->text ``` curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info @@ -258,7 +261,7 @@ FeatureEnabledCollectionByToken=True PermittedCreateCollectionByToken=True ``` -* Example: JSON + * json->json, parsed with `jq` ``` curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq @@ -272,7 +275,6 @@ curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost: } ``` - ##### API Hook "(token|map)/create" * Authorization @@ -284,8 +286,10 @@ Authenticated user is `Owner` 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 @@ -298,19 +302,27 @@ Authenticated user as `Owner` has at least read access to `PathMapped` | Hidden | bool | optional(owner/default:True) | | Properties | str | optional | - * Output + * Output: text/plain|application/json | Parameter | Type | Value | | - | - | | PathOrToken | str | (autogenerated token) | -* Example: TEXT + * Examples: + * form->text ``` -curl -u user:pass -d "PathMapped=/user/testcalendar1/" http://localhost:5232/.sharing/v1/token/create +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=' +``` + + * json->json + +``` +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" @@ -318,10 +330,11 @@ 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 @@ -335,24 +348,30 @@ Provided `User` has at least read access to `PathOrToken` | 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 +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 + +``` +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 @@ -361,14 +380,58 @@ Authenticated user as `Owner` or `User` | PathOrToken | str | optional | | PathMapped | str | optional | - * Output: plain/csv/json + * Output: text/plain|text/csv|application/json - * Example: CSV + * Examples + * form->csv ("map" only) ``` -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,Properties -map,/user/cal1-from-owner/,/owner/cal1/,owner,user,r,False,False,True,True,1771962120,1771962120,{} +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` + +``` +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] +} ``` @@ -377,8 +440,8 @@ 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 @@ -386,7 +449,23 @@ Authenticated user is `Owner` | - | - | - | - | - | | PathOrToken | str | selection | mandatory | not-permitted | - * Output: result status + * Output: text/plain|application/json + + * Examples: + * form->text + +``` +curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete +ApiVersion=1 +Status='success' +``` + + * json->json + +``` +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" @@ -395,8 +474,7 @@ 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 @@ -410,15 +488,29 @@ Authenticated user is `Owner` or `User` | Hidden | bool | adjust | optional(owner) | optional(user) | | Properties | str | adjust | optional | optional | - * Output: result status + * Output: text/plain|application/json + + * form->text +``` +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 + +``` +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 @@ -426,22 +518,21 @@ Authenticated user is `Owner` or `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 +curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable ApiVersion=1 Status='success' +``` + + * json->json + +``` +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 diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index b6b5d8a4..31f01daf 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -290,7 +290,7 @@ class BaseSharing: return None # list sharings of type "map" - def sharing_collection_map_list(self, user: str, active: bool = True) -> list[dict]: + def sharing_collection_map_list(self, user: Union[str, None], 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): @@ -488,6 +488,7 @@ class BaseSharing: # parse body according to content-type content_type = environ.get("CONTENT_TYPE", "") if 'application/json' in content_type: + output_format = "json" # default try: request_data = json.loads(request_body) except json.JSONDecodeError: @@ -501,6 +502,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + " (json): %r", f"{request_data}") elif 'application/x-www-form-urlencoded' in content_type: + output_format = "txt" # default request_parsed = parse_qs(request_body) # convert arrays into single value request_data = {} @@ -539,8 +541,11 @@ class BaseSharing: output_format = "json" elif 'text/csv' in accept: output_format = "csv" - else: + elif 'text/plain' in accept: output_format = "txt" + else: + # default from input type + pass if output_format == "csv": if not action == "list": @@ -555,7 +560,6 @@ class BaseSharing: # 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 @@ -685,7 +689,7 @@ class BaseSharing: logger.error(api_info + ": missing PathMapped") return httputils.bad_request("Missing PathMapped") - ## check whether collection exists + # check whether collection exists with self._storage.acquire_lock("r", user, path=PathMapped): item = next(iter(self._storage.discover(PathMapped)), None) if not item: @@ -741,7 +745,6 @@ class BaseSharing: else: User = user - # v1: create uuid token with 2x 32 bytes = 256 bit token = "v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') @@ -784,9 +787,9 @@ 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.info("Add sharing-by-map: access to path(mapped) %r not allowed for owner %r", PathMapped, user) return httputils.NOT_ALLOWED if self.permit_create_map is False: diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index b2111771..8fb19fb3 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -306,6 +306,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}}) @@ -605,6 +608,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}})