sharing: add support for clearing properties, doc update, fixes

This commit is contained in:
Peter Bieringer
2026-03-07 07:54:37 +01:00
parent a5de2bf28f
commit ffc5184b11
5 changed files with 214 additions and 11 deletions

View File

@@ -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='<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:ICAL="http://apple.com/ns/ical/">
<prop>
<ICAL:calendar-color />
</prop>
</propfind>'
## PROPPATCH color
xml_ppc='<?xml version="1.0"?>
<D:propertyupdate xmlns:D="DAV:">
<D:set>
<D:prop>
<I:calendar-color xmlns:I="http://apple.com/ns/ical/">#DDDDDD</I:calendar-color>
</D:prop>
</D:set>
</D:propertyupdate>'
```
* Tests
```bash
## Retrieve collection color of owner (no color set)
curl -u owner:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/
## Create read-only share for user
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
## Accept (enable+unhide) share by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (no color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Set property overlay by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Delete property overlay by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (no color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Add property overlay by user using PROPPATCH
curl -u user:pass -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/
## Retrieve collection color of share by user (color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
```

View File

@@ -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

View File

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

View File

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

View File

@@ -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, """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<current-user-principal />
</prop>
</propfind>""", login="owner:ownerpw")
logging.info("response: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int) and len(response) == 1
status, prop = response["D:current-user-principal"]
assert status == 200 and len(prop) == 1
element = prop.find(xmlutils.make_clark("D:href"))
assert element is not None and element.text == "/owner/"
# create map
logging.info("\n*** create map user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "r"
json_dict['Enabled'] = True
json_dict['Hidden'] = False
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
# enable map by user
logging.info("\n*** enable map by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
# set properties by user
logging.info("\n*** set properties by user (form)")
form_array = []
form_array.append("PathOrToken=" + path_shared_r)
form_array.append("Properties='ICAL:calendar-color'='#CCCCCC'")
_, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array)
# check that properties are existing in map
logging.info("\n*** list and check for properties (json->json)")
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="user:userpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
assert answer_dict['Lines'] == 1
assert answer_dict['Content'][0]['Properties']['ICAL:calendar-color'] == '#CCCCCC'
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
propfind_calendar_color = get_file_content("propfind_calendar_color.xml")
_, responses = self.propfind(path_shared_r, propfind_calendar_color, login="user:userpw")
logging.info("response: %r", responses)
response = responses[path_shared_r]
assert not isinstance(response, int)
status, prop = response["ICAL:calendar-color"]
logging.debug("calendar-color: %r", prop.text)
assert status == 200 and prop.text == "#CCCCCC"
# clear properties by user
logging.info("\n*** clear properties by user (form)")
form_array = []
form_array.append("PathOrToken=" + path_shared_r)
form_array.append("Properties=")
_, headers, answer = self._sharing_api_form("map", "update", check=200, login="user:userpw", form_array=form_array)
# check that properties are cleared
logging.info("\n*** list and check for cleared properties (json->json)")
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="user:userpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
assert answer_dict['Lines'] == 1
assert answer_dict['Content'][0]['Properties'] is not None
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay no longer exists) -> ok")
propfind_calendar_color = get_file_content("propfind_calendar_color.xml")
_, responses = self.propfind(path_shared_r, propfind_calendar_color, login="user:userpw")
logging.info("response: %r", responses)
response = responses[path_shared_r]
assert not isinstance(response, int)
status, prop = response["ICAL:calendar-color"]
logging.debug("calendar-color: %r", prop.text)
assert status == 404
def test_sharing_api_map_propfind_overlay_api_permissions(self) -> None:
"""share-by-map API usage tests related to proppatch."""
self.configure({"auth": {"type": "htpasswd",