# This file is part of Radicale Server - Calendar Server # Copyright © 2026-2026 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . import logging import os import pickle import urllib from typing import Union from radicale import sharing from radicale.log import logger """ File 'database' based sharing by token or map """ DB_VERSION: str = "1" class Sharing(sharing.BaseSharing): _sharing_database_path_ShareType: dict = {} # Overloaded functions def database_init(self) -> bool: logger.debug("sharing database initialization for type 'files'") sharing_database_path = self.configuration.get("sharing", "database_path") if sharing_database_path == "": folder = self.configuration.get("storage", "filesystem_folder") folder_db = os.path.join(folder, "collection-db") sharing_database_path = os.path.join(folder_db, "files") logger.info("sharing database path not provided, use default: %r", sharing_database_path) else: logger.info("sharing database path: %r", sharing_database_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_database_path): logger.warning("sharing database path is not existing: %r", sharing_database_path) try: os.mkdir(sharing_database_path) except Exception as e: logger.error("sharing database path cannot be created (check permissions): %r (%r)", sharing_database_path, e) return False logger.info("sharing database path successfully created: %r", sharing_database_path) for ShareType in sharing.SHARE_TYPES_V1: path = os.path.join(sharing_database_path, ShareType) self._sharing_database_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 database_get_info(self) -> Union[dict, None]: database_info = {'type': "files"} return database_info def database_verify(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_database_path_ShareType[ShareType]) # TODO: count amount of files logger.info("sharing database (files) verification end") return True def database_get_sharing(self, ShareType: str, PathOrToken: str, OnlyEnabled: bool = True, User: Union[str, None] = None) -> Union[dict, None]: """ retrieve sharing target and attributes by map """ # Lookup if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/get: PathOrToken=%r User=%r)", ShareType, PathOrToken, User) sharing_config_file = os.path.join(self._sharing_database_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 OnlyEnabled is True and row['EnabledByOwner'] is not True: return None elif OnlyEnabled is True and 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']) Properties: Union[dict, None] = None if 'Properties' in row: Properties = row['Properties'] if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s Properties=%r)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden, Properties) return { "mapped": True, "ShareType": ShareType, "PathOrToken": PathOrToken, "PathMapped": PathMapped, "Owner": Owner, "User": UserShare, "Hidden": Hidden, "Permissions": Permissions, "Properties": Properties} return None def database_list_sharing(self, OwnerOrUser: Union[str, None] = None, ShareType: Union[str, None] = None, PathOrToken: Union[str, None] = None, PathMapped: Union[str, None] = None, User: Union[str, None] = None, EnabledByOwner: Union[bool, None] = None, EnabledByUser: Union[bool, None] = None, HiddenByOwner: Union[bool, None] = None, HiddenByUser: Union[bool, None] = None) -> list[dict]: """ retrieve sharing """ result = [] if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) for _ShareType in sharing.SHARE_TYPES_V1: if ShareType is not None and _ShareType != ShareType: # skip continue path = self._sharing_database_path_ShareType[_ShareType] with self._storage.acquire_lock("r", OwnerOrUser, path=path): for entry in os.scandir(path): if not entry.is_file(): continue if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list: check file: %r", entry.name) # read file with open(entry, "rb") as fb: (version, row) = pickle.load(fb) if version != DB_VERSION: # skip continue if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/row: test: %r", row) if ShareType is not None and row['ShareType'] != ShareType: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/row: skip by ShareType") pass elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): pass elif User is not None and row['User'] != User: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/row: skip by User") pass elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/row: skip by PathOrToken") pass elif PathMapped is not None and row['PathMapped'] != PathMapped: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/row: skip by PathMapped") pass elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: pass elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: pass elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: pass elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: pass else: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/list/row: add: %r", row) result.append(row) return result def database_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, Properties: Union[dict, None] = None) -> dict: """ create sharing """ row: dict sharing_config_file = os.path.join(self._sharing_database_path_ShareType[ShareType], self._encode_path(PathOrToken)) if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/create: sharing_config_file=%r", ShareType, sharing_config_file) logger.debug("TRACE/sharing/%s/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", ShareType, PathOrToken, Owner, PathMapped, User, Permissions) if os.path.isfile(sharing_config_file): return {"status": "conflict"} row = {"ShareType": ShareType, "PathOrToken": PathOrToken, "PathMapped": PathMapped, "Owner": Owner, "User": User, "Permissions": Permissions, "EnabledByOwner": EnabledByOwner, "EnabledByUser": EnabledByUser, "HiddenByOwner": HiddenByOwner, "HiddenByUser": HiddenByUser, "TimestampCreated": Timestamp, "TimestampUpdated": Timestamp} version = DB_VERSION try: with self._storage.acquire_lock("w", Owner, path=sharing_config_file): if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/create: store share-config: %r into file %r", ShareType, row, sharing_config_file) # write file with open(sharing_config_file, "wb") as fb: pickle.dump((version, row), fb) if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/*/create: share-config file stored: %r", sharing_config_file) return {"status": "success"} except Exception as e: logger.error("sharing/%s/create: cannot store share-config: %r (%r)", ShareType, sharing_config_file, e) return {"status": "error"} def database_update_sharing(self, ShareType: str, PathOrToken: str, OwnerOrUser: Union[str, None] = None, User: Union[str, None] = None, PathMapped: Union[str, None] = None, Permissions: Union[str, None] = None, EnabledByOwner: Union[bool, None] = None, EnabledByUser: Union[bool, None] = None, HiddenByOwner: Union[bool, None] = None, HiddenByUser: Union[bool, None] = None, Timestamp: int = 0, Properties: Union[dict, None] = None) -> dict: """ update sharing """ if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: PathOrToken=%r OwnerOrUser=%r User=%r Properties=%r", ShareType, PathOrToken, OwnerOrUser, User, Properties) sharing_config_file = os.path.join(self._sharing_database_path_ShareType[ShareType], self._encode_path(PathOrToken)) if not os.path.isfile(sharing_config_file): return {"status": "not-found"} # read content with self._storage.acquire_lock("w", OwnerOrUser, path=sharing_config_file): # read file with open(sharing_config_file, "rb") as fb: (version, row) = pickle.load(fb) if version != DB_VERSION: return {"status": "error"} if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: check: %r", ShareType, row) if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/update: orig row=%r", ShareType, row) if PathMapped is not None: row["PathMapped"] = PathMapped if Permissions is not None: row["Permissions"] = Permissions if User is not None: row["User"] = User if EnabledByOwner is not None: row["EnabledByOwner"] = EnabledByOwner if EnabledByUser is not None: row["EnabledByUser"] = EnabledByUser if HiddenByOwner is not None: row["HiddenByOwner"] = HiddenByOwner if HiddenByUser is not None: row["HiddenByUser"] = HiddenByUser if Properties is not None: row["Properties"] = Properties # update timestamp row["TimestampUpdated"] = Timestamp logger.debug("TRACE/sharing/%s/update: adj row=%r", ShareType, row) try: # write file with open(sharing_config_file, "wb") as fb: pickle.dump((version, row), fb) if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/create: share-config file stored: %r", ShareType, sharing_config_file) return {"status": "success"} except Exception as e: logger.error("sharing/%s/create: cannot store share-config: %r (%r)", ShareType, sharing_config_file, e) return {"status": "error"} def database_delete_sharing(self, ShareType: str, PathOrToken: str) -> dict: """ delete sharing """ if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/%s/delete: PathOrToken=%r", ShareType, PathOrToken) sharing_config_file = os.path.join(self._sharing_database_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", 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"} 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"} # 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)