# This file is part of Radicale Server - Calendar Server # Copyright © 2026-2026 Peter Bieringer # Copyright © 2026-2026 Max Berger # # 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 json 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 database_init(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: sharing_db_file = os.path.abspath(sharing_db_file) folder_db = os.path.dirname(sharing_db_file) logger.info("sharing database filename: %r", sharing_db_file) if not os.path.exists(folder_db): logger.notice("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.notice("sharing database folder successfully created: %r", folder_db) if not os.path.exists(sharing_db_file): logger.notice("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.notice("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 database_get_info(self) -> Union[dict, None]: database_info = {'type': "csv"} return database_info def database_verify(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 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 logger.trace("sharing: lookup ShareType=%r PathOrToken=%r User=%r OnlyEnabled=%s)", ShareType, PathOrToken, User, OnlyEnabled) index = 0 found = False for row in self._sharing_cache: if index == 0: # skip fieldnames pass else: logger.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 OnlyEnabled is True and row['EnabledByOwner'] is not True: pass elif OnlyEnabled is True and row['EnabledByUser'] is not True: pass 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']) Properties: Union[dict, None] = None Conversion: Union[str, None] = None Actions: Union[dict, None] = None if 'Properties' in row: Properties = row['Properties'] if 'Conversion' in row: Conversion = row['Conversion'] if 'Actions' in row: Actions = row['Actions'] return { "mapped": True, "ShareType": ShareType, "PathOrToken": PathOrToken, "PathMapped": PathMapped, "Owner": Owner, "User": UserShare, "Hidden": Hidden, "EnabledByOwner": row['EnabledByOwner'], "EnabledByUser": row['EnabledByUser'], "Permissions": Permissions, "Properties": Properties, "Conversion": Conversion, "Actions": Actions, } 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, Conversion: Union[str, None] = None, ) -> list[dict]: """ retrieve sharing """ row: dict index = 0 result = [] with self._storage.acquire_lock("r", path=self._sharing_db_file): logger.trace("sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s Conversion=%r", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser, Conversion) for row in self._sharing_cache: if index == 0: # skip fieldnames pass else: logger.trace("sharing/list/row: test: %r", row) if ShareType is not None and row['ShareType'] != ShareType: logger.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.trace("sharing/list/row: skip by User") pass elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: logger.trace("sharing/list/row: skip by PathOrToken") pass elif PathMapped is not None and row['PathMapped'] != PathMapped: logger.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 elif Conversion is not None and row['Conversion'] != Conversion: pass else: logger.trace("sharing/list/row: add : %r", row) result.append(row) index += 1 return result def database_create_sharing(self, ShareType: str, PathOrToken: str, PathMapped: str, Conversion: 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, Actions: Union[dict, None] = None, ) -> dict: """ create sharing """ row: dict with self._storage.acquire_lock("w", path=self._sharing_db_file): logger.trace("sharing: ShareType=%r", ShareType) if ShareType == "token": logger.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.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, "Properties": Properties, "Conversion": Conversion, "Actions": Actions, } logger.trace("sharing/*/create: add row: %r", row) self._sharing_cache.append(row) if self._write_csv(self._sharing_db_file): logger.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 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, Conversion: Union[str, None] = None, Actions: Union[dict, None] = None, ) -> dict: """ update sharing """ logger.trace("sharing/%s/update: PathOrToken=%r OwnerOrUser=%r PathMapped=%r Properties=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s", ShareType, PathOrToken, OwnerOrUser, PathMapped, Properties, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser) with self._storage.acquire_lock("w", path=self._sharing_db_file): # 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.trace("sharing/%s/update: orig row[%d]=%r", ShareType, index, row) # CSV: remove+adjust+readd if PathMapped is not None: self._sharing_cache[index]["PathMapped"] = PathMapped if Permissions is not None: self._sharing_cache[index]["Permissions"] = Permissions if User is not None: self._sharing_cache[index]["User"] = User if EnabledByOwner is not None: self._sharing_cache[index]["EnabledByOwner"] = EnabledByOwner if EnabledByUser is not None: self._sharing_cache[index]["EnabledByUser"] = EnabledByUser if HiddenByOwner is not None: self._sharing_cache[index]["HiddenByOwner"] = HiddenByOwner if HiddenByUser is not None: self._sharing_cache[index]["HiddenByUser"] = HiddenByUser if Properties is not None: self._sharing_cache[index]["Properties"] = Properties if Conversion is not None: self._sharing_cache[index]["Conversion"] = Conversion if Actions is not None: self._sharing_cache[index]["Actions"] = Actions # update timestamp self._sharing_cache[index]["TimestampUpdated"] = Timestamp logger.trace("sharing/%s/update: adj row[%d]=%r", ShareType, index, self._sharing_cache[index]) if self._write_csv(self._sharing_db_file): logger.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 database_delete_sharing(self, ShareType: str, PathOrToken: str, User: str) -> dict: """ delete sharing """ logger.trace("sharing/%s/delete: PathOrToken=%r", ShareType, PathOrToken) with self._storage.acquire_lock("w", User, path=self._sharing_db_file): # lookup token found = False index = 0 for row in self._sharing_cache: logger.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: found = True break index += 1 if found: logger.trace("sharing/%s/delete: PathOrToken=%r Owner=%r index=%d", ShareType, PathOrToken, row['Owner'], index) self._sharing_cache.pop(index) if self._write_csv(self._sharing_db_file): logger.trace("sharing/%s/delete: write CSV done", ShareType) return {"status": "success"} logger.error("sharing/%s/delete: cannot update CSV database", ShareType) 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, delimiter=';') writer.writeheader() return True def _load_csv(self, file: str) -> bool: logger.debug("sharing database load begin: %r", file) self._sharing_cache = [] with self._storage.acquire_lock("r", None): with open(file, 'r', newline='', encoding=self._encoding) as csvfile: reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1, delimiter=';') 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 or int if self._lines > 0: for fieldname in row: logger.trace("sharing/_load_csv: test fieldname=%r", fieldname) if fieldname not in sharing.DB_TYPES_V1: logger.error("sharing database row error, unsupported fieldname found: %r", fieldname) return False if sharing.DB_TYPES_V1[fieldname] is bool: try: row[fieldname] = config._convert_to_bool(row[fieldname]) except Exception as e: logger.error("sharing database row error in type conversion fieldname=%r row=%r error: %r", fieldname, row, e) return False elif sharing.DB_TYPES_V1[fieldname] is int: try: row[fieldname] = int(row[fieldname]) except Exception as e: logger.error("sharing database row error in type conversion fieldname=%r row=%r error: %r", fieldname, row, e) return False elif sharing.DB_TYPES_V1[fieldname] is dict: if row[fieldname] is None or row[fieldname] == '': row[fieldname] = {} else: field = row[fieldname].lstrip('"').rstrip('"') logger.trace("json prep quote replacer match (before): %s", field) field = field.replace('"', '\\"').replace("\\'", "'") # escape " field = field.replace("{'", '{"') # replace for JSON start {' -> {" field = field.replace("'}", '"}') # replace for JSON end '} -> "} field = field.replace("': '", '": "') # replace for JSON entry/value ': ' -> ": " field = field.replace("': {", '": {') # replace for JSON entry/value ': { -> ": { field = field.replace("': ", '": ') # replace for JSON entry/value ': -> ":(whitespace) field = field.replace("', '", '", "') # replace for JSON delimiter ', ' -> ", " field = field.replace(", '", ', "') # replace for JSON delimiter , ' -> , " logger.trace("json prep quote replacer match (after) : %s", field) try: row[fieldname] = json.loads(field) except Exception as e: logger.error("sharing database row error in type conversion fieldname=%r field=%r row=%r error: %r", fieldname, field, row, e) return False # check for duplicates for row_cached in self._sharing_cache: if row == row_cached: logger.error("sharing database row duplicate row=%r", row) return False # 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='', encoding=self._encoding) as csvfile: writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1, delimiter=';') writer.writerows(self._sharing_cache) return True