diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 55c5d5ad8..858bb944c 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: diff --git a/config b/config index a28cd873a..0c5b90a6f 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 diff --git a/integ_tests/__init__.py b/integ_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integ_tests/common.py b/integ_tests/common.py new file mode 100644 index 000000000..465dd5eec --- /dev/null +++ b/integ_tests/common.py @@ -0,0 +1,95 @@ +import os +import pathlib +import socket +import subprocess +import sys +import time +from typing import Any, Generator + +from playwright.sync_api import Page + + +def get_free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def start_radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + port = get_free_port() + config_path = tmp_path / "config" + user_path = tmp_path / "users" + storage_path = tmp_path / "collections" + + # Create a local config file + with open(config_path, "w") as f: + f.write( + f"""[server] +hosts = 127.0.0.1:{port} +[storage] +filesystem_folder = {storage_path} +[auth] +type = htpasswd +htpasswd_filename = {user_path} +[web] +type = internal +[sharing] +type = csv +collection_by_map = true +collection_by_token = true +""" + ) + with open(user_path, "w") as f: + f.write( + """admin:adminpassword +""" + ) + + env = os.environ.copy() + # Ensure the radicale package is in PYTHONPATH + # Assuming this test file is in /integ_tests/ + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") + + # Run the server + process = subprocess.Popen( + [sys.executable, "-m", "radicale", "--config", str(config_path)], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Wait for the server to start listening + start_time = time.time() + while time.time() - start_time < 10: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except (OSError, ConnectionRefusedError): + if process.poll() is not None: + _stdout, stderr = process.communicate() + raise RuntimeError( + f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" + ) + time.sleep(0.1) + else: + process.terminate() + process.wait() + raise RuntimeError("Timeout waiting for Radicale to start") + + yield f"http://127.0.0.1:{port}" + + # Cleanup + process.terminate() + process.wait() + + +def login(page: Page, radicale_server: str) -> None: + page.goto(radicale_server) + page.fill('#loginscene input[data-name="user"]', "admin") + page.fill('#loginscene input[data-name="password"]', "adminpassword") + page.click('button:has-text("Next")') + +def create_collection(page: Page, radicale_server: str) -> None: + page.click('.fabcontainer a[data-name="new"]') + page.click('#createcollectionscene button[data-name="submit"]') \ No newline at end of file diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py index e0d07994b..def5dc079 100644 --- a/integ_tests/test_basic_operation.py +++ b/integ_tests/test_basic_operation.py @@ -1,88 +1,20 @@ -import os -import socket -import subprocess -import sys -import time +import pathlib +from typing import Any, Generator import pytest from playwright.sync_api import Page, expect - -def get_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] +from integ_tests.common import login, start_radicale_server @pytest.fixture -def radicale_server(tmp_path): - port = get_free_port() - config_path = tmp_path / "config" - user_path = tmp_path / "users" - storage_path = tmp_path / "collections" - - # Create a local config file - with open(config_path, "w") as f: - f.write( - f"""[server] -hosts = 127.0.0.1:{port} -[storage] -filesystem_folder = {storage_path} -[auth] -type = htpasswd -htpasswd_filename = {user_path} -[web] -type = internal -""" - ) - with open(user_path, "w") as f: - f.write( - """admin:adminpassword -""" - ) - - env = os.environ.copy() - # Ensure the radicale package is in PYTHONPATH - # Assuming this test file is in /integ_tests/ - repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) - env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") - - # Run the server - process = subprocess.Popen( - [sys.executable, "-m", "radicale", "--config", str(config_path)], - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) - # Wait for the server to start listening - start_time = time.time() - while time.time() - start_time < 10: - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.1): - break - except (OSError, ConnectionRefusedError): - if process.poll() is not None: - stdout, stderr = process.communicate() - raise RuntimeError( - f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" - ) - time.sleep(0.1) - else: - process.terminate() - process.wait() - raise RuntimeError("Timeout waiting for Radicale to start") - yield f"http://127.0.0.1:{port}" - - # Cleanup - process.terminate() - process.wait() - - -def test_index_html_loads(page: Page, radicale_server): +def test_index_html_loads(page: Page, radicale_server: str) -> None: """Test that the index.html loads from the server.""" - console_msgs = [] + console_msgs: list[str] = [] page.on("console", lambda msg: console_msgs.append(msg.text)) page.goto(radicale_server) expect(page).to_have_title("Radicale Web Interface") @@ -90,13 +22,9 @@ def test_index_html_loads(page: Page, radicale_server): assert len(console_msgs) == 0 -def test_user_login_works(page: Page, radicale_server): +def test_user_login_works(page: Page, radicale_server: str) -> None: """Test that the login form works.""" - page.goto(radicale_server) - # Fill in the login form - page.fill('#loginscene input[data-name="user"]', "admin") - page.fill('#loginscene input[data-name="password"]', "adminpassword") - page.click('button:has-text("Next")') + login(page, radicale_server) # After login, we should see the collections list (which is empty) expect( diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py new file mode 100644 index 000000000..cb66ddf55 --- /dev/null +++ b/integ_tests/test_sharing.py @@ -0,0 +1,42 @@ +import pathlib +from typing import Any, Generator + +import pytest +from playwright.sync_api import Page, expect + +from integ_tests.common import create_collection, login, start_radicale_server + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) + + +def test_create_and_delete_share_by_key(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) + + page.click('button[data-name="sharebytoken_ro"]') + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(1) + # TODO: Check for R/O state + page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) + page.click('button[data-name="sharebytoken_rw"]') + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(1) + # TODO: Check for R/W state + page.click('tr:not(.hidden) button[data-name="delete"]', strict=True) + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(0) diff --git a/radicale/__main__.py b/radicale/__main__.py index e5eb68db2..f5a76a920 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -1,7 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2011-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -33,7 +33,8 @@ 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 exit_signal_handler(signal_number: int, help="check the storage for errors and exit") parser.add_argument("--verify-item", action="store", nargs=1, help="check the provided item file for errors and exit") + parser.add_argument("--verify-sharing", action="store_true", + help="check the sharing database for errors and exit") parser.add_argument("-C", "--config", help="use specific configuration files", nargs="*") parser.add_argument("-D", "--debug", action="store_const", const="debug", @@ -209,6 +212,19 @@ def exit_signal_handler(signal_number: int, sys.exit(1) return + if args_ns.verify_sharing: + logger.info("Verifying sharing database") + try: + sharing_ = sharing.load(configuration) + if not sharing_.verify(): + logger.critical("Sharing database verification failed") + sys.exit(1) + except Exception as e: + logger.critical("An exception occurred during sharing database " + "verification: %s", e, exc_info=True) + sys.exit(1) + return + # Create a socket pair to notify the server of program shutdown shutdown_socket, shutdown_socket_out = socket.socketpair() diff --git a/radicale/app/base.py b/radicale/app/base.py index aa0af7a24..44f064e73 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 @@ -17,14 +17,14 @@ import io 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) + sharing, storage, types, utils, web, xmlutils) from radicale.log import logger +from radicale.rights import intersect # HACK: https://github.com/tiran/defusedxml/issues/54 import defusedxml.ElementTree as DefusedET # isort:skip @@ -38,6 +38,7 @@ class ApplicationBase: _storage: storage.BaseStorage _rights: rights.BaseRights _web: web.BaseWeb + _sharing: sharing.BaseSharing _encoding: str _max_resource_size: int _permit_delete_collection: bool @@ -51,6 +52,7 @@ def __init__(self, configuration: config.Configuration) -> None: self._storage = storage.load(configuration) self._rights = rights.load(configuration) self._web = web.load(configuration) + self._sharing = sharing.load(configuration) self._encoding = configuration.get("encoding", "request") self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content") self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") @@ -106,15 +108,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 +130,9 @@ def parent_permissions(self) -> str: if self._parent_permissions is None: self._parent_permissions = self._rights.authorization( self.user, self.parent_path) + if self._permissions_filter is not None: + parent_permissions_filtered = intersect(self._parent_permissions, self._permissions_filter) + self._parent_permissions = parent_permissions_filtered return self._parent_permissions def check(self, permission: str, diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 2201e998b..ff22e0c55 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2020 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -57,7 +57,16 @@ class ApplicationPartDelete(ApplicationBase): def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage DELETE request.""" - access = Access(self._rights, user, path) + permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user, path=path, request="DELETE"): diff --git a/radicale/app/get.py b/radicale/app/get.py index 2eac58f1e..cea26f317 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2023 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -76,7 +76,16 @@ def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, return httputils.redirect(location, client.MOVED_PERMANENTLY) # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) - access = Access(self._rights, user, path) + permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("r") and "i" not in access.permissions: return httputils.NOT_ALLOWED with self._storage.acquire_lock("r", user): diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index 53abcdbdd..b892aa6bc 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 @@ -19,7 +19,6 @@ # along with Radicale. If not, see . import errno -import posixpath import re import socket from http import client @@ -55,6 +54,13 @@ def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, logger.warning( "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST + 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"): @@ -62,8 +68,7 @@ def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, if item: return self._webdav_error_response( client.CONFLICT, "D:resource-must-be-null") - parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(path)), True) + parent_path = pathutils.parent_path(path) parent_item = next(iter(self._storage.discover(parent_path)), None) if not parent_item: return httputils.CONFLICT diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 45ad7c4a2..6670349f9 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 @@ -19,7 +19,6 @@ # along with Radicale. If not, see . import errno -import posixpath import re import socket from http import client @@ -62,12 +61,18 @@ def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, if not props.get("tag") and "W" not in permissions: logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'") return httputils.NOT_ALLOWED + 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: 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 diff --git a/radicale/app/move.py b/radicale/app/move.py index 168619e3d..7d8c05fed 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2023 Unrud -# Copyright © 2023-2025 Peter Bieringer +# Copyright © 2023-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -67,7 +67,17 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, # Remote destination server, not supported return httputils.REMOTE_DESTINATION - access = Access(self._rights, user, path) + permissions_filter = None + to_user = user + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED to_path = pathutils.sanitize_path(to_url.path) @@ -76,7 +86,17 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] - to_access = Access(self._rights, user, to_path) + to_permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(to_path, to_user) + if sharing: + # overwrite and run through extended permission check + to_path = sharing['PathMapped'] + to_user = sharing['Owner'] + to_permissions_filter = sharing['Permissions'] + to_access = Access(self._rights, to_user, to_path, to_permissions_filter) + to_access = Access(self._rights, to_user, to_path, to_permissions_filter) if not to_access.check("w"): return httputils.NOT_ALLOWED @@ -94,8 +114,7 @@ def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, to_item = next(iter(self._storage.discover(to_path)), None) if isinstance(to_item, storage.BaseCollection): return httputils.FORBIDDEN - to_parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(to_path)), True) + to_parent_path = pathutils.parent_path(to_path) to_collection = next(iter( self._storage.discover(to_parent_path)), None) if not to_collection: diff --git a/radicale/app/post.py b/radicale/app/post.py index df944499d..a70fc7366 100644 --- a/radicale/app/post.py +++ b/radicale/app/post.py @@ -4,7 +4,7 @@ # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud # Copyright © 2020-2020 Tom Hacohen -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -30,4 +30,6 @@ def do_POST(self, environ: types.WSGIEnviron, base_prefix: str, """Manage POST request.""" if path == "/.web" or path.startswith("/.web/"): return self._web.post(environ, base_prefix, path, user) + elif path == "/.sharing" or path.startswith("/.sharing/"): + return self._sharing.post(environ, base_prefix, path, user) return httputils.METHOD_NOT_ALLOWED diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 62af29492..868f10547 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,7 +24,8 @@ import socket import xml.etree.ElementTree as ET from http import client -from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple +from typing import (Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, + Union) from radicale import (httputils, pathutils, rights, storage, types, utils, xmlutils) @@ -35,7 +36,7 @@ def xml_propfind(base_prefix: str, path: str, xml_request: Optional[ET.Element], allowed_items: Iterable[Tuple[types.CollectionOrItem, str]], - user: str, encoding: str, max_resource_size: int) -> Optional[ET.Element]: + user: str, encoding: str, max_resource_size: int, sharing: Union[dict, None] = None) -> Optional[ET.Element]: """Read and answer PROPFIND requests. Read rfc4918-9.1 for info. @@ -72,7 +73,7 @@ def xml_propfind(base_prefix: str, path: str, write = permission == "w" multistatus.append(xml_propfind_response( base_prefix, path, item, props, user, encoding, write=write, - allprop=allprop, propname=propname, max_resource_size=max_resource_size)) + allprop=allprop, propname=propname, max_resource_size=max_resource_size, sharing=sharing)) return multistatus @@ -80,7 +81,7 @@ def xml_propfind(base_prefix: str, path: str, def xml_propfind_response( base_prefix: str, path: str, item: types.CollectionOrItem, props: Sequence[str], user: str, encoding: str, max_resource_size: int, write: bool = False, - propname: bool = False, allprop: bool = False) -> ET.Element: + propname: bool = False, allprop: bool = False, sharing: Union[dict, None] = None) -> ET.Element: """Build and return a PROPFIND response.""" if propname and allprop or (props and (propname or allprop)): raise ValueError("Only use one of props, propname and allprops") @@ -100,6 +101,9 @@ def xml_propfind_response( collection.path, item.href)) response = ET.Element(xmlutils.make_clark("D:response")) href = ET.Element(xmlutils.make_clark("D:href")) + if sharing: + # backmap + uri = uri.replace(sharing['PathMapped'], sharing['PathOrToken']) href.text = xmlutils.make_href(base_prefix, uri) response.append(href) @@ -178,6 +182,9 @@ def xml_propfind_response( is_collection and collection.is_principal): child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href(base_prefix, path) + if sharing: + # backmap + child_element.text = child_element.text.replace(sharing['PathMapped'], sharing['PathOrToken']) element.append(child_element) elif tag == xmlutils.make_clark("C:supported-calendar-component-set"): human_tag = xmlutils.make_human_tag(tag) @@ -213,6 +220,9 @@ def xml_propfind_response( child_element = ET.Element(xmlutils.make_clark("D:href")) child_element.text = xmlutils.make_href( base_prefix, "/%s/" % user) + if sharing: + # backmap + child_element.text = child_element.text.replace(sharing['Owner'], sharing['User']) element.append(child_element) else: element.append(ET.Element( @@ -373,6 +383,7 @@ def _collect_allowed_items( for item in items: if isinstance(item, storage.BaseCollection): path = pathutils.unstrip_path(item.path, True) + logger.debug("TRACE/PROPFIND/_collect_allowed_items/BaseCollection: path=%r user=%r", path, user) if item.tag: permissions = rights.intersect( self._rights.authorization(user, path), "rw") @@ -405,7 +416,18 @@ def _collect_allowed_items( def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPFIND request.""" - access = Access(self._rights, user, path) + 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 try: @@ -418,8 +440,9 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, logger.debug("Client timed out", exc_info=True) return httputils.REQUEST_TIMEOUT with self._storage.acquire_lock("r", user): + logger.debug("TRACE/PROPFIND: discover path=%r depth=%s", path, http_depth) items_iter = iter(self._storage.discover( - path, environ.get("HTTP_DEPTH", "0"), + path, http_depth, None, self._rights._user_groups)) # take root item for rights checking item = next(items_iter, None) @@ -429,11 +452,32 @@ def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, return httputils.NOT_ALLOWED # put item back items_iter = itertools.chain([item], items_iter) - allowed_items = self._collect_allowed_items(items_iter, user) - headers = {"DAV": httputils.DAV_HEADERS, - "Content-Type": "text/xml; charset=%s" % self._encoding} - xml_answer = xml_propfind(base_prefix, path, xml_content, - allowed_items, user, self._encoding, max_resource_size=self._max_resource_size) - if xml_answer is None: - return httputils.NOT_ALLOWED - return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) + allowed_items = list(self._collect_allowed_items(items_iter, user)) + if self._sharing._enabled: + if http_depth == "1": + logger.debug("TRACE/PROPFIND: get shared collections") + # check for shared collections + collections_shared_map = self._sharing.sharing_collection_map_list(user) + if collections_shared_map: + for sharing in collections_shared_map: + c_share = sharing['PathOrToken'] + c_path = sharing['PathMapped'] + c_user = sharing['Owner'] + c_permissions_filter = sharing['Permissions'] + logger.debug("TRACE/PROPFIND: test shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s", c_share, c_path, c_user, c_permissions_filter) + c_access = Access(self._rights, c_user, c_path, c_permissions_filter) + if not c_access.check("r"): + logger.debug("TRACE/PROPFIND: skip shared collection: PathOrToken=%r PathMapped=%r Owner=%r Permissions=%s (permissions not matching)", c_share, c_path, c_user, c_permissions_filter) + continue + logger.debug("TRACE/PROPFIND: append shared collection: PathOrToken=%r PathMapped=%r Owner=%r", c_share, c_path, c_user) + with self._storage.acquire_lock("r", c_user): + c_items_iter = iter(self._storage.discover(c_path, "0")) + c_allowed_items = list(self._collect_allowed_items(c_items_iter, c_user)) + allowed_items = allowed_items + c_allowed_items + headers = {"DAV": httputils.DAV_HEADERS, + "Content-Type": "text/xml; charset=%s" % self._encoding} + xml_answer = xml_propfind(base_prefix, path, xml_content, + allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, sharing=sharing) + if xml_answer is None: + return httputils.NOT_ALLOWED + return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index caaf7b7a8..9c527c12b 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 @@ -24,7 +24,7 @@ import socket import xml.etree.ElementTree as ET from http import client -from typing import Dict, Optional, cast +from typing import Dict, Optional, Union, cast import defusedxml.ElementTree as DefusedET @@ -37,7 +37,7 @@ def xml_proppatch(base_prefix: str, path: str, xml_request: Optional[ET.Element], - collection: storage.BaseCollection) -> ET.Element: + collection: storage.BaseCollection, sharing: Union[dict, None] = None) -> ET.Element: """Read and answer PROPPATCH requests. Read rfc4918-9.2 for info. @@ -48,6 +48,9 @@ def xml_proppatch(base_prefix: str, path: str, multistatus.append(response) href = ET.Element(xmlutils.make_clark("D:href")) href.text = xmlutils.make_href(base_prefix, path) + if sharing: + # backmap + href.text = href.text.replace(sharing['PathMapped'], sharing['PathOrToken']) response.append(href) # Create D:propstat element for props with status 200 OK propstat = ET.Element(xmlutils.make_clark("D:propstat")) @@ -75,7 +78,17 @@ class ApplicationPartProppatch(ApplicationBase): def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" - access = Access(self._rights, user, path) + permissions_filter = None + sharing = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED try: @@ -99,7 +112,7 @@ def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, "Content-Type": "text/xml; charset=%s" % self._encoding} try: xml_answer = xml_proppatch(base_prefix, path, xml_content, - item) + item, sharing) if xml_content is not None: content = DefusedET.tostring( xml_content, diff --git a/radicale/app/put.py b/radicale/app/put.py index 86e863ef3..4e62ec1df 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 @@ -181,7 +181,17 @@ class ApplicationPartPut(ApplicationBase): def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PUT request.""" - access = Access(self._rights, user, path) + permissions_filter = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED try: diff --git a/radicale/app/report.py b/radicale/app/report.py index 917a2d752..52dfc356d 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 @@ -149,8 +149,8 @@ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Eleme def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection, encoding: str, unlock_storage_fn: Callable[[], None], - max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "" - ) -> Tuple[int, ET.Element]: + max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "", + sharing: Union[dict, None] = None) -> Tuple[int, ET.Element]: """Read and answer REPORT requests that return XML. Read rfc3253-3.6 for info. @@ -354,7 +354,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], if found_props or not_found_props: multistatus.append(xml_item_response( base_prefix, uri, found_props=found_props, - not_found_props=not_found_props, found_item=True)) + not_found_props=not_found_props, found_item=True, sharing=sharing)) return client.MULTI_STATUS, multistatus @@ -705,11 +705,13 @@ def _find_overridden( def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), - found_item: bool = True) -> ET.Element: + found_item: bool = True, sharing: Union[dict, None] = None) -> ET.Element: response = ET.Element(xmlutils.make_clark("D:response")) href_element = ET.Element(xmlutils.make_clark("D:href")) href_element.text = xmlutils.make_href(base_prefix, href) + if sharing: + href_element.text = href_element.text.replace(sharing['PathMapped'], sharing['PathOrToken']) response.append(href_element) if found_item: @@ -810,7 +812,17 @@ class ApplicationPartReport(ApplicationBase): def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" - access = Access(self._rights, user, path) + permissions_filter = None + sharing = None + if self._sharing._enabled: + # Sharing by token or map (if enabled) + sharing = self._sharing.sharing_collection_resolver(path, user) + if sharing: + # overwrite and run through extended permission check + path = sharing['PathMapped'] + user = sharing['Owner'] + permissions_filter = sharing['Permissions'] + access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED try: @@ -852,7 +864,7 @@ def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, try: status, xml_answer = xml_report( base_prefix, path, xml_content, collection, self._encoding, - lock_stack.close, max_occurrence, user, remote_host, remote_useragent) + lock_stack.close, max_occurrence, user, remote_host, remote_useragent, sharing=sharing) except ValueError as e: logger.warning( "Bad REPORT request on %r: %s", path, e, exc_info=True) diff --git a/radicale/config.py b/radicale/config.py index 519269bc8..bdca5526d 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -3,7 +3,7 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2017-2020 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -37,7 +37,7 @@ from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) -from radicale import auth, hook, rights, storage, types, web +from radicale import auth, hook, rights, sharing, storage, types, web from radicale.hook import email from radicale.item import check_and_sanitize_props @@ -454,6 +454,24 @@ def json_str(value: Any) -> dict: "value": "", "help": "predefined user collections", "type": json_str})])), + ("sharing", OrderedDict([ + ("type", { + "value": "none", + "help": "sharing database type", + "type": str_or_callable, + "internal": sharing.INTERNAL_TYPES}), + ("database_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", diff --git a/radicale/httputils.py b/radicale/httputils.py index 81e017153..d2829ea82 100644 --- a/radicale/httputils.py +++ b/radicale/httputils.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -106,6 +106,10 @@ FALLBACK_MIMETYPE: str = "application/octet-stream" +def bad_request(additional_details: str) -> types.WSGIResponse: + return (client.BAD_REQUEST, (("Content-Type", "text/plain"),), f"Bad Request: {additional_details}", None) + + def decode_request(configuration: "config.Configuration", environ: types.WSGIEnviron, text: bytes) -> str: """Try to magically decode ``text`` according to given ``environ``.""" diff --git a/radicale/pathutils.py b/radicale/pathutils.py index 3193e4a22..488dbfc00 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -2,7 +2,7 @@ # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -293,6 +293,10 @@ def path_to_filesystem(root: str, sane_path: str) -> str: return safe_path +def parent_path(path: str) -> str: + return unstrip_path(posixpath.dirname(strip_path(path)), True) + + class UnsafePathError(ValueError): def __init__(self, path: str) -> None: diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py new file mode 100644 index 000000000..e65530a40 --- /dev/null +++ b/radicale/sharing/__init__.py @@ -0,0 +1,886 @@ +# 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 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: + logger.debug("TRACE/sharing_by_token: not active") + return None + + if self.sharing_collection_by_map: + result = self.sharing_collection_by_map_resolver(path, user) + if result is not None: + return result + else: + logger.debug("TRACE/sharing_by_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: + logger.debug("TRACE/sharing_by_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: + logger.debug("TRACE/sharing_by_token: check path: %r", path) + if path.startswith("/.token/"): + pattern = re.compile('^/\\.token/' + TOKEN_PATTERN_V1 + '$') + match = pattern.match(path) + if not match: + logger.debug("TRACE/sharing_by_token: unsupported token: %r", path) + return None + else: + # TODO add token validity checks + logger.debug("TRACE/sharing_by_token: supported token found in path: %r (token=%r)", path, match[1]) + return self.get_sharing( + ShareType="token", + PathOrToken=match[1]) + else: + logger.debug("TRACE/sharing_by_token: no supported prefix found in path: %r", path) + return None + else: + logger.debug("TRACE/sharing_by_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: + logger.debug("TRACE/sharing/resolver/map: 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) + logger.debug("TRACE/sharing/resolver/map: check parent path: %r", parent_path) + result = self.get_sharing( + ShareType="map", + PathOrToken=parent_path, + User=user) + if result: + result['PathMapped'] = path.replace(parent_path, result['PathMapped']) + logger.debug("TRACE/sharing/resolver/map: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path) + return result + else: + logger.debug("TRACE/sharing_by_map: not found") + return None + else: + logger.debug("TRACE/sharing_by_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: + 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: + 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: + logger.debug("TRACE/sharing/API: action not whitelisted: %r", action) + return httputils.NOT_FOUND + + logger.debug("TRACE/sharing/API: called by authenticated user: %r", user) + # read POST data + try: + 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") + logger.debug("TRACE/" + api_info + " (json): %r", f"{request_data}") + elif 'application/x-www-form-urlencoded' in content_type: + request_parsed = parse_qs(request_body) + # convert arrays into single value + request_data = {} + for key in request_parsed: + request_data[key] = request_parsed[key][0] + logger.debug("TRACE/" + api_info + " (form): %r", f"{request_data}") + else: + 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": + logger.debug("TRACE/" + api_info + ": start") + if 'PathOrToken' in request_data: + PathOrToken = request_data['PathOrToken'] + 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": + 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') + + 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) + 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 + + 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") + + 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": + 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": + 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: + 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 + 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 diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py new file mode 100644 index 000000000..f79899bc5 --- /dev/null +++ b/radicale/sharing/csv.py @@ -0,0 +1,499 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2026-2026 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +import csv +import os +from 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 + logger.debug("TRACE/sharing: lookup ShareType=%r PathOrToken=%r User=%r)", ShareType, PathOrToken, User) + + index = 0 + found = False + for row in self._sharing_cache: + if index == 0: + # skip fieldnames + pass + logger.debug("TRACE/sharing: check row: %r", row) + if row['ShareType'] != ShareType: + 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']) + logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden) + return { + "mapped": True, + "PathOrToken": PathOrToken, + "PathMapped": PathMapped, + "Owner": Owner, + "User": UserShare, + "Hidden": Hidden, + "Permissions": Permissions} + return None + + def list_sharing(self, + OwnerOrUser: Union[str, None] = None, + ShareType: Union[str, None] = None, + PathOrToken: Union[str, None] = None, + PathMapped: Union[str, None] = None, + User: Union[str, None] = None, + EnabledByOwner: Union[bool, None] = None, + EnabledByUser: Union[bool, None] = None, + HiddenByOwner: Union[bool, None] = None, + HiddenByUser: Union[bool, None] = None) -> list[dict]: + """ retrieve sharing """ + row: dict + index = 0 + result = [] + + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, HiddenByOwner, HiddenByUser) + + for row in self._sharing_cache: + if index == 0: + # skip fieldnames + pass + else: + logger.debug("TRACE/sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: + logger.debug("TRACE/sharing/list/row: skip by ShareType") + pass + elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): + pass + elif User is not None and row['User'] != User: + logger.debug("TRACE/sharing/list/row: skip by User") + pass + elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: + logger.debug("TRACE/sharing/list/row: skip by PathOrToken") + pass + elif PathMapped is not None and row['PathMapped'] != PathMapped: + logger.debug("TRACE/sharing/list/row: skip by PathMapped") + pass + elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: + pass + elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: + pass + elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: + pass + elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: + pass + else: + logger.debug("TRACE/sharing/list/row: add: %r", row) + result.append(row) + index += 1 + return result + + def create_sharing(self, + 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 + + logger.debug("TRACE/sharing: ShareType=%r", ShareType) + if ShareType == "token": + logger.debug("TRACE/sharing/token/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) + # check for duplicate token entry + 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": + 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} + 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): + 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 """ + 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: + logger.debug("TRACE/sharing/%s/update: found index=%d", ShareType, index) + if Owner is not None and row['Owner'] != Owner: + return {"status": "permission-denied"} + if User is not None and row['User'] != User: + return {"status": "permission-denied"} + logger.debug("TRACE/sharing/%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 + + 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): + 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 """ + logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r PathMapped=%r", ShareType, PathOrToken, Owner, PathMapped) + + # lookup token + found = False + index = 0 + for row in self._sharing_cache: + logger.debug("TRACE/sharing/%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: + logger.debug("TRACE/sharing/%s/delete: found index=%d", ShareType, index) + if row['Owner'] != Owner: + return {"status": "permission-denied"} + 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): + 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 + + 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 + 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: + # 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: + 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: + 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): + 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 diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py new file mode 100644 index 000000000..ab184557b --- /dev/null +++ b/radicale/sharing/files.py @@ -0,0 +1,421 @@ +# 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 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 + 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']) + 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 = [] + + logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, HiddenByOwner, HiddenByUser) + + for _ShareType in sharing.SHARE_TYPES_V1: + if ShareType is not None and _ShareType != ShareType: + # skip + continue + + path = self._sharing_db_path_ShareType[_ShareType] + with self._storage.acquire_lock("r", OwnerOrUser, path=path): + for entry in os.scandir(path): + if not entry.is_file(): + continue + + logger.debug("TRACE/sharing/list: check file: %r", entry.name) + # read file + with open(entry, "rb") as fb: + (version, row) = pickle.load(fb) + + if version != DB_VERSION: + # skip + continue + + logger.debug("TRACE/sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: + logger.debug("TRACE/sharing/list/row: skip by ShareType") + pass + elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): + pass + elif User is not None and row['User'] != User: + logger.debug("TRACE/sharing/list/row: skip by User") + pass + elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: + logger.debug("TRACE/sharing/list/row: skip by PathOrToken") + pass + elif PathMapped is not None and row['PathMapped'] != PathMapped: + logger.debug("TRACE/sharing/list/row: skip by PathMapped") + pass + elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: + pass + elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: + pass + elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: + pass + elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: + pass + else: + logger.debug("TRACE/sharing/list/row: add: %r", row) + result.append(row) + + return result + + def create_sharing(self, + ShareType: str, + PathOrToken: str, PathMapped: str, + Owner: str, User: str, + Permissions: str = "r", + EnabledByOwner: bool = False, EnabledByUser: bool = False, + HiddenByOwner: bool = True, HiddenByUser: bool = True, + Timestamp: int = 0) -> dict: + """ create sharing """ + row: dict + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + logger.debug("TRACE/sharing/%s/create: sharing_config_file=%r", ShareType, sharing_config_file) + logger.debug("TRACE/sharing/%s/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", ShareType, PathOrToken, Owner, PathMapped, User, Permissions) + if os.path.isfile(sharing_config_file): + return {"status": "conflict"} + + row = {"ShareType": ShareType, + "PathOrToken": PathOrToken, + "PathMapped": PathMapped, + "Owner": Owner, + "User": User, + "Permissions": Permissions, + "EnabledByOwner": EnabledByOwner, + "EnabledByUser": EnabledByUser, + "HiddenByOwner": HiddenByOwner, + "HiddenByUser": HiddenByUser, + "TimestampCreated": Timestamp, + "TimestampUpdated": Timestamp} + + version = DB_VERSION + + try: + with self._storage.acquire_lock("w", Owner, path=sharing_config_file): + logger.debug("TRACE/sharing/%s/create: store share-config: %r into file %r", ShareType, row, sharing_config_file) + # write file + with open(sharing_config_file, "wb") as fb: + pickle.dump((version, row), fb) + logger.debug("TRACE/sharing/*/create: share-config file stored: %r", sharing_config_file) + return {"status": "success"} + except Exception as e: + logger.error("sharing/%s/create: cannot store share-config: %r (%r)", ShareType, sharing_config_file, e) + return {"status": "error"} + + def update_sharing(self, + ShareType: str, + PathOrToken: str, + Owner: Union[str, None] = None, + User: Union[str, None] = None, + PathMapped: Union[str, None] = None, + Permissions: Union[str, None] = None, + EnabledByOwner: Union[bool, None] = None, + HiddenByOwner: Union[bool, None] = None, + Timestamp: int = 0) -> dict: + """ update sharing """ + logger.debug("TRACE/sharing/%s/update: PathOrToken=%r Owner=%r User=%r", ShareType, PathOrToken, Owner, User) + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + if not os.path.isfile(sharing_config_file): + return {"status": "not-found"} + + # read content + with self._storage.acquire_lock("w", Owner, path=sharing_config_file): + # read file + with open(sharing_config_file, "rb") as fb: + (version, row) = pickle.load(fb) + + if version != DB_VERSION: + return {"status": "error"} + + logger.debug("TRACE/sharing/%s/update: check: %r", ShareType, row) + + if Owner is not None and row['Owner'] != Owner: + return {"status": "permission-denied"} + if User is not None and row['User'] != User: + return {"status": "permission-denied"} + + logger.debug("TRACE/sharing/%s/update: orig row=%r", ShareType, row) + + if PathMapped is not None: + row["PathMapped"] = PathMapped + if Permissions is not None: + row["Permissions"] = Permissions + if User is not None: + row["User"] = User + if EnabledByOwner is not None: + row["EnabledByOwner"] = EnabledByOwner + if HiddenByOwner is not None: + row["HiddenByOwner"] = HiddenByOwner + # update timestamp + row["TimestampUpdated"] = Timestamp + + logger.debug("TRACE/sharing/%s/update: adj row=%r", ShareType, row) + + try: + # write file + with open(sharing_config_file, "wb") as fb: + pickle.dump((version, row), fb) + logger.debug("TRACE/sharing/%s/create: share-config file stored: %r", ShareType, sharing_config_file) + return {"status": "success"} + except Exception as e: + logger.error("sharing/%s/create: cannot store share-config: %r (%r)", ShareType, sharing_config_file, e) + return {"status": "error"} + + def delete_sharing(self, + ShareType: str, + PathOrToken: str, Owner: str, + PathMapped: Union[str, None] = None) -> dict: + """ delete sharing """ + logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r Owner=%r", ShareType, PathOrToken, Owner) + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + if not os.path.isfile(sharing_config_file): + return {"status": "not-found"} + + # read content + with self._storage.acquire_lock("r", Owner, path=sharing_config_file): + # read file + with open(sharing_config_file, "rb") as fb: + (version, row) = pickle.load(fb) + + if version != DB_VERSION: + return {"status": "error"} + + # verify owner + if row['Owner'] != Owner: + return {"status": "permission-denied"} + + try: + os.remove(sharing_config_file) + except Exception as e: + logger.error("sharing/%s/delete: cannot remove share-config: %r (%r)", ShareType, sharing_config_file, e) + return {"status": "error"} + + logger.debug("sharing/%s/delete: successful removed share-config: %r", ShareType, sharing_config_file) + return {"status": "success"} + + def toggle_sharing(self, + ShareType: str, + PathOrToken: str, + OwnerOrUser: str, + Action: str, + PathMapped: Union[str, None] = None, + User: Union[str, None] = None, + Timestamp: int = 0) -> dict: + """ toggle sharing """ + row: dict + + logger.debug("TRACE/sharing/%s/%s: OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r", ShareType, Action, OwnerOrUser, User, PathOrToken, PathMapped) + + if Action not in sharing.API_SHARE_TOGGLES_V1: + # should not happen + raise + + sharing_config_file = os.path.join(self._sharing_db_path_ShareType[ShareType], self._encode_path(PathOrToken)) + + if not os.path.isfile(sharing_config_file): + return {"status": "not-found"} + + # read content + with self._storage.acquire_lock("w", OwnerOrUser, path=sharing_config_file): + # read file + with open(sharing_config_file, "rb") as fb: + (version, row) = pickle.load(fb) + + if version != DB_VERSION: + return {"status": "error"} + + logger.debug("TRACE/sharing/%s/%s: check: %r", ShareType, Action, row) + + # verify ownership or user + if User is not None and row['User'] != User: + return {"status": "permission-denied"} + elif row['Owner'] == OwnerOrUser: + pass + elif row['User'] == OwnerOrUser: + pass + else: + return {"status": "permission-denied"} + + if row['Owner'] == OwnerOrUser: + logger.debug("TRACE/sharing/%s/%s: Owner=%r User=%r PathOrToken=%r", ShareType, Action, OwnerOrUser, User, PathOrToken) + if Action == "disable": + row['EnabledByOwner'] = False + elif Action == "enable": + row['EnabledByOwner'] = True + elif Action == "hide": + row['HiddenByOwner'] = True + elif Action == "unhide": + row['HiddenByOwner'] = False + row['TimestampUpdated'] = Timestamp + if row['User'] == OwnerOrUser: + logger.debug("TRACE/sharing/%s/%s: User=%r PathOrToken=%r", ShareType, Action, OwnerOrUser, PathOrToken) + if Action == "disable": + row['EnabledByUser'] = False + elif Action == "enable": + row['EnabledByUser'] = True + elif Action == "hide": + row['HiddenByUser'] = True + elif Action == "unhide": + row['HiddenByUser'] = False + + row['TimestampUpdated'] = Timestamp + + try: + # write file + with open(sharing_config_file, "wb") as fb: + pickle.dump((version, row), fb) + logger.debug("TRACE/sharing/%s/create: share-config file stored: %r", ShareType, sharing_config_file) + return {"status": "success"} + except Exception as e: + logger.error("sharing/%s/create: cannot store share-config: %r (%r)", ShareType, sharing_config_file, e) + return {"status": "error"} + + # local functions + def _encode_path(self, path: str) -> str: + return urllib.parse.quote(path, safe="") + + def _decode_path(self, path: str) -> str: + return urllib.parse.unquote(path) diff --git a/radicale/sharing/none.py b/radicale/sharing/none.py new file mode 100644 index 000000000..1264602a4 --- /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} diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index 5b637159c..30e8999dc 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -89,6 +89,8 @@ def request(self, method: str, path: str, data: Optional[str] = None, (str, type(http_if_match))) remote_useragent = kwargs.pop("remote_useragent", None) remote_host = kwargs.pop("remote_host", None) + content_type = kwargs.pop("content_type", None) + accept = kwargs.pop("accept", None) environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()} for k, v in environ.items(): if not isinstance(v, str): @@ -104,6 +106,10 @@ def request(self, method: str, path: str, data: Optional[str] = None, environ["HTTP_USER_AGENT"] = remote_useragent if remote_host: environ["REMOTE_ADDR"] = remote_host + if content_type: + environ["CONTENT_TYPE"] = content_type + if accept: + environ["HTTP_ACCEPT"] = accept environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path if data is not None: diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py new file mode 100644 index 000000000..5bcb89b6c --- /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) diff --git a/radicale/web/internal_data/CollectionsScene.js b/radicale/web/internal_data/CollectionsScene.js index 214c3a435..2c8b05fa1 100644 --- a/radicale/web/internal_data/CollectionsScene.js +++ b/radicale/web/internal_data/CollectionsScene.js @@ -20,6 +20,7 @@ import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js"; +import { CreateShareCollectionScene } from "./ShareCollectionScene.js"; import { UploadCollectionScene } from "./UploadCollectionScene.js"; import { DeleteCollectionScene } from "./DeleteCollectionScene.js"; import { LoadingScene } from "./LoadingScene.js"; @@ -78,6 +79,16 @@ export function CollectionsScene(user, password, collection, onerror) { return false; } + function onshare(collection) { + try { + let share_collection_scene = new CreateShareCollectionScene(user, password, collection); + push_scene(share_collection_scene, false); + } catch(err) { + console.error(err); + } + return false; + } + function ondelete(collection) { try { let delete_collection_scene = new DeleteCollectionScene(user, password, collection); @@ -102,6 +113,7 @@ export function CollectionsScene(user, password, collection, onerror) { let color_form = node.querySelector("[data-name=color]"); let delete_btn = node.querySelector("[data-name=delete]"); let edit_btn = node.querySelector("[data-name=edit]"); + let share_btn = node.querySelector("[data-name=share]"); let download_btn = node.querySelector("[data-name=download]"); if (collection.color) { color_form.style.background = collection.color; @@ -144,6 +156,7 @@ export function CollectionsScene(user, password, collection, onerror) { } delete_btn.onclick = function() {return ondelete(collection);}; edit_btn.onclick = function() {return onedit(collection);}; + share_btn.onclick = function() {return onshare(collection);}; node.classList.remove("hidden"); nodes.push(node); template.parentNode.insertBefore(node, template); diff --git a/radicale/web/internal_data/LoginScene.js b/radicale/web/internal_data/LoginScene.js index 1314df087..63c68f2f0 100644 --- a/radicale/web/internal_data/LoginScene.js +++ b/radicale/web/internal_data/LoginScene.js @@ -20,8 +20,9 @@ import { Scene, push_scene, pop_scene, scene_stack } from "./scene_manager.js"; import { LoadingScene } from "./LoadingScene.js"; -import { get_principal } from "./api.js"; +import { get_principal, discover_server_features } from "./api.js"; import { CollectionsScene } from "./CollectionsScene.js"; +import { maybe_enable_sharing_options } from "./ShareCollectionScene.js"; /** * @constructor @@ -89,6 +90,7 @@ export function LoginScene() { error = error1; user = saved_user; }); + discover_server_features(saved_user, password, maybe_enable_sharing_options); push_scene(collections_scene, true); } }); diff --git a/radicale/web/internal_data/ShareCollectionScene.js b/radicale/web/internal_data/ShareCollectionScene.js new file mode 100644 index 000000000..37a830bf9 --- /dev/null +++ b/radicale/web/internal_data/ShareCollectionScene.js @@ -0,0 +1,164 @@ +/** + * This file is part of Radicale Server - Calendar Server + * Copyright © 2017-2024 Unrud + * Copyright © 2023-2024 Matthew Hana + * Copyright © 2024-2025 Peter Bieringer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { + add_share_by_token, + delete_share_by_token, + reload_sharing_list, + server_features, +} from "./api.js"; +import { pop_scene, scene_stack } from "./scene_manager.js"; + +/** + * @constructor + * @implements {Scene} + * @param {string} user + * @param {string} password + * @param {Collection} collection The collection on which to edit sharing setting. Must exist. + */ +export function CreateShareCollectionScene(user, password, collection) { + /** @type {?number} */ let scene_index = null; + + let html_scene = document.getElementById("sharecollectionscene"); + + let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + let share_by_token_btn_ro = html_scene.querySelector( + "[data-name=sharebytoken_ro]", + ); + let share_by_token_btn_rw = html_scene.querySelector( + "[data-name=sharebytoken_rw]", + ); + + let title = html_scene.querySelector("[data-name=title]"); + + function oncancel() { + try { + pop_scene(scene_index - 1); + } catch (err) { + console.error(err); + } + return false; + } + + function onsharebytoken_rw() { + add_share_by_token(user, password, collection, "rw", function () { + update_share_list(user, password, collection); + }); + } + + function onsharebytoken_ro() { + add_share_by_token(user, password, collection, "r", function () { + update_share_list(user, password, collection); + }); + } + + this.show = function () { + this.release(); + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + cancel_btn.onclick = oncancel; + if (server_features["sharing"]["FeatureEnabledCollectionByToken"]) { + share_by_token_btn_ro.onclick = onsharebytoken_ro; + share_by_token_btn_rw.onclick = onsharebytoken_rw; + } else { + share_by_token_btn_ro.parentElement.removeChild(share_by_token_btn_ro); + share_by_token_btn_rw.parentElement.removeChild(share_by_token_btn_rw); + } + title.textContent = collection.displayname || collection.href; + update_share_list(user, password, collection); + }; + this.hide = function () { + html_scene.classList.add("hidden"); + cancel_btn.onclick = null; + }; + this.release = function () { + scene_index = null; + }; +} + +function update_share_list(user, password, collection) { + let share_rows = document.querySelectorAll( + "[data-name=sharetokenrowtemplate]", + ); + share_rows.forEach(function (row) { + if (!row.classList.contains("hidden")) { + row.parentNode.removeChild(row); + } + }); + + reload_sharing_list(user, password, collection, function (response) { + add_share_rows(user, password, collection, response["Content"] || []); + }); +} + +function add_share_rows(user, password, collection, shares) { + let template = document.querySelector("[data-name=sharetokenrowtemplate]"); + shares.forEach(function (share) { + let pathortoken = share["PathOrToken"] || ""; + let pathmapped = share["PathMapped"] || ""; + if ( + collection.href.includes(pathmapped) || + collection.href.includes(pathortoken) + ) { + let node = template.cloneNode(true); + node.classList.remove("hidden"); + node.querySelector("[data-name=pathortoken]").value = pathortoken; + let permissions = (share["Permissions"] || "").toLowerCase(); + if (permissions === "rw") { + node + .querySelector("[data-name=ro]") + .parentNode.removeChild(node.querySelector("[data-name=ro]")); + } else if (permissions === "r") { + node + .querySelector("[data-name=rw]") + .parentNode.removeChild(node.querySelector("[data-name=rw]")); + } else { + console.warn("Unknown permissions", permissions); + } + node.querySelector("[data-name=delete]").onclick = function () { + delete_share_by_token( + user, + password, + share["PathOrToken"], + function () { + update_share_list(user, password, collection); + }, + ); + }; + + template.parentNode.insertBefore(node, template); + } + }); +} + +export function maybe_enable_sharing_options() { + if (!server_features["sharing"]) return; + let map_is_enabled = + server_features["sharing"]["FeatureEnabledCollectionByMap"] || false; + let token_is_enabled = + server_features["sharing"]["FeatureEnabledCollectionByToken"] || false; + if (map_is_enabled || token_is_enabled) { + let share_options = document.querySelectorAll("[data-name=shareoption]"); + for (let i = 0; i < share_options.length; i++) { + let share_option = share_options[i]; + share_option.classList.remove("hidden"); + } + } +} diff --git a/radicale/web/internal_data/api.js b/radicale/web/internal_data/api.js index 3a84dfb4f..eaf818acf 100644 --- a/radicale/web/internal_data/api.js +++ b/radicale/web/internal_data/api.js @@ -22,6 +22,8 @@ import { Collection, CollectionType } from "./models.js"; import { SERVER, ROOT_PATH, COLOR_RE } from "./constants.js"; import { escape_xml } from "./utils.js"; +export let server_features = {}; + /** * Find the principal collection. * @param {string} user @@ -339,4 +341,131 @@ export function create_collection(user, password, collection, callback) { */ export function edit_collection(user, password, collection, callback) { return create_edit_collection(user, password, collection, false, callback); -} \ No newline at end of file +} +/* Sharing API */ + +function call_sharing_api( + user, + password, + path, + body, + on_success, + on_not_found = null, + on_error = null, +) { + let request = new XMLHttpRequest(); + request.open( + "POST", + SERVER + ROOT_PATH + ".sharing/v1/" + path, + true, + user, + encodeURIComponent(password), + ); + request.onreadystatechange = function () { + if (request.readyState !== 4) { + return; + } + if (200 <= request.status && request.status < 300) { + on_success(request.responseText); + } else if (request.status === 404) { + if (on_not_found) { + on_not_found(); + } else if (on_error) { + on_error("Not found"); + } else { + console.error("Not found"); + } + } else { + if (on_error) { + on_error(request.status + " " + request.statusText); + } else { + console.error(request.status + " " + request.statusText); + } + } + }; + request.setRequestHeader("Accept", "application/json"); + request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + request.send(body ? JSON.stringify(body) : null); + return request; +} + +export function discover_server_features(user, password, callback) { + call_sharing_api( + user, + password, + "all/info", + {}, + function (response) { + server_features["sharing"] = JSON.parse(response); + callback(); + }, + function () { + // sharing is disabled on the server + server_features["sharing"] = {}; + callback(); + }, + function (error) { + console.error("Failed to discover sharing features: " + error); + }, + ); +} + +export function reload_sharing_list(user, password, collection, callback) { + call_sharing_api( + user, + password, + "all/list", + { PathMapped: collection.href }, + function (response) { + callback(JSON.parse(response)); + }, + ); +} + +export function add_share_by_token( + user, + password, + collection, + permissions, + callback, +) { + call_sharing_api( + user, + password, + "token/create", + { + PathMapped: collection.href, + Permissions: permissions, + }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create share token: " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); +} + +export function delete_share_by_token( + user, + password, + token, + callback, +) { + call_sharing_api( + user, + password, + "token/delete", + { PathOrToken: token }, + function (response) { + let json_response = JSON.parse(response); + if (json_response["Status"] !== "success") { + console.error("Failed to create delete token " + token + ": " + (json_response["Status"] || "Unknown error")); + } else { + callback(); + } + }, + ); +} diff --git a/radicale/web/internal_data/css/icons/credits.md b/radicale/web/internal_data/css/icons/credits.md new file mode 100644 index 000000000..39948ae6a --- /dev/null +++ b/radicale/web/internal_data/css/icons/credits.md @@ -0,0 +1,21 @@ +# Credits + +## share.svg + +* +* MIT License + +## key.svg + +* +* MIT License + +## repeat.svg + +* +* MIT License + +## eye.svg + +* +* MIT License diff --git a/radicale/web/internal_data/css/icons/eye.svg b/radicale/web/internal_data/css/icons/eye.svg new file mode 100755 index 000000000..65d96f767 --- /dev/null +++ b/radicale/web/internal_data/css/icons/eye.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/key.svg b/radicale/web/internal_data/css/icons/key.svg new file mode 100644 index 000000000..e778e74eb --- /dev/null +++ b/radicale/web/internal_data/css/icons/key.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/repeat.svg b/radicale/web/internal_data/css/icons/repeat.svg new file mode 100644 index 000000000..c7657b08e --- /dev/null +++ b/radicale/web/internal_data/css/icons/repeat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/share.svg b/radicale/web/internal_data/css/icons/share.svg new file mode 100644 index 000000000..09b1c7bcd --- /dev/null +++ b/radicale/web/internal_data/css/icons/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 1e2dcef01..8c4086f9e 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -39,6 +39,14 @@ main{ color: #484848; } +.container h2{ + margin: 0; + width: 100%; + text-align: left; + color: #484848; + font-size: 1.5em; +} + #loginscene .infcloudlink{ margin: 0; width: 100%; @@ -303,6 +311,29 @@ main{ filter: invert(1); } +.small_icon{ + width: 1em; + height: 1em; + filter: invert(1); +} + +.med_icon{ + width: 1.5em; + height: 1.5em; +} + +.badge_icon{ + width: 1em; + height: 1em; + position: absolute; + top: -5px; + right: -5px; + filter: invert(1); + border-radius: 50%; + background-color: white; +} + + .smalltext{ font-size: 75% !important; } @@ -345,6 +376,7 @@ button{ margin-left: 10px; background: black; cursor: pointer; + position: relative; } input, select{ @@ -360,6 +392,10 @@ input, select{ outline: none !important; } +input.inline { + margin-bottom: 0 !important; +} + input[type=text], input[type=password]{ width: calc(100% - 30px); } @@ -414,6 +450,12 @@ button.blue:active, a.blue:active{ cursor: pointer !important; } +button.inline { + padding-inline: 1px; + min-width: 2em; + margin: 0; +} + @media only screen and (max-width: 600px) { #collectionsscene{ flex-direction: column !important; diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 16ef91d3f..8689fc38a 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -90,6 +90,11 @@

Title

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

    Edit Collection


    + +