diff --git a/SHARING.md b/SHARING.md index 7ece76f5..c359e123 100644 --- a/SHARING.md +++ b/SHARING.md @@ -559,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/propfind.py b/radicale/app/propfind.py index da6106a4..98ee950b 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -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 share: - # map 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] element.text = tag_text else: is404 = True diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index e83bf70d..f540fe86 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -518,7 +518,7 @@ class BaseSharing: elif 'application/x-www-form-urlencoded' in content_type: input_format = "form" output_format = "plain" # default - request_parsed = parse_qs(request_body) + request_parsed = parse_qs(request_body, keep_blank_values=True) # convert arrays into single value request_data = {} for key in request_parsed: @@ -526,6 +526,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") @@ -535,6 +539,9 @@ 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]) @@ -901,6 +908,10 @@ class BaseSharing: 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, @@ -918,7 +929,7 @@ class BaseSharing: if PathMapped is not None or Permissions is not None or User is not None: 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: @@ -935,6 +946,10 @@ class BaseSharing: 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, 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 f12b0124..8be2575c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -2985,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") @@ -3073,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",