Skip to content

Commit

Permalink
discover shared collections (WIP)
Browse files Browse the repository at this point in the history
  • Loading branch information
Unrud committed Oct 24, 2018
1 parent 3dcfcc4 commit dfffda6
Show file tree
Hide file tree
Showing 14 changed files with 349 additions and 43 deletions.
5 changes: 3 additions & 2 deletions radicale/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions radicale/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
25 changes: 25 additions & 0 deletions radicale/pathutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions radicale/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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 ``/``.
Expand All @@ -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"):
Expand Down
40 changes: 30 additions & 10 deletions radicale/storage/multifilesystem/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,26 +43,36 @@ 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
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
Expand Down Expand Up @@ -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))

Expand Down
7 changes: 3 additions & 4 deletions radicale/storage/multifilesystem/create_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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":
Expand All @@ -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)
8 changes: 7 additions & 1 deletion radicale/storage/multifilesystem/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
145 changes: 141 additions & 4 deletions radicale/storage/multifilesystem/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.

import contextlib
import json
import os

from radicale import pathutils
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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():
Expand All @@ -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)
Loading

0 comments on commit dfffda6

Please sign in to comment.