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",