diff --git a/radicale/__init__.py b/radicale/__init__.py index e803bfe..c457fb8 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -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,12 +350,11 @@ 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". + ``permission`` must either be "r" or "w". - If ``item`` is given, only access to that class of item is checked. + If ``item`` is given, only access to that class of item is checked. """ path = storage.sanitize_path(path) @@ -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 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")): + not parent_item or ( + not next(parent_item.list(), None) and + parent_item.get_meta("tag") not in ( + "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: diff --git a/radicale/__main__.py b/radicale/__main__.py index 3fcbcd6..f319226 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -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" % ( - name, filename, exception)) + 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] diff --git a/radicale/rights.py b/radicale/rights.py index 95e66e6..c2c0fd9 100644 --- a/radicale/rights.py +++ b/radicale/rights.py @@ -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) diff --git a/radicale/storage.py b/radicale/storage.py index 981553c..36c0b44 100644 --- a/radicale/storage.py +++ b/radicale/storage.py @@ -43,6 +43,7 @@ from tempfile import TemporaryDirectory from atomicwrites import AtomicWriter import vobject + if os.name == "nt": import ctypes import ctypes.wintypes @@ -55,26 +56,29 @@ if os.name == "nt": ULONG_PTR = ctypes.c_uint64 class Overlapped(ctypes.Structure): - _fields_ = [("internal", ULONG_PTR), - ("internal_high", ULONG_PTR), - ("offset", ctypes.wintypes.DWORD), - ("offset_high", ctypes.wintypes.DWORD), - ("h_event", ctypes.wintypes.HANDLE)] + _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, - ctypes.wintypes.DWORD, - ctypes.wintypes.DWORD, - ctypes.wintypes.DWORD, - ctypes.wintypes.DWORD, - ctypes.POINTER(Overlapped)] + lock_file_ex.argtypes = [ + ctypes.wintypes.HANDLE, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ctypes.POINTER(Overlapped)] lock_file_ex.restype = ctypes.wintypes.BOOL unlock_file_ex = ctypes.windll.kernel32.UnlockFileEx - unlock_file_ex.argtypes = [ctypes.wintypes.HANDLE, - ctypes.wintypes.DWORD, - ctypes.wintypes.DWORD, - ctypes.wintypes.DWORD, - ctypes.POINTER(Overlapped)] + unlock_file_ex.argtypes = [ + ctypes.wintypes.HANDLE, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ctypes.POINTER(Overlapped)] unlock_file_ex.restype = ctypes.wintypes.BOOL elif os.name == "posix": import fcntl @@ -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,40 +481,37 @@ 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 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: new_collection.add(item) self.upload( self._find_available_file_name(), new_collection) - elif props.get("tag") == "VCARD": - if collection: + elif props.get("tag") == "VCARD": 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) diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index b0d2b53..d58e77c 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -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 URL - } # } - (?P.*) # short tag name - """, re.VERBOSE) +CLARK_TAG_REGEX = re.compile(r" {(?P[^}]*)}(?P.*)", 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,22 +497,20 @@ 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"), - _tag("D", "resourcetype"), - _tag("D", "displayname"), - _tag("D", "owner"), - _tag("D", "getetag"), - _tag("ICAL", "calendar-color"), - _tag("CS", "getctag")] + props = [ + _tag("D", "getcontenttype"), + _tag("D", "resourcetype"), + _tag("D", "displayname"), + _tag("D", "owner"), + _tag("D", "getetag"), + _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,10 +594,11 @@ def _propfind_response(path, item, props, user, write=False): element.append(comp) else: is404 = True - elif tag in (_tag("D", "current-user-principal"), - _tag("C", "calendar-user-address-set"), - _tag("CR", "addressbook-home-set"), - _tag("C", "calendar-home-set")): + elif tag in ( + _tag("D", "current-user-principal"), + _tag("C", "calendar-user-address-set"), + _tag("CR", "addressbook-home-set"), + _tag("C", "calendar-home-set")): tag = ET.Element(_tag("D", "href")) tag.text = _href(collection, ("/%s/" % user) if user else "/") element.append(tag) @@ -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,17 +752,16 @@ 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"), - _tag("CR", "addressbook-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") hreferences = set() @@ -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,8 +817,9 @@ 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"), - _tag("CR", "address-data")): + elif tag in ( + _tag("C", "calendar-data"), + _tag("CR", "address-data")): element.text = item.serialize() found_props.append(element) else: @@ -869,27 +847,17 @@ def _item_response(href, found_props=(), not_found_props=(), found_item=True): response.append(href_tag) if found_item: - if found_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) - propstat.append(status) - response.append(propstat) + 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(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: status = ET.Element(_tag("D", "status")) status.text = _response(404)