diff --git a/radicale/__main__.py b/radicale/__main__.py index 2f76f56a4..6ac272135 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -27,7 +27,7 @@ import signal import socket -from radicale import VERSION, config, log, server, storage +from radicale import VERSION, config, log, server, share, storage from radicale.log import logger @@ -115,7 +115,8 @@ def run(): if args.verify_storage: logger.info("Verifying storage") try: - Collection = storage.load(configuration) + shares = share.load(configuration) + Collection = storage.load(configuration, shares) with Collection.acquire_lock("r"): if not Collection.verify(): logger.fatal("Storage verifcation failed") diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 2f87efa1e..adbbb8b02 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -37,7 +37,7 @@ from xml.etree import ElementTree as ET from radicale import ( - auth, httputils, log, pathutils, rights, storage, web, xmlutils) + auth, httputils, log, pathutils, rights, share, storage, web, xmlutils) from radicale.app.delete import ApplicationDeleteMixin from radicale.app.get import ApplicationGetMixin from radicale.app.head import ApplicationHeadMixin @@ -70,7 +70,8 @@ def __init__(self, configuration): super().__init__() self.configuration = configuration self.Auth = auth.load(configuration) - self.Collection = storage.load(configuration) + self.shares = share.load(configuration) + self.Collection = storage.load(configuration, self.shares) self.Rights = rights.load(configuration) self.Web = web.load(configuration) self.encoding = configuration.get("encoding", "request") diff --git a/radicale/pathutils.py b/radicale/pathutils.py index eee3e6d8f..de8e1069b 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -231,3 +231,28 @@ def name_from_path(path, collection): raise ValueError("%r is not a component in collection %r" % (name, collection.path)) return name + + +def escape_shared_path(path): + return path.replace(".", "..").replace("_", "._").replace("/", "_") + + +def unescape_shared_path(escaped_path): + path = "" + while escaped_path: + if escaped_path[0] == ".": + if len(escaped_path) <= 1: + raise ValueError("EOF") + if escaped_path[1] in (".", "_"): + path += escaped_path[1] + else: + raise ValueError("Illegal escape sequence: %r" % + escaped_path[0:2]) + escaped_path = escaped_path[2:] + elif escaped_path[0] == "_": + path += "/" + escaped_path = escaped_path[1:] + else: + path += escaped_path[0] + escaped_path = escaped_path[1:] + return path diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index 13c93b58f..427c88fe7 100644 --- a/radicale/storage/__init__.py +++ b/radicale/storage/__init__.py @@ -44,7 +44,7 @@ for pkg in CACHE_DEPS) + ";").encode() -def load(configuration): +def load(configuration, shares): """Load the storage manager chosen in configuration.""" storage_type = configuration.get("storage", "type") if storage_type in INTERNAL_TYPES: @@ -61,6 +61,7 @@ def load(configuration): class CollectionCopy(class_): """Collection copy, avoids overriding the original class attributes.""" CollectionCopy.configuration = configuration + CollectionCopy.shares = shares CollectionCopy.static_init() return CollectionCopy @@ -81,6 +82,7 @@ class BaseCollection: # Overriden on copy by the "load" function configuration = None + share_types = None # Properties of instance """The sanitized path of the collection without leading or trailing ``/``. @@ -99,7 +101,7 @@ def owner(self): @property def is_principal(self): """Collection is a principal.""" - return bool(self.path) and "/" not in self.path + return self.path and "/" not in self.path and not self.get_meta("tag") @classmethod def discover(cls, path, depth="0"): diff --git a/radicale/storage/multifilesystem/__init__.py b/radicale/storage/multifilesystem/__init__.py index a7ab37826..fd7e030a9 100644 --- a/radicale/storage/multifilesystem/__init__.py +++ b/radicale/storage/multifilesystem/__init__.py @@ -33,6 +33,7 @@ from radicale.storage.multifilesystem.lock import CollectionLockMixin from radicale.storage.multifilesystem.meta import CollectionMetaMixin from radicale.storage.multifilesystem.move import CollectionMoveMixin +from radicale.storage.multifilesystem.share import CollectionShareMixin from radicale.storage.multifilesystem.sync import CollectionSyncMixin from radicale.storage.multifilesystem.upload import CollectionUploadMixin from radicale.storage.multifilesystem.verify import CollectionVerifyMixin @@ -42,8 +43,8 @@ class Collection( CollectionCacheMixin, CollectionCreateCollectionMixin, CollectionDeleteMixin, CollectionDiscoverMixin, CollectionGetMixin, CollectionHistoryMixin, CollectionLockMixin, CollectionMetaMixin, - CollectionMoveMixin, CollectionSyncMixin, CollectionUploadMixin, - CollectionVerifyMixin, storage.BaseCollection): + CollectionMoveMixin, CollectionShareMixin, CollectionSyncMixin, + CollectionUploadMixin, CollectionVerifyMixin, storage.BaseCollection): """Collection stored in several files per calendar.""" @classmethod @@ -51,17 +52,27 @@ def static_init(cls): folder = os.path.expanduser(cls.configuration.get( "storage", "filesystem_folder")) cls._makedirs_synced(folder) + cls._encoding = cls.configuration.get("encoding", "stock") super().static_init() - def __init__(self, path, filesystem_path=None): + @property + def owner(self): + if self._share: + return self._base_collection.owner + return super().owner + + def __init__(self, sane_path, filesystem_path=None, + share=None, base_collection=None): + assert not ((share is None) ^ (base_collection is None)) folder = self._get_collection_root_folder() - # Path should already be sanitized - self.path = pathutils.strip_path(path) - self._encoding = self.configuration.get("encoding", "stock") + assert sane_path == pathutils.sanitize_path(sane_path).strip("/") + self.path = sane_path if filesystem_path is None: filesystem_path = pathutils.path_to_filesystem(folder, self.path) self._filesystem_path = filesystem_path self._etag_cache = None + self._share = share + self._base_collection = base_collection super().__init__() @classmethod @@ -137,12 +148,21 @@ def _makedirs_synced(cls, filesystem_path): os.makedirs(filesystem_path, exist_ok=True) cls._sync_directory(parent_filesystem_path) + def _last_modified_relevant_files(self): + yield self._filesystem_path + if os.path.exists(self._props_path): + yield self._props_path + if not self._share: + for href in self._list(): + yield os.path.join(self._filesystem_path, href) + @property def last_modified(self): - relevant_files = chain( - (self._filesystem_path,), - (self._props_path,) if os.path.exists(self._props_path) else (), - (os.path.join(self._filesystem_path, h) for h in self._list())) + relevant_files = self._last_modified_relevant_files() + if self._share: + relevant_files = chain( + relevant_files, + self._base_collection._last_modified_relevant_files()) last = max(map(os.path.getmtime, relevant_files)) return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last)) diff --git a/radicale/storage/multifilesystem/create_collection.py b/radicale/storage/multifilesystem/create_collection.py index 9edbba426..6e2ec894a 100644 --- a/radicale/storage/multifilesystem/create_collection.py +++ b/radicale/storage/multifilesystem/create_collection.py @@ -33,7 +33,7 @@ def create_collection(cls, href, items=None, props=None): if not props: cls._makedirs_synced(filesystem_path) - return cls(pathutils.unstrip_path(sane_path, True)) + return cls(sane_path) parent_dir = os.path.dirname(filesystem_path) cls._makedirs_synced(parent_dir) @@ -44,8 +44,7 @@ def create_collection(cls, href, items=None, props=None): # The temporary directory itself can't be renamed tmp_filesystem_path = os.path.join(tmp_dir, "collection") os.makedirs(tmp_filesystem_path) - self = cls(pathutils.unstrip_path(sane_path, True), - filesystem_path=tmp_filesystem_path) + self = cls(sane_path, filesystem_path=tmp_filesystem_path) self.set_meta(props) if items is not None: if props.get("tag") == "VCALENDAR": @@ -61,4 +60,4 @@ def create_collection(cls, href, items=None, props=None): os.rename(tmp_filesystem_path, filesystem_path) cls._sync_directory(parent_dir) - return cls(pathutils.unstrip_path(sane_path, True)) + return cls(sane_path) diff --git a/radicale/storage/multifilesystem/delete.py b/radicale/storage/multifilesystem/delete.py index 08aa13f44..f680307f6 100644 --- a/radicale/storage/multifilesystem/delete.py +++ b/radicale/storage/multifilesystem/delete.py @@ -41,7 +41,13 @@ def delete(self, href=None): # Delete an item if not pathutils.is_safe_filesystem_path_component(href): raise pathutils.UnsafePathError(href) - path = pathutils.path_to_filesystem(self._filesystem_path, href) + if self._share: + assert self._share.item_writethrough + path = pathutils.path_to_filesystem( + self._base_collection._filesystem_path, href) + else: + path = pathutils.path_to_filesystem( + self._filesystem_path, href) if not os.path.isfile(path): raise storage.ComponentNotFoundError(href) os.remove(path) diff --git a/radicale/storage/multifilesystem/discover.py b/radicale/storage/multifilesystem/discover.py index f41cf3588..84a33660f 100644 --- a/radicale/storage/multifilesystem/discover.py +++ b/radicale/storage/multifilesystem/discover.py @@ -17,6 +17,7 @@ # along with Radicale. If not, see . import contextlib +import json import os from radicale import pathutils @@ -36,6 +37,83 @@ def discover(cls, path, depth="0", child_context_manager=( folder = cls._get_collection_root_folder() # Create the root collection cls._makedirs_synced(folder) + + if (len(attributes) >= 1 and + attributes[-1].startswith(".share_") or + len(attributes) >= 2 and + attributes[-1].startswith(".share_")): + if attributes[-1].startswith(".share_"): + href = None + else: + href = attributes.pop() + parent_sane_path = "/".join(attributes[:-1]) + share_path = pathutils.unescape_shared_path( + attributes[-1][len(".share"):]) + base_path, *share_group = share_path.rsplit("//", 1) + if share_group: + base_path += "/" + share_group = share_group[0] + else: + share_group = "" + if (base_path != pathutils.sanitize_path(base_path) or + not base_path.endswith("/")): + return + base_sane_path = pathutils.strip_path(base_path) + for share in cls.shares: + if share.group == share_group: + break + else: + return + try: + base_filesystem_path = pathutils.path_to_filesystem( + folder, base_sane_path) + filesystem_path = os.path.join(pathutils.path_to_filesystem( + os.path.join( + pathutils.path_to_filesystem(folder, parent_sane_path), + ".Radicale.shares"), + base_sane_path), ".Radicale.share_group_%s" % share_group + if share_group else ".Radicale.share_group") + except ValueError as e: + # Path is unsafe + logger.debug("Unsafe path %r requested from storage: %s", + sane_path, e, exc_info=True) + return + share_uuid_path = os.path.join(filesystem_path, ".Radicale.share") + try: + with open(share_uuid_path, encoding=cls._encoding) as f: + share_uuid = json.load(f) + except FileNotFoundError: + return + except ValueError as e: + raise RuntimeError( + "Invalid share of collection %r to %r: %s" % + (base_sane_path, parent_sane_path, e)) from e + if not os.path.isdir(base_filesystem_path): + return + for share in cls.shares: + if share.uuid == share_uuid and share.group == share_group: + break + else: + return + base_collection = cls(base_sane_path) + if base_collection.get_meta("tag") not in share.tags: + return + collection = cls( + sane_path, filesystem_path=filesystem_path, + share=share, base_collection=base_collection) + if href: + yield collection._get(href) + return + yield collection + if depth == "0": + return + for href in collection._list(): + with child_context_manager(sane_path, href): + child_item = collection._get(href) + if child_item: + yield child_item + return + try: filesystem_path = pathutils.path_to_filesystem(folder, sane_path) except ValueError as e: @@ -54,7 +132,7 @@ def discover(cls, path, depth="0", child_context_manager=( href = None sane_path = "/".join(attributes) - collection = cls(pathutils.unstrip_path(sane_path, True)) + collection = cls(sane_path) if href: yield collection._get(href) @@ -67,7 +145,9 @@ def discover(cls, path, depth="0", child_context_manager=( for href in collection._list(): with child_context_manager(sane_path, href): - yield collection._get(href) + child_item = collection._get(href) + if child_item: + yield child_item for entry in os.scandir(filesystem_path): if not entry.is_dir(): @@ -79,6 +159,63 @@ def discover(cls, path, depth="0", child_context_manager=( href, sane_path) continue sane_child_path = posixpath.join(sane_path, href) - child_path = pathutils.unstrip_path(sane_child_path, True) with child_context_manager(sane_child_path): - yield cls(child_path) + yield cls(sane_child_path) + + def scan_shares(shares_folder, parent_sane_path=""): + for entry in os.scandir( + os.path.join(shares_folder, parent_sane_path)): + if not entry.is_dir(): + continue + if pathutils.is_safe_filesystem_path_component(entry.name): + base_sane_path = os.path.join(parent_sane_path, entry.name) + yield from scan_shares(shares_folder, base_sane_path) + continue + if (entry.name != ".Radicale.share_group" and + not entry.name.startswith(".Radicale.share_group_") or + entry.name == ".Radicale.share_group_"): + continue + share_group = entry.name[len(".Radicale.share_group_"):] + base_sane_path = parent_sane_path + child_filesystem_path = os.path.join( + shares_folder, base_sane_path, entry.name) + share_uuid_path = os.path.join( + child_filesystem_path, ".Radicale.share") + try: + with open(share_uuid_path, encoding=cls._encoding) as f: + share_uuid = json.load(f) + except FileNotFoundError: + continue + except ValueError as e: + raise RuntimeError( + "Invalid share of collection %r to %r: %s" % + (base_sane_path, sane_path, e)) from e + for share in cls.shares: + if (share.uuid == share_uuid and + share.group == share_group): + break + else: + continue + try: + base_filesystem_path = pathutils.path_to_filesystem( + folder, base_sane_path) + except ValueError: + continue + if not os.path.isdir(base_filesystem_path): + continue + base_collection = cls(base_sane_path) + if base_collection.get_meta("tag") not in share.tags: + continue + child_sane_path = os.path.join( + sane_path, + ".share%s" % pathutils.escape_shared_path( + pathutils.unstrip_path(base_sane_path, True) + + ("/%s" % share.group if share.group else ""))) + child_collection = cls( + child_sane_path, filesystem_path=child_filesystem_path, + share=share, base_collection=base_collection) + yield child_collection + + shares_folder = os.path.join(filesystem_path, ".Radicale.shares") + if os.path.isdir(shares_folder): + yield from scan_shares(shares_folder) diff --git a/radicale/storage/multifilesystem/get.py b/radicale/storage/multifilesystem/get.py index 609fa8200..f1efe5565 100644 --- a/radicale/storage/multifilesystem/get.py +++ b/radicale/storage/multifilesystem/get.py @@ -32,6 +32,9 @@ def __init__(self): self._item_cache_cleaned = False def _list(self): + if self._share: + yield from self._base_collection._list() + return for entry in os.scandir(self._filesystem_path): if not entry.is_file(): continue @@ -43,6 +46,8 @@ def _list(self): yield href def _get(self, href, verify_href=True): + if self._share: + return self._base_collection._get(href, verify_href) if verify_href: try: if not pathutils.is_safe_filesystem_path_component(href): @@ -132,4 +137,7 @@ def get_multi(self, hrefs): def get_all(self): # We don't need to check for collissions, because the the file names # are from os.listdir. - return (self._get(href, verify_href=False) for href in self._list()) + for href in self._list(): + item = self._get(href, verify_href=False) + if item: + yield item diff --git a/radicale/storage/multifilesystem/meta.py b/radicale/storage/multifilesystem/meta.py index e15229518..4ac1640af 100644 --- a/radicale/storage/multifilesystem/meta.py +++ b/radicale/storage/multifilesystem/meta.py @@ -38,6 +38,9 @@ def get_meta(self, key=None): self._meta_cache = json.load(f) except FileNotFoundError: self._meta_cache = {} + if self._share: + self._meta_cache = self._share.get_meta( + self._meta_cache, self._base_collection.get_meta()) radicale_item.check_and_sanitize_props(self._meta_cache) except ValueError as e: raise RuntimeError("Failed to load properties of collection " @@ -45,5 +48,14 @@ def get_meta(self, key=None): return self._meta_cache.get(key) if key else self._meta_cache def set_meta(self, props): - with self._atomic_write(self._props_path, "w") as f: - json.dump(props, f, sort_keys=True) + if self._share: + props, base_props = self._share.set_meta( + props, self.get_meta(), self._base_collection.get_meta()) + with self._atomic_write(self._props_path, "w") as f1,\ + self._atomic_write( + self._base_collection._props_path, "w") as f2: + json.dump(props, f1, sort_keys=True) + json.dump(base_props, f2, sort_keys=True) + else: + with self._atomic_write(self._props_path, "w") as f: + json.dump(props, f, sort_keys=True) diff --git a/radicale/storage/multifilesystem/move.py b/radicale/storage/multifilesystem/move.py index e2f587e11..e1a9077b2 100644 --- a/radicale/storage/multifilesystem/move.py +++ b/radicale/storage/multifilesystem/move.py @@ -26,18 +26,30 @@ class CollectionMoveMixin: def move(cls, item, to_collection, to_href): if not pathutils.is_safe_filesystem_path_component(to_href): raise pathutils.UnsafePathError(to_href) + collection = item.collection + if collection._share: + assert collection._share.item_writethrough + base_collection = collection._base_collection + else: + base_collection = collection + if to_collection._share: + assert to_collection._share.item_writethrough + base_to_collection = to_collection._base_collection + else: + base_to_collection = to_collection os.replace( pathutils.path_to_filesystem( - item.collection._filesystem_path, item.href), + base_collection._filesystem_path, item.href), pathutils.path_to_filesystem( - to_collection._filesystem_path, to_href)) - cls._sync_directory(to_collection._filesystem_path) - if item.collection._filesystem_path != to_collection._filesystem_path: - cls._sync_directory(item.collection._filesystem_path) + base_to_collection._filesystem_path, to_href)) + cls._sync_directory(base_to_collection._filesystem_path) + if (base_collection._filesystem_path != + base_to_collection._filesystem_path): + cls._sync_directory(base_collection._filesystem_path) # Move the item cache entry - cache_folder = os.path.join(item.collection._filesystem_path, + cache_folder = os.path.join(base_collection._filesystem_path, ".Radicale.cache", "item") - to_cache_folder = os.path.join(to_collection._filesystem_path, + to_cache_folder = os.path.join(base_to_collection._filesystem_path, ".Radicale.cache", "item") cls._makedirs_synced(to_cache_folder) try: @@ -51,7 +63,7 @@ def move(cls, item, to_collection, to_href): cls._makedirs_synced(cache_folder) # Track the change to_collection._update_history_etag(to_href, item) - item.collection._update_history_etag(item.href, None) + collection._update_history_etag(item.href, None) to_collection._clean_history() - if item.collection._filesystem_path != to_collection._filesystem_path: - item.collection._clean_history() + if collection._filesystem_path != to_collection._filesystem_path: + collection._clean_history() diff --git a/radicale/storage/multifilesystem/share.py b/radicale/storage/multifilesystem/share.py new file mode 100644 index 000000000..5eee9383b --- /dev/null +++ b/radicale/storage/multifilesystem/share.py @@ -0,0 +1,74 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2018 Unrud +# +# 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 json +import os + +from radicale import pathutils + + +class CollectionShareMixin: + + def list_shares(self): + if self._share: + yield from self._base_collection.list_shares() + return + folder = self._get_collection_root_folder() + for entry in os.scandir(folder): + if (not entry.is_dir() or + not pathutils.is_safe_filesystem_path_component( + entry.name)): + continue + user = entry.name + shares_path = os.path.join(folder, user, ".Radicale.shares") + try: + share_path = pathutils.path_to_filesystem( + shares_path, self.path) + except ValueError: + continue + try: + group_scanner = os.scandir(share_path) + except FileNotFoundError: + continue + for group_entry in group_scanner: + if (group_entry.name != ".Radicale.share_group" and + not group_entry.name.startswith( + ".Radicale.share_group_") or + entry.name == ".Radicale.share_group_"): + continue + share_group = group_entry.name[len(".Radicale.share_group_"):] + child_filesystem_path = os.path.join( + share_path, group_entry.name) + share_uuid_path = os.path.join( + child_filesystem_path, ".Radicale.share") + try: + with open(share_uuid_path, encoding=self._encoding) as f: + share_uuid = json.load(f) + except FileNotFoundError: + continue + except ValueError as e: + raise RuntimeError( + "Invalid share of collection %r to %r: %s" % + (self.path, user, e)) from e + for share in self.shares: + if (share.uuid == share_uuid and + share.group == share_group): + break + else: + continue + if self.get_meta("tag") not in share.tags: + continue + yield (user, share) diff --git a/radicale/storage/multifilesystem/upload.py b/radicale/storage/multifilesystem/upload.py index 837381d2a..257419e01 100644 --- a/radicale/storage/multifilesystem/upload.py +++ b/radicale/storage/multifilesystem/upload.py @@ -25,19 +25,26 @@ class CollectionUploadMixin: def upload(self, href, item): + if self._share: + assert self._share.item_writethrough + return self._base_collection.upload(href, item) if not pathutils.is_safe_filesystem_path_component(href): raise pathutils.UnsafePathError(href) + if self._share: + collection = self._base_collection + else: + collection = self try: - self._store_item_cache(href, item) + collection._store_item_cache(href, item) except Exception as e: raise ValueError("Failed to store item %r in collection %r: %s" % (href, self.path, e)) from e - path = pathutils.path_to_filesystem(self._filesystem_path, href) + path = pathutils.path_to_filesystem(collection._filesystem_path, href) with self._atomic_write(path, newline="") as fd: fd.write(item.serialize()) # Clean the cache after the actual item is stored, or the cache entry # will be removed again. - self._clean_item_cache() + collection._clean_item_cache() # Track the change self._update_history_etag(href, item) self._clean_history() @@ -50,6 +57,7 @@ def _upload_all_nonatomic(self, items, suffix=""): uploads them nonatomic and without existence checks. """ + assert not self._share cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", "item") self._makedirs_synced(cache_folder) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 087afafc4..b804b481e 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -28,7 +28,7 @@ import xml.etree.ElementTree as ET from functools import partial -from radicale import Application, config, storage +from radicale import Application, config, share, storage from . import BaseTest from .helpers import get_file_content @@ -258,7 +258,8 @@ def test_verify(self): events = get_file_content("event_multiple.ics") status, _, _ = self.request("PUT", "/calendar.ics/", events) assert status == 201 - s = storage.load(self.configuration) + shares = share.load(self.configuration) + s = storage.load(self.configuration, shares) assert s.verify() def test_delete(self):