Files
Radicale/radicale/sharing/csv.py
2026-08-07 21:52:01 +02:00

494 lines
24 KiB
Python

# This file is part of Radicale Server - Calendar Server
# Copyright © 2026-2026 Peter Bieringer <pb@bieringer.de>
# Copyright © 2026-2026 Max Berger <max@berger.name>
#
# 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 <http://www.gnu.org/licenses/>.
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 """
logger.trace("sharing/%s/get: PathOrToken=%r User=%r OnlyEnabled=%s", ShareType, PathOrToken, User, OnlyEnabled)
found = False
for row in self.database_list_sharing(ShareType=ShareType, PathOrToken=PathOrToken, User=User):
# run through prefiltered list
logger.trace("sharing/get/check: %r", row)
if OnlyEnabled is True and row['EnabledByOwner'] is False:
continue
elif OnlyEnabled is True and row['EnabledByUser'] is False:
continue
else:
found = True
break
if found:
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": row['PathMapped'],
"Owner": row['Owner'],
"User": row['User'],
"Hidden": Hidden,
"EnabledByOwner": row['EnabledByOwner'],
"EnabledByUser": row['EnabledByUser'],
"Permissions": row['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:
index += 1
if index == 1:
# skip fieldnames
continue
logger.trace("sharing/list/row: test: %r", row)
if ShareType is not None and row['ShareType'] != ShareType:
continue
if Conversion is not None and row['Conversion'] != Conversion:
continue
if EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner:
continue
if EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser:
continue
if HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner:
continue
if HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser:
continue
if PathMapped is not None and row['PathMapped'] != PathMapped:
continue
if OwnerOrUser is not None:
if User is not None and OwnerOrUser == User:
pass # will be checked below
elif (row['Owner'] != OwnerOrUser) and (row['User'] != OwnerOrUser):
continue
group_check = False
if row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP) or row['User'].startswith(sharing.SHARING_SEPARATOR_REALM):
group_check = True
if User is not None:
if row['User'].startswith(sharing.SHARING_SEPARATOR_REALM):
if not User.endswith(row['User']):
continue
else:
pass
elif row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP):
if sharing.SHARING_SEPARATOR_GROUP not in User:
continue # user has no group
user_without_group = User.split(sharing.SHARING_SEPARATOR_GROUP)[0]
groups_of_user = User.split(sharing.SHARING_SEPARATOR_GROUP)[1].split(',')
Groups = row['User'].removeprefix(sharing.SHARING_SEPARATOR_GROUP).split(',')
logger.trace("sharing/list/check/groups: groups_of_user=%r Groups=%r", groups_of_user, Groups)
found = False
for group in groups_of_user:
if group in Groups:
found = True
break
if found:
pass
else:
continue
elif row['User'] == User:
pass
else:
continue
row_copy = row.copy()
if group_check and User is not None:
if row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP):
user_without_group = User.split(sharing.SHARING_SEPARATOR_GROUP)[0]
else:
user_without_group = User
row_copy['PathOrToken'] = row['PathOrToken'].replace("{user}", user_without_group) # replace placeholder
row_copy['User'] = user_without_group # replace with real user
if PathOrToken is not None and row_copy['PathOrToken'] != PathOrToken:
continue
logger.trace("sharing/list/row: add : %r", row_copy)
result.append(row_copy)
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