From 3eb1669bdefb51190f9f6fb98e581a37c3e6a2fd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:23:59 +0100 Subject: [PATCH 01/63] add new function (common used): parent_path --- radicale/pathutils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/radicale/pathutils.py b/radicale/pathutils.py index 3193e4a2..09b2206e 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -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 be16a4168181f8ff1186a0870897643b80d56d88 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:24:28 +0100 Subject: [PATCH 02/63] extend copyright --- radicale/pathutils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/pathutils.py b/radicale/pathutils.py index 09b2206e..488dbfc0 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 From 54e33d8ca25bb3bc712e04112c75b4599cfb6475 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:27:14 +0100 Subject: [PATCH 03/63] add optional permissions filter --- radicale/app/base.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index aa0af7a2..33a9ddb5 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -20,11 +20,12 @@ import logging 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, 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 @@ -106,15 +107,19 @@ 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 + def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_filter: Union[str | None] = None ) -> None: 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 + permissions_filtered = intersect(self.permissions, permissions_filter) + self.permissions = permissions_filtered self._parent_permissions = None @property @@ -124,6 +129,9 @@ class Access: 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 2e1f54c7a871fb2e0e45d7ea17c072ae0581dbd9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:27:38 +0100 Subject: [PATCH 04/63] 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 33a9ddb5..a9c9b3b5 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 6a43af2cf20c1600c4fc40490fd85f7c8ab88c92 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:30:28 +0100 Subject: [PATCH 05/63] use optional permissions filter --- radicale/app/delete.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 2201e998..a5beb3f3 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -57,7 +57,8 @@ 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) + permissions_filter = None + 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"): From 8d0447f0c7c49db37d0e27ff122bdefe8e6124cb Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:30:39 +0100 Subject: [PATCH 06/63] extend copyright --- radicale/app/delete.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index a5beb3f3..cd8b0c3a 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 From 2ace2e6e8ca9e3082026d1a277c1ff016d8e587a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:31:36 +0100 Subject: [PATCH 07/63] use optional permissions filter --- radicale/app/get.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 2eac58f1..848a4daf 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -76,7 +76,8 @@ class ApplicationPartGet(ApplicationBase): 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) + permissions_filter = None + 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): From d71892b616d2e3537d058b0af69ad942e13a1308 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:31:56 +0100 Subject: [PATCH 08/63] 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 848a4daf..98e82f7a 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 32746d03cfae1f4f0499aee6e3ae5431d57ea478 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:33:44 +0100 Subject: [PATCH 09/63] use new parent_path --- 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 53abcdbd..ccdf850c 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -62,8 +62,7 @@ class ApplicationPartMkcalendar(ApplicationBase): 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 258426561df7d286c64e382b64707cce23e6bca8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:33:57 +0100 Subject: [PATCH 10/63] extend copyright --- radicale/app/mkcalendar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index ccdf850c..fcb98d14 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 From 37d0bfd227e5271628190ebcc6220f356f449b6c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:34:59 +0100 Subject: [PATCH 11/63] use new parent_path --- 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 45ad7c4a..49768534 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -66,8 +66,7 @@ class ApplicationPartMkcol(ApplicationBase): 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 3e68a54242ea0ba7eb4bd0f42fb54d983751f9b5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:35:23 +0100 Subject: [PATCH 12/63] 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 49768534..9a4d1efa 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 fb058ea112b37fac0b63072029e0bd6366198b40 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:38:19 +0100 Subject: [PATCH 13/63] use optional permissions filter --- radicale/app/move.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/radicale/app/move.py b/radicale/app/move.py index 168619e3..cb6c11cc 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -67,7 +67,8 @@ class ApplicationPartMove(ApplicationBase): # Remote destination server, not supported return httputils.REMOTE_DESTINATION - access = Access(self._rights, user, path) + permissions_filter = None + 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) @@ -76,7 +77,9 @@ class ApplicationPartMove(ApplicationBase): "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) + to_user = user + to_permissions_filter = None + to_access = Access(self._rights, to_user, to_path, to_permissions_filter) if not to_access.check("w"): return httputils.NOT_ALLOWED From b86c460ba17af39174b0b28d36b9eff3b5a359ef Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:38:37 +0100 Subject: [PATCH 14/63] use new parent_path --- 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 cb6c11cc..1dcba526 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -97,8 +97,7 @@ class ApplicationPartMove(ApplicationBase): 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 2379144772fd49d31d763ab704d1ac76111ee7f7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:38:49 +0100 Subject: [PATCH 15/63] extend copyright --- 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 1dcba526..5fce17c8 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 From 39dee28e4a3fd1b866c4c18fa975e78646c4c6a6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:46:45 +0100 Subject: [PATCH 16/63] use optional permissions filter / use http_depth as variable --- radicale/app/propfind.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 62af2949..8d35a279 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -405,7 +405,9 @@ class ApplicationPartPropfind(ApplicationBase): 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) + http_depth = environ.get("HTTP_DEPTH", "0") + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED try: @@ -419,7 +421,7 @@ class ApplicationPartPropfind(ApplicationBase): return httputils.REQUEST_TIMEOUT with self._storage.acquire_lock("r", user): 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) From 02e48b3841e9b1c81889541004a90f180af26f0f Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:47:32 +0100 Subject: [PATCH 17/63] turn iteration result into list and shorten code covered by lock --- radicale/app/propfind.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 8d35a279..ed301b67 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -431,11 +431,11 @@ class ApplicationPartPropfind(ApplicationBase): 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) - 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)) + 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) + 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 58bf84e5956b0f3435cb6fa1b28583092ec4e71a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:48:11 +0100 Subject: [PATCH 18/63] extend copyright --- radicale/app/propfind.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index ed301b67..b006da7b 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 From aa038cad0181d4cefd8434ce23afc12ba7127a2b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:49:58 +0100 Subject: [PATCH 19/63] use optional permissions filter --- radicale/app/proppatch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index caaf7b7a..cbc5a711 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -75,7 +75,8 @@ 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) + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED try: From bf72f15c7459fe801eb73b264a6ea2d96afb0c93 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:50:21 +0100 Subject: [PATCH 20/63] 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 cbc5a711..197591b2 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 ec67588541c681c653b1255d506d804b0cb594a5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:53:10 +0100 Subject: [PATCH 21/63] use optional permissions filter --- radicale/app/report.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index 917a2d75..8a925e47 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -810,7 +810,8 @@ 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) + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED try: From f84321584f246f14cfef7a769d2822be8387fe2e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 12:53:21 +0100 Subject: [PATCH 22/63] extend copyright --- radicale/app/report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index 8a925e47..526cb4d8 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 From 8a61b80e966b56a1e862a6c181d42d3a8e3428cb Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 14:56:31 +0100 Subject: [PATCH 23/63] 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 a9c9b3b5..9c6545d2 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 fcb98d14..bf6da1c8 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 9a4d1efa..8d3b1755 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 2572090cbd6dd6fdcfa9500160ed4c563a91a164 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 15:16:39 +0100 Subject: [PATCH 24/63] 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 9c6545d2..29ef3b71 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -106,9 +106,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 0b592294ad19990b48356f11caa10abd7e26c2b1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 16:15:11 +0100 Subject: [PATCH 25/63] use optional permissions filter --- radicale/app/put.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/app/put.py b/radicale/app/put.py index 86e863ef..bd6db5ed 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -181,7 +181,8 @@ 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) + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED try: From 05e3b462c0d32fcf55b0b4a446639494891381a0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 16:15:23 +0100 Subject: [PATCH 26/63] extend copyright --- 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 bd6db5ed..9ab87c94 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 From 9135ce69c0e53c662a959eab296cbc304a7d4166 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 16:18:45 +0100 Subject: [PATCH 27/63] add bad_request function with extended information --- radicale/httputils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/radicale/httputils.py b/radicale/httputils.py index 81e01715..0e829c6b 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -106,6 +106,10 @@ MIMETYPES: Mapping[str, str] = { 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``.""" From 42e5f10d7db9669c69a25646c7403d8da50c5ac4 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 16:19:18 +0100 Subject: [PATCH 28/63] extend copyright --- radicale/httputils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/httputils.py b/radicale/httputils.py index 0e829c6b..d2829ea8 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 3f66e9b76cccb450c91a860f13f43accabc83e82 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 22 Feb 2026 16:20:15 +0100 Subject: [PATCH 29/63] add options for content-type and accept --- radicale/tests/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index 5b637159..30e8999d 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -89,6 +89,8 @@ class BaseTest: (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 @@ class BaseTest: 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["HTTP_ACCEPT"] = accept environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path if data is not None: From d2f52e72bb26bbba239cb3e0b822777c29df53cc Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 23 Feb 2026 07:45:59 +0100 Subject: [PATCH 30/63] initialize values earlier in code --- radicale/app/move.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/move.py b/radicale/app/move.py index 5fce17c8..b4814234 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -67,6 +67,8 @@ class ApplicationPartMove(ApplicationBase): # Remote destination server, not supported return httputils.REMOTE_DESTINATION + to_user = user + to_permissions_filter = None permissions_filter = None access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): @@ -77,8 +79,6 @@ class ApplicationPartMove(ApplicationBase): "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 to_access = Access(self._rights, to_user, to_path, to_permissions_filter) if not to_access.check("w"): return httputils.NOT_ALLOWED From fa51f6cf7546ff8cba1eda017d152381e04305b8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 05:27:12 +0100 Subject: [PATCH 31/63] changelog for https://github.com/Kozea/Radicale/pull/1990 and https://github.com/Kozea/Radicale/pull/1991 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e56c36c4..edb93f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Fix: RECURRENCE-ID comparison on all-day events * Fix: format_ut problem on 32-bit systems (got lost inbetween since fixed in 3.5.8) * Maintain: Refactor Web UI Javascript into separate files +* Maintain: code preparation for upcoming "sharing" extension and optimizations ## 3.6.0 From 5a0bb83eef944f3184bc7f6de3df53de976e2bf8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 07:11:24 +0100 Subject: [PATCH 32/63] fix for https://github.com/Kozea/Radicale/issues/1994 forgotten by https://github.com/Kozea/Radicale/pull/1989 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 91bff149..a111cbbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,7 @@ radicale = [ "web/internal_data/css/icons/edit.svg", "web/internal_data/css/icons/new.svg", "web/internal_data/css/icons/upload.svg", - "web/internal_data/fn.js", + "web/internal_data/*.js", "web/internal_data/index.html", "py.typed", ] From 27cc9babd672c22b23686e899ec49041ab1c4312 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 07:32:59 +0100 Subject: [PATCH 33/63] Release 3.6.1 --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- setup.py.legacy | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb93f71..4c2487a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 3.6.1.dev +## 3.6.1 * Fix: MOVE failing with URL-encoded destination header * Improve: add workaround to remove empty lines in item to avoid reject by vobject parser diff --git a/pyproject.toml b/pyproject.toml index a111cbbe..3b19d2c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "Radicale" # When the version is updated, a new section in the CHANGELOG.md file must be # added too. readme = "README.md" -version = "3.6.1.dev" +version = "3.6.1" authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}, {name = "Unrud", email = "unrud@outlook.com"}, {name = "Peter Bieringer", email = "pb@bieringer.de"}] license = {text = "GNU GPL v3"} description = "CalDAV and CardDAV Server" diff --git a/setup.py.legacy b/setup.py.legacy index 27957903..8b43031c 100644 --- a/setup.py.legacy +++ b/setup.py.legacy @@ -20,7 +20,7 @@ from setuptools import find_packages, setup # When the version is updated, a new section in the CHANGELOG.md file must be # added too. -VERSION = "3.6.1.dev" +VERSION = "3.6.1" with open("README.md", encoding="utf-8") as f: long_description = f.read() From 852d4a4040e449683b4723859e9bf2f7517b518c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 08:19:18 +0100 Subject: [PATCH 34/63] Development 3.7.0 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- setup.py.legacy | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2487a9..5100dc53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 3.7.0.dev + ## 3.6.1 * Fix: MOVE failing with URL-encoded destination header diff --git a/pyproject.toml b/pyproject.toml index 3b19d2c1..297f57a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "Radicale" # When the version is updated, a new section in the CHANGELOG.md file must be # added too. readme = "README.md" -version = "3.6.1" +version = "3.7.0.dev" authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}, {name = "Unrud", email = "unrud@outlook.com"}, {name = "Peter Bieringer", email = "pb@bieringer.de"}] license = {text = "GNU GPL v3"} description = "CalDAV and CardDAV Server" diff --git a/setup.py.legacy b/setup.py.legacy index 8b43031c..31508933 100644 --- a/setup.py.legacy +++ b/setup.py.legacy @@ -20,7 +20,7 @@ from setuptools import find_packages, setup # When the version is updated, a new section in the CHANGELOG.md file must be # added too. -VERSION = "3.6.1" +VERSION = "3.7.0.dev" with open("README.md", encoding="utf-8") as f: long_description = f.read() From 2c41a7443592fddcdc5de72fdec49c4e8475add9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:28:30 +0100 Subject: [PATCH 35/63] sharing: base extension --- radicale/sharing/__init__.py | 916 +++++++++++++++++++++++++++++++++++ 1 file changed, 916 insertions(+) create mode 100644 radicale/sharing/__init__.py diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py new file mode 100644 index 00000000..ff3317fb --- /dev/null +++ b/radicale/sharing/__init__.py @@ -0,0 +1,916 @@ +# 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 base64 +import io +import json +import logging +import re +import socket +import uuid +from csv import DictWriter +from datetime import datetime +from http import client +from typing import Sequence, Union +from urllib.parse import parse_qs + +from radicale import (config, httputils, pathutils, rights, storage, types, + utils) +from radicale.log import logger + +INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") + +DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated') +DB_FIELDS_V1_BOOL: Sequence[str] = ('EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser') +DB_FIELDS_V1_INT: Sequence[str] = ('TimestampCreated', 'TimestampUpdated') +# ShareType: +# PathOrToken: [PrimaryKey] +# 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") - check skipped of Owner==User +# HiddenByOwner: True|False (share exposure controlled by owner) +# HiddenByUser: True|False (share exposure controlled by user) - check skipped if Owner==User +# TimestampCreated: (when created) +# 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" + +OUTPUT_TYPES: Sequence[str] = ('csv', 'json', 'txt') + +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 +# 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') + +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": + """Load the sharing database module chosen in configuration.""" + return utils.load_plugin(INTERNAL_TYPES, "sharing", "Sharing", BaseSharing, configuration) + + +class BaseSharing: + + _storage: storage.BaseStorage + _rights: rights.BaseRights + _enabled: bool = False + + 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 + 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") + 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 is False) and (self.sharing_collection_by_token is False)): + logger.info("sharing disabled as no feature is enabled") + self._enabled = False + return + else: + self._enabled = True + + # database tasks + self.sharing_db_type = configuration.get("sharing", "type") + logger.info("sharing.db_type: %s", self.sharing_db_type) + + try: + if self.init_database() is False: + 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) + 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 False + + 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: 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]: + """ retrieve sharing target and attributes by map """ + return {"status": "not-implemented"} + + 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 """ + return {"status": "not-implemented"} + + 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 """ + return {"status": "not-implemented"} + + def delete_sharing(self, + ShareType: str, + PathOrToken: str, + Owner: str, + PathMapped: Union[str, None] = None) -> dict: + """ delete sharing """ + return {"status": "not-implemented"} + + 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 """ + 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: + result = self.sharing_collection_by_token_resolver(path) + if result is not None: + return result + else: + # check for map + pass + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/token: not active") + return None + + if self.sharing_collection_by_map: + result = self.sharing_collection_by_map_resolver(path, user) + if result is not None: + return result + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map: not active") + return None + + # final + return None + + # list sharings of type "map" + 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map: not active") + return [{}] + + # retrieve collections which are enabled and not hidden by owner+user + 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 + + # internal sharing functions + 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/token: check path: %r", path) + if path.startswith("/.token/"): + pattern = re.compile('^/\\.token/' + TOKEN_PATTERN_V1 + '$') + match = pattern.match(path) + if not match: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/token: unsupported token: %r", path) + return None + else: + # TODO add token validity checks + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/token: supported token found in path: %r (token=%r)", path, match[1]) + return self.get_sharing( + ShareType="token", + PathOrToken=match[1]) + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/token: no supported prefix found in path: %r", path) + return None + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/token: not active") + return 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map/resolver: check path: %r", path) + result = self.get_sharing( + ShareType="map", + PathOrToken=path, + User=user) + if result: + return result + else: + # fallback to parent path + parent_path = pathutils.parent_path(path) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map/resolver: 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']) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map/resolver: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path) + return result + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map: not found") + return None + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/map: not active") + return None + + # 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 "/". + + ``path`` is sanitized and always starts with "/.sharing" + + ``user`` is empty for anonymous users. + + Request: + action: (token|map/list + PathOrToken: (optional for filter) + + action: (token|map)/create + PathMapped: (mandatory) + Permissions: (default: r) + + token -> returns + + map + PathOrToken: (mandatory) + User: (mandatory) + + action: (token|map)/(delete|disable|enable|hide|unhide) + PathOrToken: (mandatory) + + token + + map + PathMapped: (mandator) + User: + + Response: output format depending on ACCEPT header + action: list + by user-owned filtered sharing list in CSV/JSON/TEXT + + actions: (other) + Status in JSON/TEXT (TEXT can be parsed by shell) + + """ + 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 + + # supported API version check + if not path.startswith("/.sharing/v1/"): + return httputils.NOT_FOUND + + # 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/API: ShareType/action not extractable: %r", ShareType_action) + return httputils.NOT_FOUND + else: + ShareType = match.group(1) + action = match.group(2) + + # check for valid ShareTypes + if ShareType: + if ShareType not in SHARE_TYPES: + if logger.isEnabledFor(logging.DEBUG): + 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 "map" is not enabled + return httputils.NOT_FOUND + + if not self.sharing_collection_by_token and ShareType == "token": + # API "token" is not enabled + return httputils.NOT_FOUND + + # check for valid API hooks + if action not in API_HOOKS_V1: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/API: action not whitelisted: %r", action) + return httputils.NOT_FOUND + + if logger.isEnabledFor(logging.DEBUG): + 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("Failed read POST request body") + except socket.timeout: + 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: + try: + request_data = json.loads(request_body) + except json.JSONDecodeError: + return httputils.bad_request("Invalid JSON") + if logger.isEnabledFor(logging.DEBUG): + 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] + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + " (form): %r", f"{request_data}") + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": no supported content data") + return httputils.bad_request("Content-type not supported") + + # check for requested output type + accept = environ.get("HTTP_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("CSV output format is only allowed for list action") + elif output_format == "json": + pass + elif output_format == "txt": + pass + else: + return httputils.bad_request("Output format not supported") + + # parameters default + 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 + + # 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("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("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("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) + 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("Invalid value for User") + + # check for mandatory parameters + if 'PathMapped' not in request_data: + if action == 'info': + # ignored + pass + elif action == "list": + # optional + pass + else: + if ShareType == "token" and action != 'create': + # optional + pass + else: + logger.error(api_info + ": missing PathMapped") + return httputils.bad_request("Missing PathMapped") + else: + PathMapped = request_data['PathMapped'] + + if 'PathOrToken' not in request_data: + if action == 'info': + # ignored + pass + elif action not in ['list', 'create']: + logger.error(api_info + ": missing PathOrToken") + return httputils.bad_request("Missing PathOrToken") + else: + # PathOrToken is optional + pass + else: + if action == "create" and ShareType == "token": + # not supported + logger.error(api_info + ": PathOrToken found but not supported") + return httputils.bad_request("PathOrToken not supported") + 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', 'update']: + logger.warning(api_info + ": missing User") + return httputils.bad_request("Missing User") + else: + # optional + pass + else: + 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()) + + # action: list + if action == "list": + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": start") + if 'PathOrToken' in request_data: + PathOrToken = request_data['PathOrToken'] + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": filter: %r", PathOrToken) + + if ShareType != "all": + result_array = self.list_sharing( + ShareType=ShareType, + OwnerOrUser=Owner, + PathMapped=PathMapped, + PathOrToken=PathOrToken) + else: + result_array = self.list_sharing( + OwnerOrUser=Owner, + PathMapped=PathMapped, + PathOrToken=PathOrToken) + + answer['Lines'] = len(result_array) + if len(result_array) == 0: + answer['Status'] = "not-found" + else: + answer['Status'] = "success" + answer['Content'] = result_array + + # action: create + elif action == "create": + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": start") + if 'Permissions' not in request_data: + Permissions = "r" + + 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 + 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 + + # 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 logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": %r (Permissions=%r token=%r)", PathMapped, Permissions, token) + result = self.create_sharing( + ShareType=ShareType, + PathOrToken=token, + PathMapped=str(PathMapped), # mandatory + Owner=Owner, User=Owner, + Permissions=str(Permissions), # mandantory + EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, + Timestamp=Timestamp) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": result=%r", result) + + elif ShareType == "map": + # check preconditions + if PathOrToken is None: + return httputils.bad_request("Missing PathOrToken") + else: + PathOrToken = str(PathOrToken) + + if User is None: + return httputils.bad_request("Missing User") + else: + User = str(User) + + # check access Permissions + 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 + + 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 + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": %r (Permissions=%r PathOrToken=%r user=%r)", PathMapped, Permissions, PathOrToken, User) + result = self.create_sharing( + ShareType=ShareType, + PathOrToken=PathOrToken, # verification above that it is not None + PathMapped=str(PathMapped), # mandatory + Owner=Owner, + User=User, # verification above that it is not None + Permissions=str(Permissions), # mandatory + EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner, + EnabledByUser=EnabledByUser, HiddenByUser=HiddenByUser, + Timestamp=Timestamp) + + else: + logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + return httputils.bad_request("Invalid share type") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": result=%r", result) + # 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("Internal failure") + + if ShareType == "token": + logger.info(api_info + "(success): %r (Permissions=%r token=%r)", PathMapped, Permissions, token) + answer['PathOrToken'] = token + + # action: update + elif action == "update": + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": start") + + if PathOrToken is None: + return httputils.bad_request("Missing PathOrToken") + + 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, + Timestamp=Timestamp) + + 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, + Timestamp=Timestamp) + + else: + logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + return httputils.bad_request("Invalid share type") + + # 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("Invalid share type") + + # action: delete + elif action == "delete": + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/" + api_info + ": start") + + if PathOrToken is None: + return httputils.bad_request("Missing PathOrToken") + + if ShareType == "token": + result = self.delete_sharing( + ShareType=ShareType, + PathOrToken=str(PathOrToken), # verification above that it is not None + Owner=Owner) + + elif ShareType == "map": + result = self.delete_sharing( + ShareType=ShareType, + PathOrToken=str(PathOrToken), # verification above that it is not None + PathMapped=PathMapped, + Owner=Owner) + + else: + logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + return httputils.bad_request("Invalid share type") + + # 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", 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("Invalid share type") + + # action: info + elif action == "info": + 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/API/POST/" + action) + + if ShareType in ["token", "map"]: + if PathOrToken is None: + return httputils.bad_request("Missing PathOrToken") + + result = self.toggle_sharing( + ShareType=ShareType, + PathOrToken=str(PathOrToken), # verification above that it is not None + OwnerOrUser=user, # authenticated user + User=User, # optional for selection + PathMapped=PathMapped, # optional 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("Internal Error") + + else: + logger.error(api_info + ": unsupported for ShareType=%r", ShareType) + return httputils.bad_request("Invalid share type") + + else: + # default + logger.error(api_info + ": unsupported action=%r", action) + return httputils.bad_request("Invalid action") + + # output handler + if logger.isEnabledFor(logging.DEBUG): + 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": + 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) + if output_format == "csv": + writer.writeheader() + for entry in answer['Content']: + writer.writerow(entry) + 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" + } + return client.OK, headers, "\n".join(answer_array), None + elif output_format == "json": + answer_raw = json.dumps(answer) + headers = { + "Content-Type": "text/json" + } + return client.OK, headers, answer_raw, None + else: + # should not be reached + return httputils.bad_request("Invalid output format") + + return httputils.METHOD_NOT_ALLOWED From b857cb9eeabd4dbc83736b753dcd6f67c9778e39 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:28:55 +0100 Subject: [PATCH 36/63] sharing: file-based config store --- radicale/sharing/files.py | 445 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 445 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 00000000..e268099b --- /dev/null +++ b/radicale/sharing/files.py @@ -0,0 +1,445 @@ +# 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 logging +import os +import pickle +import urllib +from typing import Union + +from radicale import 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 + if logger.isEnabledFor(logging.DEBUG): + 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'] is not True: + return None + elif row['ShareType'] == "map": + if row['EnabledByUser'] is not True: + return None + + PathMapped = row['PathMapped'] + Owner = row['Owner'] + UserShare = row['User'] + Permissions = row['Permissions'] + Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden) + 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 """ + result = [] + + if logger.isEnabledFor(logging.DEBUG): + 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 + + if logger.isEnabledFor(logging.DEBUG): + 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 + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: + if logger.isEnabledFor(logging.DEBUG): + 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: skip by User") + pass + elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: skip by PathOrToken") + pass + elif PathMapped is not None and row['PathMapped'] != PathMapped: + if logger.isEnabledFor(logging.DEBUG): + 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: + if logger.isEnabledFor(logging.DEBUG): + 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)) + + if logger.isEnabledFor(logging.DEBUG): + 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): + if logger.isEnabledFor(logging.DEBUG): + 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) + if logger.isEnabledFor(logging.DEBUG): + 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 """ + if logger.isEnabledFor(logging.DEBUG): + 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"} + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: check: %r", ShareType, row) + + if Owner is not None and row['Owner'] != Owner: + return {"status": "permission-denied"} + if User is not None and row['User'] != User: + return {"status": "permission-denied"} + + if logger.isEnabledFor(logging.DEBUG): + 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) + if logger.isEnabledFor(logging.DEBUG): + 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 """ + if logger.isEnabledFor(logging.DEBUG): + 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 + + if logger.isEnabledFor(logging.DEBUG): + 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"} + + if logger.isEnabledFor(logging.DEBUG): + 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: + if logger.isEnabledFor(logging.DEBUG): + 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: + if logger.isEnabledFor(logging.DEBUG): + 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) + if logger.isEnabledFor(logging.DEBUG): + 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) From 57719b7885a27cd785ef1cae0ac63dfb9f6dafd2 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:29:16 +0100 Subject: [PATCH 37/63] sharing: csv based config store --- radicale/sharing/csv.py | 530 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 radicale/sharing/csv.py diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py new file mode 100644 index 00000000..b0481d55 --- /dev/null +++ b/radicale/sharing/csv.py @@ -0,0 +1,530 @@ +# 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 logging +import os +from typing import Union + +from radicale import config, sharing +from radicale.log import logger + +""" CVS based sharing by token or map """ + + +class Sharing(sharing.BaseSharing): + _lines: int = 0 + _sharing_cache: list[dict] = [] + _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_path") + 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.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 (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_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: + return False + 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) + self._sharing_db_file = sharing_db_file + return True + + 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, + User: Union[str, None] = None) -> Union[dict, None]: + """ retrieve sharing target and attributes by map """ + # Lookup + if logger.isEnabledFor(logging.DEBUG): + 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 + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing: check row: %r", row) + if row['ShareType'] != ShareType: + pass + elif row['PathOrToken'] != PathOrToken: + pass + elif User is not None and row['User'] != User: + pass + elif row['EnabledByOwner'] is not True: + pass + elif row['ShareType'] == "map": + if row['EnabledByUser'] is not True: + pass + else: + found = True + break + else: + found = True + break + index += 1 + + if found: + PathMapped = row['PathMapped'] + Owner = row['Owner'] + UserShare = row['User'] + Permissions = row['Permissions'] + Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden) + 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 = [] + + if logger.isEnabledFor(logging.DEBUG): + 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: + if index == 0: + # skip fieldnames + pass + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: + if logger.isEnabledFor(logging.DEBUG): + 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: skip by User") + pass + elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: skip by PathOrToken") + pass + elif PathMapped is not None and row['PathMapped'] != PathMapped: + if logger.isEnabledFor(logging.DEBUG): + 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/list/row: add: %r", row) + result.append(row) + index += 1 + 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 + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing: ShareType=%r", ShareType) + if ShareType == "token": + if logger.isEnabledFor(logging.DEBUG): + 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._sharing_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 {"status": "conflict"} + elif ShareType == "map": + if logger.isEnabledFor(logging.DEBUG): + 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._sharing_cache: + if row['ShareType'] != "map": + continue + 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"} + else: + return {"status": "error"} + + 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} + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/*/create: add row: %r", row) + self._sharing_cache.append(row) + + with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file): + if self._write_csv(self._sharing_db_file): + if logger.isEnabledFor(logging.DEBUG): + 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, + 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 """ + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: PathOrToken=%r Owner=%r PathMapped=%r", ShareType, PathOrToken, Owner, PathMapped) + + # lookup token + 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: + pass + else: + found = True + break + index += 1 + + if found: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: found index=%d", ShareType, index) + if Owner is not None and row['Owner'] != Owner: + return {"status": "permission-denied"} + if User is not None and row['User'] != User: + return {"status": "permission-denied"} + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: Owner=%r PathOrToken=%r index=%d", ShareType, Owner, PathOrToken, index) + logger.debug("TRACE/sharing/%s/update: orig row=%r", ShareType, 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 + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: adj row=%r", ShareType, row) + + # replace row + self._sharing_cache.pop(index) + self._sharing_cache.append(row) + + with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file): + if self._write_csv(self._sharing_db_file): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/update: write CSV done", ShareType) + return {"status": "success"} + logger.error("sharing/%s/update: cannot update CSV database", ShareType) + return {"status": "error"} + else: + return {"status": "not-found"} + + def delete_sharing(self, + ShareType: str, + PathOrToken: str, Owner: str, + PathMapped: Union[str, None] = None) -> dict: + """ delete sharing """ + if logger.isEnabledFor(logging.DEBUG): + 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: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/delete: check: %r", ShareType, row) + if index == 0: + # skip fieldnames + pass + if row['ShareType'] != ShareType: + pass + elif row['PathOrToken'] != PathOrToken: + pass + else: + if ShareType == "map": + # extra filter + if row['PathMapped'] != PathMapped: + pass + else: + found = True + break + else: + found = True + break + index += 1 + + if found: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/delete: found index=%d", ShareType, index) + if row['Owner'] != Owner: + return {"status": "permission-denied"} + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/delete: Owner=%r PathOrToken=%r index=%d", ShareType, Owner, PathOrToken, index) + self._sharing_cache.pop(index) + + with self._storage.acquire_lock("w", Owner, path=self._sharing_db_file): + if self._write_csv(self._sharing_db_file): + if logger.isEnabledFor(logging.DEBUG): + 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"} + + 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 + + if Action not in sharing.API_SHARE_TOGGLES_V1: + # should not happen + raise + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/%s/%s: OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r", ShareType, Action, OwnerOrUser, User, PathOrToken, PathMapped) + + # lookup entry + found = False + index = 0 + for row in self._sharing_cache: + if index == 0: + # skip fieldnames + pass + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/*/" + Action + ": check: %r", row) + if row['ShareType'] != ShareType: + pass + elif row['PathOrToken'] != PathOrToken: + pass + elif PathMapped is not None and row['PathMapped'] != PathMapped: + pass + elif row['Owner'] == OwnerOrUser: + found = True + break + else: + found = True + break + index += 1 + + if found: + # if logger.isEnabledFor(logging.DEBUG): + # logger.debug("TRACE/sharing/*/" + Action + ": found: %r", row) + 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"} + + # TODO: locking + if row['Owner'] == OwnerOrUser: + if logger.isEnabledFor(logging.DEBUG): + 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": + row['EnabledByOwner'] = True + elif Action == "hide": + row['HiddenByOwner'] = True + elif Action == "unhide": + row['HiddenByOwner'] = False + row['TimestampUpdated'] = Timestamp + if row['User'] == OwnerOrUser: + if logger.isEnabledFor(logging.DEBUG): + 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": + row['EnabledByUser'] = True + elif Action == "hide": + row['HiddenByUser'] = True + elif Action == "unhide": + row['HiddenByUser'] = False + + row['TimestampUpdated'] = Timestamp + + # remove + self._sharing_cache.pop(index) + # readd + self._sharing_cache.append(row) + + with self._storage.acquire_lock("w", OwnerOrUser, path=self._sharing_db_file): + if self._write_csv(self._sharing_db_file): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE: write CSV done") + return {"status": "success"} + logger.error("sharing: cannot update CSV database") + return {"status": "error"} + else: + return {"status": "not-found"} + + # local functions + def _create_empty_csv(self, file: str) -> bool: + 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 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", 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 35060f25b526e8053c073b9ee030e62fded03293 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:29:37 +0100 Subject: [PATCH 38/63] sharing: dummy storage type --- radicale/sharing/none.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 radicale/sharing/none.py diff --git a/radicale/sharing/none.py b/radicale/sharing/none.py new file mode 100644 index 00000000..1264602a --- /dev/null +++ b/radicale/sharing/none.py @@ -0,0 +1,38 @@ +# 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 typing import Union + +from radicale import sharing +from radicale.log import logger + + +class Sharing(sharing.BaseSharing): + + def init_database(self) -> bool: + """ dummy initialization """ + return False + + 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]: + """ 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 b80fd10e9ca9bbd38c99ac15d202c585098141a5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:30:26 +0100 Subject: [PATCH 39/63] sharing: load configuration into base --- 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 29ef3b71..44f064e7 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -22,7 +22,7 @@ import xml.etree.ElementTree as ET from typing import Optional, Union 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 from radicale.rights import intersect @@ -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 @@ class ApplicationBase: 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 2c41a573aefda84f25eb758ba023e91cf10b1608 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:32:35 +0100 Subject: [PATCH 40/63] sharing: add option for verification of sharing config database --- radicale/__main__.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/radicale/__main__.py b/radicale/__main__.py index e5eb68db..29f97c90 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -33,7 +33,8 @@ import sys 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 +68,8 @@ def run() -> None: 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 +212,19 @@ def run() -> None: 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() From 8562fcdcd73dece5f34409cb0be988abd6bfd8d5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:32:56 +0100 Subject: [PATCH 41/63] copyright: update --- radicale/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/__main__.py b/radicale/__main__.py index 29f97c90..f5a76a92 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 From 5b980fbc49bbb47bf69886c0ae8975d195d03637 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:38:46 +0100 Subject: [PATCH 42/63] sharing: add hook for simple calls --- radicale/app/delete.py | 8 ++++++++ radicale/app/get.py | 8 ++++++++ radicale/app/mkcalendar.py | 7 +++++++ radicale/app/mkcol.py | 7 +++++++ radicale/app/move.py | 17 +++++++++++++++++ 5 files changed, 47 insertions(+) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index cd8b0c3a..ff22e0c5 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -58,6 +58,14 @@ class ApplicationPartDelete(ApplicationBase): path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage DELETE request.""" 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 diff --git a/radicale/app/get.py b/radicale/app/get.py index 98e82f7a..cea26f31 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -77,6 +77,14 @@ class ApplicationPartGet(ApplicationBase): # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) 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 diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index bf6da1c8..b892aa6b 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -54,6 +54,13 @@ class ApplicationPartMkcalendar(ApplicationBase): logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST + 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 8d3b1755..6670349f 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -61,6 +61,13 @@ class ApplicationPartMkcol(ApplicationBase): 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 + 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 b4814234..b093aa25 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -70,6 +70,14 @@ class ApplicationPartMove(ApplicationBase): to_user = user to_permissions_filter = None 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 @@ -79,6 +87,15 @@ class ApplicationPartMove(ApplicationBase): "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] + 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 From c2edfee738f961c35ad26fa3407405d3a8e81c69 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:43:57 +0100 Subject: [PATCH 43/63] sharing: add support for propfind --- radicale/app/propfind.py | 55 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index b006da7b..f8d85549 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -20,11 +20,13 @@ import collections import itertools +import logging import posixpath 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 +37,7 @@ from radicale.log import logger 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 +74,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 +82,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 +102,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 +183,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 +221,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( @@ -407,6 +418,15 @@ class ApplicationPartPropfind(ApplicationBase): """Manage PROPFIND request.""" http_depth = environ.get("HTTP_DEPTH", "0") 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 @@ -432,10 +452,35 @@ class ApplicationPartPropfind(ApplicationBase): # put item back items_iter = itertools.chain([item], items_iter) allowed_items = list(self._collect_allowed_items(items_iter, user)) + if self._sharing._enabled: + if http_depth == "1": + if logger.isEnabledFor(logging.DEBUG): + 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'] + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND: test shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s", c_share, c_path, c_user, c_permissions_filter) + c_access = Access(self._rights, c_user, c_path, c_permissions_filter) + if not c_access.check("r"): + if logger.isEnabledFor(logging.DEBUG): + 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 + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND: append shared collection: PathOrToken=%r PathMapped=%r Owner=%r", c_share, c_path, c_user) + 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) + 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 128b32856905df9c87f75be9f4d489ae7d1abcb5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:44:16 +0100 Subject: [PATCH 44/63] sharing: add support for proppatch --- radicale/app/proppatch.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 197591b2..9c527c12 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -24,7 +24,7 @@ import re 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 @@ -37,7 +37,7 @@ from radicale.log import logger 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")) @@ -76,6 +79,15 @@ class ApplicationPartProppatch(ApplicationBase): path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" 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 @@ -100,7 +112,7 @@ class ApplicationPartProppatch(ApplicationBase): "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 51994bed00c74d93ceac758c1fd002aa0feb86c6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:45:00 +0100 Subject: [PATCH 45/63] sharing: add support for report --- radicale/app/report.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index 526cb4d8..0ec79357 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -25,6 +25,7 @@ import contextlib import copy import datetime +import logging import posixpath import socket import xml.etree.ElementTree as ET @@ -149,14 +150,15 @@ 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. """ - logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path) multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) if xml_request is None: return client.MULTI_STATUS, multistatus @@ -354,7 +356,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 +707,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,6 +815,15 @@ class ApplicationPartReport(ApplicationBase): path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" 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 @@ -853,7 +866,7 @@ class ApplicationPartReport(ApplicationBase): 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 1451be9ded7f666f1fcd42ad957e2a7aad992c09 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:45:16 +0100 Subject: [PATCH 46/63] sharing: add support for put --- radicale/app/put.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/radicale/app/put.py b/radicale/app/put.py index 9ab87c94..4e62ec1d 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -182,6 +182,15 @@ class ApplicationPartPut(ApplicationBase): path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PUT request.""" 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 From 8ea2afe0fe106db741d5be3e1f4be0ac90e23440 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:45:35 +0100 Subject: [PATCH 47/63] sharing: add hook for POST --- radicale/app/post.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/radicale/app/post.py b/radicale/app/post.py index df944499..0cac0b74 100644 --- a/radicale/app/post.py +++ b/radicale/app/post.py @@ -30,4 +30,6 @@ class ApplicationPartPost(ApplicationBase): """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 2e15c3af9d645b23e7dd5a5c3f167d37fc743d2b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:46:04 +0100 Subject: [PATCH 48/63] copyright: update --- radicale/app/post.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/post.py b/radicale/app/post.py index 0cac0b74..a70fc736 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 From f56612f20810d28b3681d0792623ef4b04fb3fe3 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:46:19 +0100 Subject: [PATCH 49/63] call trace only on enabled debug --- radicale/app/propfind.py | 4 ++++ radicale/app/report.py | 15 ++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index f8d85549..fc797c74 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -384,6 +384,8 @@ class ApplicationPartPropfind(ApplicationBase): for item in items: if isinstance(item, storage.BaseCollection): path = pathutils.unstrip_path(item.path, True) + if logger.isEnabledFor(logging.DEBUG): + 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") @@ -440,6 +442,8 @@ class ApplicationPartPropfind(ApplicationBase): logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT with self._storage.acquire_lock("r", user): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND: discover path=%r depth=%s", path, http_depth) items_iter = iter(self._storage.discover( path, http_depth, None, self._rights._user_groups)) diff --git a/radicale/app/report.py b/radicale/app/report.py index 0ec79357..35e9237e 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -242,7 +242,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], filter_copy = copy.deepcopy(filter_) if expand is not None: - logger.debug("TRACE/REPORT/xml_report: expand") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: expand") for comp_filter in filter_copy.findall(".//" + xmlutils.make_clark("C:comp-filter")): if comp_filter.get("name", "").upper() == "VCALENDAR": continue @@ -326,13 +327,16 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], n_vevents += n_vev if prop.tag == xmlutils.make_clark("D:getetag"): if n_vev > 0: - logger.debug("TRACE/REPORT/xml_report: getetag/expanded element") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: getetag/expanded element") element.text = item.etag found_props.append(element) else: - logger.debug("TRACE/REPORT/xml_report: getetag/no expanded element") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: getetag/no expanded element") else: - logger.debug("TRACE/REPORT/xml_report: default") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: default") found_props.append(expanded_element) else: if prop.tag == xmlutils.make_clark("D:getetag"): @@ -776,7 +780,8 @@ def retrieve_items( else: yield item, False if collection_requested: - logger.debug("TRACE/REPORT/retrieve_items: get_filtered") + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/retrieve_items: get_filtered") yield from collection.get_filtered(filters) From bdff6307e418ce381645b37dc455bf54d127f01b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:47:03 +0100 Subject: [PATCH 50/63] sharing: add config options --- radicale/config.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/radicale/config.py b/radicale/config.py index 519269bc..e147387c 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -37,7 +37,7 @@ from configparser import RawConfigParser 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 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "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_path", { + "value": "", + "help": "database path", + "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 47d963a3a98ebdb38f77d9b5dd3757382f98c3c1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:47:14 +0100 Subject: [PATCH 51/63] copyright: update --- radicale/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/config.py b/radicale/config.py index e147387c..bdca5526 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 From 0dbe6c1223fc239a393cd471856a4dce382b69e8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:47:38 +0100 Subject: [PATCH 52/63] sharing: add default config to template --- config | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/config b/config index a28cd873..0c5b90a6 100644 --- a/config +++ b/config @@ -296,6 +296,25 @@ #predefined_collections = +[sharing] + +# Sharing database type +# Value: none | csv | files +#type = none + +# Sharing database path for type 'csv' +#database_path = (filesystem_folder)/collection-db/sharing.csv + +# Sharing database path for type 'files' +#database_path = (filesystem_folder)/collection-db/files + +# Share collection by map +#collection_by_map = false + +# Share collection by token +#collection_by_token = false + + [web] # Web interface backend From a66884036ee52d60059a4f028fe2adcd8b5581b1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:47:58 +0100 Subject: [PATCH 53/63] sharing: extend doc --- DOCUMENTATION.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 55c5d5ad..858bb944 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) @@ -2043,6 +2049,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` + * `files` + +Default: `none` (implicit disabling the feature) + +##### database_path + +_(>= 3.7.0)_ + +Sharing database path + +Default: + * type `csv`: `(filesystem_folder)/collection-db/sharing.csv` + * type `files`: `(filesystem_folder)/collection-db/files` + +##### 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 d064a51f54b6861a2831cbf57cf684b05595d9fe Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 06:48:17 +0100 Subject: [PATCH 54/63] sharing: add tests --- radicale/tests/test_sharing.py | 2129 ++++++++++++++++++++++++++++++++ 1 file changed, 2129 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 00000000..5bcb89b6 --- /dev/null +++ b/radicale/tests/test_sharing.py @@ -0,0 +1,2129 @@ +# 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 re +from typing import Dict, Sequence, Tuple, Union + +from radicale import sharing, xmlutils +from radicale.tests import BaseTest +from radicale.tests.helpers import get_file_content + + +class TestSharingApiSanity(BaseTest): + """Tests with sharing.""" + + htpasswd_file_path: str + + # Setup + 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 = ["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) + + # 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]: + 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]: + data = "&".join(form_array) + content_type = "application/x-www-form-urlencoded" + 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: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: + data = json.dumps(json_dict) + content_type = "application/json" + 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 + def test_sharing_api_base_no_auth(self) -> None: + """POST request at '/.sharing' without authentication.""" + # disabled + for path in ["/.sharing", "/.sharing/"]: + _, 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: + """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"}}) + + json_dict: dict + + # path with no valid API hook + for path in ["/.sharing/", "/.sharing/v9/"]: + _, 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="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/" + sharetype + "/" + action + _, headers, _ = self.request("POST", path, check=404, login="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="owner:ownerpw") + + # check info hook + logging.info("\n*** check API hook: info/all") + 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'] 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'] is 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 = {} + _, 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": { + "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="owner:ownerpw") + # valid API + _, 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", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['FeatureEnabledCollectionByToken'] is 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.""" + 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"}}) + + form_array: Sequence[str] + json_dict: 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}}) + + 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/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 + + 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"}, + "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"}}) + + form_array: Sequence[str] + json_dict: 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 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*** 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'] is 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'] is 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.""" + 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"}}) + + form_array: Sequence[str] + json_dict: dict + + path_token = "/.token/" + path_base = "/owner/calendar.ics/" + path_base2 = "/owner/calendar2.ics/" + + logging.info("\n*** prepare") + 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") + + 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 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 (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*** 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.""" + 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 + + 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 (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 (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.""" + 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": "False"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + file_item1 = "event1.ics" + file_item2 = "event2.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/calendarU.ics/" + path_mapped_item1 = os.path.join(path_mapped, file_item1) + path_mapped_item2 = os.path.join(path_mapped, file_item2) + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped, login="owner:ownerpw") + event = get_file_content(file_item1) + 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="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*** 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*** 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*** 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*** 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'] 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") + 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 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 (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 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*** 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*** 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*** 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" + + def test_sharing_api_map_usercheck(self) -> None: + """share-by-map API usage tests related to usercheck.""" + 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.info("\n*** prepare") + 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")) + + 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 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: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 (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/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 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 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", + "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": "False"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + 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") + self.mkcalendar(path_mapped, login="owner:ownerpw") + event = get_file_content("event1.ics") + path = path_mapped + "/event1.ics" + self.put(path, event, 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 + logging.info("\n*** fetch event as owner (init) -> ok") + _, 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") + 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: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" + + 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" + + # 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") + + # 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:w -> 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") + + # 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: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) + + 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) + + # 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") + + # 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:w -> fail") + _, 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="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") + + # 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") + + 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") + + # 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 as owner -> ok") + _, 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="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: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 + "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="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 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 + "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="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") + + 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") + + # 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 + "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.""" + 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="owner:ownerpw") + event = get_file_content("event1.ics") + self.put(path_mapped_item, event, 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, """\ + + + + + +""", 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" + + # check REPORT as user + logging.info("\n*** REPORT collection user -> 404") + _, responses = self.report(path_shared, """\ + + + + + +""", 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, """\ + + + + + +""", 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 + + 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_user_item = os.path.join(path_user, "event2.ics") + + logging.info("\n*** prepare and test access") + self.mkcalendar(path_mapped, login="owner:ownerpw") + event = get_file_content("event1.ics") + self.put(path_mapped_item, event, login="owner:ownerpw") + + self.mkcalendar(path_mapped2, login="owner:ownerpw") + + self.mkcalendar(path_user, login="user:userpw") + event = get_file_content("event2.ics") + self.put(path_user_item, event, login="user:userpw") + + 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 event1 as owner -> 200") + _, 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="user:userpw") + + 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) + + # 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, """\ + + + +""", 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, """\ + + + +""", login="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", 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) + + # 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) + + def test_sharing_api_map_propfind(self) -> None: + """share-by-map API usage tests related to propfind.""" + 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="owner:ownerpw") + event = get_file_content("event1.ics") + path = os.path.join(path_mapped, "event1.ics") + self.put(path, event, 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, """\ + + + + + +""", 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" + + # check PROPFIND as user + logging.info("\n*** PROPFIND collection user -> 404") + _, responses = self.propfind(path_shared, """\ + + + + + +""", 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, """\ + + + + + +""", 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/" + + 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/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="owner:ownerpw") + event = get_file_content("event1.ics") + path = os.path.join(path_mapped, "event1.ics") + self.put(path, event, 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, """\ + + + + + +""", 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("\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" + + # 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) + + 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, """\ + + + + + +""", 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 + + def test_sharing_api_map_move(self) -> None: + """share-by-map API usage tests related to MOVE.""" + 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/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="owner:ownerpw") + event = get_file_content("event1.ics") + self.put(os.path.join(path_mapped1, "event1.ics"), event, login="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="owner:ownerpw") + + self.mkcalendar(path_user, login="user:userpw") + event = get_file_content("event3.ics") + self.put(os.path.join(path_user, "event3.ics"), event, login="user:userpw") + + 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 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") + + 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 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/event1 as owner (after move) -> ok") + _, 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="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="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*** 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")) + + 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) + + 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) + + # 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 -> 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")) + + 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 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") + + 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") + + # 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*** 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 -> 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.""" + 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"}}) + form_array: Sequence[str] + 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") + + 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 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") + + # cleanup + self.delete(path_mapped2, login="owner:ownerpw") + + 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_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") + + 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}}) + + # 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 + + 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_user2_shared1 = "/user2/calendarCCo1-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) + + 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 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 74ad76b36d359f308e18d188fe8483845ac751fe Mon Sep 17 00:00:00 2001 From: magicfelix Date: Tue, 24 Feb 2026 18:14:19 +0100 Subject: [PATCH 55/63] [OAuth2] Allow custom client_id and client_secret --- DOCUMENTATION.md | 16 ++++++++++++++++ config | 6 ++++++ radicale/auth/oauth2.py | 7 ++++++- radicale/config.py | 8 ++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 858bb944..2cd2649b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1360,6 +1360,22 @@ Endpoint URL for the OAuth2 token Default: (unset) +##### oauth2_client_id + +_(>= 3.5.0)_ + +Client ID used to request the Auth2 token + +Default: `radicale` + +##### oauth2_client_secret + +_(>= 3.5.0)_ + +Client secret used to request the Auth2 token + +Default: (unset) + ##### pam_service _(>= 3.5.0)_ diff --git a/config b/config index 0c5b90a6..7aa81fd1 100644 --- a/config +++ b/config @@ -156,6 +156,12 @@ # OAuth2 token endpoint URL #oauth2_token_endpoint = +# OAuth2 client id +#oauth2_client_id = + +# OAuth2 client secret +#oauth2_client_secret = + # PAM service #pam_serivce = radicale diff --git a/radicale/auth/oauth2.py b/radicale/auth/oauth2.py index 838a786e..87592d0b 100644 --- a/radicale/auth/oauth2.py +++ b/radicale/auth/oauth2.py @@ -34,6 +34,8 @@ class Auth(auth.BaseAuth): def __init__(self, configuration): super().__init__(configuration) self._endpoint = configuration.get("auth", "oauth2_token_endpoint") + self._client_id = configuration.get("auth", "oauth2_client_id") + self._client_secret = configuration.get("auth", "oauth2_client_secret") if not self._endpoint: logger.error("auth.oauth2_token_endpoint URL missing") raise RuntimeError("OAuth2 token endpoint URL is required") @@ -49,8 +51,11 @@ class Auth(auth.BaseAuth): "username": login, "password": password, "grant_type": "password", - "client_id": "radicale", + "client_id": self._client_id, } + if self._client_secret: + req_params["client_secret"] = self._client_secret + req_headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post( self._endpoint, data=req_params, headers=req_headers diff --git a/radicale/config.py b/radicale/config.py index bdca5526..6cc0460d 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -354,6 +354,14 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "", "help": "OAuth2 token endpoint URL", "type": str}), + ("oauth2_client_id", { + "value": "radicale", + "help": "OAuth2 client id", + "type": str}), + ("oauth2_client_secret", { + "value": "", + "help": "OAuth2 client secret", + "type": str}), ("pam_group_membership", { "value": "", "help": "PAM group user should be member of", From 372a10626ab3a78629f0e31c5aa421aaaf5f418d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 20:04:01 +0100 Subject: [PATCH 56/63] changelog for https://github.com/Kozea/Radicale/pull/1995 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5100dc53..8757d837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 3.7.0.dev +* Feature: collection sharing by map or token incl. management API + ## 3.6.1 * Fix: MOVE failing with URL-encoded destination header From 615dd53ac76b15d0b4411df22d64b3fbefd549b4 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 20:04:33 +0100 Subject: [PATCH 57/63] sharing: initial doc --- SHARING.md | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 SHARING.md diff --git a/SHARING.md b/SHARING.md new file mode 100644 index 00000000..4432802d --- /dev/null +++ b/SHARING.md @@ -0,0 +1,285 @@ + +# Collection Sharing + +Static collection sharing without permissions filter using soft-links (Unix-only) is supported since storage type `multifilesystem` was implemented, see (Wiki: Sharing Collections)[https://github.com/Kozea/Radicale/wiki/Sharing-Collections] + +With 3.7.0 a major extension was implemented using internal mapping configuration stored in a database and a management API. + +## Sharing Configuration Store + +Types of supported sharing configuration: + + * csv (_>= 3.7.0_) + * files (_>= 3.7.0_) + +### Sharing Configuration Entry Data + + * `ShareType`: type of share + * `token`: token-based share (do not require user authentication) + * `map`: map-based share (requires user authentication) + * `PathOrToken`: token or "virtual" collection, has to be unique (PRIMARY KEY) + * `PathMapped`: target collection + * `Owner`: owner of the share + * `User`: user of the share + * `Permissions`: effective permission of the share + * `EnabledByOwner`: control by owner + * `EnabledByUser`: control by user + * `HiddenByOwner`: control by owner + * `HiddenByUser`: control by user + * `TimestampCreated`: unixtime of creation + * `TimestampUpdated`: unixtime of last update + +`Enabled*`: owner AND user have to enable a share to become usable + +`Hidden*`: owner AND user have to disable a share to become visible in PROPFIND + +### Sharing Configuration Entry Storage + +#### CSV + +(_>= 3.7.0_) + +One CSV file containing one row per sharing config, separated by `,` and containing header with columns from above. + +#### Files + +(_>= 3.7.0_) + +File-based configuration store is using encoded `PathOrToken` as filename for each config. File contains the data stored as "dict" in binary Python "pickle" format (same is also used for item cache files). + +## Sharing Access + +### Sharing Access via Maps + +(_>= 3.7.0_) + +Map-based sharing can be accessed as usual after authentication and authorization. + +#### Workflow + +* create map as owner +* enable map as owner (can be combined with "create") +* enable map as user (explicit required to avoid sudden available share) + +In case share should be visible using PROPFIND + +* unhide map as owner (can be combined with "create") +* unhide map as user (explicit required to avoid sudden visible share) + + +### Sharing Access via Tokens + +(_>= 3.7.0_) + +Token-based sharing can be accessed after retrieving the token via + +Token-URI: `/.token/` + +#### Workflow + +* create token as owner +* enable token as owner (can be combined with "create") +* handover URI with token to client + + +## Sharing Configuration Management API + +### Sharing Configuration Management API version 1 + +(_>= 3.7.0_) + +Type: POST API + +Base-URI: `/.sharing/v1//` + +See also test cases in `radicale/tests/test_sharing.py` + +#### Data Format + +##### Input Data Format + +Parsing be controlled by `CONTENT_TYPE` + + * application/x-www-form-urlencoded (_>= 3.7.0_) + * application/json (_>= 3.7.0_) + +##### Output Data Format + +Can be selected by `HTTP_ACCEPT` + + * text/plain (_>= 3.7.0_) + * text/csv (_>= 3.7.0_) - only for "list" + * application/json (_>= 3.7.0_) + +##### Accepted Input Data Fields + + * `PathOrToken`: token or "virtual" collection + * `PathMapped`: target collection + * `Owner`: owner of the share + * `User`: user of the share + * `Permissions`: effective permission of the share + * `Enabled`: owner/user selected by authentication + * `Hidden`: owner/user selected by authentication + +#### API Hooks + +##### API Hook "info" + +Shows what kind of ShareTypes are supported + +* Example: TEXT + +``` +curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info +ApiVersion=1 +Status=success +FeatureEnabledCollectionByMap=True +PermittedCreateCollectionByMap=True +FeatureEnabledCollectionByToken=True +PermittedCreateCollectionByToken=True +``` + +* Example: JSON + +``` +curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq +{ + "ApiVersion": "1", + "Status": "success", + "FeatureEnabledCollectionByMap": true, + "PermittedCreateCollectionByMap": true, + "FeatureEnabledCollectionByToken": true, + "PermittedCreateCollectionByToken": true +} +``` + + +##### API Hook "*/create" + +Create a share + +###### API Hook "token/create" + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathMapped | yes | | +| Permissions | no | r + + * Output + +| Parameter | Value | +| - | - | +| PathOrToken | (autogenerated token) | + +* Example: TEXT + +``` +curl -u user:pass -d "PathMapped=/user/testcalendar1/" http://localhost:5232/.sharing/v1/token/create +ApiVersion=1 +Status=success +PathOrToken=v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw= +``` + + +###### API Hook "map/create" + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathOrToken | yes | | +| PathMapped | yes | | +| Permissions | no | r +| User | yes | | + + * Output: result status + + * Example: TEXT + +``` +curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/cal1/" -d "User=user" http://localhost:5232/.sharing/v1/map/create +ApiVersion=1 +Status=success +``` + + +##### API Hook "*/list" + +List shares (optional with filter) + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathMapped | no | (all) | +| Owner | no | (owned ones) | +| User | no | (filtered) | + + * Output: plain/csv/json + + * Example: CSV + +``` +curl -H "accept: text/csv" -u owner:pass -d "" http://localhost:5232/.sharing/v1/map/list +ShareType,PathOrToken,PathMapped,Owner,User,Permissions,EnabledByOwner,EnabledByUser,HiddenByOwner,HiddenByUser,TimestampCreated,TimestampUpdated +map,/user/cal1-from-owner/,/owner/cal1/,owner,user,r,False,False,True,True,1771962120,1771962120 +``` + + +##### API Hook "*/delete" + +Delete a share selected by `PathOrToken` + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathOrToken | yes | | + + * Output: result status + +##### API Hook "*/update" + +Update a share selected by `PathOrToken` + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathOrToken | yes | n/a | +| PathMapped | no | | +| User | no | | + + * Output: result status + +##### API Hooks "*/(enable|disable|hide|unhide)" + +Enable|disable|hide|unhide a share selected by `PathOrToken` + + * Input + +| Parameter | Mandatory | Default | +| - | - | - | +| PathOrToken | yes | n/a | + + * Output: result status + + * Example: TEXT (enable) + +``` +curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/cal1/" -d "User=user" http://localhost:5232/.sharing/v1/map/enable +ApiVersion=1 +Status=success +``` + + * Example: JSON (unhide) + +``` +curl -u owner:pass -d '{"PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/cal1/", "User": "user"} http://localhost:5232/.sharing/v1/map/unhide +ApiVersion=1 +Status=success +``` + + From 14c7c17917adb52da701fb089bc29947e94ea025 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 20:36:22 +0100 Subject: [PATCH 58/63] change ApiVersion to numeric --- 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 ff3317fb..71e072b7 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -598,7 +598,7 @@ class BaseSharing: answer: dict = {} result: dict = {} result_array: list[dict] - answer['ApiVersion'] = "1" + answer['ApiVersion'] = 1 Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) # action: list From 866b87009262f9d380427e0dea14f860c96eedea Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 20:36:39 +0100 Subject: [PATCH 59/63] sharing: update doc related to numeric API version --- SHARING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SHARING.md b/SHARING.md index 4432802d..62750c61 100644 --- a/SHARING.md +++ b/SHARING.md @@ -144,7 +144,7 @@ PermittedCreateCollectionByToken=True ``` curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq { - "ApiVersion": "1", + "ApiVersion": 1, "Status": "success", "FeatureEnabledCollectionByMap": true, "PermittedCreateCollectionByMap": true, From cba05fe3dd7f13078b644db76b9363cad0b9b1ed Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 21:00:17 +0100 Subject: [PATCH 60/63] Change oauth2_client_id and secret version to 3.7.0 Update minimum version requirement for oauth2_client_id and oauth2_client_secret. --- DOCUMENTATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 2cd2649b..43bba942 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1362,7 +1362,7 @@ Default: (unset) ##### oauth2_client_id -_(>= 3.5.0)_ +_(>= 3.7.0)_ Client ID used to request the Auth2 token @@ -1370,7 +1370,7 @@ Default: `radicale` ##### oauth2_client_secret -_(>= 3.5.0)_ +_(>= 3.7.0)_ Client secret used to request the Auth2 token From 8e517dacf6fe2b407dee714add19d09a0a34c5b1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 21:01:17 +0100 Subject: [PATCH 61/63] Modify OAuth2 client ID and secret in config Updated OAuth2 client ID and secret configuration. --- config | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config b/config index 7aa81fd1..18e29a1f 100644 --- a/config +++ b/config @@ -157,10 +157,10 @@ #oauth2_token_endpoint = # OAuth2 client id -#oauth2_client_id = +#oauth2_client_id = radicale -# OAuth2 client secret -#oauth2_client_secret = +# OAuth2 client secret (optional) +#oauth2_client_secret = # PAM service #pam_serivce = radicale From b1dbae274efa0c578337cc9e396ff50353cd74ba Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 21:08:15 +0100 Subject: [PATCH 62/63] sharing: adjust apiversion testcase --- radicale/tests/test_sharing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 5bcb89b6..febe4732 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -554,14 +554,14 @@ class TestSharingApiSanity(BaseTest): 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['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", check=200, login="owner:ownerpw", json_dict=json_dict) answer_dict = json.loads(answer) - assert answer_dict['ApiVersion'] == "1" + assert answer_dict['ApiVersion'] == 1 assert answer_dict['Status'] == "success" logging.info("\n*** delete token (form->text) -> no longer available") From d13eea01996782e006f67ff75fe9ecd66c2bde3a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Tue, 24 Feb 2026 21:32:16 +0100 Subject: [PATCH 63/63] changelog for https://github.com/Kozea/Radicale/pull/1997 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8757d837..258efbda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 3.7.0.dev * Feature: collection sharing by map or token incl. management API +* Add: [auth] oauth2: allow custom client_id and client_secret (optional) ## 3.6.1