From 99f2472bfafbc5986d3848f35eeec1faaf3d7b52 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 27 Feb 2026 08:30:54 +0100 Subject: [PATCH] sharing: add property overlay and update support --- SHARING.md | 3 ++ radicale/app/propfind.py | 4 +++ radicale/sharing/__init__.py | 60 +++++++++++++++++++++++++++--------- radicale/sharing/csv.py | 37 ++++++++++++++++------ radicale/sharing/files.py | 33 ++++++++++++++------ 5 files changed, 103 insertions(+), 34 deletions(-) diff --git a/SHARING.md b/SHARING.md index 62750c61..28bb208e 100644 --- a/SHARING.md +++ b/SHARING.md @@ -28,6 +28,7 @@ Types of supported sharing configuration: * `HiddenByUser`: control by user * `TimestampCreated`: unixtime of creation * `TimestampUpdated`: unixtime of last update + * `Properties`: overlay properties (limited set whitelisted) `Enabled*`: owner AND user have to enable a share to become usable @@ -250,7 +251,9 @@ Update a share selected by `PathOrToken` | - | - | - | | PathOrToken | yes | n/a | | PathMapped | no | | +| OwnerOrUser | yes | n/a | | User | no | | +| Properties | no | | * Output: result status diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index fc797c74..96a32eae 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -345,6 +345,10 @@ def xml_propfind_response( human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) if tag_text is not None: + if sharing: + # map from overlay + if sharing['Properties'][human_tag] is not None: + tag_text = sharing['Properties'][human_tag] element.text = tag_text else: is404 = True diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d96d3d7d..fbfa3e0e 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -33,7 +33,7 @@ from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") -DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') +DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated', 'Properties') DB_FIELDS_V1_BOOL: Sequence[str] = ('EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser') DB_FIELDS_V1_INT: Sequence[str] = ('TimestampCreated', 'TimestampUpdated') # ShareType: @@ -76,6 +76,7 @@ PATH_PATTERN: str = "([a-zA-Z0-9/.\\-]+)" # TODO: extend or find better source USER_PATTERN: str = "([a-zA-Z0-9@]+)" # TODO: extend or find better source +OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color") def load(configuration: "config.Configuration") -> "BaseSharing": """Load the sharing database module chosen in configuration.""" @@ -178,20 +179,22 @@ class BaseSharing: Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, - Timestamp: int = 0) -> dict: + Timestamp: int = 0, + Properties: Union[str, None] = None) -> dict: """ create sharing """ return {"status": "not-implemented"} def update_sharing(self, ShareType: str, PathOrToken: str, - Owner: Union[str, None] = None, + OwnerOrUser: str, User: Union[str, None] = None, PathMapped: Union[str, None] = None, Permissions: Union[str, None] = None, EnabledByOwner: Union[bool, None] = None, HiddenByOwner: Union[bool, None] = None, - Timestamp: int = 0) -> dict: + Timestamp: int = 0, + Properties: Union[str, None] = None) -> dict: """ update sharing """ return {"status": "not-implemented"} @@ -391,6 +394,8 @@ class BaseSharing: PathOrToken: (mandatory) User: (mandatory) + action: (token|map)/update + action: (token|map)/(delete|disable|enable|hide|unhide) PathOrToken: (mandatory) @@ -481,7 +486,21 @@ class BaseSharing: # convert arrays into single value request_data = {} for key in request_parsed: - request_data[key] = request_parsed[key][0] + if key == "Properties": + # Properties key value parser + properties_dict: dict = {} + for entry in request_parsed[key]: + m = re.search('^([^=]+)=([^=]+)$', entry) + if not m: + return httputils.bad_request("Invalid properties format in form") + token = m.group(1).lstrip('"\'').rstrip('"\'') + value = m.group(2).lstrip('"\'').rstrip('"\'') + properties_dict[token] = value + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/API: converted Properties from form into dict: %r", properties_dict) + request_data[key] = properties_dict + else: + request_data[key] = request_parsed[key][0] if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + " (form): %r", f"{request_data}") else: @@ -518,6 +537,7 @@ class BaseSharing: HiddenByOwner: Union[bool, None] = None EnabledByUser: Union[bool, None] = None HiddenByUser: Union[bool, None] = None + Properties: Union[str, None] = None # parameters sanity check for key in request_data: @@ -552,12 +572,9 @@ class BaseSharing: # check for mandatory parameters if 'PathMapped' not in request_data: - if action == 'info': + if action in ['info', 'list', 'update']: # ignored pass - elif action == "list": - # optional - pass else: if ShareType == "token" and action != 'create': # optional @@ -588,6 +605,13 @@ class BaseSharing: if 'Permissions' in request_data: Permissions = request_data['Permissions'] + if 'Properties' in request_data: + # verify against whitelist + for entry in request_data['Properties']: + if entry not in OVERLAY_PROPERTIES_WHITELIST: + return httputils.bad_request("Property not supported to overlay: %r" % entry) + Properties = request_data['Properties'] + if ShareType == "map": if action == 'info': # ignored @@ -691,7 +715,8 @@ class BaseSharing: Owner=Owner, User=Owner, Permissions=str(Permissions), # mandantory EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, - Timestamp=Timestamp) + Timestamp=Timestamp, + Properties=Properties) if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": result=%r", result) @@ -747,7 +772,8 @@ class BaseSharing: Permissions=str(Permissions), # mandatory EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, EnabledByUser=EnabledByUser, HiddenByUser=HiddenByUser, - Timestamp=Timestamp) + Timestamp=Timestamp, + Properties=Properties) else: logger.error(api_info + ": unsupported for ShareType=%r", ShareType) @@ -784,8 +810,10 @@ class BaseSharing: EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, PathOrToken=str(PathOrToken), # verification above that it is not None - Owner=Owner, - Timestamp=Timestamp) + OwnerOrUser=Owner, + User=User, + Timestamp=Timestamp, + Properties=Properties) elif ShareType == "map": result = self.update_sharing( @@ -795,8 +823,10 @@ class BaseSharing: EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, PathOrToken=str(PathOrToken), # verification above that it is not None - Owner=Owner, - Timestamp=Timestamp) + OwnerOrUser=Owner, + User=User, + Timestamp=Timestamp, + Properties=Properties) else: logger.error(api_info + ": unsupported for ShareType=%r", ShareType) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index b0481d55..db68f605 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -128,8 +128,9 @@ class Sharing(sharing.BaseSharing): UserShare = row['User'] Permissions = row['Permissions'] Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) + Properties = row['Properties'] if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden) + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s Properties=%r)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden, Properties) return { "mapped": True, "PathOrToken": PathOrToken, @@ -137,7 +138,8 @@ class Sharing(sharing.BaseSharing): "Owner": Owner, "User": UserShare, "Hidden": Hidden, - "Permissions": Permissions} + "Permissions": Permissions, + "Properties": Properties} return None def list_sharing(self, @@ -205,7 +207,8 @@ class Sharing(sharing.BaseSharing): Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, - Timestamp: int = 0) -> dict: + Timestamp: int = 0, + Properties: Union[str, None] = None) -> dict: """ create sharing """ row: dict @@ -263,16 +266,17 @@ class Sharing(sharing.BaseSharing): def update_sharing(self, ShareType: str, PathOrToken: str, - Owner: Union[str, None] = None, + OwnerOrUser: str, User: Union[str, None] = None, PathMapped: Union[str, None] = None, Permissions: Union[str, None] = None, EnabledByOwner: Union[bool, None] = None, HiddenByOwner: Union[bool, None] = None, - Timestamp: int = 0) -> dict: + Timestamp: int = 0, + Properties: Union[str, None] = None) -> dict: """ update sharing """ if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/%s/update: PathOrToken=%r Owner=%r PathMapped=%r", ShareType, PathOrToken, Owner, PathMapped) + logger.debug("TRACE/sharing/%s/update: PathOrToken=%r OwnerOrUser=%r PathMapped=%r Properties=%r", ShareType, PathOrToken, OwnerOrUser, PathMapped, Properties) # lookup token found = False @@ -293,12 +297,23 @@ class Sharing(sharing.BaseSharing): if found: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: found index=%d", ShareType, index) - if Owner is not None and row['Owner'] != Owner: - return {"status": "permission-denied"} + + if row['Owner'] != OwnerOrUser: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: OwnerOrUser=%r not matching Owner=%r -> check now for matching User=%r", ShareType, OwnerOrUser, row['Owner'], row['User']) + if row['User'] == OwnerOrUser and PathMapped is None and Permissions is None and EnabledByOwner is None and HiddenByOwner is None and Properties is not None: + # user is only permitted to update Properties + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: OwnerOrUser=%r PathOrToken=%r index=%d is permitted to update Properties", ShareType, OwnerOrUser, PathOrToken, index) + pass + else: + return {"status": "permission-denied"} + if User is not None and row['User'] != User: return {"status": "permission-denied"} + if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/%s/update: Owner=%r PathOrToken=%r index=%d", ShareType, Owner, PathOrToken, index) + logger.debug("TRACE/sharing/%s/update: OwnerOrUser=%r PathOrToken=%r index=%d", ShareType, OwnerOrUser, PathOrToken, index) logger.debug("TRACE/sharing/%s/update: orig row=%r", ShareType, row) # CSV: remove+adjust+readd @@ -312,6 +327,8 @@ class Sharing(sharing.BaseSharing): row["EnabledByOwner"] = EnabledByOwner if HiddenByOwner is not None: row["HiddenByOwner"] = HiddenByOwner + if Properties is not None: + row["Properties"] = Properties # update timestamp row["TimestampUpdated"] = Timestamp @@ -322,7 +339,7 @@ class Sharing(sharing.BaseSharing): self._sharing_cache.pop(index) self._sharing_cache.append(row) - with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file): + 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) diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index e268099b..f8c72898 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -122,8 +122,9 @@ class Sharing(sharing.BaseSharing): UserShare = row['User'] Permissions = row['Permissions'] Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) + Properties = row['Properties'] if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden) + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s Properties=%r)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden, Properties) return { "mapped": True, "PathOrToken": PathOrToken, @@ -131,7 +132,8 @@ class Sharing(sharing.BaseSharing): "Owner": Owner, "User": UserShare, "Hidden": Hidden, - "Permissions": Permissions} + "Permissions": Permissions, + "Properties": Properties} return None @@ -214,7 +216,8 @@ class Sharing(sharing.BaseSharing): Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, - Timestamp: int = 0) -> dict: + Timestamp: int = 0, + Properties: Union[str, None] = None) -> dict: """ create sharing """ row: dict @@ -258,16 +261,17 @@ class Sharing(sharing.BaseSharing): def update_sharing(self, ShareType: str, PathOrToken: str, - Owner: Union[str, None] = None, + OwnerOrUser: str, User: Union[str, None] = None, PathMapped: Union[str, None] = None, Permissions: Union[str, None] = None, EnabledByOwner: Union[bool, None] = None, HiddenByOwner: Union[bool, None] = None, - Timestamp: int = 0) -> dict: + Timestamp: int = 0, + Properties: Union[str, None] = None) -> dict: """ update sharing """ if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/sharing/%s/update: PathOrToken=%r Owner=%r User=%r", ShareType, PathOrToken, Owner, User) + logger.debug("TRACE/sharing/%s/update: PathOrToken=%r OwnerOrUser=%r User=%r Properties=%r", ShareType, PathOrToken, OwnerOrUser, User, Properties) sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) @@ -275,7 +279,7 @@ class Sharing(sharing.BaseSharing): return {"status": "not-found"} # read content - with self._storage.acquire_lock("w", Owner, path=sharing_config_file): + with self._storage.acquire_lock("w", OwnerOrUser, path=sharing_config_file): # read file with open(sharing_config_file, "rb") as fb: (version, row) = pickle.load(fb) @@ -286,8 +290,17 @@ class Sharing(sharing.BaseSharing): if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: check: %r", ShareType, row) - if Owner is not None and row['Owner'] != Owner: - return {"status": "permission-denied"} + if row['Owner'] != OwnerOrUser: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: OwnerOrUser=%r not matching Owner=%r -> check now for matching User=%r", ShareType, OwnerOrUser, row['Owner'], row['User']) + if row['User'] == OwnerOrUser and PathMapped is None and Permissions is None and EnabledByOwner is None and HiddenByOwner is None and Properties is not None: + # user is only permitted to update Properties + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: OwnerOrUser=%r PathOrToken=%r is permitted to update Properties", ShareType, OwnerOrUser, PathOrToken) + pass + else: + return {"status": "permission-denied"} + if User is not None and row['User'] != User: return {"status": "permission-denied"} @@ -304,6 +317,8 @@ class Sharing(sharing.BaseSharing): row["EnabledByOwner"] = EnabledByOwner if HiddenByOwner is not None: row["HiddenByOwner"] = HiddenByOwner + if Properties is not None: + row["Properties"] = Properties # update timestamp row["TimestampUpdated"] = Timestamp