Clean many, many things

This commit is contained in:
Guillaume Ayoub 2016-08-05 02:14:49 +02:00
parent 263f31c84b
commit 8ac3ce1a89
5 changed files with 240 additions and 239 deletions

View File

@ -50,9 +50,8 @@ from . import auth, rights, storage, xmlutils
VERSION = "2.0.0rc0"
# Standard "not allowed" response that is returned when an authenticated user
# tries to access information they don't have rights to
NOT_ALLOWED = (client.FORBIDDEN, {}, None)
DAV_HEADERS = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
class HTTPServer(wsgiref.simple_server.WSGIServer):
@ -136,6 +135,7 @@ class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
class Application:
"""WSGI application managing collections."""
def __init__(self, configuration, logger):
"""Initialize application."""
super().__init__()
@ -149,15 +149,20 @@ class Application:
def headers_log(self, environ):
"""Sanitize headers for logging."""
request_environ = dict(environ)
# Remove environment variables
if not self.configuration.getboolean("logging", "full_environment"):
for shell_variable in os.environ:
request_environ.pop(shell_variable, None)
# Mask credentials
if (self.configuration.getboolean("logging", "mask_passwords") and
request_environ.get("HTTP_AUTHORIZATION",
"").startswith("Basic")):
# Mask passwords
mask_passwords = self.configuration.getboolean(
"logging", "mask_passwords")
authorization = request_environ.get(
"HTTP_AUTHORIZATION", "").startswith("Basic")
if mask_passwords and authorization:
request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
return request_environ
def decode(self, text, environ):
@ -215,6 +220,7 @@ class Application:
def __call__(self, environ, start_response):
"""Manage a request."""
def response(status, headers={}, answer=None):
# Start response
status = "%i %s" % (
@ -224,6 +230,7 @@ class Application:
# Return response content
return [answer] if answer else []
self.logger.debug("\n") # Add empty lines between requests in debug
self.logger.info("%s request at %s received" % (
environ["REQUEST_METHOD"], environ["PATH_INFO"]))
headers = pprint.pformat(self.headers_log(environ))
@ -246,7 +253,6 @@ class Application:
environ["PATH_INFO"] = storage.sanitize_path(
unquote(environ["PATH_INFO"]))
self.logger.debug("Sanitized path: %s", environ["PATH_INFO"])
path = environ["PATH_INFO"]
# Get function corresponding to method
@ -254,7 +260,6 @@ class Application:
# Ask authentication backend to check rights
authorization = environ.get("HTTP_AUTHORIZATION", None)
if authorization and authorization.startswith("Basic"):
authorization = authorization[len("Basic"):].strip()
user, password = self.decode(base64.b64decode(
@ -263,7 +268,7 @@ class Application:
user = environ.get("REMOTE_USER")
password = None
# If /.well-known is not available, clients query /
# If "/.well-known" is not available, clients query "/"
if path == "/.well-known" or path.startswith("/.well-known/"):
return response(client.NOT_FOUND, {})
@ -281,7 +286,8 @@ class Application:
if self.authorized(user, principal_path, "w"):
with self.Collection.acquire_lock("r"):
principal = next(
self.Collection.discover(principal_path), None)
self.Collection.discover(principal_path, depth="1"),
None)
if not principal:
with self.Collection.acquire_lock("w"):
self.Collection.create_collection(principal_path)
@ -336,6 +342,7 @@ class Application:
headers["Content-Length"] = str(len(answer))
# Add extra headers set in configuration
if self.configuration.has_section("headers"):
for key in self.configuration.options("headers"):
headers[key] = self.configuration.get("headers", key)
@ -343,8 +350,7 @@ class Application:
return response(status, headers, answer)
def _access(self, user, path, permission, item=None):
"""Checks if ``user`` can access ``path`` or the parent collection
with ``permission``.
"""Check if ``user`` can access ``path`` or the parent collection.
``permission`` must either be "r" or "w".
@ -380,7 +386,7 @@ class Application:
if not self._access(user, path, "w"):
return NOT_ALLOWED
with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None)
item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if not item:
@ -405,7 +411,7 @@ class Application:
if not self._access(user, path, "r"):
return NOT_ALLOWED
with self._lock_collection("r", user):
item = next(self.Collection.discover(path, depth="0"), None)
item = next(self.Collection.discover(path), None)
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if not item:
@ -414,8 +420,8 @@ class Application:
collection = item
else:
collection = item.collection
content_type = storage.MIMETYPES.get(collection.get_meta("tag"),
"text/plain")
content_type = xmlutils.MIMETYPES.get(
collection.get_meta("tag"), "text/plain")
headers = {
"Content-Type": content_type,
"Last-Modified": collection.last_modified,
@ -434,7 +440,7 @@ class Application:
return NOT_ALLOWED
with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None)
item = next(self.Collection.discover(path), None)
if item:
return client.CONFLICT, {}, None
props = xmlutils.props_from_request(content)
@ -449,7 +455,7 @@ class Application:
if not self.authorized(user, path, "w"):
return NOT_ALLOWED
with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None)
item = next(self.Collection.discover(path), None)
if item:
return client.CONFLICT, {}, None
props = xmlutils.props_from_request(content)
@ -464,27 +470,33 @@ class Application:
if to_url.netloc != environ["HTTP_HOST"]:
# Remote destination server, not supported
return client.BAD_GATEWAY, {}, None
to_path = storage.sanitize_path(to_url.path)
if (not self._access(user, path, "w") or
not self._access(user, to_path, "w")):
if not self._access(user, path, "w"):
return NOT_ALLOWED
to_path = storage.sanitize_path(to_url.path)
if not self._access(user, to_path, "w"):
return NOT_ALLOWED
if to_path.strip("/").startswith(path.strip("/")):
return client.CONFLICT, {}, None
with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None)
if (not self._access(user, path, "w", item) or
not self._access(user, to_path, "w", item)):
item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if not self._access(user, to_path, "w", item):
return NOT_ALLOWED
if not item:
return client.GONE, {}, None
to_item = next(self.Collection.discover(to_path, depth="0"), None)
if isinstance(item, self.Collection):
return client.CONFLICT, {}, None
to_item = next(self.Collection.discover(to_path), None)
to_parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(to_path.strip("/")))
to_href = posixpath.basename(to_path.strip("/"))
to_collection = next(self.Collection.discover(
to_parent_path, depth="0"), None)
print(path, isinstance(item, self.Collection))
if (isinstance(item, self.Collection) or not to_collection or
to_item or to_path.strip("/").startswith(path.strip("/"))):
to_collection = next(
self.Collection.discover(to_parent_path), None)
if not to_collection or to_item:
return client.CONFLICT, {}, None
to_href = posixpath.basename(to_path.strip("/"))
to_collection.upload(to_href, item.item)
item.collection.delete(item.href)
return client.CREATED, {}, None
@ -492,22 +504,20 @@ class Application:
def do_OPTIONS(self, environ, path, content, user):
"""Manage OPTIONS request."""
headers = {
"Allow": ("DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, "
"OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT"),
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
"Allow": ", ".join(
name[3:] for name in dir(self) if name.startswith("do_")),
"DAV": DAV_HEADERS}
return client.OK, headers, None
def do_PROPFIND(self, environ, path, content, user):
"""Manage PROPFIND request."""
with self._lock_collection("r", user):
items = self.Collection.discover(path,
environ.get("HTTP_DEPTH", "0"))
items = self.Collection.discover(
path, environ.get("HTTP_DEPTH", "0"))
read_items, write_items = self.collect_allowed_items(items, user)
if not read_items and not write_items:
return (client.NOT_FOUND, {}, None) if user else NOT_ALLOWED
headers = {
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
"Content-Type": "text/xml"}
headers = {"DAV": DAV_HEADERS, "Content-Type": "text/xml"}
answer = xmlutils.propfind(
path, content, read_items, write_items, user)
return client.MULTI_STATUS, headers, answer
@ -517,13 +527,11 @@ class Application:
if not self.authorized(user, path, "w"):
return NOT_ALLOWED
with self._lock_collection("w", user):
item = next(self.Collection.discover(path, depth="0"), None)
item = next(self.Collection.discover(path), None)
if not isinstance(item, self.Collection):
return client.CONFLICT, {}, None
headers = {"DAV": DAV_HEADERS, "Content-Type": "text/xml"}
answer = xmlutils.proppatch(path, content, item)
headers = {
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
"Content-Type": "text/xml"}
return client.MULTI_STATUS, headers, answer
def do_PUT(self, environ, path, content, user):
@ -534,33 +542,39 @@ class Application:
with self._lock_collection("w", user):
parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
item = next(self.Collection.discover(path, depth="0"), None)
parent_item = next(self.Collection.discover(
parent_path, depth="0"), None)
item = next(self.Collection.discover(path), None)
parent_item = next(self.Collection.discover(parent_path), None)
write_whole_collection = (
isinstance(item, self.Collection) or
not parent_item or
not parent_item or (
not next(parent_item.list(), None) and
parent_item.get_meta("tag") not in (
"VADDRESSBOOK", "VCALENDAR"))
if (write_whole_collection and
not self.authorized(user, path, "w") or
not write_whole_collection and
not self.authorized(user, parent_path, "w")):
"VADDRESSBOOK", "VCALENDAR")))
if write_whole_collection:
if not self.authorized(user, path, "w"):
return NOT_ALLOWED
elif not self.authorized(user, parent_path, "w"):
return NOT_ALLOWED
etag = environ.get("HTTP_IF_MATCH", "")
match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
if ((not item and etag) or (item and etag and item.etag != etag) or
(item and match)):
etag = environ.get("HTTP_IF_MATCH", "")
if not item and etag:
# Etag asked but no item found: item has been removed
return client.PRECONDITION_FAILED, {}, None
if item and etag and item.etag != etag:
# Etag asked but item not matching: item has changed
return client.PRECONDITION_FAILED, {}, None
match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
if item and match:
# Creation asked but item found: item can't be replaced
return client.PRECONDITION_FAILED, {}, None
items = list(vobject.readComponents(content or ""))
content_type = environ.get("CONTENT_TYPE")
tag = None
if content_type:
tags = {value: key for key, value in storage.MIMETYPES.items()}
tag = tags.get(content_type.split(";")[0])
content_type = environ.get("CONTENT_TYPE", "").split(";")[0]
tags = {value: key for key, value in xmlutils.MIMETYPES.items()}
tag = tags.get(content_type)
if write_whole_collection:
if item:
# Delete old collection
@ -582,7 +596,7 @@ class Application:
if not self._access(user, path, "w"):
return NOT_ALLOWED
with self._lock_collection("r", user):
item = next(self.Collection.discover(path, depth="0"), None)
item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if not item:

View File

@ -167,14 +167,13 @@ def serve(configuration, logger):
try:
open(filename, "r").close()
except IOError as exception:
logger.warning(
"Error while reading SSL %s %r: %s" % (
logger.warning("Error while reading SSL %s %r: %s" % (
name, filename, exception))
else:
server_class = ThreadedHTTPServer
server_class.client_timeout = configuration.getint("server", "timeout")
server_class.max_connections = configuration.getint("server",
"max_connections")
server_class.max_connections = configuration.getint(
"server", "max_connections")
if not configuration.getboolean("server", "dns_lookup"):
RequestHandler.address_string = lambda self: self.client_address[0]

View File

@ -111,7 +111,7 @@ class Rights(BaseRights):
user = user or ""
if user and not storage.is_safe_path_component(user):
# Prevent usernames like "user/calendar.ics"
raise ValueError("Unsafe username")
raise ValueError("Refused unsafe username: %s", user)
sane_path = storage.sanitize_path(path).strip("/")
# Prevent "regex injection"
user_escaped = re.escape(user)

View File

@ -43,6 +43,7 @@ from tempfile import TemporaryDirectory
from atomicwrites import AtomicWriter
import vobject
if os.name == "nt":
import ctypes
import ctypes.wintypes
@ -55,14 +56,16 @@ if os.name == "nt":
ULONG_PTR = ctypes.c_uint64
class Overlapped(ctypes.Structure):
_fields_ = [("internal", ULONG_PTR),
_fields_ = [
("internal", ULONG_PTR),
("internal_high", ULONG_PTR),
("offset", ctypes.wintypes.DWORD),
("offset_high", ctypes.wintypes.DWORD),
("h_event", ctypes.wintypes.HANDLE)]
lock_file_ex = ctypes.windll.kernel32.LockFileEx
lock_file_ex.argtypes = [ctypes.wintypes.HANDLE,
lock_file_ex.argtypes = [
ctypes.wintypes.HANDLE,
ctypes.wintypes.DWORD,
ctypes.wintypes.DWORD,
ctypes.wintypes.DWORD,
@ -70,7 +73,8 @@ if os.name == "nt":
ctypes.POINTER(Overlapped)]
lock_file_ex.restype = ctypes.wintypes.BOOL
unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx
unlock_file_ex.argtypes = [ctypes.wintypes.HANDLE,
unlock_file_ex.argtypes = [
ctypes.wintypes.HANDLE,
ctypes.wintypes.DWORD,
ctypes.wintypes.DWORD,
ctypes.wintypes.DWORD,
@ -95,9 +99,6 @@ def load(configuration, logger):
return CollectionCopy
MIMETYPES = {"VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"}
def get_etag(text):
"""Etag from collection or item."""
etag = md5()
@ -105,13 +106,9 @@ def get_etag(text):
return '"%s"' % etag.hexdigest()
def is_safe_path_component(path):
"""Check if path is a single component of a path.
Check that the path is safe to join too.
"""
return path and "/" not in path and path not in (".", "..")
def get_uid(item):
"""UID value of an item if defined."""
return hasattr(item, "uid") and item.uid.value
def sanitize_path(path):
@ -131,6 +128,15 @@ def sanitize_path(path):
return new_path + trailing_slash
def is_safe_path_component(path):
"""Check if path is a single component of a path.
Check that the path is safe to join too.
"""
return path and "/" not in path and path not in (".", "..")
def is_safe_filesystem_path_component(path):
"""Check if path is a single component of a filesystem path.
@ -158,14 +164,13 @@ def path_to_filesystem(root, *paths):
continue
for part in path.split("/"):
if not is_safe_filesystem_path_component(part):
raise ValueError(
"Can't tranlate name safely to filesystem: %s" % part)
raise UnsafePathError(part)
safe_path = os.path.join(safe_path, part)
return safe_path
def sync_directory(path):
"""Sync directory to disk
"""Sync directory to disk.
This only works on POSIX and does nothing on other systems.
@ -181,14 +186,38 @@ def sync_directory(path):
os.close(fd)
class UnsafePathError(ValueError):
def __init__(self, path):
message = "Can't translate name safely to filesystem: %s" % path
super().__init__(message)
class ComponentExistsError(ValueError):
def __init__(self, path):
message = "Component already exists: %s" % path
super().__init__(message)
class ComponentNotFoundError(ValueError):
def __init__(self, path):
message = "Component doesn't exist: %s" % path
super().__init__(message)
class EtagMismatchError(ValueError):
def __init__(self, etag1, etag2):
message = "ETags don't match: %s != %s" % (etag1, etag2)
super().__init__(message)
class _EncodedAtomicWriter(AtomicWriter):
def __init__(self, path, encoding, mode="w", overwrite=True):
self._encoding = encoding
return super().__init__(path, mode, overwrite=True)
def get_fileobject(self, **kwargs):
return super().get_fileobject(encoding=self._encoding,
prefix=".Radicale.tmp-", **kwargs)
return super().get_fileobject(
encoding=self._encoding, prefix=".Radicale.tmp-", **kwargs)
class Item:
@ -222,7 +251,7 @@ class BaseCollection:
raise NotImplementedError
@classmethod
def discover(cls, path, depth="1"):
def discover(cls, path, depth="0"):
"""Discover a list of collections under the given ``path``.
If ``depth`` is "0", only the actual object under ``path`` is
@ -248,8 +277,9 @@ class BaseCollection:
``props`` are metadata values for the collection.
``props["tag"]`` is the type of collection (VCALENDAR or VADDRESSBOOK). If
the key ``tag`` is missing, it is guessed from the collection.
``props["tag"]`` is the type of collection (VCALENDAR or
VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
collection.
"""
raise NotImplementedError
@ -347,31 +377,25 @@ class Collection(BaseCollection):
def __init__(self, path, principal=False, folder=None):
if not folder:
folder = self._get_collection_root_folder()
# path should already be sanitized
# Path should already be sanitized
self.path = sanitize_path(path).strip("/")
self.storage_encoding = self.configuration.get("encoding", "stock")
self.encoding = self.configuration.get("encoding", "stock")
self._filesystem_path = path_to_filesystem(folder, self.path)
self._props_path = os.path.join(
self._filesystem_path, ".Radicale.props")
split_path = self.path.split("/")
if len(split_path) > 1:
# URL with at least one folder
self.owner = split_path[0]
else:
self.owner = None
self.owner = split_path[0] if len(split_path) > 1 else None
self.is_principal = principal
@classmethod
def _get_collection_root_folder(cls):
filesystem_folder = os.path.expanduser(
cls.configuration.get("storage", "filesystem_folder"))
folder = os.path.join(filesystem_folder, "collection-root")
return folder
return os.path.join(filesystem_folder, "collection-root")
@contextmanager
def _atomic_write(self, path, mode="w"):
with _EncodedAtomicWriter(
path, self.storage_encoding, mode).open() as fd:
with _EncodedAtomicWriter(path, self.encoding, mode).open() as fd:
yield fd
def _find_available_file_name(self):
@ -383,12 +407,12 @@ class Collection(BaseCollection):
raise FileExistsError(errno.EEXIST, "No usable file name found")
@classmethod
def discover(cls, path, depth="1"):
# path == None means wrong URL
def discover(cls, path, depth="0"):
if path is None:
# Wrong URL
return
# path should already be sanitized
# Path should already be sanitized
sane_path = sanitize_path(path).strip("/")
attributes = sane_path.split("/")
if not attributes[0]:
@ -401,24 +425,31 @@ class Collection(BaseCollection):
except ValueError:
# Path is unsafe
return
href = None
if not os.path.isdir(filesystem_path):
if attributes and os.path.isfile(filesystem_path):
href = attributes.pop()
else:
return
else:
href = None
path = "/".join(attributes)
principal = len(attributes) == 1
collection = cls(path, principal)
if href:
yield collection.get(href)
return
yield collection
if depth == "0":
return
for item in collection.list():
yield collection.get(item[0])
for href in os.listdir(filesystem_path):
if not is_safe_filesystem_path_component(href):
cls.logger.debug("Skipping collection: %s", href)
@ -432,7 +463,7 @@ class Collection(BaseCollection):
def create_collection(cls, href, collection=None, props=None):
folder = cls._get_collection_root_folder()
# path should already be sanitized
# Path should already be sanitized
sane_path = sanitize_path(href).strip("/")
attributes = sane_path.split("/")
if not attributes[0]:
@ -450,28 +481,24 @@ class Collection(BaseCollection):
parent_dir = os.path.dirname(filesystem_path)
os.makedirs(parent_dir, exist_ok=True)
with TemporaryDirectory(prefix=".Radicale.tmp-",
dir=parent_dir) as tmp_dir:
# Create a temporary directory with an unsafe name
with TemporaryDirectory(
prefix=".Radicale.tmp-", dir=parent_dir) as tmp_dir:
# The temporary directory itself can't be renamed
tmp_filesystem_path = os.path.join(tmp_dir, "collection")
os.makedirs(tmp_filesystem_path)
# path is unsafe
self = cls("/", principal=principal, folder=tmp_filesystem_path)
self.set_meta(props)
if props.get("tag") == "VCALENDAR":
if collection:
if props.get("tag") == "VCALENDAR":
collection, = collection
items = []
for content in ("vevent", "vtodo", "vjournal"):
items.extend(getattr(collection, "%s_list" % content,
[]))
def get_uid(item):
return hasattr(item, "uid") and item.uid.value
items_by_uid = groupby(
sorted(items, key=get_uid), get_uid)
items.extend(
getattr(collection, "%s_list" % content, []))
items_by_uid = groupby(sorted(items, key=get_uid), get_uid)
for uid, items in items_by_uid:
new_collection = vobject.iCalendar()
for item in items:
@ -479,11 +506,12 @@ class Collection(BaseCollection):
self.upload(
self._find_available_file_name(), new_collection)
elif props.get("tag") == "VCARD":
if collection:
for card in collection:
self.upload(self._find_available_file_name(), card)
os.rename(tmp_filesystem_path, filesystem_path)
sync_directory(parent_dir)
return cls(sane_path, principal=principal)
def list(self):
@ -498,7 +526,7 @@ class Collection(BaseCollection):
continue
path = os.path.join(self._filesystem_path, href)
if os.path.isfile(path):
with open(path, encoding=self.storage_encoding) as fd:
with open(path, encoding=self.encoding) as fd:
yield href, get_etag(fd.read())
def get(self, href):
@ -507,12 +535,12 @@ class Collection(BaseCollection):
href = href.strip("{}").replace("/", "_")
if not is_safe_filesystem_path_component(href):
self.logger.debug(
"Can't tranlate name safely to filesystem: %s", href)
"Can't translate name safely to filesystem: %s", href)
return None
path = path_to_filesystem(self._filesystem_path, href)
if not os.path.isfile(path):
return None
with open(path, encoding=self.storage_encoding) as fd:
with open(path, encoding=self.encoding) as fd:
text = fd.read()
last_modified = time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
@ -525,11 +553,10 @@ class Collection(BaseCollection):
def upload(self, href, vobject_item):
# TODO: use returned object in code
if not is_safe_filesystem_path_component(href):
raise ValueError(
"Can't tranlate name safely to filesystem: %s" % href)
raise UnsafePathError(href)
path = path_to_filesystem(self._filesystem_path, href)
if os.path.exists(path):
raise ValueError("Component already exists: %s" % href)
raise ComponentExistsError(href)
item = Item(self, vobject_item, href)
with self._atomic_write(path) as fd:
fd.write(item.serialize())
@ -539,16 +566,14 @@ class Collection(BaseCollection):
# TODO: use etag in code and test it here
# TODO: use returned object in code
if not is_safe_filesystem_path_component(href):
raise ValueError(
"Can't tranlate name safely to filesystem: %s" % href)
raise UnsafePathError(href)
path = path_to_filesystem(self._filesystem_path, href)
if not os.path.isfile(path):
raise ValueError("Component doesn't exist: %s" % href)
with open(path, encoding=self.storage_encoding) as fd:
raise ComponentNotFoundError(href)
with open(path, encoding=self.encoding) as fd:
text = fd.read()
if etag and etag != get_etag(text):
raise ValueError(
"ETag doesn't match: %s != %s" % (etag, get_etag(text)))
raise EtagMismatchError(etag, get_etag(text))
item = Item(self, vobject_item, href)
with self._atomic_write(path) as fd:
fd.write(item.serialize())
@ -564,31 +589,28 @@ class Collection(BaseCollection):
else:
# Delete an item
if not is_safe_filesystem_path_component(href):
raise ValueError(
"Can't tranlate name safely to filesystem: %s" % href)
raise UnsafePathError(href)
path = path_to_filesystem(self._filesystem_path, href)
if not os.path.isfile(path):
raise ValueError("Component doesn't exist: %s" % href)
with open(path, encoding=self.storage_encoding) as fd:
raise ComponentNotFoundError(href)
with open(path, encoding=self.encoding) as fd:
text = fd.read()
if etag and etag != get_etag(text):
raise ValueError(
"ETag doesn't match: %s != %s" % (etag, get_etag(text)))
raise EtagMismatchError(etag, get_etag(text))
os.remove(path)
def get_meta(self, key):
if os.path.exists(self._props_path):
with open(self._props_path, encoding=self.storage_encoding) as prop:
with open(self._props_path, encoding=self.encoding) as prop:
return json.load(prop).get(key)
def set_meta(self, props):
if os.path.exists(self._props_path):
with open(self._props_path, encoding=self.storage_encoding) as prop:
with open(self._props_path, encoding=self.encoding) as prop:
old_props = json.load(prop)
old_props.update(props)
props = old_props
# filter empty entries
props = {k:v for k,v in props.items() if v}
props = {key: value for key, value in props.items() if value}
with self._atomic_write(self._props_path, "w+") as prop:
json.dump(props, prop)
@ -609,7 +631,7 @@ class Collection(BaseCollection):
continue
path = os.path.join(self._filesystem_path, href)
if os.path.isfile(path):
with open(path, encoding=self.storage_encoding) as fd:
with open(path, encoding=self.encoding) as fd:
items.append(vobject.readOne(fd.read()))
if self.get_meta("tag") == "VCALENDAR":
collection = vobject.iCalendar()
@ -640,13 +662,11 @@ class Collection(BaseCollection):
else:
return not cls._writer and cls._readers == 0
if mode not in ("r", "w"):
raise ValueError("Invalid lock mode: %s" % mode)
# Use a primitive lock which only works within one process as a
# precondition for inter-process file-based locking
with cls._lock:
if cls._waiters or not condition():
# use FIFO for access requests
# Use FIFO for access requests
waiter = threading.Condition(lock=cls._lock)
cls._waiters.append(waiter)
while True:
@ -656,7 +676,7 @@ class Collection(BaseCollection):
cls._waiters.pop(0)
if mode == "r":
cls._readers += 1
# notify additional potential readers
# Notify additional potential readers
if cls._waiters:
cls._waiters[0].notify()
else:
@ -668,7 +688,7 @@ class Collection(BaseCollection):
os.makedirs(folder, exist_ok=True)
lock_path = os.path.join(folder, ".Radicale.lock")
cls._lock_file = open(lock_path, "w+")
# set access rights to a necessary minimum to prevent locking
# Set access rights to a necessary minimum to prevent locking
# by arbitrary users
try:
os.chmod(lock_path, stat.S_IWUSR | stat.S_IRUSR)

View File

@ -30,11 +30,13 @@ import re
import xml.etree.ElementTree as ET
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from http import client
from urllib.parse import unquote, urlparse
import vobject
from . import client, storage
MIMETYPES = {
"VADDRESSBOOK": "text/vcard",
"VCALENDAR": "text/calendar"}
NAMESPACES = {
"C": "urn:ietf:params:xml:ns:caldav",
@ -44,21 +46,12 @@ NAMESPACES = {
"ICAL": "http://apple.com/ns/ical/",
"ME": "http://me.com/_namespace/"}
NAMESPACES_REV = {}
for short, url in NAMESPACES.items():
NAMESPACES_REV[url] = short
ET.register_namespace("" if short == "D" else short, url)
CLARK_TAG_REGEX = re.compile(r"""
{ # {
(?P<namespace>[^}]*) # namespace URL
} # }
(?P<tag>.*) # short tag name
""", re.VERBOSE)
CLARK_TAG_REGEX = re.compile(r" {(?P<namespace>[^}]*)}(?P<tag>.*)", re.VERBOSE)
def _pretty_xml(element, level=0):
@ -430,7 +423,7 @@ def name_from_path(path, collection):
collection_parts = collection_path.split("/") if collection_path else []
path = path.strip("/")
path_parts = path.split("/") if path else []
if (len(path_parts) - len(collection_parts)):
if len(path_parts) - len(collection_parts):
return path_parts[-1]
@ -479,7 +472,7 @@ def delete(path, collection, href=None):
"""
collection.delete(href)
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus"))
response = ET.Element(_tag("D", "response"))
multistatus.append(response)
@ -504,12 +497,12 @@ def propfind(path, xml_request, read_collections, write_collections, user):
in the output.
"""
# Reading request
if xml_request:
root = ET.fromstring(xml_request.encode("utf8"))
props = [prop.tag for prop in root.find(_tag("D", "prop"))]
else:
props = [_tag("D", "getcontenttype"),
props = [
_tag("D", "getcontenttype"),
_tag("D", "resourcetype"),
_tag("D", "displayname"),
_tag("D", "owner"),
@ -517,9 +510,7 @@ def propfind(path, xml_request, read_collections, write_collections, user):
_tag("ICAL", "calendar-color"),
_tag("CS", "getctag")]
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus"))
collections = []
for collection in write_collections:
collections.append(collection)
@ -603,7 +594,8 @@ def _propfind_response(path, item, props, user, write=False):
element.append(comp)
else:
is404 = True
elif tag in (_tag("D", "current-user-principal"),
elif tag in (
_tag("D", "current-user-principal"),
_tag("C", "calendar-user-address-set"),
_tag("CR", "addressbook-home-set"),
_tag("C", "calendar-home-set")):
@ -632,7 +624,7 @@ def _propfind_response(path, item, props, user, write=False):
if tag == _tag("D", "getcontenttype"):
item_tag = item.get_meta("tag")
if item_tag:
element.text = storage.MIMETYPES[item_tag]
element.text = MIMETYPES[item_tag]
else:
is404 = True
elif tag == _tag("D", "resourcetype"):
@ -717,10 +709,7 @@ def _add_propstat_to(element, tag, status_number):
prop = ET.Element(_tag("D", "prop"))
propstat.append(prop)
if "{" in tag:
clark_tag = tag
else:
clark_tag = _tag(*tag.split(":", 1))
clark_tag = tag if "{" in tag else _tag(*tag.split(":", 1))
prop_tag = ET.Element(clark_tag)
prop.append(prop_tag)
@ -735,14 +724,11 @@ def proppatch(path, xml_request, collection):
Read rfc4918-9.2 for info.
"""
# Reading request
root = ET.fromstring(xml_request.encode("utf8"))
props_to_set = props_from_request(root, actions=("set",))
props_to_remove = props_from_request(root, actions=("remove",))
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus"))
response = ET.Element(_tag("D", "response"))
multistatus.append(response)
@ -750,11 +736,8 @@ def proppatch(path, xml_request, collection):
href.text = _href(collection, path)
response.append(href)
# Merge props_to_set and props_to_remove
for short_name in props_to_remove:
props_to_set[short_name] = ""
# Set/Delete props in one atomic operation
collection.set_meta(props_to_set)
for short_name in props_to_set:
@ -769,16 +752,15 @@ def report(path, xml_request, collection):
Read rfc3253-3.6 for info.
"""
# Reading request
root = ET.fromstring(xml_request.encode("utf8"))
prop_element = root.find(_tag("D", "prop"))
props = (
[prop.tag for prop in prop_element]
if prop_element is not None else [])
if collection:
if root.tag in (_tag("C", "calendar-multiget"),
if root.tag in (
_tag("C", "calendar-multiget"),
_tag("CR", "addressbook-multiget")):
# Read rfc4791-7.9 for info
base_prefix = collection.configuration.get("server", "base_prefix")
@ -795,24 +777,19 @@ def report(path, xml_request, collection):
else:
hreferences = filters = ()
# Writing answer
multistatus = ET.Element(_tag("D", "multistatus"))
for hreference in hreferences:
# Check if the reference is an item or a collection
name = name_from_path(hreference, collection)
if name:
# Reference is an item
path = "/".join(hreference.split("/")[:-1]) + "/"
item = collection.get(name)
if item is None:
multistatus.append(
_item_response(hreference, found_item=False))
response = _item_response(hreference, found_item=False)
multistatus.append(response)
continue
items = [item]
else:
# Reference is a collection
path = hreference
@ -840,7 +817,8 @@ def report(path, xml_request, collection):
"text/vcard" if name == "vcard" else "text/calendar")
element.text = "%s; component=%s" % (mimetype, name)
found_props.append(element)
elif tag in (_tag("C", "calendar-data"),
elif tag in (
_tag("C", "calendar-data"),
_tag("CR", "address-data")):
element.text = item.serialize()
found_props.append(element)
@ -869,25 +847,15 @@ def _item_response(href, found_props=(), not_found_props=(), found_item=True):
response.append(href_tag)
if found_item:
if found_props:
for code, props in ((200, found_props), (404, not_found_props)):
if props:
propstat = ET.Element(_tag("D", "propstat"))
status = ET.Element(_tag("D", "status"))
status.text = _response(200)
prop = ET.Element(_tag("D", "prop"))
for p in found_props:
prop.append(p)
propstat.append(prop)
propstat.append(status)
response.append(propstat)
if not_found_props:
propstat = ET.Element(_tag("D", "propstat"))
status = ET.Element(_tag("D", "status"))
status.text = _response(404)
prop = ET.Element(_tag("D", "prop"))
for p in not_found_props:
prop.append(p)
propstat.append(prop)
status.text = _response(code)
prop_tag = ET.Element(_tag("D", "prop"))
for prop in props:
prop_tag.append(prop)
propstat.append(prop_tag)
propstat.append(status)
response.append(propstat)
else: