From 6f3d41c0c04bafedb80b2cf9c1ceaaf93f75af08 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:44:21 +0100 Subject: [PATCH 001/138] sharing: extend config with defaults --- config | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/config b/config index a28cd873a..45972ceb3 100644 --- a/config +++ b/config @@ -296,6 +296,25 @@ #predefined_collections = +[sharing] + +# Sharing database type +# Value: none | csv | sqlite +#type = none + +# Sharing database path for type 'csv' +#database_filename = (filesystem_folder)/collection-db/sharing.csv + +# Sharing database paths for type 'sqlite' +#database_filename = (filesystem_folder)/collection-db/sharing.sqlite + +# Share collection by map +#collection_by_map = false + +# Share collection by token +#collection_by_token = false + + [web] # Web interface backend From f7df65acdd2d7c8d1c4287b9d13eed31e2db8b3d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:44:49 +0100 Subject: [PATCH 002/138] sharing: extend documentation with new options --- DOCUMENTATION.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 55c5d5ad8..d7ddc02cb 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2043,6 +2043,49 @@ is thrown instead of returning the results. Default: 10000 +#### [sharing] + +_(>= 3.7.0)_ + +##### type + +_(>= 3.7.0)_ + +Sharing database type + +One of: + * `none` + * `csv` + * `sqlite` + +Default: `none` + +##### database_filename + +_(>= 3.7.0)_ + +Sharing database path + +Default: + * type `csv`: `(filesystem_folder)/collection-db/sharing.csv` + * type `sqlite`: `(filesystem_folder)/collection-db/sharing.sqlite` + +##### collection_by_token + +_(>= 3.7.0)_ + +Share collection by token + +Default: `false` + +##### collection_by_map + +_(>= 3.7.0)_ + +Share collection by map + +Default: `false` + ## Supported Clients Radicale has been tested with: From 9ec4fc4af95fab39522294af4324f57d4b61b4a0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:45:36 +0100 Subject: [PATCH 003/138] sharing: add support for new config --- radicale/config.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/radicale/config.py b/radicale/config.py index 519269bc8..52a1925c6 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -3,7 +3,7 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2017-2020 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -37,7 +37,7 @@ from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) -from radicale import auth, hook, rights, storage, types, web +from radicale import auth, hook, rights, sharing, storage, types, web from radicale.hook import email from radicale.item import check_and_sanitize_props @@ -454,6 +454,24 @@ def json_str(value: Any) -> dict: "value": "", "help": "predefined user collections", "type": json_str})])), + ("sharing", OrderedDict([ + ("type", { + "value": "none", + "help": "sharing database type", + "type": str_or_callable, + "internal": sharing.INTERNAL_TYPES}), + ("database_filename", { + "value": "", + "help": "database filename", + "type": filepath}), + ("collection_by_map", { + "value": "false", + "help": "enable sharing of collection by map", + "type": bool}), + ("collection_by_token", { + "value": "false", + "help": "enable sharing of collection by token", + "type": bool})])), ("hook", OrderedDict([ ("type", { "value": "none", From c0cef2eaea48c6cb5b3b8c451481610d924155c2 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:51:08 +0100 Subject: [PATCH 004/138] sharing: initial support --- radicale/sharing/__init__.py | 131 ++++++++++++++++++++++++++++++++++ radicale/sharing/csv.py | 132 +++++++++++++++++++++++++++++++++++ radicale/sharing/none.py | 32 +++++++++ 3 files changed, 295 insertions(+) create mode 100644 radicale/sharing/__init__.py create mode 100644 radicale/sharing/csv.py create mode 100644 radicale/sharing/none.py diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py new file mode 100644 index 000000000..472c80ffe --- /dev/null +++ b/radicale/sharing/__init__.py @@ -0,0 +1,131 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2026-2026 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +import re + +from radicale import (utils) +from radicale.log import logger + +INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "mock", "none") + +def load(configuration: "config.Configuration") -> "BaseSharing": + """Load the sharing module chosen in configuration.""" + return utils.load_plugin(INTERNAL_TYPES, "sharing", "Sharing", BaseSharing, configuration) + + + +class BaseSharing: + + configuration: "config.Configuration" + + def __init__(self, configuration: "config.Configuration") -> None: + """Initialize Sharing. + + ``configuration`` see ``radicale.config`` module. + The ``configuration`` must not change during the lifetime of + this object, it is kept as an internal reference. + + """ + self.configuration = configuration + # Sharing + self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map") + self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token") + logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) + logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) + self.sharing_db_type = configuration.get("sharing", "type") + logger.info("sharing.db_type: %s", self.sharing_db_type) + # database tasks + try: + if self.init_database() is False: + exit(1) + except Exception as e: + exit(1) + database_info = self.get_database_info() + if database_info: + logger.info("sharing database info: %r", database_info) + else: + logger.info("sharing database info: (not provided)") + + # overloadable functions + def init_database(self) -> bool: + """ initialize database """ + return None + + def get_database_info(self) -> [ dict | None]: + """ retrieve database information """ + return None + + def get_sharing_collection_by_token(self, token: str) -> [dict | None]: + """ retrieve target and attributes by token """ + return None + + def get_sharing_collection_by_map(self, path: str) -> [dict | None]: + """ retrieve target and attributes by map """ + return None + + # static functions + def sharing_collection_resolver(self, path) -> [dict | None]: + if self.sharing_collection_by_token: + result = self.sharing_collection_by_token_resolver(path) + if result is None: + return result + elif result["mapped"]: + return result + else: + logger.debug("TRACE/sharing_by_token: not active") + + if self.sharing_collection_by_map: + result = self.sharing_collection_by_map_resolver(path) + if result is None: + return result + elif result["mapped"]: + return result + else: + logger.debug("TRACE/sharing_by_map: not active") + + # final + return {"mapped": False} + + def sharing_collection_by_token_resolver(self, path) -> [dict | None]: + """ returning dict with mapped-flag, path, user, rights or None if invalid""" + if self.sharing_collection_by_token: + logger.debug("TRACE/sharing_by_token: check path: %r", path) + if path.startswith("/.token/"): + pattern = re.compile('^/\\.token/v(\\d+)/([a-zA-z0-9]+)') + match = pattern.match(path) + if not match: + logger.debug("TRACE/sharing_by_token: unsupported token: %r", path) + return None + else: + # TODO add token validity checks + logger.debug("TRACE/sharing_by_token: supported token found in path: %r (version=%s token=%r)", path, match[1], match[2]) + return self.get_sharing_collection_by_token(match[1] + "/" + match[2]) + else: + logger.debug("TRACE/sharing_by_token: no supported prefix found in path: %r", path) + return {"mapped": False} + else: + logger.debug("TRACE/sharing_by_token: not active") + return {"mapped": False} + + def sharing_collection_by_map_resolver(self, path) -> [dict | None]: + """ returning dict with mapped-flag, path, user, rights or None if invalid""" + if self.sharing_collection_by_map: + logger.debug("TRACE/sharing_by_map: check path: %r", path) + return self.get_sharing_collection_by_map(path) + else: + logger.debug("TRACE/sharing_by_map: not active") + return {"mapped": False} + diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py new file mode 100644 index 000000000..af88638c8 --- /dev/null +++ b/radicale/sharing/csv.py @@ -0,0 +1,132 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2026-2026 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +import csv +import os + +from radicale import sharing +from radicale.log import logger + +""" CVS based sharing by token or map """ + +class Sharing(sharing.BaseSharing): + _lines: int = 0 + _map_cache = [] + + def init_database(self) -> bool: + logger.debug("sharing database initialization for type 'csv'") + sharing_db_file = self.configuration.get("sharing", "database_filename") + if sharing_db_file == "": + folder = self.configuration.get("storage", "filesystem_folder") + folder_db = os.path.join(folder, "collection-db") + sharing_db_file = os.path.join(folder_db, "sharing.csv") + logger.warning("sharing database filename not provided, use default: %r", sharing_db_file) + else: + logger.info("sharing database filename: %r", sharing_db_file) + + if not os.path.exists(folder_db): + logger.warning("sharing database folder is not existing: %r", folder_db) + try: + os.mkdir(folder_db) + except Exception as e: + logger.error("sharing database folder cannot be created (check permissions): %r (%r)", folder_db, e) + return False + logger.info("sharing database folder successfully created: %r", folder_db) + + if not os.path.exists(sharing_db_file): + logger.warning("sharing database is not existing: %r", sharing_db_file) + try: + if self._create_empty_csv(sharing_db_file) is not True: + raise + except Exception as e: + logger.error("sharing database (empty) cannot be created (check permissions): %r (%r)", sharing_db_file, e) + return False + logger.info("sharing database (empty) successfully created: %r", sharing_db_file) + else: + logger.info("sharing database exists: %r", sharing_db_file) + + # read database + try: + if self._load_csv(sharing_db_file) is not True: + raise + except Exception as e: + logger.error("sharing database load failed: %r (%r)", sharing_db_file, e) + return False + logger.info("sharing database load successful: %r (lines=%d)", sharing_db_file, self._lines) + return True + + def get_database_info(self) -> [ dict | None]: + database_info = { 'type': "csv" } + return database_info + + def get_sharing_collection_by_token(self, token: str) -> [dict | None]: + """ retrieve target and attributes by token """ + for row in self._map_cache: + if row['type'] != "token": + continue + if row['path_token'] != token: + continue + if row['enabled'] != "True": + continue + path_mapped = row['path_mapped'] + user = row['user'] + permissions = row['permissions'] + logger.debug("TRACE/sharing_by_token: map %r to %r (user=%r, permissions=%r)", token, path_mapped, user, permissions) + return {"mapped": True, "path": path_mapped, "user": user, "permissions": permissions} + + # default + logger.debug("TRACE/sharing_by_token: no entry in map found for token: %r", token) + return None + + def get_sharing_collection_by_map(self, path) -> [dict | None]: + """ retrieve target and attributes by map """ + for row in self._map_cache: + if row['type'] != "map": + continue + if row['path_token'] != path: + continue + if row['enabled'] != "True": + continue + # TODO: handle "hidden" + path_mapped = row['path_mapped'] + user = row['owner'] + permissions = row['permissions'] + logger.debug("TRACE/sharing_by_map: map %r to %r (user=%r, permissions=%r)", path, path_mapped, user, permissions) + return {"mapped": True, "path": path_mapped, "user": user, "permissions": permissions} + + # default + logger.debug("TRACE/sharing_by_map: no entry in map found for path: %r", path) + return {"mapped": False} + + ## local functions + def _create_empty_csv(self, file) -> bool: + with open(file, 'w', newline='') as csvfile: + fieldnames = ['type', 'path_token', 'path_mapped', 'owner', 'user', 'permissions', 'enabled', 'hidden', 'created', 'last_updated'] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + return True + + def _load_csv(self, file) -> bool: + logger.debug("sharing database load begin: %r", file) + with open(file, 'r', newline='') as csvfile: + fieldnames = ['type', 'path_token', 'path_mapped', 'owner', 'user', 'permissions', 'enabled', 'hidden', 'created', 'last_updated'] + reader = csv.DictReader(csvfile, fieldnames=fieldnames) + self._lines = 0 + for row in reader: + self._map_cache.append(row) + self._lines += 1 + logger.debug("sharing database load end: %r", file) + return True diff --git a/radicale/sharing/none.py b/radicale/sharing/none.py new file mode 100644 index 000000000..bdbed9fea --- /dev/null +++ b/radicale/sharing/none.py @@ -0,0 +1,32 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2026-2026 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +from radicale import sharing +from radicale.log import logger + + +class Sharing(sharing.BaseSharing): + + def get_sharing_collection_by_token(self, token: str) -> [dict | None]: + """ retrieve target and attributs by token """ + # default + logger.debug("TRACE/sharing_by_token: 'none' cannot provide any map for token: %r", token) + return None + + def get_sharing_collection_by_map(self, path) -> [dict | None]: + """ retrieve target and attributs by map """ + logger.debug("TRACE/sharing_by_map: 'none' cannot provide any map for path: %r", path) + return {"mapped": False} From 6886638906d989e32c538f966899415e7d7cbccd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:52:18 +0100 Subject: [PATCH 005/138] typo --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 472c80ffe..9b1ac5cd3 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -112,7 +112,7 @@ def sharing_collection_by_token_resolver(self, path) -> [dict | None]: else: # TODO add token validity checks logger.debug("TRACE/sharing_by_token: supported token found in path: %r (version=%s token=%r)", path, match[1], match[2]) - return self.get_sharing_collection_by_token(match[1] + "/" + match[2]) + return self.get_sharing_collection_by_token(match[1] + "/" + match[2]) else: logger.debug("TRACE/sharing_by_token: no supported prefix found in path: %r", path) return {"mapped": False} From f0ea99d09df83e8e4f103d8f7ed26170d709c1f0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:54:27 +0100 Subject: [PATCH 006/138] add support for permission filter --- radicale/app/base.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index aa0af7a24..1db493002 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -107,7 +107,7 @@ class Access: _rights: rights.BaseRights _parent_permissions: Optional[str] - def __init__(self, rights: rights.BaseRights, user: str, path: str + def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_filter: str = None ) -> None: self._rights = rights self.user = user @@ -115,6 +115,14 @@ def __init__(self, rights: rights.BaseRights, user: str, path: str self.parent_path = pathutils.unstrip_path( posixpath.dirname(pathutils.strip_path(path)), True) self.permissions = self._rights.authorization(self.user, self.path) + if permissions_filter is not None: + # filter permissions + permissions_filtered = "" + for p in self.permissions: + if p in permissions_filter: + permissions_filtered += p + logger.debug("TRACE/Access: permissions filtered: %r by %r to %r", self.permissions, permissions_filter, permissions_filtered) + self.permissions = permissions_filtered self._parent_permissions = None @property From 2cf0a12ea5350eecbdaf1be0e2cd0496c09fbbfc Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:54:57 +0100 Subject: [PATCH 007/138] sharing: add --- radicale/app/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index 1db493002..0b93fcda7 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -23,7 +23,7 @@ from typing import Optional from radicale import (auth, config, hook, httputils, pathutils, rights, - storage, types, utils, web, xmlutils) + sharing, storage, types, utils, web, xmlutils) from radicale.log import logger # HACK: https://github.com/tiran/defusedxml/issues/54 @@ -38,6 +38,7 @@ class ApplicationBase: _storage: storage.BaseStorage _rights: rights.BaseRights _web: web.BaseWeb + _sharing: sharing.BaseSharing _encoding: str _max_resource_size: int _permit_delete_collection: bool @@ -51,6 +52,7 @@ def __init__(self, configuration: config.Configuration) -> None: self._storage = storage.load(configuration) self._rights = rights.load(configuration) self._web = web.load(configuration) + self._sharing = sharing.load(configuration) self._encoding = configuration.get("encoding", "request") self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content") self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") From 29687fe335ef58520530b914bda6bb580453eaf2 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:55:28 +0100 Subject: [PATCH 008/138] sharing: add support for "GET" --- radicale/app/get.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 2eac58f1e..4830f7f47 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -22,7 +22,7 @@ from http import client from urllib.parse import quote -from radicale import httputils, pathutils, storage, types, xmlutils +from radicale import httputils, pathutils, sharing, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.log import logger @@ -76,7 +76,20 @@ def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, return httputils.redirect(location, client.MOVED_PERMANENTLY) # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) - access = Access(self._rights, user, path) + # Sharing by token or map + result = self._sharing.sharing_collection_resolver(path) + if result is None: + return httputils.NOT_FOUND + else: + if result['mapped']: + # overwrite and run through extended permission check + path = result['path'] + user = result['user'] + permissions_filter = result['permissions'] + access = Access(self._rights, user, path, permissions_filter) + else: + # default permission check + access = Access(self._rights, user, path) if not access.check("r") and "i" not in access.permissions: return httputils.NOT_ALLOWED with self._storage.acquire_lock("r", user): From 3a65b629e5ea0255ecfb97053f9618ef3c1dade2 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 08:56:59 +0100 Subject: [PATCH 009/138] bugfix --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 9b1ac5cd3..50ea43253 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -112,7 +112,7 @@ def sharing_collection_by_token_resolver(self, path) -> [dict | None]: else: # TODO add token validity checks logger.debug("TRACE/sharing_by_token: supported token found in path: %r (version=%s token=%r)", path, match[1], match[2]) - return self.get_sharing_collection_by_token(match[1] + "/" + match[2]) + return self.get_sharing_collection_by_token("v" + match[1] + "/" + match[2]) else: logger.debug("TRACE/sharing_by_token: no supported prefix found in path: %r", path) return {"mapped": False} From 3273ba07c0187a4fe7808e0c2b8359bef751df17 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 13:30:42 +0100 Subject: [PATCH 010/138] sharing: add post hook --- radicale/app/post.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/radicale/app/post.py b/radicale/app/post.py index df944499d..a70fc7366 100644 --- a/radicale/app/post.py +++ b/radicale/app/post.py @@ -4,7 +4,7 @@ # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud # Copyright © 2020-2020 Tom Hacohen -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -30,4 +30,6 @@ def do_POST(self, environ: types.WSGIEnviron, base_prefix: str, """Manage POST request.""" if path == "/.web" or path.startswith("/.web/"): return self._web.post(environ, base_prefix, path, user) + elif path == "/.sharing" or path.startswith("/.sharing/"): + return self._sharing.post(environ, base_prefix, path, user) return httputils.METHOD_NOT_ALLOWED From 22c279ec8d8c6bac906f3381ff857934cb955b02 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 13:31:24 +0100 Subject: [PATCH 011/138] add list function --- radicale/sharing/csv.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index af88638c8..8f39e7a27 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -111,19 +111,28 @@ def get_sharing_collection_by_map(self, path) -> [dict | None]: logger.debug("TRACE/sharing_by_map: no entry in map found for path: %r", path) return {"mapped": False} + def get_sharing_list_by_type_user(self, share_type, user) -> [dict | None]: + """ retrieve sharing list by type and user """ + result = [] + for row in self._map_cache: + if share_type != "*" and row['type'] != share_type: + continue + if row['owner'] != user: + continue + result.append(row) + return result + ## local functions def _create_empty_csv(self, file) -> bool: with open(file, 'w', newline='') as csvfile: - fieldnames = ['type', 'path_token', 'path_mapped', 'owner', 'user', 'permissions', 'enabled', 'hidden', 'created', 'last_updated'] - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS) writer.writeheader() return True def _load_csv(self, file) -> bool: logger.debug("sharing database load begin: %r", file) with open(file, 'r', newline='') as csvfile: - fieldnames = ['type', 'path_token', 'path_mapped', 'owner', 'user', 'permissions', 'enabled', 'hidden', 'created', 'last_updated'] - reader = csv.DictReader(csvfile, fieldnames=fieldnames) + reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS) self._lines = 0 for row in reader: self._map_cache.append(row) From cccb8cdfd1dfc98c9c734763073ccfda7a7da3c7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 7 Feb 2026 13:31:46 +0100 Subject: [PATCH 012/138] sharing: add POST api with initial support for "list" --- radicale/sharing/__init__.py | 103 ++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 50ea43253..d53729b8c 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -14,13 +14,21 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . +import io +import json import re -from radicale import (utils) +from csv import DictWriter +from http import client +from urllib.parse import parse_qs + +from radicale import (httputils, utils) from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "mock", "none") +DB_FIELDS = ['type', 'path_token', 'path_mapped', 'owner', 'user', 'permissions', 'enabled', 'hidden', 'created', 'last_updated'] + def load(configuration: "config.Configuration") -> "BaseSharing": """Load the sharing module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "sharing", "Sharing", BaseSharing, configuration) @@ -59,7 +67,7 @@ def __init__(self, configuration: "config.Configuration") -> None: else: logger.info("sharing database info: (not provided)") - # overloadable functions + ## overloadable functions def init_database(self) -> bool: """ initialize database """ return None @@ -76,7 +84,11 @@ def get_sharing_collection_by_map(self, path: str) -> [dict | None]: """ retrieve target and attributes by map """ return None - # static functions + def get_sharing_list_by_type_user(self, share_type, user) -> [dict | None]: + """ retrieve sharing list by type and user """ + return None + + ## static sharing functions def sharing_collection_resolver(self, path) -> [dict | None]: if self.sharing_collection_by_token: result = self.sharing_collection_by_token_resolver(path) @@ -129,3 +141,88 @@ def sharing_collection_by_map_resolver(self, path) -> [dict | None]: logger.debug("TRACE/sharing_by_map: not active") return {"mapped": False} + ## POST API + def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: + """POST request. + + ``base_prefix`` is sanitized and never ends with "/". + + ``path`` is sanitized and always starts with "/.sharing" + + ``user`` is empty for anonymous users. + + """ + if user == "": + # anonymous users are not allowed + return httputils.NOT_ALLOWED + + logger.debug("TRACE/sharing/API: called by authenticated user: %r", user) + # read POST data + try: + request_body = httputils.read_request_body(self.configuration, environ) + except RuntimeError as e: + logger.warning("Bad POST request on %r (read_request_body): %s", path, e, exc_info=True) + return httputils.BAD_REQUEST + except socket.timeout: + logger.debug("Client timed out", exc_info=True) + return httputils.REQUEST_TIMEOUT + + # parse body according to content-type + content_type = environ.get("CONTENT_TYPE", "") + if 'application/json' in content_type: + try: + request_data = json.loads(request_body) + except json.JSONDecodeError: + return httputils.BAD_REQUEST + logger.debug("TRACE/sharing/API/POST (json): %r", f"{request_data}") + elif 'application/x-www-form-urlencoded' in content_type: + request_data = parse_qs(request_body) + logger.debug("TRACE/sharing/API/POST (form): %r", f"{request_data}") + else: + return httputils.BAD_REQUEST + + ## action dispatcher + if not 'action' in request_data: + # mandatory + return httputils.BAD_REQUEST + + + if request_data['action'][0] == "list": + logger.debug("TRACE/sharing/API/POST/action: list") + if not 'format' in request_data: + output_format = "csv" + else: + output_format = request_data['format'][0] + + if not 'type' in request_data: + share_type = "*" # any + else: + share_type = request_data['type'][0] + + result = self.get_sharing_list_by_type_user(share_type, user) + + logger.debug("TRACE/sharing/API/POST output format: %r", output_format) + if output_format == "csv": + answer = io.StringIO() + writer = DictWriter(answer, fieldnames=DB_FIELDS) + writer.writeheader() + for entry in result: + writer.writerow(entry) + headers = { + "Content-Type": "text/csv" + } + return client.OK, headers, answer.getvalue(), None + elif output_format == "json": + answer = json.dumps(result) + headers = { + "Content-Type": "text/json" + } + return client.OK, headers, answer, None + else: + return httputils.BAD_REQUEST + + else: + # default + return httputils.BAD_REQUEST + + return httputils.METHOD_NOT_ALLOWED From dad57251979517762fc5afbe5c0b0ecb5362dc72 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Feb 2026 13:05:57 +0100 Subject: [PATCH 013/138] initial --- radicale/tests/test_sharing.py | 117 +++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 radicale/tests/test_sharing.py diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py new file mode 100644 index 000000000..439b2e0ac --- /dev/null +++ b/radicale/tests/test_sharing.py @@ -0,0 +1,117 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Radicale tests related to sharing. + +""" + +import json +import logging +import os +import posixpath +import urllib +from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple + +import pytest + +from radicale import sharing, utils +from radicale.tests import RESPONSES, BaseTest +from radicale.tests.helpers import get_file_content + + +class TestSharingApiSanity(BaseTest): + """Tests with sharing.""" + + htpasswd_file_path: str + + def setup_method(self) -> None: + BaseTest.setup_method(self) + self.htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") + encoding: str = self.configuration.get("encoding", "stock") + htpasswd_content = "owner:ownerpw" + with open(self.htpasswd_file_path, "w", encoding=encoding) as f: + f.write(htpasswd_content) + + def test_sharing_api_base_no_auth(self) -> None: + """GET/POST request at '/.sharing' without authentication.""" + for path in ["/.sharing", "/.sharing/"]: + for request in ["GET", "POST"]: + _, headers, _ = self.request(request, path, check=401) + + def test_sharing_api_base_with_auth(self) -> None: + """GET/POST request at '/.sharing' with authentication.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "rights": {"type": "owner_only"}}) + for path in ["/.sharing/", "/.sharing/v9/"]: + _, headers, _ = self.request("GET", path, check=403, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + for path in ["/.sharing/v1/"]: + _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + for action in sharing.API_HOOKS_V1: + path = "/.sharing/v1/" + action + _, headers, _ = self.request("POST", path + "NA", check=404, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) + + def test_sharing_api_list_with_auth(self) -> None: + """POST/list with authentication.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "logging": {"request_header_on_debug": "true"}, + "rights": {"type": "owner_only"}}) + action = "list" + # basic checks + path = "/.sharing/v1/" + action + _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) + # basic checks with sharingtype + for sharingtype in sharing.SHARE_TYPES: + path = "/.sharing/v1/" + action + "/" + sharingtype + _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) + + # check with request FORM response CSV + form_array:str = [] + path = "/.sharing/v1/" + action + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "# status=success" in answer + assert "# lines=0" in answer + + # check with request JSON response CSV + json_dict: dict = {} + path = "/.sharing/v1/" + action + content_type = "application/json" + data = json.dumps(json_dict) + _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "# status=success" in answer + assert "# lines=0" in answer + + # check with request JSON response JSON + json_dict: dict = {} + path = "/.sharing/v1/" + action + content_type = "application/json" + accept = "application/json" + data = json.dumps(json_dict) + _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=accept) + logging.debug("received answer %r", answer) + assert '"status": "success"' in answer + assert '"lines": 0' in answer + assert '"content": null' in answer From 4abe63839204194c1d07991f8f17442321b1cae2 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Feb 2026 13:09:28 +0100 Subject: [PATCH 014/138] add support for content-type and accept header --- radicale/tests/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index 5b637159c..a78db61d5 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -89,6 +89,8 @@ def request(self, method: str, path: str, data: Optional[str] = None, (str, type(http_if_match))) remote_useragent = kwargs.pop("remote_useragent", None) remote_host = kwargs.pop("remote_host", None) + content_type = kwargs.pop("content_type", None) + accept = kwargs.pop("accept", None) environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()} for k, v in environ.items(): if not isinstance(v, str): @@ -104,6 +106,10 @@ def request(self, method: str, path: str, data: Optional[str] = None, environ["HTTP_USER_AGENT"] = remote_useragent if remote_host: environ["REMOTE_ADDR"] = remote_host + if content_type: + environ["CONTENT_TYPE"] = content_type + if accept: + environ["ACCEPT"] = accept environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path if data is not None: From 49e42c1ece03e74f8d0afe254172b29ab4e0bdca Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Feb 2026 13:10:04 +0100 Subject: [PATCH 015/138] add sharing-by-token --- radicale/sharing/csv.py | 80 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 8f39e7a27..abc17c6f4 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -25,7 +25,9 @@ class Sharing(sharing.BaseSharing): _lines: int = 0 _map_cache = [] + _sharing_db_file: str + ## Overloaded functions def init_database(self) -> bool: logger.debug("sharing database initialization for type 'csv'") sharing_db_file = self.configuration.get("sharing", "database_filename") @@ -66,6 +68,7 @@ def init_database(self) -> bool: logger.error("sharing database load failed: %r (%r)", sharing_db_file, e) return False logger.info("sharing database load successful: %r (lines=%d)", sharing_db_file, self._lines) + self._sharing_db_file = sharing_db_file return True def get_database_info(self) -> [ dict | None]: @@ -122,6 +125,67 @@ def get_sharing_list_by_type_user(self, share_type, user) -> [dict | None]: result.append(row) return result + def add_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: + """ add sharing by token """ + logger.debug("TRACE/sharing_by_token/add: user=%r token=%r path_mapped=%r permissions=%r enabled=%s", user, token, path_mapped, permissions, enabled) + # check for duplicate token + for row in self._map_cache: + if row['type'] != "token": + continue + if row['path_token'] == token: + logger.warning("sharing/add_sharing_by_token: token already exists: user=%r token=%r path_mapped=%r", user, token, path_mapped) + return False + row = { "type": "token", + "path_token": token, + "path_mapped": path_mapped, + "owner": user, + "user": user, + "permissions": permissions, + "enabled": str(enabled), + "hidden": "False", + "created": str(timestamp), + "last_updated": str(timestamp) + } + logger.debug("TRACE/sharing_by_token: add row: %r", row) + ## TODO: add locking + self._map_cache.append(row) + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing_by_token: write CSV done") + return True + logger.warning("sharing/add_sharing_by_token: cannot update CSV database") + return False + + def delete_sharing_by_token(self, user: str, token) -> [dict | None]: + """ delete sharing by token """ + logger.debug("TRACE/sharing_by_token/delete: user=%r token=%r", user, token) + # lookup token + token_found = False + index = 0 + for row in self._map_cache: + if row['type'] != "token": + pass + if row['path_token'] != token: + pass + else: + token_found = True + break + index += 1 + + if token_found: + if row['owner'] != user: + return {"status": "permission-denied"} + logger.debug("TRACE/sharing_by_token/delete: user=%r token=%r index=%d", user, token, index) + self._map_cache.pop(index) + + ## TODO: add locking + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing_by_token: write CSV done") + return {"status": "success"} + logger.warning("sharing/add_sharing_by_token: cannot update CSV database") + return {"status": "error"} + + return {"status": "not-found"} + ## local functions def _create_empty_csv(self, file) -> bool: with open(file, 'w', newline='') as csvfile: @@ -135,7 +199,21 @@ def _load_csv(self, file) -> bool: reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS) self._lines = 0 for row in reader: - self._map_cache.append(row) + # check for duplicates + dup = False + for row_cached in self._map_cache: + if row == row_cached: + dup = True + break + if dup: + continue + self._map_cache.append(row) self._lines += 1 logger.debug("sharing database load end: %r", file) return True + + def _write_csv(self, file) -> bool: + with open(file, 'w', newline='') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS) + writer.writerows(self._map_cache) + return True From 4ae38d1922dad522bcad27c96e7f9f22c612a60e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Feb 2026 13:10:23 +0100 Subject: [PATCH 016/138] rework --- radicale/sharing/__init__.py | 259 ++++++++++++++++++++++++++++++++--- 1 file changed, 238 insertions(+), 21 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d53729b8c..de9a88f5e 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -14,20 +14,30 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . +import base64 import io import json import re +import uuid from csv import DictWriter +from datetime import datetime from http import client from urllib.parse import parse_qs -from radicale import (httputils, utils) +from radicale import (config, httputils, rights, utils) +from radicale.app.base import Access from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "mock", "none") -DB_FIELDS = ['type', 'path_token', 'path_mapped', 'owner', 'user', 'permissions', 'enabled', 'hidden', 'created', 'last_updated'] +DB_FIELDS: Sequence[str] = ('type', 'path_token', 'path_mapped', 'owner', 'user', 'permissions', 'enabled', 'hidden', 'created', 'last_updated') + +SHARE_TYPES: Sequence[str] = ('token', 'map') + +OUTPUT_TYPES: Sequence[str] = ('csv', 'json', 'txt') + +API_HOOKS_V1: Sequence[str] = ('list', 'add', 'create', 'delete', 'modify', 'hide', 'unhide', 'enable', 'disable') def load(configuration: "config.Configuration") -> "BaseSharing": """Load the sharing module chosen in configuration.""" @@ -38,6 +48,7 @@ def load(configuration: "config.Configuration") -> "BaseSharing": class BaseSharing: configuration: "config.Configuration" + _rights: rights.BaseRights def __init__(self, configuration: "config.Configuration") -> None: """Initialize Sharing. @@ -48,6 +59,7 @@ def __init__(self, configuration: "config.Configuration") -> None: """ self.configuration = configuration + self._rights = rights.load(configuration) # Sharing self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map") self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token") @@ -88,6 +100,14 @@ def get_sharing_list_by_type_user(self, share_type, user) -> [dict | None]: """ retrieve sharing list by type and user """ return None + def add_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: + """ add sharing by token """ + return None + + def delete_sharing_by_token(self, user: str, token) -> [dict | None]: + """ delete sharing by token """ + return None + ## static sharing functions def sharing_collection_resolver(self, path) -> [dict | None]: if self.sharing_collection_by_token: @@ -151,11 +171,68 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st ``user`` is empty for anonymous users. + Request: + action: list(/token|map)? + type: token|map|* (share type) + + action: add/(token|map) + token + path_mapped: (destination) + permissions: (default: r) + + map + path: + path_mapped: (destination) + user: + permissions: (default: r) + + action: (delete|disable|enable)/(token|map) + token + path_token: + + map + path_token: + user: + + Response: output format depending on ACCEPT header + action: list + by user-owned filtered sharing list in CSV/JSON + + status + """ if user == "": # anonymous users are not allowed return httputils.NOT_ALLOWED + # supported API version check + if not path.startswith("/.sharing/v1/"): + return httputils.NOT_FOUND + + # split into action and map_type + action_sharetype = path.removeprefix("/.sharing/v1/") + match = re.search('([a-z]+)(/[a-z]+)?$', action_sharetype) + if not match: + logger.debug("TRACE/sharing/API: action/sharetype not extractable: %r", action_sharetype) + return httputils.NOT_FOUND + + action = match.group(1) + if match.lastindex == 2: + sharetype = match.group(2).removeprefix("/") + else: + sharetype = None + + # check for valid API hooks + if not action in API_HOOKS_V1: + logger.debug("TRACE/sharing/API: action not whitelisted: %r", action) + return httputils.NOT_FOUND + + # check for valid map types + if sharetype: + if not sharetype in SHARE_TYPES: + logger.debug("TRACE/sharing/API: sharetype not whitelisted: %r", sharetype) + return httputils.NOT_FOUND + logger.debug("TRACE/sharing/API: called by authenticated user: %r", user) # read POST data try: @@ -179,41 +256,124 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st request_data = parse_qs(request_body) logger.debug("TRACE/sharing/API/POST (form): %r", f"{request_data}") else: + logger.debug("TRACE/sharing/API/POST: no supported content data") return httputils.BAD_REQUEST - ## action dispatcher - if not 'action' in request_data: - # mandatory + ## check for requested output type + accept = environ.get("ACCEPT", "") + if 'application/json' in accept: + output_format = "json" + elif 'text/csv' in accept: + output_format = "csv" + else: + output_format = "txt" + + if output_format == "csv": + if not action == "list": + return httputils.BAD_REQUEST + elif output_format == "json": + pass + elif output_format == "txt": + pass + else: return httputils.BAD_REQUEST + answer: dict = {} - if request_data['action'][0] == "list": + ## action: list + if action == "list": logger.debug("TRACE/sharing/API/POST/action: list") - if not 'format' in request_data: - output_format = "csv" + result = self.get_sharing_list_by_type_user(sharetype, user) + if not result: + answer['lines'] = 0 + else: + answer['lines'] = len(result) + answer['status'] = "success" + answer['content'] = result + + logger.debug("TRACE/sharing/API/POST output format: %r", output_format) + if output_format == "csv" or output_format == "txt": + answer_array = [] + if not output_format == "csv": + answer_array.append('# status=' + answer['status']) + answer_array.append('# lines=' + str(answer['lines'])) + if answer['content'] is not None: + csv = io.StringIO() + writer = DictWriter(csv, fieldnames=DB_FIELDS) + writer.writeheader() + for entry in answer['content']: + writer.writerow(entry) + answer_array.append(csv.getvalue()) + headers = { + "Content-Type": "text/csv" + } + return client.OK, headers, "\n".join(answer_array), None + elif output_format == "json": + answer = json.dumps(answer) + headers = { + "Content-Type": "text/json" + } + return client.OK, headers, answer, None else: - output_format = request_data['format'][0] + # should not be reached + return httputils.BAD_REQUEST + + ## action: add + if request_data['action'][0] == "add": + logger.debug("TRACE/sharing/API/POST/action: add") if not 'type' in request_data: - share_type = "*" # any + return httputils.BAD_REQUEST else: share_type = request_data['type'][0] + if share_type not in MAP_TYPES: + return httputils.BAD_REQUEST - result = self.get_sharing_list_by_type_user(share_type, user) + if not 'path_mapped' in request_data: + return httputils.BAD_REQUEST + else: + path_mapped = request_data['path_mapped'][0] + # check access permissions + logger.debug("TRACE/sharing/API/POST/add: %r", path_mapped) + access = Access(self._rights, user, path_mapped) + if not access.check("r") and "i" not in access.permissions: + logger.info("Add sharing-by-token: access to %r not allowed for user %s", path_mapped, user) + return httputils.NOT_ALLOWED + + if not 'permissions' in request_data: + permissions = "r" + else: + permissions = request_data['permissions'][0] - logger.debug("TRACE/sharing/API/POST output format: %r", output_format) - if output_format == "csv": - answer = io.StringIO() - writer = DictWriter(answer, fieldnames=DB_FIELDS) - writer.writeheader() - for entry in result: - writer.writerow(entry) + if not 'enabled' in request_data: + enabled = True + else: + enabled = config._convert_to_bool(request_data['enabled'][0]) + + ## v1: create uuid token with 2x 32 bytes = 256 bit + token = str(base64.urlsafe_b64encode(bytes("v1:", 'utf-8') + uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') + + timestamp = int((datetime.utcnow() - datetime(1970, 1, 1)).total_seconds()) + + if not self.add_sharing_by_token(user, token, path_mapped, timestamp, permissions, enabled): + logger.info("Add sharing-by-token: %r by user %s not successful", path_mapped, user) + return httputils.BAD_REQUEST + + if output_format == "txt": + answer_array = [] + answer_array.append("result=success") + answer_array.append("token=" + token) + answer = "\n".join(answer_array) headers = { - "Content-Type": "text/csv" + "Content-Type": "text/plain" } - return client.OK, headers, answer.getvalue(), None + return client.OK, headers, answer, None elif output_format == "json": - answer = json.dumps(result) + answer_dict = { + "result": "success", + "token": token + } + answer = json.dumps(answer_dict) headers = { "Content-Type": "text/json" } @@ -221,6 +381,63 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st else: return httputils.BAD_REQUEST + ## action: delete + if request_data['action'][0] == "delete": + logger.debug("TRACE/sharing/API/POST/action: delete") + + if not 'type' in request_data: + return httputils.BAD_REQUEST + else: + share_type = request_data['type'][0] + if share_type not in MAP_TYPES: + return httputils.BAD_REQUEST + + if share_type == "token": + if not 'token' in request_data: + return httputils.BAD_REQUEST + + result = self.delete_sharing_by_token(user, token) + if result['status'] == "not-found": + return httputils.NOT_FOUND + if result['status'] == "permission-denied": + return httputils.NOT_ALLOWED + elif result['status'] == "success": + pass + else: + logger.info("Delete sharing-by-token: %r of user %s not successful", token, user) + return httputils.BAD_REQUEST + + elif share_type == "map": + if not 'path' in request_data: + return httputils.BAD_REQUEST + if not 'user' in request_data: + return httputils.BAD_REQUEST + + ## TODO cover map + + if output_format == "txt": + answer_array = [] + answer_array.append("result=success") + answer_array.append("token=" + token) + answer = "\n".join(answer_array) + headers = { + "Content-Type": "text/plain" + } + return client.OK, headers, answer, None + elif output_format == "json": + answer_dict = { + "result": "success", + "token": token + } + answer = json.dumps(answer_dict) + headers = { + "Content-Type": "text/json" + } + return client.OK, headers, answer, None + else: + return httputils.BAD_REQUEST + + else: # default return httputils.BAD_REQUEST From 9cce87e486640e9d591cf12b894f4ee75ef5df32 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 8 Feb 2026 18:33:26 +0100 Subject: [PATCH 017/138] snapshot --- radicale/sharing/__init__.py | 162 +++++++++++++++------- radicale/sharing/csv.py | 76 ++++++++++- radicale/tests/test_sharing.py | 239 ++++++++++++++++++++++++++++----- 3 files changed, 392 insertions(+), 85 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index de9a88f5e..2b7edcce1 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -37,7 +37,7 @@ OUTPUT_TYPES: Sequence[str] = ('csv', 'json', 'txt') -API_HOOKS_V1: Sequence[str] = ('list', 'add', 'create', 'delete', 'modify', 'hide', 'unhide', 'enable', 'disable') +API_HOOKS_V1: Sequence[str] = ('list', 'add', 'delete', 'modify', 'hide', 'unhide', 'enable', 'disable') def load(configuration: "config.Configuration") -> "BaseSharing": """Load the sharing module chosen in configuration.""" @@ -96,8 +96,8 @@ def get_sharing_collection_by_map(self, path: str) -> [dict | None]: """ retrieve target and attributes by map """ return None - def get_sharing_list_by_type_user(self, share_type, user) -> [dict | None]: - """ retrieve sharing list by type and user """ + def get_sharing_list_by_type_user(self, share_type, user, path_token = None) -> [dict | None]: + """ retrieve sharing list by type and user (path_token optional)""" return None def add_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: @@ -108,6 +108,14 @@ def delete_sharing_by_token(self, user: str, token) -> [dict | None]: """ delete sharing by token """ return None + def disable_sharing_by_token(self, user: str, token) -> [dict | None]: + """ disable sharing by token """ + return None + + def enable_sharing_by_token(self, user: str, token) -> [dict | None]: + """ enable sharing by token """ + return None + ## static sharing functions def sharing_collection_resolver(self, path) -> [dict | None]: if self.sharing_collection_by_token: @@ -172,10 +180,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st ``user`` is empty for anonymous users. Request: - action: list(/token|map)? - type: token|map|* (share type) + action: (token|map/list + TODO: match given path_token - action: add/(token|map) + action: (token|map)/add token path_mapped: (destination) permissions: (default: r) @@ -186,7 +194,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st user: permissions: (default: r) - action: (delete|disable|enable)/(token|map) + action: (token|map)/(delete|disable|enable|hide|unhide) token path_token: @@ -201,6 +209,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st status """ + if not self.sharing_collection_by_map and not self.sharing_collection_by_token: + # API is not enabled + return httputils.NOT_FOUND + if user == "": # anonymous users are not allowed return httputils.NOT_ALLOWED @@ -209,30 +221,36 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if not path.startswith("/.sharing/v1/"): return httputils.NOT_FOUND - # split into action and map_type - action_sharetype = path.removeprefix("/.sharing/v1/") - match = re.search('([a-z]+)(/[a-z]+)?$', action_sharetype) + # split into sharetype and action + sharetype_action = path.removeprefix("/.sharing/v1/") + match = re.search('([a-z]+)/([a-z]+)$', sharetype_action) if not match: - logger.debug("TRACE/sharing/API: action/sharetype not extractable: %r", action_sharetype) + logger.debug("TRACE/sharing/API: sharetype/action not extractable: %r", sharetype_action) return httputils.NOT_FOUND - action = match.group(1) - if match.lastindex == 2: - sharetype = match.group(2).removeprefix("/") - else: - sharetype = None + sharetype = match.group(1) + action = match.group(2) - # check for valid API hooks - if not action in API_HOOKS_V1: - logger.debug("TRACE/sharing/API: action not whitelisted: %r", action) - return httputils.NOT_FOUND - - # check for valid map types + # check for valid sharetypes if sharetype: if not sharetype in SHARE_TYPES: logger.debug("TRACE/sharing/API: sharetype not whitelisted: %r", sharetype) return httputils.NOT_FOUND + # check for enabled sharetypes + if not self.sharing_collection_by_map and sharetype == "map": + # API is not enabled + return httputils.NOT_FOUND + + if not self.sharing_collection_by_token and sharetype == "token": + # API is not enabled + return httputils.NOT_FOUND + + # check for valid API hooks + if not action in API_HOOKS_V1: + logger.debug("TRACE/sharing/API: action not whitelisted: %r", action) + return httputils.NOT_FOUND + logger.debug("TRACE/sharing/API: called by authenticated user: %r", user) # read POST data try: @@ -253,12 +271,25 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.BAD_REQUEST logger.debug("TRACE/sharing/API/POST (json): %r", f"{request_data}") elif 'application/x-www-form-urlencoded' in content_type: - request_data = parse_qs(request_body) + request_parsed = parse_qs(request_body) + # convert arrays into single value + request_data = {} + for key in request_parsed: + request_data[key] = request_parsed[key][0] logger.debug("TRACE/sharing/API/POST (form): %r", f"{request_data}") else: logger.debug("TRACE/sharing/API/POST: no supported content data") return httputils.BAD_REQUEST + ## sanity checks + for key in request_data: + if key == "permissions": + if not re.search('^[a-zA-Z]+$', request_data[key]): + return httputils.BAD_REQUEST + if key == "token": + if not re.search('^[a-zA-Z0-9_=\\-]+$', request_data[key]): + return httputils.BAD_REQUEST + ## check for requested output type accept = environ.get("ACCEPT", "") if 'application/json' in accept: @@ -283,7 +314,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st ## action: list if action == "list": logger.debug("TRACE/sharing/API/POST/action: list") - result = self.get_sharing_list_by_type_user(sharetype, user) + path_token_filter = None + if 'token' in request_data: + path_token_filter = request_data['token'] + result = self.get_sharing_list_by_type_user(sharetype, user, path_token_filter) if not result: answer['lines'] = 0 else: @@ -319,22 +353,14 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.BAD_REQUEST ## action: add - if request_data['action'][0] == "add": - logger.debug("TRACE/sharing/API/POST/action: add") - - if not 'type' in request_data: - return httputils.BAD_REQUEST - else: - share_type = request_data['type'][0] - if share_type not in MAP_TYPES: - return httputils.BAD_REQUEST - + elif action == "add": + logger.debug("TRACE/sharing/API/POST/add") if not 'path_mapped' in request_data: + logger.debug("TRACE/sharing/API/POST/add: missing path_mapped") return httputils.BAD_REQUEST else: path_mapped = request_data['path_mapped'][0] # check access permissions - logger.debug("TRACE/sharing/API/POST/add: %r", path_mapped) access = Access(self._rights, user, path_mapped) if not access.check("r") and "i" not in access.permissions: logger.info("Add sharing-by-token: access to %r not allowed for user %s", path_mapped, user) @@ -353,7 +379,9 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st ## v1: create uuid token with 2x 32 bytes = 256 bit token = str(base64.urlsafe_b64encode(bytes("v1:", 'utf-8') + uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') - timestamp = int((datetime.utcnow() - datetime(1970, 1, 1)).total_seconds()) + timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) + + logger.debug("TRACE/sharing/API/POST/add: %r (permissions=%s timestamp=%d token=%s)", path_mapped, permissions, timestamp, token) if not self.add_sharing_by_token(user, token, path_mapped, timestamp, permissions, enabled): logger.info("Add sharing-by-token: %r by user %s not successful", path_mapped, user) @@ -361,7 +389,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if output_format == "txt": answer_array = [] - answer_array.append("result=success") + answer_array.append("status=success") answer_array.append("token=" + token) answer = "\n".join(answer_array) headers = { @@ -382,21 +410,14 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.BAD_REQUEST ## action: delete - if request_data['action'][0] == "delete": - logger.debug("TRACE/sharing/API/POST/action: delete") - - if not 'type' in request_data: - return httputils.BAD_REQUEST - else: - share_type = request_data['type'][0] - if share_type not in MAP_TYPES: - return httputils.BAD_REQUEST + elif action == "delete": + logger.debug("TRACE/sharing/API/POST/delete") - if share_type == "token": + if sharetype == "token": if not 'token' in request_data: return httputils.BAD_REQUEST - result = self.delete_sharing_by_token(user, token) + result = self.delete_sharing_by_token(user, request_data['token']) if result['status'] == "not-found": return httputils.NOT_FOUND if result['status'] == "permission-denied": @@ -417,8 +438,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if output_format == "txt": answer_array = [] - answer_array.append("result=success") - answer_array.append("token=" + token) + answer_array.append("status=success") answer = "\n".join(answer_array) headers = { "Content-Type": "text/plain" @@ -437,6 +457,48 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st else: return httputils.BAD_REQUEST + ## action: disable + elif action == "disable" or action == "enable": + logger.debug("TRACE/sharing/API/POST/" + action) + + if sharetype == "token": + if not 'token' in request_data: + return httputils.BAD_REQUEST + + if action == "disable": + result = self.disable_sharing_by_token(user, request_data['token']) + elif action == "enable": + result = self.enable_sharing_by_token(user, request_data['token']) + if result['status'] == "not-found": + return httputils.NOT_FOUND + if result['status'] == "permission-denied": + return httputils.NOT_ALLOWED + elif result['status'] == "success": + pass + else: + logger.info("Delete sharing-by-token: %r of user %s not successful", token, user) + return httputils.BAD_REQUEST + + if output_format == "txt": + answer_array = [] + answer_array.append("status=success") + answer = "\n".join(answer_array) + headers = { + "Content-Type": "text/plain" + } + return client.OK, headers, answer, None + elif output_format == "json": + answer_dict = { + "result": "success", + "token": token + } + answer = json.dumps(answer_dict) + headers = { + "Content-Type": "text/json" + } + return client.OK, headers, answer, None + else: + return httputils.BAD_REQUEST else: # default diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index abc17c6f4..1dde46528 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -114,7 +114,7 @@ def get_sharing_collection_by_map(self, path) -> [dict | None]: logger.debug("TRACE/sharing_by_map: no entry in map found for path: %r", path) return {"mapped": False} - def get_sharing_list_by_type_user(self, share_type, user) -> [dict | None]: + def get_sharing_list_by_type_user(self, share_type, user, path_token = None) -> [dict | None]: """ retrieve sharing list by type and user """ result = [] for row in self._map_cache: @@ -122,6 +122,8 @@ def get_sharing_list_by_type_user(self, share_type, user) -> [dict | None]: continue if row['owner'] != user: continue + if path_token and row['path_token'] != path_token: + continue result.append(row) return result @@ -186,6 +188,78 @@ def delete_sharing_by_token(self, user: str, token) -> [dict | None]: return {"status": "not-found"} + def disable_sharing_by_token(self, user: str, token) -> [dict | None]: + """ disable sharing by token """ + logger.debug("TRACE/sharing_by_token/disable: user=%r token=%r", user, token) + + # lookup token + token_found = False + index = 0 + for row in self._map_cache: + if row['type'] != "token": + pass + if row['path_token'] != token: + pass + else: + token_found = True + break + index += 1 + + if token_found: + if row['owner'] != user: + return {"status": "permission-denied"} + logger.debug("TRACE/sharing_by_token/disable: user=%r token=%r index=%d", user, token, index) + row['enabled'] = str(False) + # remove + self._map_cache.pop(index) + # readd + self._map_cache.append(row) + + ## TODO: add locking + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing_by_token: write CSV done") + return {"status": "success"} + logger.warning("sharing/add_sharing_by_token: cannot update CSV database") + return {"status": "error"} + + return {"status": "not-found"} + + def enable_sharing_by_token(self, user: str, token) -> [dict | None]: + """ enable sharing by token """ + logger.debug("TRACE/sharing_by_token/enable: user=%r token=%r", user, token) + + # lookup token + token_found = False + index = 0 + for row in self._map_cache: + if row['type'] != "token": + pass + if row['path_token'] != token: + pass + else: + token_found = True + break + index += 1 + + if token_found: + if row['owner'] != user: + return {"status": "permission-denied"} + logger.debug("TRACE/sharing_by_token/enable: user=%r token=%r index=%d", user, token, index) + row['enabled'] = str(True) + # remove + self._map_cache.pop(index) + # readd + self._map_cache.append(row) + + ## TODO: add locking + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing_by_token: write CSV done") + return {"status": "success"} + logger.warning("sharing/add_sharing_by_token: cannot update CSV database") + return {"status": "error"} + + return {"status": "not-found"} + ## local functions def _create_empty_csv(self, file) -> bool: with open(file, 'w', newline='') as csvfile: diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 439b2e0ac..0483c3695 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -23,6 +23,7 @@ import logging import os import posixpath +import re import urllib from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple @@ -47,25 +48,73 @@ def setup_method(self) -> None: f.write(htpasswd_content) def test_sharing_api_base_no_auth(self) -> None: - """GET/POST request at '/.sharing' without authentication.""" + """POST request at '/.sharing' without authentication.""" + # disabled for path in ["/.sharing", "/.sharing/"]: - for request in ["GET", "POST"]: - _, headers, _ = self.request(request, path, check=401) + _, headers, _ = self.request("POST", path, check=404) + # enabled (permutations) + self.configure({"sharing": { + "collection_by_map": "True", + "collection_by_token": "False"} + }) + path = "/.sharing/" + _, headers, _ = self.request("POST", path, check=401) + self.configure({"sharing": { + "collection_by_map": "False", + "collection_by_token": "True"} + }) + path = "/.sharing/" + _, headers, _ = self.request("POST", path, check=401) + self.configure({"sharing": { + "collection_by_map": "True", + "collection_by_token": "True"} + }) + path = "/.sharing/" + _, headers, _ = self.request("POST", path, check=401) def test_sharing_api_base_with_auth(self) -> None: - """GET/POST request at '/.sharing' with authentication.""" + """POST request at '/.sharing' with authentication.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, + "sharing": { + "collection_by_map": "True", + "collection_by_token": "True"}, "rights": {"type": "owner_only"}}) + # path with no valid API hook for path in ["/.sharing/", "/.sharing/v9/"]: - _, headers, _ = self.request("GET", path, check=403, login="%s:%s" % ("owner", "ownerpw")) _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + # path with valid API but no hook for path in ["/.sharing/v1/"]: _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + # path with valid API and hook but not enabled "map" + self.configure({"sharing": { + "collection_by_map": "False", + "collection_by_token": "True"} + }) + sharetype = "map" for action in sharing.API_HOOKS_V1: - path = "/.sharing/v1/" + action + path = "/.sharing/v1/" + sharetype + "/" + action + _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + # path with valid API and hook but not enabled "token" + self.configure({"sharing": { + "collection_by_map": "True", + "collection_by_token": "False"} + }) + sharetype = "token" + for action in sharing.API_HOOKS_V1: + path = "/.sharing/v1/" + sharetype + "/" + action + _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + # path with valid API and hook and all enabled + self.configure({"sharing": { + "collection_by_map": "True", + "collection_by_token": "True"} + }) + for sharetype in sharing.SHARE_TYPES: + path = "/.sharing/v1/" + sharetype + "/" + action + # invalid API _, headers, _ = self.request("POST", path + "NA", check=404, login="%s:%s" % ("owner", "ownerpw")) + # valid API _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) def test_sharing_api_list_with_auth(self) -> None: @@ -73,45 +122,167 @@ def test_sharing_api_list_with_auth(self) -> None: self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, + "sharing": { + "collection_by_map": "True", + "collection_by_token": "True"}, "logging": {"request_header_on_debug": "true"}, "rights": {"type": "owner_only"}}) action = "list" - # basic checks - path = "/.sharing/v1/" + action - _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) - # basic checks with sharingtype for sharingtype in sharing.SHARE_TYPES: - path = "/.sharing/v1/" + action + "/" + sharingtype + # basic checks with sharingtype + path = "/.sharing/v1/" + sharingtype + "/" + action _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) + # check with request FORM response CSV + form_array:str = [] + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "# status=success" in answer + assert "# lines=0" in answer + # check with request JSON response CSV + json_dict: dict = {} + content_type = "application/json" + data = json.dumps(json_dict) + _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "# status=success" in answer + assert "# lines=0" in answer + # check with request JSON response JSON + json_dict: dict = {} + content_type = "application/json" + accept = "application/json" + data = json.dumps(json_dict) + _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=accept) + logging.debug("received answer %r", answer) + assert '"status": "success"' in answer + assert '"lines": 0' in answer + assert '"content": null' in answer - # check with request FORM response CSV + def test_sharing_api_add_token(self) -> None: + """create a token-based share.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "true", + "request_content_on_debug": "true"}, + "rights": {"type": "owner_only"}}) + action = "add" + sharingtype = "token" + path = "/.sharing/v1/" + sharingtype + "/" + action + path_list = "/.sharing/v1/" + sharingtype + "/list" + path_delete = "/.sharing/v1/" + sharingtype + "/delete" + path_disable = "/.sharing/v1/" + sharingtype + "/disable" + path_enable = "/.sharing/v1/" + sharingtype + "/enable" + # without path_mapped + form_array:str = [] + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + _, headers, answer = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + ## 1 + # with path_mapped form_array:str = [] - path = "/.sharing/v1/" + action + form_array.append("path_mapped=/owner/collection1") content_type = "application/x-www-form-urlencoded" data = "\n".join(form_array) _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "# status=success" in answer - assert "# lines=0" in answer - - # check with request JSON response CSV - json_dict: dict = {} - path = "/.sharing/v1/" + action - content_type = "application/json" - data = json.dumps(json_dict) + assert "status=success" in answer + assert "token=" in answer + # extract token + match = re.search('token=(.+)', answer) + token1 = match[1] + logging.debug("received token %r", token1) + ## 2 + # with path_mapped + form_array:str = [] + form_array.append("path_mapped=/owner/collection2") + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "# status=success" in answer - assert "# lines=0" in answer - - # check with request JSON response JSON - json_dict: dict = {} - path = "/.sharing/v1/" + action - content_type = "application/json" - accept = "application/json" - data = json.dumps(json_dict) - _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=accept) + assert "status=success" in answer + assert "token=" in answer + # extract token + match = re.search('token=(.+)', answer) + token2 = match[1] + logging.debug("received token %r", token2) + ## lookup token#1 + form_array:str = [] + form_array.append("token=" + token1) + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + path_list = "/.sharing/v1/" + sharingtype + "/list" + _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "status=success" in answer + assert "lines=1" in answer + assert "/owner/collection2" in answer + ## delete #1 + form_array:str = [] + form_array.append("token=" + token1) + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + _, headers, answer = self.request("POST", path_delete, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "status=success" in answer + ## lookup token#1 + form_array:str = [] + form_array.append("token=" + token1) + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + path_list = "/.sharing/v1/" + sharingtype + "/list" + _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "status=success" in answer + assert "lines=0" in answer + ## lookup tokens + form_array:str = [] + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "status=success" in answer + assert "lines=1" in answer + ## disable token#2 + form_array:str = [] + form_array.append("token=" + token2) + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + _, headers, answer = self.request("POST", path_disable, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "status=success" in answer + ## lookup token#2, check for not enabled + form_array:str = [] + form_array.append("token=" + token2) + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + path_list = "/.sharing/v1/" + sharingtype + "/list" + _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "status=success" in answer + assert "lines=1" in answer + assert "False,False" in answer + ## enable token#2 + form_array:str = [] + form_array.append("token=" + token2) + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + _, headers, answer = self.request("POST", path_enable, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "status=success" in answer + ## lookup token#2, check for enabled + form_array:str = [] + form_array.append("token=" + token2) + content_type = "application/x-www-form-urlencoded" + data = "\n".join(form_array) + path_list = "/.sharing/v1/" + sharingtype + "/list" + _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert '"status": "success"' in answer - assert '"lines": 0' in answer - assert '"content": null' in answer + assert "status=success" in answer + assert "lines=1" in answer + assert "True,False" in answer From f4a53570bf8bf09b0cd5e7162f2241ce24acb9e4 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Feb 2026 22:14:35 +0100 Subject: [PATCH 018/138] replace selfmade filter by already existing one --- radicale/app/base.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index 0b93fcda7..ccf5e69f2 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -25,6 +25,7 @@ from radicale import (auth, config, hook, httputils, pathutils, rights, sharing, storage, types, utils, web, xmlutils) from radicale.log import logger +from radicale.rights import intersect # HACK: https://github.com/tiran/defusedxml/issues/54 import defusedxml.ElementTree as DefusedET # isort:skip @@ -118,11 +119,7 @@ def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_ posixpath.dirname(pathutils.strip_path(path)), True) self.permissions = self._rights.authorization(self.user, self.path) if permissions_filter is not None: - # filter permissions - permissions_filtered = "" - for p in self.permissions: - if p in permissions_filter: - permissions_filtered += p + permissions_filtered = intersect(self.permissions, permissions_filter) logger.debug("TRACE/Access: permissions filtered: %r by %r to %r", self.permissions, permissions_filter, permissions_filtered) self.permissions = permissions_filtered self._parent_permissions = None From fb8ced16e114aff24a91c9ef4ec605823c6588a5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Feb 2026 22:15:00 +0100 Subject: [PATCH 019/138] pass also user to mapping function --- radicale/app/get.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 4830f7f47..43b0484a9 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -77,7 +77,7 @@ def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) # Sharing by token or map - result = self._sharing.sharing_collection_resolver(path) + result = self._sharing.sharing_collection_resolver(path, user) if result is None: return httputils.NOT_FOUND else: From dca17680a17ab9734e0b519dabd42a779b7fe740 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Feb 2026 22:15:26 +0100 Subject: [PATCH 020/138] api rework + function extensions --- radicale/sharing/csv.py | 229 ++++++++++++++++++++++++++++++---------- 1 file changed, 174 insertions(+), 55 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 1dde46528..c1d392f3b 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -78,15 +78,17 @@ def get_database_info(self) -> [ dict | None]: def get_sharing_collection_by_token(self, token: str) -> [dict | None]: """ retrieve target and attributes by token """ for row in self._map_cache: - if row['type'] != "token": + if row['Type'] != "token": continue - if row['path_token'] != token: + if row['PathOrToken'] != token: continue - if row['enabled'] != "True": + if row['EnabledByOwner'] != str(True): continue - path_mapped = row['path_mapped'] - user = row['user'] - permissions = row['permissions'] + if row['EnabledByUser'] != str(True): + continue + path_mapped = row['PathMapped'] + user = row['User'] + permissions = row['Permissions'] logger.debug("TRACE/sharing_by_token: map %r to %r (user=%r, permissions=%r)", token, path_mapped, user, permissions) return {"mapped": True, "path": path_mapped, "user": user, "permissions": permissions} @@ -94,19 +96,23 @@ def get_sharing_collection_by_token(self, token: str) -> [dict | None]: logger.debug("TRACE/sharing_by_token: no entry in map found for token: %r", token) return None - def get_sharing_collection_by_map(self, path) -> [dict | None]: + def get_sharing_collection_by_map(self, path: str, user: str) -> [dict | None]: """ retrieve target and attributes by map """ for row in self._map_cache: - if row['type'] != "map": + if row['Type'] != "map": + continue + if row['PathOrToken'] != path: continue - if row['path_token'] != path: + if row['EnabledByOwner'] != str(True): continue - if row['enabled'] != "True": + if row['EnabledByUser'] != str(True): + continue + if row['User'] != user: continue # TODO: handle "hidden" - path_mapped = row['path_mapped'] - user = row['owner'] - permissions = row['permissions'] + path_mapped = row['PathMapped'] + user = row['Owner'] + permissions = row['Permissions'] logger.debug("TRACE/sharing_by_map: map %r to %r (user=%r, permissions=%r)", path, path_mapped, user, permissions) return {"mapped": True, "path": path_mapped, "user": user, "permissions": permissions} @@ -118,35 +124,37 @@ def get_sharing_list_by_type_user(self, share_type, user, path_token = None) -> """ retrieve sharing list by type and user """ result = [] for row in self._map_cache: - if share_type != "*" and row['type'] != share_type: + if share_type != "*" and row['Type'] != share_type: continue - if row['owner'] != user: + if row['Owner'] != user: continue - if path_token and row['path_token'] != path_token: + if path_token and row['PathOrToken'] != path_token: continue result.append(row) return result - def add_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: - """ add sharing by token """ - logger.debug("TRACE/sharing_by_token/add: user=%r token=%r path_mapped=%r permissions=%r enabled=%s", user, token, path_mapped, permissions, enabled) + def create_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: + """ create sharing by token """ + logger.debug("TRACE/sharing_by_token/create: user=%r token=%r path_mapped=%r permissions=%r enabled=%s", user, token, path_mapped, permissions, enabled) # check for duplicate token for row in self._map_cache: - if row['type'] != "token": + if row['Type'] != "token": continue - if row['path_token'] == token: + if row['PathOrToken'] == token: logger.warning("sharing/add_sharing_by_token: token already exists: user=%r token=%r path_mapped=%r", user, token, path_mapped) return False - row = { "type": "token", - "path_token": token, - "path_mapped": path_mapped, - "owner": user, - "user": user, - "permissions": permissions, - "enabled": str(enabled), - "hidden": "False", - "created": str(timestamp), - "last_updated": str(timestamp) + row = { "Type": "token", + "PathOrToken": token, + "PathMapped": path_mapped, + "Owner": user, + "User": user, + "Permissions": permissions, + "EnabledByOwner": str(enabled), + "EnabledByUser": str(True), + "HiddenByOwner": str(False), + "HiddenByUser": str(False), + "TimestampCreated": str(timestamp), + "TimestampUpdated": str(timestamp) } logger.debug("TRACE/sharing_by_token: add row: %r", row) ## TODO: add locking @@ -157,16 +165,17 @@ def add_sharing_by_token(self, user: str, token: str, path_mapped: str, timestam logger.warning("sharing/add_sharing_by_token: cannot update CSV database") return False - def delete_sharing_by_token(self, user: str, token) -> [dict | None]: + + def delete_sharing_by_token(self, user: str, token: str) -> [dict | None]: """ delete sharing by token """ logger.debug("TRACE/sharing_by_token/delete: user=%r token=%r", user, token) # lookup token token_found = False index = 0 for row in self._map_cache: - if row['type'] != "token": + if row['Type'] != "token": pass - if row['path_token'] != token: + if row['PathOrToken'] != token: pass else: token_found = True @@ -174,7 +183,7 @@ def delete_sharing_by_token(self, user: str, token) -> [dict | None]: index += 1 if token_found: - if row['owner'] != user: + if row['Owner'] != user: return {"status": "permission-denied"} logger.debug("TRACE/sharing_by_token/delete: user=%r token=%r index=%d", user, token, index) self._map_cache.pop(index) @@ -183,22 +192,25 @@ def delete_sharing_by_token(self, user: str, token) -> [dict | None]: if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") return {"status": "success"} - logger.warning("sharing/add_sharing_by_token: cannot update CSV database") + logger.warning("sharing/sharing_by_token: cannot update CSV database") return {"status": "error"} return {"status": "not-found"} - def disable_sharing_by_token(self, user: str, token) -> [dict | None]: - """ disable sharing by token """ - logger.debug("TRACE/sharing_by_token/disable: user=%r token=%r", user, token) + + def toggle_sharing_by_token(self, user: str, token: str, toggle: str, timestamp: int) -> [dict | None]: + """ toggle sharing by token """ + logger.debug("TRACE/sharing_by_token/" + toggle + ": user=%r token=%r", user, token) + if toggle not in sharing.API_SHARE_TOGGLES_V1: + return False # lookup token token_found = False index = 0 for row in self._map_cache: - if row['type'] != "token": + if row['Type'] != "token": pass - if row['path_token'] != token: + if row['PathOrToken'] != token: pass else: token_found = True @@ -206,10 +218,19 @@ def disable_sharing_by_token(self, user: str, token) -> [dict | None]: index += 1 if token_found: - if row['owner'] != user: + if row['Owner'] != user: return {"status": "permission-denied"} - logger.debug("TRACE/sharing_by_token/disable: user=%r token=%r index=%d", user, token, index) - row['enabled'] = str(False) + logger.debug("TRACE/sharing_by_token/" + toggle + ": user=%r token=%r index=%d", user, token, index) + + if toggle == "disable": + row['EnabledByOwner'] = str(False) + elif toggle == "enable": + row['EnabledByOwner'] = str(True) + elif toggle == "hide": + row['HiddenByOwner'] = str(True) + elif toggle == "unhide": + row['HiddenByOwner'] = str(False) + row['TimestampUpdated'] = str(timestamp) # remove self._map_cache.pop(index) # readd @@ -219,33 +240,130 @@ def disable_sharing_by_token(self, user: str, token) -> [dict | None]: if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") return {"status": "success"} - logger.warning("sharing/add_sharing_by_token: cannot update CSV database") + logger.warning("sharing/sharing_by_token: cannot update CSV database") return {"status": "error"} return {"status": "not-found"} - def enable_sharing_by_token(self, user: str, token) -> [dict | None]: - """ enable sharing by token """ - logger.debug("TRACE/sharing_by_token/enable: user=%r token=%r", user, token) + ## sharing by map + def create_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: + """ create sharing by map """ + logger.debug("TRACE/sharing_by_map/create: %r of %r mapped to %r of %r permissions=%r enabled=%s", user_share, path_share, user, path_mapped, permissions, enabled) + # check for duplicate token + for row in self._map_cache: + if row['Type'] != "map": + continue + if row['PathOrToken'] == path_share and row['User'] == user_share and row['PathMapped'] == path_mapped: + logger.warning("sharing/add_sharing_by_map: already exists: %r of %r mapped to %r of %r", user_share, path_share, user, path_mapped) + return False + row = { "Type": "map", + "PathOrToken": path_share, + "PathMapped": path_mapped, + "Owner": user, + "User": user_share, + "Permissions": permissions, + "EnabledByOwner": str(enabled), + "EnabledByUser": str(True), + "HiddenByOwner": str(False), + "HiddenByUser": str(False), + "TimestampCreated": str(timestamp), + "TimestampUpdated": str(timestamp) + } + logger.debug("TRACE/sharing_by_map: add row: %r", row) + ## TODO: add locking + self._map_cache.append(row) + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing_by_token: write CSV done") + return True + logger.warning("sharing/add_sharing_by_token: cannot update CSV database") + return False + + + def delete_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str) -> [dict | None]: + """ delete sharing by map """ + logger.debug("TRACE/sharing_by_map/delete: user=%r path_share=%r", user, path_share) # lookup token token_found = False index = 0 for row in self._map_cache: - if row['type'] != "token": - pass - if row['path_token'] != token: + if row['Type'] != "map": pass - else: + if row['PathOrToken'] == path_share and row['User'] == user_share and row['PathMapped'] == path_mapped: token_found = True break + else: + pass + index += 1 + + if token_found: + if row['Owner'] != user: + return {"status": "permission-denied"} + logger.debug("TRACE/sharing_by_map/delete: user=%r path_share=%r index=%d", user, path_share, index) + self._map_cache.pop(index) + + ## TODO: add locking + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing_by_token: write CSV done") + return {"status": "success"} + logger.warning("sharing/sharing_by_token: cannot update CSV database") + return {"status": "error"} + + return {"status": "not-found"} + + + def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, toggle: str, timestamp: int) -> [dict | None]: + """ toggle sharing by map """ + logger.debug("TRACE/sharing_by_map/" + toggle + ": user=%r path_share=%r path_mapped=%r user_share=%r", user, path_share, path_mapped, user_share) + if toggle not in sharing.API_SHARE_TOGGLES_V1: + return False + + # lookup token + token_found = False + index = 0 + for row in self._map_cache: + if row['Type'] != "map": + pass + elif row['PathOrToken'] == path_share and row['User'] == user_share and row['PathMapped'] == path_mapped: + if row['Owner'] == user or row['User'] == user: + token_found = True + break + else: + pass + else: + pass index += 1 if token_found: - if row['owner'] != user: + if row['Owner'] == user and row['User'] == user_share: + # owner-triggered toggle + pass + elif row['User'] == user: + # user-triggered toggle + pass + else: return {"status": "permission-denied"} - logger.debug("TRACE/sharing_by_token/enable: user=%r token=%r index=%d", user, token, index) - row['enabled'] = str(True) + logger.debug("TRACE/sharing_by_token/" + toggle + ": user=%r path_share=%r index=%d", user, path_share, index) + + if row['Owner'] == user: + if toggle == "disable": + row['EnabledByOwner'] = str(False) + elif toggle == "enable": + row['EnabledByOwner'] = str(True) + elif toggle == "hide": + row['HiddenByOwner'] = str(True) + elif toggle == "unhide": + row['HiddenByOwner'] = str(False) + elif row['User'] == user: + if toggle == "disable": + row['EnabledByUser'] = str(False) + elif toggle == "enable": + row['EnabledByUser'] = str(True) + elif toggle == "hide": + row['HiddenByUser'] = str(True) + elif toggle == "unhide": + row['HiddenByUser'] = str(False) + row['TimestampUpdated'] = str(timestamp) # remove self._map_cache.pop(index) # readd @@ -260,6 +378,7 @@ def enable_sharing_by_token(self, user: str, token) -> [dict | None]: return {"status": "not-found"} + ## local functions def _create_empty_csv(self, file) -> bool: with open(file, 'w', newline='') as csvfile: From 4a98db1937455f522cf3289e3593d75b6c3c7300 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Feb 2026 22:15:50 +0100 Subject: [PATCH 021/138] api rework + extensions --- radicale/sharing/__init__.py | 363 ++++++++++++++++++----------------- 1 file changed, 189 insertions(+), 174 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 2b7edcce1..aa692f54f 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -31,13 +31,18 @@ INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "mock", "none") -DB_FIELDS: Sequence[str] = ('type', 'path_token', 'path_mapped', 'owner', 'user', 'permissions', 'enabled', 'hidden', 'created', 'last_updated') +DB_FIELDS: Sequence[str] = ('Type', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') SHARE_TYPES: Sequence[str] = ('token', 'map') OUTPUT_TYPES: Sequence[str] = ('csv', 'json', 'txt') -API_HOOKS_V1: Sequence[str] = ('list', 'add', 'delete', 'modify', 'hide', 'unhide', 'enable', 'disable') +API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable') + +API_SHARE_TOGGLES_V1: Sequence[str] = ('hide', 'unhide', 'enable', 'disable') + +TOKEN_PATTERN_V1: str = "([a-zA-Z0-9_=\\-]{44})" + def load(configuration: "config.Configuration") -> "BaseSharing": """Load the sharing module chosen in configuration.""" @@ -92,7 +97,7 @@ def get_sharing_collection_by_token(self, token: str) -> [dict | None]: """ retrieve target and attributes by token """ return None - def get_sharing_collection_by_map(self, path: str) -> [dict | None]: + def get_sharing_collection_by_map(self, path: str, user: str) -> [dict | None]: """ retrieve target and attributes by map """ return None @@ -100,24 +105,33 @@ def get_sharing_list_by_type_user(self, share_type, user, path_token = None) -> """ retrieve sharing list by type and user (path_token optional)""" return None - def add_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: - """ add sharing by token """ + def create_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: + """ create sharing by token """ + return None + + def create_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: + """ create sharing by token """ return None - def delete_sharing_by_token(self, user: str, token) -> [dict | None]: + def delete_sharing_by_token(self, user: str, token: str) -> [dict | None]: """ delete sharing by token """ return None - def disable_sharing_by_token(self, user: str, token) -> [dict | None]: - """ disable sharing by token """ + def delete_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str) -> [dict | None]: + """ delete sharing by token """ + return None + + def toggle_sharing_by_token(self, user: str, token: str, toggle: str, timestamp: int) -> [dict | None]: + """ toggle sharing by token """ return None - def enable_sharing_by_token(self, user: str, token) -> [dict | None]: - """ enable sharing by token """ + def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, toggle: str, timestamp: int) -> [dict | None]: + """ toggle sharing by map """ return None + ## static sharing functions - def sharing_collection_resolver(self, path) -> [dict | None]: + def sharing_collection_resolver(self, path:str, user: str) -> [dict | None]: if self.sharing_collection_by_token: result = self.sharing_collection_by_token_resolver(path) if result is None: @@ -128,7 +142,7 @@ def sharing_collection_resolver(self, path) -> [dict | None]: logger.debug("TRACE/sharing_by_token: not active") if self.sharing_collection_by_map: - result = self.sharing_collection_by_map_resolver(path) + result = self.sharing_collection_by_map_resolver(path, user) if result is None: return result elif result["mapped"]: @@ -144,7 +158,7 @@ def sharing_collection_by_token_resolver(self, path) -> [dict | None]: if self.sharing_collection_by_token: logger.debug("TRACE/sharing_by_token: check path: %r", path) if path.startswith("/.token/"): - pattern = re.compile('^/\\.token/v(\\d+)/([a-zA-z0-9]+)') + pattern = re.compile('^/\\.token/v(\\d+)/' + TOKEN_PATTERN_V1 + '$') match = pattern.match(path) if not match: logger.debug("TRACE/sharing_by_token: unsupported token: %r", path) @@ -160,11 +174,11 @@ def sharing_collection_by_token_resolver(self, path) -> [dict | None]: logger.debug("TRACE/sharing_by_token: not active") return {"mapped": False} - def sharing_collection_by_map_resolver(self, path) -> [dict | None]: + def sharing_collection_by_map_resolver(self, path: str, user: str) -> [dict | None]: """ returning dict with mapped-flag, path, user, rights or None if invalid""" if self.sharing_collection_by_map: logger.debug("TRACE/sharing_by_map: check path: %r", path) - return self.get_sharing_collection_by_map(path) + return self.get_sharing_collection_by_map(path, user) else: logger.debug("TRACE/sharing_by_map: not active") return {"mapped": False} @@ -181,32 +195,33 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st Request: action: (token|map/list - TODO: match given path_token + PathOrToken: (optional for filter) - action: (token|map)/add - token - path_mapped: (destination) - permissions: (default: r) + action: (token|map)/create + PathMapped: (mandatory) + Permissions: (default: r) + + token -> returns map - path: - path_mapped: (destination) - user: - permissions: (default: r) + PathOrToken: (mandatory) + User: (mandatory) action: (token|map)/(delete|disable|enable|hide|unhide) + PathOrToken: (mandatory) + token - path_token: map - path_token: - user: + PathMapped: (mandator) + User: Response: output format depending on ACCEPT header action: list by user-owned filtered sharing list in CSV/JSON - status + actions: (other) + Status """ if not self.sharing_collection_by_map and not self.sharing_collection_by_token: @@ -262,6 +277,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT + api_info = "sharing/API/POST/" + sharetype + "/" + action + # parse body according to content-type content_type = environ.get("CONTENT_TYPE", "") if 'application/json' in content_type: @@ -269,16 +286,16 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st request_data = json.loads(request_body) except json.JSONDecodeError: return httputils.BAD_REQUEST - logger.debug("TRACE/sharing/API/POST (json): %r", f"{request_data}") + logger.debug("TRACE/" + api_info + " (json): %r", f"{request_data}") elif 'application/x-www-form-urlencoded' in content_type: request_parsed = parse_qs(request_body) # convert arrays into single value request_data = {} for key in request_parsed: request_data[key] = request_parsed[key][0] - logger.debug("TRACE/sharing/API/POST (form): %r", f"{request_data}") + logger.debug("TRACE/" + api_info + " (form): %r", f"{request_data}") else: - logger.debug("TRACE/sharing/API/POST: no supported content data") + logger.debug("TRACE/" + api_info + ": no supported content data") return httputils.BAD_REQUEST ## sanity checks @@ -287,7 +304,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if not re.search('^[a-zA-Z]+$', request_data[key]): return httputils.BAD_REQUEST if key == "token": - if not re.search('^[a-zA-Z0-9_=\\-]+$', request_data[key]): + if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]): return httputils.BAD_REQUEST ## check for requested output type @@ -310,198 +327,196 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.BAD_REQUEST answer: dict = {} + timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) ## action: list if action == "list": - logger.debug("TRACE/sharing/API/POST/action: list") + logger.debug("TRACE/" + api_info + ": start") path_token_filter = None - if 'token' in request_data: - path_token_filter = request_data['token'] + if 'PathOrToken' in request_data: + path_token_filter = request_data['PathOrToken'] + logger.debug("TRACE/" + api_info + ": filter: %r", path_token_filter) result = self.get_sharing_list_by_type_user(sharetype, user, path_token_filter) if not result: - answer['lines'] = 0 + answer['Lines'] = 0 + answer['Status'] = "not-found" else: - answer['lines'] = len(result) - answer['status'] = "success" - answer['content'] = result - - logger.debug("TRACE/sharing/API/POST output format: %r", output_format) - if output_format == "csv" or output_format == "txt": - answer_array = [] - if not output_format == "csv": - answer_array.append('# status=' + answer['status']) - answer_array.append('# lines=' + str(answer['lines'])) - if answer['content'] is not None: - csv = io.StringIO() - writer = DictWriter(csv, fieldnames=DB_FIELDS) - writer.writeheader() - for entry in answer['content']: - writer.writerow(entry) - answer_array.append(csv.getvalue()) - headers = { - "Content-Type": "text/csv" - } - return client.OK, headers, "\n".join(answer_array), None - elif output_format == "json": - answer = json.dumps(answer) - headers = { - "Content-Type": "text/json" - } - return client.OK, headers, answer, None - else: - # should not be reached + answer['Lines'] = len(result) + answer['Status'] = "success" + answer['Content'] = result + + ## action: create + elif action == "create": + logger.debug("TRACE/" + api_info + ": start") + if not 'PathMapped' in request_data: + logger.warning(api_info + ": missing PathMapped") return httputils.BAD_REQUEST + else: + path_mapped = request_data['PathMapped'] - ## action: add - elif action == "add": - logger.debug("TRACE/sharing/API/POST/add") - if not 'path_mapped' in request_data: - logger.debug("TRACE/sharing/API/POST/add: missing path_mapped") - return httputils.BAD_REQUEST + if not 'Permissions' in request_data: + permissions = "r" else: - path_mapped = request_data['path_mapped'][0] + permissions = request_data['Permissions'] + + if not 'EnabledByOwner' in request_data: + enabled = True + else: + enabled = config._convert_to_bool(request_data['EnabledByOwner']) + + if sharetype == "token": # check access permissions access = Access(self._rights, user, path_mapped) if not access.check("r") and "i" not in access.permissions: - logger.info("Add sharing-by-token: access to %r not allowed for user %s", path_mapped, user) + logger.info("Add sharing-by-token: access to %r not allowed for user %r", path_mapped, user) return httputils.NOT_ALLOWED - if not 'permissions' in request_data: - permissions = "r" - else: - permissions = request_data['permissions'][0] + ## v1: create uuid token with 2x 32 bytes = 256 bit + token = "v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') - if not 'enabled' in request_data: - enabled = True - else: - enabled = config._convert_to_bool(request_data['enabled'][0]) + logger.debug("TRACE/" + api_info + ": %r (permissions=%r token=%r)", path_mapped, permissions, token) - ## v1: create uuid token with 2x 32 bytes = 256 bit - token = str(base64.urlsafe_b64encode(bytes("v1:", 'utf-8') + uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') + if not self.create_sharing_by_token(user, token, path_mapped, timestamp, permissions, enabled): + logger.info("Add sharing-by-token: %r by user %s not successful", path_mapped, user) + return httputils.BAD_REQUEST - timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) + logger.info(api_info + "(success): %r (permissions=%r token=%r)", path_mapped, permissions, token) - logger.debug("TRACE/sharing/API/POST/add: %r (permissions=%s timestamp=%d token=%s)", path_mapped, permissions, timestamp, token) + answer['Status'] = "success" + answer['PathOrToken'] = token - if not self.add_sharing_by_token(user, token, path_mapped, timestamp, permissions, enabled): - logger.info("Add sharing-by-token: %r by user %s not successful", path_mapped, user) - return httputils.BAD_REQUEST + elif sharetype == "map": + if not 'User' in request_data: + logger.warning(api_info + ": missing User") + return httputils.BAD_REQUEST + else: + user_share = request_data['User'] - if output_format == "txt": - answer_array = [] - answer_array.append("status=success") - answer_array.append("token=" + token) - answer = "\n".join(answer_array) - headers = { - "Content-Type": "text/plain" - } - return client.OK, headers, answer, None - elif output_format == "json": - answer_dict = { - "result": "success", - "token": token - } - answer = json.dumps(answer_dict) - headers = { - "Content-Type": "text/json" - } - return client.OK, headers, answer, None - else: - return httputils.BAD_REQUEST + if not 'PathOrToken' in request_data: + logger.warning(api_info + ": missing PathOrToken") + return httputils.BAD_REQUEST + else: + path_share = request_data['PathOrToken'] + + # check access permissions + access = Access(self._rights, user, path_mapped) + if not access.check("r") and "i" not in access.permissions: + logger.info("Add sharing-by-map: access to %r not allowed for user %r", path_mapped, user) + return httputils.NOT_ALLOWED + + logger.debug("TRACE/" + api_info + ": %r (permissions=%r path_share=%r user=%r)", path_mapped, permissions, path_share, user_share) + if not self.create_sharing_by_map(user, path_share, path_mapped, user_share, timestamp, permissions, enabled): + logger.info("Add sharing-by-token: %r by user %s not successful", path_mapped, user) + return httputils.BAD_REQUEST + + answer['Status'] = "success" ## action: delete elif action == "delete": - logger.debug("TRACE/sharing/API/POST/delete") + logger.debug("TRACE/" + api_info + ": start") + + if not 'PathOrToken' in request_data: + logger.warning(api_info + ": missing PathOrToken") + return httputils.BAD_REQUEST if sharetype == "token": - if not 'token' in request_data: - return httputils.BAD_REQUEST + result = self.delete_sharing_by_token(user, request_data['PathOrToken']) - result = self.delete_sharing_by_token(user, request_data['token']) - if result['status'] == "not-found": - return httputils.NOT_FOUND - if result['status'] == "permission-denied": - return httputils.NOT_ALLOWED - elif result['status'] == "success": - pass - else: - logger.info("Delete sharing-by-token: %r of user %s not successful", token, user) + elif sharetype == "map": + if not 'User' in request_data: + logger.warning(api_info + ": missing User") return httputils.BAD_REQUEST - elif share_type == "map": - if not 'path' in request_data: - return httputils.BAD_REQUEST - if not 'user' in request_data: + if not 'PathOrToken' in request_data: + logger.warning(api_info + ": missing PathOrToken") return httputils.BAD_REQUEST - ## TODO cover map - - if output_format == "txt": - answer_array = [] - answer_array.append("status=success") - answer = "\n".join(answer_array) - headers = { - "Content-Type": "text/plain" - } - return client.OK, headers, answer, None - elif output_format == "json": - answer_dict = { - "result": "success", - "token": token - } - answer = json.dumps(answer_dict) - headers = { - "Content-Type": "text/json" - } - return client.OK, headers, answer, None + result = self.delete_sharing_by_map(user, request_data['PathOrToken'], request_data['PathMapped'], request_data['User']) + + ## result handling + if result['status'] == "not-found": + return httputils.NOT_FOUND + elif result['status'] == "permission-denied": + return httputils.NOT_ALLOWED + elif result['status'] == "success": + answer['Status'] = "success" + pass else: + if sharetype == "token": + logger.info("Delete sharing-by-token: %r of user %r not successful", token, user) + elif sharetype == "map": + logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) return httputils.BAD_REQUEST - ## action: disable - elif action == "disable" or action == "enable": + ## action: TOGGLE + elif action in API_SHARE_TOGGLES_V1: logger.debug("TRACE/sharing/API/POST/" + action) if sharetype == "token": - if not 'token' in request_data: + if not 'PathOrToken' in request_data: + logger.warning(api_info + ": missing PathOrToken") + return httputils.BAD_REQUEST + + result = self.toggle_sharing_by_token(user, request_data['PathOrToken'], action, timestamp) + + elif sharetype == "map": + if not 'User' in request_data: + logger.warning(api_info + ": missing User") return httputils.BAD_REQUEST - if action == "disable": - result = self.disable_sharing_by_token(user, request_data['token']) - elif action == "enable": - result = self.enable_sharing_by_token(user, request_data['token']) + if not 'PathOrToken' in request_data: + logger.warning(api_info + ": missing PathOrToken") + return httputils.BAD_REQUEST + + result = self.toggle_sharing_by_map(user, request_data['PathOrToken'], request_data['PathMapped'], request_data['User'], action, timestamp) + + if result: if result['status'] == "not-found": return httputils.NOT_FOUND if result['status'] == "permission-denied": return httputils.NOT_ALLOWED elif result['status'] == "success": + answer['Status'] = "success" pass - else: - logger.info("Delete sharing-by-token: %r of user %s not successful", token, user) - return httputils.BAD_REQUEST - - if output_format == "txt": - answer_array = [] - answer_array.append("status=success") - answer = "\n".join(answer_array) - headers = { - "Content-Type": "text/plain" - } - return client.OK, headers, answer, None - elif output_format == "json": - answer_dict = { - "result": "success", - "token": token - } - answer = json.dumps(answer_dict) - headers = { - "Content-Type": "text/json" - } - return client.OK, headers, answer, None else: + if sharetype == "token": + logger.info("Delete sharing-by-token: %r of user %s not successful", request_data['PathOrToken'], user) + elif sharetype == "map": + logger.info("Delete sharing-by-map: %r of user %s not successful", request_data['PathOrToken'], user) return httputils.BAD_REQUEST else: # default return httputils.BAD_REQUEST + ## output handler + logger.debug("TRACE/sharing/API/POST output format: %r", output_format) + if output_format == "csv" or output_format == "txt": + answer_array = [] + for key in answer: + if key != 'Content': + answer_array.append('# ' + key + '=' + str(answer[key])) + if 'Content' in answer and answer['Content'] is not None: + csv = io.StringIO() + writer = DictWriter(csv, fieldnames=DB_FIELDS) + if output_format == "csv": + writer.writeheader() + for entry in answer['Content']: + writer.writerow(entry) + answer_array.append(csv.getvalue()) + headers = { + "Content-Type": "text/csv" + } + return client.OK, headers, "\n".join(answer_array), None + elif output_format == "json": + answer = json.dumps(answer) + headers = { + "Content-Type": "text/json" + } + return client.OK, headers, answer, None + else: + # should not be reached + return httputils.BAD_REQUEST + + return httputils.METHOD_NOT_ALLOWED From eadfc4ec2a77412c675e2e2c9397180aa4e48db4 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Feb 2026 22:16:07 +0100 Subject: [PATCH 022/138] add token+map testcases --- radicale/tests/test_sharing.py | 510 ++++++++++++++++++++++++++++----- 1 file changed, 431 insertions(+), 79 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 0483c3695..11d620782 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -43,7 +43,7 @@ def setup_method(self) -> None: BaseTest.setup_method(self) self.htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") encoding: str = self.configuration.get("encoding", "stock") - htpasswd_content = "owner:ownerpw" + htpasswd_content = "owner:ownerpw\nuser:userpw" with open(self.htpasswd_file_path, "w", encoding=encoding) as f: f.write(htpasswd_content) @@ -117,6 +117,7 @@ def test_sharing_api_base_with_auth(self) -> None: # valid API _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) + def test_sharing_api_list_with_auth(self) -> None: """POST/list with authentication.""" self.configure({"auth": {"type": "htpasswd", @@ -159,8 +160,9 @@ def test_sharing_api_list_with_auth(self) -> None: assert '"lines": 0' in answer assert '"content": null' in answer - def test_sharing_api_add_token(self) -> None: - """create a token-based share.""" + + def test_sharing_api_token_basic(self) -> None: + """share-by-token API tests.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, @@ -168,121 +170,471 @@ def test_sharing_api_add_token(self) -> None: "type": "csv", "collection_by_map": "True", "collection_by_token": "True"}, - "logging": {"request_header_on_debug": "true", - "request_content_on_debug": "true"}, + "logging": {"request_header_on_debug": "False", + "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - action = "add" + sharingtype = "token" - path = "/.sharing/v1/" + sharingtype + "/" + action - path_list = "/.sharing/v1/" + sharingtype + "/list" - path_delete = "/.sharing/v1/" + sharingtype + "/delete" - path_disable = "/.sharing/v1/" + sharingtype + "/disable" - path_enable = "/.sharing/v1/" + sharingtype + "/enable" - # without path_mapped + path_base = "/.sharing/v1/" + sharingtype + "/" + + logging.debug("*** create token without PathMapped (form) -> should fail") form_array:str = [] - content_type = "application/x-www-form-urlencoded" data = "\n".join(form_array) - _, headers, answer = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - ## 1 - # with path_mapped - form_array:str = [] - form_array.append("path_mapped=/owner/collection1") content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + + logging.debug("*** create token without PathMapped (json) -> should fail") + form_dict = {} + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + + logging.debug("*** create token#1") + form_array:str = [] + form_array.append("PathMapped=/owner/collection1") data = "\n".join(form_array) - _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "create", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - assert "token=" in answer + assert "Status=success" in answer + assert "PathOrToken=" in answer # extract token - match = re.search('token=(.+)', answer) + match = re.search('PathOrToken=(.+)', answer) token1 = match[1] logging.debug("received token %r", token1) - ## 2 - # with path_mapped - form_array:str = [] - form_array.append("path_mapped=/owner/collection2") - content_type = "application/x-www-form-urlencoded" - data = "\n".join(form_array) - _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + + logging.debug("*** create token#2") + form_dict = {} + form_dict['PathMapped'] = "/owner/collection2" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "create", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - assert "token=" in answer + assert "Status=success" in answer + assert "Token=" in answer # extract token - match = re.search('token=(.+)', answer) + match = re.search('Token=(.+)', answer) token2 = match[1] logging.debug("received token %r", token2) - ## lookup token#1 + + logging.debug("*** lookup token#1 (form->text)") form_array:str = [] - form_array.append("token=" + token1) - content_type = "application/x-www-form-urlencoded" + form_array.append("PathOrToken=" + token1) data = "\n".join(form_array) - path_list = "/.sharing/v1/" + sharingtype + "/list" - _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - assert "lines=1" in answer + assert "Status=success" in answer + assert "Lines=1" in answer + assert "/owner/collection1" in answer + + logging.debug("*** lookup token#2 (json->text") + form_dict = {} + content_type = "application/json" + form_dict['PathOrToken'] = token2 + content_type = "application/json" + data = json.dumps(form_dict) + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "Status=success" in answer + assert "Lines=1" in answer assert "/owner/collection2" in answer - ## delete #1 + + logging.debug("*** lookup token#2 (json->json)") + form_dict = {} + content_type = "application/json" + form_dict['PathOrToken'] = token2 + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + assert result['Lines'] == 1 + assert "/owner/collection2" in result['Content'][0]['PathMapped'] + + logging.debug("*** delete token#1 (form->text)") form_array:str = [] - form_array.append("token=" + token1) - content_type = "application/x-www-form-urlencoded" + form_array.append("PathOrToken=" + token1) data = "\n".join(form_array) - _, headers, answer = self.request("POST", path_delete, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "delete", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - ## lookup token#1 + assert "Status=success" in answer + + logging.debug("*** lookup token#1 (form->text) -> should not be there anymore") form_array:str = [] - form_array.append("token=" + token1) - content_type = "application/x-www-form-urlencoded" + form_array.append("PathOrToken=" + token1) data = "\n".join(form_array) - path_list = "/.sharing/v1/" + sharingtype + "/list" - _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - assert "lines=0" in answer - ## lookup tokens + assert "Status=not-found" in answer + assert "Lines=0" in answer + + logging.debug("*** lookup tokens (form->text) -> still one should be there") form_array:str = [] - content_type = "application/x-www-form-urlencoded" data = "\n".join(form_array) - _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - assert "lines=1" in answer - ## disable token#2 + assert "Status=success" in answer + assert "Lines=1" in answer + + logging.debug("*** disable token#2 (form->text)") form_array:str = [] - form_array.append("token=" + token2) - content_type = "application/x-www-form-urlencoded" + form_array.append("PathOrToken=" + token2) data = "\n".join(form_array) - _, headers, answer = self.request("POST", path_disable, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "disable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - ## lookup token#2, check for not enabled + assert "Status=success" in answer + + logging.debug("*** lookup token#2 (json->json) -> check for not enabled") + form_dict = {} + form_dict['PathOrToken'] = token2 + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + assert result['Lines'] == 1 + assert "False" in result['Content'][0]['EnabledByOwner'] + + logging.debug("*** enable token#2 (json->json)") + form_dict = {} + form_dict['PathOrToken'] = token2 + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** lookup token#2 (form->text) -> check for enabled") form_array:str = [] - form_array.append("token=" + token2) + form_array.append("PathOrToken=" + token2) + data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "Status=success" in answer + assert "Lines=1" in answer + assert "True,True,False,False" in answer + + logging.debug("*** hide token#2 (form->text)") + form_array:str = [] + form_array.append("PathOrToken=" + token2) data = "\n".join(form_array) - path_list = "/.sharing/v1/" + sharingtype + "/list" - _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "hide", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - assert "lines=1" in answer - assert "False,False" in answer - ## enable token#2 + assert "Status=success" in answer + + logging.debug("*** lookup token#2 (form->text) -> check for hidden") form_array:str = [] - form_array.append("token=" + token2) + form_array.append("PathOrToken=" + token2) + data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "Status=success" in answer + assert "Lines=1" in answer + assert "True,True,True,False" in answer + + logging.debug("*** unhide token#2 (json->json)") + form_dict = {} + form_dict['PathOrToken'] = token2 + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "unhide", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** lookup token#2 (json->json) -> check for not hidden") + form_dict = {} + form_dict['PathOrToken'] = token2 + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + assert result['Lines'] == 1 + assert "False" in result['Content'][0]['HiddenByOwner'] + + logging.debug("*** delete token#2 (json->json)") + form_dict = {} + form_dict['PathOrToken'] = token2 + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "delete", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** lookup token#2 (json->json) -> should not be there anymore") + form_dict = {} + form_dict['PathOrToken'] = token2 + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "not-found" in result['Status'] + assert result['Lines'] == 0 + + + def test_sharing_api_token_usage(self) -> None: + """share-by-token API tests - real usage.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + sharingtype = "token" + path_base = "/.sharing/v1/" + sharingtype + "/" + path_token = "/.token/" + + logging.debug("*** prepare and test access") + self.mkcalendar("/owner/calendar.ics/", login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event1.ics") + path = "/owner/calendar.ics/event1.ics" + self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path, check=200, login="%s:%s" % ("owner", "ownerpw")) + + logging.debug("*** create token") + form_array:str = [] + form_array.append("PathMapped=/owner/calendar.ics") data = "\n".join(form_array) - _, headers, answer = self.request("POST", path_enable, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "create", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - ## lookup token#2, check for enabled + assert "Status=success" in answer + assert "PathOrToken=" in answer + # extract token + match = re.search('PathOrToken=(.+)', answer) + token = match[1] + logging.debug("received token %r", token) + + logging.debug("*** fetch collection using invalid token (without credentials)") + _, headers, answer = self.request("GET", path_token + "v1/invalidtoken", check=404) + + logging.debug("*** fetch collection using token (without credentials)") + _, headers, answer = self.request("GET", path_token + token, check=200) + assert "UID:event" in answer + + logging.debug("*** disable token (form->text)") form_array:str = [] - form_array.append("token=" + token2) + form_array.append("PathOrToken=" + token) + data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "disable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "Status=success" in answer + + logging.debug("*** fetch collection using disabled token (without credentials)") + _, headers, answer = self.request("GET", path_token + token, check=404) + + logging.debug("*** enable token (form->text)") + form_array:str = [] + form_array.append("PathOrToken=" + token) data = "\n".join(form_array) - path_list = "/.sharing/v1/" + sharingtype + "/list" - _, headers, answer = self.request("POST", path_list, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "status=success" in answer - assert "lines=1" in answer - assert "True,False" in answer + assert "Status=success" in answer + + logging.debug("*** fetch collection using token (without credentials)") + _, headers, answer = self.request("GET", path_token + token, check=200) + assert "UID:event" in answer + + logging.debug("*** delete token (json->json)") + form_dict = {} + form_dict['PathOrToken'] = token + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "delete", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** fetch collection using deleted token (without credentials)") + _, headers, answer = self.request("GET", path_token + token, check=404) + + + def test_sharing_api_map_basic(self) -> None: + """share-by-map API basic tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + sharingtype = "map" + path_base = "/.sharing/v1/" + sharingtype + "/" + + logging.debug("*** create map without PathMapped (json) -> should fail") + form_dict = {} + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + + logging.debug("*** create map without PathMapped but User (json) -> should fail") + form_dict = {} + form_dict['User'] = "user" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + + logging.debug("*** create map without PathMapped but User and PathOrToken (json) -> should fail") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathOrToken'] = "/owner/calendar.ics" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + + def test_sharing_api_map_usage(self) -> None: + """share-by-map API usage tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + sharingtype = "map" + path_base = "/.sharing/v1/" + sharingtype + "/" + path_share = "/user/calendar-shared-by-owner.ics" + path_mapped = "/owner/calendar.ics" + + logging.debug("*** prepare and test access") + self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event1.ics") + path = path_mapped + "/event1.ics" + self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + + logging.debug("*** create map with PathMapped and User and PathOrToken (json)") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathMapped'] = "/owner/calendar.ics" + form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "create", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** lookup map without filter (json->json)") + form_dict = {} + content_type = "application/json" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + assert result['Lines'] == 1 + assert path_share in result['Content'][0]['PathOrToken'] + assert path_mapped in result['Content'][0]['PathMapped'] + assert "owner" in result['Content'][0]['Owner'] + assert "user" in result['Content'][0]['User'] + + logging.debug("*** fetch collection (without credentials)") + _, headers, answer = self.request("GET", path_mapped, check=401) + + logging.debug("*** fetch collection (with credentials) as owner") + _, headers, answer = self.request("GET", path_mapped, check=200, login="%s:%s" % ("owner", "ownerpw")) + + logging.debug("*** fetch collection (with credentials) as user") + _, headers, answer = self.request("GET", path_mapped, check=403, login="%s:%s" % ("user", "userpw")) + + logging.debug("*** fetch collection via map (with credentials) as user") + _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) + + logging.debug("*** disable map by owner (json->json)") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathMapped'] = "/owner/calendar.ics" + form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "disable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** fetch collection via map (with credentials) as user -> n/a") + _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) + + logging.debug("*** enable map by owner (json->json)") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathMapped'] = "/owner/calendar.ics" + form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** fetch collection via map (with credentials) as user") + _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) + + logging.debug("*** disable map by user (json->json)") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathMapped'] = "/owner/calendar.ics" + form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "disable", check=200, login="%s:%s" % ("user", "userpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** fetch collection via map (with credentials) as user -> n/a") + _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) + + logging.debug("*** delete map by user (json->json) -> fail") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathMapped'] = "/owner/calendar.ics" + form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "delete", check=403, login="%s:%s" % ("user", "userpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + + logging.debug("*** delete map by owner (json->json) -> ok") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathMapped'] = "/owner/calendar.ics" + form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "delete", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + ## TODO hide+unhide for REPORT From 2ffce8696ab93e5e6a1abe617fc16f3e35aec634 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Feb 2026 22:19:56 +0100 Subject: [PATCH 023/138] cosmetics --- radicale/sharing/csv.py | 2 +- radicale/sharing/none.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index c1d392f3b..cf74239bf 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -94,7 +94,7 @@ def get_sharing_collection_by_token(self, token: str) -> [dict | None]: # default logger.debug("TRACE/sharing_by_token: no entry in map found for token: %r", token) - return None + return None def get_sharing_collection_by_map(self, path: str, user: str) -> [dict | None]: """ retrieve target and attributes by map """ diff --git a/radicale/sharing/none.py b/radicale/sharing/none.py index bdbed9fea..c1ed70ff6 100644 --- a/radicale/sharing/none.py +++ b/radicale/sharing/none.py @@ -24,7 +24,7 @@ def get_sharing_collection_by_token(self, token: str) -> [dict | None]: """ retrieve target and attributs by token """ # default logger.debug("TRACE/sharing_by_token: 'none' cannot provide any map for token: %r", token) - return None + return None def get_sharing_collection_by_map(self, path) -> [dict | None]: """ retrieve target and attributs by map """ From 4f027c4c066a694713c8eebee0d5cae887ed1412 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Feb 2026 22:22:42 +0100 Subject: [PATCH 024/138] fix test cases --- radicale/tests/test_sharing.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 11d620782..f7b2c66a1 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -139,16 +139,16 @@ def test_sharing_api_list_with_auth(self) -> None: data = "\n".join(form_array) _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "# status=success" in answer - assert "# lines=0" in answer + assert "# Status=not-found" in answer + assert "# Lines=0" in answer # check with request JSON response CSV json_dict: dict = {} content_type = "application/json" data = json.dumps(json_dict) _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "# status=success" in answer - assert "# lines=0" in answer + assert "# Status=not-found" in answer + assert "# Lines=0" in answer # check with request JSON response JSON json_dict: dict = {} content_type = "application/json" @@ -156,9 +156,9 @@ def test_sharing_api_list_with_auth(self) -> None: data = json.dumps(json_dict) _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=accept) logging.debug("received answer %r", answer) - assert '"status": "success"' in answer - assert '"lines": 0' in answer - assert '"content": null' in answer + assert '"Status": "not-found"' in answer + assert '"Lines": 0' in answer + assert '"Content": null' in answer def test_sharing_api_token_basic(self) -> None: From bf9744d294e8dc56908a719a5277bb17c9d3b591 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 9 Feb 2026 22:35:18 +0100 Subject: [PATCH 025/138] flake8 fixes --- radicale/sharing/__init__.py | 14 +++++----- radicale/sharing/csv.py | 51 ++++++++++++++++------------------ radicale/tests/test_sharing.py | 43 ++++++++++++---------------- 3 files changed, 48 insertions(+), 60 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index aa692f54f..05f8b1715 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -18,6 +18,7 @@ import io import json import re +import socket import uuid from csv import DictWriter @@ -25,7 +26,7 @@ from http import client from urllib.parse import parse_qs -from radicale import (config, httputils, rights, utils) +from radicale import config, httputils, rights, utils from radicale.app.base import Access from radicale.log import logger @@ -386,13 +387,13 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer['PathOrToken'] = token elif sharetype == "map": - if not 'User' in request_data: + if 'User' not in request_data: logger.warning(api_info + ": missing User") return httputils.BAD_REQUEST else: user_share = request_data['User'] - if not 'PathOrToken' in request_data: + if 'PathOrToken' not in request_data: logger.warning(api_info + ": missing PathOrToken") return httputils.BAD_REQUEST else: @@ -460,11 +461,11 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st result = self.toggle_sharing_by_token(user, request_data['PathOrToken'], action, timestamp) elif sharetype == "map": - if not 'User' in request_data: + if 'User' not in request_data: logger.warning(api_info + ": missing User") return httputils.BAD_REQUEST - if not 'PathOrToken' in request_data: + if 'PathOrToken' not in request_data: logger.warning(api_info + ": missing PathOrToken") return httputils.BAD_REQUEST @@ -489,7 +490,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # default return httputils.BAD_REQUEST - ## output handler + # output handler logger.debug("TRACE/sharing/API/POST output format: %r", output_format) if output_format == "csv" or output_format == "txt": answer_array = [] @@ -518,5 +519,4 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # should not be reached return httputils.BAD_REQUEST - return httputils.METHOD_NOT_ALLOWED diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index cf74239bf..6c6bf401e 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -22,6 +22,7 @@ """ CVS based sharing by token or map """ + class Sharing(sharing.BaseSharing): _lines: int = 0 _map_cache = [] @@ -71,8 +72,8 @@ def init_database(self) -> bool: self._sharing_db_file = sharing_db_file return True - def get_database_info(self) -> [ dict | None]: - database_info = { 'type': "csv" } + def get_database_info(self) -> [dict | None]: + database_info = {'type': "csv"} return database_info def get_sharing_collection_by_token(self, token: str) -> [dict | None]: @@ -157,7 +158,7 @@ def create_sharing_by_token(self, user: str, token: str, path_mapped: str, times "TimestampUpdated": str(timestamp) } logger.debug("TRACE/sharing_by_token: add row: %r", row) - ## TODO: add locking + # TODO: add locking self._map_cache.append(row) if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") @@ -165,7 +166,6 @@ def create_sharing_by_token(self, user: str, token: str, path_mapped: str, times logger.warning("sharing/add_sharing_by_token: cannot update CSV database") return False - def delete_sharing_by_token(self, user: str, token: str) -> [dict | None]: """ delete sharing by token """ logger.debug("TRACE/sharing_by_token/delete: user=%r token=%r", user, token) @@ -188,7 +188,7 @@ def delete_sharing_by_token(self, user: str, token: str) -> [dict | None]: logger.debug("TRACE/sharing_by_token/delete: user=%r token=%r index=%d", user, token, index) self._map_cache.pop(index) - ## TODO: add locking + # TODO: add locking if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") return {"status": "success"} @@ -236,7 +236,7 @@ def toggle_sharing_by_token(self, user: str, token: str, toggle: str, timestamp: # readd self._map_cache.append(row) - ## TODO: add locking + # TODO: add locking if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") return {"status": "success"} @@ -246,7 +246,7 @@ def toggle_sharing_by_token(self, user: str, token: str, toggle: str, timestamp: return {"status": "not-found"} - ## sharing by map + # sharing by map def create_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: """ create sharing by map """ logger.debug("TRACE/sharing_by_map/create: %r of %r mapped to %r of %r permissions=%r enabled=%s", user_share, path_share, user, path_mapped, permissions, enabled) @@ -257,21 +257,21 @@ def create_sharing_by_map(self, user: str, path_share: str, path_mapped: str, us if row['PathOrToken'] == path_share and row['User'] == user_share and row['PathMapped'] == path_mapped: logger.warning("sharing/add_sharing_by_map: already exists: %r of %r mapped to %r of %r", user_share, path_share, user, path_mapped) return False - row = { "Type": "map", - "PathOrToken": path_share, - "PathMapped": path_mapped, - "Owner": user, - "User": user_share, - "Permissions": permissions, - "EnabledByOwner": str(enabled), - "EnabledByUser": str(True), - "HiddenByOwner": str(False), - "HiddenByUser": str(False), - "TimestampCreated": str(timestamp), - "TimestampUpdated": str(timestamp) - } + row = {"Type": "map", + "PathOrToken": path_share, + "PathMapped": path_mapped, + "Owner": user, + "User": user_share, + "Permissions": permissions, + "EnabledByOwner": str(enabled), + "EnabledByUser": str(True), + "HiddenByOwner": str(False), + "HiddenByUser": str(False), + "TimestampCreated": str(timestamp), + "TimestampUpdated": str(timestamp), + } logger.debug("TRACE/sharing_by_map: add row: %r", row) - ## TODO: add locking + # TODO: add locking self._map_cache.append(row) if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") @@ -279,7 +279,6 @@ def create_sharing_by_map(self, user: str, path_share: str, path_mapped: str, us logger.warning("sharing/add_sharing_by_token: cannot update CSV database") return False - def delete_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str) -> [dict | None]: """ delete sharing by map """ logger.debug("TRACE/sharing_by_map/delete: user=%r path_share=%r", user, path_share) @@ -302,7 +301,7 @@ def delete_sharing_by_map(self, user: str, path_share: str, path_mapped: str, us logger.debug("TRACE/sharing_by_map/delete: user=%r path_share=%r index=%d", user, path_share, index) self._map_cache.pop(index) - ## TODO: add locking + # TODO: add locking if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") return {"status": "success"} @@ -311,7 +310,6 @@ def delete_sharing_by_map(self, user: str, path_share: str, path_mapped: str, us return {"status": "not-found"} - def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, toggle: str, timestamp: int) -> [dict | None]: """ toggle sharing by map """ logger.debug("TRACE/sharing_by_map/" + toggle + ": user=%r path_share=%r path_mapped=%r user_share=%r", user, path_share, path_mapped, user_share) @@ -369,7 +367,7 @@ def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, us # readd self._map_cache.append(row) - ## TODO: add locking + # TODO: add locking if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") return {"status": "success"} @@ -378,8 +376,7 @@ def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, us return {"status": "not-found"} - - ## local functions + # local functions def _create_empty_csv(self, file) -> bool: with open(file, 'w', newline='') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index f7b2c66a1..92a19645d 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -22,15 +22,10 @@ import json import logging import os -import posixpath import re -import urllib -from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple -import pytest - -from radicale import sharing, utils -from radicale.tests import RESPONSES, BaseTest +from radicale import sharing +from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content @@ -117,7 +112,6 @@ def test_sharing_api_base_with_auth(self) -> None: # valid API _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) - def test_sharing_api_list_with_auth(self) -> None: """POST/list with authentication.""" self.configure({"auth": {"type": "htpasswd", @@ -134,7 +128,7 @@ def test_sharing_api_list_with_auth(self) -> None: path = "/.sharing/v1/" + sharingtype + "/" + action _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) # check with request FORM response CSV - form_array:str = [] + form_array: str = [] content_type = "application/x-www-form-urlencoded" data = "\n".join(form_array) _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) @@ -160,7 +154,6 @@ def test_sharing_api_list_with_auth(self) -> None: assert '"Lines": 0' in answer assert '"Content": null' in answer - def test_sharing_api_token_basic(self) -> None: """share-by-token API tests.""" self.configure({"auth": {"type": "htpasswd", @@ -178,7 +171,7 @@ def test_sharing_api_token_basic(self) -> None: path_base = "/.sharing/v1/" + sharingtype + "/" logging.debug("*** create token without PathMapped (form) -> should fail") - form_array:str = [] + form_array: str = [] data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) @@ -190,7 +183,7 @@ def test_sharing_api_token_basic(self) -> None: _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("*** create token#1") - form_array:str = [] + form_array: str = [] form_array.append("PathMapped=/owner/collection1") data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -218,7 +211,7 @@ def test_sharing_api_token_basic(self) -> None: logging.debug("received token %r", token2) logging.debug("*** lookup token#1 (form->text)") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token1) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -254,7 +247,7 @@ def test_sharing_api_token_basic(self) -> None: assert "/owner/collection2" in result['Content'][0]['PathMapped'] logging.debug("*** delete token#1 (form->text)") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token1) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -263,7 +256,7 @@ def test_sharing_api_token_basic(self) -> None: assert "Status=success" in answer logging.debug("*** lookup token#1 (form->text) -> should not be there anymore") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token1) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -273,7 +266,7 @@ def test_sharing_api_token_basic(self) -> None: assert "Lines=0" in answer logging.debug("*** lookup tokens (form->text) -> still one should be there") - form_array:str = [] + form_array: str = [] data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) @@ -282,7 +275,7 @@ def test_sharing_api_token_basic(self) -> None: assert "Lines=1" in answer logging.debug("*** disable token#2 (form->text)") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token2) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -313,7 +306,7 @@ def test_sharing_api_token_basic(self) -> None: assert "success" in result['Status'] logging.debug("*** lookup token#2 (form->text) -> check for enabled") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token2) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -324,7 +317,7 @@ def test_sharing_api_token_basic(self) -> None: assert "True,True,False,False" in answer logging.debug("*** hide token#2 (form->text)") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token2) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -333,7 +326,7 @@ def test_sharing_api_token_basic(self) -> None: assert "Status=success" in answer logging.debug("*** lookup token#2 (form->text) -> check for hidden") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token2) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -386,7 +379,6 @@ def test_sharing_api_token_basic(self) -> None: assert "not-found" in result['Status'] assert result['Lines'] == 0 - def test_sharing_api_token_usage(self) -> None: """share-by-token API tests - real usage.""" self.configure({"auth": {"type": "htpasswd", @@ -412,7 +404,7 @@ def test_sharing_api_token_usage(self) -> None: _, headers, answer = self.request("GET", path, check=200, login="%s:%s" % ("owner", "ownerpw")) logging.debug("*** create token") - form_array:str = [] + form_array: str = [] form_array.append("PathMapped=/owner/calendar.ics") data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -433,7 +425,7 @@ def test_sharing_api_token_usage(self) -> None: assert "UID:event" in answer logging.debug("*** disable token (form->text)") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -445,7 +437,7 @@ def test_sharing_api_token_usage(self) -> None: _, headers, answer = self.request("GET", path_token + token, check=404) logging.debug("*** enable token (form->text)") - form_array:str = [] + form_array: str = [] form_array.append("PathOrToken=" + token) data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" @@ -470,7 +462,6 @@ def test_sharing_api_token_usage(self) -> None: logging.debug("*** fetch collection using deleted token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=404) - def test_sharing_api_map_basic(self) -> None: """share-by-map API basic tests.""" self.configure({"auth": {"type": "htpasswd", @@ -637,4 +628,4 @@ def test_sharing_api_map_usage(self) -> None: result = json.loads(answer) assert "success" in result['Status'] - ## TODO hide+unhide for REPORT + # TODO hide+unhide for REPORT From e134e0fc7f3fe8796a1db1334911d98d4a37526e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 10 Feb 2026 17:16:38 +0100 Subject: [PATCH 026/138] review --- radicale/sharing/__init__.py | 239 +++++++++++++++++++-------------- radicale/sharing/csv.py | 6 +- radicale/tests/test_sharing.py | 31 +++-- 3 files changed, 160 insertions(+), 116 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 05f8b1715..bcfd3144a 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -30,9 +30,21 @@ from radicale.app.base import Access from radicale.log import logger -INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "mock", "none") - -DB_FIELDS: Sequence[str] = ('Type', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') +INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "none") + +DB_FIELDS_V1: Sequence[str] = ('Type', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') +# Type: +# PathOrToken: +# PathMapped: +# Owner: (creator of database entry) +# User: (user of database entry) +# Permissions: +# EnabledByOwner: True|False (share status "invite/grant") +# EnabledByUser: True|False (share status "accept") +# HiddenByOwner: True|False (share exposure controlled by owner) +# HiddenByUser: True|False (share exposure controlled by user) +# TimestampCreated: (when created) +# TimestampUpdated: (last update) SHARE_TYPES: Sequence[str] = ('token', 'map') @@ -42,7 +54,11 @@ API_SHARE_TOGGLES_V1: Sequence[str] = ('hide', 'unhide', 'enable', 'disable') -TOKEN_PATTERN_V1: str = "([a-zA-Z0-9_=\\-]{44})" +TOKEN_PATTERN_V1: str = "(v1/[a-zA-Z0-9_=\\-]{44})" + +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 def load(configuration: "config.Configuration") -> "BaseSharing": @@ -106,11 +122,11 @@ def get_sharing_list_by_type_user(self, share_type, user, path_token = None) -> """ retrieve sharing list by type and user (path_token optional)""" return None - def create_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: + def create_sharing_by_token(self, user: str, token: str, PathMapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: """ create sharing by token """ return None - def create_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: + def create_sharing_by_map(self, user: str, path_share: str, PathMapped: str, user_share: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: """ create sharing by token """ return None @@ -118,7 +134,7 @@ def delete_sharing_by_token(self, user: str, token: str) -> [dict | None]: """ delete sharing by token """ return None - def delete_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str) -> [dict | None]: + def delete_sharing_by_map(self, user: str, path_share: str, PathMapped: str, user_share: str) -> [dict | None]: """ delete sharing by token """ return None @@ -126,7 +142,7 @@ def toggle_sharing_by_token(self, user: str, token: str, toggle: str, timestamp: """ toggle sharing by token """ return None - def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, toggle: str, timestamp: int) -> [dict | None]: + def toggle_sharing_by_map(self, user: str, path_share: str, PathMapped: str, user_share: str, toggle: str, timestamp: int) -> [dict | None]: """ toggle sharing by map """ return None @@ -159,15 +175,15 @@ def sharing_collection_by_token_resolver(self, path) -> [dict | None]: if self.sharing_collection_by_token: logger.debug("TRACE/sharing_by_token: check path: %r", path) if path.startswith("/.token/"): - pattern = re.compile('^/\\.token/v(\\d+)/' + TOKEN_PATTERN_V1 + '$') + pattern = re.compile('^/\\.token/' + TOKEN_PATTERN_V1 + '$') match = pattern.match(path) if not match: logger.debug("TRACE/sharing_by_token: unsupported token: %r", path) return None else: # TODO add token validity checks - logger.debug("TRACE/sharing_by_token: supported token found in path: %r (version=%s token=%r)", path, match[1], match[2]) - return self.get_sharing_collection_by_token("v" + match[1] + "/" + match[2]) + logger.debug("TRACE/sharing_by_token: supported token found in path: %r (token=%r)", path, match[1]) + return self.get_sharing_collection_by_token(match[1]) else: logger.debug("TRACE/sharing_by_token: no supported prefix found in path: %r", path) return {"mapped": False} @@ -237,28 +253,28 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if not path.startswith("/.sharing/v1/"): return httputils.NOT_FOUND - # split into sharetype and action - sharetype_action = path.removeprefix("/.sharing/v1/") - match = re.search('([a-z]+)/([a-z]+)$', sharetype_action) + # split into share_type and action + share_type_action = path.removeprefix("/.sharing/v1/") + match = re.search('([a-z]+)/([a-z]+)$', share_type_action) if not match: - logger.debug("TRACE/sharing/API: sharetype/action not extractable: %r", sharetype_action) + logger.debug("TRACE/sharing/API: share_type/action not extractable: %r", share_type_action) return httputils.NOT_FOUND - sharetype = match.group(1) + share_type = match.group(1) action = match.group(2) - # check for valid sharetypes - if sharetype: - if not sharetype in SHARE_TYPES: - logger.debug("TRACE/sharing/API: sharetype not whitelisted: %r", sharetype) + # check for valid share_types + if share_type: + if not share_type in SHARE_TYPES: + logger.debug("TRACE/sharing/API: share_type not whitelisted: %r", share_type) return httputils.NOT_FOUND - # check for enabled sharetypes - if not self.sharing_collection_by_map and sharetype == "map": + # check for enabled share_types + if not self.sharing_collection_by_map and share_type == "map": # API is not enabled return httputils.NOT_FOUND - if not self.sharing_collection_by_token and sharetype == "token": + if not self.sharing_collection_by_token and share_type == "token": # API is not enabled return httputils.NOT_FOUND @@ -278,7 +294,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT - api_info = "sharing/API/POST/" + sharetype + "/" + action + api_info = "sharing/API/POST/" + share_type + "/" + action # parse body according to content-type content_type = environ.get("CONTENT_TYPE", "") @@ -299,16 +315,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("TRACE/" + api_info + ": no supported content data") return httputils.BAD_REQUEST - ## sanity checks - for key in request_data: - if key == "permissions": - if not re.search('^[a-zA-Z]+$', request_data[key]): - return httputils.BAD_REQUEST - if key == "token": - if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]): - return httputils.BAD_REQUEST - - ## check for requested output type + # check for requested output type accept = environ.get("ACCEPT", "") if 'application/json' in accept: output_format = "json" @@ -327,7 +334,83 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st else: return httputils.BAD_REQUEST + # parameters default + PathOrToken: [str | None] = None + PathMapped: [str | None] = None + permissions: [str | None] = None + user_share: [str | None] = None + EnabledByOwner: False # security by default + HiddenByOwner: True # security by default + + # parameters sanity check + for key in request_data: + if key == "Permissions": + if not re.search('^[a-zA-Z]+$', request_data[key]): + return httputils.BAD_REQUEST + elif key == "PathOrToken": + if share_type == "token": + if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]): + logger.error(api_info + ": unsupported " + key) + return httputils.BAD_REQUEST + elif share_type == "map": + if not re.search('^' + PATH_PATTERN + '$', request_data[key]): + logger.error(api_info + ": unsupported " + key) + return httputils.BAD_REQUEST + elif key == "PathMapped": + if not re.search('^' + PATH_PATTERN + '$', request_data[key]): + logger.error(api_info + ": unsupported " + key) + return httputils.BAD_REQUEST + elif key == "Enabled" or key == "Hidden": + if not re.search('^(False|True)$', request_data[key]): + logger.error(api_info + ": unsupported " + key) + return httputils.BAD_REQUEST + elif key == "User": + if not re.search('^' + USER_PATTERN + '$', request_data[key]): + logger.error(api_info + ": unsupported " + key) + return httputils.BAD_REQUEST + + # check for mandatory parameters + if 'PathMapped' not in request_data: + if action != 'list': + if share_type == "token" and action != 'create': + # optinoal + pass + else: + logger.error(api_info + ": missing PathMapped") + return httputils.BAD_REQUEST + else: + # optional + pass + else: + PathMapped = request_data['PathMapped'] + + if 'PathOrToken' not in request_data: + if action not in ['list', 'create']: + logger.error(api_info + ": missing PathOrToken") + return httputils.BAD_REQUEST + else: + # optional + pass + else: + if action == "create" and share_type == "token": + # not supported + logger.error(api_info + ": PathOrToken found but not supported") + return httputils.BAD_REQUEST + PathOrToken = request_data['PathOrToken'] + + if share_type == "map": + if 'User' not in request_data: + if action != "list": + logger.warning(api_info + ": missing User") + return httputils.BAD_REQUEST + else: + # optional + pass + else: + user_share = request_data['User'] + answer: dict = {} + answer['ApiVersion'] = "1" timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) ## action: list @@ -337,7 +420,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if 'PathOrToken' in request_data: path_token_filter = request_data['PathOrToken'] logger.debug("TRACE/" + api_info + ": filter: %r", path_token_filter) - result = self.get_sharing_list_by_type_user(sharetype, user, path_token_filter) + result = self.get_sharing_list_by_type_user(share_type, user, PathOrToken) if not result: answer['Lines'] = 0 answer['Status'] = "not-found" @@ -349,12 +432,6 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st ## action: create elif action == "create": logger.debug("TRACE/" + api_info + ": start") - if not 'PathMapped' in request_data: - logger.warning(api_info + ": missing PathMapped") - return httputils.BAD_REQUEST - else: - path_mapped = request_data['PathMapped'] - if not 'Permissions' in request_data: permissions = "r" else: @@ -365,49 +442,37 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st else: enabled = config._convert_to_bool(request_data['EnabledByOwner']) - if sharetype == "token": + if share_type == "token": # check access permissions - access = Access(self._rights, user, path_mapped) + access = Access(self._rights, user, PathMapped) if not access.check("r") and "i" not in access.permissions: - logger.info("Add sharing-by-token: access to %r not allowed for user %r", path_mapped, user) + logger.info("Add sharing-by-token: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED ## v1: create uuid token with 2x 32 bytes = 256 bit token = "v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') - logger.debug("TRACE/" + api_info + ": %r (permissions=%r token=%r)", path_mapped, permissions, token) + logger.debug("TRACE/" + api_info + ": %r (permissions=%r token=%r)", PathMapped, permissions, token) - if not self.create_sharing_by_token(user, token, path_mapped, timestamp, permissions, enabled): - logger.info("Add sharing-by-token: %r by user %s not successful", path_mapped, user) + if not self.create_sharing_by_token(user, token, PathMapped, timestamp, permissions, enabled): + logger.info("Add sharing-by-token: %r by user %s not successful", PathMapped, user) return httputils.BAD_REQUEST - logger.info(api_info + "(success): %r (permissions=%r token=%r)", path_mapped, permissions, token) + logger.info(api_info + "(success): %r (permissions=%r token=%r)", PathMapped, permissions, token) answer['Status'] = "success" answer['PathOrToken'] = token - elif sharetype == "map": - if 'User' not in request_data: - logger.warning(api_info + ": missing User") - return httputils.BAD_REQUEST - else: - user_share = request_data['User'] - - if 'PathOrToken' not in request_data: - logger.warning(api_info + ": missing PathOrToken") - return httputils.BAD_REQUEST - else: - path_share = request_data['PathOrToken'] - + elif share_type == "map": # check access permissions - access = Access(self._rights, user, path_mapped) + access = Access(self._rights, user, PathMapped) if not access.check("r") and "i" not in access.permissions: - logger.info("Add sharing-by-map: access to %r not allowed for user %r", path_mapped, user) + logger.info("Add sharing-by-map: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED - logger.debug("TRACE/" + api_info + ": %r (permissions=%r path_share=%r user=%r)", path_mapped, permissions, path_share, user_share) - if not self.create_sharing_by_map(user, path_share, path_mapped, user_share, timestamp, permissions, enabled): - logger.info("Add sharing-by-token: %r by user %s not successful", path_mapped, user) + logger.debug("TRACE/" + api_info + ": %r (permissions=%r PathOrToken=%r user=%r)", PathMapped, permissions, PathOrToken, user_share) + if not self.create_sharing_by_map(user, PathOrToken, PathMapped, user_share, timestamp, permissions, enabled): + logger.info("Add sharing-by-token: %r by user %s not successful", PathMapped, user) return httputils.BAD_REQUEST answer['Status'] = "success" @@ -416,22 +481,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif action == "delete": logger.debug("TRACE/" + api_info + ": start") - if not 'PathOrToken' in request_data: - logger.warning(api_info + ": missing PathOrToken") - return httputils.BAD_REQUEST - - if sharetype == "token": + if share_type == "token": result = self.delete_sharing_by_token(user, request_data['PathOrToken']) - elif sharetype == "map": - if not 'User' in request_data: - logger.warning(api_info + ": missing User") - return httputils.BAD_REQUEST - - if not 'PathOrToken' in request_data: - logger.warning(api_info + ": missing PathOrToken") - return httputils.BAD_REQUEST - + elif share_type == "map": result = self.delete_sharing_by_map(user, request_data['PathOrToken'], request_data['PathMapped'], request_data['User']) ## result handling @@ -443,9 +496,9 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer['Status'] = "success" pass else: - if sharetype == "token": + if share_type == "token": logger.info("Delete sharing-by-token: %r of user %r not successful", token, user) - elif sharetype == "map": + elif share_type == "map": logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) return httputils.BAD_REQUEST @@ -453,22 +506,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif action in API_SHARE_TOGGLES_V1: logger.debug("TRACE/sharing/API/POST/" + action) - if sharetype == "token": - if not 'PathOrToken' in request_data: - logger.warning(api_info + ": missing PathOrToken") - return httputils.BAD_REQUEST - + if share_type == "token": result = self.toggle_sharing_by_token(user, request_data['PathOrToken'], action, timestamp) - elif sharetype == "map": - if 'User' not in request_data: - logger.warning(api_info + ": missing User") - return httputils.BAD_REQUEST - - if 'PathOrToken' not in request_data: - logger.warning(api_info + ": missing PathOrToken") - return httputils.BAD_REQUEST - + elif share_type == "map": result = self.toggle_sharing_by_map(user, request_data['PathOrToken'], request_data['PathMapped'], request_data['User'], action, timestamp) if result: @@ -480,9 +521,9 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer['Status'] = "success" pass else: - if sharetype == "token": + if share_type == "token": logger.info("Delete sharing-by-token: %r of user %s not successful", request_data['PathOrToken'], user) - elif sharetype == "map": + elif share_type == "map": logger.info("Delete sharing-by-map: %r of user %s not successful", request_data['PathOrToken'], user) return httputils.BAD_REQUEST @@ -499,7 +540,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer_array.append('# ' + key + '=' + str(answer[key])) if 'Content' in answer and answer['Content'] is not None: csv = io.StringIO() - writer = DictWriter(csv, fieldnames=DB_FIELDS) + writer = DictWriter(csv, fieldnames=DB_FIELDS_V1) if output_format == "csv": writer.writeheader() for entry in answer['Content']: diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 6c6bf401e..7dc198361 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -379,14 +379,14 @@ def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, us # local functions def _create_empty_csv(self, file) -> bool: with open(file, 'w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS) + writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) writer.writeheader() return True def _load_csv(self, file) -> bool: logger.debug("sharing database load begin: %r", file) with open(file, 'r', newline='') as csvfile: - reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS) + reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) self._lines = 0 for row in reader: # check for duplicates @@ -404,6 +404,6 @@ def _load_csv(self, file) -> bool: def _write_csv(self, file) -> bool: with open(file, 'w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS) + writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) writer.writerows(self._map_cache) return True diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 92a19645d..1deaca19c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -123,11 +123,12 @@ def test_sharing_api_list_with_auth(self) -> None: "logging": {"request_header_on_debug": "true"}, "rights": {"type": "owner_only"}}) action = "list" - for sharingtype in sharing.SHARE_TYPES: - # basic checks with sharingtype - path = "/.sharing/v1/" + sharingtype + "/" + action + for sharing_type in sharing.SHARE_TYPES: + logging.debug("*** list (without form) -> should fail") + path = "/.sharing/v1/" + sharing_type + "/" + action _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) - # check with request FORM response CSV + + logging.debug("*** list (form -> csv)") form_array: str = [] content_type = "application/x-www-form-urlencoded" data = "\n".join(form_array) @@ -135,7 +136,8 @@ def test_sharing_api_list_with_auth(self) -> None: logging.debug("received answer %r", answer) assert "# Status=not-found" in answer assert "# Lines=0" in answer - # check with request JSON response CSV + + logging.debug("*** list (json -> csv)") json_dict: dict = {} content_type = "application/json" data = json.dumps(json_dict) @@ -143,7 +145,8 @@ def test_sharing_api_list_with_auth(self) -> None: logging.debug("received answer %r", answer) assert "# Status=not-found" in answer assert "# Lines=0" in answer - # check with request JSON response JSON + + logging.debug("*** list (json -> json)") json_dict: dict = {} content_type = "application/json" accept = "application/json" @@ -167,8 +170,8 @@ def test_sharing_api_token_basic(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - sharingtype = "token" - path_base = "/.sharing/v1/" + sharingtype + "/" + sharing_type = "token" + path_base = "/.sharing/v1/" + sharing_type + "/" logging.debug("*** create token without PathMapped (form) -> should fail") form_array: str = [] @@ -392,8 +395,8 @@ def test_sharing_api_token_usage(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - sharingtype = "token" - path_base = "/.sharing/v1/" + sharingtype + "/" + sharing_type = "token" + path_base = "/.sharing/v1/" + sharing_type + "/" path_token = "/.token/" logging.debug("*** prepare and test access") @@ -475,8 +478,8 @@ def test_sharing_api_map_basic(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - sharingtype = "map" - path_base = "/.sharing/v1/" + sharingtype + "/" + sharing_type = "map" + path_base = "/.sharing/v1/" + sharing_type + "/" logging.debug("*** create map without PathMapped (json) -> should fail") form_dict = {} @@ -512,8 +515,8 @@ def test_sharing_api_map_usage(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - sharingtype = "map" - path_base = "/.sharing/v1/" + sharingtype + "/" + sharing_type = "map" + path_base = "/.sharing/v1/" + sharing_type + "/" path_share = "/user/calendar-shared-by-owner.ics" path_mapped = "/owner/calendar.ics" From 730911d4b60115254cc2c9e37bafc51abfc0ea49 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 06:27:57 +0100 Subject: [PATCH 027/138] add test cases, optimize code --- radicale/tests/test_sharing.py | 222 ++++++++++++++++++--------------- 1 file changed, 120 insertions(+), 102 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 1deaca19c..f0db0e4ec 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -34,6 +34,7 @@ class TestSharingApiSanity(BaseTest): htpasswd_file_path: str + # Setup def setup_method(self) -> None: BaseTest.setup_method(self) self.htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") @@ -42,6 +43,30 @@ def setup_method(self) -> None: with open(self.htpasswd_file_path, "w", encoding=encoding) as f: f.write(htpasswd_content) + # Helper functions + def _sharing_api(self, sharing_type: str, action: str, check: int, login: [str | None], data: str, content_type: str, accept_type: [str | None]): + path_base = "/.sharing/v1/" + sharing_type + "/" + _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept_type) + logging.debug("received answer:\n%s", "\n".join(answer.splitlines())) + return _, headers, answer + + def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: [str | None], form_array: Sequence[str], accept_type: [str | None] = None): + data = "\n".join(form_array) + content_type = "application/x-www-form-urlencoded" + if accept_type is None: + accept_type = "text/plain" + _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept_type) + return _, headers, answer + + def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: [str | None], form_dict: dict, accept_type: [str | None] = None): + data = json.dumps(form_dict) + content_type = "application/json" + if accept_type is None: + accept_type = "application/json" + _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept_type) + return _, headers, answer + + # Test functions def test_sharing_api_base_no_auth(self) -> None: """POST request at '/.sharing' without authentication.""" # disabled @@ -122,6 +147,8 @@ def test_sharing_api_list_with_auth(self) -> None: "collection_by_token": "True"}, "logging": {"request_header_on_debug": "true"}, "rights": {"type": "owner_only"}}) + form_array: Sequence[str] + json_dict: dict action = "list" for sharing_type in sharing.SHARE_TYPES: logging.debug("*** list (without form) -> should fail") @@ -129,13 +156,13 @@ def test_sharing_api_list_with_auth(self) -> None: _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) logging.debug("*** list (form -> csv)") - form_array: str = [] + form_array = [] content_type = "application/x-www-form-urlencoded" data = "\n".join(form_array) _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "# Status=not-found" in answer - assert "# Lines=0" in answer + assert "Status=not-found" in answer + assert "Lines=0" in answer logging.debug("*** list (json -> csv)") json_dict: dict = {} @@ -143,8 +170,8 @@ def test_sharing_api_list_with_auth(self) -> None: data = json.dumps(json_dict) _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) logging.debug("received answer %r", answer) - assert "# Status=not-found" in answer - assert "# Lines=0" in answer + assert "Status=not-found" in answer + assert "Lines=0" in answer logging.debug("*** list (json -> json)") json_dict: dict = {} @@ -172,26 +199,20 @@ def test_sharing_api_token_basic(self) -> None: sharing_type = "token" path_base = "/.sharing/v1/" + sharing_type + "/" + form_array: Sequence[str] logging.debug("*** create token without PathMapped (form) -> should fail") - form_array: str = [] - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + form_array = [] + _, headers, answer = self._sharing_api_form("token", "create", 400, "owner:ownerpw", form_array) logging.debug("*** create token without PathMapped (json) -> should fail") form_dict = {} - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + _, headers, answer = self._sharing_api_json("token", "create", 400, "owner:ownerpw", form_dict) - logging.debug("*** create token#1") - form_array: str = [] + logging.debug("*** create token#1 (form->text)") + form_array = [] form_array.append("PathMapped=/owner/collection1") - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "create", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "PathOrToken=" in answer # extract token @@ -199,13 +220,10 @@ def test_sharing_api_token_basic(self) -> None: token1 = match[1] logging.debug("received token %r", token1) - logging.debug("*** create token#2") + logging.debug("*** create token#2 (json->text)") form_dict = {} form_dict['PathMapped'] = "/owner/collection2" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "create", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "create", 200, "owner:ownerpw", form_dict, "text/plain") assert "Status=success" in answer assert "Token=" in answer # extract token @@ -214,85 +232,76 @@ def test_sharing_api_token_basic(self) -> None: logging.debug("received token %r", token2) logging.debug("*** lookup token#1 (form->text)") - form_array: str = [] + form_array = [] form_array.append("PathOrToken=" + token1) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection1" in answer logging.debug("*** lookup token#2 (json->text") form_dict = {} - content_type = "application/json" form_dict['PathOrToken'] = token2 - content_type = "application/json" - data = json.dumps(form_dict) - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict, "text/plain") assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection2" in answer logging.debug("*** lookup token#2 (json->json)") form_dict = {} - content_type = "application/json" form_dict['PathOrToken'] = token2 - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) result = json.loads(answer) assert "success" in result['Status'] assert result['Lines'] == 1 assert "/owner/collection2" in result['Content'][0]['PathMapped'] + logging.debug("*** lookup tokens (form->text)") + form_array = [] + _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) + assert "Status=success" in answer + assert "Lines=2" in answer + assert "/owner/collection1" in answer + assert "/owner/collection2" in answer + + logging.debug("*** lookup tokens (form->csv)") + form_array = [] + _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array, "text/csv") + assert "Status=success" not in answer + assert "Lines=2" not in answer + assert ",".join(sharing.DB_FIELDS_V1) in answer + assert "/owner/collection1" in answer + assert "/owner/collection2" in answer + logging.debug("*** delete token#1 (form->text)") - form_array: str = [] + form_array = [] form_array.append("PathOrToken=" + token1) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "delete", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "delete", 200, "owner:ownerpw", form_array) assert "Status=success" in answer logging.debug("*** lookup token#1 (form->text) -> should not be there anymore") - form_array: str = [] + form_array = [] form_array.append("PathOrToken=" + token1) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=not-found" in answer assert "Lines=0" in answer logging.debug("*** lookup tokens (form->text) -> still one should be there") - form_array: str = [] - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + form_array = [] + _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "Lines=1" in answer logging.debug("*** disable token#2 (form->text)") - form_array: str = [] + form_array = [] form_array.append("PathOrToken=" + token2) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "disable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "disable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer logging.debug("*** lookup token#2 (json->json) -> check for not enabled") form_dict = {} form_dict['PathOrToken'] = token2 - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) result = json.loads(answer) assert "success" in result['Status'] assert result['Lines'] == 1 @@ -301,61 +310,43 @@ def test_sharing_api_token_basic(self) -> None: logging.debug("*** enable token#2 (json->json)") form_dict = {} form_dict['PathOrToken'] = token2 - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "enable", 200, "owner:ownerpw", form_dict) result = json.loads(answer) assert "success" in result['Status'] logging.debug("*** lookup token#2 (form->text) -> check for enabled") - form_array: str = [] + form_array = [] form_array.append("PathOrToken=" + token2) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "Lines=1" in answer - assert "True,True,False,False" in answer + assert "True,True,True,True" in answer logging.debug("*** hide token#2 (form->text)") - form_array: str = [] + form_array = [] form_array.append("PathOrToken=" + token2) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "hide", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "hide", 200, "owner:ownerpw", form_array) assert "Status=success" in answer logging.debug("*** lookup token#2 (form->text) -> check for hidden") - form_array: str = [] + form_array = [] form_array.append("PathOrToken=" + token2) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "Lines=1" in answer - assert "True,True,True,False" in answer + assert "True,True,True,True" in answer logging.debug("*** unhide token#2 (json->json)") form_dict = {} form_dict['PathOrToken'] = token2 - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "unhide", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "unhide", 200, "owner:ownerpw", form_dict) result = json.loads(answer) assert "success" in result['Status'] logging.debug("*** lookup token#2 (json->json) -> check for not hidden") form_dict = {} form_dict['PathOrToken'] = token2 - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) result = json.loads(answer) assert "success" in result['Status'] assert result['Lines'] == 1 @@ -364,20 +355,14 @@ def test_sharing_api_token_basic(self) -> None: logging.debug("*** delete token#2 (json->json)") form_dict = {} form_dict['PathOrToken'] = token2 - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "delete", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", form_dict) result = json.loads(answer) assert "success" in result['Status'] logging.debug("*** lookup token#2 (json->json) -> should not be there anymore") form_dict = {} form_dict['PathOrToken'] = token2 - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) result = json.loads(answer) assert "not-found" in result['Status'] assert result['Lines'] == 0 @@ -420,8 +405,17 @@ def test_sharing_api_token_usage(self) -> None: token = match[1] logging.debug("received token %r", token) + logging.debug("*** enable token (form->text)") + form_array: str = [] + form_array.append("PathOrToken=" + token) + data = "\n".join(form_array) + content_type = "application/x-www-form-urlencoded" + _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("received answer %r", answer) + assert "Status=success" in answer + logging.debug("*** fetch collection using invalid token (without credentials)") - _, headers, answer = self.request("GET", path_token + "v1/invalidtoken", check=404) + _, headers, answer = self.request("GET", path_token + "v1/invalidtoken", check=401) logging.debug("*** fetch collection using token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=200) @@ -437,7 +431,7 @@ def test_sharing_api_token_usage(self) -> None: assert "Status=success" in answer logging.debug("*** fetch collection using disabled token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=404) + _, headers, answer = self.request("GET", path_token + token, check=401) logging.debug("*** enable token (form->text)") form_array: str = [] @@ -463,7 +457,7 @@ def test_sharing_api_token_usage(self) -> None: assert "success" in result['Status'] logging.debug("*** fetch collection using deleted token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=404) + _, headers, answer = self.request("GET", path_token + token, check=401) def test_sharing_api_map_basic(self) -> None: """share-by-map API basic tests.""" @@ -552,6 +546,30 @@ def test_sharing_api_map_usage(self) -> None: assert "owner" in result['Content'][0]['Owner'] assert "user" in result['Content'][0]['User'] + logging.debug("*** enable map by owner (json->json)") + form_dict = {} + form_dict['User'] = "owner" + form_dict['PathMapped'] = path_mapped + form_dict['PathOrToken'] = path_share + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + + logging.debug("*** enable map by user (json->json)") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathMapped'] = path_mapped + form_dict['PathOrToken'] = path_share + data = json.dumps(form_dict) + content_type = "application/json" + _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("user", "userpw"), data=data, content_type=content_type, accept=content_type) + logging.debug("received answer %r", answer) + result = json.loads(answer) + assert "success" in result['Status'] + logging.debug("*** fetch collection (without credentials)") _, headers, answer = self.request("GET", path_mapped, check=401) From 50439c4fc1defe7ec717cb0af139c4018a3e6187 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 06:28:46 +0100 Subject: [PATCH 028/138] align mapping call --- radicale/app/get.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 43b0484a9..6ebba4707 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -78,18 +78,15 @@ def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, return self._web.get(environ, base_prefix, path, user) # Sharing by token or map result = self._sharing.sharing_collection_resolver(path, user) - if result is None: - return httputils.NOT_FOUND + if result: + # overwrite and run through extended permission check + path = result['PathMapped'] + user = result['Owner'] + permissions_filter = result['Permissions'] + access = Access(self._rights, user, path, permissions_filter) else: - if result['mapped']: - # overwrite and run through extended permission check - path = result['path'] - user = result['user'] - permissions_filter = result['permissions'] - access = Access(self._rights, user, path, permissions_filter) - else: - # default permission check - access = Access(self._rights, user, path) + # default permission check + access = Access(self._rights, user, path) if not access.check("r") and "i" not in access.permissions: return httputils.NOT_ALLOWED with self._storage.acquire_lock("r", user): From 649a64399518330a5929451e99fa6cac16a9048f Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 06:31:12 +0100 Subject: [PATCH 029/138] reorg/cleanup --- radicale/sharing/csv.py | 375 ++++++++++++++++------------------------ 1 file changed, 152 insertions(+), 223 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 7dc198361..b822eea3d 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -76,88 +76,101 @@ def get_database_info(self) -> [dict | None]: database_info = {'type': "csv"} return database_info - def get_sharing_collection_by_token(self, token: str) -> [dict | None]: - """ retrieve target and attributes by token """ + def get_sharing(self, + ShareType: str, + PathOrToken: str, + User: [str | None ] = None) -> [dict | None]: + """ retrieve sharing target and attributes by map """ + # Lookup for row in self._map_cache: - if row['Type'] != "token": + if row['ShareType'] != ShareType: continue - if row['PathOrToken'] != token: + if row['PathOrToken'] != PathOrToken: + continue + if User and row['User'] != User: continue if row['EnabledByOwner'] != str(True): continue if row['EnabledByUser'] != str(True): continue - path_mapped = row['PathMapped'] - user = row['User'] - permissions = row['Permissions'] - logger.debug("TRACE/sharing_by_token: map %r to %r (user=%r, permissions=%r)", token, path_mapped, user, permissions) - return {"mapped": True, "path": path_mapped, "user": user, "permissions": permissions} - - # default - logger.debug("TRACE/sharing_by_token: no entry in map found for token: %r", token) + PathMapped = row['PathMapped'] + Owner = row['Owner'] + User = row['User'] + Permissions = row['Permissions'] + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r)", PathOrToken, PathMapped, Owner, User, Permissions) + return { + "mapped": True, + "PathOrToken": PathOrToken, + "PathMapped": PathMapped, + "Owner": Owner, + "User": User, + "Permissions": Permissions} return None - def get_sharing_collection_by_map(self, path: str, user: str) -> [dict | None]: - """ retrieve target and attributes by map """ + def list_sharing(self, + ShareType: [str | None] = None, + PathOrToken: [str | None] = None, PathMapped: [str | None] = None, + Owner: [str | None] = None, User: [str | None] = None) -> bool: + """ retrieve sharing """ + result = [] for row in self._map_cache: - if row['Type'] != "map": - continue - if row['PathOrToken'] != path: - continue - if row['EnabledByOwner'] != str(True): + if ShareType and row['ShareType'] != ShareType: continue - if row['EnabledByUser'] != str(True): - continue - if row['User'] != user: + if Owner and row['Owner'] != Owner: continue - # TODO: handle "hidden" - path_mapped = row['PathMapped'] - user = row['Owner'] - permissions = row['Permissions'] - logger.debug("TRACE/sharing_by_map: map %r to %r (user=%r, permissions=%r)", path, path_mapped, user, permissions) - return {"mapped": True, "path": path_mapped, "user": user, "permissions": permissions} - - # default - logger.debug("TRACE/sharing_by_map: no entry in map found for path: %r", path) - return {"mapped": False} - - def get_sharing_list_by_type_user(self, share_type, user, path_token = None) -> [dict | None]: - """ retrieve sharing list by type and user """ - result = [] - for row in self._map_cache: - if share_type != "*" and row['Type'] != share_type: + if User and row['User'] != User: continue - if row['Owner'] != user: + if PathOrToken and row['PathOrToken'] != PathOrToken: continue - if path_token and row['PathOrToken'] != path_token: + if PathMapped and row['PathMapped'] != PathMapped: continue result.append(row) return result - def create_sharing_by_token(self, user: str, token: str, path_mapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: - """ create sharing by token """ - logger.debug("TRACE/sharing_by_token/create: user=%r token=%r path_mapped=%r permissions=%r enabled=%s", user, token, path_mapped, permissions, enabled) - # check for duplicate token - for row in self._map_cache: - if row['Type'] != "token": - continue - if row['PathOrToken'] == token: - logger.warning("sharing/add_sharing_by_token: token already exists: user=%r token=%r path_mapped=%r", user, token, path_mapped) - return False - row = { "Type": "token", - "PathOrToken": token, - "PathMapped": path_mapped, - "Owner": user, - "User": user, - "Permissions": permissions, - "EnabledByOwner": str(enabled), - "EnabledByUser": str(True), - "HiddenByOwner": str(False), - "HiddenByUser": str(False), - "TimestampCreated": str(timestamp), - "TimestampUpdated": str(timestamp) + def create_sharing(self, + ShareType: str, + PathOrToken: str, PathMapped: str, + Owner: str, User: str, + Permissions: str = "r", + EnabledByOwner: bool = False, EnabledByUser: bool = False, + HiddenByOwner: bool = True , HiddenByUser: bool = True, + Timestamp: int = 0) -> bool: + """ create sharing """ + if ShareType == "token": + logger.debug("TRACE/sharing/token/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) + # check for duplicate token entry + for row in self._map_cache: + if row['ShareType'] != "token": + continue + if row['PathOrToken'] == PathOrToken: + # must be unique systemwide + logger.error("sharing/token/create: PathOrToken already exists: PathOrToken=%r", PathOrToken) + return False + elif ShareType == "map": + logger.debug("TRACE/sharing/map/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) + # check for duplicate map entry + for row in self._map_cache: + if row['ShareType'] != "map": + continue + if row['PathMapped'] == PathMapped and row['User'] == User: + # must be unique systemwide + logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User, Permissions) + return False + + row = { "ShareType": ShareType, + "PathOrToken": PathOrToken, + "PathMapped": PathMapped, + "Owner": Owner, + "User": User, + "Permissions": Permissions, + "EnabledByOwner": str(EnabledByOwner), + "EnabledByUser": str(EnabledByUser), + "HiddenByOwner": str(HiddenByOwner), + "HiddenByUser": str(HiddenByUser), + "TimestampCreated": str(Timestamp), + "TimestampUpdated": str(Timestamp) } - logger.debug("TRACE/sharing_by_token: add row: %r", row) + logger.debug("TRACE/sharing/*/create: add row: %r", row) # TODO: add locking self._map_cache.append(row) if self._write_csv(self._sharing_db_file): @@ -166,75 +179,48 @@ def create_sharing_by_token(self, user: str, token: str, path_mapped: str, times logger.warning("sharing/add_sharing_by_token: cannot update CSV database") return False - def delete_sharing_by_token(self, user: str, token: str) -> [dict | None]: - """ delete sharing by token """ - logger.debug("TRACE/sharing_by_token/delete: user=%r token=%r", user, token) - # lookup token - token_found = False - index = 0 - for row in self._map_cache: - if row['Type'] != "token": - pass - if row['PathOrToken'] != token: - pass - else: - token_found = True - break - index += 1 - - if token_found: - if row['Owner'] != user: - return {"status": "permission-denied"} - logger.debug("TRACE/sharing_by_token/delete: user=%r token=%r index=%d", user, token, index) - self._map_cache.pop(index) - - # TODO: add locking - if self._write_csv(self._sharing_db_file): - logger.debug("TRACE/sharing_by_token: write CSV done") - return {"status": "success"} - logger.warning("sharing/sharing_by_token: cannot update CSV database") - return {"status": "error"} - - return {"status": "not-found"} - - - def toggle_sharing_by_token(self, user: str, token: str, toggle: str, timestamp: int) -> [dict | None]: - """ toggle sharing by token """ - logger.debug("TRACE/sharing_by_token/" + toggle + ": user=%r token=%r", user, token) - if toggle not in sharing.API_SHARE_TOGGLES_V1: - return False + def delete_sharing(self, + ShareType: str, + PathOrToken: str, Owner: str, + PathMapped: [str | None] = None, + User: [str | None] = None) -> [dict | None]: + """ delete sharing """ + if ShareType == "token": + logger.debug("TRACE/sharing/token/delete: PathOrToken=%r Owner=%r", PathOrToken, Owner) + elif ShareType == "map": + logger.debug("TRACE/sharing/map/delete: PathOrToken=%r Owner=%r PathMapped=%r User=%r", PathOrToken, Owner, PathMapped, User) + else: + raise # should not be reached # lookup token - token_found = False + found = False index = 0 for row in self._map_cache: - if row['Type'] != "token": + if row['ShareType'] != ShareType: pass - if row['PathOrToken'] != token: + elif row['PathOrToken'] != PathOrToken: pass else: - token_found = True - break + if ShareType == "map": + # extra filter + if row['PathMapped'] != PathMapped: + pass + elif row['User'] != User: + pass + else: + found = True + break + else: + found = True + break index += 1 - if token_found: - if row['Owner'] != user: + if found: + logger.debug("TRACE/sharing/*/delete: found index=%d", index) + if row['Owner'] != Owner: return {"status": "permission-denied"} - logger.debug("TRACE/sharing_by_token/" + toggle + ": user=%r token=%r index=%d", user, token, index) - - if toggle == "disable": - row['EnabledByOwner'] = str(False) - elif toggle == "enable": - row['EnabledByOwner'] = str(True) - elif toggle == "hide": - row['HiddenByOwner'] = str(True) - elif toggle == "unhide": - row['HiddenByOwner'] = str(False) - row['TimestampUpdated'] = str(timestamp) - # remove + logger.debug("TRACE/sharing/*/delete: Owner=%r PathOrToken=%r index=%d", Owner, PathOrToken, index) self._map_cache.pop(index) - # readd - self._map_cache.append(row) # TODO: add locking if self._write_csv(self._sharing_db_file): @@ -242,126 +228,69 @@ def toggle_sharing_by_token(self, user: str, token: str, toggle: str, timestamp: return {"status": "success"} logger.warning("sharing/sharing_by_token: cannot update CSV database") return {"status": "error"} + else: + return {"status": "not-found"} + + def toggle_sharing(self, + ShareType: str, + PathOrToken: str, + OwnerOrUser: str, + Action: str, + PathMapped: [str | None] = None, + User: [str | None] = None, + Timestamp: int = 0) -> [dict | None]: + """ toggle sharing """ + if Action not in sharing.API_SHARE_TOGGLES_V1: + return False - return {"status": "not-found"} - + logger.debug("TRACE/sharing/*/" + Action + ": OwnerOrUser=%r PathOrToken=%r Action=%r", OwnerOrUser, PathOrToken, Action) - # sharing by map - def create_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: - """ create sharing by map """ - logger.debug("TRACE/sharing_by_map/create: %r of %r mapped to %r of %r permissions=%r enabled=%s", user_share, path_share, user, path_mapped, permissions, enabled) - # check for duplicate token - for row in self._map_cache: - if row['Type'] != "map": - continue - if row['PathOrToken'] == path_share and row['User'] == user_share and row['PathMapped'] == path_mapped: - logger.warning("sharing/add_sharing_by_map: already exists: %r of %r mapped to %r of %r", user_share, path_share, user, path_mapped) - return False - row = {"Type": "map", - "PathOrToken": path_share, - "PathMapped": path_mapped, - "Owner": user, - "User": user_share, - "Permissions": permissions, - "EnabledByOwner": str(enabled), - "EnabledByUser": str(True), - "HiddenByOwner": str(False), - "HiddenByUser": str(False), - "TimestampCreated": str(timestamp), - "TimestampUpdated": str(timestamp), - } - logger.debug("TRACE/sharing_by_map: add row: %r", row) - # TODO: add locking - self._map_cache.append(row) - if self._write_csv(self._sharing_db_file): - logger.debug("TRACE/sharing_by_token: write CSV done") - return True - logger.warning("sharing/add_sharing_by_token: cannot update CSV database") - return False - - def delete_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str) -> [dict | None]: - """ delete sharing by map """ - logger.debug("TRACE/sharing_by_map/delete: user=%r path_share=%r", user, path_share) - # lookup token - token_found = False + # lookup entry + found = False index = 0 for row in self._map_cache: - if row['Type'] != "map": + if row['ShareType'] != ShareType: pass - if row['PathOrToken'] == path_share and row['User'] == user_share and row['PathMapped'] == path_mapped: - token_found = True - break - else: - pass - index += 1 - - if token_found: - if row['Owner'] != user: - return {"status": "permission-denied"} - logger.debug("TRACE/sharing_by_map/delete: user=%r path_share=%r index=%d", user, path_share, index) - self._map_cache.pop(index) - - # TODO: add locking - if self._write_csv(self._sharing_db_file): - logger.debug("TRACE/sharing_by_token: write CSV done") - return {"status": "success"} - logger.warning("sharing/sharing_by_token: cannot update CSV database") - return {"status": "error"} - - return {"status": "not-found"} - - def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, user_share: str, toggle: str, timestamp: int) -> [dict | None]: - """ toggle sharing by map """ - logger.debug("TRACE/sharing_by_map/" + toggle + ": user=%r path_share=%r path_mapped=%r user_share=%r", user, path_share, path_mapped, user_share) - if toggle not in sharing.API_SHARE_TOGGLES_V1: - return False - - # lookup token - token_found = False - index = 0 - for row in self._map_cache: - if row['Type'] != "map": + if row['PathOrToken'] != PathOrToken: pass - elif row['PathOrToken'] == path_share and row['User'] == user_share and row['PathMapped'] == path_mapped: - if row['Owner'] == user or row['User'] == user: - token_found = True - break - else: - pass else: - pass + found = True + break index += 1 - if token_found: - if row['Owner'] == user and row['User'] == user_share: - # owner-triggered toggle + if found: + if row['Owner'] == OwnerOrUser: pass - elif row['User'] == user: - # user-triggered toggle + elif row['User'] == OwnerOrUser: pass else: return {"status": "permission-denied"} - logger.debug("TRACE/sharing_by_token/" + toggle + ": user=%r path_share=%r index=%d", user, path_share, index) - if row['Owner'] == user: - if toggle == "disable": + # TODO: locking + if row['Owner'] == OwnerOrUser: + logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": Owner=%r PathOrToken=%r index=%d", OwnerOrUser, PathOrToken, index) + if Action == "disable": row['EnabledByOwner'] = str(False) - elif toggle == "enable": + elif Action == "enable": row['EnabledByOwner'] = str(True) - elif toggle == "hide": + elif Action == "hide": row['HiddenByOwner'] = str(True) - elif toggle == "unhide": + elif Action == "unhide": row['HiddenByOwner'] = str(False) - elif row['User'] == user: - if toggle == "disable": + row['TimestampUpdated'] = str(Timestamp) + if row['User'] == OwnerOrUser: + logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": User=%r PathOrToken=%r index=%d", OwnerOrUser, PathOrToken, index) + if Action == "disable": row['EnabledByUser'] = str(False) - elif toggle == "enable": + elif Action == "enable": row['EnabledByUser'] = str(True) - elif toggle == "hide": + elif Action == "hide": row['HiddenByUser'] = str(True) - elif toggle == "unhide": + elif Action == "unhide": row['HiddenByUser'] = str(False) - row['TimestampUpdated'] = str(timestamp) + + row['TimestampUpdated'] = str(Timestamp) + # remove self._map_cache.pop(index) # readd @@ -369,12 +298,12 @@ def toggle_sharing_by_map(self, user: str, path_share: str, path_mapped: str, us # TODO: add locking if self._write_csv(self._sharing_db_file): - logger.debug("TRACE/sharing_by_token: write CSV done") + logger.debug("TRACE: write CSV done") return {"status": "success"} - logger.warning("sharing/add_sharing_by_token: cannot update CSV database") + logger.error("sharing: cannot update CSV database") return {"status": "error"} - - return {"status": "not-found"} + else: + return {"status": "not-found"} # local functions def _create_empty_csv(self, file) -> bool: From 162ff3b87e24f484e07e02bc18dee919d678f69b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 06:32:29 +0100 Subject: [PATCH 030/138] reorg/cleanup --- radicale/sharing/__init__.py | 254 ++++++++++++++++++++--------------- 1 file changed, 148 insertions(+), 106 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index bcfd3144a..693748181 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -32,17 +32,17 @@ INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "none") -DB_FIELDS_V1: Sequence[str] = ('Type', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') -# Type: +DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') +# ShareType: # PathOrToken: # PathMapped: # Owner: (creator of database entry) # User: (user of database entry) # Permissions: # EnabledByOwner: True|False (share status "invite/grant") -# EnabledByUser: True|False (share status "accept") +# EnabledByUser: True|False (share status "accept") - check skipped of Owner==User # HiddenByOwner: True|False (share exposure controlled by owner) -# HiddenByUser: True|False (share exposure controlled by user) +# HiddenByUser: True|False (share exposure controlled by user) - check skipped if Owner==User # TimestampCreated: (when created) # TimestampUpdated: (last update) @@ -101,7 +101,7 @@ def __init__(self, configuration: "config.Configuration") -> None: else: logger.info("sharing database info: (not provided)") - ## overloadable functions + # overloadable functions def init_database(self) -> bool: """ initialize database """ return None @@ -110,44 +110,52 @@ def get_database_info(self) -> [ dict | None]: """ retrieve database information """ return None - def get_sharing_collection_by_token(self, token: str) -> [dict | None]: - """ retrieve target and attributes by token """ + def list_sharing(self, + ShareType: [str | None] = None, + PathOrToken: [str | None ] = None, PathMapped: [str | None ] = None, + Owner: [str | None ] = None, User: [str | None ] = None) -> bool: + """ retrieve sharing """ return None - def get_sharing_collection_by_map(self, path: str, user: str) -> [dict | None]: - """ retrieve target and attributes by map """ + def get_sharing(self, + ShareType: str, + PathOrToken: str, + User: [str | None ] = None) -> [dict | None]: + """ retrieve sharing target and attributes by map """ return None - def get_sharing_list_by_type_user(self, share_type, user, path_token = None) -> [dict | None]: - """ retrieve sharing list by type and user (path_token optional)""" + def create_sharing(self, + ShareType: str, + PathOrToken: str, PathMapped: str, + Owner: str, User: str, + Permissions: str = "r", + EnabledByOwner: bool = False, EnabledByUser: bool = False, + HiddenByOwner: bool = True , HiddenByUser: bool = True, + Timestamp: int = 0) -> bool: + """ create sharing """ return None - def create_sharing_by_token(self, user: str, token: str, PathMapped: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: - """ create sharing by token """ + def delete_sharing(self, + ShareType: str, + PathOrToken: str, + Owner: str, + PathMapped: [str | None] = None, + User: [str | None] = None) -> [dict | None]: + """ delete sharing """ return None - def create_sharing_by_map(self, user: str, path_share: str, PathMapped: str, user_share: str, timestamp: int, permissions: str = "r", enabled: bool = True) -> bool: - """ create sharing by token """ + def toggle_sharing(self, + ShareType: str, + PathOrToken: str, + OwnerOrUser: str, + Action: str, + PathMapped: [str | None] = None, + User: [str | None] = None, + Timestamp: int = 0) -> [dict | None]: + """ toggle sharing """ return None - def delete_sharing_by_token(self, user: str, token: str) -> [dict | None]: - """ delete sharing by token """ - return None - - def delete_sharing_by_map(self, user: str, path_share: str, PathMapped: str, user_share: str) -> [dict | None]: - """ delete sharing by token """ - return None - - def toggle_sharing_by_token(self, user: str, token: str, toggle: str, timestamp: int) -> [dict | None]: - """ toggle sharing by token """ - return None - - def toggle_sharing_by_map(self, user: str, path_share: str, PathMapped: str, user_share: str, toggle: str, timestamp: int) -> [dict | None]: - """ toggle sharing by map """ - return None - - - ## static sharing functions + # static sharing functions def sharing_collection_resolver(self, path:str, user: str) -> [dict | None]: if self.sharing_collection_by_token: result = self.sharing_collection_by_token_resolver(path) @@ -183,7 +191,9 @@ def sharing_collection_by_token_resolver(self, path) -> [dict | None]: else: # TODO add token validity checks logger.debug("TRACE/sharing_by_token: supported token found in path: %r (token=%r)", path, match[1]) - return self.get_sharing_collection_by_token(match[1]) + return self.get_sharing( + ShareType="token", + PathOrToken=match[1]) else: logger.debug("TRACE/sharing_by_token: no supported prefix found in path: %r", path) return {"mapped": False} @@ -194,8 +204,11 @@ def sharing_collection_by_token_resolver(self, path) -> [dict | None]: def sharing_collection_by_map_resolver(self, path: str, user: str) -> [dict | None]: """ returning dict with mapped-flag, path, user, rights or None if invalid""" if self.sharing_collection_by_map: - logger.debug("TRACE/sharing_by_map: check path: %r", path) - return self.get_sharing_collection_by_map(path, user) + logger.debug("TRACE/sharing/resolver/map: check path: %r", path) + return self.get_sharing( + ShareType="map", + PathOrToken=path, + User=user) else: logger.debug("TRACE/sharing_by_map: not active") return {"mapped": False} @@ -216,7 +229,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st action: (token|map)/create PathMapped: (mandatory) - Permissions: (default: r) + Permissions: (default: r) token -> returns @@ -235,10 +248,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st Response: output format depending on ACCEPT header action: list - by user-owned filtered sharing list in CSV/JSON + by user-owned filtered sharing list in CSV/JSON/TEXT actions: (other) - Status + Status in JSON/TEXT (TEXT can be parsed by shell) """ if not self.sharing_collection_by_map and not self.sharing_collection_by_token: @@ -253,29 +266,29 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if not path.startswith("/.sharing/v1/"): return httputils.NOT_FOUND - # split into share_type and action - share_type_action = path.removeprefix("/.sharing/v1/") - match = re.search('([a-z]+)/([a-z]+)$', share_type_action) + # split into ShareType and action + ShareType_action = path.removeprefix("/.sharing/v1/") + match = re.search('([a-z]+)/([a-z]+)$', ShareType_action) if not match: - logger.debug("TRACE/sharing/API: share_type/action not extractable: %r", share_type_action) + logger.debug("TRACE/sharing/API: ShareType/action not extractable: %r", ShareType_action) return httputils.NOT_FOUND - share_type = match.group(1) + ShareType = match.group(1) action = match.group(2) - # check for valid share_types - if share_type: - if not share_type in SHARE_TYPES: - logger.debug("TRACE/sharing/API: share_type not whitelisted: %r", share_type) + # check for valid ShareTypes + if ShareType: + if not ShareType in SHARE_TYPES: + logger.debug("TRACE/sharing/API: ShareType not whitelisted: %r", ShareType) return httputils.NOT_FOUND - # check for enabled share_types - if not self.sharing_collection_by_map and share_type == "map": - # API is not enabled + # check for enabled ShareTypes + if not self.sharing_collection_by_map and ShareType == "map": + # API "map" is not enabled return httputils.NOT_FOUND - if not self.sharing_collection_by_token and share_type == "token": - # API is not enabled + if not self.sharing_collection_by_token and ShareType == "token": + # API "token" is not enabled return httputils.NOT_FOUND # check for valid API hooks @@ -294,7 +307,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT - api_info = "sharing/API/POST/" + share_type + "/" + action + api_info = "sharing/API/POST/" + ShareType + "/" + action # parse body according to content-type content_type = environ.get("CONTENT_TYPE", "") @@ -337,10 +350,13 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # parameters default PathOrToken: [str | None] = None PathMapped: [str | None] = None - permissions: [str | None] = None - user_share: [str | None] = None - EnabledByOwner: False # security by default - HiddenByOwner: True # security by default + Owner: [str | None] = user + User: [str | None] = None + Permissions: [str | None] = None + EnabledByOwner: bool = False # security by default + HiddenByOwner: bool = True # security by default + EnabledByUser: bool = False # security by default + HiddenByUser: bool = True # security by default # parameters sanity check for key in request_data: @@ -348,11 +364,11 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if not re.search('^[a-zA-Z]+$', request_data[key]): return httputils.BAD_REQUEST elif key == "PathOrToken": - if share_type == "token": + if ShareType == "token": if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]): logger.error(api_info + ": unsupported " + key) return httputils.BAD_REQUEST - elif share_type == "map": + elif ShareType == "map": if not re.search('^' + PATH_PATTERN + '$', request_data[key]): logger.error(api_info + ": unsupported " + key) return httputils.BAD_REQUEST @@ -372,7 +388,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # check for mandatory parameters if 'PathMapped' not in request_data: if action != 'list': - if share_type == "token" and action != 'create': + if ShareType == "token" and action != 'create': # optinoal pass else: @@ -392,13 +408,13 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # optional pass else: - if action == "create" and share_type == "token": + if action == "create" and ShareType == "token": # not supported logger.error(api_info + ": PathOrToken found but not supported") return httputils.BAD_REQUEST PathOrToken = request_data['PathOrToken'] - if share_type == "map": + if ShareType == "map": if 'User' not in request_data: if action != "list": logger.warning(api_info + ": missing User") @@ -407,20 +423,22 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # optional pass else: - user_share = request_data['User'] + User = request_data['User'] answer: dict = {} answer['ApiVersion'] = "1" - timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) + Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) ## action: list if action == "list": logger.debug("TRACE/" + api_info + ": start") - path_token_filter = None if 'PathOrToken' in request_data: - path_token_filter = request_data['PathOrToken'] - logger.debug("TRACE/" + api_info + ": filter: %r", path_token_filter) - result = self.get_sharing_list_by_type_user(share_type, user, PathOrToken) + PathOrToken = request_data['PathOrToken'] + logger.debug("TRACE/" + api_info + ": filter: %r", PathOrToken) + result = self.list_sharing( + ShareType=ShareType, + Owner=Owner, + PathOrToken=PathOrToken) if not result: answer['Lines'] = 0 answer['Status'] = "not-found" @@ -433,46 +451,56 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif action == "create": logger.debug("TRACE/" + api_info + ": start") if not 'Permissions' in request_data: - permissions = "r" + Permissions = "r" else: - permissions = request_data['Permissions'] + Permissions = request_data['Permissions'] - if not 'EnabledByOwner' in request_data: - enabled = True - else: - enabled = config._convert_to_bool(request_data['EnabledByOwner']) + if 'Enabled' in request_data: + EnabledByOwner = config._convert_to_bool(request_data['EnabledByOwner']) - if share_type == "token": - # check access permissions + if ShareType == "token": + # check access Permissions access = Access(self._rights, user, PathMapped) - if not access.check("r") and "i" not in access.permissions: + if not access.check("r") and "i" not in access.Permissions: logger.info("Add sharing-by-token: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED ## v1: create uuid token with 2x 32 bytes = 256 bit token = "v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') - logger.debug("TRACE/" + api_info + ": %r (permissions=%r token=%r)", PathMapped, permissions, token) + logger.debug("TRACE/" + api_info + ": %r (Permissions=%r token=%r)", PathMapped, Permissions, token) - if not self.create_sharing_by_token(user, token, PathMapped, timestamp, permissions, enabled): + if not self.create_sharing( + ShareType=ShareType, + PathOrToken=token, PathMapped=PathMapped, + Owner=Owner, User=Owner, + Permissions=Permissions, + EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, + Timestamp=Timestamp): logger.info("Add sharing-by-token: %r by user %s not successful", PathMapped, user) return httputils.BAD_REQUEST - logger.info(api_info + "(success): %r (permissions=%r token=%r)", PathMapped, permissions, token) + logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token) answer['Status'] = "success" answer['PathOrToken'] = token - elif share_type == "map": - # check access permissions + elif ShareType == "map": + # check access Permissions access = Access(self._rights, user, PathMapped) - if not access.check("r") and "i" not in access.permissions: + if not access.check("r") and "i" not in access.Permissions: logger.info("Add sharing-by-map: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED - logger.debug("TRACE/" + api_info + ": %r (permissions=%r PathOrToken=%r user=%r)", PathMapped, permissions, PathOrToken, user_share) - if not self.create_sharing_by_map(user, PathOrToken, PathMapped, user_share, timestamp, permissions, enabled): - logger.info("Add sharing-by-token: %r by user %s not successful", PathMapped, user) + logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r user=%r)", PathMapped, Permissions, PathOrToken, User) + if not self.create_sharing( + ShareType=ShareType, + PathOrToken=PathOrToken, PathMapped=PathMapped, + Owner=Owner, User=User, + Permissions=Permissions, + EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, + Timestamp=Timestamp): + logger.error(api_info + ": %r (%r) -> %r (%r)", PathMapped, User, PathOrToken, Owner) return httputils.BAD_REQUEST answer['Status'] = "success" @@ -481,11 +509,19 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif action == "delete": logger.debug("TRACE/" + api_info + ": start") - if share_type == "token": - result = self.delete_sharing_by_token(user, request_data['PathOrToken']) + if ShareType == "token": + result = self.delete_sharing( + ShareType=ShareType, + PathOrToken=PathOrToken, + Owner=Owner) - elif share_type == "map": - result = self.delete_sharing_by_map(user, request_data['PathOrToken'], request_data['PathMapped'], request_data['User']) + elif ShareType == "map": + result = self.delete_sharing( + ShareType=ShareType, + PathOrToken=PathOrToken, + PathMapped=PathMapped, + Owner=Owner, + User=User) ## result handling if result['status'] == "not-found": @@ -496,9 +532,9 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer['Status'] = "success" pass else: - if share_type == "token": + if ShareType == "token": logger.info("Delete sharing-by-token: %r of user %r not successful", token, user) - elif share_type == "map": + elif ShareType == "map": logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) return httputils.BAD_REQUEST @@ -506,11 +542,12 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif action in API_SHARE_TOGGLES_V1: logger.debug("TRACE/sharing/API/POST/" + action) - if share_type == "token": - result = self.toggle_sharing_by_token(user, request_data['PathOrToken'], action, timestamp) - - elif share_type == "map": - result = self.toggle_sharing_by_map(user, request_data['PathOrToken'], request_data['PathMapped'], request_data['User'], action, timestamp) + result = self.toggle_sharing( + ShareType=ShareType, + PathOrToken=PathOrToken, + OwnerOrUser=user, # authenticated user + Action=action, + Timestamp=Timestamp) if result: if result['status'] == "not-found": @@ -521,10 +558,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer['Status'] = "success" pass else: - if share_type == "token": - logger.info("Delete sharing-by-token: %r of user %s not successful", request_data['PathOrToken'], user) - elif share_type == "map": - logger.info("Delete sharing-by-map: %r of user %s not successful", request_data['PathOrToken'], user) + logger.error("Toggle sharing: %r of user %s not successful", request_data['PathOrToken'], user) return httputils.BAD_REQUEST else: @@ -535,9 +569,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("TRACE/sharing/API/POST output format: %r", output_format) if output_format == "csv" or output_format == "txt": answer_array = [] - for key in answer: - if key != 'Content': - answer_array.append('# ' + key + '=' + str(answer[key])) + if output_format == "txt": + for key in answer: + if key != 'Content': + answer_array.append(key + '=' + str(answer[key])) if 'Content' in answer and answer['Content'] is not None: csv = io.StringIO() writer = DictWriter(csv, fieldnames=DB_FIELDS_V1) @@ -545,7 +580,14 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st writer.writeheader() for entry in answer['Content']: writer.writerow(entry) - answer_array.append(csv.getvalue()) + if output_format == "csv": + answer_array.append(csv.getvalue()) + else: + index = 0 + for line in csv.getvalue().splitlines(): + # create a shell array with content lines + answer_array.append('Content[' + str(index) + ']="' + line + '"') + index += 1 headers = { "Content-Type": "text/csv" } From 0055df9078afac969977129c15367e71b4cb6365 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:12:39 +0100 Subject: [PATCH 031/138] add missing prototype --- radicale/sharing/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 693748181..2afd7d51a 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -546,6 +546,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st ShareType=ShareType, PathOrToken=PathOrToken, OwnerOrUser=user, # authenticated user + User=User, # provided user for selection Action=action, Timestamp=Timestamp) From 008f301a52939e412a261723897ea26fd8fff0e3 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:13:04 +0100 Subject: [PATCH 032/138] fix logic --- radicale/sharing/csv.py | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index b822eea3d..fb82491dd 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -85,13 +85,13 @@ def get_sharing(self, for row in self._map_cache: if row['ShareType'] != ShareType: continue - if row['PathOrToken'] != PathOrToken: + elif row['PathOrToken'] != PathOrToken: continue - if User and row['User'] != User: + elif User and row['User'] != User: continue - if row['EnabledByOwner'] != str(True): + elif row['EnabledByOwner'] != str(True): continue - if row['EnabledByUser'] != str(True): + elif row['EnabledByUser'] != str(True): continue PathMapped = row['PathMapped'] Owner = row['Owner'] @@ -116,13 +116,13 @@ def list_sharing(self, for row in self._map_cache: if ShareType and row['ShareType'] != ShareType: continue - if Owner and row['Owner'] != Owner: + elif Owner and row['Owner'] != Owner: continue - if User and row['User'] != User: + elif User and row['User'] != User: continue - if PathOrToken and row['PathOrToken'] != PathOrToken: + elif PathOrToken and row['PathOrToken'] != PathOrToken: continue - if PathMapped and row['PathMapped'] != PathMapped: + elif PathMapped and row['PathMapped'] != PathMapped: continue result.append(row) return result @@ -215,7 +215,7 @@ def delete_sharing(self, break index += 1 - if found: + if found == True: logger.debug("TRACE/sharing/*/delete: found index=%d", index) if row['Owner'] != Owner: return {"status": "permission-denied"} @@ -243,23 +243,36 @@ def toggle_sharing(self, if Action not in sharing.API_SHARE_TOGGLES_V1: return False - logger.debug("TRACE/sharing/*/" + Action + ": OwnerOrUser=%r PathOrToken=%r Action=%r", OwnerOrUser, PathOrToken, Action) + logger.debug("TRACE/sharing/*/" + Action + ": ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r Action=%r", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, Action) # lookup entry found = False index = 0 for row in self._map_cache: + logger.debug("TRACE/sharing/*/" + Action + ": check: %r", row) if row['ShareType'] != ShareType: pass - if row['PathOrToken'] != PathOrToken: + elif row['PathOrToken'] != PathOrToken: + pass + elif PathMapped and row['PathMapped'] != PathMapped: pass + elif row['Owner'] == OwnerOrUser: + # owner has requested filter-by-user + if User and row['User'] != User: + pass + else: + found = True + break else: found = True break index += 1 - if found: - if row['Owner'] == OwnerOrUser: + if found == True: + # logger.debug("TRACE/sharing/*/" + Action + ": found: %r", row) + if User and row['User'] != User: + return {"status": "permission-denied"} + elif row['Owner'] == OwnerOrUser: pass elif row['User'] == OwnerOrUser: pass @@ -268,7 +281,7 @@ def toggle_sharing(self, # TODO: locking if row['Owner'] == OwnerOrUser: - logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": Owner=%r PathOrToken=%r index=%d", OwnerOrUser, PathOrToken, index) + logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": Owner=%r User=%r PathOrToken=%r index=%d", OwnerOrUser, User, PathOrToken, index) if Action == "disable": row['EnabledByOwner'] = str(False) elif Action == "enable": From 0c0972a52463b2037dfcf66086c125c675d49b90 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:13:51 +0100 Subject: [PATCH 033/138] code cleanup --- radicale/tests/test_sharing.py | 235 +++++++++++++-------------------- 1 file changed, 92 insertions(+), 143 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index f0db0e4ec..e70464f6d 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -64,6 +64,8 @@ def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: [ if accept_type is None: accept_type = "application/json" _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept_type) + if check == 200 and accept_type == "application/json": + answer = json.loads(answer) return _, headers, answer # Test functions @@ -101,6 +103,7 @@ def test_sharing_api_base_with_auth(self) -> None: "collection_by_map": "True", "collection_by_token": "True"}, "rights": {"type": "owner_only"}}) + # path with no valid API hook for path in ["/.sharing/", "/.sharing/v9/"]: _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) @@ -197,8 +200,6 @@ def test_sharing_api_token_basic(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - sharing_type = "token" - path_base = "/.sharing/v1/" + sharing_type + "/" form_array: Sequence[str] logging.debug("*** create token without PathMapped (form) -> should fail") @@ -210,8 +211,7 @@ def test_sharing_api_token_basic(self) -> None: _, headers, answer = self._sharing_api_json("token", "create", 400, "owner:ownerpw", form_dict) logging.debug("*** create token#1 (form->text)") - form_array = [] - form_array.append("PathMapped=/owner/collection1") + form_array = ["PathMapped=/owner/collection1"] _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "PathOrToken=" in answer @@ -221,8 +221,7 @@ def test_sharing_api_token_basic(self) -> None: logging.debug("received token %r", token1) logging.debug("*** create token#2 (json->text)") - form_dict = {} - form_dict['PathMapped'] = "/owner/collection2" + form_dict = {'PathMapped': "/owner/collection2"} _, headers, answer = self._sharing_api_json("token", "create", 200, "owner:ownerpw", form_dict, "text/plain") assert "Status=success" in answer assert "Token=" in answer @@ -232,29 +231,25 @@ def test_sharing_api_token_basic(self) -> None: logging.debug("received token %r", token2) logging.debug("*** lookup token#1 (form->text)") - form_array = [] - form_array.append("PathOrToken=" + token1) + form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection1" in answer logging.debug("*** lookup token#2 (json->text") - form_dict = {} - form_dict['PathOrToken'] = token2 + form_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict, "text/plain") assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection2" in answer logging.debug("*** lookup token#2 (json->json)") - form_dict = {} - form_dict['PathOrToken'] = token2 + form_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) - result = json.loads(answer) - assert "success" in result['Status'] - assert result['Lines'] == 1 - assert "/owner/collection2" in result['Content'][0]['PathMapped'] + assert "success" in answer['Status'] + assert answer['Lines'] == 1 + assert "/owner/collection2" in answer['Content'][0]['PathMapped'] logging.debug("*** lookup tokens (form->text)") form_array = [] @@ -274,14 +269,12 @@ def test_sharing_api_token_basic(self) -> None: assert "/owner/collection2" in answer logging.debug("*** delete token#1 (form->text)") - form_array = [] - form_array.append("PathOrToken=" + token1) + form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "delete", 200, "owner:ownerpw", form_array) assert "Status=success" in answer logging.debug("*** lookup token#1 (form->text) -> should not be there anymore") - form_array = [] - form_array.append("PathOrToken=" + token1) + form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=not-found" in answer assert "Lines=0" in answer @@ -293,26 +286,22 @@ def test_sharing_api_token_basic(self) -> None: assert "Lines=1" in answer logging.debug("*** disable token#2 (form->text)") - form_array = [] - form_array.append("PathOrToken=" + token2) + form_array = ["PathOrToken=" + token2] _, headers, answer = self._sharing_api_form("token", "disable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer logging.debug("*** lookup token#2 (json->json) -> check for not enabled") - form_dict = {} - form_dict['PathOrToken'] = token2 + form_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) - result = json.loads(answer) - assert "success" in result['Status'] - assert result['Lines'] == 1 - assert "False" in result['Content'][0]['EnabledByOwner'] + assert "success" in answer['Status'] + assert answer['Lines'] == 1 + assert "False" in answer['Content'][0]['EnabledByOwner'] logging.debug("*** enable token#2 (json->json)") form_dict = {} form_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "enable", 200, "owner:ownerpw", form_dict) - result = json.loads(answer) - assert "success" in result['Status'] + assert "success" in answer['Status'] logging.debug("*** lookup token#2 (form->text) -> check for enabled") form_array = [] @@ -340,32 +329,28 @@ def test_sharing_api_token_basic(self) -> None: form_dict = {} form_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "unhide", 200, "owner:ownerpw", form_dict) - result = json.loads(answer) - assert "success" in result['Status'] + assert "success" in answer['Status'] logging.debug("*** lookup token#2 (json->json) -> check for not hidden") form_dict = {} form_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) - result = json.loads(answer) - assert "success" in result['Status'] - assert result['Lines'] == 1 - assert "False" in result['Content'][0]['HiddenByOwner'] + assert "success" in answer['Status'] + assert answer['Lines'] == 1 + assert "False" in answer['Content'][0]['HiddenByOwner'] logging.debug("*** delete token#2 (json->json)") form_dict = {} form_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", form_dict) - result = json.loads(answer) - assert "success" in result['Status'] + assert "success" in answer['Status'] logging.debug("*** lookup token#2 (json->json) -> should not be there anymore") form_dict = {} form_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) - result = json.loads(answer) - assert "not-found" in result['Status'] - assert result['Lines'] == 0 + assert "not-found" in answer['Status'] + assert answer['Lines'] == 0 def test_sharing_api_token_usage(self) -> None: """share-by-token API tests - real usage.""" @@ -380,8 +365,9 @@ def test_sharing_api_token_usage(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - sharing_type = "token" - path_base = "/.sharing/v1/" + sharing_type + "/" + form_array: Sequence[str] + form_dict = dict + path_token = "/.token/" logging.debug("*** prepare and test access") @@ -392,12 +378,9 @@ def test_sharing_api_token_usage(self) -> None: _, headers, answer = self.request("GET", path, check=200, login="%s:%s" % ("owner", "ownerpw")) logging.debug("*** create token") - form_array: str = [] + form_array = [] form_array.append("PathMapped=/owner/calendar.ics") - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "create", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "PathOrToken=" in answer # extract token @@ -406,12 +389,8 @@ def test_sharing_api_token_usage(self) -> None: logging.debug("received token %r", token) logging.debug("*** enable token (form->text)") - form_array: str = [] - form_array.append("PathOrToken=" + token) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "enable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer logging.debug("*** fetch collection using invalid token (without credentials)") @@ -422,24 +401,16 @@ def test_sharing_api_token_usage(self) -> None: assert "UID:event" in answer logging.debug("*** disable token (form->text)") - form_array: str = [] - form_array.append("PathOrToken=" + token) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "disable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "disable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer logging.debug("*** fetch collection using disabled token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=401) logging.debug("*** enable token (form->text)") - form_array: str = [] - form_array.append("PathOrToken=" + token) - data = "\n".join(form_array) - content_type = "application/x-www-form-urlencoded" - _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "enable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer logging.debug("*** fetch collection using token (without credentials)") @@ -447,14 +418,9 @@ def test_sharing_api_token_usage(self) -> None: assert "UID:event" in answer logging.debug("*** delete token (json->json)") - form_dict = {} - form_dict['PathOrToken'] = token - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "delete", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) - result = json.loads(answer) - assert "success" in result['Status'] + form_dict = {'PathOrToken': token} + _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", form_dict) + assert "success" in answer['Status'] logging.debug("*** fetch collection using deleted token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=401) @@ -472,29 +438,22 @@ def test_sharing_api_map_basic(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - sharing_type = "map" - path_base = "/.sharing/v1/" + sharing_type + "/" + form_array: Sequence[str] + form_dict = dict logging.debug("*** create map without PathMapped (json) -> should fail") form_dict = {} - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", form_dict) logging.debug("*** create map without PathMapped but User (json) -> should fail") - form_dict = {} - form_dict['User'] = "user" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + form_dict = {'User': "user"} + _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", form_dict) logging.debug("*** create map without PathMapped but User and PathOrToken (json) -> should fail") form_dict = {} form_dict['User'] = "user" form_dict['PathOrToken'] = "/owner/calendar.ics" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "create", check=400, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", form_dict) def test_sharing_api_map_usage(self) -> None: """share-by-map API usage tests.""" @@ -509,8 +468,9 @@ def test_sharing_api_map_usage(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - sharing_type = "map" - path_base = "/.sharing/v1/" + sharing_type + "/" + form_array: Sequence[str] + form_dict = dict + path_share = "/user/calendar-shared-by-owner.ics" path_mapped = "/owner/calendar.ics" @@ -525,50 +485,55 @@ def test_sharing_api_map_usage(self) -> None: form_dict['User'] = "user" form_dict['PathMapped'] = "/owner/calendar.ics" form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "create", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - result = json.loads(answer) - assert "success" in result['Status'] + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", form_dict) + assert "success" in answer['Status'] logging.debug("*** lookup map without filter (json->json)") form_dict = {} - content_type = "application/json" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "list", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) - result = json.loads(answer) - assert "success" in result['Status'] - assert result['Lines'] == 1 - assert path_share in result['Content'][0]['PathOrToken'] - assert path_mapped in result['Content'][0]['PathMapped'] - assert "owner" in result['Content'][0]['Owner'] - assert "user" in result['Content'][0]['User'] + _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", form_dict) + assert "success" in answer['Status'] + assert answer['Lines'] == 1 + assert path_share in answer['Content'][0]['PathOrToken'] + assert path_mapped in answer['Content'][0]['PathMapped'] + assert "map" in answer['Content'][0]['ShareType'] + assert "owner" in answer['Content'][0]['Owner'] + assert "user" in answer['Content'][0]['User'] + assert "False" in answer['Content'][0]['EnabledByOwner'] + assert "False" in answer['Content'][0]['EnabledByUser'] + assert "True" in answer['Content'][0]['HiddenByOwner'] + assert "True" in answer['Content'][0]['HiddenByUser'] + assert "r" in answer['Content'][0]['Permissions'] logging.debug("*** enable map by owner (json->json)") form_dict = {} form_dict['User'] = "owner" form_dict['PathMapped'] = path_mapped form_dict['PathOrToken'] = path_share - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) - result = json.loads(answer) - assert "success" in result['Status'] + _, headers, answer = self._sharing_api_json("map", "enable", 404, "owner:ownerpw", form_dict) +# assert "success" in answer['Status'] +# assert "True" in answer['Content'][0]['EnabledByOwner'] + + logging.debug("*** enable map by owner for user (json->json)") + form_dict = {} + form_dict['User'] = "user" + form_dict['PathMapped'] = path_mapped + form_dict['PathOrToken'] = path_share + _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", form_dict) logging.debug("*** enable map by user (json->json)") form_dict = {} form_dict['User'] = "user" form_dict['PathMapped'] = path_mapped form_dict['PathOrToken'] = path_share - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("user", "userpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) - result = json.loads(answer) - assert "success" in result['Status'] + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", form_dict) + assert "success" in answer['Status'] + + logging.debug("*** enable map by user for owner (json->json) -> should fail") + form_dict = {} + form_dict['User'] = "owner" + form_dict['PathMapped'] = path_mapped + form_dict['PathOrToken'] = path_share + _, headers, answer = self._sharing_api_json("map", "enable", 403, "user:userpw", form_dict) logging.debug("*** fetch collection (without credentials)") _, headers, answer = self.request("GET", path_mapped, check=401) @@ -587,12 +552,8 @@ def test_sharing_api_map_usage(self) -> None: form_dict['User'] = "user" form_dict['PathMapped'] = "/owner/calendar.ics" form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "disable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) - result = json.loads(answer) - assert "success" in result['Status'] + _, headers, answer = self._sharing_api_json("map", "disable", 200, "owner:ownerpw", form_dict) + assert "success" in answer['Status'] logging.debug("*** fetch collection via map (with credentials) as user -> n/a") _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) @@ -602,12 +563,9 @@ def test_sharing_api_map_usage(self) -> None: form_dict['User'] = "user" form_dict['PathMapped'] = "/owner/calendar.ics" form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "enable", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) + _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", form_dict) logging.debug("received answer %r", answer) - result = json.loads(answer) - assert "success" in result['Status'] + assert "success" in answer['Status'] logging.debug("*** fetch collection via map (with credentials) as user") _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) @@ -617,12 +575,8 @@ def test_sharing_api_map_usage(self) -> None: form_dict['User'] = "user" form_dict['PathMapped'] = "/owner/calendar.ics" form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "disable", check=200, login="%s:%s" % ("user", "userpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) - result = json.loads(answer) - assert "success" in result['Status'] + _, headers, answer = self._sharing_api_json("map", "disable", 200, "user:userpw", form_dict) + assert "success" in answer['Status'] logging.debug("*** fetch collection via map (with credentials) as user -> n/a") _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) @@ -632,10 +586,7 @@ def test_sharing_api_map_usage(self) -> None: form_dict['User'] = "user" form_dict['PathMapped'] = "/owner/calendar.ics" form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - data = json.dumps(form_dict) - content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "delete", check=403, login="%s:%s" % ("user", "userpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_json("map", "delete", 403, "user:userpw", form_dict) logging.debug("*** delete map by owner (json->json) -> ok") form_dict = {} @@ -644,9 +595,7 @@ def test_sharing_api_map_usage(self) -> None: form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" data = json.dumps(form_dict) content_type = "application/json" - _, headers, answer = self.request("POST", path_base + "delete", check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=content_type) - logging.debug("received answer %r", answer) - result = json.loads(answer) - assert "success" in result['Status'] + _, headers, answer = self._sharing_api_json("map", "delete", 200, "owner:ownerpw", form_dict) + assert "success" in answer['Status'] # TODO hide+unhide for REPORT From 7240a1bc69191becd0701ec54f2aad6d2a8623f9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:16:17 +0100 Subject: [PATCH 034/138] flake8 --- radicale/tests/test_sharing.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index e70464f6d..ddf8b889a 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -24,6 +24,8 @@ import os import re +from typing import (Sequence) + from radicale import sharing from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content @@ -438,7 +440,6 @@ def test_sharing_api_map_basic(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - form_array: Sequence[str] form_dict = dict logging.debug("*** create map without PathMapped (json) -> should fail") @@ -468,7 +469,6 @@ def test_sharing_api_map_usage(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - form_array: Sequence[str] form_dict = dict path_share = "/user/calendar-shared-by-owner.ics" @@ -593,8 +593,6 @@ def test_sharing_api_map_usage(self) -> None: form_dict['User'] = "user" form_dict['PathMapped'] = "/owner/calendar.ics" form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - data = json.dumps(form_dict) - content_type = "application/json" _, headers, answer = self._sharing_api_json("map", "delete", 200, "owner:ownerpw", form_dict) assert "success" in answer['Status'] From b306144aa10fca488e86a20c1dc30d0316107565 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:18:58 +0100 Subject: [PATCH 035/138] flake8 --- radicale/sharing/csv.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index fb82491dd..a3cad20e1 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -28,7 +28,7 @@ class Sharing(sharing.BaseSharing): _map_cache = [] _sharing_db_file: str - ## Overloaded functions + # Overloaded functions def init_database(self) -> bool: logger.debug("sharing database initialization for type 'csv'") sharing_db_file = self.configuration.get("sharing", "database_filename") @@ -79,7 +79,7 @@ def get_database_info(self) -> [dict | None]: def get_sharing(self, ShareType: str, PathOrToken: str, - User: [str | None ] = None) -> [dict | None]: + User: [str | None] = None) -> [dict | None]: """ retrieve sharing target and attributes by map """ # Lookup for row in self._map_cache: @@ -108,7 +108,7 @@ def get_sharing(self, return None def list_sharing(self, - ShareType: [str | None] = None, + ShareType: [str | None] = None, PathOrToken: [str | None] = None, PathMapped: [str | None] = None, Owner: [str | None] = None, User: [str | None] = None) -> bool: """ retrieve sharing """ @@ -133,7 +133,7 @@ def create_sharing(self, Owner: str, User: str, Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, - HiddenByOwner: bool = True , HiddenByUser: bool = True, + HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0) -> bool: """ create sharing """ if ShareType == "token": @@ -157,19 +157,18 @@ def create_sharing(self, logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User, Permissions) return False - row = { "ShareType": ShareType, - "PathOrToken": PathOrToken, - "PathMapped": PathMapped, - "Owner": Owner, - "User": User, - "Permissions": Permissions, - "EnabledByOwner": str(EnabledByOwner), - "EnabledByUser": str(EnabledByUser), - "HiddenByOwner": str(HiddenByOwner), - "HiddenByUser": str(HiddenByUser), - "TimestampCreated": str(Timestamp), - "TimestampUpdated": str(Timestamp) - } + row = {"ShareType": ShareType, + "PathOrToken": PathOrToken, + "PathMapped": PathMapped, + "Owner": Owner, + "User": User, + "Permissions": Permissions, + "EnabledByOwner": str(EnabledByOwner), + "EnabledByUser": str(EnabledByUser), + "HiddenByOwner": str(HiddenByOwner), + "HiddenByUser": str(HiddenByUser), + "TimestampCreated": str(Timestamp), + "TimestampUpdated": str(Timestamp)} logger.debug("TRACE/sharing/*/create: add row: %r", row) # TODO: add locking self._map_cache.append(row) @@ -215,7 +214,7 @@ def delete_sharing(self, break index += 1 - if found == True: + if found: logger.debug("TRACE/sharing/*/delete: found index=%d", index) if row['Owner'] != Owner: return {"status": "permission-denied"} @@ -268,7 +267,7 @@ def toggle_sharing(self, break index += 1 - if found == True: + if found: # logger.debug("TRACE/sharing/*/" + Action + ": found: %r", row) if User and row['User'] != User: return {"status": "permission-denied"} From 77f46b8aaca5ee2ccbe640bbd32da26d1914f81c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:23:56 +0100 Subject: [PATCH 036/138] flake8 --- radicale/app/get.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 6ebba4707..2e60d7e48 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -22,7 +22,7 @@ from http import client from urllib.parse import quote -from radicale import httputils, pathutils, sharing, storage, types, xmlutils +from radicale import httputils, pathutils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.log import logger From 5c380c09f7f619758eb2f6bdeece550d6c544b74 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:25:00 +0100 Subject: [PATCH 037/138] flake8 --- radicale/sharing/__init__.py | 40 +++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 2afd7d51a..9a6ad8651 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -25,8 +25,9 @@ from datetime import datetime from http import client from urllib.parse import parse_qs +from typing import (Sequence) -from radicale import config, httputils, rights, utils +from radicale import config, httputils, rights, utils, types from radicale.app.base import Access from radicale.log import logger @@ -66,7 +67,6 @@ def load(configuration: "config.Configuration") -> "BaseSharing": return utils.load_plugin(INTERNAL_TYPES, "sharing", "Sharing", BaseSharing, configuration) - class BaseSharing: configuration: "config.Configuration" @@ -94,6 +94,7 @@ def __init__(self, configuration: "config.Configuration") -> None: if self.init_database() is False: exit(1) except Exception as e: + logger.error("sharing database cannot be initialized: %r", e) exit(1) database_info = self.get_database_info() if database_info: @@ -106,21 +107,21 @@ def init_database(self) -> bool: """ initialize database """ return None - def get_database_info(self) -> [ dict | None]: + def get_database_info(self) -> [dict | None]: """ retrieve database information """ return None def list_sharing(self, ShareType: [str | None] = None, - PathOrToken: [str | None ] = None, PathMapped: [str | None ] = None, - Owner: [str | None ] = None, User: [str | None ] = None) -> bool: + PathOrToken: [str | None] = None, PathMapped: [str | None] = None, + Owner: [str | None] = None, User: [str | None] = None) -> bool: """ retrieve sharing """ return None def get_sharing(self, ShareType: str, PathOrToken: str, - User: [str | None ] = None) -> [dict | None]: + User: [str | None] = None) -> [dict | None]: """ retrieve sharing target and attributes by map """ return None @@ -130,7 +131,7 @@ def create_sharing(self, Owner: str, User: str, Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, - HiddenByOwner: bool = True , HiddenByUser: bool = True, + HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0) -> bool: """ create sharing """ return None @@ -139,7 +140,7 @@ def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, - PathMapped: [str | None] = None, + PathMapped: [str | None] = None, User: [str | None] = None) -> [dict | None]: """ delete sharing """ return None @@ -156,7 +157,7 @@ def toggle_sharing(self, return None # static sharing functions - def sharing_collection_resolver(self, path:str, user: str) -> [dict | None]: + def sharing_collection_resolver(self, path: str, user: str) -> [dict | None]: if self.sharing_collection_by_token: result = self.sharing_collection_by_token_resolver(path) if result is None: @@ -213,7 +214,7 @@ def sharing_collection_by_map_resolver(self, path: str, user: str) -> [dict | No logger.debug("TRACE/sharing_by_map: not active") return {"mapped": False} - ## POST API + # POST API def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """POST request. @@ -278,7 +279,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # check for valid ShareTypes if ShareType: - if not ShareType in SHARE_TYPES: + if ShareType not in SHARE_TYPES: logger.debug("TRACE/sharing/API: ShareType not whitelisted: %r", ShareType) return httputils.NOT_FOUND @@ -292,7 +293,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.NOT_FOUND # check for valid API hooks - if not action in API_HOOKS_V1: + if action not in API_HOOKS_V1: logger.debug("TRACE/sharing/API: action not whitelisted: %r", action) return httputils.NOT_FOUND @@ -429,7 +430,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer['ApiVersion'] = "1" Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) - ## action: list + # action: list if action == "list": logger.debug("TRACE/" + api_info + ": start") if 'PathOrToken' in request_data: @@ -447,10 +448,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer['Status'] = "success" answer['Content'] = result - ## action: create + # action: create elif action == "create": logger.debug("TRACE/" + api_info + ": start") - if not 'Permissions' in request_data: + if 'Permissions' not in request_data: Permissions = "r" else: Permissions = request_data['Permissions'] @@ -465,7 +466,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info("Add sharing-by-token: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED - ## v1: create uuid token with 2x 32 bytes = 256 bit + # v1: create uuid token with 2x 32 bytes = 256 bit token = "v1/" + str(base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes), 'utf-8') logger.debug("TRACE/" + api_info + ": %r (Permissions=%r token=%r)", PathMapped, Permissions, token) @@ -499,13 +500,14 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st Owner=Owner, User=User, Permissions=Permissions, EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, + EnabledByUser=EnabledByUser, HiddenByUser=HiddenByUser, Timestamp=Timestamp): logger.error(api_info + ": %r (%r) -> %r (%r)", PathMapped, User, PathOrToken, Owner) return httputils.BAD_REQUEST answer['Status'] = "success" - ## action: delete + # action: delete elif action == "delete": logger.debug("TRACE/" + api_info + ": start") @@ -523,7 +525,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st Owner=Owner, User=User) - ## result handling + # result handling if result['status'] == "not-found": return httputils.NOT_FOUND elif result['status'] == "permission-denied": @@ -538,7 +540,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) return httputils.BAD_REQUEST - ## action: TOGGLE + # action: TOGGLE elif action in API_SHARE_TOGGLES_V1: logger.debug("TRACE/sharing/API/POST/" + action) From 8229e4e667a828914a821a2d97c3a6c52423ed06 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:37:11 +0100 Subject: [PATCH 038/138] add dummy init --- radicale/sharing/none.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/radicale/sharing/none.py b/radicale/sharing/none.py index c1ed70ff6..42bae5dbe 100644 --- a/radicale/sharing/none.py +++ b/radicale/sharing/none.py @@ -20,6 +20,10 @@ class Sharing(sharing.BaseSharing): + def init_database(self) -> bool: + """ dummy initialization """ + return True + def get_sharing_collection_by_token(self, token: str) -> [dict | None]: """ retrieve target and attributs by token """ # default From 375d7d2161926f76597811495d4958cd3be1fc3e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:42:12 +0100 Subject: [PATCH 039/138] typing --- radicale/sharing/csv.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index a3cad20e1..36fde183c 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -16,6 +16,7 @@ import csv import os +from typing import Sequence from radicale import sharing from radicale.log import logger @@ -110,7 +111,7 @@ def get_sharing(self, def list_sharing(self, ShareType: [str | None] = None, PathOrToken: [str | None] = None, PathMapped: [str | None] = None, - Owner: [str | None] = None, User: [str | None] = None) -> bool: + Owner: [str | None] = None, User: [str | None] = None) -> [Sequence(str) | None]: """ retrieve sharing """ result = [] for row in self._map_cache: From c2b9a9725daf8abd8adc7ca89e895f3c2f6e41bd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 08:42:22 +0100 Subject: [PATCH 040/138] review --- radicale/sharing/__init__.py | 18 ++++++++++-------- radicale/tests/test_sharing.py | 3 +-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 9a6ad8651..2fdccc000 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -20,14 +20,13 @@ import re import socket import uuid - from csv import DictWriter from datetime import datetime from http import client +from typing import Sequence from urllib.parse import parse_qs -from typing import (Sequence) -from radicale import config, httputils, rights, utils, types +from radicale import config, httputils, rights, types, utils from radicale.app.base import Access from radicale.log import logger @@ -90,6 +89,7 @@ def __init__(self, configuration: "config.Configuration") -> None: self.sharing_db_type = configuration.get("sharing", "type") logger.info("sharing.db_type: %s", self.sharing_db_type) # database tasks + try: if self.init_database() is False: exit(1) @@ -105,7 +105,7 @@ def __init__(self, configuration: "config.Configuration") -> None: # overloadable functions def init_database(self) -> bool: """ initialize database """ - return None + return False def get_database_info(self) -> [dict | None]: """ retrieve database information """ @@ -114,7 +114,7 @@ def get_database_info(self) -> [dict | None]: def list_sharing(self, ShareType: [str | None] = None, PathOrToken: [str | None] = None, PathMapped: [str | None] = None, - Owner: [str | None] = None, User: [str | None] = None) -> bool: + Owner: [str | None] = None, User: [str | None] = None) -> [Sequence(str) | None]: """ retrieve sharing """ return None @@ -166,6 +166,7 @@ def sharing_collection_resolver(self, path: str, user: str) -> [dict | None]: return result else: logger.debug("TRACE/sharing_by_token: not active") + return None if self.sharing_collection_by_map: result = self.sharing_collection_by_map_resolver(path, user) @@ -175,6 +176,7 @@ def sharing_collection_resolver(self, path: str, user: str) -> [dict | None]: return result else: logger.debug("TRACE/sharing_by_map: not active") + return None # final return {"mapped": False} @@ -462,7 +464,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if ShareType == "token": # check access Permissions access = Access(self._rights, user, PathMapped) - if not access.check("r") and "i" not in access.Permissions: + if not access.check("r") and "i" not in access.permissions: logger.info("Add sharing-by-token: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED @@ -489,7 +491,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif ShareType == "map": # check access Permissions access = Access(self._rights, user, PathMapped) - if not access.check("r") and "i" not in access.Permissions: + if not access.check("r") and "i" not in access.permissions: logger.info("Add sharing-by-map: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED @@ -535,7 +537,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st pass else: if ShareType == "token": - logger.info("Delete sharing-by-token: %r of user %r not successful", token, user) + logger.info("Delete sharing-by-token: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) elif ShareType == "map": logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) return httputils.BAD_REQUEST diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index ddf8b889a..947a1fc07 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -23,8 +23,7 @@ import logging import os import re - -from typing import (Sequence) +from typing import Sequence from radicale import sharing from radicale.tests import BaseTest From 4c0f18a958dc0c5a97842f151060851859906844 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 17:53:31 +0100 Subject: [PATCH 041/138] fix typing --- radicale/tests/test_sharing.py | 299 +++++++++++++++++---------------- 1 file changed, 150 insertions(+), 149 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 947a1fc07..cf8a75e40 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -23,7 +23,7 @@ import logging import os import re -from typing import Sequence +from typing import Sequence, Union from radicale import sharing from radicale.tests import BaseTest @@ -45,13 +45,13 @@ def setup_method(self) -> None: f.write(htpasswd_content) # Helper functions - def _sharing_api(self, sharing_type: str, action: str, check: int, login: [str | None], data: str, content_type: str, accept_type: [str | None]): + def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str | None], data: str, content_type: str, accept_type: Union[str | None]): path_base = "/.sharing/v1/" + sharing_type + "/" _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept_type) logging.debug("received answer:\n%s", "\n".join(answer.splitlines())) return _, headers, answer - def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: [str | None], form_array: Sequence[str], accept_type: [str | None] = None): + def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str | None], form_array: Sequence[str], accept_type: Union[str | None] = None): data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" if accept_type is None: @@ -59,8 +59,8 @@ def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: [ _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept_type) return _, headers, answer - def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: [str | None], form_dict: dict, accept_type: [str | None] = None): - data = json.dumps(form_dict) + def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str | None], json_dict: dict, accept_type: Union[str | None] = None): + data = json.dumps(json_dict) content_type = "application/json" if accept_type is None: accept_type = "application/json" @@ -151,42 +151,35 @@ def test_sharing_api_list_with_auth(self) -> None: "collection_by_token": "True"}, "logging": {"request_header_on_debug": "true"}, "rights": {"type": "owner_only"}}) + form_array: Sequence[str] json_dict: dict + action = "list" for sharing_type in sharing.SHARE_TYPES: logging.debug("*** list (without form) -> should fail") path = "/.sharing/v1/" + sharing_type + "/" + action _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) - logging.debug("*** list (form -> csv)") + logging.debug("*** list (form->csv)") form_array = [] - content_type = "application/x-www-form-urlencoded" - data = "\n".join(form_array) - _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) - logging.debug("received answer %r", answer) + _, headers, answer = self._sharing_api_form(sharing_type, "list", 200, "owner:ownerpw", form_array) assert "Status=not-found" in answer assert "Lines=0" in answer - logging.debug("*** list (json -> csv)") - json_dict: dict = {} - content_type = "application/json" - data = json.dumps(json_dict) - _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type) + logging.debug("*** list (json->text)") + json_dict = {} + _, headers, answer = self._sharing_api_json(sharing_type, "list", 200, "owner:ownerpw", json_dict, "text/plain") logging.debug("received answer %r", answer) assert "Status=not-found" in answer assert "Lines=0" in answer - logging.debug("*** list (json -> json)") - json_dict: dict = {} - content_type = "application/json" - accept = "application/json" - data = json.dumps(json_dict) - _, headers, answer = self.request("POST", path, check=200, login="%s:%s" % ("owner", "ownerpw"), data=data, content_type=content_type, accept=accept) - logging.debug("received answer %r", answer) - assert '"Status": "not-found"' in answer - assert '"Lines": 0' in answer - assert '"Content": null' in answer + logging.debug("*** list (json->json)") + json_dict = {} + _, headers, answer = self._sharing_api_json(sharing_type, "list", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "not-found" + assert answer['Lines'] == 0 + assert answer['Content'] is None def test_sharing_api_token_basic(self) -> None: """share-by-token API tests.""" @@ -202,14 +195,15 @@ def test_sharing_api_token_basic(self) -> None: "rights": {"type": "owner_only"}}) form_array: Sequence[str] + json_dict: dict logging.debug("*** create token without PathMapped (form) -> should fail") form_array = [] _, headers, answer = self._sharing_api_form("token", "create", 400, "owner:ownerpw", form_array) logging.debug("*** create token without PathMapped (json) -> should fail") - form_dict = {} - _, headers, answer = self._sharing_api_json("token", "create", 400, "owner:ownerpw", form_dict) + json_dict = {} + _, headers, answer = self._sharing_api_json("token", "create", 400, "owner:ownerpw", json_dict) logging.debug("*** create token#1 (form->text)") form_array = ["PathMapped=/owner/collection1"] @@ -218,18 +212,24 @@ def test_sharing_api_token_basic(self) -> None: assert "PathOrToken=" in answer # extract token match = re.search('PathOrToken=(.+)', answer) - token1 = match[1] - logging.debug("received token %r", token1) + if match: + token1 = match.group(1) + logging.debug("received token %r", token1) + else: + assert False logging.debug("*** create token#2 (json->text)") - form_dict = {'PathMapped': "/owner/collection2"} - _, headers, answer = self._sharing_api_json("token", "create", 200, "owner:ownerpw", form_dict, "text/plain") + json_dict = {'PathMapped': "/owner/collection2"} + _, headers, answer = self._sharing_api_json("token", "create", 200, "owner:ownerpw", json_dict, "text/plain") assert "Status=success" in answer assert "Token=" in answer # extract token match = re.search('Token=(.+)', answer) - token2 = match[1] - logging.debug("received token %r", token2) + if match: + token2 = match.group(1) + logging.debug("received token %r", token2) + else: + assert False logging.debug("*** lookup token#1 (form->text)") form_array = ["PathOrToken=" + token1] @@ -239,15 +239,15 @@ def test_sharing_api_token_basic(self) -> None: assert "/owner/collection1" in answer logging.debug("*** lookup token#2 (json->text") - form_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict, "text/plain") + json_dict = {'PathOrToken': token2} + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict, "text/plain") assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection2" in answer logging.debug("*** lookup token#2 (json->json)") - form_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) + json_dict = {'PathOrToken': token2} + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) assert "success" in answer['Status'] assert answer['Lines'] == 1 assert "/owner/collection2" in answer['Content'][0]['PathMapped'] @@ -292,17 +292,17 @@ def test_sharing_api_token_basic(self) -> None: assert "Status=success" in answer logging.debug("*** lookup token#2 (json->json) -> check for not enabled") - form_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {'PathOrToken': token2} + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" assert answer['Lines'] == 1 - assert "False" in answer['Content'][0]['EnabledByOwner'] + assert answer['Content'][0]['EnabledByOwner'] == str(False) logging.debug("*** enable token#2 (json->json)") - form_dict = {} - form_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "enable", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "enable", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" logging.debug("*** lookup token#2 (form->text) -> check for enabled") form_array = [] @@ -327,30 +327,30 @@ def test_sharing_api_token_basic(self) -> None: assert "True,True,True,True" in answer logging.debug("*** unhide token#2 (json->json)") - form_dict = {} - form_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "unhide", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "unhide", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" logging.debug("*** lookup token#2 (json->json) -> check for not hidden") - form_dict = {} - form_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" assert answer['Lines'] == 1 - assert "False" in answer['Content'][0]['HiddenByOwner'] + assert answer['Content'][0]['HiddenByOwner'] == str(False) logging.debug("*** delete token#2 (json->json)") - form_dict = {} - form_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" logging.debug("*** lookup token#2 (json->json) -> should not be there anymore") - form_dict = {} - form_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", form_dict) - assert "not-found" in answer['Status'] + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "not-found" assert answer['Lines'] == 0 def test_sharing_api_token_usage(self) -> None: @@ -367,7 +367,7 @@ def test_sharing_api_token_usage(self) -> None: "rights": {"type": "owner_only"}}) form_array: Sequence[str] - form_dict = dict + json_dict: dict path_token = "/.token/" @@ -386,8 +386,11 @@ def test_sharing_api_token_usage(self) -> None: assert "PathOrToken=" in answer # extract token match = re.search('PathOrToken=(.+)', answer) - token = match[1] - logging.debug("received token %r", token) + if match: + token = match.group(1) + logging.debug("received token %r", token) + else: + assert False logging.debug("*** enable token (form->text)") form_array = ["PathOrToken=" + token] @@ -419,9 +422,9 @@ def test_sharing_api_token_usage(self) -> None: assert "UID:event" in answer logging.debug("*** delete token (json->json)") - form_dict = {'PathOrToken': token} - _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {'PathOrToken': token} + _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" logging.debug("*** fetch collection using deleted token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=401) @@ -439,21 +442,21 @@ def test_sharing_api_map_basic(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - form_dict = dict + json_dict: dict logging.debug("*** create map without PathMapped (json) -> should fail") - form_dict = {} - _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", form_dict) + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", json_dict) logging.debug("*** create map without PathMapped but User (json) -> should fail") - form_dict = {'User': "user"} - _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", form_dict) + json_dict = {'User': "user"} + _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", json_dict) logging.debug("*** create map without PathMapped but User and PathOrToken (json) -> should fail") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathOrToken'] = "/owner/calendar.ics" - _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", form_dict) + json_dict = {} + json_dict['User'] = "user" + json_dict['PathOrToken'] = "/owner/calendar.ics" + _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", json_dict) def test_sharing_api_map_usage(self) -> None: """share-by-map API usage tests.""" @@ -468,7 +471,7 @@ def test_sharing_api_map_usage(self) -> None: "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) - form_dict = dict + json_dict: dict path_share = "/user/calendar-shared-by-owner.ics" path_mapped = "/owner/calendar.ics" @@ -480,59 +483,57 @@ def test_sharing_api_map_usage(self) -> None: self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) logging.debug("*** create map with PathMapped and User and PathOrToken (json)") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathMapped'] = "/owner/calendar.ics" - form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = "/owner/calendar.ics" + json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" logging.debug("*** lookup map without filter (json->json)") - form_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" assert answer['Lines'] == 1 - assert path_share in answer['Content'][0]['PathOrToken'] - assert path_mapped in answer['Content'][0]['PathMapped'] - assert "map" in answer['Content'][0]['ShareType'] - assert "owner" in answer['Content'][0]['Owner'] - assert "user" in answer['Content'][0]['User'] - assert "False" in answer['Content'][0]['EnabledByOwner'] - assert "False" in answer['Content'][0]['EnabledByUser'] - assert "True" in answer['Content'][0]['HiddenByOwner'] - assert "True" in answer['Content'][0]['HiddenByUser'] - assert "r" in answer['Content'][0]['Permissions'] + assert answer['Content'][0]['PathOrToken'] == path_share + assert answer['Content'][0]['PathMapped'] == path_mapped + assert answer['Content'][0]['ShareType'] == "map" + assert answer['Content'][0]['Owner'] == "owner" + assert answer['Content'][0]['User'] == "user" + assert answer['Content'][0]['EnabledByOwner'] == str(False) + assert answer['Content'][0]['EnabledByUser'] == str(False) + assert answer['Content'][0]['HiddenByOwner'] == str(True) + assert answer['Content'][0]['HiddenByUser'] == str(True) + assert answer['Content'][0]['Permissions'] == "r" logging.debug("*** enable map by owner (json->json)") - form_dict = {} - form_dict['User'] = "owner" - form_dict['PathMapped'] = path_mapped - form_dict['PathOrToken'] = path_share - _, headers, answer = self._sharing_api_json("map", "enable", 404, "owner:ownerpw", form_dict) -# assert "success" in answer['Status'] -# assert "True" in answer['Content'][0]['EnabledByOwner'] + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share + _, headers, answer = self._sharing_api_json("map", "enable", 404, "owner:ownerpw", json_dict) logging.debug("*** enable map by owner for user (json->json)") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathMapped'] = path_mapped - form_dict['PathOrToken'] = path_share - _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", form_dict) + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share + _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) logging.debug("*** enable map by user (json->json)") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathMapped'] = path_mapped - form_dict['PathOrToken'] = path_share - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + assert answer['Status'] == "success" logging.debug("*** enable map by user for owner (json->json) -> should fail") - form_dict = {} - form_dict['User'] = "owner" - form_dict['PathMapped'] = path_mapped - form_dict['PathOrToken'] = path_share - _, headers, answer = self._sharing_api_json("map", "enable", 403, "user:userpw", form_dict) + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share + _, headers, answer = self._sharing_api_json("map", "enable", 403, "user:userpw", json_dict) logging.debug("*** fetch collection (without credentials)") _, headers, answer = self.request("GET", path_mapped, check=401) @@ -547,52 +548,52 @@ def test_sharing_api_map_usage(self) -> None: _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) logging.debug("*** disable map by owner (json->json)") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathMapped'] = "/owner/calendar.ics" - form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - _, headers, answer = self._sharing_api_json("map", "disable", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = "/owner/calendar.ics" + json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + _, headers, answer = self._sharing_api_json("map", "disable", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" logging.debug("*** fetch collection via map (with credentials) as user -> n/a") _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) logging.debug("*** enable map by owner (json->json)") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathMapped'] = "/owner/calendar.ics" - form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", form_dict) + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = "/owner/calendar.ics" + json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) logging.debug("received answer %r", answer) - assert "success" in answer['Status'] + assert answer['Status'] == "success" logging.debug("*** fetch collection via map (with credentials) as user") _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) logging.debug("*** disable map by user (json->json)") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathMapped'] = "/owner/calendar.ics" - form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - _, headers, answer = self._sharing_api_json("map", "disable", 200, "user:userpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = "/owner/calendar.ics" + json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + _, headers, answer = self._sharing_api_json("map", "disable", 200, "user:userpw", json_dict) + assert answer['Status'] == "success" logging.debug("*** fetch collection via map (with credentials) as user -> n/a") _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) logging.debug("*** delete map by user (json->json) -> fail") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathMapped'] = "/owner/calendar.ics" - form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - _, headers, answer = self._sharing_api_json("map", "delete", 403, "user:userpw", form_dict) + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = "/owner/calendar.ics" + json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + _, headers, answer = self._sharing_api_json("map", "delete", 403, "user:userpw", json_dict) logging.debug("*** delete map by owner (json->json) -> ok") - form_dict = {} - form_dict['User'] = "user" - form_dict['PathMapped'] = "/owner/calendar.ics" - form_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" - _, headers, answer = self._sharing_api_json("map", "delete", 200, "owner:ownerpw", form_dict) - assert "success" in answer['Status'] + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = "/owner/calendar.ics" + json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + _, headers, answer = self._sharing_api_json("map", "delete", 200, "owner:ownerpw", json_dict) + assert answer['Status'] == "success" # TODO hide+unhide for REPORT From 26023e744f6cf219c5bfdbebf638b759e19b4d14 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 19:15:27 +0100 Subject: [PATCH 042/138] fix typing --- radicale/sharing/__init__.py | 61 ++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 2fdccc000..182628f8d 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -23,7 +23,7 @@ from csv import DictWriter from datetime import datetime from http import client -from typing import Sequence +from typing import Sequence, Union from urllib.parse import parse_qs from radicale import config, httputils, rights, types, utils @@ -107,21 +107,21 @@ def init_database(self) -> bool: """ initialize database """ return False - def get_database_info(self) -> [dict | None]: + def get_database_info(self) -> Union[dict, None]: """ retrieve database information """ return None def list_sharing(self, - ShareType: [str | None] = None, - PathOrToken: [str | None] = None, PathMapped: [str | None] = None, - Owner: [str | None] = None, User: [str | None] = None) -> [Sequence(str) | None]: + ShareType: Union[str, None] = None, + PathOrToken: Union[str | None] = None, PathMapped: Union[str | None] = None, + Owner: Union[str | None] = None, User: Union[str | None] = None) -> list[dict]: """ retrieve sharing """ - return None + return [] def get_sharing(self, ShareType: str, PathOrToken: str, - User: [str | None] = None) -> [dict | None]: + User: Union[str | None] = None) -> Union[dict | None]: """ retrieve sharing target and attributes by map """ return None @@ -134,30 +134,30 @@ def create_sharing(self, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0) -> bool: """ create sharing """ - return None + return False def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, - PathMapped: [str | None] = None, - User: [str | None] = None) -> [dict | None]: + PathMapped: Union[str | None] = None, + User: Union[str | None] = None) -> dict: """ delete sharing """ - return None + return {} def toggle_sharing(self, ShareType: str, PathOrToken: str, OwnerOrUser: str, Action: str, - PathMapped: [str | None] = None, - User: [str | None] = None, - Timestamp: int = 0) -> [dict | None]: + PathMapped: Union[str | None] = None, + User: Union[str | None] = None, + Timestamp: int = 0) -> dict: """ toggle sharing """ - return None + return {} # static sharing functions - def sharing_collection_resolver(self, path: str, user: str) -> [dict | None]: + def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None]: if self.sharing_collection_by_token: result = self.sharing_collection_by_token_resolver(path) if result is None: @@ -181,7 +181,7 @@ def sharing_collection_resolver(self, path: str, user: str) -> [dict | None]: # final return {"mapped": False} - def sharing_collection_by_token_resolver(self, path) -> [dict | None]: + def sharing_collection_by_token_resolver(self, path) -> Union[dict | None]: """ returning dict with mapped-flag, path, user, rights or None if invalid""" if self.sharing_collection_by_token: logger.debug("TRACE/sharing_by_token: check path: %r", path) @@ -204,7 +204,7 @@ def sharing_collection_by_token_resolver(self, path) -> [dict | None]: logger.debug("TRACE/sharing_by_token: not active") return {"mapped": False} - def sharing_collection_by_map_resolver(self, path: str, user: str) -> [dict | None]: + def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict | None]: """ returning dict with mapped-flag, path, user, rights or None if invalid""" if self.sharing_collection_by_map: logger.debug("TRACE/sharing/resolver/map: check path: %r", path) @@ -351,11 +351,11 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.BAD_REQUEST # parameters default - PathOrToken: [str | None] = None - PathMapped: [str | None] = None - Owner: [str | None] = user - User: [str | None] = None - Permissions: [str | None] = None + PathOrToken: str + PathMapped: str + Owner: str = user + User: str + Permissions: str = "" # no permissions by default EnabledByOwner: bool = False # security by default HiddenByOwner: bool = True # security by default EnabledByUser: bool = False # security by default @@ -429,6 +429,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st User = request_data['User'] answer: dict = {} + result: dict = {} + result_array: list[dict] answer['ApiVersion'] = "1" Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) @@ -438,17 +440,16 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if 'PathOrToken' in request_data: PathOrToken = request_data['PathOrToken'] logger.debug("TRACE/" + api_info + ": filter: %r", PathOrToken) - result = self.list_sharing( + result_array = self.list_sharing( ShareType=ShareType, Owner=Owner, PathOrToken=PathOrToken) - if not result: - answer['Lines'] = 0 + answer['Lines'] = len(result_array) + if len(result_array) == 0: answer['Status'] = "not-found" else: - answer['Lines'] = len(result) answer['Status'] = "success" - answer['Content'] = result + answer['Content'] = result_array # action: create elif action == "create": @@ -598,11 +599,11 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st } return client.OK, headers, "\n".join(answer_array), None elif output_format == "json": - answer = json.dumps(answer) + answer_raw = json.dumps(answer) headers = { "Content-Type": "text/json" } - return client.OK, headers, answer, None + return client.OK, headers, answer_raw, None else: # should not be reached return httputils.BAD_REQUEST From 9ab766bdf7f320a52a2a7b5fe50ac4dc6a75c1e0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 22:02:31 +0100 Subject: [PATCH 043/138] fix typing --- radicale/sharing/__init__.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 182628f8d..36ff8f04a 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -351,10 +351,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.BAD_REQUEST # parameters default - PathOrToken: str + PathOrToken: Union[str | None] = None PathMapped: str Owner: str = user - User: str + User: Union[str | None] = None Permissions: str = "" # no permissions by default EnabledByOwner: bool = False # security by default HiddenByOwner: bool = True # security by default @@ -408,7 +408,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.error(api_info + ": missing PathOrToken") return httputils.BAD_REQUEST else: - # optional + # PathOrToken is optional pass else: if action == "create" and ShareType == "token": @@ -496,11 +496,18 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info("Add sharing-by-map: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED + if PathOrToken is None: + return httputils.BAD_REQUEST + if User is None: + return httputils.BAD_REQUEST + logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r user=%r)", PathMapped, Permissions, PathOrToken, User) if not self.create_sharing( ShareType=ShareType, - PathOrToken=PathOrToken, PathMapped=PathMapped, - Owner=Owner, User=User, + PathOrToken=str(PathOrToken), # verification above that it is not None + PathMapped=PathMapped, + Owner=Owner, + User=str(User), # verification above that it is not None Permissions=Permissions, EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, EnabledByUser=EnabledByUser, HiddenByUser=HiddenByUser, @@ -514,16 +521,19 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif action == "delete": logger.debug("TRACE/" + api_info + ": start") + if PathOrToken is None: + return httputils.BAD_REQUEST + if ShareType == "token": result = self.delete_sharing( ShareType=ShareType, - PathOrToken=PathOrToken, + PathOrToken=str(PathOrToken), # verification above that it is not None Owner=Owner) elif ShareType == "map": result = self.delete_sharing( ShareType=ShareType, - PathOrToken=PathOrToken, + PathOrToken=str(PathOrToken), # verification above that it is not None PathMapped=PathMapped, Owner=Owner, User=User) @@ -547,9 +557,12 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif action in API_SHARE_TOGGLES_V1: logger.debug("TRACE/sharing/API/POST/" + action) + if PathOrToken is None: + return httputils.BAD_REQUEST + result = self.toggle_sharing( ShareType=ShareType, - PathOrToken=PathOrToken, + PathOrToken=str(PathOrToken), # verification above that it is not None OwnerOrUser=user, # authenticated user User=User, # provided user for selection Action=action, @@ -573,6 +586,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # output handler logger.debug("TRACE/sharing/API/POST output format: %r", output_format) + logger.debug("TRACE/sharing/API/POST answer: %r", answer) if output_format == "csv" or output_format == "txt": answer_array = [] if output_format == "txt": From 958e57f4b1efc4b1a3be59f7820d638734d7622e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 22:02:48 +0100 Subject: [PATCH 044/138] fix typing --- radicale/sharing/csv.py | 71 ++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 36fde183c..2bc70dc7f 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -16,7 +16,7 @@ import csv import os -from typing import Sequence +from typing import Union from radicale import sharing from radicale.log import logger @@ -26,7 +26,7 @@ class Sharing(sharing.BaseSharing): _lines: int = 0 - _map_cache = [] + _sharing_cache: list[dict] = [] _sharing_db_file: str # Overloaded functions @@ -73,17 +73,17 @@ def init_database(self) -> bool: self._sharing_db_file = sharing_db_file return True - def get_database_info(self) -> [dict | None]: + def get_database_info(self) -> Union[dict | None]: database_info = {'type': "csv"} return database_info def get_sharing(self, ShareType: str, PathOrToken: str, - User: [str | None] = None) -> [dict | None]: + User: Union[str | None] = None) -> Union[dict | None]: """ retrieve sharing target and attributes by map """ # Lookup - for row in self._map_cache: + for row in self._sharing_cache: if row['ShareType'] != ShareType: continue elif row['PathOrToken'] != PathOrToken: @@ -96,25 +96,27 @@ def get_sharing(self, continue PathMapped = row['PathMapped'] Owner = row['Owner'] - User = row['User'] + UserShare = row['User'] Permissions = row['Permissions'] - logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r)", PathOrToken, PathMapped, Owner, User, Permissions) + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r)", PathOrToken, PathMapped, Owner, UserShare, Permissions) return { "mapped": True, "PathOrToken": PathOrToken, "PathMapped": PathMapped, "Owner": Owner, - "User": User, + "User": UserShare, "Permissions": Permissions} return None def list_sharing(self, - ShareType: [str | None] = None, - PathOrToken: [str | None] = None, PathMapped: [str | None] = None, - Owner: [str | None] = None, User: [str | None] = None) -> [Sequence(str) | None]: + ShareType: Union[str | None] = None, + PathOrToken: Union[str | None] = None, PathMapped: Union[str | None] = None, + Owner: Union[str | None] = None, User: Union[str | None] = None) -> list[dict]: """ retrieve sharing """ + row: dict result = [] - for row in self._map_cache: + + for row in self._sharing_cache: if ShareType and row['ShareType'] != ShareType: continue elif Owner and row['Owner'] != Owner: @@ -137,10 +139,12 @@ def create_sharing(self, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0) -> bool: """ create sharing """ + row: dict + if ShareType == "token": logger.debug("TRACE/sharing/token/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) # check for duplicate token entry - for row in self._map_cache: + for row in self._sharing_cache: if row['ShareType'] != "token": continue if row['PathOrToken'] == PathOrToken: @@ -150,7 +154,7 @@ def create_sharing(self, elif ShareType == "map": logger.debug("TRACE/sharing/map/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) # check for duplicate map entry - for row in self._map_cache: + for row in self._sharing_cache: if row['ShareType'] != "map": continue if row['PathMapped'] == PathMapped and row['User'] == User: @@ -172,7 +176,7 @@ def create_sharing(self, "TimestampUpdated": str(Timestamp)} logger.debug("TRACE/sharing/*/create: add row: %r", row) # TODO: add locking - self._map_cache.append(row) + self._sharing_cache.append(row) if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") return True @@ -182,8 +186,8 @@ def create_sharing(self, def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, - PathMapped: [str | None] = None, - User: [str | None] = None) -> [dict | None]: + PathMapped: Union[str | None] = None, + User: Union[str | None] = None) -> dict: """ delete sharing """ if ShareType == "token": logger.debug("TRACE/sharing/token/delete: PathOrToken=%r Owner=%r", PathOrToken, Owner) @@ -195,7 +199,7 @@ def delete_sharing(self, # lookup token found = False index = 0 - for row in self._map_cache: + for row in self._sharing_cache: if row['ShareType'] != ShareType: pass elif row['PathOrToken'] != PathOrToken: @@ -220,7 +224,7 @@ def delete_sharing(self, if row['Owner'] != Owner: return {"status": "permission-denied"} logger.debug("TRACE/sharing/*/delete: Owner=%r PathOrToken=%r index=%d", Owner, PathOrToken, index) - self._map_cache.pop(index) + self._sharing_cache.pop(index) # TODO: add locking if self._write_csv(self._sharing_db_file): @@ -236,19 +240,22 @@ def toggle_sharing(self, PathOrToken: str, OwnerOrUser: str, Action: str, - PathMapped: [str | None] = None, - User: [str | None] = None, - Timestamp: int = 0) -> [dict | None]: + PathMapped: Union[str | None] = None, + User: Union[str | None] = None, + Timestamp: int = 0) -> dict: """ toggle sharing """ + row: dict + if Action not in sharing.API_SHARE_TOGGLES_V1: - return False + # should not happen + raise logger.debug("TRACE/sharing/*/" + Action + ": ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r Action=%r", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, Action) # lookup entry found = False index = 0 - for row in self._map_cache: + for row in self._sharing_cache: logger.debug("TRACE/sharing/*/" + Action + ": check: %r", row) if row['ShareType'] != ShareType: pass @@ -305,9 +312,9 @@ def toggle_sharing(self, row['TimestampUpdated'] = str(Timestamp) # remove - self._map_cache.pop(index) + self._sharing_cache.pop(index) # readd - self._map_cache.append(row) + self._sharing_cache.append(row) # TODO: add locking if self._write_csv(self._sharing_db_file): @@ -319,13 +326,13 @@ def toggle_sharing(self, return {"status": "not-found"} # local functions - def _create_empty_csv(self, file) -> bool: + def _create_empty_csv(self, file: str) -> bool: with open(file, 'w', newline='') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) writer.writeheader() return True - def _load_csv(self, file) -> bool: + def _load_csv(self, file: str) -> bool: logger.debug("sharing database load begin: %r", file) with open(file, 'r', newline='') as csvfile: reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) @@ -333,19 +340,19 @@ def _load_csv(self, file) -> bool: for row in reader: # check for duplicates dup = False - for row_cached in self._map_cache: + for row_cached in self._sharing_cache: if row == row_cached: dup = True break if dup: continue - self._map_cache.append(row) + self._sharing_cache.append(row) self._lines += 1 logger.debug("sharing database load end: %r", file) return True - def _write_csv(self, file) -> bool: + def _write_csv(self, file: str) -> bool: with open(file, 'w', newline='') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) - writer.writerows(self._map_cache) + writer.writerows(self._sharing_cache) return True From a6160ec438c99113f69685c36277b87d5aab332d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 22:35:53 +0100 Subject: [PATCH 045/138] fix typing --- radicale/tests/test_sharing.py | 127 ++++++++++++++++++++++----------- 1 file changed, 84 insertions(+), 43 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index cf8a75e40..d72c5c6e9 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -23,7 +23,7 @@ import logging import os import re -from typing import Sequence, Union +from typing import Dict, Sequence, Tuple, Union from radicale import sharing from radicale.tests import BaseTest @@ -45,13 +45,13 @@ def setup_method(self) -> None: f.write(htpasswd_content) # Helper functions - def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str | None], data: str, content_type: str, accept_type: Union[str | None]): + def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str | None], data: str, content_type: str, accept_type: Union[str | None]) -> Tuple[int, Dict[str, str], str]: path_base = "/.sharing/v1/" + sharing_type + "/" _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept_type) logging.debug("received answer:\n%s", "\n".join(answer.splitlines())) return _, headers, answer - def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str | None], form_array: Sequence[str], accept_type: Union[str | None] = None): + def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str | None], form_array: Sequence[str], accept_type: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: data = "\n".join(form_array) content_type = "application/x-www-form-urlencoded" if accept_type is None: @@ -59,14 +59,12 @@ def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: U _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept_type) return _, headers, answer - def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str | None], json_dict: dict, accept_type: Union[str | None] = None): + def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str | None], json_dict: dict, accept_type: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: data = json.dumps(json_dict) content_type = "application/json" if accept_type is None: accept_type = "application/json" _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept_type) - if check == 200 and accept_type == "application/json": - answer = json.loads(answer) return _, headers, answer # Test functions @@ -177,9 +175,9 @@ def test_sharing_api_list_with_auth(self) -> None: logging.debug("*** list (json->json)") json_dict = {} _, headers, answer = self._sharing_api_json(sharing_type, "list", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "not-found" - assert answer['Lines'] == 0 - assert answer['Content'] is None + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" + assert answer_dict['Lines'] == 0 def test_sharing_api_token_basic(self) -> None: """share-by-token API tests.""" @@ -248,9 +246,10 @@ def test_sharing_api_token_basic(self) -> None: logging.debug("*** lookup token#2 (json->json)") json_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) - assert "success" in answer['Status'] - assert answer['Lines'] == 1 - assert "/owner/collection2" in answer['Content'][0]['PathMapped'] + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['PathMapped'] == "/owner/collection2" logging.debug("*** lookup tokens (form->text)") form_array = [] @@ -294,15 +293,17 @@ def test_sharing_api_token_basic(self) -> None: logging.debug("*** lookup token#2 (json->json) -> check for not enabled") json_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" - assert answer['Lines'] == 1 - assert answer['Content'][0]['EnabledByOwner'] == str(False) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['EnabledByOwner'] == str(False) logging.debug("*** enable token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "enable", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** lookup token#2 (form->text) -> check for enabled") form_array = [] @@ -330,28 +331,32 @@ def test_sharing_api_token_basic(self) -> None: json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "unhide", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** lookup token#2 (json->json) -> check for not hidden") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" - assert answer['Lines'] == 1 - assert answer['Content'][0]['HiddenByOwner'] == str(False) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['HiddenByOwner'] == str(False) logging.debug("*** delete token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** lookup token#2 (json->json) -> should not be there anymore") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "not-found" - assert answer['Lines'] == 0 + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" + assert answer_dict['Lines'] == 0 def test_sharing_api_token_usage(self) -> None: """share-by-token API tests - real usage.""" @@ -392,6 +397,20 @@ def test_sharing_api_token_usage(self) -> None: else: assert False + logging.debug("*** create token#2") + form_array = [] + form_array.append("PathMapped=/owner/calendar2.ics") + _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) + assert "Status=success" in answer + assert "PathOrToken=" in answer + # extract token + match = re.search('PathOrToken=(.+)', answer) + if match: + token2 = match.group(1) + logging.debug("received token %r", token2) + else: + assert False + logging.debug("*** enable token (form->text)") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "enable", 200, "owner:ownerpw", form_array) @@ -421,10 +440,24 @@ def test_sharing_api_token_usage(self) -> None: _, headers, answer = self.request("GET", path_token + token, check=200) assert "UID:event" in answer + logging.debug("*** delete token#2 (json->json)") + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['ApiVersion'] == "1" + assert answer_dict['Status'] == "success" + logging.debug("*** delete token (json->json)") json_dict = {'PathOrToken': token} _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['ApiVersion'] == "1" + assert answer_dict['Status'] == "success" + + logging.debug("*** delete token (form->text) -> no longer available") + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "delete", 404, "owner:ownerpw", form_array) logging.debug("*** fetch collection using deleted token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=401) @@ -488,23 +521,25 @@ def test_sharing_api_map_usage(self) -> None: json_dict['PathMapped'] = "/owner/calendar.ics" json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** lookup map without filter (json->json)") json_dict = {} _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" - assert answer['Lines'] == 1 - assert answer['Content'][0]['PathOrToken'] == path_share - assert answer['Content'][0]['PathMapped'] == path_mapped - assert answer['Content'][0]['ShareType'] == "map" - assert answer['Content'][0]['Owner'] == "owner" - assert answer['Content'][0]['User'] == "user" - assert answer['Content'][0]['EnabledByOwner'] == str(False) - assert answer['Content'][0]['EnabledByUser'] == str(False) - assert answer['Content'][0]['HiddenByOwner'] == str(True) - assert answer['Content'][0]['HiddenByUser'] == str(True) - assert answer['Content'][0]['Permissions'] == "r" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['PathOrToken'] == path_share + assert answer_dict['Content'][0]['PathMapped'] == path_mapped + assert answer_dict['Content'][0]['ShareType'] == "map" + assert answer_dict['Content'][0]['Owner'] == "owner" + assert answer_dict['Content'][0]['User'] == "user" + assert answer_dict['Content'][0]['EnabledByOwner'] == str(False) + assert answer_dict['Content'][0]['EnabledByUser'] == str(False) + assert answer_dict['Content'][0]['HiddenByOwner'] == str(True) + assert answer_dict['Content'][0]['HiddenByUser'] == str(True) + assert answer_dict['Content'][0]['Permissions'] == "r" logging.debug("*** enable map by owner (json->json)") json_dict = {} @@ -519,6 +554,8 @@ def test_sharing_api_map_usage(self) -> None: json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** enable map by user (json->json)") json_dict = {} @@ -526,7 +563,8 @@ def test_sharing_api_map_usage(self) -> None: json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** enable map by user for owner (json->json) -> should fail") json_dict = {} @@ -553,7 +591,8 @@ def test_sharing_api_map_usage(self) -> None: json_dict['PathMapped'] = "/owner/calendar.ics" json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" _, headers, answer = self._sharing_api_json("map", "disable", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** fetch collection via map (with credentials) as user -> n/a") _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) @@ -564,8 +603,8 @@ def test_sharing_api_map_usage(self) -> None: json_dict['PathMapped'] = "/owner/calendar.ics" json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) - logging.debug("received answer %r", answer) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** fetch collection via map (with credentials) as user") _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) @@ -576,7 +615,8 @@ def test_sharing_api_map_usage(self) -> None: json_dict['PathMapped'] = "/owner/calendar.ics" json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" _, headers, answer = self._sharing_api_json("map", "disable", 200, "user:userpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" logging.debug("*** fetch collection via map (with credentials) as user -> n/a") _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) @@ -594,6 +634,7 @@ def test_sharing_api_map_usage(self) -> None: json_dict['PathMapped'] = "/owner/calendar.ics" json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" _, headers, answer = self._sharing_api_json("map", "delete", 200, "owner:ownerpw", json_dict) - assert answer['Status'] == "success" + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" # TODO hide+unhide for REPORT From 6bcda96dc28035b43233ef66dda4c288a0420ed4 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 22:41:04 +0100 Subject: [PATCH 046/138] fix typing --- radicale/sharing/none.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/radicale/sharing/none.py b/radicale/sharing/none.py index 42bae5dbe..daca8c63c 100644 --- a/radicale/sharing/none.py +++ b/radicale/sharing/none.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . +from typing import Union + from radicale import sharing from radicale.log import logger @@ -24,13 +26,13 @@ def init_database(self) -> bool: """ dummy initialization """ return True - def get_sharing_collection_by_token(self, token: str) -> [dict | None]: + def get_sharing_collection_by_token(self, token: str) -> Union[dict | None]: """ retrieve target and attributs by token """ # default logger.debug("TRACE/sharing_by_token: 'none' cannot provide any map for token: %r", token) return None - def get_sharing_collection_by_map(self, path) -> [dict | None]: + def get_sharing_collection_by_map(self, path) -> Union[dict | None]: """ retrieve target and attributs by map """ logger.debug("TRACE/sharing_by_map: 'none' cannot provide any map for path: %r", path) return {"mapped": False} From 8e185ef220b1a94695e77efa3212d38efc94b12e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 22:41:25 +0100 Subject: [PATCH 047/138] cosmetics --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 36ff8f04a..14203b0c0 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -62,7 +62,7 @@ def load(configuration: "config.Configuration") -> "BaseSharing": - """Load the sharing module chosen in configuration.""" + """Load the sharing database module chosen in configuration.""" return utils.load_plugin(INTERNAL_TYPES, "sharing", "Sharing", BaseSharing, configuration) From 5e516389852e86bee9bae0dcdb32edaf694f6567 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 12 Feb 2026 22:41:38 +0100 Subject: [PATCH 048/138] fix typing --- radicale/app/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index ccf5e69f2..ad2bf536c 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -20,7 +20,7 @@ import posixpath import sys import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, Union from radicale import (auth, config, hook, httputils, pathutils, rights, sharing, storage, types, utils, web, xmlutils) @@ -110,7 +110,7 @@ class Access: _rights: rights.BaseRights _parent_permissions: Optional[str] - def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_filter: str = None + def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_filter: Union[str | None] = None ) -> None: self._rights = rights self.user = user From b817d3c3750286b51f792102229aa3aeef2f4fc5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 06:23:51 +0100 Subject: [PATCH 049/138] change to status codes on create --- radicale/sharing/csv.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 2bc70dc7f..2cce02b2b 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -137,7 +137,7 @@ def create_sharing(self, Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, - Timestamp: int = 0) -> bool: + Timestamp: int = 0) -> dict: """ create sharing """ row: dict @@ -150,7 +150,7 @@ def create_sharing(self, if row['PathOrToken'] == PathOrToken: # must be unique systemwide logger.error("sharing/token/create: PathOrToken already exists: PathOrToken=%r", PathOrToken) - return False + return {"status": "conflict"} elif ShareType == "map": logger.debug("TRACE/sharing/map/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) # check for duplicate map entry @@ -159,8 +159,8 @@ def create_sharing(self, continue if row['PathMapped'] == PathMapped and row['User'] == User: # must be unique systemwide - logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User, Permissions) - return False + logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) + return {"status": "conflict"} row = {"ShareType": ShareType, "PathOrToken": PathOrToken, @@ -179,9 +179,9 @@ def create_sharing(self, self._sharing_cache.append(row) if self._write_csv(self._sharing_db_file): logger.debug("TRACE/sharing_by_token: write CSV done") - return True - logger.warning("sharing/add_sharing_by_token: cannot update CSV database") - return False + return {"status": "success"} + logger.error("sharing/add_sharing_by_token: cannot update CSV database") + return {"status": "error"} def delete_sharing(self, ShareType: str, From 4252bf06a3f66389f7c27e6faabfb456445e31fd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 06:24:35 +0100 Subject: [PATCH 050/138] add permission check for owner/user access mismatch --- radicale/sharing/__init__.py | 58 ++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 14203b0c0..c5d8409e5 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -132,9 +132,9 @@ def create_sharing(self, Permissions: str = "r", EnabledByOwner: bool = False, EnabledByUser: bool = False, HiddenByOwner: bool = True, HiddenByUser: bool = True, - Timestamp: int = 0) -> bool: + Timestamp: int = 0) -> dict: """ create sharing """ - return False + return {} def delete_sharing(self, ShareType: str, @@ -474,48 +474,62 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("TRACE/" + api_info + ": %r (Permissions=%r token=%r)", PathMapped, Permissions, token) - if not self.create_sharing( + result = self.create_sharing( ShareType=ShareType, PathOrToken=token, PathMapped=PathMapped, Owner=Owner, User=Owner, Permissions=Permissions, EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, - Timestamp=Timestamp): - logger.info("Add sharing-by-token: %r by user %s not successful", PathMapped, user) - return httputils.BAD_REQUEST + Timestamp=Timestamp) - logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token) + elif ShareType == "map": + # check preconditions + if PathOrToken is None: + return httputils.BAD_REQUEST + else: + PathOrToken = str(PathOrToken) - answer['Status'] = "success" - answer['PathOrToken'] = token + if User is None: + return httputils.BAD_REQUEST + else: + User = str(User) - elif ShareType == "map": # check access Permissions - access = Access(self._rights, user, PathMapped) + access = Access(self._rights, Owner, PathMapped) if not access.check("r") and "i" not in access.permissions: - logger.info("Add sharing-by-map: access to %r not allowed for user %r", PathMapped, user) + logger.info("Add sharing-by-map: access to path(mapped) %r not allowed for owner %r", PathMapped, Owner) return httputils.NOT_ALLOWED - if PathOrToken is None: - return httputils.BAD_REQUEST - if User is None: - return httputils.BAD_REQUEST + access = Access(self._rights, str(User), str(PathOrToken)) + if not access.check("r") and "i" not in access.permissions: + logger.info("Add sharing-by-map: access to path %r not allowed for user %r", PathOrToken, user) + return httputils.NOT_ALLOWED logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r user=%r)", PathMapped, Permissions, PathOrToken, User) - if not self.create_sharing( + result = self.create_sharing( ShareType=ShareType, - PathOrToken=str(PathOrToken), # verification above that it is not None + PathOrToken=PathOrToken, # verification above that it is not None PathMapped=PathMapped, Owner=Owner, - User=str(User), # verification above that it is not None + User=User, # verification above that it is not None Permissions=Permissions, EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, EnabledByUser=EnabledByUser, HiddenByUser=HiddenByUser, - Timestamp=Timestamp): - logger.error(api_info + ": %r (%r) -> %r (%r)", PathMapped, User, PathOrToken, Owner) - return httputils.BAD_REQUEST + Timestamp=Timestamp) + # result handling + if result['status'] == "conflict": + return httputils.CONFLICT + elif result['status'] == "error": + return httputils.INTERNAL_SERVER_ERROR + elif result['status'] == "success": answer['Status'] = "success" + else: + return httputils.BAD_REQUEST + + if ShareType == "token": + logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token) + answer['PathOrToken'] = token # action: delete elif action == "delete": From cc207a191a0f936171f433bb0c9e0026585142a7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 06:25:07 +0100 Subject: [PATCH 051/138] additional testcases --- radicale/tests/test_sharing.py | 77 +++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index d72c5c6e9..93779805e 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -40,7 +40,10 @@ def setup_method(self) -> None: BaseTest.setup_method(self) self.htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") encoding: str = self.configuration.get("encoding", "stock") - htpasswd_content = "owner:ownerpw\nuser:userpw" + htpasswd = ["owner:ownerpw", "user:userpw", + "owner1:owner1pw", "user1:user1pw", + "owner2:owner2pw", "user2:user2pw"] + htpasswd_content = "\n".join(htpasswd) with open(self.htpasswd_file_path, "w", encoding=encoding) as f: f.write(htpasswd_content) @@ -637,4 +640,74 @@ def test_sharing_api_map_usage(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - # TODO hide+unhide for REPORT + def test_sharing_api_map_usercheck(self) -> None: + """share-by-map API usage tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + path_share1 = "/user1/calendar-shared-by-owner1.ics" + path_mapped1 = "/owner1/calendar1.ics" + path_share2 = "/user2/calendar-shared-by-owner2.ics" + path_mapped2 = "/owner2/calendar2.ics" + + logging.debug("*** prepare and test access") + self.mkcalendar(path_mapped1, login="%s:%s" % ("owner1", "owner1pw")) + event = get_file_content("event1.ics") + path = path_mapped1 + "/event1.ics" + self.put(path, event, login="%s:%s" % ("owner1", "owner1pw")) + + self.mkcalendar(path_mapped2, login="%s:%s" % ("owner2", "owner2pw")) + event = get_file_content("event1.ics") + path = path_mapped2 + "/event1.ics" + self.put(path, event, login="%s:%s" % ("owner2", "owner2pw")) + + logging.debug("*** create map user1/owner1 as owner -> fail") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "create", 403, "owner:ownerpw", json_dict) + + logging.debug("*** create map user1/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner1:owner1pw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.debug("*** create map user1/owner1 (repeat) -> fail") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "create", 409, "owner1:owner1pw", json_dict) + + logging.debug("*** create map user2/owner2 -> ok") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_share2 + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner2:owner2pw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.debug("*** create map user2/owner1 -> fail") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "create", 403, "owner2:owner2pw", json_dict) + + # TODO hide+unhide for REPORT From b4e5dbde3558408743b0404ed6bb1b5a0eb18b67 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 06:33:48 +0100 Subject: [PATCH 052/138] fix conflict check --- radicale/sharing/csv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 2cce02b2b..55f0c7cf4 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -157,7 +157,7 @@ def create_sharing(self, for row in self._sharing_cache: if row['ShareType'] != "map": continue - if row['PathMapped'] == PathMapped and row['User'] == User: + if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken: # must be unique systemwide logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) return {"status": "conflict"} From 01e35df14350796cca5c17b31791b20b5a7c114d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 06:34:09 +0100 Subject: [PATCH 053/138] start permission checks --- radicale/tests/test_sharing.py | 65 +++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 93779805e..cac225c81 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -641,7 +641,7 @@ def test_sharing_api_map_usage(self) -> None: assert answer_dict['Status'] == "success" def test_sharing_api_map_usercheck(self) -> None: - """share-by-map API usage tests.""" + """share-by-map API usage tests related to usercheck.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, @@ -710,4 +710,67 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict['PathOrToken'] = path_share1 _, headers, answer = self._sharing_api_json("map", "create", 403, "owner2:owner2pw", json_dict) + def test_sharing_api_map_permissions(self) -> None: + """share-by-map API usage tests related to permissions.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + path_share_r = "/user/calendar-shared-by-owner-r.ics" + path_share_w = "/user/calendar-shared-by-owner-w.ics" + path_share_rw = "/user/calendar-shared-by-owner-rw.ics" + path_mapped = "/owner/calendar.ics" + + logging.debug("*** prepare and test access") + self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event1.ics") + path = path_mapped + "/event1.ics" + self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + + logging.debug("*** create map user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share_r + json_dict['Permissions'] = "r" + json_dict['EnabledByOwner'] = "True" + json_dict['EnabledByUser'] = "True" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.debug("*** create map user/owner:w -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share_w + json_dict['Permissions'] = "w" + json_dict['EnabledByOwner'] = "True" + json_dict['EnabledByUser'] = "True" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.debug("*** create map user/owner:rw -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share_rw + json_dict['Permissions'] = "w" + json_dict['EnabledByOwner'] = "True" + json_dict['EnabledByUser'] = "True" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # TODO hide+unhide for REPORT From 6e947c1893d93af0328816ea1196ed9d83feff1a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 07:13:59 +0100 Subject: [PATCH 054/138] bugfix --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index c5d8409e5..a41c7b390 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -460,7 +460,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st Permissions = request_data['Permissions'] if 'Enabled' in request_data: - EnabledByOwner = config._convert_to_bool(request_data['EnabledByOwner']) + EnabledByOwner = config._convert_to_bool(request_data['Enabled']) if ShareType == "token": # check access Permissions From 91272b144522750dba7b190b91eb2b5215268263 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 07:39:07 +0100 Subject: [PATCH 055/138] add mapping to PUT --- radicale/app/put.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/radicale/app/put.py b/radicale/app/put.py index 86e863ef3..191a3655a 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -4,7 +4,7 @@ # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2020 Unrud # Copyright © 2020-2023 Tuna Celik -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -33,7 +33,7 @@ import vobject import radicale.item as radicale_item -from radicale import (httputils, pathutils, rights, storage, types, utils, +from radicale import (httputils, pathutils, rights, sharing, storage, types, utils, xmlutils) from radicale.app.base import Access, ApplicationBase from radicale.hook import HookNotificationItem, HookNotificationItemTypes @@ -181,7 +181,17 @@ class ApplicationPartPut(ApplicationBase): def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PUT request.""" - access = Access(self._rights, user, path) + # Sharing by token or map + result = self._sharing.sharing_collection_resolver(path, user) + if result: + # overwrite and run through extended permission check + path = result['PathMapped'] + user = result['Owner'] + permissions_filter = result['Permissions'] + access = Access(self._rights, user, path, permissions_filter) + else: + # default permission check + access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED try: From d4bae7c69fe0f454e82f682f2cb60415f96ffc2b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 07:39:21 +0100 Subject: [PATCH 056/138] extend debug --- radicale/sharing/csv.py | 1 + 1 file changed, 1 insertion(+) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 55f0c7cf4..b19937789 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -83,6 +83,7 @@ def get_sharing(self, User: Union[str | None] = None) -> Union[dict | None]: """ retrieve sharing target and attributes by map """ # Lookup + logger.debug("TRACE/sharing: lookup ShareType=%r PathOrToken=%r User=%r)", ShareType, PathOrToken, User) for row in self._sharing_cache: if row['ShareType'] != ShareType: continue From 2944bd66f057ea2183b5bf7b0a27aa102297d002 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 07:39:44 +0100 Subject: [PATCH 057/138] add parent_path fallback for mapping --- radicale/sharing/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index a41c7b390..51c6f8faa 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -17,6 +17,7 @@ import base64 import io import json +import posixpath import re import socket import uuid @@ -26,7 +27,7 @@ from typing import Sequence, Union from urllib.parse import parse_qs -from radicale import config, httputils, rights, types, utils +from radicale import config, httputils, pathutils, rights, types, utils from radicale.app.base import Access from radicale.log import logger @@ -208,10 +209,19 @@ def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict """ returning dict with mapped-flag, path, user, rights or None if invalid""" if self.sharing_collection_by_map: logger.debug("TRACE/sharing/resolver/map: check path: %r", path) - return self.get_sharing( + result = self.get_sharing( ShareType="map", PathOrToken=path, User=user) + if result: + return result + # fallback to parent path + parent_path = pathutils.unstrip_path(posixpath.dirname(pathutils.strip_path(path)), True) + logger.debug("TRACE/sharing/resolver/map: check parent path: %r", parent_path) + return self.get_sharing( + ShareType="map", + PathOrToken=parent_path, + User=user) else: logger.debug("TRACE/sharing_by_map: not active") return {"mapped": False} From 8f3aecb7998293892fcaec882a7115054a76e00c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Feb 2026 22:47:49 +0100 Subject: [PATCH 058/138] add testcases, cosmetics --- radicale/tests/test_sharing.py | 355 +++++++++++++++++++++++---------- 1 file changed, 247 insertions(+), 108 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index cac225c81..8f007ab2b 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -51,7 +51,7 @@ def setup_method(self) -> None: def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str | None], data: str, content_type: str, accept_type: Union[str | None]) -> Tuple[int, Dict[str, str], str]: path_base = "/.sharing/v1/" + sharing_type + "/" _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept_type) - logging.debug("received answer:\n%s", "\n".join(answer.splitlines())) + logging.info("received answer:\n%s", "\n".join(answer.splitlines())) return _, headers, answer def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str | None], form_array: Sequence[str], accept_type: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: @@ -158,24 +158,23 @@ def test_sharing_api_list_with_auth(self) -> None: action = "list" for sharing_type in sharing.SHARE_TYPES: - logging.debug("*** list (without form) -> should fail") + logging.info("\n*** list (without form) -> should fail") path = "/.sharing/v1/" + sharing_type + "/" + action _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) - logging.debug("*** list (form->csv)") + logging.info("\n*** list (form->csv)") form_array = [] _, headers, answer = self._sharing_api_form(sharing_type, "list", 200, "owner:ownerpw", form_array) assert "Status=not-found" in answer assert "Lines=0" in answer - logging.debug("*** list (json->text)") + logging.info("\n*** list (json->text)") json_dict = {} _, headers, answer = self._sharing_api_json(sharing_type, "list", 200, "owner:ownerpw", json_dict, "text/plain") - logging.debug("received answer %r", answer) assert "Status=not-found" in answer assert "Lines=0" in answer - logging.debug("*** list (json->json)") + logging.info("\n*** list (json->json)") json_dict = {} _, headers, answer = self._sharing_api_json(sharing_type, "list", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) @@ -198,15 +197,15 @@ def test_sharing_api_token_basic(self) -> None: form_array: Sequence[str] json_dict: dict - logging.debug("*** create token without PathMapped (form) -> should fail") + logging.info("\n*** create token without PathMapped (form) -> should fail") form_array = [] _, headers, answer = self._sharing_api_form("token", "create", 400, "owner:ownerpw", form_array) - logging.debug("*** create token without PathMapped (json) -> should fail") + logging.info("\n*** create token without PathMapped (json) -> should fail") json_dict = {} _, headers, answer = self._sharing_api_json("token", "create", 400, "owner:ownerpw", json_dict) - logging.debug("*** create token#1 (form->text)") + logging.info("\n*** create token#1 (form->text)") form_array = ["PathMapped=/owner/collection1"] _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) assert "Status=success" in answer @@ -215,11 +214,11 @@ def test_sharing_api_token_basic(self) -> None: match = re.search('PathOrToken=(.+)', answer) if match: token1 = match.group(1) - logging.debug("received token %r", token1) + logging.info("received token %r", token1) else: assert False - logging.debug("*** create token#2 (json->text)") + logging.info("\n*** create token#2 (json->text)") json_dict = {'PathMapped': "/owner/collection2"} _, headers, answer = self._sharing_api_json("token", "create", 200, "owner:ownerpw", json_dict, "text/plain") assert "Status=success" in answer @@ -228,25 +227,25 @@ def test_sharing_api_token_basic(self) -> None: match = re.search('Token=(.+)', answer) if match: token2 = match.group(1) - logging.debug("received token %r", token2) + logging.info("received token %r", token2) else: assert False - logging.debug("*** lookup token#1 (form->text)") + logging.info("\n*** lookup token#1 (form->text)") form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection1" in answer - logging.debug("*** lookup token#2 (json->text") + logging.info("\n*** lookup token#2 (json->text") json_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict, "text/plain") assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection2" in answer - logging.debug("*** lookup token#2 (json->json)") + logging.info("\n*** lookup token#2 (json->json)") json_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) @@ -254,7 +253,7 @@ def test_sharing_api_token_basic(self) -> None: assert answer_dict['Lines'] == 1 assert answer_dict['Content'][0]['PathMapped'] == "/owner/collection2" - logging.debug("*** lookup tokens (form->text)") + logging.info("\n*** lookup tokens (form->text)") form_array = [] _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=success" in answer @@ -262,7 +261,7 @@ def test_sharing_api_token_basic(self) -> None: assert "/owner/collection1" in answer assert "/owner/collection2" in answer - logging.debug("*** lookup tokens (form->csv)") + logging.info("\n*** lookup tokens (form->csv)") form_array = [] _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array, "text/csv") assert "Status=success" not in answer @@ -271,29 +270,29 @@ def test_sharing_api_token_basic(self) -> None: assert "/owner/collection1" in answer assert "/owner/collection2" in answer - logging.debug("*** delete token#1 (form->text)") + logging.info("\n*** delete token#1 (form->text)") form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "delete", 200, "owner:ownerpw", form_array) assert "Status=success" in answer - logging.debug("*** lookup token#1 (form->text) -> should not be there anymore") + logging.info("\n*** lookup token#1 (form->text) -> should not be there anymore") form_array = ["PathOrToken=" + token1] _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=not-found" in answer assert "Lines=0" in answer - logging.debug("*** lookup tokens (form->text) -> still one should be there") + logging.info("\n*** lookup tokens (form->text) -> still one should be there") form_array = [] _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) assert "Status=success" in answer assert "Lines=1" in answer - logging.debug("*** disable token#2 (form->text)") + logging.info("\n*** disable token#2 (form->text)") form_array = ["PathOrToken=" + token2] _, headers, answer = self._sharing_api_form("token", "disable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer - logging.debug("*** lookup token#2 (json->json) -> check for not enabled") + logging.info("\n*** lookup token#2 (json->json) -> check for not enabled") json_dict = {'PathOrToken': token2} _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) @@ -301,14 +300,14 @@ def test_sharing_api_token_basic(self) -> None: assert answer_dict['Lines'] == 1 assert answer_dict['Content'][0]['EnabledByOwner'] == str(False) - logging.debug("*** enable token#2 (json->json)") + logging.info("\n*** enable token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "enable", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** lookup token#2 (form->text) -> check for enabled") + logging.info("\n*** lookup token#2 (form->text) -> check for enabled") form_array = [] form_array.append("PathOrToken=" + token2) _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) @@ -316,13 +315,13 @@ def test_sharing_api_token_basic(self) -> None: assert "Lines=1" in answer assert "True,True,True,True" in answer - logging.debug("*** hide token#2 (form->text)") + logging.info("\n*** hide token#2 (form->text)") form_array = [] form_array.append("PathOrToken=" + token2) _, headers, answer = self._sharing_api_form("token", "hide", 200, "owner:ownerpw", form_array) assert "Status=success" in answer - logging.debug("*** lookup token#2 (form->text) -> check for hidden") + logging.info("\n*** lookup token#2 (form->text) -> check for hidden") form_array = [] form_array.append("PathOrToken=" + token2) _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) @@ -330,14 +329,14 @@ def test_sharing_api_token_basic(self) -> None: assert "Lines=1" in answer assert "True,True,True,True" in answer - logging.debug("*** unhide token#2 (json->json)") + logging.info("\n*** unhide token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "unhide", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** lookup token#2 (json->json) -> check for not hidden") + logging.info("\n*** lookup token#2 (json->json) -> check for not hidden") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) @@ -346,14 +345,14 @@ def test_sharing_api_token_basic(self) -> None: assert answer_dict['Lines'] == 1 assert answer_dict['Content'][0]['HiddenByOwner'] == str(False) - logging.debug("*** delete token#2 (json->json)") + logging.info("\n*** delete token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** lookup token#2 (json->json) -> should not be there anymore") + logging.info("\n*** lookup token#2 (json->json) -> should not be there anymore") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) @@ -378,15 +377,23 @@ def test_sharing_api_token_usage(self) -> None: json_dict: dict path_token = "/.token/" + path_base = "/owner/calendar.ics" - logging.debug("*** prepare and test access") + logging.info("\n*** prepare") self.mkcalendar("/owner/calendar.ics/", login="%s:%s" % ("owner", "ownerpw")) event = get_file_content("event1.ics") - path = "/owner/calendar.ics/event1.ics" + path = path_base + "/event1.ics" self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** test access to collection") + _, headers, answer = self.request("GET", path_base, check=200, login="%s:%s" % ("owner", "ownerpw")) + assert "UID:event" in answer + + logging.info("\n*** test access to item") _, headers, answer = self.request("GET", path, check=200, login="%s:%s" % ("owner", "ownerpw")) + assert "UID:event" in answer - logging.debug("*** create token") + logging.info("\n*** create token") form_array = [] form_array.append("PathMapped=/owner/calendar.ics") _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) @@ -396,11 +403,11 @@ def test_sharing_api_token_usage(self) -> None: match = re.search('PathOrToken=(.+)', answer) if match: token = match.group(1) - logging.debug("received token %r", token) + logging.info("received token %r", token) else: assert False - logging.debug("*** create token#2") + logging.info("\n*** create token#2") form_array = [] form_array.append("PathMapped=/owner/calendar2.ics") _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) @@ -410,40 +417,40 @@ def test_sharing_api_token_usage(self) -> None: match = re.search('PathOrToken=(.+)', answer) if match: token2 = match.group(1) - logging.debug("received token %r", token2) + logging.info("received token %r", token2) else: assert False - logging.debug("*** enable token (form->text)") + logging.info("\n*** enable token (form->text)") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "enable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer - logging.debug("*** fetch collection using invalid token (without credentials)") + logging.info("\n*** fetch collection using invalid token (without credentials)") _, headers, answer = self.request("GET", path_token + "v1/invalidtoken", check=401) - logging.debug("*** fetch collection using token (without credentials)") + logging.info("\n*** fetch collection using token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=200) assert "UID:event" in answer - logging.debug("*** disable token (form->text)") + logging.info("\n*** disable token (form->text)") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "disable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer - logging.debug("*** fetch collection using disabled token (without credentials)") + logging.info("\n*** fetch collection using disabled token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=401) - logging.debug("*** enable token (form->text)") + logging.info("\n*** enable token (form->text)") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "enable", 200, "owner:ownerpw", form_array) assert "Status=success" in answer - logging.debug("*** fetch collection using token (without credentials)") + logging.info("\n*** fetch collection using token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=200) assert "UID:event" in answer - logging.debug("*** delete token#2 (json->json)") + logging.info("\n*** delete token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) @@ -451,18 +458,18 @@ def test_sharing_api_token_usage(self) -> None: assert answer_dict['ApiVersion'] == "1" assert answer_dict['Status'] == "success" - logging.debug("*** delete token (json->json)") + logging.info("\n*** delete token (json->json)") json_dict = {'PathOrToken': token} _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['ApiVersion'] == "1" assert answer_dict['Status'] == "success" - logging.debug("*** delete token (form->text) -> no longer available") + logging.info("\n*** delete token (form->text) -> no longer available") form_array = ["PathOrToken=" + token] _, headers, answer = self._sharing_api_form("token", "delete", 404, "owner:ownerpw", form_array) - logging.debug("*** fetch collection using deleted token (without credentials)") + logging.info("\n*** fetch collection using deleted token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=401) def test_sharing_api_map_basic(self) -> None: @@ -480,15 +487,15 @@ def test_sharing_api_map_basic(self) -> None: json_dict: dict - logging.debug("*** create map without PathMapped (json) -> should fail") + logging.info("\n*** create map without PathMapped (json) -> should fail") json_dict = {} _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", json_dict) - logging.debug("*** create map without PathMapped but User (json) -> should fail") + logging.info("\n*** create map without PathMapped but User (json) -> should fail") json_dict = {'User': "user"} _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", json_dict) - logging.debug("*** create map without PathMapped but User and PathOrToken (json) -> should fail") + logging.info("\n*** create map without PathMapped but User and PathOrToken (json) -> should fail") json_dict = {} json_dict['User'] = "user" json_dict['PathOrToken'] = "/owner/calendar.ics" @@ -509,25 +516,41 @@ def test_sharing_api_map_usage(self) -> None: json_dict: dict - path_share = "/user/calendar-shared-by-owner.ics" - path_mapped = "/owner/calendar.ics" + file_item1 = "event1.ics" + file_item2 = "event2.ics" + path_share = "/user/calendar-shared-by-owner.ics/" + path_share_item1 = os.path.join(path_share, file_item1) + path_share_item2 = os.path.join(path_share, file_item2) + path_mapped = "/owner/calendar.ics/" + path_mapped_item1 = os.path.join(path_mapped, file_item1) + path_mapped_item2 = os.path.join(path_mapped, file_item2) - logging.debug("*** prepare and test access") + logging.info("\n*** prepare and test access") self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) - event = get_file_content("event1.ics") - path = path_mapped + "/event1.ics" - self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content(file_item1) + self.put(path_mapped_item1, event, check=201, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content(file_item2) + self.put(path_mapped_item2, event, check=201, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** test access to collection") + _, headers, answer = self.request("GET", path_mapped, check=200, login="%s:%s" % ("owner", "ownerpw")) + assert "UID:event1" in answer + assert "UID:event2" in answer - logging.debug("*** create map with PathMapped and User and PathOrToken (json)") + logging.info("\n*** test access to item") + _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="%s:%s" % ("owner", "ownerpw")) + assert "UID:event1" in answer + + logging.info("\n*** create map with PathMapped and User and PathOrToken (json)") json_dict = {} json_dict['User'] = "user" - json_dict['PathMapped'] = "/owner/calendar.ics" - json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** lookup map without filter (json->json)") + logging.info("\n*** lookup map without filter (json->json)") json_dict = {} _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) @@ -544,14 +567,14 @@ def test_sharing_api_map_usage(self) -> None: assert answer_dict['Content'][0]['HiddenByUser'] == str(True) assert answer_dict['Content'][0]['Permissions'] == "r" - logging.debug("*** enable map by owner (json->json)") + logging.info("\n*** enable map by owner (json->json)") json_dict = {} json_dict['User'] = "owner" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "enable", 404, "owner:ownerpw", json_dict) - logging.debug("*** enable map by owner for user (json->json)") + logging.info("\n*** enable map by owner for user (json->json)") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped @@ -560,7 +583,7 @@ def test_sharing_api_map_usage(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** enable map by user (json->json)") + logging.info("\n*** enable map by user (json->json)") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped @@ -569,73 +592,86 @@ def test_sharing_api_map_usage(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** enable map by user for owner (json->json) -> should fail") + logging.info("\n*** enable map by user for owner (json->json) -> should fail") json_dict = {} json_dict['User'] = "owner" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "enable", 403, "user:userpw", json_dict) - logging.debug("*** fetch collection (without credentials)") + logging.info("\n*** fetch collection (without credentials)") _, headers, answer = self.request("GET", path_mapped, check=401) - logging.debug("*** fetch collection (with credentials) as owner") + logging.info("\n*** fetch collection (with credentials) as owner") _, headers, answer = self.request("GET", path_mapped, check=200, login="%s:%s" % ("owner", "ownerpw")) + assert "UID:event" in answer - logging.debug("*** fetch collection (with credentials) as user") + logging.info("\n*** fetch item (with credentials) as owner") + _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="%s:%s" % ("owner", "ownerpw")) + assert "UID:event" in answer + + logging.info("\n*** fetch collection (with credentials) as user") _, headers, answer = self.request("GET", path_mapped, check=403, login="%s:%s" % ("user", "userpw")) - logging.debug("*** fetch collection via map (with credentials) as user") + logging.info("\n*** fetch collection via map (with credentials) as user") _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) + assert "UID:event1" in answer + assert "UID:event2" in answer + + logging.info("\n*** fetch item via map (with credentials) as user") + _, headers, answer = self.request("GET", path_share_item1, check=200, login="%s:%s" % ("user", "userpw")) + assert "UID:event1" in answer + assert "UID:event2" not in answer + exit(1) - logging.debug("*** disable map by owner (json->json)") + logging.info("\n*** disable map by owner (json->json)") json_dict = {} json_dict['User'] = "user" - json_dict['PathMapped'] = "/owner/calendar.ics" - json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "disable", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** fetch collection via map (with credentials) as user -> n/a") + logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) - logging.debug("*** enable map by owner (json->json)") + logging.info("\n*** enable map by owner (json->json)") json_dict = {} json_dict['User'] = "user" - json_dict['PathMapped'] = "/owner/calendar.ics" - json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** fetch collection via map (with credentials) as user") + logging.info("\n*** fetch collection via map (with credentials) as user") _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) - logging.debug("*** disable map by user (json->json)") + logging.info("\n*** disable map by user (json->json)") json_dict = {} json_dict['User'] = "user" - json_dict['PathMapped'] = "/owner/calendar.ics" - json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "disable", 200, "user:userpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** fetch collection via map (with credentials) as user -> n/a") + logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) - logging.debug("*** delete map by user (json->json) -> fail") + logging.info("\n*** delete map by user (json->json) -> fail") json_dict = {} json_dict['User'] = "user" - json_dict['PathMapped'] = "/owner/calendar.ics" - json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "delete", 403, "user:userpw", json_dict) - logging.debug("*** delete map by owner (json->json) -> ok") + logging.info("\n*** delete map by owner (json->json) -> ok") json_dict = {} json_dict['User'] = "user" - json_dict['PathMapped'] = "/owner/calendar.ics" - json_dict['PathOrToken'] = "/user/calendar-shared-by-owner.ics" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share _, headers, answer = self._sharing_api_json("map", "delete", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -660,7 +696,7 @@ def test_sharing_api_map_usercheck(self) -> None: path_share2 = "/user2/calendar-shared-by-owner2.ics" path_mapped2 = "/owner2/calendar2.ics" - logging.debug("*** prepare and test access") + logging.info("\n*** prepare and test access") self.mkcalendar(path_mapped1, login="%s:%s" % ("owner1", "owner1pw")) event = get_file_content("event1.ics") path = path_mapped1 + "/event1.ics" @@ -671,14 +707,14 @@ def test_sharing_api_map_usercheck(self) -> None: path = path_mapped2 + "/event1.ics" self.put(path, event, login="%s:%s" % ("owner2", "owner2pw")) - logging.debug("*** create map user1/owner1 as owner -> fail") + logging.info("\n*** create map user1/owner1 as owner -> fail") json_dict = {} json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_share1 _, headers, answer = self._sharing_api_json("map", "create", 403, "owner:ownerpw", json_dict) - logging.debug("*** create map user1/owner1 -> ok") + logging.info("\n*** create map user1/owner1 -> ok") json_dict = {} json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped1 @@ -687,14 +723,14 @@ def test_sharing_api_map_usercheck(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** create map user1/owner1 (repeat) -> fail") + logging.info("\n*** create map user1/owner1 (repeat) -> fail") json_dict = {} json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_share1 _, headers, answer = self._sharing_api_json("map", "create", 409, "owner1:owner1pw", json_dict) - logging.debug("*** create map user2/owner2 -> ok") + logging.info("\n*** create map user2/owner2 -> ok") json_dict = {} json_dict['User'] = "user2" json_dict['PathMapped'] = path_mapped2 @@ -703,7 +739,7 @@ def test_sharing_api_map_usercheck(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** create map user2/owner1 -> fail") + logging.info("\n*** create map user2/owner1 -> fail") json_dict = {} json_dict['User'] = "user2" json_dict['PathMapped'] = path_mapped2 @@ -720,57 +756,160 @@ def test_sharing_api_map_permissions(self) -> None: "collection_by_map": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "False", - "request_content_on_debug": "True"}, + "request_content_on_debug": "False"}, "rights": {"type": "owner_only"}}) json_dict: dict - path_share_r = "/user/calendar-shared-by-owner-r.ics" - path_share_w = "/user/calendar-shared-by-owner-w.ics" - path_share_rw = "/user/calendar-shared-by-owner-rw.ics" - path_mapped = "/owner/calendar.ics" + path_share_r = "/user/calendar-shared-by-owner-r.ics/" + path_share_w = "/user/calendar-shared-by-owner-w.ics/" + path_share_rw = "/user/calendar-shared-by-owner-rw.ics/" + path_mapped = "/owner/calendar.ics/" - logging.debug("*** prepare and test access") + logging.info("\n*** prepare and test access") self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) event = get_file_content("event1.ics") path = path_mapped + "/event1.ics" self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) - logging.debug("*** create map user/owner:r -> ok") + # check + logging.info("\n*** fetch event as owner (init) -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + + # create maps + logging.info("\n*** create map user/owner:r -> ok") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_share_r json_dict['Permissions'] = "r" - json_dict['EnabledByOwner'] = "True" - json_dict['EnabledByUser'] = "True" + json_dict['Enabled'] = "True" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** create map user/owner:w -> ok") + logging.info("\n*** create map user/owner:w -> ok") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_share_w json_dict['Permissions'] = "w" - json_dict['EnabledByOwner'] = "True" - json_dict['EnabledByUser'] = "True" + json_dict['Enabled'] = "True" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.debug("*** create map user/owner:rw -> ok") + logging.info("\n*** create map user/owner:rw -> ok") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_share_rw - json_dict['Permissions'] = "w" - json_dict['EnabledByOwner'] = "True" - json_dict['EnabledByUser'] = "True" + json_dict['Permissions'] = "rw" + json_dict['Enabled'] = "True" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" + # list created maps + logging.info("\n*** list (json->text)") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict, "text/csv") + + # check permissions, no map is enabled by user -> 404 + logging.info("\n*** fetch collection via map:r -> n/a") + _, headers, answer = self.request("GET", path_share_r, check=404, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch collection via map:w -> n/a") + _, headers, answer = self.request("GET", path_share_r, check=404, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch collection via map:rw -> n/a") + _, headers, answer = self.request("GET", path_share_r, check=404, login="%s:%s" % ("user", "userpw")) + + # enable maps by user + logging.info("\n*** enable map by user:r") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share_r + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + logging.info("\n*** enable map by user:w") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share_w + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + logging.info("\n*** enable map by user:rw") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_share_rw + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + # list adjusted maps + logging.info("\n*** list (json->text)") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict, "text/csv") + + # check permissions, no map is enabled by user -> 404 + logging.info("\n*** fetch collection via map:r -> ok") + _, headers, answer = self.request("GET", path_share_r, check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch collection via map:w -> fail") + _, headers, answer = self.request("GET", path_share_w, check=403, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch collection via map:rw -> ok") + _, headers, answer = self.request("GET", path_share_rw, check=200, login="%s:%s" % ("user", "userpw")) + + # list adjusted maps + logging.info("\n*** list (json->text)") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict, "text/csv") + + # PUT + logging.info("\n*** put to collection by user via map:r -> fail") + event = get_file_content("event2.ics") + path = path_share_r + "/event2.ics" + self.put(path, event, check=403, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** put to collection by user via map:w -> ok") + event = get_file_content("event2.ics") + path = path_share_w + "event2.ics" + self.put(path, event, check=201, login="%s:%s" % ("user", "userpw")) + + # check result + logging.info("\n*** fetch event via map:r -> ok") + _, headers, answer = self.request("GET", path_share_r + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** put to collection by user via map:rw -> ok") + event = get_file_content("event3.ics") + path = path_share_rw + "event3.ics" + self.put(path, event, check=201, login="%s:%s" % ("user", "userpw")) + + # check result + logging.info("\n*** fetch event via map:r -> ok") + _, headers, answer = self.request("GET", path_share_r + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch event via map:r -> ok") + _, headers, answer = self.request("GET", path_share_r + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch event via map:rw -> ok") + _, headers, answer = self.request("GET", path_share_rw + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch event via map:rw -> ok") + _, headers, answer = self.request("GET", path_share_rw + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) # TODO hide+unhide for REPORT From e5b09ad485a87483276b4a4de13c9eadb8431891 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 06:58:03 +0100 Subject: [PATCH 059/138] remove not required import --- radicale/app/put.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/put.py b/radicale/app/put.py index 191a3655a..206fe9f95 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -33,7 +33,7 @@ import vobject import radicale.item as radicale_item -from radicale import (httputils, pathutils, rights, sharing, storage, types, utils, +from radicale import (httputils, pathutils, rights, storage, types, utils, xmlutils) from radicale.app.base import Access, ApplicationBase from radicale.hook import HookNotificationItem, HookNotificationItemTypes From 813f62540900240432934326fe5051dba745b26b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 06:58:52 +0100 Subject: [PATCH 060/138] extend test case --- radicale/tests/test_sharing.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 8f007ab2b..329c49902 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -511,7 +511,7 @@ def test_sharing_api_map_usage(self) -> None: "collection_by_map": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "False", - "request_content_on_debug": "True"}, + "request_content_on_debug": "False"}, "rights": {"type": "owner_only"}}) json_dict: dict @@ -620,9 +620,15 @@ def test_sharing_api_map_usage(self) -> None: logging.info("\n*** fetch item via map (with credentials) as user") _, headers, answer = self.request("GET", path_share_item1, check=200, login="%s:%s" % ("user", "userpw")) + # only requested event has to be in the answer assert "UID:event1" in answer assert "UID:event2" not in answer - exit(1) + + logging.info("\n*** fetch item via map (with credentials) as user") + _, headers, answer = self.request("GET", path_share_item2, check=200, login="%s:%s" % ("user", "userpw")) + # only requested event has to be in the answer + assert "UID:event2" in answer + assert "UID:event1" not in answer logging.info("\n*** disable map by owner (json->json)") json_dict = {} From 032865f8aa002202708f9b4e98fcd1a0b6a3c5fd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 07:00:02 +0100 Subject: [PATCH 061/138] apply filter also on parent permissions --- radicale/app/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index ad2bf536c..c41d38a3c 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -109,6 +109,7 @@ class Access: permissions: str _rights: rights.BaseRights _parent_permissions: Optional[str] + _permissions_filter: Union[str | None] = None def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_filter: Union[str | None] = None ) -> None: @@ -119,8 +120,8 @@ def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_ posixpath.dirname(pathutils.strip_path(path)), True) self.permissions = self._rights.authorization(self.user, self.path) if permissions_filter is not None: + self._permissions_filter = permissions_filter permissions_filtered = intersect(self.permissions, permissions_filter) - logger.debug("TRACE/Access: permissions filtered: %r by %r to %r", self.permissions, permissions_filter, permissions_filtered) self.permissions = permissions_filtered self._parent_permissions = None @@ -131,6 +132,9 @@ def parent_permissions(self) -> str: if self._parent_permissions is None: self._parent_permissions = self._rights.authorization( self.user, self.parent_path) + if self._permissions_filter is not None: + parent_permissions_filtered = intersect(self._parent_permissions, self._permissions_filter) + self._parent_permissions = parent_permissions_filtered return self._parent_permissions def check(self, permission: str, From 3e4abdbc468abb388020c90db0ae48718ccc38ca Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 07:00:35 +0100 Subject: [PATCH 062/138] fix parent path replacement --- radicale/sharing/__init__.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 51c6f8faa..b9c3c4358 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -159,6 +159,7 @@ def toggle_sharing(self, # static sharing functions def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None]: + """ returning dict with mapped-flag, PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_token: result = self.sharing_collection_by_token_resolver(path) if result is None: @@ -180,10 +181,10 @@ def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None return None # final - return {"mapped": False} + return None def sharing_collection_by_token_resolver(self, path) -> Union[dict | None]: - """ returning dict with mapped-flag, path, user, rights or None if invalid""" + """ returning dict with mapped-flag, PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_token: logger.debug("TRACE/sharing_by_token: check path: %r", path) if path.startswith("/.token/"): @@ -206,7 +207,7 @@ def sharing_collection_by_token_resolver(self, path) -> Union[dict | None]: return {"mapped": False} def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict | None]: - """ returning dict with mapped-flag, path, user, rights or None if invalid""" + """ returning dict with mapped-flag, PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_map: logger.debug("TRACE/sharing/resolver/map: check path: %r", path) result = self.get_sharing( @@ -215,13 +216,21 @@ def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict User=user) if result: return result - # fallback to parent path - parent_path = pathutils.unstrip_path(posixpath.dirname(pathutils.strip_path(path)), True) - logger.debug("TRACE/sharing/resolver/map: check parent path: %r", parent_path) - return self.get_sharing( - ShareType="map", - PathOrToken=parent_path, - User=user) + else: + # fallback to parent path + parent_path = pathutils.unstrip_path(posixpath.dirname(pathutils.strip_path(path)), True) + logger.debug("TRACE/sharing/resolver/map: check parent path: %r", parent_path) + result = self.get_sharing( + ShareType="map", + PathOrToken=parent_path, + User=user) + if result: + result['PathMapped'] = path.replace(parent_path, result['PathMapped']) + logger.debug("TRACE/sharing/resolver/map: replaced parent_path: %r", result['PathMapped']) + return result + else: + logger.debug("TRACE/sharing_by_map: not found") + return {"mapped": False} else: logger.debug("TRACE/sharing_by_map: not active") return {"mapped": False} From d0ac8ce1179d3295e72706f1ce6cc3d4acae274f Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 07:13:17 +0100 Subject: [PATCH 063/138] add sharing support --- radicale/app/delete.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 2201e998b..85e965b1d 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2020 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -57,7 +57,17 @@ class ApplicationPartDelete(ApplicationBase): def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage DELETE request.""" - access = Access(self._rights, user, path) + # Sharing by token or map + result = self._sharing.sharing_collection_resolver(path, user) + if result: + # overwrite and run through extended permission check + path = result['PathMapped'] + user = result['Owner'] + permissions_filter = result['Permissions'] + access = Access(self._rights, user, path, permissions_filter) + else: + # default permission check + access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user, path=path, request="DELETE"): From c8831afcda6114550ae97d5873727a6ee6c5f5c8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 07:13:27 +0100 Subject: [PATCH 064/138] extend test cases --- radicale/tests/test_sharing.py | 38 +++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 329c49902..b548472c2 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -697,12 +697,12 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict: dict - path_share1 = "/user1/calendar-shared-by-owner1.ics" - path_mapped1 = "/owner1/calendar1.ics" - path_share2 = "/user2/calendar-shared-by-owner2.ics" - path_mapped2 = "/owner2/calendar2.ics" + path_share1 = "/user1/calendar-shared-by-owner1.ics/" + path_mapped1 = "/owner1/calendar1.ics/" + path_share2 = "/user2/calendar-shared-by-owner2.ics/" + path_mapped2 = "/owner2/calendar2.ics/" - logging.info("\n*** prepare and test access") + logging.info("\n*** prepare") self.mkcalendar(path_mapped1, login="%s:%s" % ("owner1", "owner1pw")) event = get_file_content("event1.ics") path = path_mapped1 + "/event1.ics" @@ -713,18 +713,19 @@ def test_sharing_api_map_usercheck(self) -> None: path = path_mapped2 + "/event1.ics" self.put(path, event, login="%s:%s" % ("owner2", "owner2pw")) - logging.info("\n*** create map user1/owner1 as owner -> fail") + logging.info("\n*** create map user1/owner1 as owner(wrong owner) -> fail") json_dict = {} json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_share1 _, headers, answer = self._sharing_api_json("map", "create", 403, "owner:ownerpw", json_dict) - logging.info("\n*** create map user1/owner1 -> ok") + logging.info("\n*** create map user1/owner1:r -> ok") json_dict = {} json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_share1 + json_dict['Permissions'] = "r" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner1:owner1pw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -736,11 +737,12 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict['PathOrToken'] = path_share1 _, headers, answer = self._sharing_api_json("map", "create", 409, "owner1:owner1pw", json_dict) - logging.info("\n*** create map user2/owner2 -> ok") + logging.info("\n*** create map user2/owner2:rw -> ok") json_dict = {} json_dict['User'] = "user2" json_dict['PathMapped'] = path_mapped2 json_dict['PathOrToken'] = path_share2 + json_dict['Permissions'] = "rw" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner2:owner2pw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -918,4 +920,24 @@ def test_sharing_api_map_permissions(self) -> None: logging.info("\n*** fetch event as owner -> ok") _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + # DELETE + logging.info("\n*** DELETE from collection by user via map:r -> fail") + _, headers, answer = self.request("DELETE", path_share_r + "event1.ics", check=403, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** DELETE from collection by user via map:rw -> ok") + _, headers, answer = self.request("DELETE", path_share_rw + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** DELETE from collection by user via map:w -> ok") + _, headers, answer = self.request("DELETE", path_share_w + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + + # check results + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** fetch event as owner -> fail") + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=404, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** fetch event as owner -> fail") + _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=404, login="%s:%s" % ("owner", "ownerpw")) + # TODO hide+unhide for REPORT From 890c71b9da317c9d752165afc0c8b88a1b12f28b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 07:33:35 +0100 Subject: [PATCH 065/138] add sharing support --- radicale/app/report.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index 917a2d752..023e1995a 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -6,7 +6,7 @@ # Copyright © 2024-2024 Pieter Hijma # Copyright © 2024-2024 Ray # Copyright © 2024-2025 Georgiy -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # Copyright © 2025-2025 David Greaves # # This library is free software: you can redistribute it and/or modify @@ -810,7 +810,17 @@ class ApplicationPartReport(ApplicationBase): def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" - access = Access(self._rights, user, path) + # Sharing by token or map + result = self._sharing.sharing_collection_resolver(path, user) + if result: + # overwrite and run through extended permission check + path = result['PathMapped'] + user = result['Owner'] + permissions_filter = result['Permissions'] + access = Access(self._rights, user, path, permissions_filter) + else: + # default permission check + access = Access(self._rights, user, path) if not access.check("r"): return httputils.NOT_ALLOWED try: From 489b4937ebbc99917b14d622a076cfab48bc883b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 08:37:45 +0100 Subject: [PATCH 066/138] cosmetics --- radicale/app/delete.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 85e965b1d..d04ea472d 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -58,12 +58,12 @@ def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage DELETE request.""" # Sharing by token or map - result = self._sharing.sharing_collection_resolver(path, user) - if result: + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: # overwrite and run through extended permission check - path = result['PathMapped'] - user = result['Owner'] - permissions_filter = result['Permissions'] + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] access = Access(self._rights, user, path, permissions_filter) else: # default permission check From 48ae1a59b12ffbe99ed03c6d8a4c1c80848b829d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 08:37:57 +0100 Subject: [PATCH 067/138] cosmetics --- radicale/app/get.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 2e60d7e48..d1ed84735 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -77,12 +77,12 @@ def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) # Sharing by token or map - result = self._sharing.sharing_collection_resolver(path, user) - if result: + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: # overwrite and run through extended permission check - path = result['PathMapped'] - user = result['Owner'] - permissions_filter = result['Permissions'] + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] access = Access(self._rights, user, path, permissions_filter) else: # default permission check From 8549c80e276aae04719bba1a411e565b3438a80e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 08:38:11 +0100 Subject: [PATCH 068/138] cosmetics --- radicale/app/put.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/radicale/app/put.py b/radicale/app/put.py index 206fe9f95..2d3b9275e 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -182,12 +182,12 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PUT request.""" # Sharing by token or map - result = self._sharing.sharing_collection_resolver(path, user) - if result: + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: # overwrite and run through extended permission check - path = result['PathMapped'] - user = result['Owner'] - permissions_filter = result['Permissions'] + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] access = Access(self._rights, user, path, permissions_filter) else: # default permission check From 91013d52cc8544d4e6e386423fa838d59ab1c68c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 09:42:38 +0100 Subject: [PATCH 069/138] cosmetics --- radicale/app/report.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index 023e1995a..11227e254 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -149,8 +149,8 @@ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Eleme def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection, encoding: str, unlock_storage_fn: Callable[[], None], - max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "" - ) -> Tuple[int, ET.Element]: + max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "", + sharing: Union[dict | None] = None) -> Tuple[int, ET.Element]: """Read and answer REPORT requests that return XML. Read rfc3253-3.6 for info. @@ -354,7 +354,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], if found_props or not_found_props: multistatus.append(xml_item_response( base_prefix, uri, found_props=found_props, - not_found_props=not_found_props, found_item=True)) + not_found_props=not_found_props, found_item=True, sharing=sharing)) return client.MULTI_STATUS, multistatus @@ -705,11 +705,13 @@ def _find_overridden( def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), - found_item: bool = True) -> ET.Element: + found_item: bool = True, sharing: Union[dict | None] = None) -> ET.Element: response = ET.Element(xmlutils.make_clark("D:response")) href_element = ET.Element(xmlutils.make_clark("D:href")) href_element.text = xmlutils.make_href(base_prefix, href) + if sharing: + href_element.text = href_element.text.replace(sharing['PathMapped'], sharing['PathOrToken']) response.append(href_element) if found_item: @@ -811,12 +813,12 @@ def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" # Sharing by token or map - result = self._sharing.sharing_collection_resolver(path, user) - if result: + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: # overwrite and run through extended permission check - path = result['PathMapped'] - user = result['Owner'] - permissions_filter = result['Permissions'] + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] access = Access(self._rights, user, path, permissions_filter) else: # default permission check @@ -862,7 +864,7 @@ def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, try: status, xml_answer = xml_report( base_prefix, path, xml_content, collection, self._encoding, - lock_stack.close, max_occurrence, user, remote_host, remote_useragent) + lock_stack.close, max_occurrence, user, remote_host, remote_useragent, sharing=sharing) except ValueError as e: logger.warning( "Bad REPORT request on %r: %s", path, e, exc_info=True) From c35712e63c6dd7a66271be01216f9195571c3d82 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 09:43:28 +0100 Subject: [PATCH 070/138] cosmetics, add propfind test cases --- radicale/tests/test_sharing.py | 226 ++++++++++++++++++++++++++++++--- 1 file changed, 206 insertions(+), 20 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index b548472c2..d00aa8d0c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -25,7 +25,7 @@ import re from typing import Dict, Sequence, Tuple, Union -from radicale import sharing +from radicale import sharing, xmlutils from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content @@ -518,9 +518,9 @@ def test_sharing_api_map_usage(self) -> None: file_item1 = "event1.ics" file_item2 = "event2.ics" - path_share = "/user/calendar-shared-by-owner.ics/" - path_share_item1 = os.path.join(path_share, file_item1) - path_share_item2 = os.path.join(path_share, file_item2) + path_shared = "/user/calendar-shared-by-owner.ics/" + path_share_item1 = os.path.join(path_shared, file_item1) + path_share_item2 = os.path.join(path_shared, file_item2) path_mapped = "/owner/calendar.ics/" path_mapped_item1 = os.path.join(path_mapped, file_item1) path_mapped_item2 = os.path.join(path_mapped, file_item2) @@ -545,7 +545,7 @@ def test_sharing_api_map_usage(self) -> None: json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -556,7 +556,7 @@ def test_sharing_api_map_usage(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['PathOrToken'] == path_share + assert answer_dict['Content'][0]['PathOrToken'] == path_shared assert answer_dict['Content'][0]['PathMapped'] == path_mapped assert answer_dict['Content'][0]['ShareType'] == "map" assert answer_dict['Content'][0]['Owner'] == "owner" @@ -571,14 +571,14 @@ def test_sharing_api_map_usage(self) -> None: json_dict = {} json_dict['User'] = "owner" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "enable", 404, "owner:ownerpw", json_dict) logging.info("\n*** enable map by owner for user (json->json)") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -587,7 +587,7 @@ def test_sharing_api_map_usage(self) -> None: json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -596,7 +596,7 @@ def test_sharing_api_map_usage(self) -> None: json_dict = {} json_dict['User'] = "owner" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "enable", 403, "user:userpw", json_dict) logging.info("\n*** fetch collection (without credentials)") @@ -614,7 +614,7 @@ def test_sharing_api_map_usage(self) -> None: _, headers, answer = self.request("GET", path_mapped, check=403, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch collection via map (with credentials) as user") - _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared, check=200, login="%s:%s" % ("user", "userpw")) assert "UID:event1" in answer assert "UID:event2" in answer @@ -634,50 +634,50 @@ def test_sharing_api_map_usage(self) -> None: json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "disable", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") - _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared, check=404, login="%s:%s" % ("user", "userpw")) logging.info("\n*** enable map by owner (json->json)") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** fetch collection via map (with credentials) as user") - _, headers, answer = self.request("GET", path_share, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared, check=200, login="%s:%s" % ("user", "userpw")) logging.info("\n*** disable map by user (json->json)") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "disable", 200, "user:userpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") - _, headers, answer = self.request("GET", path_share, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared, check=404, login="%s:%s" % ("user", "userpw")) logging.info("\n*** delete map by user (json->json) -> fail") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "delete", 403, "user:userpw", json_dict) logging.info("\n*** delete map by owner (json->json) -> ok") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share + json_dict['PathOrToken'] = path_shared _, headers, answer = self._sharing_api_json("map", "delete", 200, "owner:ownerpw", json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -940,4 +940,190 @@ def test_sharing_api_map_permissions(self) -> None: logging.info("\n*** fetch event as owner -> fail") _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=404, login="%s:%s" % ("owner", "ownerpw")) - # TODO hide+unhide for REPORT + def test_sharing_api_map_report(self) -> None: + # TODO: depth + hide + """share-by-map API usage tests related to report.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "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"}}) + + json_dict: dict + + path_shared = "/user/calendar2-shared-by-owner.ics/" + path_mapped = "/owner/calendar2.ics/" + path_shared_item = os.path.join(path_shared, "event1.ics") + path_mapped_item = os.path.join(path_mapped, "event1.ics") + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event1.ics") + self.put(path_mapped_item, event, login="%s:%s" % ("owner", "ownerpw")) + + # check GET + logging.info("\n*** GET event as owner (init) -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + + # check REPORT as owner + logging.info("\n*** REPORT collection owner -> ok") + _, responses = self.report(path_mapped, """\ + + + + + +""", login="%s:%s" % ("owner", "ownerpw")) + assert len(responses) == 1 + logging.info("response: %r", responses) + response = responses[path_mapped_item] + assert isinstance(response, dict) + status, prop = response["D:getetag"] + assert status == 200 and prop.text + + # create map + logging.info("\n*** create map user/owner -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # check REPORT as user + logging.info("\n*** REPORT collection user -> 404") + _, responses = self.report(path_shared, """\ + + + + + +""", login="%s:%s" % ("user", "userpw"), check=404) + + # 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 + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + # check REPORT as user + logging.info("\n*** REPORT collection user -> ok") + _, responses = self.report(path_shared, """\ + + + + + +""", login="%s:%s" % ("user", "userpw")) + assert len(responses) == 1 + logging.info("response: %r", responses) + response = responses[path_shared_item] + assert isinstance(response, dict) + status, prop = response["D:getetag"] + assert status == 200 and prop.text + + def test_sharing_api_map_propfind(self) -> None: + """share-by-map API usage tests related to report.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "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"}}) + + json_dict: dict + + path_shared = "/user/calendar-shared-by-owner.ics/" + path_mapped = "/owner/calendar.ics/" + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event1.ics") + path = os.path.join(path_mapped, "event1.ics") + self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + + # check GET + logging.info("\n*** GET event as owner (init) -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + + + +""", login="%s:%s" % ("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 -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> 404") + _, responses = self.propfind(path_shared, """\ + + + + + +""", login="%s:%s" % ("user", "userpw"), check=404) + + # 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 + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok") + _, responses = self.propfind(path_shared, """\ + + + + + +""", login="%s:%s" % ("user", "userpw"), check=207) + logging.info("response: %r", responses) + response = responses[path_shared] + 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 == "/user/" From 5a9494dc2e89a99167e6ae1b4a7e4f25b3e983d7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 12:24:19 +0100 Subject: [PATCH 071/138] sharing for propfind --- radicale/app/propfind.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 62af29492..8394ac3eb 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,7 +24,8 @@ import socket import xml.etree.ElementTree as ET from http import client -from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple +from typing import (Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, + Union) from radicale import (httputils, pathutils, rights, storage, types, utils, xmlutils) @@ -35,7 +36,7 @@ def xml_propfind(base_prefix: str, path: str, xml_request: Optional[ET.Element], allowed_items: Iterable[Tuple[types.CollectionOrItem, str]], - user: str, encoding: str, max_resource_size: int) -> Optional[ET.Element]: + user: str, encoding: str, max_resource_size: int, sharing: Union[dict | None] = None) -> Optional[ET.Element]: """Read and answer PROPFIND requests. Read rfc4918-9.1 for info. @@ -72,7 +73,7 @@ def xml_propfind(base_prefix: str, path: str, write = permission == "w" multistatus.append(xml_propfind_response( base_prefix, path, item, props, user, encoding, write=write, - allprop=allprop, propname=propname, max_resource_size=max_resource_size)) + allprop=allprop, propname=propname, max_resource_size=max_resource_size, sharing=sharing)) return multistatus @@ -80,7 +81,7 @@ def xml_propfind(base_prefix: str, path: str, def xml_propfind_response( base_prefix: str, path: str, item: types.CollectionOrItem, props: Sequence[str], user: str, encoding: str, max_resource_size: int, write: bool = False, - propname: bool = False, allprop: bool = False) -> ET.Element: + propname: bool = False, allprop: bool = False, sharing: Union[dict | None] = None) -> ET.Element: """Build and return a PROPFIND response.""" if propname and allprop or (props and (propname or allprop)): raise ValueError("Only use one of props, propname and allprops") @@ -100,6 +101,9 @@ def xml_propfind_response( collection.path, item.href)) response = ET.Element(xmlutils.make_clark("D:response")) href = ET.Element(xmlutils.make_clark("D:href")) + if sharing: + # backmap + uri = uri.replace(sharing['PathMapped'], sharing['PathOrToken']) href.text = xmlutils.make_href(base_prefix, uri) response.append(href) @@ -178,6 +182,9 @@ def xml_propfind_response( is_collection and collection.is_principal): child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href(base_prefix, path) + if sharing: + # backmap + child_element.text = child_element.text.replace(sharing['PathMapped'], sharing['PathOrToken']) element.append(child_element) elif tag == xmlutils.make_clark("C:supported-calendar-component-set"): human_tag = xmlutils.make_human_tag(tag) @@ -213,6 +220,9 @@ def xml_propfind_response( child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href( base_prefix, "/%s/" % user) + if sharing: + # backmap + child_element.text = child_element.text.replace(sharing['Owner'], sharing['User']) element.append(child_element) else: element.append(ET.Element( @@ -405,7 +415,17 @@ def _collect_allowed_items( def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPFIND request.""" - access = Access(self._rights, user, path) + # Sharing by token or map + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) + else: + # default permission check + access = Access(self._rights, user, path) if not access.check("r"): return httputils.NOT_ALLOWED try: @@ -433,7 +453,7 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} xml_answer = xml_propfind(base_prefix, path, xml_content, - allowed_items, user, self._encoding, max_resource_size=self._max_resource_size) + allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, sharing=sharing) if xml_answer is None: return httputils.NOT_ALLOWED return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) From 7c83ccd3d613445599bdedf5c492356deb50dd40 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 12:40:33 +0100 Subject: [PATCH 072/138] add sharing support --- radicale/app/proppatch.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index caaf7b7a8..8689cd504 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -37,7 +37,7 @@ def xml_proppatch(base_prefix: str, path: str, xml_request: Optional[ET.Element], - collection: storage.BaseCollection) -> ET.Element: + collection: storage.BaseCollection, sharing: Union[dict | None] = None) -> ET.Element: """Read and answer PROPPATCH requests. Read rfc4918-9.2 for info. @@ -48,6 +48,9 @@ def xml_proppatch(base_prefix: str, path: str, multistatus.append(response) href = ET.Element(xmlutils.make_clark("D:href")) href.text = xmlutils.make_href(base_prefix, path) + if sharing: + # backmap + href.text = href.text.replace(sharing['PathMapped'], sharing['PathOrToken']) response.append(href) # Create D:propstat element for props with status 200 OK propstat = ET.Element(xmlutils.make_clark("D:propstat")) @@ -75,7 +78,17 @@ class ApplicationPartProppatch(ApplicationBase): def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" - access = Access(self._rights, user, path) + # Sharing by token or map + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) + else: + # default permission check + access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED try: @@ -99,7 +112,7 @@ def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, "Content-Type": "text/xml; charset=%s" % self._encoding} try: xml_answer = xml_proppatch(base_prefix, path, xml_content, - item) + item, sharing) if xml_content is not None: content = DefusedET.tostring( xml_content, From 038d49fbb8197fb56ed5cb683a57bda0278d41a9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 12:40:44 +0100 Subject: [PATCH 073/138] proppatch tests --- radicale/tests/test_sharing.py | 224 ++++++++++++++++++++++++++++----- 1 file changed, 193 insertions(+), 31 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index d00aa8d0c..4dc47bf93 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -519,8 +519,8 @@ def test_sharing_api_map_usage(self) -> None: file_item1 = "event1.ics" file_item2 = "event2.ics" path_shared = "/user/calendar-shared-by-owner.ics/" - path_share_item1 = os.path.join(path_shared, file_item1) - path_share_item2 = os.path.join(path_shared, file_item2) + path_shared_item1 = os.path.join(path_shared, file_item1) + path_shared_item2 = os.path.join(path_shared, file_item2) path_mapped = "/owner/calendar.ics/" path_mapped_item1 = os.path.join(path_mapped, file_item1) path_mapped_item2 = os.path.join(path_mapped, file_item2) @@ -619,13 +619,13 @@ def test_sharing_api_map_usage(self) -> None: assert "UID:event2" in answer logging.info("\n*** fetch item via map (with credentials) as user") - _, headers, answer = self.request("GET", path_share_item1, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_item1, check=200, login="%s:%s" % ("user", "userpw")) # only requested event has to be in the answer assert "UID:event1" in answer assert "UID:event2" not in answer logging.info("\n*** fetch item via map (with credentials) as user") - _, headers, answer = self.request("GET", path_share_item2, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_item2, check=200, login="%s:%s" % ("user", "userpw")) # only requested event has to be in the answer assert "UID:event2" in answer assert "UID:event1" not in answer @@ -769,9 +769,9 @@ def test_sharing_api_map_permissions(self) -> None: json_dict: dict - path_share_r = "/user/calendar-shared-by-owner-r.ics/" - path_share_w = "/user/calendar-shared-by-owner-w.ics/" - path_share_rw = "/user/calendar-shared-by-owner-rw.ics/" + path_shared_r = "/user/calendar-shared-by-owner-r.ics/" + path_shared_w = "/user/calendar-shared-by-owner-w.ics/" + path_shared_rw = "/user/calendar-shared-by-owner-rw.ics/" path_mapped = "/owner/calendar.ics/" logging.info("\n*** prepare and test access") @@ -789,7 +789,7 @@ def test_sharing_api_map_permissions(self) -> None: json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share_r + json_dict['PathOrToken'] = path_shared_r json_dict['Permissions'] = "r" json_dict['Enabled'] = "True" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) @@ -800,7 +800,7 @@ def test_sharing_api_map_permissions(self) -> None: json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share_w + json_dict['PathOrToken'] = path_shared_w json_dict['Permissions'] = "w" json_dict['Enabled'] = "True" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) @@ -811,7 +811,7 @@ def test_sharing_api_map_permissions(self) -> None: json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share_rw + json_dict['PathOrToken'] = path_shared_rw json_dict['Permissions'] = "rw" json_dict['Enabled'] = "True" _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) @@ -825,34 +825,34 @@ def test_sharing_api_map_permissions(self) -> None: # check permissions, no map is enabled by user -> 404 logging.info("\n*** fetch collection via map:r -> n/a") - _, headers, answer = self.request("GET", path_share_r, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r, check=404, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch collection via map:w -> n/a") - _, headers, answer = self.request("GET", path_share_r, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r, check=404, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch collection via map:rw -> n/a") - _, headers, answer = self.request("GET", path_share_r, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r, check=404, login="%s:%s" % ("user", "userpw")) # enable maps by user logging.info("\n*** enable map by user:r") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share_r + json_dict['PathOrToken'] = path_shared_r _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) logging.info("\n*** enable map by user:w") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share_w + json_dict['PathOrToken'] = path_shared_w _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) logging.info("\n*** enable map by user:rw") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_share_rw + json_dict['PathOrToken'] = path_shared_rw _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) # list adjusted maps @@ -862,13 +862,13 @@ def test_sharing_api_map_permissions(self) -> None: # check permissions, no map is enabled by user -> 404 logging.info("\n*** fetch collection via map:r -> ok") - _, headers, answer = self.request("GET", path_share_r, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r, check=200, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch collection via map:w -> fail") - _, headers, answer = self.request("GET", path_share_w, check=403, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_w, check=403, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch collection via map:rw -> ok") - _, headers, answer = self.request("GET", path_share_rw, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_rw, check=200, login="%s:%s" % ("user", "userpw")) # list adjusted maps logging.info("\n*** list (json->text)") @@ -878,38 +878,38 @@ def test_sharing_api_map_permissions(self) -> None: # PUT logging.info("\n*** put to collection by user via map:r -> fail") event = get_file_content("event2.ics") - path = path_share_r + "/event2.ics" + path = path_shared_r + "/event2.ics" self.put(path, event, check=403, login="%s:%s" % ("user", "userpw")) logging.info("\n*** put to collection by user via map:w -> ok") event = get_file_content("event2.ics") - path = path_share_w + "event2.ics" + path = path_shared_w + "event2.ics" self.put(path, event, check=201, login="%s:%s" % ("user", "userpw")) # check result logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_share_r + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch event as owner -> ok") _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) logging.info("\n*** put to collection by user via map:rw -> ok") event = get_file_content("event3.ics") - path = path_share_rw + "event3.ics" + path = path_shared_rw + "event3.ics" self.put(path, event, check=201, login="%s:%s" % ("user", "userpw")) # check result logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_share_r + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_share_r + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch event via map:rw -> ok") - _, headers, answer = self.request("GET", path_share_rw + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_rw + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch event via map:rw -> ok") - _, headers, answer = self.request("GET", path_share_rw + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_rw + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) logging.info("\n*** fetch event as owner -> ok") _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) @@ -922,13 +922,13 @@ def test_sharing_api_map_permissions(self) -> None: # DELETE logging.info("\n*** DELETE from collection by user via map:r -> fail") - _, headers, answer = self.request("DELETE", path_share_r + "event1.ics", check=403, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("DELETE", path_shared_r + "event1.ics", check=403, login="%s:%s" % ("user", "userpw")) logging.info("\n*** DELETE from collection by user via map:rw -> ok") - _, headers, answer = self.request("DELETE", path_share_rw + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("DELETE", path_shared_rw + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) logging.info("\n*** DELETE from collection by user via map:w -> ok") - _, headers, answer = self.request("DELETE", path_share_w + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("DELETE", path_shared_w + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) # check results logging.info("\n*** fetch event as owner -> ok") @@ -1035,7 +1035,7 @@ def test_sharing_api_map_report(self) -> None: assert status == 200 and prop.text def test_sharing_api_map_propfind(self) -> None: - """share-by-map API usage tests related to report.""" + """share-by-map API usage tests related to propfind.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, @@ -1127,3 +1127,165 @@ def test_sharing_api_map_propfind(self) -> None: assert status == 200 and len(prop) == 1 element = prop.find(xmlutils.make_clark("D:href")) assert element is not None and element.text == "/user/" + + def test_sharing_api_map_proppatch(self) -> None: + """share-by-map API usage tests related to report.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "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"}}) + + json_dict: dict + + path_mapped = "/owner/calendar.ics/" + path_shared_r = "/user/calendar-shared-by-owner-r.ics/" + path_shared_w = "/user/calendar-shared-by-owner-w.ics/" + path_shared_rw = "/user/calendar-shared-by-owner-rw.ics/" + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event1.ics") + path = os.path.join(path_mapped, "event1.ics") + self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + + # check GET + logging.info("\n*** GET event as owner (init) -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + + + +""", login="%s:%s" % ("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/" + + # check PROPPATCH as owner + logging.info("\n*** PROPPATCH collection owner -> ok") + proppatch = get_file_content("proppatch_set_calendar_color.xml") + _, responses = self.proppatch(path_mapped, proppatch, login="%s:%s" % ("owner", "ownerpw")) + logging.info("response: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) and len(response) == 1 + status, prop = response["ICAL:calendar-color"] + assert status == 200 and not prop.text + + # check PROPPATCH as user + logging.info("\n*** PROPPATCH collection as user -> 404") + proppatch = get_file_content("proppatch_remove_calendar_color.xml") + _, responses = self.proppatch(path_shared_r, proppatch, login="%s:%s" % ("user", "userpw"), check=404) + _, responses = self.proppatch(path_shared_w, proppatch, login="%s:%s" % ("user", "userpw"), check=404) + _, responses = self.proppatch(path_shared_rw, proppatch, login="%s:%s" % ("user", "userpw"), check=404) + + # 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", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** create map user/owner:w -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_w + json_dict['Permissions'] = "w" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** create map user/owner:rw -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_rw + json_dict['Permissions'] = "rw" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # check PROPPATCH as user + logging.info("\n*** PROPPATCH collection as user -> 403") + proppatch = get_file_content("proppatch_set_calendar_color.xml") + _, responses = self.proppatch(path_shared_r, proppatch, login="%s:%s" % ("user", "userpw"), check=404) + _, responses = self.proppatch(path_shared_w, proppatch, login="%s:%s" % ("user", "userpw"), check=404) + _, responses = self.proppatch(path_shared_rw, proppatch, login="%s:%s" % ("user", "userpw"), check=404) + + # 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", 200, "user:userpw", json_dict) + + logging.info("\n*** enable map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_w + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + logging.info("\n*** enable map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_rw + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + # check PROPPATCH as user + proppatch = get_file_content("proppatch_remove_calendar_color.xml") + logging.info("\n*** PROPPATCH collection as user:r -> 403") + _, responses = self.proppatch(path_shared_r, proppatch, login="%s:%s" % ("user", "userpw"), check=403) + + logging.info("\n*** PROPPATCH collection as user:w -> ok") + _, responses = self.proppatch(path_shared_w, proppatch, login="%s:%s" % ("user", "userpw")) + logging.info("response: %r", responses) + + logging.info("\n*** PROPPATCH collection as user:rw -> ok") + _, responses = self.proppatch(path_shared_rw, proppatch, login="%s:%s" % ("user", "userpw")) + logging.info("response: %r", responses) + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + + + +""", login="%s:%s" % ("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/" + assert "ICAL:calendar-color" not in response From 5c3bf52156a1fb7e598f136dfcdc20387bebe632 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 14:53:28 +0100 Subject: [PATCH 074/138] add sharing to move --- radicale/app/move.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/radicale/app/move.py b/radicale/app/move.py index 168619e3d..667376107 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2023 Unrud -# Copyright © 2023-2025 Peter Bieringer +# Copyright © 2023-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -67,7 +67,18 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, # Remote destination server, not supported return httputils.REMOTE_DESTINATION - access = Access(self._rights, user, path) + to_user = user + # Sharing by token or map + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) + else: + # default permission check + access = Access(self._rights, user, path) if not access.check("w"): return httputils.NOT_ALLOWED to_path = pathutils.sanitize_path(to_url.path) @@ -76,7 +87,17 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] - to_access = Access(self._rights, user, to_path) + # Sharing by token or map + sharing = self._sharing.sharing_collection_resolver(to_path, to_user) + if sharing: + # overwrite and run through extended permission check + to_path = sharing['PathMapped'] + to_user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + to_access = Access(self._rights, to_user, to_path, permissions_filter) + else: + # default permission check + to_access = Access(self._rights, to_user, to_path) if not to_access.check("w"): return httputils.NOT_ALLOWED From 2a7d45e84e8be102e5b94510102937be2c405c0b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 14:54:48 +0100 Subject: [PATCH 075/138] extend trace log --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index b9c3c4358..0cff14072 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -226,7 +226,7 @@ def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict User=user) if result: result['PathMapped'] = path.replace(parent_path, result['PathMapped']) - logger.debug("TRACE/sharing/resolver/map: replaced parent_path: %r", result['PathMapped']) + logger.debug("TRACE/sharing/resolver/map: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path) return result else: logger.debug("TRACE/sharing_by_map: not found") From aceb14a7b17b7602b4a73adcad54c830fc86a104 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Feb 2026 14:55:04 +0100 Subject: [PATCH 076/138] add tests for move --- radicale/tests/test_sharing.py | 193 ++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 4 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 4dc47bf93..404fb76fd 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -1144,10 +1144,10 @@ def test_sharing_api_map_proppatch(self) -> None: json_dict: dict - path_mapped = "/owner/calendar.ics/" - path_shared_r = "/user/calendar-shared-by-owner-r.ics/" - path_shared_w = "/user/calendar-shared-by-owner-w.ics/" - path_shared_rw = "/user/calendar-shared-by-owner-rw.ics/" + path_mapped = "/owner/calendarPP.ics/" + path_shared_r = "/user/calendarPP-shared-by-owner-r.ics/" + path_shared_w = "/user/calendarPP-shared-by-owner-w.ics/" + path_shared_rw = "/user/calendarPP-shared-by-owner-rw.ics/" logging.info("\n*** prepare and test access") self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) @@ -1289,3 +1289,188 @@ def test_sharing_api_map_proppatch(self) -> None: element = prop.find(xmlutils.make_clark("D:href")) assert element is not None and element.text == "/owner/" assert "ICAL:calendar-color" not in response + + def test_sharing_api_map_move(self) -> None: + """share-by-map API usage tests related to report.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "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"}}) + + json_dict: dict + + path_user = "/user/calendar.ics/" + path_mapped1 = "/owner/calendar1.ics/" + path_mapped2 = "/owner/calendar2.ics/" + path_shared1_r = "/user/calendar1-shared-by-owner-r.ics/" + path_shared1_rw = "/user/calendar1-shared-by-owner-rw.ics/" + path_shared2_rw = "/user/calendar2-shared-by-owner-rw.ics/" + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped1, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event1.ics") + self.put(os.path.join(path_mapped1, "event1.ics"), event, login="%s:%s" % ("owner", "ownerpw")) + + self.mkcalendar(path_mapped2, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event2.ics") + self.put(os.path.join(path_mapped2, "event2.ics"), event, login="%s:%s" % ("owner", "ownerpw")) + + self.mkcalendar(path_user, login="%s:%s" % ("user", "userpw")) + event = get_file_content("event3.ics") + self.put(os.path.join(path_user, "event3.ics"), event, login="%s:%s" % ("user", "userpw")) + + # check GET as owner + logging.info("\n*** GET event1 as owner (init) -> ok") + _, headers, answer = self.request("GET", os.path.join(path_mapped1, "event1.ics"), check=200, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** GET event2 as owner (init) -> ok") + _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event2.ics"), check=200, login="%s:%s" % ("owner", "ownerpw")) + + # check MOVE as owner + logging.info("\n*** MOVE event1 to mapped2 as owner -> ok") + self.request("MOVE", os.path.join(path_mapped1, "event1.ics"), check=201, + login="%s:%s" % ("owner", "ownerpw"), + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_mapped2, "event1.ics")) + + # check GET as user + logging.info("\n*** GET event1 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** GET event2 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event2.ics"), check=404, login="%s:%s" % ("user", "userpw")) + + # create map + logging.info("\n*** create map user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_r + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** create map user/owner:rw -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_rw + json_dict['Permissions'] = "rw" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** create map user/owner:rw -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_shared2_rw + json_dict['Permissions'] = "rw" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # check MOVE as user + logging.info("\n*** MOVE event1 of shared1 to shared2 as user -> 404 (not enabled)") + self.request("MOVE", os.path.join(path_shared1_r, "event1.ics"), check=404, + login="%s:%s" % ("user", "userpw"), + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) + + logging.info("\n*** MOVE event1 of shared2 to shared1 as user -> 404 (not enabled)") + self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=404, + login="%s:%s" % ("user", "userpw"), + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_r, "event1.ics")) + + # enable map by user + logging.info("\n*** enable map shared1_r by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_r + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + logging.info("\n*** enable map shared1_rw by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_rw + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + logging.info("\n*** enable map shared2_rw by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_shared2_rw + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + # check GET as user + logging.info("\n*** GET event1 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** GET event1 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** GET event1 as user -> ok") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=200, login="%s:%s" % ("user", "userpw")) + + # check MOVE as user between shares + logging.info("\n*** MOVE event1 of shared1_r to shared2_rw as user -> 403 (not permitted to move from r)") + self.request("MOVE", os.path.join(path_shared1_r, "event1.ics"), check=403, + login="%s:%s" % ("user", "userpw"), + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) + + logging.info("\n*** MOVE event1 of shared2_rw to shared1_r as user -> 403 (not permitted to move to r)") + self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=403, + login="%s:%s" % ("user", "userpw"), + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_r, "event1.ics")) + + logging.info("\n*** MOVE event1 of shared1_rw to shared2_rw as user -> 404 (already moved by owner)") + self.request("MOVE", os.path.join(path_shared1_rw, "event1.ics"), check=404, + login="%s:%s" % ("user", "userpw"), + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) + + logging.info("\n*** MOVE event1 of shared2_rw to shared1_rw as user -> 201") + self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=201, + login="%s:%s" % ("user", "userpw"), + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_rw, "event1.ics")) + + # check GET as user + logging.info("\n*** GET event1 as user -> ok") + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** GET event1 as user -> ok") + _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** GET event1 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) + + # check MOVE as user between shares and own calendar + logging.info("\n*** GET event3 as user -> 200") + _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** GET event3 as user from shared2_rw -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=404, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** MOVE event3 of own to shared2_rw as user -> 201") + self.request("MOVE", os.path.join(path_user, "event3.ics"), check=201, + login="%s:%s" % ("user", "userpw"), + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event3.ics")) + + logging.info("\n*** GET event3 as user from shared2_rw -> 200") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** GET event3 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=404, login="%s:%s" % ("user", "userpw")) From 86f53181f0866b447c1a56570b0d6daeb7048a78 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 09:49:54 +0100 Subject: [PATCH 077/138] add new function parent_path --- radicale/pathutils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/radicale/pathutils.py b/radicale/pathutils.py index 3193e4a22..488dbfc00 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -2,7 +2,7 @@ # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -293,6 +293,10 @@ def path_to_filesystem(root: str, sane_path: str) -> str: return safe_path +def parent_path(path: str) -> str: + return unstrip_path(posixpath.dirname(strip_path(path)), True) + + class UnsafePathError(ValueError): def __init__(self, path: str) -> None: From ff4d16a48be51a3926a32d5433f48b965ee1e894 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 09:50:49 +0100 Subject: [PATCH 078/138] use new function parent_path --- radicale/app/base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index c41d38a3c..f485a977a 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -116,8 +116,7 @@ def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_ self._rights = rights self.user = user self.path = path - self.parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(path)), True) + self.parent_path = pathutils.parent_path(path) self.permissions = self._rights.authorization(self.user, self.path) if permissions_filter is not None: self._permissions_filter = permissions_filter From 0eb3d2bb43f52fd1b6a6b337704221fc1dcb194e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 09:58:43 +0100 Subject: [PATCH 079/138] use new func --- radicale/app/move.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/radicale/app/move.py b/radicale/app/move.py index 667376107..3652c527f 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -115,8 +115,7 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, to_item = next(iter(self._storage.discover(to_path)), None) if isinstance(to_item, storage.BaseCollection): return httputils.FORBIDDEN - to_parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(to_path)), True) + to_parent_path = pathutils.parent_path(to_path) to_collection = next(iter( self._storage.discover(to_parent_path)), None) if not to_collection: From ff33fa507b89fdca4dde4ecf254329d4065464e7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 09:59:39 +0100 Subject: [PATCH 080/138] use new func --- radicale/app/mkcol.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 45ad7c4a2..497685349 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -66,8 +66,7 @@ def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, item = next(iter(self._storage.discover(path)), None) if item: return httputils.METHOD_NOT_ALLOWED - parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(path)), True) + parent_path = pathutils.parent_path(path) parent_item = next(iter(self._storage.discover(parent_path)), None) if not parent_item: return httputils.CONFLICT From be597980a86c55b49cecb5c4114f878a1581c045 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 10:00:17 +0100 Subject: [PATCH 081/138] use new func --- radicale/app/mkcalendar.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index 53abcdbdd..ccdf850cc 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -62,8 +62,7 @@ def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, if item: return self._webdav_error_response( client.CONFLICT, "D:resource-must-be-null") - parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(path)), True) + parent_path = pathutils.parent_path(path) parent_item = next(iter(self._storage.discover(parent_path)), None) if not parent_item: return httputils.CONFLICT From 5c88b97212fa4571c6ab6e784fea86f12f208aec Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 10:02:18 +0100 Subject: [PATCH 082/138] bugfix + extend filter --- radicale/sharing/csv.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index b19937789..3113f69f6 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -99,35 +99,55 @@ def get_sharing(self, Owner = row['Owner'] UserShare = row['User'] Permissions = row['Permissions'] - logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r)", PathOrToken, PathMapped, Owner, UserShare, Permissions) + Hidden: bool = (row['HiddenByOwner'] == str(True)) or (row['HiddenByUser'] == str(True)) + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, str(Hidden)) return { "mapped": True, "PathOrToken": PathOrToken, "PathMapped": PathMapped, "Owner": Owner, "User": UserShare, + "Hidden": Hidden, "Permissions": Permissions} return None def list_sharing(self, ShareType: Union[str | None] = None, - PathOrToken: Union[str | None] = None, PathMapped: Union[str | None] = None, - Owner: Union[str | None] = None, User: Union[str | None] = None) -> list[dict]: + PathOrToken: Union[str | None] = None, + PathMapped: Union[str | None] = None, + Owner: Union[str | None] = None, + User: Union[str | None] = None, + EnabledByOwner: Union[bool | None] = None, + EnabledByUser: Union[bool | None] = None, + HiddenByOwner: Union[bool | None] = None, + HiddenByUser: Union[bool | None] = None) -> list[dict]: """ retrieve sharing """ row: dict result = [] + logger.debug("TRACE/sharing/list/called: HiddenByOwner=%s HiddenByUser=%s", HiddenByOwner, HiddenByUser) + for row in self._sharing_cache: - if ShareType and row['ShareType'] != ShareType: + logger.debug("TRACE/sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: continue - elif Owner and row['Owner'] != Owner: + elif Owner is not None and row['Owner'] != Owner: continue - elif User and row['User'] != User: + elif User is not None and row['User'] != User: continue - elif PathOrToken and row['PathOrToken'] != PathOrToken: + elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: continue - elif PathMapped and row['PathMapped'] != PathMapped: + elif PathMapped is not None and row['PathMapped'] != PathMapped: + continue + elif EnabledByOwner is not None and row['EnabledByOwner'] != str(EnabledByOwner): + continue + elif EnabledByUser is not None and row['EnabledByUser'] != str(EnabledByUser): + continue + elif HiddenByOwner is not None and row['HiddenByOwner'] != str(HiddenByOwner): + continue + elif HiddenByUser is not None and row['HiddenByUser'] != str(HiddenByUser): continue + logger.debug("TRACE/sharing/list/row: add: %r", row) result.append(row) return result From e6c469d5d111d3dbd3c29c2b796256ad13c65c47 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 10:05:33 +0100 Subject: [PATCH 083/138] extensions for hidden and others --- radicale/sharing/__init__.py | 39 +++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 0cff14072..d7ab746d5 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -113,9 +113,15 @@ def get_database_info(self) -> Union[dict, None]: return None def list_sharing(self, - ShareType: Union[str, None] = None, - PathOrToken: Union[str | None] = None, PathMapped: Union[str | None] = None, - Owner: Union[str | None] = None, User: Union[str | None] = None) -> list[dict]: + ShareType: Union[str | None] = None, + PathOrToken: Union[str | None] = None, + PathMapped: Union[str | None] = None, + Owner: Union[str | None] = None, + User: Union[str | None] = None, + EnabledByOwner: Union[bool | None] = None, + EnabledByUser: Union[bool | None] = None, + HiddenByOwner: Union[bool | None] = None, + HiddenByUser: Union[bool | None] = None) -> list[dict]: """ retrieve sharing """ return [] @@ -157,7 +163,7 @@ def toggle_sharing(self, """ toggle sharing """ return {} - # static sharing functions + # sharing functions called by request methods def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None]: """ returning dict with mapped-flag, PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_token: @@ -183,6 +189,26 @@ def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None # final return None + # list active sharings of type "map" + def sharing_collection_map_list(self, user: str) -> Union[dict | None]: + """ returning dict with shared collections (enabled and unhidden) or None if invalid""" + if not self.sharing_collection_by_map: + logger.debug("TRACE/sharing_by_map: not active") + return None + + # retrieve collections which are enabled and not hidden by owner+user + shared_collection_list = self.list_sharing( + ShareType="map", + User=user, + EnabledByOwner=True, + EnabledByUser=True, + HiddenByOwner=False, + HiddenByUser=False) + + # final + return shared_collection_list + + # internal sharing functions def sharing_collection_by_token_resolver(self, path) -> Union[dict | None]: """ returning dict with mapped-flag, PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_token: @@ -218,7 +244,7 @@ def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict return result else: # fallback to parent path - parent_path = pathutils.unstrip_path(posixpath.dirname(pathutils.strip_path(path)), True) + parent_path = pathutils.parent_path(path) logger.debug("TRACE/sharing/resolver/map: check parent path: %r", parent_path) result = self.get_sharing( ShareType="map", @@ -481,6 +507,9 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if 'Enabled' in request_data: EnabledByOwner = config._convert_to_bool(request_data['Enabled']) + if 'Hidden' in request_data: + HiddenByOwner = config._convert_to_bool(request_data['Hidden']) + if ShareType == "token": # check access Permissions access = Access(self._rights, user, PathMapped) From d79d5eaa52f218d6bb62b609302af4737adc281e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 10:05:50 +0100 Subject: [PATCH 084/138] add support for shared collections --- radicale/app/propfind.py | 44 +++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 8394ac3eb..ed9e1a298 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -383,6 +383,7 @@ def _collect_allowed_items( for item in items: if isinstance(item, storage.BaseCollection): path = pathutils.unstrip_path(item.path, True) + logger.debug("TRACE/PROPFIND/_collect_allowed_items/BaseCollection: path=%r user=%r", path, user) if item.tag: permissions = rights.intersect( self._rights.authorization(user, path), "rw") @@ -415,7 +416,8 @@ def _collect_allowed_items( def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPFIND request.""" - # Sharing by token or map + http_depth = environ.get("HTTP_DEPTH", "0") + # Sharing by token or map (only for depth==0) sharing = self._sharing.sharing_collection_resolver(path, user) if sharing: # overwrite and run through extended permission check @@ -438,8 +440,9 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT with self._storage.acquire_lock("r", user): + logger.debug("TRACE/PROPFIND: discover path=%r depth=%s", path, http_depth) items_iter = iter(self._storage.discover( - path, environ.get("HTTP_DEPTH", "0"), + path, http_depth, None, self._rights._user_groups)) # take root item for rights checking item = next(items_iter, None) @@ -449,11 +452,32 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, return httputils.NOT_ALLOWED # put item back items_iter = itertools.chain([item], items_iter) - allowed_items = self._collect_allowed_items(items_iter, user) - headers = {"DAV": httputils.DAV_HEADERS, - "Content-Type": "text/xml; charset=%s" % self._encoding} - xml_answer = xml_propfind(base_prefix, path, xml_content, - allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, sharing=sharing) - if xml_answer is None: - return httputils.NOT_ALLOWED - return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) + allowed_items = list(self._collect_allowed_items(items_iter, user)) + if http_depth == "1": + logger.debug("TRACE/PROPFIND: get shared collections") + # check for shared collections + collections_shared_map = self._sharing.sharing_collection_map_list(user) + if collections_shared_map: + for sharing in collections_shared_map: + c_share = sharing['PathOrToken'] + c_path = sharing['PathMapped'] + c_user = sharing['Owner'] + c_permissions_filter = sharing['Permissions'] + logger.debug("TRACE/PROPFIND: test shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s", c_share, c_path, c_user, c_permissions_filter) + access = Access(self._rights, c_user, c_path, c_permissions_filter) + if not access.check("r"): + logger.debug("TRACE/PROPFIND: skip shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s (permissions not matching)", c_share, c_path, c_user, c_permissions_filter) + continue + logger.debug("TRACE/PROPFIND: append shared collection: PathOrToken=%r PathMapped=%r Owner=%r", c_share, c_path, c_user) + c_parent_path = pathutils.parent_path(c_path) + with self._storage.acquire_lock("r", c_user): + c_items_iter = iter(self._storage.discover(c_path, "0")) + c_allowed_items = list(self._collect_allowed_items(c_items_iter, c_user)) + allowed_items = allowed_items + c_allowed_items + headers = {"DAV": httputils.DAV_HEADERS, + "Content-Type": "text/xml; charset=%s" % self._encoding} + xml_answer = xml_propfind(base_prefix, path, xml_content, + allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, sharing=sharing) + if xml_answer is None: + return httputils.NOT_ALLOWED + return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) From d1f5cb768051269926e75daaaa1bf431ea86cab7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 10:06:08 +0100 Subject: [PATCH 085/138] add testcases for propfind/shared --- radicale/tests/test_sharing.py | 163 +++++++++++++++++++++++++++++++-- 1 file changed, 153 insertions(+), 10 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 404fb76fd..524374f69 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -940,8 +940,7 @@ def test_sharing_api_map_permissions(self) -> None: logging.info("\n*** fetch event as owner -> fail") _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=404, login="%s:%s" % ("owner", "ownerpw")) - def test_sharing_api_map_report(self) -> None: - # TODO: depth + hide + def test_sharing_api_map_report_access(self) -> None: """share-by-map API usage tests related to report.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, @@ -1034,6 +1033,147 @@ def test_sharing_api_map_report(self) -> None: status, prop = response["D:getetag"] assert status == 200 and prop.text + def test_sharing_api_map_hidden(self) -> None: + """share-by-map API usage tests related to report checking hidden.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "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"}}) + + json_dict: dict + + path_user_base = "/user/" + path_mapped_base = "/owner/" + path_user = path_user_base + "calendarRH.ics/" + path_mapped = path_mapped_base + "calendarRH.ics/" + path_mapped2 = path_mapped_base + "calendarRH2.ics/" + path_shared = path_user_base + "calendarRH-shared-by-owner.ics/" + path_mapped_item = os.path.join(path_mapped, "event1.ics") + path_shared_item = os.path.join(path_shared, "event1.ics") + path_user_item = os.path.join(path_user, "event2.ics") + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + event = get_file_content("event1.ics") + self.put(path_mapped_item, event, login="%s:%s" % ("owner", "ownerpw")) + + self.mkcalendar(path_mapped2, login="%s:%s" % ("owner", "ownerpw")) + + self.mkcalendar(path_user, login="%s:%s" % ("user", "userpw")) + event = get_file_content("event2.ics") + self.put(path_user_item, event, login="%s:%s" % ("user", "userpw")) + + # check GET + logging.info("\n*** GET event1 as owner -> 200") + _, headers, answer = self.request("GET", path_mapped_item, check=200, login="%s:%s" % ("owner", "ownerpw")) + + logging.info("\n*** GET event2 as user -> 200") + _, headers, answer = self.request("GET", path_user_item, check=200, login="%s:%s" % ("user", "userpw")) + + logging.info("\n*** GET collections as user -> 403") + _, headers, answer = self.request("GET", path_user_base, check=403, login="%s:%s" % ("user", "userpw")) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok") + _, responses = self.propfind(path_user_base, """\ + + + +""", login="%s:%s" % ("user", "userpw"), HTTP_DEPTH="1") + assert len(responses) == 2 + logging.info("response: %r", responses) + response = responses[path_user] + assert isinstance(response, dict) + + # create map + logging.info("\n*** create map user/owner -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped_base, """\ + + + +""", login="%s:%s" % ("owner", "ownerpw"), HTTP_DEPTH="1") + assert len(responses) == 3 + logging.info("response: %r", responses) + response = responses[path_mapped] + assert isinstance(response, dict) + response = responses[path_mapped2] + assert isinstance(response, dict) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok") + _, responses = self.propfind(path_user_base, """\ + + + +""", login="%s:%s" % ("user", "userpw"), HTTP_DEPTH="1") + assert len(responses) == 2 + logging.info("response: %r", responses) + response = responses[path_user] + assert isinstance(response, dict) + + # 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 + _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok") + _, responses = self.propfind(path_user_base, """\ + + + +""", login="%s:%s" % ("user", "userpw"), HTTP_DEPTH="1") + assert len(responses) == 2 + logging.info("response: %r", responses) + response = responses[path_user] + assert isinstance(response, dict) + + # unhide map by user + logging.info("\n*** unhide map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "unhide", 200, "user:userpw", json_dict) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok (now 3 items)") + _, responses = self.propfind(path_user_base, """\ + + + +""", login="%s:%s" % ("user", "userpw"), HTTP_DEPTH="1") + assert len(responses) == 3 + logging.info("response: %r", responses) + response = responses[path_user] + assert isinstance(response, dict) + response = responses[path_shared] + assert isinstance(response, dict) + + def test_sharing_api_map_propfind(self) -> None: """share-by-map API usage tests related to propfind.""" self.configure({"auth": {"type": "htpasswd", @@ -1306,12 +1446,12 @@ def test_sharing_api_map_move(self) -> None: json_dict: dict - path_user = "/user/calendar.ics/" - path_mapped1 = "/owner/calendar1.ics/" - path_mapped2 = "/owner/calendar2.ics/" - path_shared1_r = "/user/calendar1-shared-by-owner-r.ics/" - path_shared1_rw = "/user/calendar1-shared-by-owner-rw.ics/" - path_shared2_rw = "/user/calendar2-shared-by-owner-rw.ics/" + path_user = "/user/calendarM.ics/" + path_mapped1 = "/owner/calendar1M.ics/" + path_mapped2 = "/owner/calendar2M.ics/" + path_shared1_r = "/user/calendar1M-shared-by-owner-r.ics/" + path_shared1_rw = "/user/calendar1M-shared-by-owner-rw.ics/" + path_shared2_rw = "/user/calendar2M-shared-by-owner-rw.ics/" logging.info("\n*** prepare and test access") self.mkcalendar(path_mapped1, login="%s:%s" % ("owner", "ownerpw")) @@ -1327,10 +1467,10 @@ def test_sharing_api_map_move(self) -> None: self.put(os.path.join(path_user, "event3.ics"), event, login="%s:%s" % ("user", "userpw")) # check GET as owner - logging.info("\n*** GET event1 as owner (init) -> ok") + logging.info("\n*** GET mapped1/event1 as owner (init) -> ok") _, headers, answer = self.request("GET", os.path.join(path_mapped1, "event1.ics"), check=200, login="%s:%s" % ("owner", "ownerpw")) - logging.info("\n*** GET event2 as owner (init) -> ok") + logging.info("\n*** GET mapped2/event2 as owner (init) -> ok") _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event2.ics"), check=200, login="%s:%s" % ("owner", "ownerpw")) # check MOVE as owner @@ -1339,6 +1479,9 @@ def test_sharing_api_map_move(self) -> None: login="%s:%s" % ("owner", "ownerpw"), HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_mapped2, "event1.ics")) + logging.info("\n*** GET mapped2/event1 as owner (after move) -> ok") + _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event1.ics"), check=200, login="%s:%s" % ("owner", "ownerpw")) + # check GET as user logging.info("\n*** GET event1 as user -> 404") _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) From 529dc218ced45627bfd311acb970ae220b799a5c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 12:11:48 +0100 Subject: [PATCH 086/138] map/delete do not need user filter --- radicale/sharing/csv.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 3113f69f6..e95e010a6 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -162,6 +162,7 @@ def create_sharing(self, """ create sharing """ row: dict + logger.debug("TRACE/sharing: ShareType=%r", ShareType) if ShareType == "token": logger.debug("TRACE/sharing/token/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) # check for duplicate token entry @@ -182,6 +183,8 @@ def create_sharing(self, # must be unique systemwide logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) return {"status": "conflict"} + else: + return {"status": "error"} row = {"ShareType": ShareType, "PathOrToken": PathOrToken, @@ -207,13 +210,12 @@ def create_sharing(self, def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, - PathMapped: Union[str | None] = None, - User: Union[str | None] = None) -> dict: + PathMapped: Union[str | None] = None) -> dict: """ delete sharing """ if ShareType == "token": logger.debug("TRACE/sharing/token/delete: PathOrToken=%r Owner=%r", PathOrToken, Owner) elif ShareType == "map": - logger.debug("TRACE/sharing/map/delete: PathOrToken=%r Owner=%r PathMapped=%r User=%r", PathOrToken, Owner, PathMapped, User) + logger.debug("TRACE/sharing/map/delete: PathOrToken=%r Owner=%r PathMapped=%r", PathOrToken, Owner, PathMapped) else: raise # should not be reached @@ -230,8 +232,6 @@ def delete_sharing(self, # extra filter if row['PathMapped'] != PathMapped: pass - elif row['User'] != User: - pass else: found = True break From d6ca0a724ae8ca687e3c5cc05655d8221fff1c23 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 12:12:25 +0100 Subject: [PATCH 087/138] test info hook and all/list --- radicale/tests/test_sharing.py | 82 ++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 524374f69..6faf1db12 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -55,7 +55,7 @@ def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[ return _, headers, answer def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str | None], form_array: Sequence[str], accept_type: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: - data = "\n".join(form_array) + data = "&".join(form_array) content_type = "application/x-www-form-urlencoded" if accept_type is None: accept_type = "text/plain" @@ -106,12 +106,16 @@ def test_sharing_api_base_with_auth(self) -> None: "collection_by_token": "True"}, "rights": {"type": "owner_only"}}) + json_dict: dict + # path with no valid API hook for path in ["/.sharing/", "/.sharing/v9/"]: _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + # path with valid API but no hook for path in ["/.sharing/v1/"]: _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + # path with valid API and hook but not enabled "map" self.configure({"sharing": { "collection_by_map": "False", @@ -121,6 +125,7 @@ def test_sharing_api_base_with_auth(self) -> None: for action in sharing.API_HOOKS_V1: path = "/.sharing/v1/" + sharetype + "/" + action _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + # path with valid API and hook but not enabled "token" self.configure({"sharing": { "collection_by_map": "True", @@ -130,6 +135,26 @@ def test_sharing_api_base_with_auth(self) -> None: for action in sharing.API_HOOKS_V1: path = "/.sharing/v1/" + sharetype + "/" + action _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + + # check info hook + logging.info("\n*** check API hook: info/all") + json_dict = {} + _, headers, answer = self._sharing_api_json("all", "info", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['FeatureEnabledCollectionByMap'] == True + assert answer_dict['FeatureEnabledCollectionByToken'] == False + + logging.info("\n*** check API hook: info/map") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "info", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['FeatureEnabledCollectionByMap'] == True + assert 'FeatureEnabledCollectionByToken' not in answer_dict + + logging.info("\n*** check API hook: info/token -> 404 (not enabled)") + json_dict = {} + _, headers, answer = self._sharing_api_json("token", "info", 404, "owner:ownerpw", json_dict) + # path with valid API and hook and all enabled self.configure({"sharing": { "collection_by_map": "True", @@ -142,15 +167,24 @@ def test_sharing_api_base_with_auth(self) -> None: # valid API _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) + logging.info("\n*** check API hook: info/token -> 200") + json_dict = {} + _, headers, answer = self._sharing_api_json("token", "info", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['FeatureEnabledCollectionByToken'] == True + assert 'FeatureEnabledCollectionByMap' not in answer_dict + def test_sharing_api_list_with_auth(self) -> None: """POST/list with authentication.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, "sharing": { + "type": "csv", "collection_by_map": "True", "collection_by_token": "True"}, - "logging": {"request_header_on_debug": "true"}, + "logging": {"request_header_on_debug": "true", + "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) form_array: Sequence[str] @@ -181,6 +215,46 @@ def test_sharing_api_list_with_auth(self) -> None: assert answer_dict['Status'] == "not-found" assert answer_dict['Lines'] == 0 + logging.info("\n*** create a token -> 200") + form_array = ["PathMapped=/owner/collectionL1/"] + _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) + assert "Status=success" in answer + assert "PathOrToken=" in answer + # extract token + match = re.search('PathOrToken=(.+)', answer) + if match: + token = match.group(1) + logging.info("received token %r", token) + else: + assert False + + logging.info("\n*** create a map -> 200") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = "/owner/collectionL2/" + json_dict['PathOrToken'] = "/user/collectionL2-shared-by-owner/" + _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** list/all (form->csv)") + form_array = [] + _, headers, answer = self._sharing_api_form("all", "list", 200, "owner:ownerpw", form_array) + assert "Status=success" in answer + assert "Lines=2" in answer + + logging.info("\n*** delete token -> 200") + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "delete", 200, "owner:ownerpw", form_array) + assert "Status=success" in answer + + logging.info("\n*** delete share -> 200") + form_array = [] + form_array.append("PathOrToken=/user/collectionL2-shared-by-owner/") + form_array.append("PathMapped=/owner/collectionL2/") + _, headers, answer = self._sharing_api_form("map", "delete", 200, "owner:ownerpw", form_array) + assert "Status=success" in answer + def test_sharing_api_token_basic(self) -> None: """share-by-token API tests.""" self.configure({"auth": {"type": "htpasswd", @@ -518,10 +592,10 @@ def test_sharing_api_map_usage(self) -> None: file_item1 = "event1.ics" file_item2 = "event2.ics" - path_shared = "/user/calendar-shared-by-owner.ics/" + path_shared = "/user/calendarU-shared-by-owner.ics/" path_shared_item1 = os.path.join(path_shared, file_item1) path_shared_item2 = os.path.join(path_shared, file_item2) - path_mapped = "/owner/calendar.ics/" + path_mapped = "/owner/calendarU.ics/" path_mapped_item1 = os.path.join(path_mapped, file_item1) path_mapped_item2 = os.path.join(path_mapped, file_item2) From ccf57c8dea62034b931b82decf6ff5c5fa9e3ee8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 12:13:15 +0100 Subject: [PATCH 088/138] catch errors related to list and others --- radicale/sharing/__init__.py | 149 +++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 51 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d7ab746d5..e417a8552 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -35,7 +35,7 @@ DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') # ShareType: -# PathOrToken: +# PathOrToken: [PrimaryKey] # PathMapped: # Owner: (creator of database entry) # User: (user of database entry) @@ -47,11 +47,19 @@ # TimestampCreated: (when created) # TimestampUpdated: (last update) -SHARE_TYPES: Sequence[str] = ('token', 'map') +SHARE_TYPES: Sequence[str] = ('token', 'map', 'all') OUTPUT_TYPES: Sequence[str] = ('csv', 'json', 'txt') -API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable') +API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable', 'info') +# list : list sharings (optional filtered) +# create : create share by token or map +# delete : delete share +# update : update share (TODO implementation) +# hide : hide share (by user or owner) +# unhide : unhide share (by user or owner) +# enable : hide share (by user or owner) +# disable: unhide share (by user or owner) API_SHARE_TOGGLES_V1: Sequence[str] = ('hide', 'unhide', 'enable', 'disable') @@ -130,7 +138,7 @@ def get_sharing(self, PathOrToken: str, User: Union[str | None] = None) -> Union[dict | None]: """ retrieve sharing target and attributes by map """ - return None + return {"status": "not-implemented"} def create_sharing(self, ShareType: str, @@ -141,16 +149,15 @@ def create_sharing(self, HiddenByOwner: bool = True, HiddenByUser: bool = True, Timestamp: int = 0) -> dict: """ create sharing """ - return {} + return {"status": "not-implemented"} def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, - PathMapped: Union[str | None] = None, - User: Union[str | None] = None) -> dict: + PathMapped: Union[str | None] = None) -> dict: """ delete sharing """ - return {} + return {"status": "not-implemented"} def toggle_sharing(self, ShareType: str, @@ -161,7 +168,7 @@ def toggle_sharing(self, User: Union[str | None] = None, Timestamp: int = 0) -> dict: """ toggle sharing """ - return {} + return {"status": "not-implemented"} # sharing functions called by request methods def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None]: @@ -314,15 +321,15 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if not path.startswith("/.sharing/v1/"): return httputils.NOT_FOUND - # split into ShareType and action + # split into ShareType and action or "info" ShareType_action = path.removeprefix("/.sharing/v1/") match = re.search('([a-z]+)/([a-z]+)$', ShareType_action) if not match: logger.debug("TRACE/sharing/API: ShareType/action not extractable: %r", ShareType_action) return httputils.NOT_FOUND - - ShareType = match.group(1) - action = match.group(2) + else: + ShareType = match.group(1) + action = match.group(2) # check for valid ShareTypes if ShareType: @@ -435,21 +442,27 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # check for mandatory parameters if 'PathMapped' not in request_data: - if action != 'list': + if action == 'info': + # ignored + pass + elif action =="list": + # optional + pass + else: if ShareType == "token" and action != 'create': - # optinoal + # optional pass else: logger.error(api_info + ": missing PathMapped") return httputils.BAD_REQUEST - else: - # optional - pass else: PathMapped = request_data['PathMapped'] if 'PathOrToken' not in request_data: - if action not in ['list', 'create']: + if action == 'info': + # ignored + pass + elif action not in ['list', 'create']: logger.error(api_info + ": missing PathOrToken") return httputils.BAD_REQUEST else: @@ -463,15 +476,19 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st PathOrToken = request_data['PathOrToken'] if ShareType == "map": - if 'User' not in request_data: - if action != "list": - logger.warning(api_info + ": missing User") - return httputils.BAD_REQUEST - else: - # optional - pass + if action == 'info': + # ignored + pass else: - User = request_data['User'] + if 'User' not in request_data: + if action not in ['list', 'delete']: + logger.warning(api_info + ": missing User") + return httputils.BAD_REQUEST + else: + # optional + pass + else: + User = request_data['User'] answer: dict = {} result: dict = {} @@ -485,10 +502,17 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if 'PathOrToken' in request_data: PathOrToken = request_data['PathOrToken'] logger.debug("TRACE/" + api_info + ": filter: %r", PathOrToken) - result_array = self.list_sharing( - ShareType=ShareType, - Owner=Owner, - PathOrToken=PathOrToken) + + if ShareType != "all": + result_array = self.list_sharing( + ShareType=ShareType, + Owner=Owner, + PathOrToken=PathOrToken) + else: + result_array = self.list_sharing( + Owner=Owner, + PathOrToken=PathOrToken) + answer['Lines'] = len(result_array) if len(result_array) == 0: answer['Status'] = "not-found" @@ -529,6 +553,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st Permissions=Permissions, EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, Timestamp=Timestamp) + logger.debug("TRACE/" + api_info + ": result=%r", result) elif ShareType == "map": # check preconditions @@ -565,6 +590,11 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st EnabledByUser=EnabledByUser, HiddenByUser=HiddenByUser, Timestamp=Timestamp) + else: + logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + return httputils.BAD_REQUEST + + logger.debug("TRACE/" + api_info + ": result=%r", result) # result handling if result['status'] == "conflict": return httputils.CONFLICT @@ -597,8 +627,11 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st ShareType=ShareType, PathOrToken=str(PathOrToken), # verification above that it is not None PathMapped=PathMapped, - Owner=Owner, - User=User) + Owner=Owner) + + else: + logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + return httputils.BAD_REQUEST # result handling if result['status'] == "not-found": @@ -615,35 +648,49 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) return httputils.BAD_REQUEST + # action: info + elif action == "info": + answer['Status'] = "success" + if ShareType in ["all", "map"]: + answer['FeatureEnabledCollectionByMap'] = self.sharing_collection_by_map; + if ShareType in ["all", "token"]: + answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token; + # action: TOGGLE elif action in API_SHARE_TOGGLES_V1: logger.debug("TRACE/sharing/API/POST/" + action) - if PathOrToken is None: - return httputils.BAD_REQUEST + if ShareType in ["token", "map"]: + if PathOrToken is None: + return httputils.BAD_REQUEST - result = self.toggle_sharing( - ShareType=ShareType, - PathOrToken=str(PathOrToken), # verification above that it is not None - OwnerOrUser=user, # authenticated user - User=User, # provided user for selection - Action=action, - Timestamp=Timestamp) + result = self.toggle_sharing( + ShareType=ShareType, + PathOrToken=str(PathOrToken), # verification above that it is not None + OwnerOrUser=user, # authenticated user + User=User, # provided user for selection + Action=action, + Timestamp=Timestamp) + + if result: + if result['status'] == "not-found": + return httputils.NOT_FOUND + if result['status'] == "permission-denied": + return httputils.NOT_ALLOWED + elif result['status'] == "success": + answer['Status'] = "success" + pass + else: + logger.error("Toggle sharing: %r of user %s not successful", request_data['PathOrToken'], user) + return httputils.BAD_REQUEST - if result: - if result['status'] == "not-found": - return httputils.NOT_FOUND - if result['status'] == "permission-denied": - return httputils.NOT_ALLOWED - elif result['status'] == "success": - answer['Status'] = "success" - pass else: - logger.error("Toggle sharing: %r of user %s not successful", request_data['PathOrToken'], user) + logger.error(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.BAD_REQUEST else: # default + logger.error(api_info + ": unsupported action=%r", action) return httputils.BAD_REQUEST # output handler From d7442b20efaf2196abc6880bf45d3f706d6c1d7b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 13:00:27 +0100 Subject: [PATCH 089/138] cosmetics --- radicale/tests/test_sharing.py | 444 ++++++++++++++++----------------- 1 file changed, 222 insertions(+), 222 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 6faf1db12..3160770a6 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -48,26 +48,26 @@ def setup_method(self) -> None: f.write(htpasswd_content) # Helper functions - def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str | None], data: str, content_type: str, accept_type: Union[str | None]) -> Tuple[int, Dict[str, str], str]: + def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str | None], data: str, content_type: str, accept: Union[str | None]) -> Tuple[int, Dict[str, str], str]: path_base = "/.sharing/v1/" + sharing_type + "/" - _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept_type) + _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept) logging.info("received answer:\n%s", "\n".join(answer.splitlines())) return _, headers, answer - def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str | None], form_array: Sequence[str], accept_type: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str | None], form_array: Sequence[str], accept: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: data = "&".join(form_array) content_type = "application/x-www-form-urlencoded" - if accept_type is None: - accept_type = "text/plain" - _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept_type) + if accept is None: + accept = "text/plain" + _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept) return _, headers, answer - def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str | None], json_dict: dict, accept_type: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str | None], json_dict: dict, accept: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: data = json.dumps(json_dict) content_type = "application/json" - if accept_type is None: - accept_type = "application/json" - _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept_type) + if accept is None: + accept = "application/json" + _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept) return _, headers, answer # Test functions @@ -110,11 +110,11 @@ def test_sharing_api_base_with_auth(self) -> None: # path with no valid API hook for path in ["/.sharing/", "/.sharing/v9/"]: - _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path, check=404, login="owner:ownerpw") # path with valid API but no hook for path in ["/.sharing/v1/"]: - _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path, check=404, login="owner:ownerpw") # path with valid API and hook but not enabled "map" self.configure({"sharing": { @@ -124,7 +124,7 @@ def test_sharing_api_base_with_auth(self) -> None: sharetype = "map" for action in sharing.API_HOOKS_V1: path = "/.sharing/v1/" + sharetype + "/" + action - _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path, check=404, login="owner:ownerpw") # path with valid API and hook but not enabled "token" self.configure({"sharing": { @@ -134,26 +134,26 @@ def test_sharing_api_base_with_auth(self) -> None: sharetype = "token" for action in sharing.API_HOOKS_V1: path = "/.sharing/v1/" + sharetype + "/" + action - _, headers, _ = self.request("POST", path, check=404, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path, check=404, login="owner:ownerpw") # check info hook logging.info("\n*** check API hook: info/all") json_dict = {} - _, headers, answer = self._sharing_api_json("all", "info", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("all", "info", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['FeatureEnabledCollectionByMap'] == True assert answer_dict['FeatureEnabledCollectionByToken'] == False logging.info("\n*** check API hook: info/map") json_dict = {} - _, headers, answer = self._sharing_api_json("map", "info", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "info", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['FeatureEnabledCollectionByMap'] == True assert 'FeatureEnabledCollectionByToken' not in answer_dict logging.info("\n*** check API hook: info/token -> 404 (not enabled)") json_dict = {} - _, headers, answer = self._sharing_api_json("token", "info", 404, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "info", check=404, login="owner:ownerpw", json_dict=json_dict) # path with valid API and hook and all enabled self.configure({"sharing": { @@ -163,13 +163,13 @@ def test_sharing_api_base_with_auth(self) -> None: for sharetype in sharing.SHARE_TYPES: path = "/.sharing/v1/" + sharetype + "/" + action # invalid API - _, headers, _ = self.request("POST", path + "NA", check=404, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path + "NA", check=404, login="owner:ownerpw") # valid API - _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw") logging.info("\n*** check API hook: info/token -> 200") json_dict = {} - _, headers, answer = self._sharing_api_json("token", "info", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "info", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['FeatureEnabledCollectionByToken'] == True assert 'FeatureEnabledCollectionByMap' not in answer_dict @@ -194,30 +194,30 @@ def test_sharing_api_list_with_auth(self) -> None: for sharing_type in sharing.SHARE_TYPES: logging.info("\n*** list (without form) -> should fail") path = "/.sharing/v1/" + sharing_type + "/" + action - _, headers, _ = self.request("POST", path, check=400, login="%s:%s" % ("owner", "ownerpw")) + _, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw") logging.info("\n*** list (form->csv)") form_array = [] - _, headers, answer = self._sharing_api_form(sharing_type, "list", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form(sharing_type, "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=not-found" in answer assert "Lines=0" in answer logging.info("\n*** list (json->text)") json_dict = {} - _, headers, answer = self._sharing_api_json(sharing_type, "list", 200, "owner:ownerpw", json_dict, "text/plain") + _, headers, answer = self._sharing_api_json(sharing_type, "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") assert "Status=not-found" in answer assert "Lines=0" in answer logging.info("\n*** list (json->json)") json_dict = {} - _, headers, answer = self._sharing_api_json(sharing_type, "list", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json(sharing_type, "list", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "not-found" assert answer_dict['Lines'] == 0 logging.info("\n*** create a token -> 200") form_array = ["PathMapped=/owner/collectionL1/"] - _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "PathOrToken=" in answer # extract token @@ -233,26 +233,26 @@ def test_sharing_api_list_with_auth(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = "/owner/collectionL2/" json_dict['PathOrToken'] = "/user/collectionL2-shared-by-owner/" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" logging.info("\n*** list/all (form->csv)") form_array = [] - _, headers, answer = self._sharing_api_form("all", "list", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("all", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "Lines=2" in answer logging.info("\n*** delete token -> 200") form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "delete", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer logging.info("\n*** delete share -> 200") form_array = [] form_array.append("PathOrToken=/user/collectionL2-shared-by-owner/") form_array.append("PathMapped=/owner/collectionL2/") - _, headers, answer = self._sharing_api_form("map", "delete", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("map", "delete", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer def test_sharing_api_token_basic(self) -> None: @@ -273,15 +273,15 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** create token without PathMapped (form) -> should fail") form_array = [] - _, headers, answer = self._sharing_api_form("token", "create", 400, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "create", 400, login="owner:ownerpw", form_array=form_array) logging.info("\n*** create token without PathMapped (json) -> should fail") json_dict = {} - _, headers, answer = self._sharing_api_json("token", "create", 400, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "create", 400, login="owner:ownerpw", json_dict=json_dict) logging.info("\n*** create token#1 (form->text)") form_array = ["PathMapped=/owner/collection1"] - _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "PathOrToken=" in answer # extract token @@ -294,7 +294,7 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** create token#2 (json->text)") json_dict = {'PathMapped': "/owner/collection2"} - _, headers, answer = self._sharing_api_json("token", "create", 200, "owner:ownerpw", json_dict, "text/plain") + _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") assert "Status=success" in answer assert "Token=" in answer # extract token @@ -307,21 +307,21 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** lookup token#1 (form->text)") form_array = ["PathOrToken=" + token1] - _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection1" in answer logging.info("\n*** lookup token#2 (json->text") json_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict, "text/plain") + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") assert "Status=success" in answer assert "Lines=1" in answer assert "/owner/collection2" in answer logging.info("\n*** lookup token#2 (json->json)") json_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 @@ -329,7 +329,7 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** lookup tokens (form->text)") form_array = [] - _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "Lines=2" in answer assert "/owner/collection1" in answer @@ -337,7 +337,7 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** lookup tokens (form->csv)") form_array = [] - _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array, "text/csv") + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array, accept="text/csv") assert "Status=success" not in answer assert "Lines=2" not in answer assert ",".join(sharing.DB_FIELDS_V1) in answer @@ -346,29 +346,29 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** delete token#1 (form->text)") form_array = ["PathOrToken=" + token1] - _, headers, answer = self._sharing_api_form("token", "delete", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer logging.info("\n*** lookup token#1 (form->text) -> should not be there anymore") form_array = ["PathOrToken=" + token1] - _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=not-found" in answer assert "Lines=0" in answer logging.info("\n*** lookup tokens (form->text) -> still one should be there") form_array = [] - _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "Lines=1" in answer logging.info("\n*** disable token#2 (form->text)") form_array = ["PathOrToken=" + token2] - _, headers, answer = self._sharing_api_form("token", "disable", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer logging.info("\n*** lookup token#2 (json->json) -> check for not enabled") json_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 @@ -377,14 +377,14 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** enable token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "enable", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** lookup token#2 (form->text) -> check for enabled") form_array = [] form_array.append("PathOrToken=" + token2) - _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "Lines=1" in answer assert "True,True,True,True" in answer @@ -392,13 +392,13 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** hide token#2 (form->text)") form_array = [] form_array.append("PathOrToken=" + token2) - _, headers, answer = self._sharing_api_form("token", "hide", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "hide", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer logging.info("\n*** lookup token#2 (form->text) -> check for hidden") form_array = [] form_array.append("PathOrToken=" + token2) - _, headers, answer = self._sharing_api_form("token", "list", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "Lines=1" in answer assert "True,True,True,True" in answer @@ -406,14 +406,14 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** unhide token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "unhide", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "unhide", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** lookup token#2 (json->json) -> check for not hidden") json_dict = {} json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 @@ -422,14 +422,14 @@ def test_sharing_api_token_basic(self) -> None: logging.info("\n*** delete token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** lookup token#2 (json->json) -> should not be there anymore") json_dict = {} json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "list", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "not-found" assert answer_dict['Lines'] == 0 @@ -454,23 +454,23 @@ def test_sharing_api_token_usage(self) -> None: path_base = "/owner/calendar.ics" logging.info("\n*** prepare") - self.mkcalendar("/owner/calendar.ics/", login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar("/owner/calendar.ics/", login="owner:ownerpw") event = get_file_content("event1.ics") path = path_base + "/event1.ics" - self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + self.put(path, event, login="owner:ownerpw") logging.info("\n*** test access to collection") - _, headers, answer = self.request("GET", path_base, check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_base, check=200, login="owner:ownerpw") assert "UID:event" in answer logging.info("\n*** test access to item") - _, headers, answer = self.request("GET", path, check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path, check=200, login="owner:ownerpw") assert "UID:event" in answer logging.info("\n*** create token") form_array = [] form_array.append("PathMapped=/owner/calendar.ics") - _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "PathOrToken=" in answer # extract token @@ -484,7 +484,7 @@ def test_sharing_api_token_usage(self) -> None: logging.info("\n*** create token#2") form_array = [] form_array.append("PathMapped=/owner/calendar2.ics") - _, headers, answer = self._sharing_api_form("token", "create", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer assert "PathOrToken=" in answer # extract token @@ -497,7 +497,7 @@ def test_sharing_api_token_usage(self) -> None: logging.info("\n*** enable token (form->text)") form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "enable", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer logging.info("\n*** fetch collection using invalid token (without credentials)") @@ -509,7 +509,7 @@ def test_sharing_api_token_usage(self) -> None: logging.info("\n*** disable token (form->text)") form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "disable", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer logging.info("\n*** fetch collection using disabled token (without credentials)") @@ -517,7 +517,7 @@ def test_sharing_api_token_usage(self) -> None: logging.info("\n*** enable token (form->text)") form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "enable", 200, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array) assert "Status=success" in answer logging.info("\n*** fetch collection using token (without credentials)") @@ -527,21 +527,21 @@ def test_sharing_api_token_usage(self) -> None: logging.info("\n*** delete token#2 (json->json)") json_dict = {} json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['ApiVersion'] == "1" assert answer_dict['Status'] == "success" logging.info("\n*** delete token (json->json)") json_dict = {'PathOrToken': token} - _, headers, answer = self._sharing_api_json("token", "delete", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['ApiVersion'] == "1" assert answer_dict['Status'] == "success" logging.info("\n*** delete token (form->text) -> no longer available") form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "delete", 404, "owner:ownerpw", form_array) + _, headers, answer = self._sharing_api_form("token", "delete", check=404, login="owner:ownerpw", form_array=form_array) logging.info("\n*** fetch collection using deleted token (without credentials)") _, headers, answer = self.request("GET", path_token + token, check=401) @@ -563,17 +563,17 @@ def test_sharing_api_map_basic(self) -> None: logging.info("\n*** create map without PathMapped (json) -> should fail") json_dict = {} - _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) logging.info("\n*** create map without PathMapped but User (json) -> should fail") json_dict = {'User': "user"} - _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) logging.info("\n*** create map without PathMapped but User and PathOrToken (json) -> should fail") json_dict = {} json_dict['User'] = "user" json_dict['PathOrToken'] = "/owner/calendar.ics" - _, headers, answer = self._sharing_api_json("map", "create", 400, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) def test_sharing_api_map_usage(self) -> None: """share-by-map API usage tests.""" @@ -600,19 +600,19 @@ def test_sharing_api_map_usage(self) -> None: path_mapped_item2 = os.path.join(path_mapped, file_item2) logging.info("\n*** prepare and test access") - self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped, login="owner:ownerpw") event = get_file_content(file_item1) - self.put(path_mapped_item1, event, check=201, login="%s:%s" % ("owner", "ownerpw")) + self.put(path_mapped_item1, event, check=201, login="owner:ownerpw") event = get_file_content(file_item2) - self.put(path_mapped_item2, event, check=201, login="%s:%s" % ("owner", "ownerpw")) + self.put(path_mapped_item2, event, check=201, login="owner:ownerpw") logging.info("\n*** test access to collection") - _, headers, answer = self.request("GET", path_mapped, check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped, check=200, login="owner:ownerpw") assert "UID:event1" in answer assert "UID:event2" in answer logging.info("\n*** test access to item") - _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="owner:ownerpw") assert "UID:event1" in answer logging.info("\n*** create map with PathMapped and User and PathOrToken (json)") @@ -620,13 +620,13 @@ def test_sharing_api_map_usage(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" logging.info("\n*** lookup map without filter (json->json)") json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 @@ -641,19 +641,19 @@ def test_sharing_api_map_usage(self) -> None: assert answer_dict['Content'][0]['HiddenByUser'] == str(True) assert answer_dict['Content'][0]['Permissions'] == "r" - logging.info("\n*** enable map by owner (json->json)") + logging.info("\n*** enable map by owner (json->json) -> 404") json_dict = {} json_dict['User'] = "owner" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", 404, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=404, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** enable map by owner for user (json->json)") + logging.info("\n*** enable map by owner for user (json->json) -> 200") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -662,7 +662,7 @@ def test_sharing_api_map_usage(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -671,35 +671,35 @@ def test_sharing_api_map_usage(self) -> None: json_dict['User'] = "owner" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", 403, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=403, login="user:userpw", json_dict=json_dict) logging.info("\n*** fetch collection (without credentials)") _, headers, answer = self.request("GET", path_mapped, check=401) logging.info("\n*** fetch collection (with credentials) as owner") - _, headers, answer = self.request("GET", path_mapped, check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped, check=200, login="owner:ownerpw") assert "UID:event" in answer logging.info("\n*** fetch item (with credentials) as owner") - _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="owner:ownerpw") assert "UID:event" in answer logging.info("\n*** fetch collection (with credentials) as user") - _, headers, answer = self.request("GET", path_mapped, check=403, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_mapped, check=403, login="user:userpw") logging.info("\n*** fetch collection via map (with credentials) as user") - _, headers, answer = self.request("GET", path_shared, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared, check=200, login="user:userpw") assert "UID:event1" in answer assert "UID:event2" in answer logging.info("\n*** fetch item via map (with credentials) as user") - _, headers, answer = self.request("GET", path_shared_item1, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_item1, check=200, login="user:userpw") # only requested event has to be in the answer assert "UID:event1" in answer assert "UID:event2" not in answer logging.info("\n*** fetch item via map (with credentials) as user") - _, headers, answer = self.request("GET", path_shared_item2, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_item2, check=200, login="user:userpw") # only requested event has to be in the answer assert "UID:event2" in answer assert "UID:event1" not in answer @@ -709,50 +709,50 @@ def test_sharing_api_map_usage(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "disable", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "disable", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") - _, headers, answer = self.request("GET", path_shared, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared, check=404, login="user:userpw") logging.info("\n*** enable map by owner (json->json)") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** fetch collection via map (with credentials) as user") - _, headers, answer = self.request("GET", path_shared, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared, check=200, login="user:userpw") logging.info("\n*** disable map by user (json->json)") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "disable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "disable", check=200, login="user:userpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") - _, headers, answer = self.request("GET", path_shared, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared, check=404, login="user:userpw") logging.info("\n*** delete map by user (json->json) -> fail") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "delete", 403, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "delete", check=403, login="user:userpw", json_dict=json_dict) logging.info("\n*** delete map by owner (json->json) -> ok") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "delete", 200, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -792,7 +792,7 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_share1 - _, headers, answer = self._sharing_api_json("map", "create", 403, "owner:ownerpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner:ownerpw", json_dict=json_dict) logging.info("\n*** create map user1/owner1:r -> ok") json_dict = {} @@ -800,7 +800,7 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_share1 json_dict['Permissions'] = "r" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner1:owner1pw", json_dict) + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -809,7 +809,7 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict['User'] = "user1" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_share1 - _, headers, answer = self._sharing_api_json("map", "create", 409, "owner1:owner1pw", json_dict) + _, headers, answer = self._sharing_api_json("map", "create", check=409, login="owner1:owner1pw", json_dict=json_dict) logging.info("\n*** create map user2/owner2:rw -> ok") json_dict = {} @@ -817,7 +817,7 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict['PathMapped'] = path_mapped2 json_dict['PathOrToken'] = path_share2 json_dict['Permissions'] = "rw" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner2:owner2pw", json_dict) + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner2:owner2pw", json_dict=json_dict) answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" @@ -826,7 +826,7 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict['User'] = "user2" json_dict['PathMapped'] = path_mapped2 json_dict['PathOrToken'] = path_share1 - _, headers, answer = self._sharing_api_json("map", "create", 403, "owner2:owner2pw", json_dict) + _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner2:owner2pw", json_dict=json_dict) def test_sharing_api_map_permissions(self) -> None: """share-by-map API usage tests related to permissions.""" @@ -849,14 +849,14 @@ def test_sharing_api_map_permissions(self) -> None: path_mapped = "/owner/calendar.ics/" logging.info("\n*** prepare and test access") - self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped, login="owner:ownerpw") event = get_file_content("event1.ics") path = path_mapped + "/event1.ics" - self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + self.put(path, event, login="owner:ownerpw") # check logging.info("\n*** fetch event as owner (init) -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") # create maps logging.info("\n*** create map user/owner:r -> ok") @@ -866,7 +866,7 @@ def test_sharing_api_map_permissions(self) -> None: json_dict['PathOrToken'] = path_shared_r json_dict['Permissions'] = "r" json_dict['Enabled'] = "True" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -877,7 +877,7 @@ def test_sharing_api_map_permissions(self) -> None: json_dict['PathOrToken'] = path_shared_w json_dict['Permissions'] = "w" json_dict['Enabled'] = "True" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -888,24 +888,24 @@ def test_sharing_api_map_permissions(self) -> None: json_dict['PathOrToken'] = path_shared_rw json_dict['Permissions'] = "rw" json_dict['Enabled'] = "True" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" # list created maps logging.info("\n*** list (json->text)") json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict, "text/csv") + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") # check permissions, no map is enabled by user -> 404 logging.info("\n*** fetch collection via map:r -> n/a") - _, headers, answer = self.request("GET", path_shared_r, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") logging.info("\n*** fetch collection via map:w -> n/a") - _, headers, answer = self.request("GET", path_shared_r, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") logging.info("\n*** fetch collection via map:rw -> n/a") - _, headers, answer = self.request("GET", path_shared_r, check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") # enable maps by user logging.info("\n*** enable map by user:r") @@ -913,106 +913,106 @@ def test_sharing_api_map_permissions(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) logging.info("\n*** enable map by user:w") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_w - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) logging.info("\n*** enable map by user:rw") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_rw - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # list adjusted maps logging.info("\n*** list (json->text)") json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict, "text/csv") + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") # check permissions, no map is enabled by user -> 404 logging.info("\n*** fetch collection via map:r -> ok") - _, headers, answer = self.request("GET", path_shared_r, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r, check=200, login="user:userpw") logging.info("\n*** fetch collection via map:w -> fail") - _, headers, answer = self.request("GET", path_shared_w, check=403, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_w, check=403, login="user:userpw") logging.info("\n*** fetch collection via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_rw, check=200, login="user:userpw") # list adjusted maps logging.info("\n*** list (json->text)") json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", 200, "owner:ownerpw", json_dict, "text/csv") + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") # PUT logging.info("\n*** put to collection by user via map:r -> fail") event = get_file_content("event2.ics") path = path_shared_r + "/event2.ics" - self.put(path, event, check=403, login="%s:%s" % ("user", "userpw")) + self.put(path, event, check=403, login="user:userpw") logging.info("\n*** put to collection by user via map:w -> ok") event = get_file_content("event2.ics") path = path_shared_w + "event2.ics" - self.put(path, event, check=201, login="%s:%s" % ("user", "userpw")) + self.put(path, event, check=201, login="user:userpw") # check result logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="user:userpw") logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="owner:ownerpw") logging.info("\n*** put to collection by user via map:rw -> ok") event = get_file_content("event3.ics") path = path_shared_rw + "event3.ics" - self.put(path, event, check=201, login="%s:%s" % ("user", "userpw")) + self.put(path, event, check=201, login="user:userpw") # check result logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="user:userpw") logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_shared_r + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_r + "event3.ics", check=200, login="user:userpw") logging.info("\n*** fetch event via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_rw + "event2.ics", check=200, login="user:userpw") logging.info("\n*** fetch event via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_shared_rw + "event3.ics", check=200, login="user:userpw") logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="owner:ownerpw") logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=200, login="owner:ownerpw") # DELETE logging.info("\n*** DELETE from collection by user via map:r -> fail") - _, headers, answer = self.request("DELETE", path_shared_r + "event1.ics", check=403, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("DELETE", path_shared_r + "event1.ics", check=403, login="user:userpw") logging.info("\n*** DELETE from collection by user via map:rw -> ok") - _, headers, answer = self.request("DELETE", path_shared_rw + "event2.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("DELETE", path_shared_rw + "event2.ics", check=200, login="user:userpw") logging.info("\n*** DELETE from collection by user via map:w -> ok") - _, headers, answer = self.request("DELETE", path_shared_w + "event3.ics", check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("DELETE", path_shared_w + "event3.ics", check=200, login="user:userpw") # check results logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") logging.info("\n*** fetch event as owner -> fail") - _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=404, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=404, login="owner:ownerpw") logging.info("\n*** fetch event as owner -> fail") - _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=404, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=404, login="owner:ownerpw") def test_sharing_api_map_report_access(self) -> None: """share-by-map API usage tests related to report.""" @@ -1036,13 +1036,13 @@ def test_sharing_api_map_report_access(self) -> None: path_mapped_item = os.path.join(path_mapped, "event1.ics") logging.info("\n*** prepare and test access") - self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped, login="owner:ownerpw") event = get_file_content("event1.ics") - self.put(path_mapped_item, event, login="%s:%s" % ("owner", "ownerpw")) + self.put(path_mapped_item, event, login="owner:ownerpw") # check GET logging.info("\n*** GET event as owner (init) -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") # check REPORT as owner logging.info("\n*** REPORT collection owner -> ok") @@ -1052,7 +1052,7 @@ def test_sharing_api_map_report_access(self) -> None: -""", login="%s:%s" % ("owner", "ownerpw")) +""", login="owner:ownerpw") assert len(responses) == 1 logging.info("response: %r", responses) response = responses[path_mapped_item] @@ -1069,7 +1069,7 @@ def test_sharing_api_map_report_access(self) -> None: json_dict['Permissions'] = "r" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -1081,7 +1081,7 @@ def test_sharing_api_map_report_access(self) -> None: -""", login="%s:%s" % ("user", "userpw"), check=404) +""", login="user:userpw", check=404) # enable map by user logging.info("\n*** enable map by user") @@ -1089,7 +1089,7 @@ def test_sharing_api_map_report_access(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # check REPORT as user logging.info("\n*** REPORT collection user -> ok") @@ -1099,7 +1099,7 @@ def test_sharing_api_map_report_access(self) -> None: -""", login="%s:%s" % ("user", "userpw")) +""", login="user:userpw") assert len(responses) == 1 logging.info("response: %r", responses) response = responses[path_shared_item] @@ -1134,25 +1134,25 @@ def test_sharing_api_map_hidden(self) -> None: path_user_item = os.path.join(path_user, "event2.ics") logging.info("\n*** prepare and test access") - self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped, login="owner:ownerpw") event = get_file_content("event1.ics") - self.put(path_mapped_item, event, login="%s:%s" % ("owner", "ownerpw")) + self.put(path_mapped_item, event, login="owner:ownerpw") - self.mkcalendar(path_mapped2, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped2, login="owner:ownerpw") - self.mkcalendar(path_user, login="%s:%s" % ("user", "userpw")) + self.mkcalendar(path_user, login="user:userpw") event = get_file_content("event2.ics") - self.put(path_user_item, event, login="%s:%s" % ("user", "userpw")) + self.put(path_user_item, event, login="user:userpw") # check GET logging.info("\n*** GET event1 as owner -> 200") - _, headers, answer = self.request("GET", path_mapped_item, check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped_item, check=200, login="owner:ownerpw") logging.info("\n*** GET event2 as user -> 200") - _, headers, answer = self.request("GET", path_user_item, check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_user_item, check=200, login="user:userpw") logging.info("\n*** GET collections as user -> 403") - _, headers, answer = self.request("GET", path_user_base, check=403, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", path_user_base, check=403, login="user:userpw") # check PROPFIND as user logging.info("\n*** PROPFIND collection user -> ok") @@ -1160,7 +1160,7 @@ def test_sharing_api_map_hidden(self) -> None: -""", login="%s:%s" % ("user", "userpw"), HTTP_DEPTH="1") +""", login="user:userpw", HTTP_DEPTH="1") assert len(responses) == 2 logging.info("response: %r", responses) response = responses[path_user] @@ -1175,7 +1175,7 @@ def test_sharing_api_map_hidden(self) -> None: json_dict['Permissions'] = "r" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -1185,7 +1185,7 @@ def test_sharing_api_map_hidden(self) -> None: -""", login="%s:%s" % ("owner", "ownerpw"), HTTP_DEPTH="1") +""", login="owner:ownerpw", HTTP_DEPTH="1") assert len(responses) == 3 logging.info("response: %r", responses) response = responses[path_mapped] @@ -1199,7 +1199,7 @@ def test_sharing_api_map_hidden(self) -> None: -""", login="%s:%s" % ("user", "userpw"), HTTP_DEPTH="1") +""", login="user:userpw", HTTP_DEPTH="1") assert len(responses) == 2 logging.info("response: %r", responses) response = responses[path_user] @@ -1211,7 +1211,7 @@ def test_sharing_api_map_hidden(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # check PROPFIND as user logging.info("\n*** PROPFIND collection user -> ok") @@ -1219,7 +1219,7 @@ def test_sharing_api_map_hidden(self) -> None: -""", login="%s:%s" % ("user", "userpw"), HTTP_DEPTH="1") +""", login="user:userpw", HTTP_DEPTH="1") assert len(responses) == 2 logging.info("response: %r", responses) response = responses[path_user] @@ -1231,7 +1231,7 @@ def test_sharing_api_map_hidden(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "unhide", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "unhide", check=200, login="user:userpw", json_dict=json_dict) # check PROPFIND as user logging.info("\n*** PROPFIND collection user -> ok (now 3 items)") @@ -1239,7 +1239,7 @@ def test_sharing_api_map_hidden(self) -> None: -""", login="%s:%s" % ("user", "userpw"), HTTP_DEPTH="1") +""", login="user:userpw", HTTP_DEPTH="1") assert len(responses) == 3 logging.info("response: %r", responses) response = responses[path_user] @@ -1268,14 +1268,14 @@ def test_sharing_api_map_propfind(self) -> None: path_mapped = "/owner/calendar.ics/" logging.info("\n*** prepare and test access") - self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped, login="owner:ownerpw") event = get_file_content("event1.ics") path = os.path.join(path_mapped, "event1.ics") - self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + self.put(path, event, login="owner:ownerpw") # check GET logging.info("\n*** GET event as owner (init) -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") # check PROPFIND as owner logging.info("\n*** PROPFIND collection owner -> ok") @@ -1285,7 +1285,7 @@ def test_sharing_api_map_propfind(self) -> None: -""", login="%s:%s" % ("owner", "ownerpw")) +""", login="owner:ownerpw") logging.info("response: %r", responses) response = responses[path_mapped] assert not isinstance(response, int) and len(response) == 1 @@ -1303,7 +1303,7 @@ def test_sharing_api_map_propfind(self) -> None: json_dict['Permissions'] = "r" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -1315,7 +1315,7 @@ def test_sharing_api_map_propfind(self) -> None: -""", login="%s:%s" % ("user", "userpw"), check=404) +""", login="user:userpw", check=404) # enable map by user logging.info("\n*** enable map by user") @@ -1323,7 +1323,7 @@ def test_sharing_api_map_propfind(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # check PROPFIND as user logging.info("\n*** PROPFIND collection user -> ok") @@ -1333,7 +1333,7 @@ def test_sharing_api_map_propfind(self) -> None: -""", login="%s:%s" % ("user", "userpw"), check=207) +""", login="user:userpw", check=207) logging.info("response: %r", responses) response = responses[path_shared] assert not isinstance(response, int) and len(response) == 1 @@ -1364,14 +1364,14 @@ def test_sharing_api_map_proppatch(self) -> None: path_shared_rw = "/user/calendarPP-shared-by-owner-rw.ics/" logging.info("\n*** prepare and test access") - self.mkcalendar(path_mapped, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped, login="owner:ownerpw") event = get_file_content("event1.ics") path = os.path.join(path_mapped, "event1.ics") - self.put(path, event, login="%s:%s" % ("owner", "ownerpw")) + self.put(path, event, login="owner:ownerpw") # check GET logging.info("\n*** GET event as owner (init) -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") # check PROPFIND as owner logging.info("\n*** PROPFIND collection owner -> ok") @@ -1381,7 +1381,7 @@ def test_sharing_api_map_proppatch(self) -> None: -""", login="%s:%s" % ("owner", "ownerpw")) +""", login="owner:ownerpw") logging.info("response: %r", responses) response = responses[path_mapped] assert not isinstance(response, int) and len(response) == 1 @@ -1393,7 +1393,7 @@ def test_sharing_api_map_proppatch(self) -> None: # check PROPPATCH as owner logging.info("\n*** PROPPATCH collection owner -> ok") proppatch = get_file_content("proppatch_set_calendar_color.xml") - _, responses = self.proppatch(path_mapped, proppatch, login="%s:%s" % ("owner", "ownerpw")) + _, responses = self.proppatch(path_mapped, proppatch, login="owner:ownerpw") logging.info("response: %r", responses) response = responses[path_mapped] assert not isinstance(response, int) and len(response) == 1 @@ -1403,9 +1403,9 @@ def test_sharing_api_map_proppatch(self) -> None: # check PROPPATCH as user logging.info("\n*** PROPPATCH collection as user -> 404") proppatch = get_file_content("proppatch_remove_calendar_color.xml") - _, responses = self.proppatch(path_shared_r, proppatch, login="%s:%s" % ("user", "userpw"), check=404) - _, responses = self.proppatch(path_shared_w, proppatch, login="%s:%s" % ("user", "userpw"), check=404) - _, responses = self.proppatch(path_shared_rw, proppatch, login="%s:%s" % ("user", "userpw"), check=404) + _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw", check=404) # create map logging.info("\n*** create map user/owner:r -> ok") @@ -1416,7 +1416,7 @@ def test_sharing_api_map_proppatch(self) -> None: json_dict['Permissions'] = "r" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -1428,7 +1428,7 @@ def test_sharing_api_map_proppatch(self) -> None: json_dict['Permissions'] = "w" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -1440,16 +1440,16 @@ def test_sharing_api_map_proppatch(self) -> None: json_dict['Permissions'] = "rw" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" # check PROPPATCH as user logging.info("\n*** PROPPATCH collection as user -> 403") proppatch = get_file_content("proppatch_set_calendar_color.xml") - _, responses = self.proppatch(path_shared_r, proppatch, login="%s:%s" % ("user", "userpw"), check=404) - _, responses = self.proppatch(path_shared_w, proppatch, login="%s:%s" % ("user", "userpw"), check=404) - _, responses = self.proppatch(path_shared_rw, proppatch, login="%s:%s" % ("user", "userpw"), check=404) + _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw", check=404) # enable map by user logging.info("\n*** enable map by user") @@ -1457,33 +1457,33 @@ def test_sharing_api_map_proppatch(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) logging.info("\n*** enable map by user") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_w - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) logging.info("\n*** enable map by user") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_rw - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # check PROPPATCH as user proppatch = get_file_content("proppatch_remove_calendar_color.xml") logging.info("\n*** PROPPATCH collection as user:r -> 403") - _, responses = self.proppatch(path_shared_r, proppatch, login="%s:%s" % ("user", "userpw"), check=403) + _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=403) logging.info("\n*** PROPPATCH collection as user:w -> ok") - _, responses = self.proppatch(path_shared_w, proppatch, login="%s:%s" % ("user", "userpw")) + _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw") logging.info("response: %r", responses) logging.info("\n*** PROPPATCH collection as user:rw -> ok") - _, responses = self.proppatch(path_shared_rw, proppatch, login="%s:%s" % ("user", "userpw")) + _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw") logging.info("response: %r", responses) # check PROPFIND as owner @@ -1494,7 +1494,7 @@ def test_sharing_api_map_proppatch(self) -> None: -""", login="%s:%s" % ("owner", "ownerpw")) +""", login="owner:ownerpw") logging.info("response: %r", responses) response = responses[path_mapped] assert not isinstance(response, int) and len(response) == 1 @@ -1528,40 +1528,40 @@ def test_sharing_api_map_move(self) -> None: path_shared2_rw = "/user/calendar2M-shared-by-owner-rw.ics/" logging.info("\n*** prepare and test access") - self.mkcalendar(path_mapped1, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped1, login="owner:ownerpw") event = get_file_content("event1.ics") - self.put(os.path.join(path_mapped1, "event1.ics"), event, login="%s:%s" % ("owner", "ownerpw")) + self.put(os.path.join(path_mapped1, "event1.ics"), event, login="owner:ownerpw") - self.mkcalendar(path_mapped2, login="%s:%s" % ("owner", "ownerpw")) + self.mkcalendar(path_mapped2, login="owner:ownerpw") event = get_file_content("event2.ics") - self.put(os.path.join(path_mapped2, "event2.ics"), event, login="%s:%s" % ("owner", "ownerpw")) + self.put(os.path.join(path_mapped2, "event2.ics"), event, login="owner:ownerpw") - self.mkcalendar(path_user, login="%s:%s" % ("user", "userpw")) + self.mkcalendar(path_user, login="user:userpw") event = get_file_content("event3.ics") - self.put(os.path.join(path_user, "event3.ics"), event, login="%s:%s" % ("user", "userpw")) + self.put(os.path.join(path_user, "event3.ics"), event, login="user:userpw") # check GET as owner logging.info("\n*** GET mapped1/event1 as owner (init) -> ok") - _, headers, answer = self.request("GET", os.path.join(path_mapped1, "event1.ics"), check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", os.path.join(path_mapped1, "event1.ics"), check=200, login="owner:ownerpw") logging.info("\n*** GET mapped2/event2 as owner (init) -> ok") - _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event2.ics"), check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event2.ics"), check=200, login="owner:ownerpw") # check MOVE as owner logging.info("\n*** MOVE event1 to mapped2 as owner -> ok") self.request("MOVE", os.path.join(path_mapped1, "event1.ics"), check=201, - login="%s:%s" % ("owner", "ownerpw"), + login="owner:ownerpw", HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_mapped2, "event1.ics")) logging.info("\n*** GET mapped2/event1 as owner (after move) -> ok") - _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event1.ics"), check=200, login="%s:%s" % ("owner", "ownerpw")) + _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event1.ics"), check=200, login="owner:ownerpw") # check GET as user logging.info("\n*** GET event1 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="user:userpw") logging.info("\n*** GET event2 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event2.ics"), check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event2.ics"), check=404, login="user:userpw") # create map logging.info("\n*** create map user/owner:r -> ok") @@ -1572,7 +1572,7 @@ def test_sharing_api_map_move(self) -> None: json_dict['Permissions'] = "r" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -1584,7 +1584,7 @@ def test_sharing_api_map_move(self) -> None: json_dict['Permissions'] = "rw" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" @@ -1596,19 +1596,19 @@ def test_sharing_api_map_move(self) -> None: json_dict['Permissions'] = "rw" json_dict['Enabled'] = "True" json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", 200, "owner:ownerpw", json_dict) + _, 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" # check MOVE as user logging.info("\n*** MOVE event1 of shared1 to shared2 as user -> 404 (not enabled)") self.request("MOVE", os.path.join(path_shared1_r, "event1.ics"), check=404, - login="%s:%s" % ("user", "userpw"), + login="user:userpw", HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) logging.info("\n*** MOVE event1 of shared2 to shared1 as user -> 404 (not enabled)") self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=404, - login="%s:%s" % ("user", "userpw"), + login="user:userpw", HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_r, "event1.ics")) # enable map by user @@ -1617,77 +1617,77 @@ def test_sharing_api_map_move(self) -> None: json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_shared1_r - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) logging.info("\n*** enable map shared1_rw by user") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped1 json_dict['PathOrToken'] = path_shared1_rw - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) logging.info("\n*** enable map shared2_rw by user") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped2 json_dict['PathOrToken'] = path_shared2_rw - _, headers, answer = self._sharing_api_json("map", "enable", 200, "user:userpw", json_dict) + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) # check GET as user logging.info("\n*** GET event1 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="user:userpw") logging.info("\n*** GET event1 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=404, login="user:userpw") logging.info("\n*** GET event1 as user -> ok") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=200, login="user:userpw") # check MOVE as user between shares logging.info("\n*** MOVE event1 of shared1_r to shared2_rw as user -> 403 (not permitted to move from r)") self.request("MOVE", os.path.join(path_shared1_r, "event1.ics"), check=403, - login="%s:%s" % ("user", "userpw"), + login="user:userpw", HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) logging.info("\n*** MOVE event1 of shared2_rw to shared1_r as user -> 403 (not permitted to move to r)") self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=403, - login="%s:%s" % ("user", "userpw"), + login="user:userpw", HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_r, "event1.ics")) logging.info("\n*** MOVE event1 of shared1_rw to shared2_rw as user -> 404 (already moved by owner)") self.request("MOVE", os.path.join(path_shared1_rw, "event1.ics"), check=404, - login="%s:%s" % ("user", "userpw"), + login="user:userpw", HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) logging.info("\n*** MOVE event1 of shared2_rw to shared1_rw as user -> 201") self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=201, - login="%s:%s" % ("user", "userpw"), + login="user:userpw", HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_rw, "event1.ics")) # check GET as user logging.info("\n*** GET event1 as user -> ok") - _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=200, login="user:userpw") logging.info("\n*** GET event1 as user -> ok") - _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=200, login="user:userpw") logging.info("\n*** GET event1 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=404, login="user:userpw") # check MOVE as user between shares and own calendar logging.info("\n*** GET event3 as user -> 200") - _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=200, login="user:userpw") logging.info("\n*** GET event3 as user from shared2_rw -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=404, login="user:userpw") logging.info("\n*** MOVE event3 of own to shared2_rw as user -> 201") self.request("MOVE", os.path.join(path_user, "event3.ics"), check=201, - login="%s:%s" % ("user", "userpw"), + login="user:userpw", HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event3.ics")) logging.info("\n*** GET event3 as user from shared2_rw -> 200") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=200, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=200, login="user:userpw") logging.info("\n*** GET event3 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=404, login="%s:%s" % ("user", "userpw")) + _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=404, login="user:userpw") From a35b1f950c3723800abd443ff0a4d5734adda1c5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 15:01:20 +0100 Subject: [PATCH 090/138] add support for "update" --- radicale/sharing/csv.py | 67 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index e95e010a6..2303aaa59 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -207,6 +207,73 @@ def create_sharing(self, logger.error("sharing/add_sharing_by_token: cannot update CSV database") return {"status": "error"} + def update_sharing(self, + ShareType: str, + PathOrToken: str, + Owner: 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: + """ update sharing """ + if ShareType == "token": + logger.debug("TRACE/sharing/token/update: PathOrToken=%r Owner=%r", PathOrToken, Owner) + elif ShareType == "map": + logger.debug("TRACE/sharing/map/update: PathOrToken=%r Owner=%r PathMapped=%r", PathOrToken, Owner, PathMapped) + else: + raise # should not be reached + + # lookup token + found = False + index = 0 + for row in self._sharing_cache: + if row['ShareType'] != ShareType: + pass + elif row['PathOrToken'] != PathOrToken: + pass + else: + found = True + break + index += 1 + + if found: + logger.debug("TRACE/sharing/*/update: found index=%d", index) + if row['Owner'] != Owner: + return {"status": "permission-denied"} + logger.debug("TRACE/sharing/*/update: Owner=%r PathOrToken=%r index=%d", Owner, PathOrToken, index) + + logger.debug("TRACE/sharing/*/update: orig row=%r", row) + + # CSV: remove+adjust+readd + if PathMapped is not None: + row["PathMapped"] = PathMapped + if Permissions is not None: + row["Permissions"] = Permissions + if User is not None: + row["User"] = User + if EnabledByOwner is not None: + row["EnabledByOwner"] = EnabledByOwner + if HiddenByOwner is not None: + row["HiddenByOwner"] = HiddenByOwner + # update timestamp + row["TimestampUpdated"] = Timestamp + + logger.debug("TRACE/sharing/*/update: adj row=%r", row) + + # TODO: add locking + # replace row + self._sharing_cache.pop(index) + self._sharing_cache.append(row) + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing_by_token: write CSV done") + return {"status": "success"} + logger.warning("sharing/sharing_by_token: cannot update CSV database") + return {"status": "error"} + else: + return {"status": "not-found"} + def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, From 42b4f03b01b00d22df24cd69265e70c69cfbc29b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 15:02:18 +0100 Subject: [PATCH 091/138] add support for "update" --- radicale/sharing/__init__.py | 83 ++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index e417a8552..d38712f5a 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -151,6 +151,19 @@ def create_sharing(self, """ create sharing """ return {"status": "not-implemented"} + def update_sharing(self, + ShareType: str, + PathOrToken: str, + Owner: 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: + """ update sharing """ + return {"status": "not-implemented"} + def delete_sharing(self, ShareType: str, PathOrToken: str, @@ -407,11 +420,11 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st PathMapped: str Owner: str = user User: Union[str | None] = None - Permissions: str = "" # no permissions by default - EnabledByOwner: bool = False # security by default - HiddenByOwner: bool = True # security by default - EnabledByUser: bool = False # security by default - HiddenByUser: bool = True # security by default + Permissions: Union[str | None] = None # no permissions by default + EnabledByOwner: Union[bool | None] = None + HiddenByOwner: Union[bool | None] = None + EnabledByUser: Union[bool | None] = None + HiddenByUser: Union[bool | None] = None # parameters sanity check for key in request_data: @@ -475,13 +488,16 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.BAD_REQUEST PathOrToken = request_data['PathOrToken'] + if 'Permissions' in request_data: + Permissions = request_data['Permissions'] + if ShareType == "map": if action == 'info': # ignored pass else: if 'User' not in request_data: - if action not in ['list', 'delete']: + if action not in ['list', 'delete', 'update']: logger.warning(api_info + ": missing User") return httputils.BAD_REQUEST else: @@ -525,14 +541,19 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("TRACE/" + api_info + ": start") if 'Permissions' not in request_data: Permissions = "r" - else: - Permissions = request_data['Permissions'] if 'Enabled' in request_data: EnabledByOwner = config._convert_to_bool(request_data['Enabled']) + else: + EnabledByOwner = False # security by default if 'Hidden' in request_data: HiddenByOwner = config._convert_to_bool(request_data['Hidden']) + else: + HiddenByOwner = True # security by default + + EnabledByUser = False # security by default + HiddenByUser = True # security by default if ShareType == "token": # check access Permissions @@ -609,6 +630,52 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token) answer['PathOrToken'] = token + # action: update + elif action == "update": + logger.debug("TRACE/" + api_info + ": start") + + if PathOrToken is None: + return httputils.BAD_REQUEST + + if ShareType == "token": + result = self.update_sharing( + ShareType=ShareType, + PathMapped=PathMapped, + Permissions=Permissions, + EnabledByOwner=EnabledByOwner, + HiddenByOwner=HiddenByOwner, + PathOrToken=str(PathOrToken), # verification above that it is not None + Owner=Owner) + + elif ShareType == "map": + result = self.update_sharing( + ShareType=ShareType, + PathMapped=PathMapped, + Permissions=Permissions, + EnabledByOwner=EnabledByOwner, + HiddenByOwner=HiddenByOwner, + PathOrToken=str(PathOrToken), # verification above that it is not None + Owner=Owner) + + else: + logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + return httputils.BAD_REQUEST + + # result handling + if result['status'] == "not-found": + return httputils.NOT_FOUND + elif result['status'] == "permission-denied": + return httputils.NOT_ALLOWED + elif result['status'] == "success": + answer['Status'] = "success" + pass + else: + if ShareType == "token": + logger.info("Update of sharing-by-token: %r not successful", request_data['PathOrToken']) + elif ShareType == "map": + logger.info("Update of sharing-by-map: %r not successful", request_data['PathOrToken']) + return httputils.BAD_REQUEST + # action: delete elif action == "delete": logger.debug("TRACE/" + api_info + ": start") From f9f6d67631b92e14b6c0b71f2853c928b15e98cf Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 15:02:38 +0100 Subject: [PATCH 092/138] testcases for "update" --- radicale/tests/test_sharing.py | 108 ++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 3160770a6..7cbf83937 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -1505,7 +1505,7 @@ def test_sharing_api_map_proppatch(self) -> None: assert "ICAL:calendar-color" not in response def test_sharing_api_map_move(self) -> None: - """share-by-map API usage tests related to report.""" + """share-by-map API usage tests related to MOVE.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, @@ -1691,3 +1691,109 @@ def test_sharing_api_map_move(self) -> None: logging.info("\n*** GET event3 as user -> 404") _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=404, login="user:userpw") + + def test_sharing_api_update(self) -> None: + """sharing API usage tests related to update.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "False", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + json_dict: dict + + path_mapped1 = "/owner/calendar1U.ics/" + path_mapped2 = "/owner/calendar2U.ics/" + path_shared1 = "/user/calendar1U-shared-by-owner.ics/" + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped1, login="owner:ownerpw") + event = get_file_content("event1.ics") + self.put(os.path.join(path_mapped1, "event1.ics"), event, login="owner:ownerpw") + + # check GET as owner + logging.info("\n*** GET mapped1 as owner (init) -> 200") + _, headers, answer = self.request("GET", path_mapped1, check=200, login="owner:ownerpw") + + logging.info("\n*** GET shared1 as user (init) -> 404") + _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") + + logging.info("\n*** create map user/owner:w -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1 + json_dict['Permissions'] = "w" + 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" + + logging.info("\n*** GET shared1 as user (still not enabled by user) -> 404") + _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") + + # enable map by user + logging.info("\n*** enable map shared1 by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1 + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + logging.info("\n*** list/all (form->csv)") + form_array = [] + _, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=1" in answer + + # read collection + logging.info("\n*** GET shared1 as user (no read permissions set by owner) -> 403") + _, headers, answer = self.request("GET", path_shared1, check=403, login="user:userpw") + + # update map + logging.info("\n*** update map user/owner:r -> ok") + json_dict = {} + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1 + json_dict['Permissions'] = "r" + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** list/all (form->csv)") + form_array = [] + _, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=1" in answer + + # read collection + logging.info("\n*** GET shared1 as user (read permissions set by owner) -> 200") + _, headers, answer = self.request("GET", path_shared1, check=200, login="user:userpw") + + # update map + logging.info("\n*** update map user/owner:path_mapped2 -> ok") + json_dict = {} + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_shared1 + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # read collection + logging.info("\n*** GET shared1 as user (path not matching) -> 404") + _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") + + logging.info("\n*** create mapped2 collection") + self.mkcalendar(path_mapped2, login="owner:ownerpw") + event = get_file_content("event2.ics") + self.put(os.path.join(path_mapped2, "event2.ics"), event, login="owner:ownerpw") + + # read collection + logging.info("\n*** GET shared1 as user (path now matching) -> 200") + _, headers, answer = self.request("GET", path_shared1, check=200, login="user:userpw") From f6308b89bf4831109327fdce65ad98fc28c58da1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 15:13:47 +0100 Subject: [PATCH 093/138] use boolean instead of string representation --- radicale/sharing/csv.py | 40 +++++++++++++++++----------------- radicale/tests/test_sharing.py | 12 +++++----- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 2303aaa59..b46e56bff 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -91,16 +91,16 @@ def get_sharing(self, continue elif User and row['User'] != User: continue - elif row['EnabledByOwner'] != str(True): + elif row['EnabledByOwner'] != True: continue - elif row['EnabledByUser'] != str(True): + elif row['EnabledByUser'] != True: continue PathMapped = row['PathMapped'] Owner = row['Owner'] UserShare = row['User'] Permissions = row['Permissions'] - Hidden: bool = (row['HiddenByOwner'] == str(True)) or (row['HiddenByUser'] == str(True)) - logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, str(Hidden)) + Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden) return { "mapped": True, "PathOrToken": PathOrToken, @@ -139,13 +139,13 @@ def list_sharing(self, continue elif PathMapped is not None and row['PathMapped'] != PathMapped: continue - elif EnabledByOwner is not None and row['EnabledByOwner'] != str(EnabledByOwner): + elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: continue - elif EnabledByUser is not None and row['EnabledByUser'] != str(EnabledByUser): + elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: continue - elif HiddenByOwner is not None and row['HiddenByOwner'] != str(HiddenByOwner): + elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: continue - elif HiddenByUser is not None and row['HiddenByUser'] != str(HiddenByUser): + elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: continue logger.debug("TRACE/sharing/list/row: add: %r", row) result.append(row) @@ -192,10 +192,10 @@ def create_sharing(self, "Owner": Owner, "User": User, "Permissions": Permissions, - "EnabledByOwner": str(EnabledByOwner), - "EnabledByUser": str(EnabledByUser), - "HiddenByOwner": str(HiddenByOwner), - "HiddenByUser": str(HiddenByUser), + "EnabledByOwner": EnabledByOwner, + "EnabledByUser": EnabledByUser, + "HiddenByOwner": HiddenByOwner, + "HiddenByUser": HiddenByUser, "TimestampCreated": str(Timestamp), "TimestampUpdated": str(Timestamp)} logger.debug("TRACE/sharing/*/create: add row: %r", row) @@ -378,24 +378,24 @@ def toggle_sharing(self, if row['Owner'] == OwnerOrUser: logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": Owner=%r User=%r PathOrToken=%r index=%d", OwnerOrUser, User, PathOrToken, index) if Action == "disable": - row['EnabledByOwner'] = str(False) + row['EnabledByOwner'] = False elif Action == "enable": - row['EnabledByOwner'] = str(True) + row['EnabledByOwner'] = True elif Action == "hide": - row['HiddenByOwner'] = str(True) + row['HiddenByOwner'] = True elif Action == "unhide": - row['HiddenByOwner'] = str(False) + row['HiddenByOwner'] = False row['TimestampUpdated'] = str(Timestamp) if row['User'] == OwnerOrUser: logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": User=%r PathOrToken=%r index=%d", OwnerOrUser, PathOrToken, index) if Action == "disable": - row['EnabledByUser'] = str(False) + row['EnabledByUser'] = False elif Action == "enable": - row['EnabledByUser'] = str(True) + row['EnabledByUser'] = True elif Action == "hide": - row['HiddenByUser'] = str(True) + row['HiddenByUser'] = True elif Action == "unhide": - row['HiddenByUser'] = str(False) + row['HiddenByUser'] = False row['TimestampUpdated'] = str(Timestamp) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 7cbf83937..fc8010ce9 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -372,7 +372,7 @@ def test_sharing_api_token_basic(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['EnabledByOwner'] == str(False) + assert answer_dict['Content'][0]['EnabledByOwner'] == False logging.info("\n*** enable token#2 (json->json)") json_dict = {} @@ -417,7 +417,7 @@ def test_sharing_api_token_basic(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['HiddenByOwner'] == str(False) + assert answer_dict['Content'][0]['HiddenByOwner'] == False logging.info("\n*** delete token#2 (json->json)") json_dict = {} @@ -635,10 +635,10 @@ def test_sharing_api_map_usage(self) -> None: assert answer_dict['Content'][0]['ShareType'] == "map" assert answer_dict['Content'][0]['Owner'] == "owner" assert answer_dict['Content'][0]['User'] == "user" - assert answer_dict['Content'][0]['EnabledByOwner'] == str(False) - assert answer_dict['Content'][0]['EnabledByUser'] == str(False) - assert answer_dict['Content'][0]['HiddenByOwner'] == str(True) - assert answer_dict['Content'][0]['HiddenByUser'] == str(True) + assert answer_dict['Content'][0]['EnabledByOwner'] == False + assert answer_dict['Content'][0]['EnabledByUser'] == False + assert answer_dict['Content'][0]['HiddenByOwner'] == True + assert answer_dict['Content'][0]['HiddenByUser'] == True assert answer_dict['Content'][0]['Permissions'] == "r" logging.info("\n*** enable map by owner (json->json) -> 404") From 514de80ec79185b3fc3dae6a664248798c147cf6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 15:23:09 +0100 Subject: [PATCH 094/138] extend info otput --- radicale/sharing/__init__.py | 8 +++++++- radicale/tests/test_sharing.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d38712f5a..36e1c3425 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -48,6 +48,9 @@ # TimestampUpdated: (last update) SHARE_TYPES: Sequence[str] = ('token', 'map', 'all') +# token: share by secret token (does not require authentication) +# map : share by mapping collection of one user to another as virtual +# all : only supported for "list" and "info" OUTPUT_TYPES: Sequence[str] = ('csv', 'json', 'txt') @@ -55,11 +58,12 @@ # list : list sharings (optional filtered) # create : create share by token or map # delete : delete share -# update : update share (TODO implementation) +# update : update share # hide : hide share (by user or owner) # unhide : unhide share (by user or owner) # enable : hide share (by user or owner) # disable: unhide share (by user or owner) +# info : display support status and permissions API_SHARE_TOGGLES_V1: Sequence[str] = ('hide', 'unhide', 'enable', 'disable') @@ -720,8 +724,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st answer['Status'] = "success" if ShareType in ["all", "map"]: answer['FeatureEnabledCollectionByMap'] = self.sharing_collection_by_map; + answer['PermittedCreateCollectionByMap'] = True # TODO toggle per permission, default? if ShareType in ["all", "token"]: answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token; + answer['PermittedCreateCollectionByToken'] = True # TODO toggle per permission, default? # action: TOGGLE elif action in API_SHARE_TOGGLES_V1: diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index fc8010ce9..1d2991d49 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -143,6 +143,8 @@ def test_sharing_api_base_with_auth(self) -> None: answer_dict = json.loads(answer) assert answer_dict['FeatureEnabledCollectionByMap'] == True assert answer_dict['FeatureEnabledCollectionByToken'] == False + assert answer_dict['PermittedCreateCollectionByMap'] == True + assert answer_dict['PermittedCreateCollectionByToken'] == True logging.info("\n*** check API hook: info/map") json_dict = {} @@ -150,6 +152,7 @@ def test_sharing_api_base_with_auth(self) -> None: answer_dict = json.loads(answer) assert answer_dict['FeatureEnabledCollectionByMap'] == True assert 'FeatureEnabledCollectionByToken' not in answer_dict + assert 'PermittedCreateCollectionByToken' not in answer_dict logging.info("\n*** check API hook: info/token -> 404 (not enabled)") json_dict = {} @@ -173,6 +176,7 @@ def test_sharing_api_base_with_auth(self) -> None: answer_dict = json.loads(answer) assert answer_dict['FeatureEnabledCollectionByToken'] == True assert 'FeatureEnabledCollectionByMap' not in answer_dict + assert 'PermittedCreateCollectionByMap' not in answer_dict def test_sharing_api_list_with_auth(self) -> None: """POST/list with authentication.""" From af487735780d09ba482e97fe7a304e78545fd112 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sun, 15 Feb 2026 11:27:19 +0100 Subject: [PATCH 095/138] Add missing import --- radicale/app/proppatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 8689cd504..e769a9847 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -24,7 +24,7 @@ import socket import xml.etree.ElementTree as ET from http import client -from typing import Dict, Optional, cast +from typing import Dict, Optional, Union, cast import defusedxml.ElementTree as DefusedET From e00d136946c1931d08955750d15c531c28a25fe6 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sun, 15 Feb 2026 11:27:34 +0100 Subject: [PATCH 096/138] Moved import to avoid circular dependency --- radicale/sharing/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 36e1c3425..9a1a136f6 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -28,7 +28,6 @@ from urllib.parse import parse_qs from radicale import config, httputils, pathutils, rights, types, utils -from radicale.app.base import Access from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "none") @@ -287,6 +286,9 @@ def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict # POST API def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: + #Late import to avoid circular dependency in config + from radicale.app.base import Access + """POST request. ``base_prefix`` is sanitized and never ends with "/". From a20631fee6298eb356aeb567175ca8a9244bb125 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sun, 15 Feb 2026 14:55:15 +0100 Subject: [PATCH 097/138] Fix: use HTTP_ACCEPT to conform with WSGIEnviron --- radicale/sharing/__init__.py | 2 +- radicale/tests/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 9a1a136f6..00eb9606f 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -403,7 +403,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.BAD_REQUEST # check for requested output type - accept = environ.get("ACCEPT", "") + accept = environ.get("HTTP_ACCEPT", "") if 'application/json' in accept: output_format = "json" elif 'text/csv' in accept: diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index a78db61d5..30e8999dc 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -109,7 +109,7 @@ def request(self, method: str, path: str, data: Optional[str] = None, if content_type: environ["CONTENT_TYPE"] = content_type if accept: - environ["ACCEPT"] = accept + environ["HTTP_ACCEPT"] = accept environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path if data is not None: From 754c02da39bb1bb6f015cd984ce3e8df54c77083 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 17:15:26 +0100 Subject: [PATCH 098/138] catch incompatible csv database --- radicale/sharing/csv.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index b46e56bff..f615302fd 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -65,7 +65,7 @@ def init_database(self) -> bool: # read database try: if self._load_csv(sharing_db_file) is not True: - raise + return False except Exception as e: logger.error("sharing database load failed: %r (%r)", sharing_db_file, e) return False @@ -423,9 +423,17 @@ def _create_empty_csv(self, file: str) -> bool: def _load_csv(self, file: str) -> bool: logger.debug("sharing database load begin: %r", file) with open(file, 'r', newline='') as csvfile: - reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) + reader = csv.DictReader(csvfile) self._lines = 0 for row in reader: + logger.debug("sharing database load read: %r", row) + if self._lines == 0: + # header line, check + for fieldname in sharing.DB_FIELDS_V1: + logger.debug("sharing database load check fieldname: %r", fieldname) + if fieldname not in row: + logger.debug("sharing database is incompatible: %r", fil, filee) + return False # check for duplicates dup = False for row_cached in self._sharing_cache: @@ -434,7 +442,8 @@ def _load_csv(self, file: str) -> bool: break if dup: continue - self._sharing_cache.append(row) + if self._lines > 0: + self._sharing_cache.append(row) self._lines += 1 logger.debug("sharing database load end: %r", file) return True From b4314b7f16d5fc768e578fdec1e6806f94d7bec0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 17:22:28 +0100 Subject: [PATCH 099/138] fix csv/dict --- radicale/sharing/csv.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index f615302fd..749fa3513 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -423,7 +423,7 @@ def _create_empty_csv(self, file: str) -> bool: def _load_csv(self, file: str) -> bool: logger.debug("sharing database load begin: %r", file) with open(file, 'r', newline='') as csvfile: - reader = csv.DictReader(csvfile) + reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) self._lines = 0 for row in reader: logger.debug("sharing database load read: %r", row) @@ -442,8 +442,7 @@ def _load_csv(self, file: str) -> bool: break if dup: continue - if self._lines > 0: - self._sharing_cache.append(row) + self._sharing_cache.append(row) self._lines += 1 logger.debug("sharing database load end: %r", file) return True From ce4c7fbb985ddde525aca5dd98cb16ca17d3c240 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 17:52:24 +0100 Subject: [PATCH 100/138] convert bool/int --- radicale/sharing/__init__.py | 2 ++ radicale/sharing/csv.py | 28 ++++++++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 00eb9606f..69d2a099e 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -33,6 +33,8 @@ INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "none") DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') +DB_FIELDS_V1_BOOL: Sequence[str] = ('EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser') +DB_FIELDS_V1_INT: Sequence[str] = ('TimestampCreated', 'TimestampUpdated') # ShareType: # PathOrToken: [PrimaryKey] # PathMapped: diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 749fa3513..d749a4825 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -18,7 +18,7 @@ import os from typing import Union -from radicale import sharing +from radicale import config, sharing from radicale.log import logger """ CVS based sharing by token or map """ @@ -85,16 +85,18 @@ def get_sharing(self, # Lookup logger.debug("TRACE/sharing: lookup ShareType=%r PathOrToken=%r User=%r)", ShareType, PathOrToken, User) for row in self._sharing_cache: + logger.debug("TRACE/sharing: check row: %r", row) if row['ShareType'] != ShareType: continue - elif row['PathOrToken'] != PathOrToken: - continue - elif User and row['User'] != User: + if row['PathOrToken'] != PathOrToken: continue - elif row['EnabledByOwner'] != True: + if User is not None and row['User'] != User: continue - elif row['EnabledByUser'] != True: + if row['EnabledByOwner'] != True: continue + if row['ShareType'] == "map": + if row['EnabledByUser'] != True: + continue PathMapped = row['PathMapped'] Owner = row['Owner'] UserShare = row['User'] @@ -196,8 +198,8 @@ def create_sharing(self, "EnabledByUser": EnabledByUser, "HiddenByOwner": HiddenByOwner, "HiddenByUser": HiddenByUser, - "TimestampCreated": str(Timestamp), - "TimestampUpdated": str(Timestamp)} + "TimestampCreated": Timestamp, + "TimestampUpdated": Timestamp} logger.debug("TRACE/sharing/*/create: add row: %r", row) # TODO: add locking self._sharing_cache.append(row) @@ -385,7 +387,7 @@ def toggle_sharing(self, row['HiddenByOwner'] = True elif Action == "unhide": row['HiddenByOwner'] = False - row['TimestampUpdated'] = str(Timestamp) + row['TimestampUpdated'] = Timestamp if row['User'] == OwnerOrUser: logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": User=%r PathOrToken=%r index=%d", OwnerOrUser, PathOrToken, index) if Action == "disable": @@ -397,7 +399,7 @@ def toggle_sharing(self, elif Action == "unhide": row['HiddenByUser'] = False - row['TimestampUpdated'] = str(Timestamp) + row['TimestampUpdated'] = Timestamp # remove self._sharing_cache.pop(index) @@ -434,6 +436,12 @@ def _load_csv(self, file: str) -> bool: if fieldname not in row: logger.debug("sharing database is incompatible: %r", fil, filee) return False + # convert txt to bool + if self._lines > 0: + for fieldname in sharing.DB_FIELDS_V1_BOOL: + row[fieldname] = config._convert_to_bool(row[fieldname]) + for fieldname in sharing.DB_FIELDS_V1_INT: + row[fieldname] = int(row[fieldname]) # check for duplicates dup = False for row_cached in self._sharing_cache: From a3a07d057ec63576c075f852e0f706c876c82df6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 18:02:40 +0100 Subject: [PATCH 101/138] skip header line --- radicale/sharing/csv.py | 43 ++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index d749a4825..39e8ecb11 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -84,19 +84,34 @@ def get_sharing(self, """ retrieve sharing target and attributes by map """ # Lookup logger.debug("TRACE/sharing: lookup ShareType=%r PathOrToken=%r User=%r)", ShareType, PathOrToken, User) + + index = 0 + found = False for row in self._sharing_cache: + if index == 0: + # skip fieldnames + pass logger.debug("TRACE/sharing: check row: %r", row) if row['ShareType'] != ShareType: - continue - if row['PathOrToken'] != PathOrToken: - continue - if User is not None and row['User'] != User: - continue - if row['EnabledByOwner'] != True: - continue - if row['ShareType'] == "map": + pass + elif row['PathOrToken'] != PathOrToken: + pass + elif User is not None and row['User'] != User: + pass + elif row['EnabledByOwner'] != True: + pass + elif row['ShareType'] == "map": if row['EnabledByUser'] != True: - continue + pass + else: + found = True + break + else: + found = True + break + index += 1 + + if found: PathMapped = row['PathMapped'] Owner = row['Owner'] UserShare = row['User'] @@ -231,6 +246,9 @@ def update_sharing(self, found = False index = 0 for row in self._sharing_cache: + if index == 0: + # skip fieldnames + pass if row['ShareType'] != ShareType: pass elif row['PathOrToken'] != PathOrToken: @@ -292,6 +310,10 @@ def delete_sharing(self, found = False index = 0 for row in self._sharing_cache: + logger.debug("TRACE/sharing/map/delete: check: %r", row) + if index == 0: + # skip fieldnames + pass if row['ShareType'] != ShareType: pass elif row['PathOrToken'] != PathOrToken: @@ -346,6 +368,9 @@ def toggle_sharing(self, found = False index = 0 for row in self._sharing_cache: + if index == 0: + # skip fieldnames + pass logger.debug("TRACE/sharing/*/" + Action + ": check: %r", row) if row['ShareType'] != ShareType: pass From ba13278a526e3ff6b94a3c357fadd6b63e1dbc45 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 18:06:10 +0100 Subject: [PATCH 102/138] adj debug --- radicale/sharing/csv.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 39e8ecb11..bcb43ca8b 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -453,7 +453,7 @@ def _load_csv(self, file: str) -> bool: reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) self._lines = 0 for row in reader: - logger.debug("sharing database load read: %r", row) + # logger.debug("sharing database load read: %r", row) if self._lines == 0: # header line, check for fieldname in sharing.DB_FIELDS_V1: @@ -475,6 +475,7 @@ def _load_csv(self, file: str) -> bool: break if dup: continue + # logger.debug("sharing database load add: %r", row) self._sharing_cache.append(row) self._lines += 1 logger.debug("sharing database load end: %r", file) From 44a52d120f8ecbd2a66f3a655b403941e2cfd622 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 18:30:09 +0100 Subject: [PATCH 103/138] add missing timestamp --- radicale/sharing/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 69d2a099e..c962def6e 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -653,7 +653,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, PathOrToken=str(PathOrToken), # verification above that it is not None - Owner=Owner) + Owner=Owner, + Timestamp=Timestamp) elif ShareType == "map": result = self.update_sharing( @@ -663,7 +664,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, PathOrToken=str(PathOrToken), # verification above that it is not None - Owner=Owner) + Owner=Owner, + Timestamp=Timestamp) else: logger.error(api_info + ": unsupported for ShareType=%r", ShareType) From b7c30fdb096d5246009add19388050eccea4de7f Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Feb 2026 18:41:39 +0100 Subject: [PATCH 104/138] fixes --- radicale/sharing/__init__.py | 9 +++++---- radicale/sharing/csv.py | 14 ++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index c962def6e..5b85138b3 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -159,7 +159,7 @@ def create_sharing(self, def update_sharing(self, ShareType: str, PathOrToken: str, - Owner: str, + Owner: Union[str | None] = None, User: Union[str | None] = None, PathMapped: Union[str | None] = None, Permissions: Union[str | None] = None, @@ -425,7 +425,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st # parameters default PathOrToken: Union[str | None] = None - PathMapped: str + PathMapped: Union[str | None] = None Owner: str = user User: Union[str | None] = None Permissions: Union[str | None] = None # no permissions by default @@ -746,8 +746,9 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st result = self.toggle_sharing( ShareType=ShareType, PathOrToken=str(PathOrToken), # verification above that it is not None - OwnerOrUser=user, # authenticated user - User=User, # provided user for selection + OwnerOrUser=user, # authenticated user + User=User, # optional for selection + PathMapped=PathMapped, # optional for selection Action=action, Timestamp=Timestamp) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index bcb43ca8b..ddb77227e 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -227,7 +227,7 @@ def create_sharing(self, def update_sharing(self, ShareType: str, PathOrToken: str, - Owner: str, + Owner: Union[str | None] = None, User: Union[str | None] = None, PathMapped: Union[str | None] = None, Permissions: Union[str | None] = None, @@ -236,7 +236,7 @@ def update_sharing(self, Timestamp: int = 0) -> dict: """ update sharing """ if ShareType == "token": - logger.debug("TRACE/sharing/token/update: PathOrToken=%r Owner=%r", PathOrToken, Owner) + logger.debug("TRACE/sharing/token/update: PathOrToken=%r Owner=%r User=%r", PathOrToken, Owner, User) elif ShareType == "map": logger.debug("TRACE/sharing/map/update: PathOrToken=%r Owner=%r PathMapped=%r", PathOrToken, Owner, PathMapped) else: @@ -260,7 +260,9 @@ def update_sharing(self, if found: logger.debug("TRACE/sharing/*/update: found index=%d", index) - if row['Owner'] != Owner: + if Owner is not None and row['Owner'] != Owner: + return {"status": "permission-denied"} + if User is not None and row['User'] != User: return {"status": "permission-denied"} logger.debug("TRACE/sharing/*/update: Owner=%r PathOrToken=%r index=%d", Owner, PathOrToken, index) @@ -376,11 +378,11 @@ def toggle_sharing(self, pass elif row['PathOrToken'] != PathOrToken: pass - elif PathMapped and row['PathMapped'] != PathMapped: + elif PathMapped is not None and row['PathMapped'] != PathMapped: pass elif row['Owner'] == OwnerOrUser: # owner has requested filter-by-user - if User and row['User'] != User: + if User is not None and row['User'] != User: pass else: found = True @@ -392,7 +394,7 @@ def toggle_sharing(self, if found: # logger.debug("TRACE/sharing/*/" + Action + ": found: %r", row) - if User and row['User'] != User: + if User is not None and row['User'] != User: return {"status": "permission-denied"} elif row['Owner'] == OwnerOrUser: pass From 2f1282a205c2fd5065c5a1e0f408805a23cc991f Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sun, 15 Feb 2026 15:33:43 +0100 Subject: [PATCH 105/138] Add additional information on bad request response --- radicale/httputils.py | 2 ++ radicale/sharing/__init__.py | 61 ++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/radicale/httputils.py b/radicale/httputils.py index 81e017153..cc1f81e81 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -105,6 +105,8 @@ ".xml": "text/xml"} FALLBACK_MIMETYPE: str = "application/octet-stream" +def bad_request(additional_details: str) -> types.WSGIResponse: + return (client.BAD_REQUEST, (("Content-Type", "text/plain"),), f"Bad Request: {additional_details}", None) def decode_request(configuration: "config.Configuration", environ: types.WSGIEnviron, text: bytes) -> str: diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 5b85138b3..e985b8a7a 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -378,7 +378,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st request_body = httputils.read_request_body(self.configuration, environ) except RuntimeError as e: logger.warning("Bad POST request on %r (read_request_body): %s", path, e, exc_info=True) - return httputils.BAD_REQUEST + return httputils.bad_request("Failed read POST request body") except socket.timeout: logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT @@ -391,7 +391,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st try: request_data = json.loads(request_body) except json.JSONDecodeError: - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid JSON") logger.debug("TRACE/" + api_info + " (json): %r", f"{request_data}") elif 'application/x-www-form-urlencoded' in content_type: request_parsed = parse_qs(request_body) @@ -402,7 +402,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("TRACE/" + api_info + " (form): %r", f"{request_data}") else: logger.debug("TRACE/" + api_info + ": no supported content data") - return httputils.BAD_REQUEST + return httputils.bad_request("Content-type not supported") # check for requested output type accept = environ.get("HTTP_ACCEPT", "") @@ -415,13 +415,13 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if output_format == "csv": if not action == "list": - return httputils.BAD_REQUEST + return httputils.bad_request("CSV output format is only allowed for list action") elif output_format == "json": pass elif output_format == "txt": pass else: - return httputils.BAD_REQUEST + return httputils.bad_request("Output format not supported") # parameters default PathOrToken: Union[str | None] = None @@ -438,28 +438,29 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st for key in request_data: if key == "Permissions": if not re.search('^[a-zA-Z]+$', request_data[key]): - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid value for Permissions") elif key == "PathOrToken": if ShareType == "token": if not re.search('^' + TOKEN_PATTERN_V1 + '$', request_data[key]): logger.error(api_info + ": unsupported " + key) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid value for PathOrToken") elif ShareType == "map": if not re.search('^' + PATH_PATTERN + '$', request_data[key]): logger.error(api_info + ": unsupported " + key) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid value for PathOrToken") elif key == "PathMapped": if not re.search('^' + PATH_PATTERN + '$', request_data[key]): logger.error(api_info + ": unsupported " + key) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid value for PathMapped") elif key == "Enabled" or key == "Hidden": if not re.search('^(False|True)$', request_data[key]): logger.error(api_info + ": unsupported " + key) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid value for " + key) elif key == "User": if not re.search('^' + USER_PATTERN + '$', request_data[key]): logger.error(api_info + ": unsupported " + key) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid value for User") + # check for mandatory parameters if 'PathMapped' not in request_data: @@ -475,7 +476,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st pass else: logger.error(api_info + ": missing PathMapped") - return httputils.BAD_REQUEST + return httputils.bad_request("Missing PathMapped") else: PathMapped = request_data['PathMapped'] @@ -485,7 +486,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st pass elif action not in ['list', 'create']: logger.error(api_info + ": missing PathOrToken") - return httputils.BAD_REQUEST + return httputils.bad_request("Missing PathOrToken") else: # PathOrToken is optional pass @@ -493,7 +494,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if action == "create" and ShareType == "token": # not supported logger.error(api_info + ": PathOrToken found but not supported") - return httputils.BAD_REQUEST + return httputils.bad_request("PathOrToken not supported") PathOrToken = request_data['PathOrToken'] if 'Permissions' in request_data: @@ -507,7 +508,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if 'User' not in request_data: if action not in ['list', 'delete', 'update']: logger.warning(api_info + ": missing User") - return httputils.BAD_REQUEST + return httputils.bad_request("Missing User") else: # optional pass @@ -587,12 +588,12 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif ShareType == "map": # check preconditions if PathOrToken is None: - return httputils.BAD_REQUEST + return httputils.bad_request("Missing PathOrToken") else: PathOrToken = str(PathOrToken) if User is None: - return httputils.BAD_REQUEST + return httputils.bad_request("Missing User") else: User = str(User) @@ -621,7 +622,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st else: logger.error(api_info + ": unsupported for ShareType=%r", ShareType) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid share type") logger.debug("TRACE/" + api_info + ": result=%r", result) # result handling @@ -632,7 +633,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif result['status'] == "success": answer['Status'] = "success" else: - return httputils.BAD_REQUEST + return httputils.bad_request("Internal failure") if ShareType == "token": logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token) @@ -643,7 +644,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.debug("TRACE/" + api_info + ": start") if PathOrToken is None: - return httputils.BAD_REQUEST + return httputils.bad_request("Missing PathOrToken") if ShareType == "token": result = self.update_sharing( @@ -669,7 +670,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st else: logger.error(api_info + ": unsupported for ShareType=%r", ShareType) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid share type") # result handling if result['status'] == "not-found": @@ -684,14 +685,14 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info("Update of sharing-by-token: %r not successful", request_data['PathOrToken']) elif ShareType == "map": logger.info("Update of sharing-by-map: %r not successful", request_data['PathOrToken']) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid share type") # action: delete elif action == "delete": logger.debug("TRACE/" + api_info + ": start") if PathOrToken is None: - return httputils.BAD_REQUEST + return httputils.bad_request("Missing PathOrToken") if ShareType == "token": result = self.delete_sharing( @@ -708,7 +709,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st else: logger.error(api_info + ": unsupported for ShareType=%r", ShareType) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid share type") # result handling if result['status'] == "not-found": @@ -723,7 +724,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info("Delete sharing-by-token: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) elif ShareType == "map": logger.info("Delete sharing-by-map: %r of user %r not successful", request_data['PathOrToken'], request_data['User']) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid share type") # action: info elif action == "info": @@ -741,7 +742,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if ShareType in ["token", "map"]: if PathOrToken is None: - return httputils.BAD_REQUEST + return httputils.bad_request("Missing PathOrToken") result = self.toggle_sharing( ShareType=ShareType, @@ -762,16 +763,16 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st pass else: logger.error("Toggle sharing: %r of user %s not successful", request_data['PathOrToken'], user) - return httputils.BAD_REQUEST + return httputils.bad_request("Internal Error") else: logger.error(api_info + ": unsupported for ShareType=%r", ShareType) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid share type") else: # default logger.error(api_info + ": unsupported action=%r", action) - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid action") # output handler logger.debug("TRACE/sharing/API/POST output format: %r", output_format) @@ -809,6 +810,6 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return client.OK, headers, answer_raw, None else: # should not be reached - return httputils.BAD_REQUEST + return httputils.bad_request("Invalid output format") return httputils.METHOD_NOT_ALLOWED From 491d90db8509d3825896c0839f5b6642b3d20d35 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 17 Feb 2026 06:22:06 +0100 Subject: [PATCH 106/138] fix/extend filter on "list" --- radicale/sharing/__init__.py | 9 ++++-- radicale/sharing/csv.py | 57 +++++++++++++++++++++--------------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index e985b8a7a..ce5a782b1 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -126,10 +126,10 @@ def get_database_info(self) -> Union[dict, None]: return None def list_sharing(self, + OwnerOrUser: str, ShareType: Union[str | None] = None, PathOrToken: Union[str | None] = None, PathMapped: Union[str | None] = None, - Owner: Union[str | None] = None, User: Union[str | None] = None, EnabledByOwner: Union[bool | None] = None, EnabledByUser: Union[bool | None] = None, @@ -224,6 +224,7 @@ def sharing_collection_map_list(self, user: str) -> Union[dict | None]: # retrieve collections which are enabled and not hidden by owner+user shared_collection_list = self.list_sharing( ShareType="map", + OwnerOrUser=user, User=user, EnabledByOwner=True, EnabledByUser=True, @@ -531,11 +532,13 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if ShareType != "all": result_array = self.list_sharing( ShareType=ShareType, - Owner=Owner, + OwnerOrUser=Owner, + PathMapped=PathMapped, PathOrToken=PathOrToken) else: result_array = self.list_sharing( - Owner=Owner, + OwnerOrUser=Owner, + PathMapped=PathMapped, PathOrToken=PathOrToken) answer['Lines'] = len(result_array) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index ddb77227e..a5dcb7b50 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -129,10 +129,10 @@ def get_sharing(self, return None def list_sharing(self, + OwnerOrUser: str, ShareType: Union[str | None] = None, PathOrToken: Union[str | None] = None, PathMapped: Union[str | None] = None, - Owner: Union[str | None] = None, User: Union[str | None] = None, EnabledByOwner: Union[bool | None] = None, EnabledByUser: Union[bool | None] = None, @@ -140,32 +140,43 @@ def list_sharing(self, HiddenByUser: Union[bool | None] = None) -> list[dict]: """ retrieve sharing """ row: dict + index = 0 result = [] - logger.debug("TRACE/sharing/list/called: HiddenByOwner=%s HiddenByUser=%s", HiddenByOwner, HiddenByUser) + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, HiddenByOwner, HiddenByUser) for row in self._sharing_cache: - logger.debug("TRACE/sharing/list/row: test: %r", row) - if ShareType is not None and row['ShareType'] != ShareType: - continue - elif Owner is not None and row['Owner'] != Owner: - continue - elif User is not None and row['User'] != User: - continue - elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: - continue - elif PathMapped is not None and row['PathMapped'] != PathMapped: - continue - elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: - continue - elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: - continue - elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: - continue - elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: - continue - logger.debug("TRACE/sharing/list/row: add: %r", row) - result.append(row) + if index == 0: + # skip fieldnames + pass + else: + logger.debug("TRACE/sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: + logger.debug("TRACE/sharing/list/row: skip by ShareType") + pass + elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): + pass + elif User is not None and row['User'] != User: + logger.debug("TRACE/sharing/list/row: skip by User") + pass + elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: + logger.debug("TRACE/sharing/list/row: skip by PathOrToken") + pass + elif PathMapped is not None and row['PathMapped'] != PathMapped: + logger.debug("TRACE/sharing/list/row: skip by PathMapped") + pass + elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: + pass + elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: + pass + elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: + pass + elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: + pass + else: + logger.debug("TRACE/sharing/list/row: add: %r", row) + result.append(row) + index += 1 return result def create_sharing(self, From cd40e79941b4cd9c562f3cad8527b0a4b4ed46cf Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 17 Feb 2026 06:22:32 +0100 Subject: [PATCH 107/138] extend text cases --- radicale/tests/test_sharing.py | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 1d2991d49..9a7ca2189 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -832,6 +832,20 @@ def test_sharing_api_map_usercheck(self) -> None: json_dict['PathOrToken'] = path_share1 _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner2:owner2pw", json_dict=json_dict) + logging.info("\n*** delete map user1 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner1:owner1pw", json_dict=json_dict) + + logging.info("\n*** delete map user2 -> ok") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_share2 + _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner2:owner2pw", json_dict=json_dict) + def test_sharing_api_map_permissions(self) -> None: """share-by-map API usage tests related to permissions.""" self.configure({"auth": {"type": "htpasswd", @@ -1801,3 +1815,141 @@ def test_sharing_api_update(self) -> None: # read collection logging.info("\n*** GET shared1 as user (path now matching) -> 200") _, headers, answer = self.request("GET", path_shared1, check=200, login="user:userpw") + + def test_sharing_api_list_filter(self) -> None: + """sharing API usage tests related to update.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "False", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + json_dict: dict + + path_user1 = "/user1/calendarLFu1.ics/" + path_user2 = "/user2/calendarLFu2.ics/" + path_user1_shared1 = "/user1/calendarLFo1-shared.ics/" + path_user1_shared2 = "/user1/calendarLFo2-shared.ics/" + path_user2_shared1 = "/user2/calendarLFo1-shared.ics/" + path_user2_shared2 = "/user2/calendarLFo2-shared.ics/" + path_owner1 = "/owner1/calendarLFo1.ics/" + path_owner2 = "/owner2/calendarLFo2.ics/" + + logging.info("\n*** prepare") + self.mkcalendar(path_owner1, login="owner1:owner1pw") + self.mkcalendar(path_owner2, login="owner2:owner2pw") + self.mkcalendar(path_user1, login="user1:user1pw") + self.mkcalendar(path_user2, login="user2:user2pw") + + # list current + logging.info("\n*** list owner1 -> empty") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" + + logging.info("\n*** list owner2 -> empty") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner2:owner2pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" + + logging.info("\n*** list user1 -> empty") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" + + logging.info("\n*** list user2 -> empty") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user2:user2pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" + + # create map#1 + logging.info("\n*** create map user1/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user1_shared1 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** list owner1") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + + logging.info("\n*** list user1") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + + # create map#2 + logging.info("\n*** create map user1/owner2 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_owner2 + json_dict['PathOrToken'] = path_user1_shared2 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner2:owner2pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** list user1 -> 2 entries") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 2 + + # create map#3 + logging.info("\n*** create map user2/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user2_shared1 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** list owner1 -> 2 entries") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 2 + + logging.info("\n*** list user1 filter for PathMapped -> 1 entries") + json_dict = {} + json_dict['PathMapped'] = path_owner1 + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + + logging.info("\n*** list user1 filter for PathShared -> 1 entries") + json_dict = {} + json_dict['PathOrToken'] = path_user1_shared1 + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 From b17231aabc2a638784daa87c1ad761254000d231 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 17 Feb 2026 06:37:13 +0100 Subject: [PATCH 108/138] cleanup --- radicale/sharing/__init__.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index ce5a782b1..c0c6700cd 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -190,22 +190,21 @@ def toggle_sharing(self, # sharing functions called by request methods def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None]: - """ returning dict with mapped-flag, PathMapped, Owner, Permissions or None if invalid""" + """ returning dict with PathMapped, Owner, Permissions or None if not found""" if self.sharing_collection_by_token: result = self.sharing_collection_by_token_resolver(path) - if result is None: - return result - elif result["mapped"]: + if result is not None: return result + else: + # check for map + pass else: logger.debug("TRACE/sharing_by_token: not active") return None if self.sharing_collection_by_map: result = self.sharing_collection_by_map_resolver(path, user) - if result is None: - return result - elif result["mapped"]: + if result is not None: return result else: logger.debug("TRACE/sharing_by_map: not active") @@ -216,7 +215,7 @@ def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None # list active sharings of type "map" def sharing_collection_map_list(self, user: str) -> Union[dict | None]: - """ returning dict with shared collections (enabled and unhidden) or None if invalid""" + """ returning dict with shared collections (enabled and unhidden) or None if not found""" if not self.sharing_collection_by_map: logger.debug("TRACE/sharing_by_map: not active") return None @@ -236,7 +235,7 @@ def sharing_collection_map_list(self, user: str) -> Union[dict | None]: # internal sharing functions def sharing_collection_by_token_resolver(self, path) -> Union[dict | None]: - """ returning dict with mapped-flag, PathMapped, Owner, Permissions or None if invalid""" + """ returning dict with PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_token: logger.debug("TRACE/sharing_by_token: check path: %r", path) if path.startswith("/.token/"): @@ -253,13 +252,13 @@ def sharing_collection_by_token_resolver(self, path) -> Union[dict | None]: PathOrToken=match[1]) else: logger.debug("TRACE/sharing_by_token: no supported prefix found in path: %r", path) - return {"mapped": False} + return None else: logger.debug("TRACE/sharing_by_token: not active") - return {"mapped": False} + return None def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict | None]: - """ returning dict with mapped-flag, PathMapped, Owner, Permissions or None if invalid""" + """ returning dict with PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_map: logger.debug("TRACE/sharing/resolver/map: check path: %r", path) result = self.get_sharing( @@ -282,10 +281,10 @@ def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict return result else: logger.debug("TRACE/sharing_by_map: not found") - return {"mapped": False} + return None else: logger.debug("TRACE/sharing_by_map: not active") - return {"mapped": False} + return None # POST API def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: From 80da52aa249ad4b14dcfd406a77786036e85d06c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 17 Feb 2026 20:59:58 +0100 Subject: [PATCH 109/138] check for conflicts --- radicale/sharing/__init__.py | 43 +++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index c0c6700cd..c11d7c89c 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -27,7 +27,7 @@ from typing import Sequence, Union from urllib.parse import parse_qs -from radicale import config, httputils, pathutils, rights, types, utils +from radicale import config, httputils, pathutils, rights, storage, types, utils from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "none") @@ -95,6 +95,7 @@ def __init__(self, configuration: "config.Configuration") -> None: """ self.configuration = configuration self._rights = rights.load(configuration) + self._storage = storage.load(configuration) # Sharing self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map") self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token") @@ -213,22 +214,29 @@ def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None # final return None - # list active sharings of type "map" - def sharing_collection_map_list(self, user: str) -> Union[dict | None]: - """ returning dict with shared collections (enabled and unhidden) or None if not found""" + # list sharings of type "map" + def sharing_collection_map_list(self, user: str, active: bool = True) -> Union[dict | None]: + """ returning dict with shared collections (active==True: enabled and unhidden) or None if not found""" if not self.sharing_collection_by_map: logger.debug("TRACE/sharing_by_map: not active") return None # retrieve collections which are enabled and not hidden by owner+user - shared_collection_list = self.list_sharing( - ShareType="map", - OwnerOrUser=user, - User=user, - EnabledByOwner=True, - EnabledByUser=True, - HiddenByOwner=False, - HiddenByUser=False) + if active: + shared_collection_list = self.list_sharing( + ShareType="map", + OwnerOrUser=user, + User=user, + EnabledByOwner=True, + EnabledByUser=True, + HiddenByOwner=False, + HiddenByUser=False) + else: + # unconditional + shared_collection_list = self.list_sharing( + ShareType="map", + OwnerOrUser=user, + User=user) # final return shared_collection_list @@ -605,11 +613,20 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info("Add sharing-by-map: access to path(mapped) %r not allowed for owner %r", PathMapped, Owner) return httputils.NOT_ALLOWED - access = Access(self._rights, str(User), str(PathOrToken)) + access = Access(self._rights, str(User), PathOrToken) if not access.check("r") and "i" not in access.permissions: logger.info("Add sharing-by-map: access to path %r not allowed for user %r", PathOrToken, user) return httputils.NOT_ALLOWED + # check whether share is already existing as real collection + with self._storage.acquire_lock("r", user, path=PathOrToken): + item = next(iter(self._storage.discover(PathOrToken)), None) + if not item: + pass + else: + logger.info("Add sharing-by-map: path %r already exists as real collection for user %r", PathOrToken, user) + return httputils.CONFLICT + logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r user=%r)", PathMapped, Permissions, PathOrToken, User) result = self.create_sharing( ShareType=ShareType, From 3382e79c49ad5a9f99d4532ed55d635e71fb1a1d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 17 Feb 2026 21:00:13 +0100 Subject: [PATCH 110/138] check for conflicts --- radicale/app/mkcalendar.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index ccdf850cc..47f8ca3e4 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -55,6 +55,12 @@ def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST + # check for shared collections (active or inactive) + collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False) + if collections_shared_map: + for sharing in collections_shared_map: + if sharing['PathOrToken'] == path: + return httputils.CONFLICT # TODO: use this? # timezone = props.get("C:calendar-timezone") with self._storage.acquire_lock("w", user, path=path, request="MKCALENDAR"): From 19ce8fbca1c32ae8c7a14fa4daea6456030b5039 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 17 Feb 2026 21:00:25 +0100 Subject: [PATCH 111/138] check for conflicts --- radicale/app/mkcol.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 497685349..477799b0e 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -62,6 +62,12 @@ def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, if not props.get("tag") and "W" not in permissions: logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'") return httputils.NOT_ALLOWED + # check for shared collections (active or inactive) + collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False) + if collections_shared_map: + for sharing in collections_shared_map: + if sharing['PathOrToken'] == path: + return httputils.CONFLICT with self._storage.acquire_lock("w", user, path=path, request="MKCOL"): item = next(iter(self._storage.discover(path)), None) if item: From 72d4c4016632a64d8d5e8d2d8a69c6d54a093d2e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 17 Feb 2026 21:00:35 +0100 Subject: [PATCH 112/138] test: check for conflicts --- radicale/tests/test_sharing.py | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 9a7ca2189..f0c33e26e 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -1953,3 +1953,80 @@ def test_sharing_api_list_filter(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 + + def test_sharing_api_create_conflict(self) -> None: + """sharing API usage tests related to update.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "collection_by_map": "True", + "collection_by_token": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "False", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + json_dict: dict + + path_user1 = "/user1/calendarCCu1.ics/" + path_user2 = "/user2/calendarCCu2.ics/" + path_user1_shared1 = "/user1/calendarCCo1-shared.ics/" + path_user1_shared2 = "/user1/calendarCCo2-shared.ics/" + path_user2_shared1 = "/user2/calendarCCo1-shared.ics/" + path_user2_shared2 = "/user2/calendarCCo2-shared.ics/" + path_owner1 = "/owner1/calendarCCo1.ics/" + path_owner2 = "/owner2/calendarCCo2.ics/" + + logging.info("\n*** prepare") + self.mkcalendar(path_owner1, login="owner1:owner1pw") + self.mkcalendar(path_owner2, login="owner2:owner2pw") + self.mkcalendar(path_user1, login="user1:user1pw") + self.mkcalendar(path_user2, login="user2:user2pw") + + # create calendar a 2nd time + logging.info("\n*** mkcalendar user2 -> conflict") + self.mkcalendar(path_user2, login="user2:user2pw", check=409) + + # create map + logging.info("\n*** create map user1/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user1_shared1 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** mkcalendar user1 for shared -> conflict") + self.mkcalendar(path_user1_shared1, login="user1:user1pw", check=409) + + # create map + logging.info("\n*** create map user2/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user2_shared1 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** mkcol user2 for shared -> conflict") + self.mkcalendar(path_user2_shared1, login="user2:user2pw", check=409) + + # create map + logging.info("\n*** create map user2/owner2 -> 409") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user2 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=409, login="owner1:owner1pw", json_dict=json_dict) From 0c4e81defb89ae1153083203972d813e65f3a399 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Wed, 18 Feb 2026 06:18:26 +0100 Subject: [PATCH 113/138] add sharing database verification --- DOCUMENTATION.md | 6 +++++ radicale/__main__.py | 19 ++++++++++++++-- radicale/sharing/__init__.py | 43 +++++++++++++++++++++++++++++++++++- radicale/sharing/csv.py | 14 +++++++++--- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d7ddc02cb..5ee33b722 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -726,6 +726,12 @@ _(>= 3.6.0)_ Verification of a particular item file +##### --verify-sharing + +_(>= 3.7.0)_ + +Verification of local sharing database + ##### -C|--config Load one or more specified config file(s) diff --git a/radicale/__main__.py b/radicale/__main__.py index e5eb68db2..50ea3e5e7 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -1,7 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -33,7 +33,7 @@ from types import FrameType from typing import List, Optional, cast -from radicale import VERSION, config, item, log, server, storage, types +from radicale import VERSION, config, item, log, server, sharing, storage, types from radicale.log import logger @@ -67,6 +67,8 @@ def exit_signal_handler(signal_number: int, help="check the storage for errors and exit") parser.add_argument("--verify-item", action="store", nargs=1, help="check the provided item file for errors and exit") + parser.add_argument("--verify-sharing", action="store_true", + help="check the sharing database for errors and exit") parser.add_argument("-C", "--config", help="use specific configuration files", nargs="*") parser.add_argument("-D", "--debug", action="store_const", const="debug", @@ -209,6 +211,19 @@ def exit_signal_handler(signal_number: int, sys.exit(1) return + if args_ns.verify_sharing: + logger.info("Verifying sharing database") + try: + sharing_ = sharing.load(configuration) + if not sharing_.verify(): + logger.critical("Sharing database verification failed") + sys.exit(1) + except Exception as e: + logger.critical("An exception occurred during sharing database " + "verification: %s", e, exc_info=True) + sys.exit(1) + return + # Create a socket pair to notify the server of program shutdown shutdown_socket, shutdown_socket_out = socket.socketpair() diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index c11d7c89c..bc5d2ff82 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -49,6 +49,7 @@ # TimestampUpdated: (last update) SHARE_TYPES: Sequence[str] = ('token', 'map', 'all') +SHARE_TYPES_V1: Sequence[str] = ('token', 'map') # token: share by secret token (does not require authentication) # map : share by mapping collection of one user to another as virtual # all : only supported for "list" and "info" @@ -126,8 +127,12 @@ def get_database_info(self) -> Union[dict, None]: """ retrieve database information """ return None + def verify_database(self) -> bool: + """ verify database information """ + return False + def list_sharing(self, - OwnerOrUser: str, + OwnerOrUser: Union[str | None] = None, ShareType: Union[str | None] = None, PathOrToken: Union[str | None] = None, PathMapped: Union[str | None] = None, @@ -190,6 +195,38 @@ def toggle_sharing(self, return {"status": "not-implemented"} # sharing functions called by request methods + def verify(self) -> bool: + """ verify database """ + logger.info("sharing database verification begin") + logger.info("sharing database verification call: %s", self.sharing_db_type) + result = self.verify_database() + if result is not True: + logger.error("sharing database verification call -> PROBLEM: %s", self.sharing_db_type) + return False + else: + pass + logger.info("sharing database verification call -> OK: %s", self.sharing_db_type) + # check all entries + logger.info("sharing database verification content start") + with self._storage.acquire_lock("r"): + for entry in self.list_sharing(): + logger.debug("analyze: %r", entry) + if entry['ShareType'] not in SHARE_TYPES_V1: + logger.error("ShareType not supported: %r", entry['ShareType']) + return False + elif not entry['PathMapped'].endswith("/"): + logger.error("PathMapped not ending with '/': %r", entry['PathMapped']) + return False + elif entry['ShareType'] == "map": + if not entry['PathOrToken'].endswith("/"): + logger.error("PathOrToken not ending with '/': %r", entry['PathOrToken']) + return False + else: + pass + # TODO: check PathMapped exists + logger.info("sharing database verification content successful") + return True + def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None]: """ returning dict with PathMapped, Owner, Permissions or None if not found""" if self.sharing_collection_by_token: @@ -456,10 +493,14 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if not re.search('^' + PATH_PATTERN + '$', request_data[key]): logger.error(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for PathOrToken") + elif not request_data[key].endswith("/"): + return httputils.bad_request("PathOrToken not ending with /") elif key == "PathMapped": if not re.search('^' + PATH_PATTERN + '$', request_data[key]): logger.error(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for PathMapped") + elif not request_data[key].endswith("/"): + return httputils.bad_request("PathMapped not ending with /") elif key == "Enabled" or key == "Hidden": if not re.search('^(False|True)$', request_data[key]): logger.error(api_info + ": unsupported " + key) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index a5dcb7b50..564e725d8 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -37,12 +37,12 @@ def init_database(self) -> bool: folder = self.configuration.get("storage", "filesystem_folder") folder_db = os.path.join(folder, "collection-db") sharing_db_file = os.path.join(folder_db, "sharing.csv") - logger.warning("sharing database filename not provided, use default: %r", sharing_db_file) + logger.info("sharing database filename not provided, use default: %r", sharing_db_file) else: logger.info("sharing database filename: %r", sharing_db_file) if not os.path.exists(folder_db): - logger.warning("sharing database folder is not existing: %r", folder_db) + logger.warning("sharing database folder is not existing: %r (create now)", folder_db) try: os.mkdir(folder_db) except Exception as e: @@ -77,6 +77,14 @@ def get_database_info(self) -> Union[dict | None]: database_info = {'type': "csv"} return database_info + def verify_database(self) -> bool: + logger.info("sharing database (csv) verification begin") + logger.info("sharing database (csv) file: %r", self._sharing_db_file) + logger.info("sharing database (csv) loaded entries: %d", self._lines) + # nothing more todo for CSV + logger.info("sharing database (csv) verification end") + return True + def get_sharing(self, ShareType: str, PathOrToken: str, @@ -129,7 +137,7 @@ def get_sharing(self, return None def list_sharing(self, - OwnerOrUser: str, + OwnerOrUser: Union[str | None] = None, ShareType: Union[str | None] = None, PathOrToken: Union[str | None] = None, PathMapped: Union[str | None] = None, From e88e8b9cdcf02c8f213f7f167245fb1102ef4fae Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Wed, 18 Feb 2026 06:22:38 +0100 Subject: [PATCH 114/138] rename option, adjust db types --- DOCUMENTATION.md | 8 ++++---- radicale/config.py | 4 ++-- radicale/sharing/__init__.py | 2 +- radicale/sharing/csv.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5ee33b722..858bb944c 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2062,11 +2062,11 @@ Sharing database type One of: * `none` * `csv` - * `sqlite` + * `files` -Default: `none` +Default: `none` (implicit disabling the feature) -##### database_filename +##### database_path _(>= 3.7.0)_ @@ -2074,7 +2074,7 @@ Sharing database path Default: * type `csv`: `(filesystem_folder)/collection-db/sharing.csv` - * type `sqlite`: `(filesystem_folder)/collection-db/sharing.sqlite` + * type `files`: `(filesystem_folder)/collection-db/files` ##### collection_by_token diff --git a/radicale/config.py b/radicale/config.py index 52a1925c6..bdca5526d 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -460,9 +460,9 @@ def json_str(value: Any) -> dict: "help": "sharing database type", "type": str_or_callable, "internal": sharing.INTERNAL_TYPES}), - ("database_filename", { + ("database_path", { "value": "", - "help": "database filename", + "help": "database path", "type": filepath}), ("collection_by_map", { "value": "false", diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index bc5d2ff82..fd3031eff 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -30,7 +30,7 @@ from radicale import config, httputils, pathutils, rights, storage, types, utils from radicale.log import logger -INTERNAL_TYPES: Sequence[str] = ("csv", "sqlite", "none") +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_BOOL: Sequence[str] = ('EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser') diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 564e725d8..b980dd9a9 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -32,7 +32,7 @@ class Sharing(sharing.BaseSharing): # Overloaded functions def init_database(self) -> bool: logger.debug("sharing database initialization for type 'csv'") - sharing_db_file = self.configuration.get("sharing", "database_filename") + sharing_db_file = self.configuration.get("sharing", "database_path") if sharing_db_file == "": folder = self.configuration.get("storage", "filesystem_folder") folder_db = os.path.join(folder, "collection-db") From b94ee28580e09e44422ba3e50e0c521408224060 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Wed, 18 Feb 2026 20:55:49 +0100 Subject: [PATCH 115/138] add locking, cosmetics --- radicale/sharing/csv.py | 148 +++++++++++++++++++--------------------- 1 file changed, 69 insertions(+), 79 deletions(-) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index b980dd9a9..0b1f8718f 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -235,12 +235,13 @@ def create_sharing(self, "TimestampCreated": Timestamp, "TimestampUpdated": Timestamp} logger.debug("TRACE/sharing/*/create: add row: %r", row) - # TODO: add locking self._sharing_cache.append(row) - if self._write_csv(self._sharing_db_file): - logger.debug("TRACE/sharing_by_token: write CSV done") - return {"status": "success"} - logger.error("sharing/add_sharing_by_token: cannot update CSV database") + + with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file): + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing/%s/create: write CSV done", ShareType) + return {"status": "success"} + logger.error("sharing/%s/create: cannot update CSV database", ShareType) return {"status": "error"} def update_sharing(self, @@ -254,12 +255,7 @@ def update_sharing(self, HiddenByOwner: Union[bool | None] = None, Timestamp: int = 0) -> dict: """ update sharing """ - if ShareType == "token": - logger.debug("TRACE/sharing/token/update: PathOrToken=%r Owner=%r User=%r", PathOrToken, Owner, User) - elif ShareType == "map": - logger.debug("TRACE/sharing/map/update: PathOrToken=%r Owner=%r PathMapped=%r", PathOrToken, Owner, PathMapped) - else: - raise # should not be reached + logger.debug("TRACE/sharing/%s/update: PathOrToken=%r Owner=%r PathMapped=%r", ShareType, PathOrToken, Owner, PathMapped) # lookup token found = False @@ -278,14 +274,14 @@ def update_sharing(self, index += 1 if found: - logger.debug("TRACE/sharing/*/update: found index=%d", index) + 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 User is not None and row['User'] != User: return {"status": "permission-denied"} - logger.debug("TRACE/sharing/*/update: Owner=%r PathOrToken=%r index=%d", Owner, PathOrToken, index) + logger.debug("TRACE/sharing/%s/update: Owner=%r PathOrToken=%r index=%d", ShareType, Owner, PathOrToken, index) - logger.debug("TRACE/sharing/*/update: orig row=%r", row) + logger.debug("TRACE/sharing/%s/update: orig row=%r", ShareType, row) # CSV: remove+adjust+readd if PathMapped is not None: @@ -301,16 +297,17 @@ def update_sharing(self, # update timestamp row["TimestampUpdated"] = Timestamp - logger.debug("TRACE/sharing/*/update: adj row=%r", row) + logger.debug("TRACE/sharing/%s/update: adj row=%r", ShareType, row) - # TODO: add locking # replace row self._sharing_cache.pop(index) self._sharing_cache.append(row) - if self._write_csv(self._sharing_db_file): - logger.debug("TRACE/sharing_by_token: write CSV done") - return {"status": "success"} - logger.warning("sharing/sharing_by_token: cannot update CSV database") + + with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file): + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing/%s/update: write CSV done", ShareType) + return {"status": "success"} + logger.error("sharing/%s/update: cannot update CSV database", ShareType) return {"status": "error"} else: return {"status": "not-found"} @@ -320,18 +317,13 @@ def delete_sharing(self, PathOrToken: str, Owner: str, PathMapped: Union[str | None] = None) -> dict: """ delete sharing """ - if ShareType == "token": - logger.debug("TRACE/sharing/token/delete: PathOrToken=%r Owner=%r", PathOrToken, Owner) - elif ShareType == "map": - logger.debug("TRACE/sharing/map/delete: PathOrToken=%r Owner=%r PathMapped=%r", PathOrToken, Owner, PathMapped) - else: - raise # should not be reached + logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r PathMapped=%r", ShareType, PathOrToken, Owner, PathMapped) # lookup token found = False index = 0 for row in self._sharing_cache: - logger.debug("TRACE/sharing/map/delete: check: %r", row) + logger.debug("TRACE/sharing/%s/delete: check: %r", ShareType, row) if index == 0: # skip fieldnames pass @@ -353,17 +345,17 @@ def delete_sharing(self, index += 1 if found: - logger.debug("TRACE/sharing/*/delete: found index=%d", index) + logger.debug("TRACE/sharing/%s/delete: found index=%d", ShareType, index) if row['Owner'] != Owner: return {"status": "permission-denied"} - logger.debug("TRACE/sharing/*/delete: Owner=%r PathOrToken=%r index=%d", Owner, PathOrToken, index) + logger.debug("TRACE/sharing/%s/delete: Owner=%r PathOrToken=%r index=%d", ShareType, Owner, PathOrToken, index) self._sharing_cache.pop(index) - # TODO: add locking - if self._write_csv(self._sharing_db_file): - logger.debug("TRACE/sharing_by_token: write CSV done") - return {"status": "success"} - logger.warning("sharing/sharing_by_token: cannot update CSV database") + with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file): + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE/sharing_by_token: write CSV done") + return {"status": "success"} + logger.error("sharing/%s/delete: cannot update CSV database", ShareType) return {"status": "error"} else: return {"status": "not-found"} @@ -383,7 +375,7 @@ def toggle_sharing(self, # should not happen raise - logger.debug("TRACE/sharing/*/" + Action + ": ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r Action=%r", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, Action) + logger.debug("TRACE/sharing/%s/%s: OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r", ShareType, Action, OwnerOrUser, User, PathOrToken, PathMapped) # lookup entry found = False @@ -400,12 +392,8 @@ def toggle_sharing(self, elif PathMapped is not None and row['PathMapped'] != PathMapped: pass elif row['Owner'] == OwnerOrUser: - # owner has requested filter-by-user - if User is not None and row['User'] != User: - pass - else: - found = True - break + found = True + break else: found = True break @@ -424,7 +412,7 @@ def toggle_sharing(self, # TODO: locking if row['Owner'] == OwnerOrUser: - logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": Owner=%r User=%r PathOrToken=%r index=%d", OwnerOrUser, User, PathOrToken, index) + logger.debug("TRACE/sharing/%s/%s: Owner=%r User=%r PathOrToken=%r index=%d", ShareType, Action, OwnerOrUser, User, PathOrToken, index) if Action == "disable": row['EnabledByOwner'] = False elif Action == "enable": @@ -435,7 +423,7 @@ def toggle_sharing(self, row['HiddenByOwner'] = False row['TimestampUpdated'] = Timestamp if row['User'] == OwnerOrUser: - logger.debug("TRACE/sharing/" + ShareType + "/" + Action + ": User=%r PathOrToken=%r index=%d", OwnerOrUser, PathOrToken, index) + logger.debug("TRACE/sharing/%s/%s: User=%r PathOrToken=%r index=%d", ShareType, Action, OwnerOrUser, PathOrToken, index) if Action == "disable": row['EnabledByUser'] = False elif Action == "enable": @@ -452,10 +440,10 @@ def toggle_sharing(self, # readd self._sharing_cache.append(row) - # TODO: add locking - if self._write_csv(self._sharing_db_file): - logger.debug("TRACE: write CSV done") - return {"status": "success"} + with self._storage.acquire_lock("w", OwnerOrUser, path=self._sharing_db_file): + if self._write_csv(self._sharing_db_file): + logger.debug("TRACE: write CSV done") + return {"status": "success"} logger.error("sharing: cannot update CSV database") return {"status": "error"} else: @@ -463,42 +451,44 @@ def toggle_sharing(self, # local functions def _create_empty_csv(self, file: str) -> bool: - with open(file, 'w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) - writer.writeheader() + with self._storage.acquire_lock("w", None, path=file): + with open(file, 'w', newline='') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) + writer.writeheader() return True def _load_csv(self, file: str) -> bool: logger.debug("sharing database load begin: %r", file) - with open(file, 'r', newline='') as csvfile: - reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) - self._lines = 0 - for row in reader: - # logger.debug("sharing database load read: %r", row) - if self._lines == 0: - # header line, check - for fieldname in sharing.DB_FIELDS_V1: - logger.debug("sharing database load check fieldname: %r", fieldname) - if fieldname not in row: - logger.debug("sharing database is incompatible: %r", fil, filee) - return False - # convert txt to bool - if self._lines > 0: - for fieldname in sharing.DB_FIELDS_V1_BOOL: - row[fieldname] = config._convert_to_bool(row[fieldname]) - for fieldname in sharing.DB_FIELDS_V1_INT: - row[fieldname] = int(row[fieldname]) - # check for duplicates - dup = False - for row_cached in self._sharing_cache: - if row == row_cached: - dup = True - break - if dup: - continue - # logger.debug("sharing database load add: %r", row) - self._sharing_cache.append(row) - self._lines += 1 + with self._storage.acquire_lock("r", None): + with open(file, 'r', newline='') as csvfile: + reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) + self._lines = 0 + for row in reader: + # logger.debug("sharing database load read: %r", row) + if self._lines == 0: + # header line, check + for fieldname in sharing.DB_FIELDS_V1: + logger.debug("sharing database load check fieldname: %r", fieldname) + if fieldname not in row: + logger.debug("sharing database is incompatible: %r", fil, filee) + return False + # convert txt to bool + if self._lines > 0: + for fieldname in sharing.DB_FIELDS_V1_BOOL: + row[fieldname] = config._convert_to_bool(row[fieldname]) + for fieldname in sharing.DB_FIELDS_V1_INT: + row[fieldname] = int(row[fieldname]) + # check for duplicates + dup = False + for row_cached in self._sharing_cache: + if row == row_cached: + dup = True + break + if dup: + continue + # logger.debug("sharing database load add: %r", row) + self._sharing_cache.append(row) + self._lines += 1 logger.debug("sharing database load end: %r", file) return True From 0a6a2dfef7584f5e3b36a8c903a7c45e7cbfbde3 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Wed, 18 Feb 2026 20:56:10 +0100 Subject: [PATCH 116/138] file-based sharing config storage --- radicale/sharing/files.py | 471 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 radicale/sharing/files.py diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py new file mode 100644 index 000000000..aae96ab8b --- /dev/null +++ b/radicale/sharing/files.py @@ -0,0 +1,471 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2026-2026 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +import csv +import os +import pickle +import urllib +from typing import Union + +from radicale import config, sharing +from radicale.log import logger + +""" File 'database' based sharing by token or map """ + +DB_VERSION: str = "1" + +class Sharing(sharing.BaseSharing): + _sharing_db_path_ShareType: dict = {} + + # Overloaded functions + def init_database(self) -> bool: + logger.debug("sharing database initialization for type 'files'") + sharing_db_path = self.configuration.get("sharing", "database_path") + if sharing_db_path == "": + folder = self.configuration.get("storage", "filesystem_folder") + folder_db = os.path.join(folder, "collection-db") + sharing_db_path = os.path.join(folder_db, "files") + logger.info("sharing database path not provided, use default: %r", sharing_db_path) + else: + logger.info("sharing database path: %r", sharing_db_path) + + if not os.path.exists(folder_db): + logger.warning("sharing database folder is not existing: %r (create now)", folder_db) + try: + os.mkdir(folder_db) + except Exception as e: + logger.error("sharing database folder cannot be created (check permissions): %r (%r)", folder_db, e) + return False + logger.info("sharing database folder successfully created: %r", folder_db) + + if not os.path.exists(sharing_db_path): + logger.warning("sharing database path is not existing: %r", sharing_db_path) + try: + os.mkdir(sharing_db_path) + except Exception as e: + logger.error("sharing database path cannot be created (check permissions): %r (%r)", sharing_db_path, e) + return False + logger.info("sharing database path successfully created: %r", sharing_db_path) + + for ShareType in sharing.SHARE_TYPES_V1: + path = os.path.join(sharing_db_path, ShareType) + self._sharing_db_path_ShareType[ShareType] = path + if not os.path.exists(path): + logger.warning("sharing database path for %r is not existing: %r", ShareType, path) + try: + os.mkdir(path) + except Exception as e: + logger.error("sharing database path for %r cannot be created (check permissions): %r (%r)", ShareType, path, e) + return False + logger.info("sharing database path for %r successfully created: %r", ShareType, path) + return True + + def get_database_info(self) -> Union[dict | None]: + database_info = {'type': "files"} + return database_info + + def verify_database(self) -> bool: + logger.info("sharing database (files) verification begin") + for ShareType in sharing.SHARE_TYPES_V1: + logger.info("sharing database (files) path for %r: %r", ShareType, self._sharing_db_path_ShareType[ShareType]) + # TODO: count amount of files + logger.info("sharing database (files) verification end") + return True + + def get_sharing(self, + ShareType: str, + PathOrToken: str, + User: Union[str | None] = None) -> Union[dict | None]: + """ retrieve sharing target and attributes by map """ + # Lookup + logger.debug("TRACE/sharing/%s/get: PathOrToken=%r User=%r)", ShareType, PathOrToken, User) + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + if not os.path.isfile(sharing_config_file): + return None + + # read content + with self._storage.acquire_lock("r", User): + # read file + with open(sharing_config_file, "rb") as fb: + (version, row) = pickle.load(fb) + + if version != DB_VERSION: + return {"status": "error"} + + if User is not None and row['User'] != User: + return None + elif row['EnabledByOwner'] != True: + return None + elif row['ShareType'] == "map": + if row['EnabledByUser'] != True: + return None + + PathMapped = row['PathMapped'] + Owner = row['Owner'] + UserShare = row['User'] + Permissions = row['Permissions'] + Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden) + return { + "mapped": True, + "PathOrToken": PathOrToken, + "PathMapped": PathMapped, + "Owner": Owner, + "User": UserShare, + "Hidden": Hidden, + "Permissions": Permissions} + + return None + + def list_sharing(self, + OwnerOrUser: Union[str | None] = None, + ShareType: Union[str | None] = None, + PathOrToken: Union[str | None] = None, + PathMapped: Union[str | None] = None, + User: Union[str | None] = None, + EnabledByOwner: Union[bool | None] = None, + EnabledByUser: Union[bool | None] = None, + HiddenByOwner: Union[bool | None] = None, + HiddenByUser: Union[bool | None] = None) -> list[dict]: + """ retrieve sharing """ + row: dict + index = 0 + result = [] + + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, HiddenByOwner, HiddenByUser) + + for _ShareType in sharing.SHARE_TYPES_V1: + if ShareType is not None and _ShareType != ShareType: + # skip + continue + + path = self._sharing_db_path_ShareType[_ShareType] + with self._storage.acquire_lock("r", OwnerOrUser, path=path): + for entry in os.scandir(path): + if not entry.is_file(): + continue + + logger.debug("TRACE/sharing/list: check file: %r", entry.name) + # read file + with open(entry, "rb") as fb: + (version, row) = pickle.load(fb) + + if version != DB_VERSION: + # skip + continue + + logger.debug("TRACE/sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: + logger.debug("TRACE/sharing/list/row: skip by ShareType") + pass + elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): + pass + elif User is not None and row['User'] != User: + logger.debug("TRACE/sharing/list/row: skip by User") + pass + elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: + logger.debug("TRACE/sharing/list/row: skip by PathOrToken") + pass + elif PathMapped is not None and row['PathMapped'] != PathMapped: + logger.debug("TRACE/sharing/list/row: skip by PathMapped") + pass + elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: + pass + elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: + pass + elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: + pass + elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: + pass + else: + logger.debug("TRACE/sharing/list/row: add: %r", row) + result.append(row) + + return result + + def create_sharing(self, + ShareType: str, + PathOrToken: str, PathMapped: str, + Owner: str, User: str, + Permissions: str = "r", + EnabledByOwner: bool = False, EnabledByUser: bool = False, + HiddenByOwner: bool = True, HiddenByUser: bool = True, + Timestamp: int = 0) -> dict: + """ create sharing """ + row: dict + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + logger.debug("TRACE/sharing/%s/create: sharing_config_file=%r", ShareType, sharing_config_file) + logger.debug("TRACE/sharing/%s/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", ShareType, PathOrToken, Owner, PathMapped, User, Permissions) + if os.path.isfile(sharing_config_file): + return {"status": "conflict"} + + row = {"ShareType": ShareType, + "PathOrToken": PathOrToken, + "PathMapped": PathMapped, + "Owner": Owner, + "User": User, + "Permissions": Permissions, + "EnabledByOwner": EnabledByOwner, + "EnabledByUser": EnabledByUser, + "HiddenByOwner": HiddenByOwner, + "HiddenByUser": HiddenByUser, + "TimestampCreated": Timestamp, + "TimestampUpdated": Timestamp} + + version = DB_VERSION + + try: + with self._storage.acquire_lock("w", Owner, path=sharing_config_file): + logger.debug("TRACE/sharing/%s/create: store share-config: %r into file %r", ShareType, row, sharing_config_file) + # write file + with open(sharing_config_file, "wb") as fb: + pickle.dump((version, row), fb) + logger.debug("TRACE/sharing/*/create: share-config file stored: %r", sharing_config_file) + return {"status": "success"} + except Exception as e: + logger.error("sharing/%s/create: cannot store share-config: %r (%r)", ShareType, sharing_config_file, e) + return {"status": "error"} + + def update_sharing(self, + ShareType: str, + PathOrToken: str, + Owner: Union[str | None] = None, + 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: + """ update sharing """ + logger.debug("TRACE/sharing/%s/update: PathOrToken=%r Owner=%r User=%r", ShareType, PathOrToken, Owner, User) + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + if not os.path.isfile(sharing_config_file): + return {"status": "not-found"} + + # read content + with self._storage.acquire_lock("w", Owner,path=sharing_config_file): + # read file + with open(sharing_config_file, "rb") as fb: + (version, row) = pickle.load(fb) + + if version != DB_VERSION: + return {"status": "error"} + + logger.debug("TRACE/sharing/%s/update: check: %r", ShareType, row) + + if Owner is not None and row['Owner'] != Owner: + return {"status": "permission-denied"} + if User is not None and row['User'] != User: + return {"status": "permission-denied"} + + logger.debug("TRACE/sharing/%s/update: orig row=%r", ShareType, row) + + if PathMapped is not None: + row["PathMapped"] = PathMapped + if Permissions is not None: + row["Permissions"] = Permissions + if User is not None: + row["User"] = User + if EnabledByOwner is not None: + row["EnabledByOwner"] = EnabledByOwner + if HiddenByOwner is not None: + row["HiddenByOwner"] = HiddenByOwner + # update timestamp + row["TimestampUpdated"] = Timestamp + + logger.debug("TRACE/sharing/%s/update: adj row=%r", ShareType,row) + + try: + # write file + with open(sharing_config_file, "wb") as fb: + pickle.dump((version, row), fb) + logger.debug("TRACE/sharing/%s/create: share-config file stored: %r", ShareType, sharing_config_file) + return {"status": "success"} + except Exception as e: + logger.error("sharing/%s/create: cannot store share-config: %r (%r)", ShareType, sharing_config_file, e) + return {"status": "error"} + + def delete_sharing(self, + ShareType: str, + PathOrToken: str, Owner: str, + PathMapped: Union[str | None] = None) -> dict: + """ delete sharing """ + logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r", ShareType, PathOrToken, Owner) + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + if not os.path.isfile(sharing_config_file): + return {"status": "not-found"} + + # read content + with self._storage.acquire_lock("r", Owner, path=sharing_config_file): + # read file + with open(sharing_config_file, "rb") as fb: + (version, row) = pickle.load(fb) + + if version != DB_VERSION: + return {"status": "error"} + + # verify owner + if row['Owner'] != Owner: + return {"status": "permission-denied"} + + try: + os.remove(sharing_config_file) + except Exception as e: + logger.error("sharing/%s/delete: cannot remove share-config: %r (%r)", ShareType, sharing_config_file, e) + return {"status": "error"} + + logger.debug("sharing/%s/delete: successful removed share-config: %r", ShareType, sharing_config_file) + return {"status": "success"} + + def toggle_sharing(self, + ShareType: str, + PathOrToken: str, + OwnerOrUser: str, + Action: str, + PathMapped: Union[str | None] = None, + User: Union[str | None] = None, + Timestamp: int = 0) -> dict: + """ toggle sharing """ + row: dict + + logger.debug("TRACE/sharing/%s/%s: OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r", ShareType, Action, OwnerOrUser, User, PathOrToken, PathMapped) + + if Action not in sharing.API_SHARE_TOGGLES_V1: + # should not happen + raise + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + if not os.path.isfile(sharing_config_file): + return {"status": "not-found"} + + # read content + 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) + + if version != DB_VERSION: + return {"status": "error"} + + logger.debug("TRACE/sharing/%s/%s: check: %r", ShareType, Action, row) + + # verify ownership or user + if User is not None and row['User'] != User: + return {"status": "permission-denied"} + elif row['Owner'] == OwnerOrUser: + pass + elif row['User'] == OwnerOrUser: + pass + else: + return {"status": "permission-denied"} + + if row['Owner'] == OwnerOrUser: + logger.debug("TRACE/sharing/%s/%s: Owner=%r User=%r PathOrToken=%r", ShareType, Action, OwnerOrUser, User, PathOrToken) + if Action == "disable": + row['EnabledByOwner'] = False + elif Action == "enable": + row['EnabledByOwner'] = True + elif Action == "hide": + row['HiddenByOwner'] = True + elif Action == "unhide": + row['HiddenByOwner'] = False + row['TimestampUpdated'] = Timestamp + if row['User'] == OwnerOrUser: + logger.debug("TRACE/sharing/%s/%s: User=%r PathOrToken=%r", ShareType, Action, OwnerOrUser, PathOrToken) + if Action == "disable": + row['EnabledByUser'] = False + elif Action == "enable": + row['EnabledByUser'] = True + elif Action == "hide": + row['HiddenByUser'] = True + elif Action == "unhide": + row['HiddenByUser'] = False + + row['TimestampUpdated'] = Timestamp + + try: + # write file + with open(sharing_config_file, "wb") as fb: + pickle.dump((version, row), fb) + logger.debug("TRACE/sharing/%s/create: share-config file stored: %r", ShareType, sharing_config_file) + return {"status": "success"} + except Exception as e: + logger.error("sharing/%s/create: cannot store share-config: %r (%r)", ShareType, sharing_config_file, e) + return {"status": "error"} + + # local functions + def _encode_path(self, path: str) -> str: + return urllib.parse.quote(path, safe="") + + def _decode_path(self, path: str) -> str: + return urllib.parse.unquote(path) + + def _create_empty_csv(self, file: str) -> bool: + with open(file, 'w', newline='') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) + writer.writeheader() + return True + + def _load_csv(self, file: str) -> bool: + logger.debug("sharing database load begin: %r", file) + with open(file, 'r', newline='') as csvfile: + reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) + self._lines = 0 + for row in reader: + # logger.debug("sharing database load read: %r", row) + if self._lines == 0: + # header line, check + for fieldname in sharing.DB_FIELDS_V1: + logger.debug("sharing database load check fieldname: %r", fieldname) + if fieldname not in row: + logger.debug("sharing database is incompatible: %r", fil, filee) + return False + # convert txt to bool + if self._lines > 0: + for fieldname in sharing.DB_FIELDS_V1_BOOL: + row[fieldname] = config._convert_to_bool(row[fieldname]) + for fieldname in sharing.DB_FIELDS_V1_INT: + row[fieldname] = int(row[fieldname]) + # check for duplicates + dup = False + for row_cached in self._sharing_cache: + if row == row_cached: + dup = True + break + if dup: + continue + # logger.debug("sharing database load add: %r", row) + self._sharing_cache.append(row) + self._lines += 1 + logger.debug("sharing database load end: %r", file) + return True + + def _write_csv(self, file: str) -> bool: + with open(file, 'w', newline='') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) + writer.writerows(self._sharing_cache) + return True + + From 8097f6e4953802d5e319778161625ede3e24db87 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Wed, 18 Feb 2026 20:56:25 +0100 Subject: [PATCH 117/138] add test cases for files database --- radicale/tests/test_sharing.py | 2565 +++++++++++++++++--------------- 1 file changed, 1333 insertions(+), 1232 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index f0c33e26e..01406c1f4 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -194,70 +194,76 @@ def test_sharing_api_list_with_auth(self) -> None: form_array: Sequence[str] json_dict: dict - action = "list" - for sharing_type in sharing.SHARE_TYPES: - logging.info("\n*** list (without form) -> should fail") - path = "/.sharing/v1/" + sharing_type + "/" + action - _, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw") + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + action = "list" + for sharing_type in sharing.SHARE_TYPES: + logging.info("\n*** list (without form) -> should fail") + path = "/.sharing/v1/" + sharing_type + "/" + action + _, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw") + + logging.info("\n*** list (form->csv)") + form_array = [] + _, headers, answer = self._sharing_api_form(sharing_type, "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=not-found" in answer + assert "Lines=0" in answer + + logging.info("\n*** list (json->text)") + json_dict = {} + _, headers, answer = self._sharing_api_json(sharing_type, "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") + assert "Status=not-found" in answer + assert "Lines=0" in answer + + logging.info("\n*** list (json->json)") + json_dict = {} + _, headers, answer = self._sharing_api_json(sharing_type, "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" + assert answer_dict['Lines'] == 0 + + logging.info("\n*** create a token -> 200") + form_array = ["PathMapped=/owner/collectionL1/"] + _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "PathOrToken=" in answer + # extract token + match = re.search('PathOrToken=(.+)', answer) + if match: + token = match.group(1) + logging.info("received token %r", token) + else: + assert False + + logging.info("\n*** create a map -> 200") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = "/owner/collectionL2/" + json_dict['PathOrToken'] = "/user/collectionL2-shared-by-owner/" + _, 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" - logging.info("\n*** list (form->csv)") + logging.info("\n*** list/all (form->csv)") form_array = [] - _, headers, answer = self._sharing_api_form(sharing_type, "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=not-found" in answer - assert "Lines=0" in answer + _, headers, answer = self._sharing_api_form("all", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=2" in answer - logging.info("\n*** list (json->text)") - json_dict = {} - _, headers, answer = self._sharing_api_json(sharing_type, "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") - assert "Status=not-found" in answer - assert "Lines=0" in answer + logging.info("\n*** delete token -> 200") + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer - logging.info("\n*** list (json->json)") - json_dict = {} - _, headers, answer = self._sharing_api_json(sharing_type, "list", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "not-found" - assert answer_dict['Lines'] == 0 - - logging.info("\n*** create a token -> 200") - form_array = ["PathMapped=/owner/collectionL1/"] - _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "PathOrToken=" in answer - # extract token - match = re.search('PathOrToken=(.+)', answer) - if match: - token = match.group(1) - logging.info("received token %r", token) - else: - assert False - - logging.info("\n*** create a map -> 200") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = "/owner/collectionL2/" - json_dict['PathOrToken'] = "/user/collectionL2-shared-by-owner/" - _, 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" - - logging.info("\n*** list/all (form->csv)") - form_array = [] - _, headers, answer = self._sharing_api_form("all", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "Lines=2" in answer - - logging.info("\n*** delete token -> 200") - form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - - logging.info("\n*** delete share -> 200") - form_array = [] - form_array.append("PathOrToken=/user/collectionL2-shared-by-owner/") - form_array.append("PathMapped=/owner/collectionL2/") - _, headers, answer = self._sharing_api_form("map", "delete", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer + logging.info("\n*** delete share -> 200") + form_array = [] + form_array.append("PathOrToken=/user/collectionL2-shared-by-owner/") + form_array.append("PathMapped=/owner/collectionL2/") + _, headers, answer = self._sharing_api_form("map", "delete", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer def test_sharing_api_token_basic(self) -> None: """share-by-token API tests.""" @@ -269,174 +275,181 @@ def test_sharing_api_token_basic(self) -> None: "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 - logging.info("\n*** create token without PathMapped (form) -> should fail") - form_array = [] - _, headers, answer = self._sharing_api_form("token", "create", 400, login="owner:ownerpw", form_array=form_array) + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** create token without PathMapped (json) -> should fail") - json_dict = {} - _, headers, answer = self._sharing_api_json("token", "create", 400, login="owner:ownerpw", json_dict=json_dict) - - logging.info("\n*** create token#1 (form->text)") - form_array = ["PathMapped=/owner/collection1"] - _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "PathOrToken=" in answer - # extract token - match = re.search('PathOrToken=(.+)', answer) - if match: - token1 = match.group(1) - logging.info("received token %r", token1) - else: - assert False - - logging.info("\n*** create token#2 (json->text)") - json_dict = {'PathMapped': "/owner/collection2"} - _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") - assert "Status=success" in answer - assert "Token=" in answer - # extract token - match = re.search('Token=(.+)', answer) - if match: - token2 = match.group(1) - logging.info("received token %r", token2) - else: - assert False - - logging.info("\n*** lookup token#1 (form->text)") - form_array = ["PathOrToken=" + token1] - _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "Lines=1" in answer - assert "/owner/collection1" in answer - - logging.info("\n*** lookup token#2 (json->text") - json_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") - assert "Status=success" in answer - assert "Lines=1" in answer - assert "/owner/collection2" in answer - - logging.info("\n*** lookup token#2 (json->json)") - json_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['PathMapped'] == "/owner/collection2" - - logging.info("\n*** lookup tokens (form->text)") - form_array = [] - _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "Lines=2" in answer - assert "/owner/collection1" in answer - assert "/owner/collection2" in answer - - logging.info("\n*** lookup tokens (form->csv)") - form_array = [] - _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array, accept="text/csv") - assert "Status=success" not in answer - assert "Lines=2" not in answer - assert ",".join(sharing.DB_FIELDS_V1) in answer - assert "/owner/collection1" in answer - assert "/owner/collection2" in answer - - logging.info("\n*** delete token#1 (form->text)") - form_array = ["PathOrToken=" + token1] - _, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - - logging.info("\n*** lookup token#1 (form->text) -> should not be there anymore") - form_array = ["PathOrToken=" + token1] - _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=not-found" in answer - assert "Lines=0" in answer - - logging.info("\n*** lookup tokens (form->text) -> still one should be there") - form_array = [] - _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "Lines=1" in answer - - logging.info("\n*** disable token#2 (form->text)") - form_array = ["PathOrToken=" + token2] - _, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - - logging.info("\n*** lookup token#2 (json->json) -> check for not enabled") - json_dict = {'PathOrToken': token2} - _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['EnabledByOwner'] == False + logging.info("\n*** create token without PathMapped (form) -> should fail") + form_array = [] + _, headers, answer = self._sharing_api_form("token", "create", 400, login="owner:ownerpw", form_array=form_array) - logging.info("\n*** enable token#2 (json->json)") - json_dict = {} - json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - - logging.info("\n*** lookup token#2 (form->text) -> check for enabled") - form_array = [] - form_array.append("PathOrToken=" + token2) - _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "Lines=1" in answer - assert "True,True,True,True" in answer - - logging.info("\n*** hide token#2 (form->text)") - form_array = [] - form_array.append("PathOrToken=" + token2) - _, headers, answer = self._sharing_api_form("token", "hide", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - - logging.info("\n*** lookup token#2 (form->text) -> check for hidden") - form_array = [] - form_array.append("PathOrToken=" + token2) - _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "Lines=1" in answer - assert "True,True,True,True" in answer - - logging.info("\n*** unhide token#2 (json->json)") - json_dict = {} - json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "unhide", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** create token without PathMapped (json) -> should fail") + json_dict = {} + _, headers, answer = self._sharing_api_json("token", "create", 400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** create token#1 (form->text)") + form_array = ["PathMapped=/owner/collection1/"] + _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "PathOrToken=" in answer + # extract token + match = re.search('PathOrToken=(.+)', answer) + if match: + token1 = match.group(1) + logging.info("received token %r", token1) + else: + assert False + + logging.info("\n*** create token#2 (json->text)") + json_dict = {'PathMapped': "/owner/collection2/"} + _, headers, answer = self._sharing_api_json("token", "create", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") + assert "Status=success" in answer + assert "Token=" in answer + # extract token + match = re.search('Token=(.+)', answer) + if match: + token2 = match.group(1) + logging.info("received token %r", token2) + else: + assert False + + logging.info("\n*** lookup token#1 (form->text)") + form_array = ["PathOrToken=" + token1] + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=1" in answer + assert "/owner/collection1/" in answer + + logging.info("\n*** lookup token#2 (json->text") + json_dict = {'PathOrToken': token2} + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain") + assert "Status=success" in answer + assert "Lines=1" in answer + assert "/owner/collection2/" in answer + + logging.info("\n*** lookup token#2 (json->json)") + json_dict = {'PathOrToken': token2} + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['PathMapped'] == "/owner/collection2/" - logging.info("\n*** lookup token#2 (json->json) -> check for not hidden") - json_dict = {} - json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['HiddenByOwner'] == False + logging.info("\n*** lookup tokens (form->text)") + form_array = [] + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=2" in answer + assert "/owner/collection1/" in answer + assert "/owner/collection2/" in answer - logging.info("\n*** delete token#2 (json->json)") - json_dict = {} - json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** lookup tokens (form->csv)") + form_array = [] + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array, accept="text/csv") + assert "Status=success" not in answer + assert "Lines=2" not in answer + assert ",".join(sharing.DB_FIELDS_V1) in answer + assert "/owner/collection1/" in answer + assert "/owner/collection2/" in answer + + logging.info("\n*** delete token#1 (form->text)") + form_array = ["PathOrToken=" + token1] + _, headers, answer = self._sharing_api_form("token", "delete", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + + logging.info("\n*** lookup token#1 (form->text) -> should not be there anymore") + form_array = ["PathOrToken=" + token1] + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=not-found" in answer + assert "Lines=0" in answer - logging.info("\n*** lookup token#2 (json->json) -> should not be there anymore") - json_dict = {} - json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "not-found" - assert answer_dict['Lines'] == 0 + logging.info("\n*** lookup tokens (form->text) -> still one should be there") + form_array = [] + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=1" in answer + + logging.info("\n*** disable token#2 (form->text)") + form_array = ["PathOrToken=" + token2] + _, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + + logging.info("\n*** lookup token#2 (json->json) -> check for not enabled") + json_dict = {'PathOrToken': token2} + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['EnabledByOwner'] == False + + logging.info("\n*** enable token#2 (json->json)") + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** lookup token#2 (form->text) -> check for enabled") + form_array = [] + form_array.append("PathOrToken=" + token2) + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=1" in answer + assert "True,True,True,True" in answer + + logging.info("\n*** hide token#2 (form->text)") + form_array = [] + form_array.append("PathOrToken=" + token2) + _, headers, answer = self._sharing_api_form("token", "hide", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + + logging.info("\n*** lookup token#2 (form->text) -> check for hidden") + form_array = [] + form_array.append("PathOrToken=" + token2) + _, headers, answer = self._sharing_api_form("token", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=1" in answer + assert "True,True,True,True" in answer + + logging.info("\n*** unhide token#2 (json->json)") + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "unhide", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** lookup token#2 (json->json) -> check for not hidden") + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['HiddenByOwner'] == False + + logging.info("\n*** delete token#2 (json->json)") + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** lookup token#2 (json->json) -> should not be there anymore") + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" + assert answer_dict['Lines'] == 0 def test_sharing_api_token_usage(self) -> None: """share-by-token API tests - real usage.""" @@ -448,6 +461,7 @@ def test_sharing_api_token_usage(self) -> None: "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"}}) @@ -455,100 +469,107 @@ def test_sharing_api_token_usage(self) -> None: json_dict: dict path_token = "/.token/" - path_base = "/owner/calendar.ics" + path_base = "/owner/calendar.ics/" + path_base2 = "/owner/calendar2.ics/" logging.info("\n*** prepare") - self.mkcalendar("/owner/calendar.ics/", login="owner:ownerpw") + self.mkcalendar(path_base, login="owner:ownerpw") event = get_file_content("event1.ics") path = path_base + "/event1.ics" self.put(path, event, login="owner:ownerpw") - logging.info("\n*** test access to collection") - _, headers, answer = self.request("GET", path_base, check=200, login="owner:ownerpw") - assert "UID:event" in answer - - logging.info("\n*** test access to item") - _, headers, answer = self.request("GET", path, check=200, login="owner:ownerpw") - assert "UID:event" in answer - - logging.info("\n*** create token") - form_array = [] - form_array.append("PathMapped=/owner/calendar.ics") - _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "PathOrToken=" in answer - # extract token - match = re.search('PathOrToken=(.+)', answer) - if match: - token = match.group(1) - logging.info("received token %r", token) - else: - assert False - - logging.info("\n*** create token#2") - form_array = [] - form_array.append("PathMapped=/owner/calendar2.ics") - _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "PathOrToken=" in answer - # extract token - match = re.search('PathOrToken=(.+)', answer) - if match: - token2 = match.group(1) - logging.info("received token %r", token2) - else: - assert False - - logging.info("\n*** enable token (form->text)") - form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - - logging.info("\n*** fetch collection using invalid token (without credentials)") - _, headers, answer = self.request("GET", path_token + "v1/invalidtoken", check=401) - - logging.info("\n*** fetch collection using token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=200) - assert "UID:event" in answer - - logging.info("\n*** disable token (form->text)") - form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - - logging.info("\n*** fetch collection using disabled token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=401) - - logging.info("\n*** enable token (form->text)") - form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - - logging.info("\n*** fetch collection using token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=200) - assert "UID:event" in answer - - logging.info("\n*** delete token#2 (json->json)") - json_dict = {} - json_dict['PathOrToken'] = token2 - _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['ApiVersion'] == "1" - assert answer_dict['Status'] == "success" + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** delete token (json->json)") - json_dict = {'PathOrToken': token} - _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['ApiVersion'] == "1" - assert answer_dict['Status'] == "success" + logging.info("\n*** test access to collection") + _, headers, answer = self.request("GET", path_base, check=200, login="owner:ownerpw") + assert "UID:event" in answer + + logging.info("\n*** test access to item") + _, headers, answer = self.request("GET", path, check=200, login="owner:ownerpw") + assert "UID:event" in answer + + logging.info("\n*** create token") + form_array = [] + form_array.append("PathMapped=" + path_base) + _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "PathOrToken=" in answer + # extract token + match = re.search('PathOrToken=(.+)', answer) + if match: + token = match.group(1) + logging.info("received token %r", token) + else: + assert False + + logging.info("\n*** create token#2") + form_array = [] + form_array.append("PathMapped=" + path_base2) + _, headers, answer = self._sharing_api_form("token", "create", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "PathOrToken=" in answer + # extract token + match = re.search('PathOrToken=(.+)', answer) + if match: + token2 = match.group(1) + logging.info("received token %r", token2) + else: + assert False + + logging.info("\n*** enable token (form->text)") + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + + logging.info("\n*** fetch collection using invalid token (without credentials)") + _, headers, answer = self.request("GET", path_token + "v1/invalidtoken", check=401) + + logging.info("\n*** fetch collection using token (without credentials)") + _, headers, answer = self.request("GET", path_token + token, check=200) + assert "UID:event" in answer + + logging.info("\n*** disable token (form->text)") + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "disable", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + + logging.info("\n*** fetch collection using disabled token (without credentials)") + _, headers, answer = self.request("GET", path_token + token, check=401) + + logging.info("\n*** enable token (form->text)") + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "enable", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + + logging.info("\n*** fetch collection using token (without credentials)") + _, headers, answer = self.request("GET", path_token + token, check=200) + assert "UID:event" in answer + + logging.info("\n*** delete token#2 (json->json)") + json_dict = {} + json_dict['PathOrToken'] = token2 + _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['ApiVersion'] == "1" + assert answer_dict['Status'] == "success" - logging.info("\n*** delete token (form->text) -> no longer available") - form_array = ["PathOrToken=" + token] - _, headers, answer = self._sharing_api_form("token", "delete", check=404, login="owner:ownerpw", form_array=form_array) + logging.info("\n*** delete token (json->json)") + json_dict = {'PathOrToken': token} + _, headers, answer = self._sharing_api_json("token", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['ApiVersion'] == "1" + assert answer_dict['Status'] == "success" - logging.info("\n*** fetch collection using deleted token (without credentials)") - _, headers, answer = self.request("GET", path_token + token, check=401) + logging.info("\n*** delete token (form->text) -> no longer available") + form_array = ["PathOrToken=" + token] + _, headers, answer = self._sharing_api_form("token", "delete", check=404, login="owner:ownerpw", form_array=form_array) + + logging.info("\n*** fetch collection using deleted token (without credentials)") + _, headers, answer = self.request("GET", path_token + token, check=401) def test_sharing_api_map_basic(self) -> None: """share-by-map API basic tests.""" @@ -565,19 +586,25 @@ def test_sharing_api_map_basic(self) -> None: json_dict: dict - logging.info("\n*** create map without PathMapped (json) -> should fail") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** create map without PathMapped but User (json) -> should fail") - json_dict = {'User': "user"} - _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** create map without PathMapped (json) -> should fail") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** create map without PathMapped but User and PathOrToken (json) -> should fail") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathOrToken'] = "/owner/calendar.ics" - _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** create map without PathMapped but User (json) -> should fail") + json_dict = {'User': "user"} + _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** create map without PathMapped but User and PathOrToken (json) -> should fail") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathOrToken'] = "/owner/calendar.ics" + _, headers, answer = self._sharing_api_json("map", "create", 400, login="owner:ownerpw", json_dict=json_dict) def test_sharing_api_map_usage(self) -> None: """share-by-map API usage tests.""" @@ -610,155 +637,161 @@ def test_sharing_api_map_usage(self) -> None: event = get_file_content(file_item2) self.put(path_mapped_item2, event, check=201, login="owner:ownerpw") - logging.info("\n*** test access to collection") - _, headers, answer = self.request("GET", path_mapped, check=200, login="owner:ownerpw") - assert "UID:event1" in answer - assert "UID:event2" in answer + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** test access to item") - _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="owner:ownerpw") - assert "UID:event1" in answer + logging.info("\n*** test access to collection") + _, headers, answer = self.request("GET", path_mapped, check=200, login="owner:ownerpw") + assert "UID:event1" in answer + assert "UID:event2" in answer - logging.info("\n*** create map with PathMapped and User and PathOrToken (json)") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, 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" + logging.info("\n*** test access to item") + _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="owner:ownerpw") + assert "UID:event1" in answer - logging.info("\n*** lookup map without filter (json->json)") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['PathOrToken'] == path_shared - assert answer_dict['Content'][0]['PathMapped'] == path_mapped - assert answer_dict['Content'][0]['ShareType'] == "map" - assert answer_dict['Content'][0]['Owner'] == "owner" - assert answer_dict['Content'][0]['User'] == "user" - assert answer_dict['Content'][0]['EnabledByOwner'] == False - assert answer_dict['Content'][0]['EnabledByUser'] == False - assert answer_dict['Content'][0]['HiddenByOwner'] == True - assert answer_dict['Content'][0]['HiddenByUser'] == True - assert answer_dict['Content'][0]['Permissions'] == "r" - - logging.info("\n*** enable map by owner (json->json) -> 404") - json_dict = {} - json_dict['User'] = "owner" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", check=404, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** create map with PathMapped and User and PathOrToken (json)") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, 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" - logging.info("\n*** enable map by owner for user (json->json) -> 200") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** lookup map without filter (json->json)") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + assert answer_dict['Content'][0]['PathOrToken'] == path_shared + assert answer_dict['Content'][0]['PathMapped'] == path_mapped + assert answer_dict['Content'][0]['ShareType'] == "map" + assert answer_dict['Content'][0]['Owner'] == "owner" + assert answer_dict['Content'][0]['User'] == "user" + assert answer_dict['Content'][0]['EnabledByOwner'] == False + assert answer_dict['Content'][0]['EnabledByUser'] == False + assert answer_dict['Content'][0]['HiddenByOwner'] == True + assert answer_dict['Content'][0]['HiddenByUser'] == True + assert answer_dict['Content'][0]['Permissions'] == "r" + + logging.info("\n*** enable map by owner for owner (json->json) -> 403") + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "enable", check=403, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** enable map by user (json->json)") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** enable map by owner for user (json->json) -> 200") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** enable map by user for owner (json->json) -> should fail") - json_dict = {} - json_dict['User'] = "owner" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", check=403, login="user:userpw", json_dict=json_dict) - - logging.info("\n*** fetch collection (without credentials)") - _, headers, answer = self.request("GET", path_mapped, check=401) - - logging.info("\n*** fetch collection (with credentials) as owner") - _, headers, answer = self.request("GET", path_mapped, check=200, login="owner:ownerpw") - assert "UID:event" in answer - - logging.info("\n*** fetch item (with credentials) as owner") - _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="owner:ownerpw") - assert "UID:event" in answer - - logging.info("\n*** fetch collection (with credentials) as user") - _, headers, answer = self.request("GET", path_mapped, check=403, login="user:userpw") - - logging.info("\n*** fetch collection via map (with credentials) as user") - _, headers, answer = self.request("GET", path_shared, check=200, login="user:userpw") - assert "UID:event1" in answer - assert "UID:event2" in answer - - logging.info("\n*** fetch item via map (with credentials) as user") - _, headers, answer = self.request("GET", path_shared_item1, check=200, login="user:userpw") - # only requested event has to be in the answer - assert "UID:event1" in answer - assert "UID:event2" not in answer - - logging.info("\n*** fetch item via map (with credentials) as user") - _, headers, answer = self.request("GET", path_shared_item2, check=200, login="user:userpw") - # only requested event has to be in the answer - assert "UID:event2" in answer - assert "UID:event1" not in answer - - logging.info("\n*** disable map by owner (json->json)") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "disable", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** enable map by user (json->json)") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") - _, headers, answer = self.request("GET", path_shared, check=404, login="user:userpw") + logging.info("\n*** enable map by user for owner (json->json) -> should fail") + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "enable", check=403, login="user:userpw", json_dict=json_dict) + + logging.info("\n*** fetch collection (without credentials)") + _, headers, answer = self.request("GET", path_mapped, check=401) + + logging.info("\n*** fetch collection (with credentials) as owner") + _, headers, answer = self.request("GET", path_mapped, check=200, login="owner:ownerpw") + assert "UID:event" in answer + + logging.info("\n*** fetch item (with credentials) as owner") + _, headers, answer = self.request("GET", path_mapped_item1, check=200, login="owner:ownerpw") + assert "UID:event" in answer + + logging.info("\n*** fetch collection (with credentials) as user") + _, headers, answer = self.request("GET", path_mapped, check=403, login="user:userpw") + + logging.info("\n*** fetch collection via map (with credentials) as user") + _, headers, answer = self.request("GET", path_shared, check=200, login="user:userpw") + assert "UID:event1" in answer + assert "UID:event2" in answer + + logging.info("\n*** fetch item via map (with credentials) as user") + _, headers, answer = self.request("GET", path_shared_item1, check=200, login="user:userpw") + # only requested event has to be in the answer + assert "UID:event1" in answer + assert "UID:event2" not in answer + + logging.info("\n*** fetch item via map (with credentials) as user") + _, headers, answer = self.request("GET", path_shared_item2, check=200, login="user:userpw") + # only requested event has to be in the answer + assert "UID:event2" in answer + assert "UID:event1" not in answer + + logging.info("\n*** disable map by owner (json->json)") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "disable", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** enable map by owner (json->json)") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") + _, headers, answer = self.request("GET", path_shared, check=404, login="user:userpw") + + logging.info("\n*** enable map by owner (json->json)") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** fetch collection via map (with credentials) as user") - _, headers, answer = self.request("GET", path_shared, check=200, login="user:userpw") + logging.info("\n*** fetch collection via map (with credentials) as user") + _, headers, answer = self.request("GET", path_shared, check=200, login="user:userpw") - logging.info("\n*** disable map by user (json->json)") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "disable", check=200, login="user:userpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** disable map by user (json->json)") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "disable", check=200, login="user:userpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") - _, headers, answer = self.request("GET", path_shared, check=404, login="user:userpw") + logging.info("\n*** fetch collection via map (with credentials) as user -> n/a") + _, headers, answer = self.request("GET", path_shared, check=404, login="user:userpw") - logging.info("\n*** delete map by user (json->json) -> fail") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "delete", check=403, login="user:userpw", json_dict=json_dict) + logging.info("\n*** delete map by user (json->json) -> fail") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "delete", check=403, login="user:userpw", json_dict=json_dict) - logging.info("\n*** delete map by owner (json->json) -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** delete map by owner (json->json) -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" def test_sharing_api_map_usercheck(self) -> None: """share-by-map API usage tests related to usercheck.""" @@ -791,60 +824,66 @@ def test_sharing_api_map_usercheck(self) -> None: path = path_mapped2 + "/event1.ics" self.put(path, event, login="%s:%s" % ("owner2", "owner2pw")) - logging.info("\n*** create map user1/owner1 as owner(wrong owner) -> fail") - json_dict = {} - json_dict['User'] = "user1" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_share1 - _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner:ownerpw", json_dict=json_dict) + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** create map user1/owner1:r -> ok") - json_dict = {} - json_dict['User'] = "user1" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_share1 - json_dict['Permissions'] = "r" - _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** create map user1/owner1 as owner(wrong owner) -> fail") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** create map user1/owner1 (repeat) -> fail") - json_dict = {} - json_dict['User'] = "user1" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_share1 - _, headers, answer = self._sharing_api_json("map", "create", check=409, login="owner1:owner1pw", json_dict=json_dict) + logging.info("\n*** create map user1/owner1:r -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_share1 + json_dict['Permissions'] = "r" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** create map user2/owner2:rw -> ok") - json_dict = {} - json_dict['User'] = "user2" - json_dict['PathMapped'] = path_mapped2 - json_dict['PathOrToken'] = path_share2 - json_dict['Permissions'] = "rw" - _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner2:owner2pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** create map user1/owner1 (repeat) -> fail") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "create", check=409, login="owner1:owner1pw", json_dict=json_dict) - logging.info("\n*** create map user2/owner1 -> fail") - json_dict = {} - json_dict['User'] = "user2" - json_dict['PathMapped'] = path_mapped2 - json_dict['PathOrToken'] = path_share1 - _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner2:owner2pw", json_dict=json_dict) + logging.info("\n*** create map user2/owner2:rw -> ok") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_share2 + json_dict['Permissions'] = "rw" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner2:owner2pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** delete map user1 -> ok") - json_dict = {} - json_dict['User'] = "user1" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_share1 - _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner1:owner1pw", json_dict=json_dict) + logging.info("\n*** create map user2/owner1 -> fail") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner2:owner2pw", json_dict=json_dict) - logging.info("\n*** delete map user2 -> ok") - json_dict = {} - json_dict['User'] = "user2" - json_dict['PathMapped'] = path_mapped2 - json_dict['PathOrToken'] = path_share2 - _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner2:owner2pw", json_dict=json_dict) + logging.info("\n*** delete map user1 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_share1 + _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner1:owner1pw", json_dict=json_dict) + + logging.info("\n*** delete map user2 -> ok") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_share2 + _, headers, answer = self._sharing_api_json("map", "delete", check=200, login="owner2:owner2pw", json_dict=json_dict) def test_sharing_api_map_permissions(self) -> None: """share-by-map API usage tests related to permissions.""" @@ -872,165 +911,171 @@ def test_sharing_api_map_permissions(self) -> None: path = path_mapped + "/event1.ics" self.put(path, event, login="owner:ownerpw") - # check - logging.info("\n*** fetch event as owner (init) -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - # create maps - 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" - _, 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" + # check + logging.info("\n*** fetch event as owner (init) -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") - logging.info("\n*** create map user/owner:w -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared_w - json_dict['Permissions'] = "w" - json_dict['Enabled'] = "True" - _, 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" + # create maps + 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" + _, 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" - logging.info("\n*** create map user/owner:rw -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared_rw - json_dict['Permissions'] = "rw" - json_dict['Enabled'] = "True" - _, 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" + logging.info("\n*** create map user/owner:w -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_w + json_dict['Permissions'] = "w" + json_dict['Enabled'] = "True" + _, 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" - # list created maps - logging.info("\n*** list (json->text)") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") + logging.info("\n*** create map user/owner:rw -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_rw + json_dict['Permissions'] = "rw" + json_dict['Enabled'] = "True" + _, 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" - # check permissions, no map is enabled by user -> 404 - logging.info("\n*** fetch collection via map:r -> n/a") - _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") + # list created maps + logging.info("\n*** list (json->text)") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") - logging.info("\n*** fetch collection via map:w -> n/a") - _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") + # check permissions, no map is enabled by user -> 404 + logging.info("\n*** fetch collection via map:r -> n/a") + _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") - logging.info("\n*** fetch collection via map:rw -> n/a") - _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") + logging.info("\n*** fetch collection via map:w -> n/a") + _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") - # enable maps by user - logging.info("\n*** enable map by user:r") - 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) + logging.info("\n*** fetch collection via map:rw -> n/a") + _, headers, answer = self.request("GET", path_shared_r, check=404, login="user:userpw") - logging.info("\n*** enable map by user:w") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared_w - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + # enable maps by user + logging.info("\n*** enable map by user:r") + 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) - logging.info("\n*** enable map by user:rw") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared_rw - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + logging.info("\n*** enable map by user:w") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_w + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - # list adjusted maps - logging.info("\n*** list (json->text)") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") + logging.info("\n*** enable map by user:rw") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_rw + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - # check permissions, no map is enabled by user -> 404 - logging.info("\n*** fetch collection via map:r -> ok") - _, headers, answer = self.request("GET", path_shared_r, check=200, login="user:userpw") + # list adjusted maps + logging.info("\n*** list (json->text)") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") - logging.info("\n*** fetch collection via map:w -> fail") - _, headers, answer = self.request("GET", path_shared_w, check=403, login="user:userpw") + # check permissions, no map is enabled by user -> 404 + logging.info("\n*** fetch collection via map:r -> ok") + _, headers, answer = self.request("GET", path_shared_r, check=200, login="user:userpw") - logging.info("\n*** fetch collection via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw, check=200, login="user:userpw") + logging.info("\n*** fetch collection via map:w -> fail") + _, headers, answer = self.request("GET", path_shared_w, check=403, login="user:userpw") - # list adjusted maps - logging.info("\n*** list (json->text)") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") + logging.info("\n*** fetch collection via map:rw -> ok") + _, headers, answer = self.request("GET", path_shared_rw, check=200, login="user:userpw") - # PUT - logging.info("\n*** put to collection by user via map:r -> fail") - event = get_file_content("event2.ics") - path = path_shared_r + "/event2.ics" - self.put(path, event, check=403, login="user:userpw") + # list adjusted maps + logging.info("\n*** list (json->text)") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv") - logging.info("\n*** put to collection by user via map:w -> ok") - event = get_file_content("event2.ics") - path = path_shared_w + "event2.ics" - self.put(path, event, check=201, login="user:userpw") + # PUT + logging.info("\n*** put to collection by user via map:r -> fail") + event = get_file_content("event2.ics") + path = path_shared_r + "/event2.ics" + self.put(path, event, check=403, login="user:userpw") - # check result - logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="user:userpw") + logging.info("\n*** put to collection by user via map:w -> ok") + event = get_file_content("event2.ics") + path = path_shared_w + "event2.ics" + self.put(path, event, check=201, login="user:userpw") - logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="owner:ownerpw") + # check result + logging.info("\n*** fetch event via map:r -> ok") + _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="user:userpw") - logging.info("\n*** put to collection by user via map:rw -> ok") - event = get_file_content("event3.ics") - path = path_shared_rw + "event3.ics" - self.put(path, event, check=201, login="user:userpw") + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="owner:ownerpw") - # check result - logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="user:userpw") + logging.info("\n*** put to collection by user via map:rw -> ok") + event = get_file_content("event3.ics") + path = path_shared_rw + "event3.ics" + self.put(path, event, check=201, login="user:userpw") - logging.info("\n*** fetch event via map:r -> ok") - _, headers, answer = self.request("GET", path_shared_r + "event3.ics", check=200, login="user:userpw") + # check result + logging.info("\n*** fetch event via map:r -> ok") + _, headers, answer = self.request("GET", path_shared_r + "event2.ics", check=200, login="user:userpw") - logging.info("\n*** fetch event via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw + "event2.ics", check=200, login="user:userpw") + logging.info("\n*** fetch event via map:r -> ok") + _, headers, answer = self.request("GET", path_shared_r + "event3.ics", check=200, login="user:userpw") - logging.info("\n*** fetch event via map:rw -> ok") - _, headers, answer = self.request("GET", path_shared_rw + "event3.ics", check=200, login="user:userpw") + logging.info("\n*** fetch event via map:rw -> ok") + _, headers, answer = self.request("GET", path_shared_rw + "event2.ics", check=200, login="user:userpw") - logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") + logging.info("\n*** fetch event via map:rw -> ok") + _, headers, answer = self.request("GET", path_shared_rw + "event3.ics", check=200, login="user:userpw") - logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="owner:ownerpw") + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") - logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=200, login="owner:ownerpw") + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=200, login="owner:ownerpw") - # DELETE - logging.info("\n*** DELETE from collection by user via map:r -> fail") - _, headers, answer = self.request("DELETE", path_shared_r + "event1.ics", check=403, login="user:userpw") + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=200, login="owner:ownerpw") - logging.info("\n*** DELETE from collection by user via map:rw -> ok") - _, headers, answer = self.request("DELETE", path_shared_rw + "event2.ics", check=200, login="user:userpw") + # DELETE + logging.info("\n*** DELETE from collection by user via map:r -> fail") + _, headers, answer = self.request("DELETE", path_shared_r + "event1.ics", check=403, login="user:userpw") - logging.info("\n*** DELETE from collection by user via map:w -> ok") - _, headers, answer = self.request("DELETE", path_shared_w + "event3.ics", check=200, login="user:userpw") + logging.info("\n*** DELETE from collection by user via map:rw -> ok") + _, headers, answer = self.request("DELETE", path_shared_rw + "event2.ics", check=200, login="user:userpw") - # check results - logging.info("\n*** fetch event as owner -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") + logging.info("\n*** DELETE from collection by user via map:w -> ok") + _, headers, answer = self.request("DELETE", path_shared_w + "event3.ics", check=200, login="user:userpw") - logging.info("\n*** fetch event as owner -> fail") - _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=404, login="owner:ownerpw") + # check results + logging.info("\n*** fetch event as owner -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") - logging.info("\n*** fetch event as owner -> fail") - _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=404, login="owner:ownerpw") + logging.info("\n*** fetch event as owner -> fail") + _, headers, answer = self.request("GET", path_mapped + "event2.ics", check=404, login="owner:ownerpw") + + logging.info("\n*** fetch event as owner -> fail") + _, headers, answer = self.request("GET", path_mapped + "event3.ics", check=404, login="owner:ownerpw") def test_sharing_api_map_report_access(self) -> None: """share-by-map API usage tests related to report.""" @@ -1058,42 +1103,48 @@ def test_sharing_api_map_report_access(self) -> None: event = get_file_content("event1.ics") self.put(path_mapped_item, event, login="owner:ownerpw") - # check GET - logging.info("\n*** GET event as owner (init) -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + # check GET + logging.info("\n*** GET event as owner (init) -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") - # check REPORT as owner - logging.info("\n*** REPORT collection owner -> ok") - _, responses = self.report(path_mapped, """\ + # check REPORT as owner + logging.info("\n*** REPORT collection owner -> ok") + _, responses = self.report(path_mapped, """\ """, login="owner:ownerpw") - assert len(responses) == 1 - logging.info("response: %r", responses) - response = responses[path_mapped_item] - assert isinstance(response, dict) - status, prop = response["D:getetag"] - assert status == 200 and prop.text - - # create map - logging.info("\n*** create map user/owner -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - 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" + assert len(responses) == 1 + logging.info("response: %r", responses) + response = responses[path_mapped_item] + assert isinstance(response, dict) + status, prop = response["D:getetag"] + assert status == 200 and prop.text + + # create map + logging.info("\n*** create map user/owner -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + 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" - # check REPORT as user - logging.info("\n*** REPORT collection user -> 404") - _, responses = self.report(path_shared, """\ + # check REPORT as user + logging.info("\n*** REPORT collection user -> 404") + _, responses = self.report(path_shared, """\ @@ -1101,29 +1152,29 @@ def test_sharing_api_map_report_access(self) -> None: """, login="user:userpw", check=404) - # 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 - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - - # check REPORT as user - logging.info("\n*** REPORT collection user -> ok") - _, responses = self.report(path_shared, """\ + # 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 + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # check REPORT as user + logging.info("\n*** REPORT collection user -> ok") + _, responses = self.report(path_shared, """\ """, login="user:userpw") - assert len(responses) == 1 - logging.info("response: %r", responses) - response = responses[path_shared_item] - assert isinstance(response, dict) - status, prop = response["D:getetag"] - assert status == 200 and prop.text + assert len(responses) == 1 + logging.info("response: %r", responses) + response = responses[path_shared_item] + assert isinstance(response, dict) + status, prop = response["D:getetag"] + assert status == 200 and prop.text def test_sharing_api_map_hidden(self) -> None: """share-by-map API usage tests related to report checking hidden.""" @@ -1162,108 +1213,114 @@ def test_sharing_api_map_hidden(self) -> None: event = get_file_content("event2.ics") self.put(path_user_item, event, login="user:userpw") - # check GET - logging.info("\n*** GET event1 as owner -> 200") - _, headers, answer = self.request("GET", path_mapped_item, check=200, login="owner:ownerpw") + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** GET event2 as user -> 200") - _, headers, answer = self.request("GET", path_user_item, check=200, login="user:userpw") + # check GET + logging.info("\n*** GET event1 as owner -> 200") + _, headers, answer = self.request("GET", path_mapped_item, check=200, login="owner:ownerpw") - logging.info("\n*** GET collections as user -> 403") - _, headers, answer = self.request("GET", path_user_base, check=403, login="user:userpw") + logging.info("\n*** GET event2 as user -> 200") + _, headers, answer = self.request("GET", path_user_item, check=200, login="user:userpw") - # check PROPFIND as user - logging.info("\n*** PROPFIND collection user -> ok") - _, responses = self.propfind(path_user_base, """\ + logging.info("\n*** GET collections as user -> 403") + _, headers, answer = self.request("GET", path_user_base, check=403, login="user:userpw") + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok") + _, responses = self.propfind(path_user_base, """\ """, login="user:userpw", HTTP_DEPTH="1") - assert len(responses) == 2 - logging.info("response: %r", responses) - response = responses[path_user] - assert isinstance(response, dict) + assert len(responses) == 2 + logging.info("response: %r", responses) + response = responses[path_user] + assert isinstance(response, dict) - # create map - logging.info("\n*** create map user/owner -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - 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" + # create map + logging.info("\n*** create map user/owner -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + 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" - # check PROPFIND as owner - logging.info("\n*** PROPFIND collection owner -> ok") - _, responses = self.propfind(path_mapped_base, """\ + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped_base, """\ """, login="owner:ownerpw", HTTP_DEPTH="1") - assert len(responses) == 3 - logging.info("response: %r", responses) - response = responses[path_mapped] - assert isinstance(response, dict) - response = responses[path_mapped2] - assert isinstance(response, dict) - - # check PROPFIND as user - logging.info("\n*** PROPFIND collection user -> ok") - _, responses = self.propfind(path_user_base, """\ + assert len(responses) == 3 + logging.info("response: %r", responses) + response = responses[path_mapped] + assert isinstance(response, dict) + response = responses[path_mapped2] + assert isinstance(response, dict) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok") + _, responses = self.propfind(path_user_base, """\ """, login="user:userpw", HTTP_DEPTH="1") - assert len(responses) == 2 - logging.info("response: %r", responses) - response = responses[path_user] - assert isinstance(response, dict) + assert len(responses) == 2 + logging.info("response: %r", responses) + response = responses[path_user] + assert isinstance(response, dict) - # 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 - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - - # check PROPFIND as user - logging.info("\n*** PROPFIND collection user -> ok") - _, responses = self.propfind(path_user_base, """\ + # 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 + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok") + _, responses = self.propfind(path_user_base, """\ """, login="user:userpw", HTTP_DEPTH="1") - assert len(responses) == 2 - logging.info("response: %r", responses) - response = responses[path_user] - assert isinstance(response, dict) + assert len(responses) == 2 + logging.info("response: %r", responses) + response = responses[path_user] + assert isinstance(response, dict) - # unhide map by user - logging.info("\n*** unhide map by user") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - _, headers, answer = self._sharing_api_json("map", "unhide", check=200, login="user:userpw", json_dict=json_dict) - - # check PROPFIND as user - logging.info("\n*** PROPFIND collection user -> ok (now 3 items)") - _, responses = self.propfind(path_user_base, """\ + # unhide map by user + logging.info("\n*** unhide map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + _, headers, answer = self._sharing_api_json("map", "unhide", check=200, login="user:userpw", json_dict=json_dict) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok (now 3 items)") + _, responses = self.propfind(path_user_base, """\ """, login="user:userpw", HTTP_DEPTH="1") - assert len(responses) == 3 - logging.info("response: %r", responses) - response = responses[path_user] - assert isinstance(response, dict) - response = responses[path_shared] - assert isinstance(response, dict) + assert len(responses) == 3 + logging.info("response: %r", responses) + response = responses[path_user] + assert isinstance(response, dict) + response = responses[path_shared] + assert isinstance(response, dict) def test_sharing_api_map_propfind(self) -> None: @@ -1291,43 +1348,49 @@ def test_sharing_api_map_propfind(self) -> None: path = os.path.join(path_mapped, "event1.ics") self.put(path, event, login="owner:ownerpw") - # check GET - logging.info("\n*** GET event as owner (init) -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + # check GET + logging.info("\n*** GET event as owner (init) -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") - # check PROPFIND as owner - logging.info("\n*** PROPFIND collection owner -> ok") - _, responses = self.propfind(path_mapped, """\ + # 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 -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared - 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" + 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 -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + 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" - # check PROPFIND as user - logging.info("\n*** PROPFIND collection user -> 404") - _, responses = self.propfind(path_shared, """\ + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> 404") + _, responses = self.propfind(path_shared, """\ @@ -1335,30 +1398,30 @@ def test_sharing_api_map_propfind(self) -> None: """, login="user:userpw", check=404) - # 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 - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - - # check PROPFIND as user - logging.info("\n*** PROPFIND collection user -> ok") - _, responses = self.propfind(path_shared, """\ + # 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 + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> ok") + _, responses = self.propfind(path_shared, """\ """, login="user:userpw", check=207) - logging.info("response: %r", responses) - response = responses[path_shared] - 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 == "/user/" + logging.info("response: %r", responses) + response = responses[path_shared] + 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 == "/user/" def test_sharing_api_map_proppatch(self) -> None: """share-by-map API usage tests related to report.""" @@ -1387,140 +1450,146 @@ def test_sharing_api_map_proppatch(self) -> None: path = os.path.join(path_mapped, "event1.ics") self.put(path, event, login="owner:ownerpw") - # check GET - logging.info("\n*** GET event as owner (init) -> ok") - _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + # check GET + logging.info("\n*** GET event as owner (init) -> ok") + _, headers, answer = self.request("GET", path_mapped + "event1.ics", check=200, login="owner:ownerpw") - # check PROPFIND as owner - logging.info("\n*** PROPFIND collection owner -> ok") - _, responses = self.propfind(path_mapped, """\ + # 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/" - - # check PROPPATCH as owner - logging.info("\n*** PROPPATCH collection owner -> ok") - proppatch = get_file_content("proppatch_set_calendar_color.xml") - _, responses = self.proppatch(path_mapped, proppatch, login="owner:ownerpw") - logging.info("response: %r", responses) - response = responses[path_mapped] - assert not isinstance(response, int) and len(response) == 1 - status, prop = response["ICAL:calendar-color"] - assert status == 200 and not prop.text - - # check PROPPATCH as user - logging.info("\n*** PROPPATCH collection as user -> 404") - proppatch = get_file_content("proppatch_remove_calendar_color.xml") - _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=404) - _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw", check=404) - _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw", check=404) - - # 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" + 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/" + + # check PROPPATCH as owner + logging.info("\n*** PROPPATCH collection owner -> ok") + proppatch = get_file_content("proppatch_set_calendar_color.xml") + _, responses = self.proppatch(path_mapped, proppatch, login="owner:ownerpw") + logging.info("response: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) and len(response) == 1 + status, prop = response["ICAL:calendar-color"] + assert status == 200 and not prop.text + + # check PROPPATCH as user + logging.info("\n*** PROPPATCH collection as user -> 404") + proppatch = get_file_content("proppatch_remove_calendar_color.xml") + _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw", check=404) + + # 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" - logging.info("\n*** create map user/owner:w -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared_w - json_dict['Permissions'] = "w" - 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" + logging.info("\n*** create map user/owner:w -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_w + json_dict['Permissions'] = "w" + 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" - logging.info("\n*** create map user/owner:rw -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared_rw - json_dict['Permissions'] = "rw" - 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" + logging.info("\n*** create map user/owner:rw -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_rw + json_dict['Permissions'] = "rw" + 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" - # check PROPPATCH as user - logging.info("\n*** PROPPATCH collection as user -> 403") - proppatch = get_file_content("proppatch_set_calendar_color.xml") - _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=404) - _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw", check=404) - _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw", check=404) + # check PROPPATCH as user + logging.info("\n*** PROPPATCH collection as user -> 403") + proppatch = get_file_content("proppatch_set_calendar_color.xml") + _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw", check=404) + _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw", check=404) - # 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) + # 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) - logging.info("\n*** enable map by user") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared_w - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + logging.info("\n*** enable map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_w + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - logging.info("\n*** enable map by user") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped - json_dict['PathOrToken'] = path_shared_rw - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - - # check PROPPATCH as user - proppatch = get_file_content("proppatch_remove_calendar_color.xml") - logging.info("\n*** PROPPATCH collection as user:r -> 403") - _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=403) - - logging.info("\n*** PROPPATCH collection as user:w -> ok") - _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw") - logging.info("response: %r", responses) - - logging.info("\n*** PROPPATCH collection as user:rw -> ok") - _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw") - logging.info("response: %r", responses) - - # check PROPFIND as owner - logging.info("\n*** PROPFIND collection owner -> ok") - _, responses = self.propfind(path_mapped, """\ + logging.info("\n*** enable map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_rw + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # check PROPPATCH as user + proppatch = get_file_content("proppatch_remove_calendar_color.xml") + logging.info("\n*** PROPPATCH collection as user:r -> 403") + _, responses = self.proppatch(path_shared_r, proppatch, login="user:userpw", check=403) + + logging.info("\n*** PROPPATCH collection as user:w -> ok") + _, responses = self.proppatch(path_shared_w, proppatch, login="user:userpw") + logging.info("response: %r", responses) + + logging.info("\n*** PROPPATCH collection as user:rw -> ok") + _, responses = self.proppatch(path_shared_rw, proppatch, login="user:userpw") + logging.info("response: %r", responses) + + # 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/" - assert "ICAL:calendar-color" not in response + 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/" + assert "ICAL:calendar-color" not in response def test_sharing_api_map_move(self) -> None: """share-by-map API usage tests related to MOVE.""" @@ -1558,157 +1627,168 @@ def test_sharing_api_map_move(self) -> None: event = get_file_content("event3.ics") self.put(os.path.join(path_user, "event3.ics"), event, login="user:userpw") - # check GET as owner - logging.info("\n*** GET mapped1/event1 as owner (init) -> ok") - _, headers, answer = self.request("GET", os.path.join(path_mapped1, "event1.ics"), check=200, login="owner:ownerpw") + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** GET mapped2/event2 as owner (init) -> ok") - _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event2.ics"), check=200, login="owner:ownerpw") + # check GET as owner + logging.info("\n*** GET mapped1/event1 as owner (init) -> ok") + _, headers, answer = self.request("GET", os.path.join(path_mapped1, "event1.ics"), check=200, login="owner:ownerpw") - # check MOVE as owner - logging.info("\n*** MOVE event1 to mapped2 as owner -> ok") - self.request("MOVE", os.path.join(path_mapped1, "event1.ics"), check=201, - login="owner:ownerpw", - HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_mapped2, "event1.ics")) + logging.info("\n*** GET mapped2/event2 as owner (init) -> ok") + _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event2.ics"), check=200, login="owner:ownerpw") - logging.info("\n*** GET mapped2/event1 as owner (after move) -> ok") - _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event1.ics"), check=200, login="owner:ownerpw") + # check MOVE as owner + logging.info("\n*** MOVE event1 to mapped2 as owner -> ok") + self.request("MOVE", os.path.join(path_mapped1, "event1.ics"), check=201, + login="owner:ownerpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_mapped2, "event1.ics")) - # check GET as user - logging.info("\n*** GET event1 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="user:userpw") + logging.info("\n*** GET mapped2/event1 as owner (after move) -> ok") + _, headers, answer = self.request("GET", os.path.join(path_mapped2, "event1.ics"), check=200, login="owner:ownerpw") - logging.info("\n*** GET event2 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event2.ics"), check=404, login="user:userpw") + # check GET as user + logging.info("\n*** GET event1 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="user:userpw") - # create map - logging.info("\n*** create map user/owner:r -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_shared1_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" + logging.info("\n*** GET event2 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event2.ics"), check=404, login="user:userpw") - logging.info("\n*** create map user/owner:rw -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_shared1_rw - json_dict['Permissions'] = "rw" - 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" + # create map + logging.info("\n*** create map user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_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" - logging.info("\n*** create map user/owner:rw -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped2 - json_dict['PathOrToken'] = path_shared2_rw - json_dict['Permissions'] = "rw" - 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" + logging.info("\n*** create map user/owner:rw -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_rw + json_dict['Permissions'] = "rw" + 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" + + logging.info("\n*** create map user/owner:rw -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_shared2_rw + json_dict['Permissions'] = "rw" + 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" - # check MOVE as user - logging.info("\n*** MOVE event1 of shared1 to shared2 as user -> 404 (not enabled)") - self.request("MOVE", os.path.join(path_shared1_r, "event1.ics"), check=404, - login="user:userpw", - HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) + # check MOVE as user + logging.info("\n*** MOVE event1 of shared1 to shared2 as user -> 404 (not enabled)") + self.request("MOVE", os.path.join(path_shared1_r, "event1.ics"), check=404, + login="user:userpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) - logging.info("\n*** MOVE event1 of shared2 to shared1 as user -> 404 (not enabled)") - self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=404, - login="user:userpw", - HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_r, "event1.ics")) + logging.info("\n*** MOVE event1 of shared2 to shared1 as user -> 404 (not enabled)") + self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=404, + login="user:userpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_r, "event1.ics")) - # enable map by user - logging.info("\n*** enable map shared1_r by user") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_shared1_r - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + # enable map by user + logging.info("\n*** enable map shared1_r by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - logging.info("\n*** enable map shared1_rw by user") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_shared1_rw - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + logging.info("\n*** enable map shared1_rw by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_rw + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - logging.info("\n*** enable map shared2_rw by user") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped2 - json_dict['PathOrToken'] = path_shared2_rw - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + logging.info("\n*** enable map shared2_rw by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_shared2_rw + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # check GET as user + logging.info("\n*** GET event1 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="user:userpw") - # check GET as user - logging.info("\n*** GET event1 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=404, login="user:userpw") + logging.info("\n*** GET event1 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=404, login="user:userpw") - logging.info("\n*** GET event1 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=404, login="user:userpw") + logging.info("\n*** GET event1 as user -> ok") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=200, login="user:userpw") - logging.info("\n*** GET event1 as user -> ok") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=200, login="user:userpw") + # check MOVE as user between shares + logging.info("\n*** MOVE event1 of shared1_r to shared2_rw as user -> 403 (not permitted to move from r)") + self.request("MOVE", os.path.join(path_shared1_r, "event1.ics"), check=403, + login="user:userpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) - # check MOVE as user between shares - logging.info("\n*** MOVE event1 of shared1_r to shared2_rw as user -> 403 (not permitted to move from r)") - self.request("MOVE", os.path.join(path_shared1_r, "event1.ics"), check=403, - login="user:userpw", - HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) + logging.info("\n*** MOVE event1 of shared2_rw to shared1_r as user -> 403 (not permitted to move to r)") + self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=403, + login="user:userpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_r, "event1.ics")) - logging.info("\n*** MOVE event1 of shared2_rw to shared1_r as user -> 403 (not permitted to move to r)") - self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=403, - login="user:userpw", - HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_r, "event1.ics")) + logging.info("\n*** MOVE event1 of shared1_rw to shared2_rw as user -> 404 (already moved by owner)") + self.request("MOVE", os.path.join(path_shared1_rw, "event1.ics"), check=404, + login="user:userpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) - logging.info("\n*** MOVE event1 of shared1_rw to shared2_rw as user -> 404 (already moved by owner)") - self.request("MOVE", os.path.join(path_shared1_rw, "event1.ics"), check=404, - login="user:userpw", - HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event1.ics")) + logging.info("\n*** MOVE event1 of shared2_rw to shared1_rw as user -> 201") + self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=201, + login="user:userpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_rw, "event1.ics")) - logging.info("\n*** MOVE event1 of shared2_rw to shared1_rw as user -> 201") - self.request("MOVE", os.path.join(path_shared2_rw, "event1.ics"), check=201, - login="user:userpw", - HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared1_rw, "event1.ics")) + # check GET as user + logging.info("\n*** GET event1 as user -> ok") + _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=200, login="user:userpw") - # check GET as user - logging.info("\n*** GET event1 as user -> ok") - _, headers, answer = self.request("GET", os.path.join(path_shared1_r, "event1.ics"), check=200, login="user:userpw") + logging.info("\n*** GET event1 as user -> ok") + _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=200, login="user:userpw") - logging.info("\n*** GET event1 as user -> ok") - _, headers, answer = self.request("GET", os.path.join(path_shared1_rw, "event1.ics"), check=200, login="user:userpw") + logging.info("\n*** GET event1 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=404, login="user:userpw") - logging.info("\n*** GET event1 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event1.ics"), check=404, login="user:userpw") + # check MOVE as user between shares and own calendar + logging.info("\n*** GET event3 as user -> 200") + _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=200, login="user:userpw") - # check MOVE as user between shares and own calendar - logging.info("\n*** GET event3 as user -> 200") - _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=200, login="user:userpw") + logging.info("\n*** GET event3 as user from shared2_rw -> 404") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=404, login="user:userpw") - logging.info("\n*** GET event3 as user from shared2_rw -> 404") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=404, login="user:userpw") + logging.info("\n*** MOVE event3 of own to shared2_rw as user -> 201") + self.request("MOVE", os.path.join(path_user, "event3.ics"), check=201, + login="user:userpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event3.ics")) - logging.info("\n*** MOVE event3 of own to shared2_rw as user -> 201") - self.request("MOVE", os.path.join(path_user, "event3.ics"), check=201, - login="user:userpw", - HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_shared2_rw, "event3.ics")) + logging.info("\n*** GET event3 as user from shared2_rw -> 200") + _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=200, login="user:userpw") - logging.info("\n*** GET event3 as user from shared2_rw -> 200") - _, headers, answer = self.request("GET", os.path.join(path_shared2_rw, "event3.ics"), check=200, login="user:userpw") + logging.info("\n*** GET event3 as user -> 404") + _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=404, login="user:userpw") - logging.info("\n*** GET event3 as user -> 404") - _, headers, answer = self.request("GET", os.path.join(path_user, "event3.ics"), check=404, login="user:userpw") + logging.info("\n*** MOVE event3 to own from shared2_rw as user -> 201") + self.request("MOVE", os.path.join(path_shared2_rw, "event3.ics"), check=201, + login="user:userpw", + HTTP_DESTINATION="http://127.0.0.1/"+os.path.join(path_user, "event3.ics")) def test_sharing_api_update(self) -> None: """sharing API usage tests related to update.""" @@ -1734,87 +1814,96 @@ def test_sharing_api_update(self) -> None: event = get_file_content("event1.ics") self.put(os.path.join(path_mapped1, "event1.ics"), event, login="owner:ownerpw") - # check GET as owner - logging.info("\n*** GET mapped1 as owner (init) -> 200") - _, headers, answer = self.request("GET", path_mapped1, check=200, login="owner:ownerpw") + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** GET shared1 as user (init) -> 404") - _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") + # check GET as owner + logging.info("\n*** GET mapped1 as owner (init) -> 200") + _, headers, answer = self.request("GET", path_mapped1, check=200, login="owner:ownerpw") - logging.info("\n*** create map user/owner:w -> ok") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_shared1 - json_dict['Permissions'] = "w" - 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" + logging.info("\n*** GET shared1 as user (init) -> 404") + _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") - logging.info("\n*** GET shared1 as user (still not enabled by user) -> 404") - _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") + logging.info("\n*** create map user/owner:w -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1 + json_dict['Permissions'] = "w" + 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 shared1 by user") - json_dict = {} - json_dict['User'] = "user" - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_shared1 - _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - - logging.info("\n*** list/all (form->csv)") - form_array = [] - _, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "Lines=1" in answer - - # read collection - logging.info("\n*** GET shared1 as user (no read permissions set by owner) -> 403") - _, headers, answer = self.request("GET", path_shared1, check=403, login="user:userpw") - - # update map - logging.info("\n*** update map user/owner:r -> ok") - json_dict = {} - json_dict['PathMapped'] = path_mapped1 - json_dict['PathOrToken'] = path_shared1 - json_dict['Permissions'] = "r" - _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** GET shared1 as user (still not enabled by user) -> 404") + _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") - logging.info("\n*** list/all (form->csv)") - form_array = [] - _, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array) - assert "Status=success" in answer - assert "Lines=1" in answer + # enable map by user + logging.info("\n*** enable map shared1 by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1 + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) - # read collection - logging.info("\n*** GET shared1 as user (read permissions set by owner) -> 200") - _, headers, answer = self.request("GET", path_shared1, check=200, login="user:userpw") + logging.info("\n*** list/all (form->csv)") + form_array = [] + _, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=1" in answer - # update map - logging.info("\n*** update map user/owner:path_mapped2 -> ok") - json_dict = {} - json_dict['PathMapped'] = path_mapped2 - json_dict['PathOrToken'] = path_shared1 - _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + # read collection + logging.info("\n*** GET shared1 as user (no read permissions set by owner) -> 403") + _, headers, answer = self.request("GET", path_shared1, check=403, login="user:userpw") - # read collection - logging.info("\n*** GET shared1 as user (path not matching) -> 404") - _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") + # update map + logging.info("\n*** update map user/owner:r -> ok") + json_dict = {} + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1 + json_dict['Permissions'] = "r" + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** create mapped2 collection") - self.mkcalendar(path_mapped2, login="owner:ownerpw") - event = get_file_content("event2.ics") - self.put(os.path.join(path_mapped2, "event2.ics"), event, login="owner:ownerpw") + logging.info("\n*** list/all (form->csv)") + form_array = [] + _, headers, answer = self._sharing_api_form("map", "list", check=200, login="owner:ownerpw", form_array=form_array) + assert "Status=success" in answer + assert "Lines=1" in answer + + # read collection + logging.info("\n*** GET shared1 as user (read permissions set by owner) -> 200") + _, headers, answer = self.request("GET", path_shared1, check=200, login="user:userpw") + + # update map + logging.info("\n*** update map user/owner:path_mapped2 -> ok") + json_dict = {} + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_shared1 + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # read collection + logging.info("\n*** GET shared1 as user (path not matching) -> 404") + _, headers, answer = self.request("GET", path_shared1, check=404, login="user:userpw") - # read collection - logging.info("\n*** GET shared1 as user (path now matching) -> 200") - _, headers, answer = self.request("GET", path_shared1, check=200, login="user:userpw") + logging.info("\n*** create mapped2 collection") + self.mkcalendar(path_mapped2, login="owner:ownerpw") + event = get_file_content("event2.ics") + self.put(os.path.join(path_mapped2, "event2.ics"), event, login="owner:ownerpw") + + # read collection + logging.info("\n*** GET shared1 as user (path now matching) -> 200") + _, headers, answer = self.request("GET", path_shared1, check=200, login="user:userpw") + + # cleanup + self.delete(path_mapped2, login="owner:ownerpw") def test_sharing_api_list_filter(self) -> None: """sharing API usage tests related to update.""" @@ -1846,113 +1935,119 @@ def test_sharing_api_list_filter(self) -> None: self.mkcalendar(path_user1, login="user1:user1pw") self.mkcalendar(path_user2, login="user2:user2pw") - # list current - logging.info("\n*** list owner1 -> empty") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "not-found" + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** list owner2 -> empty") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner2:owner2pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "not-found" + # list current + logging.info("\n*** list owner1 -> empty") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" - logging.info("\n*** list user1 -> empty") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "not-found" + logging.info("\n*** list owner2 -> empty") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner2:owner2pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" - logging.info("\n*** list user2 -> empty") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user2:user2pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "not-found" + logging.info("\n*** list user1 -> empty") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" - # create map#1 - logging.info("\n*** create map user1/owner1 -> ok") - json_dict = {} - json_dict['User'] = "user1" - json_dict['PathMapped'] = path_owner1 - json_dict['PathOrToken'] = path_user1_shared1 - json_dict['Permissions'] = "r" - json_dict['Enabled'] = "True" - json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** list user2 -> empty") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user2:user2pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "not-found" - logging.info("\n*** list owner1") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 1 + # create map#1 + logging.info("\n*** create map user1/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user1_shared1 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** list user1") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 1 + logging.info("\n*** list owner1") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 - # create map#2 - logging.info("\n*** create map user1/owner2 -> ok") - json_dict = {} - json_dict['User'] = "user1" - json_dict['PathMapped'] = path_owner2 - json_dict['PathOrToken'] = path_user1_shared2 - json_dict['Permissions'] = "r" - json_dict['Enabled'] = "True" - json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner2:owner2pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** list user1") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 - logging.info("\n*** list user1 -> 2 entries") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 2 + # create map#2 + logging.info("\n*** create map user1/owner2 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_owner2 + json_dict['PathOrToken'] = path_user1_shared2 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner2:owner2pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - # create map#3 - logging.info("\n*** create map user2/owner1 -> ok") - json_dict = {} - json_dict['User'] = "user2" - json_dict['PathMapped'] = path_owner1 - json_dict['PathOrToken'] = path_user2_shared1 - json_dict['Permissions'] = "r" - json_dict['Enabled'] = "True" - json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** list user1 -> 2 entries") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 2 - logging.info("\n*** list owner1 -> 2 entries") - json_dict = {} - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 2 + # create map#3 + logging.info("\n*** create map user2/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user2_shared1 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - logging.info("\n*** list user1 filter for PathMapped -> 1 entries") - json_dict = {} - json_dict['PathMapped'] = path_owner1 - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 1 + logging.info("\n*** list owner1 -> 2 entries") + json_dict = {} + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 2 - logging.info("\n*** list user1 filter for PathShared -> 1 entries") - json_dict = {} - json_dict['PathOrToken'] = path_user1_shared1 - _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" - assert answer_dict['Lines'] == 1 + logging.info("\n*** list user1 filter for PathMapped -> 1 entries") + json_dict = {} + json_dict['PathMapped'] = path_owner1 + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 + + logging.info("\n*** list user1 filter for PathShared -> 1 entries") + json_dict = {} + json_dict['PathOrToken'] = path_user1_shared1 + _, headers, answer = self._sharing_api_json("map", "list", check=200, login="user1:user1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + assert answer_dict['Lines'] == 1 def test_sharing_api_create_conflict(self) -> None: """sharing API usage tests related to update.""" @@ -1988,45 +2083,51 @@ def test_sharing_api_create_conflict(self) -> None: logging.info("\n*** mkcalendar user2 -> conflict") self.mkcalendar(path_user2, login="user2:user2pw", check=409) - # create map - logging.info("\n*** create map user1/owner1 -> ok") - json_dict = {} - json_dict['User'] = "user1" - json_dict['PathMapped'] = path_owner1 - json_dict['PathOrToken'] = path_user1_shared1 - json_dict['Permissions'] = "r" - json_dict['Enabled'] = "True" - json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + for db_type in sharing.INTERNAL_TYPES: + if db_type == "none": + continue + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) - logging.info("\n*** mkcalendar user1 for shared -> conflict") - self.mkcalendar(path_user1_shared1, login="user1:user1pw", check=409) + # create map + logging.info("\n*** create map user1/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user1" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user1_shared1 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - # create map - logging.info("\n*** create map user2/owner1 -> ok") - json_dict = {} - json_dict['User'] = "user2" - json_dict['PathMapped'] = path_owner1 - json_dict['PathOrToken'] = path_user2_shared1 - json_dict['Permissions'] = "r" - json_dict['Enabled'] = "True" - json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) - answer_dict = json.loads(answer) - assert answer_dict['Status'] == "success" + logging.info("\n*** mkcalendar user1 for shared -> conflict") + self.mkcalendar(path_user1_shared1, login="user1:user1pw", check=409) - logging.info("\n*** mkcol user2 for shared -> conflict") - self.mkcalendar(path_user2_shared1, login="user2:user2pw", check=409) + # create map + logging.info("\n*** create map user2/owner1 -> ok") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user2_shared1 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner1:owner1pw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" - # create map - logging.info("\n*** create map user2/owner2 -> 409") - json_dict = {} - json_dict['User'] = "user2" - json_dict['PathMapped'] = path_owner1 - json_dict['PathOrToken'] = path_user2 - json_dict['Permissions'] = "r" - json_dict['Enabled'] = "True" - json_dict['Hidden'] = "False" - _, headers, answer = self._sharing_api_json("map", "create", check=409, login="owner1:owner1pw", json_dict=json_dict) + logging.info("\n*** mkcol user2 for shared -> conflict") + self.mkcalendar(path_user2_shared1, login="user2:user2pw", check=409) + + # create map + logging.info("\n*** create map user2/owner2 -> 409") + json_dict = {} + json_dict['User'] = "user2" + json_dict['PathMapped'] = path_owner1 + json_dict['PathOrToken'] = path_user2 + json_dict['Permissions'] = "r" + json_dict['Enabled'] = "True" + json_dict['Hidden'] = "False" + _, headers, answer = self._sharing_api_json("map", "create", check=409, login="owner1:owner1pw", json_dict=json_dict) From 2ba6254460ef5d85430ac293bca594891323bd73 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:17:45 +0100 Subject: [PATCH 118/138] add enable flag --- radicale/app/delete.py | 21 ++++++------ radicale/app/get.py | 21 ++++++------ radicale/app/mkcalendar.py | 13 ++++---- radicale/app/mkcol.py | 13 ++++---- radicale/app/move.py | 43 ++++++++++++------------ radicale/app/propfind.py | 63 ++++++++++++++++++------------------ radicale/app/proppatch.py | 22 ++++++------- radicale/app/put.py | 22 ++++++------- radicale/app/report.py | 22 ++++++------- radicale/sharing/__init__.py | 12 ++++++- 10 files changed, 131 insertions(+), 121 deletions(-) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index d04ea472d..ff22e0c55 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -57,17 +57,16 @@ class ApplicationPartDelete(ApplicationBase): def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage DELETE request.""" - # Sharing by token or map - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: - # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] - access = Access(self._rights, user, path, permissions_filter) - else: - # default permission check - access = Access(self._rights, user, path) + permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user, path=path, request="DELETE"): diff --git a/radicale/app/get.py b/radicale/app/get.py index d1ed84735..5ff3a7218 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -76,17 +76,16 @@ def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, return httputils.redirect(location, client.MOVED_PERMANENTLY) # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) - # Sharing by token or map - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: - # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] - access = Access(self._rights, user, path, permissions_filter) - else: - # default permission check - access = Access(self._rights, user, path) + permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("r") and "i" not in access.permissions: return httputils.NOT_ALLOWED with self._storage.acquire_lock("r", user): diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index 47f8ca3e4..2c49bca13 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -55,12 +55,13 @@ def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST - # check for shared collections (active or inactive) - collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False) - if collections_shared_map: - for sharing in collections_shared_map: - if sharing['PathOrToken'] == path: - return httputils.CONFLICT + if self._sharing._enabled: + # check for shared collections (active or inactive) + collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False) + if collections_shared_map: + for sharing in collections_shared_map: + if sharing['PathOrToken'] == path: + return httputils.CONFLICT # TODO: use this? # timezone = props.get("C:calendar-timezone") with self._storage.acquire_lock("w", user, path=path, request="MKCALENDAR"): diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 477799b0e..efcdb76cb 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -62,12 +62,13 @@ def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, if not props.get("tag") and "W" not in permissions: logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'") return httputils.NOT_ALLOWED - # check for shared collections (active or inactive) - collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False) - if collections_shared_map: - for sharing in collections_shared_map: - if sharing['PathOrToken'] == path: - return httputils.CONFLICT + if self._sharing._enabled: + # check for shared collections (active or inactive) + collections_shared_map = self._sharing.sharing_collection_map_list(user, active=False) + if collections_shared_map: + for sharing in collections_shared_map: + if sharing['PathOrToken'] == path: + return httputils.CONFLICT with self._storage.acquire_lock("w", user, path=path, request="MKCOL"): item = next(iter(self._storage.discover(path)), None) if item: diff --git a/radicale/app/move.py b/radicale/app/move.py index 3652c527f..4715828a7 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -68,17 +68,16 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, return httputils.REMOTE_DESTINATION to_user = user - # Sharing by token or map - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: - # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] - access = Access(self._rights, user, path, permissions_filter) - else: - # default permission check - access = Access(self._rights, user, path) + permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED to_path = pathutils.sanitize_path(to_url.path) @@ -87,17 +86,17 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] - # Sharing by token or map - sharing = self._sharing.sharing_collection_resolver(to_path, to_user) - if sharing: - # overwrite and run through extended permission check - to_path = sharing['PathMapped'] - to_user = sharing['Owner'] - permissions_filter = sharing['Permissions'] - to_access = Access(self._rights, to_user, to_path, permissions_filter) - else: - # default permission check - to_access = Access(self._rights, to_user, to_path) + to_permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(to_path, to_user) + if sharing: + # overwrite and run through extended permission check + to_path = sharing['PathMapped'] + to_user = sharing['Owner'] + to_permissions_filter = sharing['Permissions'] + to_access = Access(self._rights, to_user, to_path, to_permissions_filter) + to_access = Access(self._rights, to_user, to_path, to_permissions_filter) if not to_access.check("w"): return httputils.NOT_ALLOWED diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index ed9e1a298..39b40b2df 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -417,17 +417,17 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPFIND request.""" http_depth = environ.get("HTTP_DEPTH", "0") - # Sharing by token or map (only for depth==0) - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: - # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] - access = Access(self._rights, user, path, permissions_filter) - else: - # default permission check - access = Access(self._rights, user, path) + permissions_filter = None + sharing = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED try: @@ -454,26 +454,27 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, items_iter = itertools.chain([item], items_iter) allowed_items = list(self._collect_allowed_items(items_iter, user)) if http_depth == "1": - logger.debug("TRACE/PROPFIND: get shared collections") - # check for shared collections - collections_shared_map = self._sharing.sharing_collection_map_list(user) - if collections_shared_map: - for sharing in collections_shared_map: - c_share = sharing['PathOrToken'] - c_path = sharing['PathMapped'] - c_user = sharing['Owner'] - c_permissions_filter = sharing['Permissions'] - logger.debug("TRACE/PROPFIND: test shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s", c_share, c_path, c_user, c_permissions_filter) - access = Access(self._rights, c_user, c_path, c_permissions_filter) - if not access.check("r"): - logger.debug("TRACE/PROPFIND: skip shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s (permissions not matching)", c_share, c_path, c_user, c_permissions_filter) - continue - logger.debug("TRACE/PROPFIND: append shared collection: PathOrToken=%r PathMapped=%r Owner=%r", c_share, c_path, c_user) - c_parent_path = pathutils.parent_path(c_path) - with self._storage.acquire_lock("r", c_user): - c_items_iter = iter(self._storage.discover(c_path, "0")) - c_allowed_items = list(self._collect_allowed_items(c_items_iter, c_user)) - allowed_items = allowed_items + c_allowed_items + if self._sharing._enabled: + logger.debug("TRACE/PROPFIND: get shared collections") + # check for shared collections + collections_shared_map = self._sharing.sharing_collection_map_list(user) + if collections_shared_map: + for sharing in collections_shared_map: + c_share = sharing['PathOrToken'] + c_path = sharing['PathMapped'] + c_user = sharing['Owner'] + c_permissions_filter = sharing['Permissions'] + logger.debug("TRACE/PROPFIND: test shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s", c_share, c_path, c_user, c_permissions_filter) + access = Access(self._rights, c_user, c_path, c_permissions_filter) + if not access.check("r"): + logger.debug("TRACE/PROPFIND: skip shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s (permissions not matching)", c_share, c_path, c_user, c_permissions_filter) + continue + logger.debug("TRACE/PROPFIND: append shared collection: PathOrToken=%r PathMapped=%r Owner=%r", c_share, c_path, c_user) + c_parent_path = pathutils.parent_path(c_path) + with self._storage.acquire_lock("r", c_user): + c_items_iter = iter(self._storage.discover(c_path, "0")) + c_allowed_items = list(self._collect_allowed_items(c_items_iter, c_user)) + allowed_items = allowed_items + c_allowed_items headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} xml_answer = xml_propfind(base_prefix, path, xml_content, diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index e769a9847..a4ed5f9b1 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -78,17 +78,17 @@ class ApplicationPartProppatch(ApplicationBase): def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" - # Sharing by token or map - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: - # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] - access = Access(self._rights, user, path, permissions_filter) - else: - # default permission check - access = Access(self._rights, user, path) + permissions_filter = None + sharing = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED try: diff --git a/radicale/app/put.py b/radicale/app/put.py index 2d3b9275e..4e62ec1df 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -181,17 +181,17 @@ class ApplicationPartPut(ApplicationBase): def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PUT request.""" - # Sharing by token or map - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: - # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] - access = Access(self._rights, user, path, permissions_filter) - else: - # default permission check - access = Access(self._rights, user, path) + permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED try: diff --git a/radicale/app/report.py b/radicale/app/report.py index 11227e254..cfb9458f1 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -812,17 +812,17 @@ class ApplicationPartReport(ApplicationBase): def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" - # Sharing by token or map - sharing = self._sharing.sharing_collection_resolver(path, user) - if sharing: - # overwrite and run through extended permission check - path = sharing['PathMapped'] - user = sharing['Owner'] - permissions_filter = sharing['Permissions'] - access = Access(self._rights, user, path, permissions_filter) - else: - # default permission check - access = Access(self._rights, user, path) + permissions_filter = None + sharing = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED try: diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index fd3031eff..b0dd7fe40 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -83,8 +83,10 @@ def load(configuration: "config.Configuration") -> "BaseSharing": class BaseSharing: - configuration: "config.Configuration" + configuration: config.Configuration + _storage: multifilesystem.Storage _rights: rights.BaseRights + _enabled: bool = False def __init__(self, configuration: "config.Configuration") -> None: """Initialize Sharing. @@ -102,6 +104,14 @@ def __init__(self, configuration: "config.Configuration") -> None: self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token") logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) + + if (self.sharing_collection_by_map == False and self.sharing_collection_by_token == False): + logger.info("sharing disabled as no feature is enabled") + self._enabled = False + return + else: + self._enabled = True + self.sharing_db_type = configuration.get("sharing", "type") logger.info("sharing.db_type: %s", self.sharing_db_type) # database tasks From 1c2e4d80f024cc4dc58e84ea099422bd69e26264 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:19:27 +0100 Subject: [PATCH 119/138] align --- config | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config b/config index 45972ceb3..0c5b90a6f 100644 --- a/config +++ b/config @@ -299,14 +299,14 @@ [sharing] # Sharing database type -# Value: none | csv | sqlite +# Value: none | csv | files #type = none # Sharing database path for type 'csv' -#database_filename = (filesystem_folder)/collection-db/sharing.csv +#database_path = (filesystem_folder)/collection-db/sharing.csv -# Sharing database paths for type 'sqlite' -#database_filename = (filesystem_folder)/collection-db/sharing.sqlite +# Sharing database path for type 'files' +#database_path = (filesystem_folder)/collection-db/files # Share collection by map #collection_by_map = false From b883fe0874fd923e747f16f6be2638482eab5505 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:21:18 +0100 Subject: [PATCH 120/138] fix --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index b0dd7fe40..d57d5b306 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -84,7 +84,7 @@ def load(configuration: "config.Configuration") -> "BaseSharing": class BaseSharing: configuration: config.Configuration - _storage: multifilesystem.Storage + _storage: storage.BaseStorage _rights: rights.BaseRights _enabled: bool = False From b712dc4d463ad86a82cb5d2280e99fa58c9d8092 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:27:38 +0100 Subject: [PATCH 121/138] extend copyright --- radicale/app/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index f485a977a..e66f60191 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -1,6 +1,6 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2020 Unrud -# Copyright © 2024-2024 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by From 47a115fa7a7ba2b33ba45a9137ab092464e12375 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:31:56 +0100 Subject: [PATCH 122/138] extend copyright --- radicale/app/get.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 5ff3a7218..cea26f317 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2023 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by From aa5241c44c13f35378230cf579b25ebbb542de77 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:35:23 +0100 Subject: [PATCH 123/138] extend copyright --- radicale/app/mkcol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index efcdb76cb..c47f68fc9 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by From 097246ce7fb8d2555bc32907e4ed05c893ec0e48 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:40:27 +0100 Subject: [PATCH 124/138] code reorg --- radicale/app/move.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/move.py b/radicale/app/move.py index 4715828a7..e16d15a82 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -67,7 +67,6 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, # Remote destination server, not supported return httputils.REMOTE_DESTINATION - to_user = user permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) @@ -86,6 +85,7 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] + to_user = user to_permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) From 58b9d372c87b290aeaf6f614b1e0bc86314974d8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:50:21 +0100 Subject: [PATCH 125/138] extend copyright --- radicale/app/proppatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index a4ed5f9b1..4ebdb6503 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -4,7 +4,7 @@ # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2020 Unrud # Copyright © 2020-2020 Tuna Celik -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by From abaa6d8a0df0f395943cf538a27c32540da2d72b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 14:56:31 +0100 Subject: [PATCH 126/138] remove no longer required include related to use new parent_path --- radicale/app/base.py | 1 - radicale/app/mkcalendar.py | 1 - radicale/app/mkcol.py | 1 - 3 files changed, 3 deletions(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index e66f60191..dff5ce54b 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -17,7 +17,6 @@ import io import logging -import posixpath import sys import xml.etree.ElementTree as ET from typing import Optional, Union diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index 2c49bca13..b892aa6bc 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -19,7 +19,6 @@ # along with Radicale. If not, see . import errno -import posixpath import re import socket from http import client diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index c47f68fc9..6670349f9 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -19,7 +19,6 @@ # along with Radicale. If not, see . import errno -import posixpath import re import socket from http import client From e9d74ad908cbc64398c7c1e51c9c8ecc36ee0454 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:02:18 +0100 Subject: [PATCH 127/138] code fix --- radicale/app/propfind.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 39b40b2df..c573c2810 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -453,8 +453,8 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, # put item back items_iter = itertools.chain([item], items_iter) allowed_items = list(self._collect_allowed_items(items_iter, user)) - if http_depth == "1": - if self._sharing._enabled: + if self._sharing._enabled: + if http_depth == "1": logger.debug("TRACE/PROPFIND: get shared collections") # check for shared collections collections_shared_map = self._sharing.sharing_collection_map_list(user) From 2952f0d7abf5d8b66d55e6034d1b77c0b73ece91 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:14:54 +0100 Subject: [PATCH 128/138] catch sharing database "none" better --- radicale/sharing/__init__.py | 6 ++++-- radicale/sharing/none.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d57d5b306..2fe818c01 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -112,13 +112,15 @@ def __init__(self, configuration: "config.Configuration") -> None: else: self._enabled = True + # database tasks self.sharing_db_type = configuration.get("sharing", "type") logger.info("sharing.db_type: %s", self.sharing_db_type) - # database tasks try: if self.init_database() is False: - exit(1) + logger.info("sharing disabled as no database is active") + self._enabled = False + return except Exception as e: logger.error("sharing database cannot be initialized: %r", e) exit(1) diff --git a/radicale/sharing/none.py b/radicale/sharing/none.py index daca8c63c..5f7112308 100644 --- a/radicale/sharing/none.py +++ b/radicale/sharing/none.py @@ -24,7 +24,7 @@ class Sharing(sharing.BaseSharing): def init_database(self) -> bool: """ dummy initialization """ - return True + return False def get_sharing_collection_by_token(self, token: str) -> Union[dict | None]: """ retrieve target and attributs by token """ From 6333c8439adaadcbf23f32d8669db8be650fe7ed Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:16:39 +0100 Subject: [PATCH 129/138] python 3.9 support --- radicale/app/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index dff5ce54b..44f064e73 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -108,9 +108,9 @@ class Access: permissions: str _rights: rights.BaseRights _parent_permissions: Optional[str] - _permissions_filter: Union[str | None] = None + _permissions_filter: Union[str, None] = None - def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_filter: Union[str | None] = None + def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_filter: Union[str, None] = None ) -> None: self._rights = rights self.user = user From 476272fa138008d402a7b4fa2daa9a9b95d644a8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:29:06 +0100 Subject: [PATCH 130/138] flake8 fixes --- radicale/app/propfind.py | 5 ++--- radicale/httputils.py | 2 ++ radicale/sharing/__init__.py | 16 +++++++--------- radicale/sharing/csv.py | 6 +++--- radicale/sharing/files.py | 17 +++++++---------- radicale/tests/test_sharing.py | 29 ++++++++++++----------------- 6 files changed, 33 insertions(+), 42 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index c573c2810..3f1a431b0 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -465,12 +465,11 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, c_user = sharing['Owner'] c_permissions_filter = sharing['Permissions'] logger.debug("TRACE/PROPFIND: test shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s", c_share, c_path, c_user, c_permissions_filter) - access = Access(self._rights, c_user, c_path, c_permissions_filter) - if not access.check("r"): + c_access = Access(self._rights, c_user, c_path, c_permissions_filter) + if not c_access.check("r"): logger.debug("TRACE/PROPFIND: skip shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s (permissions not matching)", c_share, c_path, c_user, c_permissions_filter) continue logger.debug("TRACE/PROPFIND: append shared collection: PathOrToken=%r PathMapped=%r Owner=%r", c_share, c_path, c_user) - c_parent_path = pathutils.parent_path(c_path) with self._storage.acquire_lock("r", c_user): c_items_iter = iter(self._storage.discover(c_path, "0")) c_allowed_items = list(self._collect_allowed_items(c_items_iter, c_user)) diff --git a/radicale/httputils.py b/radicale/httputils.py index cc1f81e81..0e829c6b9 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -105,9 +105,11 @@ ".xml": "text/xml"} FALLBACK_MIMETYPE: str = "application/octet-stream" + def bad_request(additional_details: str) -> types.WSGIResponse: return (client.BAD_REQUEST, (("Content-Type", "text/plain"),), f"Bad Request: {additional_details}", None) + def decode_request(configuration: "config.Configuration", environ: types.WSGIEnviron, text: bytes) -> str: """Try to magically decode ``text`` according to given ``environ``.""" diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 2fe818c01..16ba8025c 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -17,7 +17,6 @@ import base64 import io import json -import posixpath import re import socket import uuid @@ -105,7 +104,7 @@ def __init__(self, configuration: "config.Configuration") -> None: logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) - if (self.sharing_collection_by_map == False and self.sharing_collection_by_token == False): + if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False)): logger.info("sharing disabled as no feature is enabled") self._enabled = False return @@ -345,9 +344,9 @@ def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict # POST API def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: - #Late import to avoid circular dependency in config + # Late import to avoid circular dependency in config from radicale.app.base import Access - + """POST request. ``base_prefix`` is sanitized and never ends with "/". @@ -522,13 +521,12 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.error(api_info + ": unsupported " + key) return httputils.bad_request("Invalid value for User") - # check for mandatory parameters if 'PathMapped' not in request_data: if action == 'info': # ignored pass - elif action =="list": + elif action == "list": # optional pass else: @@ -711,7 +709,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token) answer['PathOrToken'] = token - # action: update + # action: update elif action == "update": logger.debug("TRACE/" + api_info + ": start") @@ -802,10 +800,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st elif action == "info": answer['Status'] = "success" if ShareType in ["all", "map"]: - answer['FeatureEnabledCollectionByMap'] = self.sharing_collection_by_map; + answer['FeatureEnabledCollectionByMap'] = self.sharing_collection_by_map answer['PermittedCreateCollectionByMap'] = True # TODO toggle per permission, default? if ShareType in ["all", "token"]: - answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token; + answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token answer['PermittedCreateCollectionByToken'] = True # TODO toggle per permission, default? # action: TOGGLE diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 0b1f8718f..aa93d11f5 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -106,10 +106,10 @@ def get_sharing(self, pass elif User is not None and row['User'] != User: pass - elif row['EnabledByOwner'] != True: + elif row['EnabledByOwner'] is not True: pass elif row['ShareType'] == "map": - if row['EnabledByUser'] != True: + if row['EnabledByUser'] is not True: pass else: found = True @@ -470,7 +470,7 @@ def _load_csv(self, file: str) -> bool: for fieldname in sharing.DB_FIELDS_V1: logger.debug("sharing database load check fieldname: %r", fieldname) if fieldname not in row: - logger.debug("sharing database is incompatible: %r", fil, filee) + logger.debug("sharing database is incompatible: %r", file) return False # convert txt to bool if self._lines > 0: diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index aae96ab8b..90e1c0406 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -27,6 +27,7 @@ DB_VERSION: str = "1" + class Sharing(sharing.BaseSharing): _sharing_db_path_ShareType: dict = {} @@ -109,10 +110,10 @@ def get_sharing(self, if User is not None and row['User'] != User: return None - elif row['EnabledByOwner'] != True: + elif row['EnabledByOwner'] is not True: return None elif row['ShareType'] == "map": - if row['EnabledByUser'] != True: + if row['EnabledByUser'] is not True: return None PathMapped = row['PathMapped'] @@ -143,8 +144,6 @@ def list_sharing(self, HiddenByOwner: Union[bool | None] = None, HiddenByUser: Union[bool | None] = None) -> list[dict]: """ retrieve sharing """ - row: dict - index = 0 result = [] logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, HiddenByOwner, HiddenByUser) @@ -262,7 +261,7 @@ def update_sharing(self, return {"status": "not-found"} # read content - with self._storage.acquire_lock("w", Owner,path=sharing_config_file): + with self._storage.acquire_lock("w", Owner, path=sharing_config_file): # read file with open(sharing_config_file, "rb") as fb: (version, row) = pickle.load(fb) @@ -292,7 +291,7 @@ def update_sharing(self, # update timestamp row["TimestampUpdated"] = Timestamp - logger.debug("TRACE/sharing/%s/update: adj row=%r", ShareType,row) + logger.debug("TRACE/sharing/%s/update: adj row=%r", ShareType, row) try: # write file @@ -361,7 +360,7 @@ def toggle_sharing(self, return {"status": "not-found"} # read content - with self._storage.acquire_lock("w", OwnerOrUser,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) @@ -440,7 +439,7 @@ def _load_csv(self, file: str) -> bool: for fieldname in sharing.DB_FIELDS_V1: logger.debug("sharing database load check fieldname: %r", fieldname) if fieldname not in row: - logger.debug("sharing database is incompatible: %r", fil, filee) + logger.debug("sharing database is incompatible: %r", file) return False # convert txt to bool if self._lines > 0: @@ -467,5 +466,3 @@ def _write_csv(self, file: str) -> bool: writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) writer.writerows(self._sharing_cache) return True - - diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 01406c1f4..b4c998a39 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -141,16 +141,16 @@ def test_sharing_api_base_with_auth(self) -> None: json_dict = {} _, headers, answer = self._sharing_api_json("all", "info", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) - assert answer_dict['FeatureEnabledCollectionByMap'] == True - assert answer_dict['FeatureEnabledCollectionByToken'] == False - assert answer_dict['PermittedCreateCollectionByMap'] == True - assert answer_dict['PermittedCreateCollectionByToken'] == True + assert answer_dict['FeatureEnabledCollectionByMap'] is True + assert answer_dict['FeatureEnabledCollectionByToken'] is False + assert answer_dict['PermittedCreateCollectionByMap'] is True + assert answer_dict['PermittedCreateCollectionByToken'] is True logging.info("\n*** check API hook: info/map") json_dict = {} _, headers, answer = self._sharing_api_json("map", "info", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) - assert answer_dict['FeatureEnabledCollectionByMap'] == True + assert answer_dict['FeatureEnabledCollectionByMap'] is True assert 'FeatureEnabledCollectionByToken' not in answer_dict assert 'PermittedCreateCollectionByToken' not in answer_dict @@ -174,7 +174,7 @@ def test_sharing_api_base_with_auth(self) -> None: json_dict = {} _, headers, answer = self._sharing_api_json("token", "info", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) - assert answer_dict['FeatureEnabledCollectionByToken'] == True + assert answer_dict['FeatureEnabledCollectionByToken'] is True assert 'FeatureEnabledCollectionByMap' not in answer_dict assert 'PermittedCreateCollectionByMap' not in answer_dict @@ -389,7 +389,7 @@ def test_sharing_api_token_basic(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['EnabledByOwner'] == False + assert answer_dict['Content'][0]['EnabledByOwner'] is False logging.info("\n*** enable token#2 (json->json)") json_dict = {} @@ -434,7 +434,7 @@ def test_sharing_api_token_basic(self) -> None: answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" assert answer_dict['Lines'] == 1 - assert answer_dict['Content'][0]['HiddenByOwner'] == False + assert answer_dict['Content'][0]['HiddenByOwner'] is False logging.info("\n*** delete token#2 (json->json)") json_dict = {} @@ -672,10 +672,10 @@ def test_sharing_api_map_usage(self) -> None: assert answer_dict['Content'][0]['ShareType'] == "map" assert answer_dict['Content'][0]['Owner'] == "owner" assert answer_dict['Content'][0]['User'] == "user" - assert answer_dict['Content'][0]['EnabledByOwner'] == False - assert answer_dict['Content'][0]['EnabledByUser'] == False - assert answer_dict['Content'][0]['HiddenByOwner'] == True - assert answer_dict['Content'][0]['HiddenByUser'] == True + assert answer_dict['Content'][0]['EnabledByOwner'] is False + assert answer_dict['Content'][0]['EnabledByUser'] is False + assert answer_dict['Content'][0]['HiddenByOwner'] is True + assert answer_dict['Content'][0]['HiddenByUser'] is True assert answer_dict['Content'][0]['Permissions'] == "r" logging.info("\n*** enable map by owner for owner (json->json) -> 403") @@ -1199,7 +1199,6 @@ def test_sharing_api_map_hidden(self) -> None: path_mapped2 = path_mapped_base + "calendarRH2.ics/" path_shared = path_user_base + "calendarRH-shared-by-owner.ics/" path_mapped_item = os.path.join(path_mapped, "event1.ics") - path_shared_item = os.path.join(path_shared, "event1.ics") path_user_item = os.path.join(path_user, "event2.ics") logging.info("\n*** prepare and test access") @@ -1322,7 +1321,6 @@ def test_sharing_api_map_hidden(self) -> None: response = responses[path_shared] assert isinstance(response, dict) - def test_sharing_api_map_propfind(self) -> None: """share-by-map API usage tests related to propfind.""" self.configure({"auth": {"type": "htpasswd", @@ -1925,7 +1923,6 @@ def test_sharing_api_list_filter(self) -> None: path_user1_shared1 = "/user1/calendarLFo1-shared.ics/" path_user1_shared2 = "/user1/calendarLFo2-shared.ics/" path_user2_shared1 = "/user2/calendarLFo1-shared.ics/" - path_user2_shared2 = "/user2/calendarLFo2-shared.ics/" path_owner1 = "/owner1/calendarLFo1.ics/" path_owner2 = "/owner2/calendarLFo2.ics/" @@ -2067,9 +2064,7 @@ def test_sharing_api_create_conflict(self) -> None: path_user1 = "/user1/calendarCCu1.ics/" path_user2 = "/user2/calendarCCu2.ics/" path_user1_shared1 = "/user1/calendarCCo1-shared.ics/" - path_user1_shared2 = "/user1/calendarCCo2-shared.ics/" path_user2_shared1 = "/user2/calendarCCo1-shared.ics/" - path_user2_shared2 = "/user2/calendarCCo2-shared.ics/" path_owner1 = "/owner1/calendarCCo1.ics/" path_owner2 = "/owner2/calendarCCo2.ics/" From 5f1e122e054e9fb802f8bca6e6326e0d4289a54a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:30:15 +0100 Subject: [PATCH 131/138] remove leftover code --- radicale/sharing/files.py | 46 --------------------------------------- 1 file changed, 46 deletions(-) diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index 90e1c0406..3be1df610 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -420,49 +420,3 @@ def _encode_path(self, path: str) -> str: def _decode_path(self, path: str) -> str: return urllib.parse.unquote(path) - - def _create_empty_csv(self, file: str) -> bool: - with open(file, 'w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) - writer.writeheader() - return True - - def _load_csv(self, file: str) -> bool: - logger.debug("sharing database load begin: %r", file) - with open(file, 'r', newline='') as csvfile: - reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1) - self._lines = 0 - for row in reader: - # logger.debug("sharing database load read: %r", row) - if self._lines == 0: - # header line, check - for fieldname in sharing.DB_FIELDS_V1: - logger.debug("sharing database load check fieldname: %r", fieldname) - if fieldname not in row: - logger.debug("sharing database is incompatible: %r", file) - return False - # convert txt to bool - if self._lines > 0: - for fieldname in sharing.DB_FIELDS_V1_BOOL: - row[fieldname] = config._convert_to_bool(row[fieldname]) - for fieldname in sharing.DB_FIELDS_V1_INT: - row[fieldname] = int(row[fieldname]) - # check for duplicates - dup = False - for row_cached in self._sharing_cache: - if row == row_cached: - dup = True - break - if dup: - continue - # logger.debug("sharing database load add: %r", row) - self._sharing_cache.append(row) - self._lines += 1 - logger.debug("sharing database load end: %r", file) - return True - - def _write_csv(self, file: str) -> bool: - with open(file, 'w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1) - writer.writerows(self._sharing_cache) - return True From d70f3f1f0f4a3addba89a5050113f0a3cf41203a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:33:38 +0100 Subject: [PATCH 132/138] cleanup leftovers --- radicale/sharing/files.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index 3be1df610..80dbb1bbb 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -14,13 +14,12 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . -import csv import os import pickle import urllib from typing import Union -from radicale import config, sharing +from radicale import sharing from radicale.log import logger """ File 'database' based sharing by token or map """ From 0e4c7dc3f69082d0a4853eadf6a8eb8c2635eaf0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:34:59 +0100 Subject: [PATCH 133/138] isort fixes --- radicale/__main__.py | 3 ++- radicale/sharing/__init__.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/radicale/__main__.py b/radicale/__main__.py index 50ea3e5e7..f5a76a920 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -33,7 +33,8 @@ from types import FrameType from typing import List, Optional, cast -from radicale import VERSION, config, item, log, server, sharing, storage, types +from radicale import (VERSION, config, item, log, server, sharing, storage, + types) from radicale.log import logger diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 16ba8025c..59ca0c280 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -26,7 +26,8 @@ from typing import Sequence, Union from urllib.parse import parse_qs -from radicale import config, httputils, pathutils, rights, storage, types, utils +from radicale import (config, httputils, pathutils, rights, storage, types, + utils) from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") From 8b505ce21829b7dff3ec6d9f3f487f70387a3025 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:59:47 +0100 Subject: [PATCH 134/138] prevent user overtaken from from user --- radicale/app/move.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/move.py b/radicale/app/move.py index e16d15a82..7d8c05fed 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -68,6 +68,7 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, return httputils.REMOTE_DESTINATION permissions_filter = None + to_user = user if self._sharing._enabled: # Sharing by token or map (if enabled) sharing = self._sharing.sharing_collection_resolver(path, user) @@ -85,7 +86,6 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] - to_user = user to_permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) From a6628ca3bed11dcfd28bc2d8b7f874f8492ec930 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 16:01:10 +0100 Subject: [PATCH 135/138] mypy fixes --- radicale/sharing/__init__.py | 17 +++++++++-------- radicale/tests/test_sharing.py | 1 + 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 59ca0c280..4ccf452e3 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -264,11 +264,11 @@ def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None return None # list sharings of type "map" - def sharing_collection_map_list(self, user: str, active: bool = True) -> Union[dict | None]: + def sharing_collection_map_list(self, user: str, 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: logger.debug("TRACE/sharing_by_map: not active") - return None + return [{}] # retrieve collections which are enabled and not hidden by owner+user if active: @@ -628,7 +628,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st if ShareType == "token": # check access Permissions - access = Access(self._rights, user, PathMapped) + access = Access(self._rights, user, str(PathMapped)) # PathMapped is mandatory if not access.check("r") and "i" not in access.permissions: logger.info("Add sharing-by-token: access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED @@ -640,9 +640,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st result = self.create_sharing( ShareType=ShareType, - PathOrToken=token, PathMapped=PathMapped, + PathOrToken=token, + PathMapped=str(PathMapped), # mandatory Owner=Owner, User=Owner, - Permissions=Permissions, + Permissions=str(Permissions), # mandantory EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, Timestamp=Timestamp) logger.debug("TRACE/" + api_info + ": result=%r", result) @@ -660,7 +661,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st User = str(User) # check access Permissions - access = Access(self._rights, Owner, PathMapped) + access = Access(self._rights, Owner, str(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) return httputils.NOT_ALLOWED @@ -683,10 +684,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st result = self.create_sharing( ShareType=ShareType, PathOrToken=PathOrToken, # verification above that it is not None - PathMapped=PathMapped, + PathMapped=str(PathMapped), # mandatory Owner=Owner, User=User, # verification above that it is not None - Permissions=Permissions, + Permissions=str(Permissions), # mandatory EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, EnabledByUser=EnabledByUser, HiddenByUser=HiddenByUser, Timestamp=Timestamp) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index b4c998a39..306e8ae27 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -1801,6 +1801,7 @@ def test_sharing_api_update(self) -> None: "response_content_on_debug": "False", "request_content_on_debug": "True"}, "rights": {"type": "owner_only"}}) + form_array: Sequence[str] json_dict: dict path_mapped1 = "/owner/calendar1U.ics/" From 80a1c3067d6e8566988661d97c2cb21c71b0b458 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 16:07:50 +0100 Subject: [PATCH 136/138] python 3.9 fixes --- radicale/app/propfind.py | 4 +-- radicale/app/proppatch.py | 2 +- radicale/app/report.py | 4 +-- radicale/sharing/__init__.py | 61 +++++++++++++++++----------------- radicale/sharing/csv.py | 40 +++++++++++----------- radicale/sharing/files.py | 40 +++++++++++----------- radicale/sharing/none.py | 4 +-- radicale/tests/test_sharing.py | 6 ++-- 8 files changed, 80 insertions(+), 81 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 3f1a431b0..868f10547 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -36,7 +36,7 @@ def xml_propfind(base_prefix: str, path: str, xml_request: Optional[ET.Element], allowed_items: Iterable[Tuple[types.CollectionOrItem, str]], - user: str, encoding: str, max_resource_size: int, sharing: Union[dict | None] = None) -> Optional[ET.Element]: + user: str, encoding: str, max_resource_size: int, sharing: Union[dict, None] = None) -> Optional[ET.Element]: """Read and answer PROPFIND requests. Read rfc4918-9.1 for info. @@ -81,7 +81,7 @@ def xml_propfind(base_prefix: str, path: str, def xml_propfind_response( base_prefix: str, path: str, item: types.CollectionOrItem, props: Sequence[str], user: str, encoding: str, max_resource_size: int, write: bool = False, - propname: bool = False, allprop: bool = False, sharing: Union[dict | None] = None) -> ET.Element: + propname: bool = False, allprop: bool = False, sharing: Union[dict, None] = None) -> ET.Element: """Build and return a PROPFIND response.""" if propname and allprop or (props and (propname or allprop)): raise ValueError("Only use one of props, propname and allprops") diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 4ebdb6503..9c527c12b 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -37,7 +37,7 @@ def xml_proppatch(base_prefix: str, path: str, xml_request: Optional[ET.Element], - collection: storage.BaseCollection, sharing: Union[dict | None] = None) -> ET.Element: + collection: storage.BaseCollection, sharing: Union[dict, None] = None) -> ET.Element: """Read and answer PROPPATCH requests. Read rfc4918-9.2 for info. diff --git a/radicale/app/report.py b/radicale/app/report.py index cfb9458f1..52dfc356d 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -150,7 +150,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection, encoding: str, unlock_storage_fn: Callable[[], None], max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "", - sharing: Union[dict | None] = None) -> Tuple[int, ET.Element]: + sharing: Union[dict, None] = None) -> Tuple[int, ET.Element]: """Read and answer REPORT requests that return XML. Read rfc3253-3.6 for info. @@ -705,7 +705,7 @@ def _find_overridden( def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), - found_item: bool = True, sharing: Union[dict | None] = None) -> ET.Element: + found_item: bool = True, sharing: Union[dict, None] = None) -> ET.Element: response = ET.Element(xmlutils.make_clark("D:response")) href_element = ET.Element(xmlutils.make_clark("D:href")) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 4ccf452e3..e65530a40 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -83,7 +83,6 @@ def load(configuration: "config.Configuration") -> "BaseSharing": class BaseSharing: - configuration: config.Configuration _storage: storage.BaseStorage _rights: rights.BaseRights _enabled: bool = False @@ -144,22 +143,22 @@ def verify_database(self) -> bool: return False def list_sharing(self, - OwnerOrUser: Union[str | None] = None, - ShareType: Union[str | None] = None, - PathOrToken: Union[str | None] = None, - PathMapped: Union[str | None] = None, - User: Union[str | None] = None, - EnabledByOwner: Union[bool | None] = None, - EnabledByUser: Union[bool | None] = None, - HiddenByOwner: Union[bool | None] = None, - HiddenByUser: Union[bool | None] = None) -> list[dict]: + OwnerOrUser: Union[str, None] = None, + ShareType: Union[str, None] = None, + PathOrToken: Union[str, None] = None, + PathMapped: Union[str, None] = None, + User: Union[str, None] = None, + EnabledByOwner: Union[bool, None] = None, + EnabledByUser: Union[bool, None] = None, + HiddenByOwner: Union[bool, None] = None, + HiddenByUser: Union[bool, None] = None) -> list[dict]: """ retrieve sharing """ return [] def get_sharing(self, ShareType: str, PathOrToken: str, - User: Union[str | None] = None) -> Union[dict | None]: + User: Union[str, None] = None) -> Union[dict, None]: """ retrieve sharing target and attributes by map """ return {"status": "not-implemented"} @@ -177,12 +176,12 @@ def create_sharing(self, def update_sharing(self, ShareType: str, PathOrToken: str, - Owner: Union[str | None] = None, - 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, + Owner: Union[str, None] = None, + 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: """ update sharing """ return {"status": "not-implemented"} @@ -191,7 +190,7 @@ def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, - PathMapped: Union[str | None] = None) -> dict: + PathMapped: Union[str, None] = None) -> dict: """ delete sharing """ return {"status": "not-implemented"} @@ -200,8 +199,8 @@ def toggle_sharing(self, PathOrToken: str, OwnerOrUser: str, Action: str, - PathMapped: Union[str | None] = None, - User: Union[str | None] = None, + PathMapped: Union[str, None] = None, + User: Union[str, None] = None, Timestamp: int = 0) -> dict: """ toggle sharing """ return {"status": "not-implemented"} @@ -239,7 +238,7 @@ def verify(self) -> bool: logger.info("sharing database verification content successful") return True - def sharing_collection_resolver(self, path: str, user: str) -> Union[dict | None]: + def sharing_collection_resolver(self, path: str, user: str) -> Union[dict, None]: """ returning dict with PathMapped, Owner, Permissions or None if not found""" if self.sharing_collection_by_token: result = self.sharing_collection_by_token_resolver(path) @@ -291,7 +290,7 @@ def sharing_collection_map_list(self, user: str, active: bool = True) -> list[di return shared_collection_list # internal sharing functions - def sharing_collection_by_token_resolver(self, path) -> Union[dict | None]: + def sharing_collection_by_token_resolver(self, path) -> Union[dict, None]: """ returning dict with PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_token: logger.debug("TRACE/sharing_by_token: check path: %r", path) @@ -314,7 +313,7 @@ def sharing_collection_by_token_resolver(self, path) -> Union[dict | None]: logger.debug("TRACE/sharing_by_token: not active") return None - def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict | None]: + def sharing_collection_by_map_resolver(self, path: str, user: str) -> Union[dict, None]: """ returning dict with PathMapped, Owner, Permissions or None if invalid""" if self.sharing_collection_by_map: logger.debug("TRACE/sharing/resolver/map: check path: %r", path) @@ -481,15 +480,15 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st return httputils.bad_request("Output format not supported") # parameters default - PathOrToken: Union[str | None] = None - PathMapped: Union[str | None] = None + 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 - EnabledByOwner: Union[bool | None] = None - HiddenByOwner: Union[bool | None] = None - EnabledByUser: Union[bool | None] = None - HiddenByUser: Union[bool | None] = None + User: Union[str, None] = None + Permissions: Union[str, None] = None # no permissions by default + EnabledByOwner: Union[bool, None] = None + HiddenByOwner: Union[bool, None] = None + EnabledByUser: Union[bool, None] = None + HiddenByUser: Union[bool, None] = None # parameters sanity check for key in request_data: diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index aa93d11f5..f79899bc5 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -73,7 +73,7 @@ def init_database(self) -> bool: self._sharing_db_file = sharing_db_file return True - def get_database_info(self) -> Union[dict | None]: + def get_database_info(self) -> Union[dict, None]: database_info = {'type': "csv"} return database_info @@ -88,7 +88,7 @@ def verify_database(self) -> bool: def get_sharing(self, ShareType: str, PathOrToken: str, - User: Union[str | None] = None) -> Union[dict | None]: + User: Union[str, None] = None) -> Union[dict, None]: """ retrieve sharing target and attributes by map """ # Lookup logger.debug("TRACE/sharing: lookup ShareType=%r PathOrToken=%r User=%r)", ShareType, PathOrToken, User) @@ -137,15 +137,15 @@ def get_sharing(self, return None def list_sharing(self, - OwnerOrUser: Union[str | None] = None, - ShareType: Union[str | None] = None, - PathOrToken: Union[str | None] = None, - PathMapped: Union[str | None] = None, - User: Union[str | None] = None, - EnabledByOwner: Union[bool | None] = None, - EnabledByUser: Union[bool | None] = None, - HiddenByOwner: Union[bool | None] = None, - HiddenByUser: Union[bool | None] = None) -> list[dict]: + OwnerOrUser: Union[str, None] = None, + ShareType: Union[str, None] = None, + PathOrToken: Union[str, None] = None, + PathMapped: Union[str, None] = None, + User: Union[str, None] = None, + EnabledByOwner: Union[bool, None] = None, + EnabledByUser: Union[bool, None] = None, + HiddenByOwner: Union[bool, None] = None, + HiddenByUser: Union[bool, None] = None) -> list[dict]: """ retrieve sharing """ row: dict index = 0 @@ -247,12 +247,12 @@ def create_sharing(self, def update_sharing(self, ShareType: str, PathOrToken: str, - Owner: Union[str | None] = None, - 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, + Owner: Union[str, None] = None, + 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: """ update sharing """ logger.debug("TRACE/sharing/%s/update: PathOrToken=%r Owner=%r PathMapped=%r", ShareType, PathOrToken, Owner, PathMapped) @@ -315,7 +315,7 @@ def update_sharing(self, def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, - PathMapped: Union[str | None] = None) -> dict: + PathMapped: Union[str, None] = None) -> dict: """ delete sharing """ logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r PathMapped=%r", ShareType, PathOrToken, Owner, PathMapped) @@ -365,8 +365,8 @@ def toggle_sharing(self, PathOrToken: str, OwnerOrUser: str, Action: str, - PathMapped: Union[str | None] = None, - User: Union[str | None] = None, + PathMapped: Union[str, None] = None, + User: Union[str, None] = None, Timestamp: int = 0) -> dict: """ toggle sharing """ row: dict diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index 80dbb1bbb..ab184557b 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -73,7 +73,7 @@ def init_database(self) -> bool: logger.info("sharing database path for %r successfully created: %r", ShareType, path) return True - def get_database_info(self) -> Union[dict | None]: + def get_database_info(self) -> Union[dict, None]: database_info = {'type': "files"} return database_info @@ -88,7 +88,7 @@ def verify_database(self) -> bool: def get_sharing(self, ShareType: str, PathOrToken: str, - User: Union[str | None] = None) -> Union[dict | None]: + User: Union[str, None] = None) -> Union[dict, None]: """ retrieve sharing target and attributes by map """ # Lookup logger.debug("TRACE/sharing/%s/get: PathOrToken=%r User=%r)", ShareType, PathOrToken, User) @@ -133,15 +133,15 @@ def get_sharing(self, return None def list_sharing(self, - OwnerOrUser: Union[str | None] = None, - ShareType: Union[str | None] = None, - PathOrToken: Union[str | None] = None, - PathMapped: Union[str | None] = None, - User: Union[str | None] = None, - EnabledByOwner: Union[bool | None] = None, - EnabledByUser: Union[bool | None] = None, - HiddenByOwner: Union[bool | None] = None, - HiddenByUser: Union[bool | None] = None) -> list[dict]: + OwnerOrUser: Union[str, None] = None, + ShareType: Union[str, None] = None, + PathOrToken: Union[str, None] = None, + PathMapped: Union[str, None] = None, + User: Union[str, None] = None, + EnabledByOwner: Union[bool, None] = None, + EnabledByUser: Union[bool, None] = None, + HiddenByOwner: Union[bool, None] = None, + HiddenByUser: Union[bool, None] = None) -> list[dict]: """ retrieve sharing """ result = [] @@ -244,12 +244,12 @@ def create_sharing(self, def update_sharing(self, ShareType: str, PathOrToken: str, - Owner: Union[str | None] = None, - 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, + Owner: Union[str, None] = None, + 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: """ update sharing """ logger.debug("TRACE/sharing/%s/update: PathOrToken=%r Owner=%r User=%r", ShareType, PathOrToken, Owner, User) @@ -305,7 +305,7 @@ def update_sharing(self, def delete_sharing(self, ShareType: str, PathOrToken: str, Owner: str, - PathMapped: Union[str | None] = None) -> dict: + PathMapped: Union[str, None] = None) -> dict: """ delete sharing """ logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r", ShareType, PathOrToken, Owner) @@ -341,8 +341,8 @@ def toggle_sharing(self, PathOrToken: str, OwnerOrUser: str, Action: str, - PathMapped: Union[str | None] = None, - User: Union[str | None] = None, + PathMapped: Union[str, None] = None, + User: Union[str, None] = None, Timestamp: int = 0) -> dict: """ toggle sharing """ row: dict diff --git a/radicale/sharing/none.py b/radicale/sharing/none.py index 5f7112308..1264602a4 100644 --- a/radicale/sharing/none.py +++ b/radicale/sharing/none.py @@ -26,13 +26,13 @@ def init_database(self) -> bool: """ dummy initialization """ return False - def get_sharing_collection_by_token(self, token: str) -> Union[dict | None]: + def get_sharing_collection_by_token(self, token: str) -> Union[dict, None]: """ retrieve target and attributs by token """ # default logger.debug("TRACE/sharing_by_token: 'none' cannot provide any map for token: %r", token) return None - def get_sharing_collection_by_map(self, path) -> Union[dict | None]: + def get_sharing_collection_by_map(self, path) -> Union[dict, None]: """ retrieve target and attributs by map """ logger.debug("TRACE/sharing_by_map: 'none' cannot provide any map for path: %r", path) return {"mapped": False} diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 306e8ae27..5bcb89b6c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -48,13 +48,13 @@ def setup_method(self) -> None: f.write(htpasswd_content) # Helper functions - def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str | None], data: str, content_type: str, accept: Union[str | None]) -> Tuple[int, Dict[str, str], str]: + def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str, None], data: str, content_type: str, accept: Union[str, None]) -> Tuple[int, Dict[str, str], str]: path_base = "/.sharing/v1/" + sharing_type + "/" _, headers, answer = self.request("POST", path_base + action, check=check, login=login, data=data, content_type=content_type, accept=accept) logging.info("received answer:\n%s", "\n".join(answer.splitlines())) return _, headers, answer - def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str | None], form_array: Sequence[str], accept: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: Union[str, None], form_array: Sequence[str], accept: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: data = "&".join(form_array) content_type = "application/x-www-form-urlencoded" if accept is None: @@ -62,7 +62,7 @@ def _sharing_api_form(self, sharing_type: str, action: str, check: int, login: U _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept) return _, headers, answer - def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str | None], json_dict: dict, accept: Union[str | None] = None) -> Tuple[int, Dict[str, str], str]: + def _sharing_api_json(self, sharing_type: str, action: str, check: int, login: Union[str, None], json_dict: dict, accept: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: data = json.dumps(json_dict) content_type = "application/json" if accept is None: From 2c2f2bd507f0ea0b6cf3116cea2fd48d0968f4e6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 16:19:18 +0100 Subject: [PATCH 137/138] extend copyright --- radicale/httputils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/httputils.py b/radicale/httputils.py index 0e829c6b9..d2829ea82 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by From 74aaf09bd20661242cdfa61cabc1ea215c562c22 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sun, 22 Feb 2026 22:52:28 +0100 Subject: [PATCH 138/138] Add UI for share by token --- integ_tests/__init__.py | 0 integ_tests/common.py | 95 ++++++++++ integ_tests/test_basic_operation.py | 90 +--------- integ_tests/test_sharing.py | 42 +++++ .../web/internal_data/CollectionsScene.js | 13 ++ radicale/web/internal_data/LoginScene.js | 4 +- .../web/internal_data/ShareCollectionScene.js | 164 ++++++++++++++++++ radicale/web/internal_data/api.js | 131 +++++++++++++- .../web/internal_data/css/icons/credits.md | 21 +++ radicale/web/internal_data/css/icons/eye.svg | 4 + radicale/web/internal_data/css/icons/key.svg | 1 + .../web/internal_data/css/icons/repeat.svg | 1 + .../web/internal_data/css/icons/share.svg | 1 + radicale/web/internal_data/css/main.css | 42 +++++ radicale/web/internal_data/index.html | 38 ++++ 15 files changed, 564 insertions(+), 83 deletions(-) create mode 100644 integ_tests/__init__.py create mode 100644 integ_tests/common.py create mode 100644 integ_tests/test_sharing.py create mode 100644 radicale/web/internal_data/ShareCollectionScene.js create mode 100644 radicale/web/internal_data/css/icons/credits.md create mode 100755 radicale/web/internal_data/css/icons/eye.svg create mode 100644 radicale/web/internal_data/css/icons/key.svg create mode 100644 radicale/web/internal_data/css/icons/repeat.svg create mode 100644 radicale/web/internal_data/css/icons/share.svg diff --git a/integ_tests/__init__.py b/integ_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integ_tests/common.py b/integ_tests/common.py new file mode 100644 index 000000000..465dd5eec --- /dev/null +++ b/integ_tests/common.py @@ -0,0 +1,95 @@ +import os +import pathlib +import socket +import subprocess +import sys +import time +from typing import Any, Generator + +from playwright.sync_api import Page + + +def get_free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def start_radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + port = get_free_port() + config_path = tmp_path / "config" + user_path = tmp_path / "users" + storage_path = tmp_path / "collections" + + # Create a local config file + with open(config_path, "w") as f: + f.write( + f"""[server] +hosts = 127.0.0.1:{port} +[storage] +filesystem_folder = {storage_path} +[auth] +type = htpasswd +htpasswd_filename = {user_path} +[web] +type = internal +[sharing] +type = csv +collection_by_map = true +collection_by_token = true +""" + ) + with open(user_path, "w") as f: + f.write( + """admin:adminpassword +""" + ) + + env = os.environ.copy() + # Ensure the radicale package is in PYTHONPATH + # Assuming this test file is in /integ_tests/ + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") + + # Run the server + process = subprocess.Popen( + [sys.executable, "-m", "radicale", "--config", str(config_path)], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Wait for the server to start listening + start_time = time.time() + while time.time() - start_time < 10: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except (OSError, ConnectionRefusedError): + if process.poll() is not None: + _stdout, stderr = process.communicate() + raise RuntimeError( + f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" + ) + time.sleep(0.1) + else: + process.terminate() + process.wait() + raise RuntimeError("Timeout waiting for Radicale to start") + + yield f"http://127.0.0.1:{port}" + + # Cleanup + process.terminate() + process.wait() + + +def login(page: Page, radicale_server: str) -> None: + page.goto(radicale_server) + page.fill('#loginscene input[data-name="user"]', "admin") + page.fill('#loginscene input[data-name="password"]', "adminpassword") + page.click('button:has-text("Next")') + +def create_collection(page: Page, radicale_server: str) -> None: + page.click('.fabcontainer a[data-name="new"]') + page.click('#createcollectionscene button[data-name="submit"]') \ No newline at end of file diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py index e0d07994b..def5dc079 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -1,88 +1,20 @@ -import os -import socket -import subprocess -import sys -import time +import pathlib +from typing import Any, Generator import pytest from playwright.sync_api import Page, expect - -def get_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] +from integ_tests.common import login, start_radicale_server @pytest.fixture -def radicale_server(tmp_path): - port = get_free_port() - config_path = tmp_path / "config" - user_path = tmp_path / "users" - storage_path = tmp_path / "collections" - - # Create a local config file - with open(config_path, "w") as f: - f.write( - f"""[server] -hosts = 127.0.0.1:{port} -[storage] -filesystem_folder = {storage_path} -[auth] -type = htpasswd -htpasswd_filename = {user_path} -[web] -type = internal -""" - ) - with open(user_path, "w") as f: - f.write( - """admin:adminpassword -""" - ) - - env = os.environ.copy() - # Ensure the radicale package is in PYTHONPATH - # Assuming this test file is in /integ_tests/ - repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") - - # Run the server - process = subprocess.Popen( - [sys.executable, "-m", "radicale", "--config", str(config_path)], - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) - # Wait for the server to start listening - start_time = time.time() - while time.time() - start_time < 10: - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.1): - break - except (OSError, ConnectionRefusedError): - if process.poll() is not None: - stdout, stderr = process.communicate() - raise RuntimeError( - f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" - ) - time.sleep(0.1) - else: - process.terminate() - process.wait() - raise RuntimeError("Timeout waiting for Radicale to start") - yield f"http://127.0.0.1:{port}" - - # Cleanup - process.terminate() - process.wait() - - -def test_index_html_loads(page: Page, radicale_server): +def test_index_html_loads(page: Page, radicale_server: str) -> None: """Test that the index.html loads from the server.""" - console_msgs = [] + console_msgs: list[str] = [] page.on("console", lambda msg: console_msgs.append(msg.text)) page.goto(radicale_server) expect(page).to_have_title("Radicale Web Interface") @@ -90,13 +22,9 @@ def test_index_html_loads(page: Page, radicale_server): assert len(console_msgs) == 0 -def test_user_login_works(page: Page, radicale_server): +def test_user_login_works(page: Page, radicale_server: str) -> None: """Test that the login form works.""" - page.goto(radicale_server) - # Fill in the login form - page.fill('#loginscene input[data-name="user"]', "admin") - page.fill('#loginscene input[data-name="password"]', "adminpassword") - page.click('button:has-text("Next")') + login(page, radicale_server) # After login, we should see the collections list (which is empty) expect( diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py new file mode 100644 index 000000000..cb66ddf55 --- /dev/null +++ b/integ_tests/test_sharing.py @@ -0,0 +1,42 @@ +import pathlib +from typing import Any, Generator + +import pytest +from playwright.sync_api import Page, expect + +from integ_tests.common import create_collection, login, start_radicale_server + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) + + +def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) + + page.click('button[data-name="sharebytoken_ro"]') + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(1) + # TODO: Check for R/O state + page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) + page.click('button[data-name="sharebytoken_rw"]') + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(1) + # TODO: Check for R/W state + page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) diff --git a/radicale/web/internal_data/CollectionsScene.js b/radicale/web/internal_data/CollectionsScene.js index 214c3a435..2c8b05fa1 100644 --- a/radicale/web/internal_data/CollectionsScene.js +++ b/radicale/web/internal_data/CollectionsScene.js @@ -20,6 +20,7 @@ import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; +import { CreateShareCollectionScene } from "./ShareCollectionScene.js"; import { UploadCollectionScene } from "./UploadCollectionScene.js"; import { DeleteCollectionScene } from "./DeleteCollectionScene.js"; import { LoadingScene } from "./LoadingScene.js"; @@ -78,6 +79,16 @@ export function CollectionsScene(user, password, collection, onerror) { return false; } + function onshare(collection) { + try { + let share_collection_scene = new CreateShareCollectionScene(user, password, collection); + push_scene(share_collection_scene, false); + } catch(err) { + console.error(err); + } + return false; + } + function ondelete(collection) { try { let delete_collection_scene = new DeleteCollectionScene(user, password, collection); @@ -102,6 +113,7 @@ export function CollectionsScene(user, password, collection, onerror) { let color_form = node.querySelector("[data-name=color]"); let delete_btn = node.querySelector("[data-name=delete]"); let edit_btn = node.querySelector("[data-name=edit]"); + let share_btn = node.querySelector("[data-name=share]"); let download_btn = node.querySelector("[data-name=download]"); if (collection.color) { color_form.style.background = collection.color; @@ -144,6 +156,7 @@ export function CollectionsScene(user, password, collection, onerror) { } delete_btn.onclick = function() {return ondelete(collection);}; edit_btn.onclick = function() {return onedit(collection);}; + share_btn.onclick = function() {return onshare(collection);}; node.classList.remove("hidden"); nodes.push(node); template.parentNode.insertBefore(node, template); diff --git a/radicale/web/internal_data/LoginScene.js b/radicale/web/internal_data/LoginScene.js index 1314df087..63c68f2f0 100644 --- a/radicale/web/internal_data/LoginScene.js +++ b/radicale/web/internal_data/LoginScene.js @@ -20,8 +20,9 @@ import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; import { LoadingScene } from "./LoadingScene.js"; -import { get_principal } from "./api.js"; +import { get_principal, discover_server_features } from "./api.js"; import { CollectionsScene } from "./CollectionsScene.js"; +import { maybe_enable_sharing_options } from "./ShareCollectionScene.js"; /** * @constructor @@ -89,6 +90,7 @@ export function LoginScene() { error = error1; user = saved_user; }); + discover_server_features(saved_user, password, maybe_enable_sharing_options); push_scene(collections_scene, true); } }); diff --git a/radicale/web/internal_data/ShareCollectionScene.js b/radicale/web/internal_data/ShareCollectionScene.js new file mode 100644 index 000000000..37a830bf9 --- /dev/null +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -0,0 +1,164 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { + add_share_by_token, + delete_share_by_token, + reload_sharing_list, + server_features, +} from "./api.js"; +import { pop_scene, scene_stack } from "./scene_manager.js"; + +/** + * @constructor + * @implements {Scene} + * @param {string} user + * @param {string} password + * @param {Collection} collection The collection on which to edit sharing setting. Must exist. + */ +export function CreateShareCollectionScene(user, password, collection) { + /** @type {?number} */ let scene_index = null; + + let html_scene = document.getElementById("sharecollectionscene"); + + let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + let share_by_token_btn_ro = html_scene.querySelector( + "[data-name=sharebytoken_ro]", + ); + let share_by_token_btn_rw = html_scene.querySelector( + "[data-name=sharebytoken_rw]", + ); + + let title = html_scene.querySelector("[data-name=title]"); + + function oncancel() { + try { + pop_scene(scene_index - 1); + } catch (err) { + console.error(err); + } + return false; + } + + function onsharebytoken_rw() { + add_share_by_token(user, password, collection, "rw", function () { + update_share_list(user, password, collection); + }); + } + + function onsharebytoken_ro() { + add_share_by_token(user, password, collection, "r", function () { + update_share_list(user, password, collection); + }); + } + + this.show = function () { + this.release(); + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + cancel_btn.onclick = oncancel; + if (server_features["sharing"]["FeatureEnabledCollectionByToken"]) { + share_by_token_btn_ro.onclick = onsharebytoken_ro; + share_by_token_btn_rw.onclick = onsharebytoken_rw; + } else { + share_by_token_btn_ro.parentElement.removeChild(share_by_token_btn_ro); + share_by_token_btn_rw.parentElement.removeChild(share_by_token_btn_rw); + } + title.textContent = collection.displayname || collection.href; + update_share_list(user, password, collection); + }; + this.hide = function () { + html_scene.classList.add("hidden"); + cancel_btn.onclick = null; + }; + this.release = function () { + scene_index = null; + }; +} + +function update_share_list(user, password, collection) { + let share_rows = document.querySelectorAll( + "[data-name=sharetokenrowtemplate]", + ); + share_rows.forEach(function (row) { + if (!row.classList.contains("hidden")) { + row.parentNode.removeChild(row); + } + }); + + reload_sharing_list(user, password, collection, function (response) { + add_share_rows(user, password, collection, response["Content"] || []); + }); +} + +function add_share_rows(user, password, collection, shares) { + let template = document.querySelector("[data-name=sharetokenrowtemplate]"); + shares.forEach(function (share) { + let pathortoken = share["PathOrToken"] || ""; + let pathmapped = share["PathMapped"] || ""; + if ( + collection.href.includes(pathmapped) || + collection.href.includes(pathortoken) + ) { + let node = template.cloneNode(true); + node.classList.remove("hidden"); + node.querySelector("[data-name=pathortoken]").value = pathortoken; + let permissions = (share["Permissions"] || "").toLowerCase(); + if (permissions === "rw") { + node + .querySelector("[data-name=ro]") + .parentNode.removeChild(node.querySelector("[data-name=ro]")); + } else if (permissions === "r") { + node + .querySelector("[data-name=rw]") + .parentNode.removeChild(node.querySelector("[data-name=rw]")); + } else { + console.warn("Unknown permissions", permissions); + } + node.querySelector("[data-name=delete]").onclick = function () { + delete_share_by_token( + user, + password, + share["PathOrToken"], + function () { + update_share_list(user, password, collection); + }, + ); + }; + + template.parentNode.insertBefore(node, template); + } + }); +} + +export function maybe_enable_sharing_options() { + if (!server_features["sharing"]) return; + let map_is_enabled = + server_features["sharing"]["FeatureEnabledCollectionByMap"] || false; + let token_is_enabled = + server_features["sharing"]["FeatureEnabledCollectionByToken"] || false; + if (map_is_enabled || token_is_enabled) { + let share_options = document.querySelectorAll("[data-name=shareoption]"); + for (let i = 0; i < share_options.length; i++) { + let share_option = share_options[i]; + share_option.classList.remove("hidden"); + } + } +} diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index 3a84dfb4f..eaf818acf 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -22,6 +22,8 @@ import { Collection, CollectionType } from "./models.js"; import { SERVER, ROOT_PATH, COLOR_RE } from "./constants.js"; import { escape_xml } from "./utils.js"; +export let server_features = {}; + /** * Find the principal collection. * @param {string} user @@ -339,4 +341,131 @@ export function create_collection(user, password, collection, callback) { */ export function edit_collection(user, password, collection, callback) { return create_edit_collection(user, password, collection, false, callback); -} \ No newline at end of file +} +/* Sharing API */ + +function call_sharing_api( + user, + password, + path, + body, + on_success, + on_not_found = null, + on_error = null, +) { + let request = new XMLHttpRequest(); + request.open( + "POST", + SERVER + ROOT_PATH + ".sharing/v1/" + path, + true, + user, + encodeURIComponent(password), + ); + request.onreadystatechange = function () { + if (request.readyState !== 4) { + return; + } + if (200 <= request.status && request.status < 300) { + on_success(request.responseText); + } else if (request.status === 404) { + if (on_not_found) { + on_not_found(); + } else if (on_error) { + on_error("Not found"); + } else { + console.error("Not found"); + } + } else { + if (on_error) { + on_error(request.status + " " + request.statusText); + } else { + console.error(request.status + " " + request.statusText); + } + } + }; + request.setRequestHeader("Accept", "application/json"); + request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + request.send(body ? JSON.stringify(body) : null); + return request; +} + +export function discover_server_features(user, password, callback) { + call_sharing_api( + user, + password, + "all/info", + {}, + function (response) { + server_features["sharing"] = JSON.parse(response); + callback(); + }, + function () { + // sharing is disabled on the server + server_features["sharing"] = {}; + callback(); + }, + function (error) { + console.error("Failed to discover sharing features: " + error); + }, + ); +} + +export function reload_sharing_list(user, password, collection, callback) { + call_sharing_api( + user, + password, + "all/list", + { PathMapped: collection.href }, + function (response) { + callback(JSON.parse(response)); + }, + ); +} + +export function add_share_by_token( + user, + password, + collection, + permissions, + callback, +) { + call_sharing_api( + user, + password, + "token/create", + { + PathMapped: collection.href, + Permissions: permissions, + }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create share token: " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); +} + +export function delete_share_by_token( + user, + password, + token, + callback, +) { + call_sharing_api( + user, + password, + "token/delete", + { PathOrToken: token }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create delete token " + token + ": " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); +} diff --git a/radicale/web/internal_data/css/icons/credits.md b/radicale/web/internal_data/css/icons/credits.md new file mode 100644 index 000000000..39948ae6a --- /dev/null +++ b/radicale/web/internal_data/css/icons/credits.md @@ -0,0 +1,21 @@ +# Credits + +## share.svg + +* +* MIT License + +## key.svg + +* +* MIT License + +## repeat.svg + +* +* MIT License + +## eye.svg + +* +* MIT License diff --git a/radicale/web/internal_data/css/icons/eye.svg b/radicale/web/internal_data/css/icons/eye.svg new file mode 100755 index 000000000..65d96f767 --- /dev/null +++ b/radicale/web/internal_data/css/icons/eye.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/key.svg b/radicale/web/internal_data/css/icons/key.svg new file mode 100644 index 000000000..e778e74eb --- /dev/null +++ b/radicale/web/internal_data/css/icons/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/repeat.svg b/radicale/web/internal_data/css/icons/repeat.svg new file mode 100644 index 000000000..c7657b08e --- /dev/null +++ b/radicale/web/internal_data/css/icons/repeat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/share.svg b/radicale/web/internal_data/css/icons/share.svg new file mode 100644 index 000000000..09b1c7bcd --- /dev/null +++ b/radicale/web/internal_data/css/icons/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 1e2dcef01..8c4086f9e 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -39,6 +39,14 @@ main{ color: #484848; } +.container h2{ + margin: 0; + width: 100%; + text-align: left; + color: #484848; + font-size: 1.5em; +} + #loginscene .infcloudlink{ margin: 0; width: 100%; @@ -303,6 +311,29 @@ main{ filter: invert(1); } +.small_icon{ + width: 1em; + height: 1em; + filter: invert(1); +} + +.med_icon{ + width: 1.5em; + height: 1.5em; +} + +.badge_icon{ + width: 1em; + height: 1em; + position: absolute; + top: -5px; + right: -5px; + filter: invert(1); + border-radius: 50%; + background-color: white; +} + + .smalltext{ font-size: 75% !important; } @@ -345,6 +376,7 @@ button{ margin-left: 10px; background: black; cursor: pointer; + position: relative; } input, select{ @@ -360,6 +392,10 @@ input, select{ outline: none !important; } +input.inline { + margin-bottom: 0 !important; +} + input[type=text], input[type=password]{ width: calc(100% - 30px); } @@ -414,6 +450,12 @@ button.blue:active, a.blue:active{ cursor: pointer !important; } +button.inline { + padding-inline: 1px; + min-width: 2em; + margin: 0; +} + @media only screen and (max-width: 600px) { #collectionsscene{ flex-direction: column !important; diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 16ef91d3f..8689fc38a 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -90,6 +90,11 @@

Title

✏️ +
  • ❌ @@ -133,6 +138,39 @@

    Edit Collection


    + +