From b7832edd832ad1783a64cbd84ae65a4f9de87c96 Mon Sep 17 00:00:00 2001 From: Fabian Stamm Date: Mon, 28 Sep 2020 18:17:28 +0200 Subject: [PATCH] Upgrading to radicale 3x --- .gitignore | 2 + collection/.Radicale.lock | 0 config | 20 - plugins/radicale_openauth/__init__.py | 36 +- radicale/__init__.py | 949 ----------- radicale/__main__.py | 291 ---- radicale/auth.py | 274 ---- radicale/config.py | 259 --- radicale/log.py | 75 - radicale/rights.py | 176 -- radicale/storage.py | 1683 -------------------- radicale/tests/__init__.py | 66 - radicale/tests/custom/__init__.py | 0 radicale/tests/custom/auth.py | 31 - radicale/tests/custom/rights.py | 27 - radicale/tests/custom/storage.py | 31 - radicale/tests/helpers.py | 36 - radicale/tests/static/allprop.xml | 4 - radicale/tests/static/broken-vcard.vcf | 8 - radicale/tests/static/broken-vevent.ics | 15 - radicale/tests/static/contact1.vcf | 7 - radicale/tests/static/contact_multiple.vcf | 12 - radicale/tests/static/event1-prime.ics | 34 - radicale/tests/static/event1.ics | 34 - radicale/tests/static/event2.ics | 42 - radicale/tests/static/event3.ics | 31 - radicale/tests/static/event4.ics | 30 - radicale/tests/static/event5.ics | 30 - radicale/tests/static/event6.ics | 46 - radicale/tests/static/event7.ics | 59 - radicale/tests/static/event8.ics | 33 - radicale/tests/static/event_multiple.ics | 34 - radicale/tests/static/journal1.ics | 30 - radicale/tests/static/journal2.ics | 32 - radicale/tests/static/journal3.ics | 31 - radicale/tests/static/journal4.ics | 23 - radicale/tests/static/journal5.ics | 23 - radicale/tests/static/propfind1.xml | 6 - radicale/tests/static/propname.xml | 4 - radicale/tests/static/proppatch1.xml | 8 - radicale/tests/static/todo1.ics | 28 - radicale/tests/static/todo2.ics | 28 - radicale/tests/static/todo3.ics | 26 - radicale/tests/static/todo4.ics | 26 - radicale/tests/static/todo5.ics | 27 - radicale/tests/static/todo6.ics | 26 - radicale/tests/static/todo7.ics | 26 - radicale/tests/static/todo8.ics | 25 - radicale/tests/test_auth.py | 167 -- radicale/tests/test_base.py | 1530 ------------------ radicale/tests/test_rights.py | 139 -- radicale/web.py | 124 -- radicale/web/css/icon.png | Bin 1069 -> 0 bytes radicale/web/css/main.css | 43 - radicale/web/fn.js | 1003 ------------ radicale/web/index.html | 105 -- radicale/xmlutils.py | 1324 --------------- 57 files changed, 21 insertions(+), 9158 deletions(-) create mode 100644 .gitignore delete mode 100644 collection/.Radicale.lock delete mode 100644 config delete mode 100644 radicale/__init__.py delete mode 100644 radicale/__main__.py delete mode 100644 radicale/auth.py delete mode 100644 radicale/config.py delete mode 100644 radicale/log.py delete mode 100644 radicale/rights.py delete mode 100644 radicale/storage.py delete mode 100644 radicale/tests/__init__.py delete mode 100644 radicale/tests/custom/__init__.py delete mode 100644 radicale/tests/custom/auth.py delete mode 100644 radicale/tests/custom/rights.py delete mode 100644 radicale/tests/custom/storage.py delete mode 100644 radicale/tests/helpers.py delete mode 100644 radicale/tests/static/allprop.xml delete mode 100644 radicale/tests/static/broken-vcard.vcf delete mode 100644 radicale/tests/static/broken-vevent.ics delete mode 100644 radicale/tests/static/contact1.vcf delete mode 100644 radicale/tests/static/contact_multiple.vcf delete mode 100644 radicale/tests/static/event1-prime.ics delete mode 100644 radicale/tests/static/event1.ics delete mode 100644 radicale/tests/static/event2.ics delete mode 100644 radicale/tests/static/event3.ics delete mode 100644 radicale/tests/static/event4.ics delete mode 100644 radicale/tests/static/event5.ics delete mode 100644 radicale/tests/static/event6.ics delete mode 100644 radicale/tests/static/event7.ics delete mode 100644 radicale/tests/static/event8.ics delete mode 100644 radicale/tests/static/event_multiple.ics delete mode 100644 radicale/tests/static/journal1.ics delete mode 100644 radicale/tests/static/journal2.ics delete mode 100644 radicale/tests/static/journal3.ics delete mode 100644 radicale/tests/static/journal4.ics delete mode 100644 radicale/tests/static/journal5.ics delete mode 100644 radicale/tests/static/propfind1.xml delete mode 100644 radicale/tests/static/propname.xml delete mode 100644 radicale/tests/static/proppatch1.xml delete mode 100644 radicale/tests/static/todo1.ics delete mode 100644 radicale/tests/static/todo2.ics delete mode 100644 radicale/tests/static/todo3.ics delete mode 100644 radicale/tests/static/todo4.ics delete mode 100644 radicale/tests/static/todo5.ics delete mode 100644 radicale/tests/static/todo6.ics delete mode 100644 radicale/tests/static/todo7.ics delete mode 100644 radicale/tests/static/todo8.ics delete mode 100644 radicale/tests/test_auth.py delete mode 100644 radicale/tests/test_base.py delete mode 100644 radicale/tests/test_rights.py delete mode 100644 radicale/web.py delete mode 100644 radicale/web/css/icon.png delete mode 100644 radicale/web/css/main.css delete mode 100644 radicale/web/fn.js delete mode 100644 radicale/web/index.html delete mode 100644 radicale/xmlutils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2acd07c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +Collections/ +config.ini \ No newline at end of file diff --git a/collection/.Radicale.lock b/collection/.Radicale.lock deleted file mode 100644 index e69de29..0000000 diff --git a/config b/config deleted file mode 100644 index 3a84f82..0000000 --- a/config +++ /dev/null @@ -1,20 +0,0 @@ -[server] -hosts = 0.0.0.0:5232 -max_connections = 50 -dns_lookup = True - - -[auth] -type = radicale_stamm_auth -server = https://auth.stamm.me -client_id = 02fcf12f66a73e0d816677a6d1c669b383ac4e656a3f02ae262207cb236a8bf9f973c6ff194f27e6f40ac53a3001949c8f2de66d586bdd70991ada25f68591e8 -client_secret = fe9696c800bf59346e895ae8e95edf8b18b14f5c305a53aca1d66b2b6f1a91c4a324fb7d910ede0f691087d99649e28fc606bf9312f2a7c9f65f06f8c2bbb49d5663c2306c5f3446f335f03f3371dea61fa96815376147e30d7dc0b06afe25e2a02f7aec42d07bc6e89f2ef6bcb664c4d6bd867a7f4a67e9fac7e0568440fb409535927703f5c2272624918f7be9f1e34a7178a9d0ed8c0e39ed2b587436087fc74d1b9917c30c803940240837e429166aba6935ffe4c770f7bf45201296ecc9 - -[rights] -type = owner_only - -[storage] -filesystem_folder = .\\collection - -[logging] -debug = True diff --git a/plugins/radicale_openauth/__init__.py b/plugins/radicale_openauth/__init__.py index 3c3ffb6..e51629a 100644 --- a/plugins/radicale_openauth/__init__.py +++ b/plugins/radicale_openauth/__init__.py @@ -3,24 +3,37 @@ import urllib.request import json import hashlib import requests +import logging +from radicale.log import logger class Auth(BaseAuth): def get_server(self): return self.configuration.get("auth", "server") - def is_authenticated(self, user, password): - if user is None: - return False + def login(self, login, password): + logger.debug("Things %s %s", login, password) + # Get uid from username + if login is None or login is "": + return "" + res = requests.post(self.get_server() + "/api/login?type=username&username=" + login) + data = res.json() + if "error" in data: + return "" + + user = data["uid"] + + # Get salt res1 = requests.post(self.get_server() + "/api/login?type=username&uid=" + user) data1 = res1.json() if "error" in data1: - return False + return "" salt = data1["salt"].encode() + # Check password id = self.configuration.get("auth", "client_id") secret = self.configuration.get("auth", "client_secret") password = hashlib.sha512(salt + password.encode()).hexdigest() @@ -29,16 +42,5 @@ class Auth(BaseAuth): data2 = res2.json() if "success" in data2 and data2["success"] is True: - return True - return False - - def map_login_to_user(self, login): - # Get uid from username - if login is None or login is "": - return None - req_data = dict() - res = requests.post(self.get_server() + "/api/login?type=username&username=" + login) - data = res.json() - if "error" in data: - return None - return data["uid"] + return user + return "" \ No newline at end of file diff --git a/radicale/__init__.py b/radicale/__init__.py deleted file mode 100644 index 88b1bb6..0000000 --- a/radicale/__init__.py +++ /dev/null @@ -1,949 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2008 Nicolas Kandel -# Copyright © 2008 Pascal Halter -# Copyright © 2008-2017 Guillaume Ayoub -# -# 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 . - -""" -Radicale Server module. - -This module offers a WSGI application class. - -To use this module, you should take a look at the file ``radicale.py`` that -should have been included in this package. - -""" - -import base64 -import contextlib -import datetime -import io -import itertools -import logging -import os -import posixpath -import pprint -import random -import socket -import socketserver -import ssl -import sys -import threading -import time -import wsgiref.simple_server -import zlib -from http import client -from urllib.parse import unquote, urlparse -from xml.etree import ElementTree as ET - -import vobject - -from . import auth, rights, storage, web, xmlutils - -VERSION = "2.1.8" - -NOT_ALLOWED = ( - client.FORBIDDEN, (("Content-Type", "text/plain"),), - "Access to the requested resource forbidden.") -BAD_REQUEST = ( - client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request") -NOT_FOUND = ( - client.NOT_FOUND, (("Content-Type", "text/plain"),), - "The requested resource could not be found.") -WEBDAV_PRECONDITION_FAILED = ( - client.CONFLICT, (("Content-Type", "text/plain"),), - "WebDAV precondition failed.") -PRECONDITION_FAILED = ( - client.PRECONDITION_FAILED, - (("Content-Type", "text/plain"),), "Precondition failed.") -REQUEST_TIMEOUT = ( - client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),), - "Connection timed out.") -REQUEST_ENTITY_TOO_LARGE = ( - client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),), - "Request body too large.") -REMOTE_DESTINATION = ( - client.BAD_GATEWAY, (("Content-Type", "text/plain"),), - "Remote destination not supported.") -DIRECTORY_LISTING = ( - client.FORBIDDEN, (("Content-Type", "text/plain"),), - "Directory listings are not supported.") -INTERNAL_SERVER_ERROR = ( - client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),), - "A server error occurred. Please contact the administrator.") - -DAV_HEADERS = "1, 2, 3, calendar-access, addressbook, extended-mkcol" - - -class HTTPServer(wsgiref.simple_server.WSGIServer): - """HTTP server.""" - - # These class attributes must be set before creating instance - client_timeout = None - max_connections = None - logger = None - - def __init__(self, address, handler, bind_and_activate=True): - """Create server.""" - ipv6 = ":" in address[0] - - if ipv6: - self.address_family = socket.AF_INET6 - - # Do not bind and activate, as we might change socket options - super().__init__(address, handler, False) - - if ipv6: - # Only allow IPv6 connections to the IPv6 socket - self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) - - if self.max_connections: - self.connections_guard = threading.BoundedSemaphore( - self.max_connections) - else: - # use dummy context manager - self.connections_guard = contextlib.ExitStack() - - if bind_and_activate: - try: - self.server_bind() - self.server_activate() - except BaseException: - self.server_close() - raise - - if self.client_timeout and sys.version_info < (3, 5, 2): - self.logger.warning("Using server.timeout with Python < 3.5.2 " - "can cause network connection failures") - - def get_request(self): - # Set timeout for client - _socket, address = super().get_request() - if self.client_timeout: - _socket.settimeout(self.client_timeout) - return _socket, address - - def handle_error(self, request, client_address): - if issubclass(sys.exc_info()[0], socket.timeout): - self.logger.info("client timed out", exc_info=True) - else: - self.logger.error("An exception occurred during request: %s", - sys.exc_info()[1], exc_info=True) - - -class HTTPSServer(HTTPServer): - """HTTPS server.""" - - # These class attributes must be set before creating instance - certificate = None - key = None - protocol = None - ciphers = None - certificate_authority = None - - def __init__(self, address, handler): - """Create server by wrapping HTTP socket in an SSL socket.""" - super().__init__(address, handler, bind_and_activate=False) - - self.socket = ssl.wrap_socket( - self.socket, self.key, self.certificate, server_side=True, - cert_reqs=ssl.CERT_REQUIRED if self.certificate_authority else - ssl.CERT_NONE, - ca_certs=self.certificate_authority or None, - ssl_version=self.protocol, ciphers=self.ciphers, - do_handshake_on_connect=False) - - self.server_bind() - self.server_activate() - - -class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer): - def process_request_thread(self, request, client_address): - with self.connections_guard: - return super().process_request_thread(request, client_address) - - -class ThreadedHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): - def process_request_thread(self, request, client_address): - try: - try: - request.do_handshake() - except socket.timeout: - raise - except Exception as e: - raise RuntimeError("SSL handshake failed: %s" % e) from e - except Exception: - try: - self.handle_error(request, client_address) - finally: - self.shutdown_request(request) - return - with self.connections_guard: - return super().process_request_thread(request, client_address) - - -class RequestHandler(wsgiref.simple_server.WSGIRequestHandler): - """HTTP requests handler.""" - - # These class attributes must be set before creating instance - logger = None - - def __init__(self, *args, **kwargs): - # Store exception for logging - self.error_stream = io.StringIO() - super().__init__(*args, **kwargs) - - def get_stderr(self): - return self.error_stream - - def log_message(self, *args, **kwargs): - """Disable inner logging management.""" - - def get_environ(self): - env = super().get_environ() - if hasattr(self.connection, "getpeercert"): - # The certificate can be evaluated by the auth module - env["REMOTE_CERTIFICATE"] = self.connection.getpeercert() - # Parent class only tries latin1 encoding - env["PATH_INFO"] = unquote(self.path.split("?", 1)[0]) - return env - - def handle(self): - super().handle() - # Log exception - error = self.error_stream.getvalue().strip("\n") - if error: - self.logger.error( - "An unhandled exception occurred during request:\n%s" % error) - - -class Application: - """WSGI application managing collections.""" - - def __init__(self, configuration, logger): - """Initialize application.""" - super().__init__() - self.configuration = configuration - self.logger = logger - self.Auth = auth.load(configuration, logger) - self.Collection = storage.load(configuration, logger) - self.Rights = rights.load(configuration, logger) - self.Web = web.load(configuration, logger) - self.encoding = configuration.get("encoding", "request") - - 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 passwords - mask_passwords = self.configuration.getboolean( - "logging", "mask_passwords") - authorization = request_environ.get("HTTP_AUTHORIZATION", "") - if mask_passwords and authorization.startswith("Basic"): - request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**" - if request_environ.get("HTTP_COOKIE"): - request_environ["HTTP_COOKIE"] = "**masked**" - - return request_environ - - def decode(self, text, environ): - """Try to magically decode ``text`` according to given ``environ``.""" - # List of charsets to try - charsets = [] - - # First append content charset given in the request - content_type = environ.get("CONTENT_TYPE") - if content_type and "charset=" in content_type: - charsets.append( - content_type.split("charset=")[1].split(";")[0].strip()) - # Then append default Radicale charset - charsets.append(self.encoding) - # Then append various fallbacks - charsets.append("utf-8") - charsets.append("iso8859-1") - - # Try to decode - for charset in charsets: - try: - return text.decode(charset) - except UnicodeDecodeError: - pass - raise UnicodeDecodeError - - def collect_allowed_items(self, items, user): - """Get items from request that user is allowed to access.""" - read_allowed_items = [] - write_allowed_items = [] - for item in items: - if isinstance(item, storage.BaseCollection): - path = storage.sanitize_path("/%s/" % item.path) - can_read = self.Rights.authorized(user, path, "r") - can_write = self.Rights.authorized(user, path, "w") - target = "collection %r" % item.path - else: - path = storage.sanitize_path("/%s/%s" % (item.collection.path, - item.href)) - can_read = self.Rights.authorized_item(user, path, "r") - can_write = self.Rights.authorized_item(user, path, "w") - target = "item %r from %r" % (item.href, item.collection.path) - text_status = [] - if can_read: - text_status.append("read") - read_allowed_items.append(item) - if can_write: - text_status.append("write") - write_allowed_items.append(item) - self.logger.debug( - "%s has %s access to %s", - repr(user) if user else "anonymous user", - " and ".join(text_status) if text_status else "NO", target) - return read_allowed_items, write_allowed_items - - def __call__(self, environ, start_response): - try: - status, headers, answers = self._handle_request(environ) - except Exception as e: - try: - method = str(environ["REQUEST_METHOD"]) - except Exception: - method = "unknown" - try: - path = str(environ.get("PATH_INFO", "")) - except Exception: - path = "" - self.logger.error("An exception occurred during %s request on %r: " - "%s", method, path, e, exc_info=True) - status, headers, answer = INTERNAL_SERVER_ERROR - answer = answer.encode("ascii") - status = "%d %s" % ( - status, client.responses.get(status, "Unknown")) - headers = [("Content-Length", str(len(answer)))] + list(headers) - answers = [answer] - start_response(status, headers) - return answers - - def _handle_request(self, environ): - """Manage a request.""" - def response(status, headers=(), answer=None): - headers = dict(headers) - # Set content length - if answer: - if hasattr(answer, "encode"): - self.logger.debug("Response content:\n%s", answer) - headers["Content-Type"] += "; charset=%s" % self.encoding - answer = answer.encode(self.encoding) - accept_encoding = [ - encoding.strip() for encoding in - environ.get("HTTP_ACCEPT_ENCODING", "").split(",") - if encoding.strip()] - - if "gzip" in accept_encoding: - zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS) - answer = zcomp.compress(answer) + zcomp.flush() - headers["Content-Encoding"] = "gzip" - - 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) - - # Start response - time_end = datetime.datetime.now() - status = "%d %s" % ( - status, client.responses.get(status, "Unknown")) - self.logger.info( - "%s response status for %r%s in %.3f seconds: %s", - environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), - depthinfo, (time_end - time_begin).total_seconds(), status) - # Return response content - return status, list(headers.items()), [answer] if answer else [] - - remote_host = "unknown" - if environ.get("REMOTE_HOST"): - remote_host = repr(environ["REMOTE_HOST"]) - elif environ.get("REMOTE_ADDR"): - remote_host = environ["REMOTE_ADDR"] - if environ.get("HTTP_X_FORWARDED_FOR"): - remote_host = "%r (forwarded by %s)" % ( - environ["HTTP_X_FORWARDED_FOR"], remote_host) - remote_useragent = "" - if environ.get("HTTP_USER_AGENT"): - remote_useragent = " using %r" % environ["HTTP_USER_AGENT"] - depthinfo = "" - if environ.get("HTTP_DEPTH"): - depthinfo = " with depth %r" % environ["HTTP_DEPTH"] - time_begin = datetime.datetime.now() - self.logger.info( - "%s request for %r%s received from %s%s", - environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo, - remote_host, remote_useragent) - headers = pprint.pformat(self.headers_log(environ)) - self.logger.debug("Request headers:\n%s", headers) - - # Let reverse proxies overwrite SCRIPT_NAME - if "HTTP_X_SCRIPT_NAME" in environ: - # script_name must be removed from PATH_INFO by the client. - unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"] - self.logger.debug("Script name overwritten by client: %r", - unsafe_base_prefix) - else: - # SCRIPT_NAME is already removed from PATH_INFO, according to the - # WSGI specification. - unsafe_base_prefix = environ.get("SCRIPT_NAME", "") - # Sanitize base prefix - base_prefix = storage.sanitize_path(unsafe_base_prefix).rstrip("/") - self.logger.debug("Sanitized script name: %r", base_prefix) - # Sanitize request URI (a WSGI server indicates with an empty path, - # that the URL targets the application root without a trailing slash) - path = storage.sanitize_path(environ.get("PATH_INFO", "")) - self.logger.debug("Sanitized path: %r", path) - - # Get function corresponding to method - function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper()) - - # If "/.well-known" is not available, clients query "/" - if path == "/.well-known" or path.startswith("/.well-known/"): - return response(*NOT_FOUND) - - # Ask authentication backend to check rights - external_login = self.Auth.get_external_login(environ) - authorization = environ.get("HTTP_AUTHORIZATION", "") - if external_login: - login, password = external_login - elif authorization.startswith("Basic"): - authorization = authorization[len("Basic"):].strip() - login, password = self.decode(base64.b64decode( - authorization.encode("ascii")), environ).split(":", 1) - else: - # DEPRECATED: use remote_user backend instead - login = environ.get("REMOTE_USER", "") - password = "" - user = self.Auth.map_login_to_user(login) - - if not user: - is_authenticated = True - elif not storage.is_safe_path_component(user): - # Prevent usernames like "user/calendar.ics" - self.logger.info("Refused unsafe username: %r", user) - is_authenticated = False - else: - is_authenticated = self.Auth.is_authenticated2(login, user, - password) - if not is_authenticated: - self.logger.info("Failed login attempt: %r", user) - # Random delay to avoid timing oracles and bruteforce attacks - delay = self.configuration.getfloat("auth", "delay") - if delay > 0: - random_delay = delay * (0.5 + random.random()) - self.logger.debug("Sleeping %.3f seconds", random_delay) - time.sleep(random_delay) - else: - self.logger.info("Successful login: %r", user) - - # Create principal collection - if user and is_authenticated: - principal_path = "/%s/" % user - if self.Rights.authorized(user, principal_path, "w"): - with self.Collection.acquire_lock("r", user): - principal = next( - self.Collection.discover(principal_path, depth="1"), - None) - if not principal: - with self.Collection.acquire_lock("w", user): - try: - self.Collection.create_collection(principal_path) - except ValueError as e: - self.logger.warning("Failed to create principal " - "collection %r: %s", user, e) - is_authenticated = False - else: - self.logger.warning("Access to principal path %r denied by " - "rights backend", principal_path) - - # Verify content length - content_length = int(environ.get("CONTENT_LENGTH") or 0) - if content_length: - max_content_length = self.configuration.getint( - "server", "max_content_length") - if max_content_length and content_length > max_content_length: - self.logger.info( - "Request body too large: %d", content_length) - return response(*REQUEST_ENTITY_TOO_LARGE) - - if is_authenticated: - status, headers, answer = function( - environ, base_prefix, path, user) - if (status, headers, answer) == NOT_ALLOWED: - self.logger.info("Access to %r denied for %s", path, - repr(user) if user else "anonymous user") - else: - status, headers, answer = NOT_ALLOWED - - if (status, headers, answer) == NOT_ALLOWED and not ( - user and is_authenticated) and not external_login: - # Unknown or unauthorized user - self.logger.debug("Asking client for authentication") - status = client.UNAUTHORIZED - realm = self.configuration.get("server", "realm") - headers = dict(headers) - headers.update({ - "WWW-Authenticate": - "Basic realm=\"%s\"" % realm}) - - return response(status, headers, answer) - - def _access(self, user, path, permission, item=None): - """Check if ``user`` can access ``path`` or the parent collection. - - ``permission`` must either be "r" or "w". - - If ``item`` is given, only access to that class of item is checked. - - """ - allowed = False - if not item or isinstance(item, storage.BaseCollection): - allowed |= self.Rights.authorized(user, path, permission) - if not item or not isinstance(item, storage.BaseCollection): - allowed |= self.Rights.authorized_item(user, path, permission) - return allowed - - def _read_raw_content(self, environ): - content_length = int(environ.get("CONTENT_LENGTH") or 0) - if not content_length: - return b"" - content = environ["wsgi.input"].read(content_length) - if len(content) < content_length: - raise RuntimeError("Request body too short: %d" % len(content)) - return content - - def _read_content(self, environ): - content = self.decode(self._read_raw_content(environ), environ) - self.logger.debug("Request content:\n%s", content) - return content - - def _read_xml_content(self, environ): - content = self.decode(self._read_raw_content(environ), environ) - if not content: - return None - try: - xml_content = ET.fromstring(content) - except ET.ParseError as e: - self.logger.debug("Request content (Invalid XML):\n%s", content) - raise RuntimeError("Failed to parse XML: %s" % e) from e - if self.logger.isEnabledFor(logging.DEBUG): - self.logger.debug("Request content:\n%s", - xmlutils.pretty_xml(xml_content)) - return xml_content - - def _write_xml_content(self, xml_content): - if self.logger.isEnabledFor(logging.DEBUG): - self.logger.debug("Response content:\n%s", - xmlutils.pretty_xml(xml_content)) - f = io.BytesIO() - ET.ElementTree(xml_content).write(f, encoding=self.encoding, - xml_declaration=True) - return f.getvalue() - - def do_DELETE(self, environ, base_prefix, path, user): - """Manage DELETE request.""" - if not self._access(user, path, "w"): - return NOT_ALLOWED - with self.Collection.acquire_lock("w", user): - item = next(self.Collection.discover(path), None) - if not self._access(user, path, "w", item): - return NOT_ALLOWED - if not item: - return NOT_FOUND - if_match = environ.get("HTTP_IF_MATCH", "*") - if if_match not in ("*", item.etag): - # ETag precondition not verified, do not delete item - return PRECONDITION_FAILED - if isinstance(item, storage.BaseCollection): - xml_answer = xmlutils.delete(base_prefix, path, item) - else: - xml_answer = xmlutils.delete( - base_prefix, path, item.collection, item.href) - headers = {"Content-Type": "text/xml; charset=%s" % self.encoding} - return client.OK, headers, self._write_xml_content(xml_answer) - - def do_GET(self, environ, base_prefix, path, user): - """Manage GET request.""" - # Redirect to .web if the root URL is requested - if not path.strip("/"): - web_path = ".web" - if not environ.get("PATH_INFO"): - web_path = posixpath.join(posixpath.basename(base_prefix), - web_path) - return (client.FOUND, - {"Location": web_path, "Content-Type": "text/plain"}, - "Redirected to %s" % web_path) - # Dispatch .web URL to web module - if path == "/.web" or path.startswith("/.web/"): - return self.Web.get(environ, base_prefix, path, user) - if not self._access(user, path, "r"): - return NOT_ALLOWED - with self.Collection.acquire_lock("r", user): - item = next(self.Collection.discover(path), None) - if not self._access(user, path, "r", item): - return NOT_ALLOWED - if not item: - return NOT_FOUND - if isinstance(item, storage.BaseCollection): - tag = item.get_meta("tag") - if not tag: - return DIRECTORY_LISTING - content_type = xmlutils.MIMETYPES[tag] - else: - content_type = xmlutils.OBJECT_MIMETYPES[item.name] - headers = { - "Content-Type": content_type, - "Last-Modified": item.last_modified, - "ETag": item.etag} - answer = item.serialize() - return client.OK, headers, answer - - def do_HEAD(self, environ, base_prefix, path, user): - """Manage HEAD request.""" - status, headers, answer = self.do_GET( - environ, base_prefix, path, user) - return status, headers, None - - def do_MKCALENDAR(self, environ, base_prefix, path, user): - """Manage MKCALENDAR request.""" - if not self.Rights.authorized(user, path, "w"): - return NOT_ALLOWED - try: - xml_content = self._read_xml_content(environ) - except RuntimeError as e: - self.logger.warning( - "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - except socket.timeout as e: - self.logger.debug("client timed out", exc_info=True) - return REQUEST_TIMEOUT - with self.Collection.acquire_lock("w", user): - item = next(self.Collection.discover(path), None) - if item: - return WEBDAV_PRECONDITION_FAILED - props = xmlutils.props_from_request(xml_content) - props["tag"] = "VCALENDAR" - # TODO: use this? - # timezone = props.get("C:calendar-timezone") - try: - storage.check_and_sanitize_props(props) - self.Collection.create_collection(path, props=props) - except ValueError as e: - self.logger.warning( - "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - return client.CREATED, {}, None - - def do_MKCOL(self, environ, base_prefix, path, user): - """Manage MKCOL request.""" - if not self.Rights.authorized(user, path, "w"): - return NOT_ALLOWED - try: - xml_content = self._read_xml_content(environ) - except RuntimeError as e: - self.logger.warning( - "Bad MKCOL request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - except socket.timeout as e: - self.logger.debug("client timed out", exc_info=True) - return REQUEST_TIMEOUT - with self.Collection.acquire_lock("w", user): - item = next(self.Collection.discover(path), None) - if item: - return WEBDAV_PRECONDITION_FAILED - props = xmlutils.props_from_request(xml_content) - try: - storage.check_and_sanitize_props(props) - self.Collection.create_collection(path, props=props) - except ValueError as e: - self.logger.warning( - "Bad MKCOL request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - return client.CREATED, {}, None - - def do_MOVE(self, environ, base_prefix, path, user): - """Manage MOVE request.""" - raw_dest = environ.get("HTTP_DESTINATION", "") - to_url = urlparse(raw_dest) - if to_url.netloc != environ["HTTP_HOST"]: - self.logger.info("Unsupported destination address: %r", raw_dest) - # Remote destination server, not supported - return REMOTE_DESTINATION - if not self._access(user, path, "w"): - return NOT_ALLOWED - to_path = storage.sanitize_path(to_url.path) - if not (to_path + "/").startswith(base_prefix + "/"): - self.logger.warning("Destination %r from MOVE request on %r does" - "n't start with base prefix", to_path, path) - return NOT_ALLOWED - to_path = to_path[len(base_prefix):] - if not self._access(user, to_path, "w"): - return NOT_ALLOWED - - with self.Collection.acquire_lock("w", user): - 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 NOT_FOUND - if isinstance(item, storage.BaseCollection): - return WEBDAV_PRECONDITION_FAILED - - to_item = next(self.Collection.discover(to_path), None) - if (isinstance(to_item, storage.BaseCollection) or - to_item and environ.get("HTTP_OVERWRITE", "F") != "T"): - return WEBDAV_PRECONDITION_FAILED - to_parent_path = storage.sanitize_path( - "/%s/" % posixpath.dirname(to_path.strip("/"))) - to_collection = next( - self.Collection.discover(to_parent_path), None) - if not to_collection: - return WEBDAV_PRECONDITION_FAILED - to_href = posixpath.basename(to_path.strip("/")) - try: - self.Collection.move(item, to_collection, to_href) - except ValueError as e: - self.logger.warning( - "Bad MOVE request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - return client.CREATED, {}, None - - def do_OPTIONS(self, environ, base_prefix, path, user): - """Manage OPTIONS request.""" - headers = { - "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, base_prefix, path, user): - """Manage PROPFIND request.""" - if not self._access(user, path, "r"): - return NOT_ALLOWED - try: - xml_content = self._read_xml_content(environ) - except RuntimeError as e: - self.logger.warning( - "Bad PROPFIND request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - except socket.timeout as e: - self.logger.debug("client timed out", exc_info=True) - return REQUEST_TIMEOUT - with self.Collection.acquire_lock("r", user): - items = self.Collection.discover( - path, environ.get("HTTP_DEPTH", "0")) - # take root item for rights checking - item = next(items, None) - if not self._access(user, path, "r", item): - return NOT_ALLOWED - if not item: - return NOT_FOUND - # put item back - items = itertools.chain([item], items) - read_items, write_items = self.collect_allowed_items(items, user) - headers = {"DAV": DAV_HEADERS, - "Content-Type": "text/xml; charset=%s" % self.encoding} - status, xml_answer = xmlutils.propfind( - base_prefix, path, xml_content, read_items, write_items, user) - if status == client.FORBIDDEN: - return NOT_ALLOWED - else: - return status, headers, self._write_xml_content(xml_answer) - - def do_PROPPATCH(self, environ, base_prefix, path, user): - """Manage PROPPATCH request.""" - if not self.Rights.authorized(user, path, "w"): - return NOT_ALLOWED - try: - xml_content = self._read_xml_content(environ) - except RuntimeError as e: - self.logger.warning( - "Bad PROPPATCH request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - except socket.timeout as e: - self.logger.debug("client timed out", exc_info=True) - return REQUEST_TIMEOUT - with self.Collection.acquire_lock("w", user): - item = next(self.Collection.discover(path), None) - if not isinstance(item, storage.BaseCollection): - return WEBDAV_PRECONDITION_FAILED - headers = {"DAV": DAV_HEADERS, - "Content-Type": "text/xml; charset=%s" % self.encoding} - try: - xml_answer = xmlutils.proppatch(base_prefix, path, xml_content, - item) - except ValueError as e: - self.logger.warning( - "Bad PROPPATCH request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - return (client.MULTI_STATUS, headers, - self._write_xml_content(xml_answer)) - - def do_PUT(self, environ, base_prefix, path, user): - """Manage PUT request.""" - if not self._access(user, path, "w"): - return NOT_ALLOWED - try: - content = self._read_content(environ) - except RuntimeError as e: - self.logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - except socket.timeout as e: - self.logger.debug("client timed out", exc_info=True) - return REQUEST_TIMEOUT - with self.Collection.acquire_lock("w", user): - parent_path = storage.sanitize_path( - "/%s/" % posixpath.dirname(path.strip("/"))) - item = next(self.Collection.discover(path), None) - parent_item = next(self.Collection.discover(parent_path), None) - - write_whole_collection = ( - isinstance(item, storage.BaseCollection) 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: - if not self.Rights.authorized(user, path, "w"): - return NOT_ALLOWED - elif not self.Rights.authorized_item(user, path, "w"): - return NOT_ALLOWED - - etag = environ.get("HTTP_IF_MATCH", "") - if not item and etag: - # Etag asked but no item found: item has been removed - return PRECONDITION_FAILED - if item and etag and item.etag != etag: - # Etag asked but item not matching: item has changed - return PRECONDITION_FAILED - - match = environ.get("HTTP_IF_NONE_MATCH", "") == "*" - if item and match: - # Creation asked but item found: item can't be replaced - return PRECONDITION_FAILED - - try: - items = tuple(vobject.readComponents(content or "")) - if not write_whole_collection and len(items) != 1: - raise RuntimeError( - "Item contains %d components" % len(items)) - if write_whole_collection or not parent_item.get_meta("tag"): - content_type = environ.get("CONTENT_TYPE", - "").split(";")[0] - tags = {value: key - for key, value in xmlutils.MIMETYPES.items()} - tag = tags.get(content_type) - if items and items[0].name == "VCALENDAR": - tag = "VCALENDAR" - elif items and items[0].name in ("VCARD", "VLIST"): - tag = "VADDRESSBOOK" - else: - tag = parent_item.get_meta("tag") - if tag == "VCALENDAR" and len(items) > 1: - raise RuntimeError("VCALENDAR collection contains %d " - "components" % len(items)) - for i in items: - storage.check_and_sanitize_item( - i, is_collection=write_whole_collection, uid=item.uid - if not write_whole_collection and item else None, - tag=tag) - except Exception as e: - self.logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - - if write_whole_collection: - props = {} - if tag: - props["tag"] = tag - if tag == "VCALENDAR" and items: - if hasattr(items[0], "x_wr_calname"): - calname = items[0].x_wr_calname.value - if calname: - props["D:displayname"] = calname - if hasattr(items[0], "x_wr_caldesc"): - caldesc = items[0].x_wr_caldesc.value - if caldesc: - props["C:calendar-description"] = caldesc - try: - storage.check_and_sanitize_props(props) - new_item = self.Collection.create_collection( - path, items, props) - except ValueError as e: - self.logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - else: - href = posixpath.basename(path.strip("/")) - try: - if tag and not parent_item.get_meta("tag"): - new_props = parent_item.get_meta() - new_props["tag"] = tag - storage.check_and_sanitize_props(new_props) - parent_item.set_meta_all(new_props) - new_item = parent_item.upload(href, items[0]) - except ValueError as e: - self.logger.warning( - "Bad PUT request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - headers = {"ETag": new_item.etag} - return client.CREATED, headers, None - - def do_REPORT(self, environ, base_prefix, path, user): - """Manage REPORT request.""" - if not self._access(user, path, "r"): - return NOT_ALLOWED - try: - xml_content = self._read_xml_content(environ) - except RuntimeError as e: - self.logger.warning( - "Bad REPORT request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - except socket.timeout as e: - self.logger.debug("client timed out", exc_info=True) - return REQUEST_TIMEOUT - with self.Collection.acquire_lock("r", user): - item = next(self.Collection.discover(path), None) - if not self._access(user, path, "r", item): - return NOT_ALLOWED - if not item: - return NOT_FOUND - if isinstance(item, storage.BaseCollection): - collection = item - else: - collection = item.collection - headers = {"Content-Type": "text/xml; charset=%s" % self.encoding} - try: - status, xml_answer = xmlutils.report( - base_prefix, path, xml_content, collection) - except ValueError as e: - self.logger.warning( - "Bad REPORT request on %r: %s", path, e, exc_info=True) - return BAD_REQUEST - return (status, headers, self._write_xml_content(xml_answer)) diff --git a/radicale/__main__.py b/radicale/__main__.py deleted file mode 100644 index 3e133c7..0000000 --- a/radicale/__main__.py +++ /dev/null @@ -1,291 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2011-2017 Guillaume Ayoub -# -# 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 . - -""" -Radicale executable module. - -This module can be executed from a command line with ``$python -m radicale`` or -from a python programme with ``radicale.__main__.run()``. - -""" - -import argparse -import atexit -import os -import select -import signal -import socket -import ssl -import sys -from wsgiref.simple_server import make_server - -from . import (VERSION, Application, RequestHandler, ThreadedHTTPServer, - ThreadedHTTPSServer, config, log, storage) - - -def run(): - """Run Radicale as a standalone server.""" - # Get command-line arguments - parser = argparse.ArgumentParser(usage="radicale [OPTIONS]") - - parser.add_argument("--version", action="version", version=VERSION) - parser.add_argument("--verify-storage", action="store_true", - help="check the storage for errors and exit") - parser.add_argument( - "-C", "--config", help="use a specific configuration file") - - groups = {} - for section, values in config.INITIAL_CONFIG.items(): - group = parser.add_argument_group(section) - groups[group] = [] - for option, data in values.items(): - kwargs = data.copy() - long_name = "--{0}-{1}".format( - section, option.replace("_", "-")) - args = kwargs.pop("aliases", []) - args.append(long_name) - kwargs["dest"] = "{0}_{1}".format(section, option) - groups[group].append(kwargs["dest"]) - del kwargs["value"] - if "internal" in kwargs: - del kwargs["internal"] - - if kwargs["type"] == bool: - del kwargs["type"] - kwargs["action"] = "store_const" - kwargs["const"] = "True" - opposite_args = kwargs.pop("opposite", []) - opposite_args.append("--no{0}".format(long_name[1:])) - group.add_argument(*args, **kwargs) - - kwargs["const"] = "False" - kwargs["help"] = "do not {0} (opposite of {1})".format( - kwargs["help"], long_name) - group.add_argument(*opposite_args, **kwargs) - else: - group.add_argument(*args, **kwargs) - - args = parser.parse_args() - if args.config is not None: - config_paths = [args.config] if args.config else [] - ignore_missing_paths = False - else: - config_paths = ["/etc/radicale/config", - os.path.expanduser("~/.config/radicale/config")] - if "RADICALE_CONFIG" in os.environ: - config_paths.append(os.environ["RADICALE_CONFIG"]) - ignore_missing_paths = True - try: - configuration = config.load(config_paths, - ignore_missing_paths=ignore_missing_paths) - except Exception as e: - print("ERROR: Invalid configuration: %s" % e, file=sys.stderr) - if args.logging_debug: - raise - exit(1) - - # Update Radicale configuration according to arguments - for group, actions in groups.items(): - section = group.title - for action in actions: - value = getattr(args, action) - if value is not None: - configuration.set(section, action.split('_', 1)[1], value) - - if args.verify_storage: - # Write to stderr when storage verification is requested - configuration["logging"]["config"] = "" - - # Start logging - filename = os.path.expanduser(configuration.get("logging", "config")) - debug = configuration.getboolean("logging", "debug") - try: - logger = log.start("radicale", filename, debug) - except Exception as e: - print("ERROR: Failed to start logger: %s" % e, file=sys.stderr) - if debug: - raise - exit(1) - - if args.verify_storage: - logger.info("Verifying storage") - try: - Collection = storage.load(configuration, logger) - with Collection.acquire_lock("r"): - if not Collection.verify(): - logger.error("Storage verifcation failed") - exit(1) - except Exception as e: - logger.error("An exception occurred during storage verification: " - "%s", e, exc_info=True) - exit(1) - return - - try: - serve(configuration, logger) - except Exception as e: - logger.error("An exception occurred during server startup: %s", e, - exc_info=True) - exit(1) - - -def daemonize(configuration, logger): - """Fork and decouple if Radicale is configured as daemon.""" - # Check and create PID file in a race-free manner - if configuration.get("server", "pid"): - try: - pid_path = os.path.abspath(os.path.expanduser( - configuration.get("server", "pid"))) - pid_fd = os.open( - pid_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) - except OSError as e: - raise OSError("PID file exists: %r" % - configuration.get("server", "pid")) from e - pid = os.fork() - if pid: - # Write PID - if configuration.get("server", "pid"): - with os.fdopen(pid_fd, "w") as pid_file: - pid_file.write(str(pid)) - sys.exit() - if configuration.get("server", "pid"): - os.close(pid_fd) - - # Register exit function - def cleanup(): - """Remove the PID files.""" - logger.debug("Cleaning up") - # Remove PID file - os.unlink(pid_path) - atexit.register(cleanup) - # Decouple environment - os.chdir("/") - os.setsid() - with open(os.devnull, "r") as null_in: - os.dup2(null_in.fileno(), sys.stdin.fileno()) - with open(os.devnull, "w") as null_out: - os.dup2(null_out.fileno(), sys.stdout.fileno()) - os.dup2(null_out.fileno(), sys.stderr.fileno()) - - -def serve(configuration, logger): - """Serve radicale from configuration.""" - logger.info("Starting Radicale") - - # Create collection servers - servers = {} - if configuration.getboolean("server", "ssl"): - server_class = ThreadedHTTPSServer - server_class.certificate = configuration.get("server", "certificate") - server_class.key = configuration.get("server", "key") - server_class.certificate_authority = configuration.get( - "server", "certificate_authority") - server_class.ciphers = configuration.get("server", "ciphers") - server_class.protocol = getattr( - ssl, configuration.get("server", "protocol"), ssl.PROTOCOL_SSLv23) - # Test if the SSL files can be read - for name in ["certificate", "key"] + ( - ["certificate_authority"] - if server_class.certificate_authority else []): - filename = getattr(server_class, name) - try: - open(filename, "r").close() - except OSError as e: - raise RuntimeError("Failed to read SSL %s %r: %s" % - (name, filename, e)) from e - else: - server_class = ThreadedHTTPServer - server_class.client_timeout = configuration.getint("server", "timeout") - server_class.max_connections = configuration.getint( - "server", "max_connections") - server_class.logger = logger - - RequestHandler.logger = logger - if not configuration.getboolean("server", "dns_lookup"): - RequestHandler.address_string = lambda self: self.client_address[0] - - shutdown_program = False - - for host in configuration.get("server", "hosts").split(","): - try: - address, port = host.strip().rsplit(":", 1) - address, port = address.strip("[] "), int(port) - except ValueError as e: - raise RuntimeError( - "Failed to parse address %r: %s" % (host, e)) from e - application = Application(configuration, logger) - try: - server = make_server( - address, port, application, server_class, RequestHandler) - except OSError as e: - raise RuntimeError( - "Failed to start server %r: %s" % (host, e)) from e - servers[server.socket] = server - logger.info("Listening to %r on port %d%s", - server.server_name, server.server_port, " using SSL" - if configuration.getboolean("server", "ssl") else "") - - # Create a socket pair to notify the select syscall of program shutdown - # This is not available in python < 3.5 on Windows - if hasattr(socket, "socketpair"): - shutdown_program_socket_in, shutdown_program_socket_out = ( - socket.socketpair()) - else: - shutdown_program_socket_in, shutdown_program_socket_out = None, None - - # SIGTERM and SIGINT (aka KeyboardInterrupt) should just mark this for - # shutdown - def shutdown(*args): - nonlocal shutdown_program - if shutdown_program: - # Ignore following signals - return - logger.info("Stopping Radicale") - shutdown_program = True - if shutdown_program_socket_in: - shutdown_program_socket_in.sendall(b"goodbye") - signal.signal(signal.SIGTERM, shutdown) - signal.signal(signal.SIGINT, shutdown) - - # Main loop: wait for requests on any of the servers or program shutdown - sockets = list(servers.keys()) - if shutdown_program_socket_out: - # Use socket pair to get notified of program shutdown - sockets.append(shutdown_program_socket_out) - select_timeout = None - if not shutdown_program_socket_out or os.name == "nt": - # Fallback to busy waiting. (select.select blocks SIGINT on Windows.) - select_timeout = 1.0 - if configuration.getboolean("server", "daemon"): - daemonize(configuration, logger) - logger.info("Radicale server ready") - while not shutdown_program: - try: - rlist, _, xlist = select.select( - sockets, [], sockets, select_timeout) - except (KeyboardInterrupt, select.error): - # SIGINT is handled by signal handler above - rlist, xlist = [], [] - if xlist: - raise RuntimeError("unhandled socket error") - if rlist: - server = servers.get(rlist[0]) - if server: - server.handle_request() - - -if __name__ == "__main__": - run() diff --git a/radicale/auth.py b/radicale/auth.py deleted file mode 100644 index fc5d425..0000000 --- a/radicale/auth.py +++ /dev/null @@ -1,274 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2008 Nicolas Kandel -# Copyright © 2008 Pascal Halter -# Copyright © 2008-2017 Guillaume Ayoub -# -# 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 . - -""" -Authentication management. - -Default is htpasswd authentication. - -Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html) -manages a file for storing user credentials. It can encrypt passwords using -different methods, e.g. BCRYPT, MD5-APR1 (a version of MD5 modified for -Apache), SHA1, or by using the system's CRYPT routine. The CRYPT and SHA1 -encryption methods implemented by htpasswd are considered as insecure. MD5-APR1 -provides medium security as of 2015. Only BCRYPT can be considered secure by -current standards. - -MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it -is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer. - -The `is_authenticated(user, password)` function provided by this module -verifies the user-given credentials by parsing the htpasswd credential file -pointed to by the ``htpasswd_filename`` configuration value while assuming -the password encryption method specified via the ``htpasswd_encryption`` -configuration value. - -The following htpasswd password encrpytion methods are supported by Radicale -out-of-the-box: - - - plain-text (created by htpasswd -p...) -- INSECURE - - CRYPT (created by htpasswd -d...) -- INSECURE - - SHA1 (created by htpasswd -s...) -- INSECURE - -When passlib (https://pypi.python.org/pypi/passlib) is importable, the -following significantly more secure schemes are parsable by Radicale: - - - MD5-APR1 (htpasswd -m...) -- htpasswd's default method - - BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x - -""" - -import base64 -import functools -import hashlib -import hmac -import os -from importlib import import_module - -INTERNAL_TYPES = ("None", "none", "remote_user", "http_x_remote_user", - "htpasswd") - - -def load(configuration, logger): - """Load the authentication manager chosen in configuration.""" - auth_type = configuration.get("auth", "type") - if auth_type in ("None", "none"): # DEPRECATED: use "none" - class_ = NoneAuth - elif auth_type == "remote_user": - class_ = RemoteUserAuth - elif auth_type == "http_x_remote_user": - class_ = HttpXRemoteUserAuth - elif auth_type == "htpasswd": - class_ = Auth - else: - try: - class_ = import_module(auth_type).Auth - except Exception as e: - raise RuntimeError("Failed to load authentication module %r: %s" % - (auth_type, e)) from e - logger.info("Authentication type is %r", auth_type) - return class_(configuration, logger) - - -class BaseAuth: - def __init__(self, configuration, logger): - self.configuration = configuration - self.logger = logger - - def get_external_login(self, environ): - """Optionally provide the login and password externally. - - ``environ`` a dict with the WSGI environment - - If ``()`` is returned, Radicale handles HTTP authentication. - Otherwise, returns a tuple ``(login, password)``. For anonymous users - ``login`` must be ``""``. - - """ - return () - - def is_authenticated2(self, login, user, password): - """Validate credentials. - - ``login`` the login name - - ``user`` the user from ``map_login_to_user(login)``. - - ``password`` the login password - - """ - return self.is_authenticated(user, password) - - def is_authenticated(self, user, password): - """Validate credentials. - - DEPRECATED: use ``is_authenticated2`` instead - - """ - raise NotImplementedError - - def map_login_to_user(self, login): - """Map login name to internal user. - - ``login`` the login name, ``""`` for anonymous users - - Returns a string with the user name. - If a login can't be mapped to an user, return ``login`` and - return ``False`` in ``is_authenticated2(...)``. - - """ - return login - - -class NoneAuth(BaseAuth): - def is_authenticated(self, user, password): - return True - - -class Auth(BaseAuth): - def __init__(self, configuration, logger): - super().__init__(configuration, logger) - self.filename = os.path.expanduser( - configuration.get("auth", "htpasswd_filename")) - self.encryption = configuration.get("auth", "htpasswd_encryption") - - if self.encryption == "ssha": - self.verify = self._ssha - elif self.encryption == "sha1": - self.verify = self._sha1 - elif self.encryption == "plain": - self.verify = self._plain - elif self.encryption == "md5": - try: - from passlib.hash import apr_md5_crypt - except ImportError as e: - raise RuntimeError( - "The htpasswd encryption method 'md5' requires " - "the passlib module.") from e - self.verify = functools.partial(self._md5apr1, apr_md5_crypt) - elif self.encryption == "bcrypt": - try: - from passlib.hash import bcrypt - except ImportError as e: - raise RuntimeError( - "The htpasswd encryption method 'bcrypt' requires " - "the passlib module with bcrypt support.") from e - # A call to `encrypt` raises passlib.exc.MissingBackendError with a - # good error message if bcrypt backend is not available. Trigger - # this here. - bcrypt.encrypt("test-bcrypt-backend") - self.verify = functools.partial(self._bcrypt, bcrypt) - elif self.encryption == "crypt": - try: - import crypt - except ImportError as e: - raise RuntimeError( - "The htpasswd encryption method 'crypt' requires " - "the crypt() system support.") from e - self.verify = functools.partial(self._crypt, crypt) - else: - raise RuntimeError( - "The htpasswd encryption method %r is not " - "supported." % self.encryption) - - def _plain(self, hash_value, password): - """Check if ``hash_value`` and ``password`` match, plain method.""" - return hmac.compare_digest(hash_value, password) - - def _crypt(self, crypt, hash_value, password): - """Check if ``hash_value`` and ``password`` match, crypt method.""" - hash_value = hash_value.strip() - return hmac.compare_digest(crypt.crypt(password, hash_value), - hash_value) - - def _sha1(self, hash_value, password): - """Check if ``hash_value`` and ``password`` match, sha1 method.""" - hash_value = base64.b64decode(hash_value.strip().replace( - "{SHA}", "").encode("ascii")) - password = password.encode(self.configuration.get("encoding", "stock")) - sha1 = hashlib.sha1() - sha1.update(password) - return hmac.compare_digest(sha1.digest(), hash_value) - - def _ssha(self, hash_value, password): - """Check if ``hash_value`` and ``password`` match, salted sha1 method. - - This method is not directly supported by htpasswd, but it can be - written with e.g. openssl, and nginx can parse it. - - """ - hash_value = base64.b64decode(hash_value.strip().replace( - "{SSHA}", "").encode("ascii")) - password = password.encode(self.configuration.get("encoding", "stock")) - salt_value = hash_value[20:] - hash_value = hash_value[:20] - sha1 = hashlib.sha1() - sha1.update(password) - sha1.update(salt_value) - return hmac.compare_digest(sha1.digest(), hash_value) - - def _bcrypt(self, bcrypt, hash_value, password): - hash_value = hash_value.strip() - return bcrypt.verify(password, hash_value) - - def _md5apr1(self, md5_apr1, hash_value, password): - hash_value = hash_value.strip() - return md5_apr1.verify(password, hash_value) - - def is_authenticated(self, user, password): - """Validate credentials. - - Iterate through htpasswd credential file until user matches, extract - hash (encrypted password) and check hash against user-given password, - using the method specified in the Radicale config. - - The content of the file is not cached because reading is generally a - very cheap operation, and it's useful to get live updates of the - htpasswd file. - - """ - try: - with open(self.filename) as f: - for line in f: - line = line.rstrip("\n") - if line.lstrip() and not line.lstrip().startswith("#"): - try: - login, hash_value = line.split(":", maxsplit=1) - # Always compare both login and password to avoid - # timing attacks, see #591. - login_ok = hmac.compare_digest(login, user) - password_ok = self.verify(hash_value, password) - if login_ok and password_ok: - return True - except ValueError as e: - raise RuntimeError("Invalid htpasswd file %r: %s" % - (self.filename, e)) from e - except OSError as e: - raise RuntimeError("Failed to load htpasswd file %r: %s" % - (self.filename, e)) from e - return False - - -class RemoteUserAuth(NoneAuth): - def get_external_login(self, environ): - return environ.get("REMOTE_USER", ""), "" - - -class HttpXRemoteUserAuth(NoneAuth): - def get_external_login(self, environ): - return environ.get("HTTP_X_REMOTE_USER", ""), "" diff --git a/radicale/config.py b/radicale/config.py deleted file mode 100644 index 7a6fa6b..0000000 --- a/radicale/config.py +++ /dev/null @@ -1,259 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2008 Nicolas Kandel -# Copyright © 2008 Pascal Halter -# -# 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 . - -""" -Radicale configuration module. - -Give a configparser-like interface to read and write configuration. - -""" - -import math -import os -from collections import OrderedDict -from configparser import RawConfigParser as ConfigParser - -from . import auth, rights, storage, web - - -def positive_int(value): - value = int(value) - if value < 0: - raise ValueError("value is negative: %d" % value) - return value - - -def positive_float(value): - value = float(value) - if not math.isfinite(value): - raise ValueError("value is infinite") - if math.isnan(value): - raise ValueError("value is not a number") - if value < 0: - raise ValueError("value is negative: %f" % value) - return value - - -# Default configuration -INITIAL_CONFIG = OrderedDict([ - ("server", OrderedDict([ - ("hosts", { - "value": "127.0.0.1:5232", - "help": "set server hostnames including ports", - "aliases": ["-H", "--hosts"], - "type": str}), - ("daemon", { - "value": "False", - "help": "launch as daemon", - "aliases": ["-d", "--daemon"], - "opposite": ["-f", "--foreground"], - "type": bool}), - ("pid", { - "value": "", - "help": "set PID filename for daemon mode", - "aliases": ["-p", "--pid"], - "type": str}), - ("max_connections", { - "value": "20", - "help": "maximum number of parallel connections", - "type": positive_int}), - ("max_content_length", { - "value": "10000000", - "help": "maximum size of request body in bytes", - "type": positive_int}), - ("timeout", { - "value": "10", - "help": "socket timeout", - "type": positive_int}), - ("ssl", { - "value": "False", - "help": "use SSL connection", - "aliases": ["-s", "--ssl"], - "opposite": ["-S", "--no-ssl"], - "type": bool}), - ("certificate", { - "value": "/etc/ssl/radicale.cert.pem", - "help": "set certificate file", - "aliases": ["-c", "--certificate"], - "type": str}), - ("key", { - "value": "/etc/ssl/radicale.key.pem", - "help": "set private key file", - "aliases": ["-k", "--key"], - "type": str}), - ("certificate_authority", { - "value": "", - "help": "set CA certificate for validating clients", - "aliases": ["--certificate-authority"], - "type": str}), - ("protocol", { - "value": "PROTOCOL_TLSv1_2", - "help": "SSL protocol used", - "type": str}), - ("ciphers", { - "value": "", - "help": "available ciphers", - "type": str}), - ("dns_lookup", { - "value": "True", - "help": "use reverse DNS to resolve client address in logs", - "type": bool}), - ("realm", { - "value": "Radicale - Password Required", - "help": "message displayed when a password is needed", - "type": str})])), - ("encoding", OrderedDict([ - ("request", { - "value": "utf-8", - "help": "encoding for responding requests", - "type": str}), - ("stock", { - "value": "utf-8", - "help": "encoding for storing local collections", - "type": str})])), - ("auth", OrderedDict([ - ("type", { - "value": "none", - "help": "authentication method", - "type": str, - "internal": auth.INTERNAL_TYPES}), - ("htpasswd_filename", { - "value": "/etc/radicale/users", - "help": "htpasswd filename", - "type": str}), - ("htpasswd_encryption", { - "value": "bcrypt", - "help": "htpasswd encryption method", - "type": str}), - ("delay", { - "value": "1", - "help": "incorrect authentication delay", - "type": positive_float})])), - ("rights", OrderedDict([ - ("type", { - "value": "owner_only", - "help": "rights backend", - "type": str, - "internal": rights.INTERNAL_TYPES}), - ("file", { - "value": "/etc/radicale/rights", - "help": "file for rights management from_file", - "type": str})])), - ("storage", OrderedDict([ - ("type", { - "value": "multifilesystem", - "help": "storage backend", - "type": str, - "internal": storage.INTERNAL_TYPES}), - ("filesystem_folder", { - "value": os.path.expanduser( - "/var/lib/radicale/collections"), - "help": "path where collections are stored", - "type": str}), - ("max_sync_token_age", { - "value": 2592000, # 30 days - "help": "delete sync token that are older", - "type": int}), - ("filesystem_fsync", { - "value": "True", - "help": "sync all changes to filesystem during requests", - "type": bool}), - ("filesystem_locking", { - "value": "True", - "help": "lock the storage while accessing it", - "type": bool}), - ("filesystem_close_lock_file", { - "value": "False", - "help": "close the lock file when no more clients are waiting", - "type": bool}), - ("hook", { - "value": "", - "help": "command that is run after changes to storage", - "type": str})])), - ("web", OrderedDict([ - ("type", { - "value": "internal", - "help": "web interface backend", - "type": str, - "internal": web.INTERNAL_TYPES})])), - ("logging", OrderedDict([ - ("config", { - "value": "", - "help": "logging configuration file", - "type": str}), - ("debug", { - "value": "False", - "help": "print debug information", - "aliases": ["-D", "--debug"], - "type": bool}), - ("full_environment", { - "value": "False", - "help": "store all environment variables", - "type": bool}), - ("mask_passwords", { - "value": "True", - "help": "mask passwords in logs", - "type": bool})]))]) - - -def load(paths=(), extra_config=None, ignore_missing_paths=True): - config = ConfigParser() - for section, values in INITIAL_CONFIG.items(): - config.add_section(section) - for key, data in values.items(): - config.set(section, key, data["value"]) - if extra_config: - for section, values in extra_config.items(): - for key, value in values.items(): - config.set(section, key, value) - for path in paths: - if path or not ignore_missing_paths: - try: - if not config.read(path) and not ignore_missing_paths: - raise RuntimeError("No such file: %r" % path) - except Exception as e: - raise RuntimeError( - "Failed to load config file %r: %s" % (path, e)) from e - # Check the configuration - for section in config.sections(): - if section == "headers": - continue - if section not in INITIAL_CONFIG: - raise RuntimeError("Invalid section %r in config" % section) - allow_extra_options = ("type" in INITIAL_CONFIG[section] and - config.get(section, "type") not in - INITIAL_CONFIG[section]["type"].get("internal", - ())) - for option in config[section]: - if option not in INITIAL_CONFIG[section]: - if allow_extra_options: - continue - raise RuntimeError("Invalid option %r in section %r in " - "config" % (option, section)) - type_ = INITIAL_CONFIG[section][option]["type"] - try: - if type_ == bool: - config.getboolean(section, option) - else: - type_(config.get(section, option)) - except Exception as e: - raise RuntimeError( - "Invalid %s value for option %r in section %r in config: " - "%r" % (type_.__name__, option, section, - config.get(section, option))) from e - return config diff --git a/radicale/log.py b/radicale/log.py deleted file mode 100644 index e803a19..0000000 --- a/radicale/log.py +++ /dev/null @@ -1,75 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2011-2017 Guillaume Ayoub -# -# 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 . - -""" -Radicale logging module. - -Manage logging from a configuration file. For more information, see: -http://docs.python.org/library/logging.config.html - -""" - -import logging -import logging.config -import signal -import sys - - -def configure_from_file(logger, filename, debug): - logging.config.fileConfig(filename, disable_existing_loggers=False) - if debug: - logger.setLevel(logging.DEBUG) - for handler in logger.handlers: - handler.setLevel(logging.DEBUG) - return logger - - -class RemoveTracebackFilter(logging.Filter): - def filter(self, record): - record.exc_info = None - return True - - -def start(name="radicale", filename=None, debug=False): - """Start the logging according to the configuration.""" - logger = logging.getLogger(name) - if debug: - logger.setLevel(logging.DEBUG) - else: - logger.addFilter(RemoveTracebackFilter()) - if filename: - # Configuration taken from file - try: - configure_from_file(logger, filename, debug) - except Exception as e: - raise RuntimeError("Failed to load logging configuration file %r: " - "%s" % (filename, e)) from e - # Reload config on SIGHUP (UNIX only) - if hasattr(signal, "SIGHUP"): - def handler(signum, frame): - try: - configure_from_file(logger, filename, debug) - except Exception as e: - logger.error("Failed to reload logging configuration file " - "%r: %s", filename, e, exc_info=True) - signal.signal(signal.SIGHUP, handler) - else: - # Default configuration, standard output - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter( - logging.Formatter("[%(thread)x] %(levelname)s: %(message)s")) - logger.addHandler(handler) - return logger diff --git a/radicale/rights.py b/radicale/rights.py deleted file mode 100644 index 76f4ad3..0000000 --- a/radicale/rights.py +++ /dev/null @@ -1,176 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2012-2017 Guillaume Ayoub -# -# 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 . - -""" -Rights backends. - -This module loads the rights backend, according to the rights -configuration. - -Default rights are based on a regex-based file whose name is specified in the -config (section "right", key "file"). - -Authentication login is matched against the "user" key, and collection's path -is matched against the "collection" key. You can use Python's ConfigParser -interpolation values %(login)s and %(path)s. You can also get groups from the -user regex in the collection with {0}, {1}, etc. - -For example, for the "user" key, ".+" means "authenticated user" and ".*" -means "anybody" (including anonymous users). - -Section names are only used for naming the rule. - -Leading or ending slashes are trimmed from collection's path. - -""" - -import configparser -import os.path -import posixpath -import re -from importlib import import_module - -from . import storage - -INTERNAL_TYPES = ("None", "none", "authenticated", "owner_write", "owner_only", - "from_file") - - -def load(configuration, logger): - """Load the rights manager chosen in configuration.""" - rights_type = configuration.get("rights", "type") - if configuration.get("auth", "type") in ("None", "none"): # DEPRECATED - rights_type = "None" - if rights_type in ("None", "none"): # DEPRECATED: use "none" - rights_class = NoneRights - elif rights_type == "authenticated": - rights_class = AuthenticatedRights - elif rights_type == "owner_write": - rights_class = OwnerWriteRights - elif rights_type == "owner_only": - rights_class = OwnerOnlyRights - elif rights_type == "from_file": - rights_class = Rights - else: - try: - rights_class = import_module(rights_type).Rights - except Exception as e: - raise RuntimeError("Failed to load rights module %r: %s" % - (rights_type, e)) from e - logger.info("Rights type is %r", rights_type) - return rights_class(configuration, logger) - - -class BaseRights: - def __init__(self, configuration, logger): - self.configuration = configuration - self.logger = logger - - def authorized(self, user, path, permission): - """Check if the user is allowed to read or write the collection. - - If ``user`` is empty, check for anonymous rights. - - ``path`` is sanitized. - - ``permission`` is "r" or "w". - - """ - raise NotImplementedError - - def authorized_item(self, user, path, permission): - """Check if the user is allowed to read or write the item.""" - path = storage.sanitize_path(path) - parent_path = storage.sanitize_path( - "/%s/" % posixpath.dirname(path.strip("/"))) - return self.authorized(user, parent_path, permission) - - -class NoneRights(BaseRights): - def authorized(self, user, path, permission): - return True - - -class AuthenticatedRights(BaseRights): - def authorized(self, user, path, permission): - return bool(user) - - -class OwnerWriteRights(BaseRights): - def authorized(self, user, path, permission): - sane_path = storage.sanitize_path(path).strip("/") - return bool(user) and (permission == "r" or - user == sane_path.split("/", maxsplit=1)[0]) - - -class OwnerOnlyRights(BaseRights): - def authorized(self, user, path, permission): - sane_path = storage.sanitize_path(path).strip("/") - return bool(user) and ( - permission == "r" and not sane_path or - user == sane_path.split("/", maxsplit=1)[0]) - - def authorized_item(self, user, path, permission): - sane_path = storage.sanitize_path(path).strip("/") - if "/" not in sane_path: - return False - return super().authorized_item(user, path, permission) - - -class Rights(BaseRights): - def __init__(self, configuration, logger): - super().__init__(configuration, logger) - self.filename = os.path.expanduser(configuration.get("rights", "file")) - - def authorized(self, user, path, permission): - user = user or "" - sane_path = storage.sanitize_path(path).strip("/") - # Prevent "regex injection" - user_escaped = re.escape(user) - sane_path_escaped = re.escape(sane_path) - regex = configparser.ConfigParser( - {"login": user_escaped, "path": sane_path_escaped}) - try: - if not regex.read(self.filename): - raise RuntimeError("No such file: %r" % - self.filename) - except Exception as e: - raise RuntimeError("Failed to load rights file %r: %s" % - (self.filename, e)) from e - for section in regex.sections(): - try: - re_user_pattern = regex.get(section, "user") - re_collection_pattern = regex.get(section, "collection") - # Emulate fullmatch - user_match = re.match(r"(?:%s)\Z" % re_user_pattern, user) - collection_match = user_match and re.match( - r"(?:%s)\Z" % re_collection_pattern.format( - *map(re.escape, user_match.groups())), sane_path) - except Exception as e: - raise RuntimeError("Error in section %r of rights file %r: " - "%s" % (section, self.filename, e)) from e - if user_match and collection_match: - self.logger.debug("Rule %r:%r matches %r:%r from section %r", - user, sane_path, re_user_pattern, - re_collection_pattern, section) - return permission in regex.get(section, "permission") - else: - self.logger.debug("Rule %r:%r doesn't match %r:%r from section" - " %r", user, sane_path, re_user_pattern, - re_collection_pattern, section) - self.logger.info( - "Rights: %r:%r doesn't match any section", user, sane_path) - return False diff --git a/radicale/storage.py b/radicale/storage.py deleted file mode 100644 index 9ac3842..0000000 --- a/radicale/storage.py +++ /dev/null @@ -1,1683 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2014 Jean-Marc Martins -# Copyright © 2012-2017 Guillaume Ayoub -# -# 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 . - -""" -Storage backends. - -This module loads the storage backend, according to the storage configuration. - -Default storage uses one folder per collection and one file per collection -entry. - -""" - -import binascii -import contextlib -import json -import os -import pickle -import posixpath -import shlex -import subprocess -import sys -import threading -import time -from contextlib import contextmanager -from hashlib import md5 -from importlib import import_module -from itertools import chain, groupby -from math import log -from random import getrandbits -from tempfile import NamedTemporaryFile, TemporaryDirectory - -import vobject - -if sys.version_info >= (3, 5): - # HACK: Avoid import cycle for Python < 3.5 - from . import xmlutils - -if os.name == "nt": - import ctypes - import ctypes.wintypes - import msvcrt - - LOCKFILE_EXCLUSIVE_LOCK = 2 - if ctypes.sizeof(ctypes.c_void_p) == 4: - ULONG_PTR = ctypes.c_uint32 - else: - 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)] - - 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.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.restype = ctypes.wintypes.BOOL -elif os.name == "posix": - import fcntl - -INTERNAL_TYPES = ("multifilesystem",) - - -def load(configuration, logger): - """Load the storage manager chosen in configuration.""" - if sys.version_info < (3, 5): - # HACK: Avoid import cycle for Python < 3.5 - global xmlutils - from . import xmlutils - storage_type = configuration.get("storage", "type") - if storage_type == "multifilesystem": - collection_class = Collection - else: - try: - collection_class = import_module(storage_type).Collection - except Exception as e: - raise RuntimeError("Failed to load storage module %r: %s" % - (storage_type, e)) from e - logger.info("Storage type is %r", storage_type) - - class CollectionCopy(collection_class): - """Collection copy, avoids overriding the original class attributes.""" - CollectionCopy.configuration = configuration - CollectionCopy.logger = logger - return CollectionCopy - - -def check_and_sanitize_item(vobject_item, is_collection=False, uid=None, - tag=None): - """Check vobject items for common errors and add missing UIDs. - - ``multiple`` indicates that the vobject_item contains unrelated components. - - If ``uid`` is not set, the UID is generated randomly. - - """ - if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"): - raise ValueError("Unsupported collection tag: %r" % tag) - if vobject_item.name == "VCALENDAR" and tag == "VCALENDAR": - component_name = None - object_uid = None - object_uid_set = False - for component in vobject_item.components(): - # https://tools.ietf.org/html/rfc4791#section-4.1 - if component.name == "VTIMEZONE": - continue - if component_name is None or is_collection: - component_name = component.name - elif component_name != component.name: - raise ValueError("Multiple component types in object: %r, %r" % - (component_name, component.name)) - if component_name not in ("VTODO", "VEVENT", "VJOURNAL"): - continue - component_uid = get_uid(component) - if not object_uid_set or is_collection: - object_uid_set = True - object_uid = component_uid - if component_uid is None: - component.add("UID").value = uid or random_uuid4() - elif not component_uid: - component.uid.value = uid or random_uuid4() - elif not object_uid or not component_uid: - raise ValueError("Multiple %s components without UID in " - "object" % component_name) - elif object_uid != component_uid: - raise ValueError( - "Multiple %s components with different UIDs in object: " - "%r, %r" % (component_name, object_uid, component_uid)) - # vobject interprets recurrence rules on demand - try: - component.rruleset - except Exception as e: - raise ValueError("invalid recurrence rules in %s" % - component.name) from e - elif vobject_item.name == "VCARD" and tag == "VADDRESSBOOK": - # https://tools.ietf.org/html/rfc6352#section-5.1 - object_uid = get_uid(vobject_item) - if object_uid is None: - vobject_item.add("UID").value = uid or random_uuid4() - elif not object_uid: - vobject_item.uid.value = uid or random_uuid4() - elif vobject_item.name == "VLIST" and tag == "VADDRESSBOOK": - # Custom format used by SOGo Connector to store lists of contacts - pass - else: - raise ValueError("Item type %r not supported in %s collection" % - (vobject_item.name, repr(tag) if tag else "generic")) - - -def check_and_sanitize_props(props): - """Check collection properties for common errors.""" - tag = props.get("tag") - if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"): - raise ValueError("Unsupported collection tag: %r" % tag) - - -def random_uuid4(): - """Generate a pseudo-random UUID""" - r = "%016x" % getrandbits(128) - return "%s-%s-%s-%s-%s" % (r[:8], r[8:12], r[12:16], r[16:20], r[20:]) - - -def scandir(path, only_dirs=False, only_files=False): - """Iterator for directory elements. (For compatibility with Python < 3.5) - - ``only_dirs`` only return directories - - ``only_files`` only return files - - """ - if sys.version_info >= (3, 5): - for entry in os.scandir(path): - if ((not only_files or entry.is_file()) and - (not only_dirs or entry.is_dir())): - yield entry.name - else: - for name in os.listdir(path): - p = os.path.join(path, name) - if ((not only_files or os.path.isfile(p)) and - (not only_dirs or os.path.isdir(p))): - yield name - - -def get_etag(text): - """Etag from collection or item. - - Encoded as quoted-string (see RFC 2616). - - """ - etag = md5() - etag.update(text.encode("utf-8")) - return '"%s"' % etag.hexdigest() - - -def get_uid(vobject_component): - """UID value of an item if defined.""" - return ((hasattr(vobject_component, "uid") or None) and - vobject_component.uid.value) - - -def get_uid_from_object(vobject_item): - """UID value of an calendar/addressbook object.""" - if vobject_item.name == "VCALENDAR": - if hasattr(vobject_item, "vevent"): - return get_uid(vobject_item.vevent) - if hasattr(vobject_item, "vjournal"): - return get_uid(vobject_item.vjournal) - if hasattr(vobject_item, "vtodo"): - return get_uid(vobject_item.vtodo) - elif vobject_item.name == "VCARD": - return get_uid(vobject_item) - return None - - -def sanitize_path(path): - """Make path absolute with leading slash to prevent access to other data. - - Preserve a potential trailing slash. - - """ - trailing_slash = "/" if path.endswith("/") else "" - path = posixpath.normpath(path) - new_path = "/" - for part in path.split("/"): - if not is_safe_path_component(part): - continue - new_path = posixpath.join(new_path, part) - trailing_slash = "" if new_path.endswith("/") else trailing_slash - 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 local and posix filesystem - path. - - Check that the path is safe to join too. - - """ - return ( - path and not os.path.splitdrive(path)[0] and - not os.path.split(path)[0] and path not in (os.curdir, os.pardir) and - not path.startswith(".") and not path.endswith("~") and - is_safe_path_component(path)) - - -def path_to_filesystem(root, *paths): - """Convert path to a local filesystem path relative to base_folder. - - `root` must be a secure filesystem path, it will be prepend to the path. - - Conversion of `paths` is done in a secure manner, or raises ``ValueError``. - - """ - paths = [sanitize_path(path).strip("/") for path in paths] - safe_path = root - for path in paths: - if not path: - continue - for part in path.split("/"): - if not is_safe_filesystem_path_component(part): - raise UnsafePathError(part) - safe_path_parent = safe_path - safe_path = os.path.join(safe_path, part) - # Check for conflicting files (e.g. case-insensitive file systems - # or short names on Windows file systems) - if (os.path.lexists(safe_path) and - part not in scandir(safe_path_parent)): - raise CollidingPathError(part) - return safe_path - - -def left_encode_int(v): - length = int(log(v, 256)) + 1 if v != 0 else 1 - return bytes((length,)) + v.to_bytes(length, 'little') - - -class UnsafePathError(ValueError): - def __init__(self, path): - message = "Can't translate name safely to filesystem: %r" % path - super().__init__(message) - - -class CollidingPathError(ValueError): - def __init__(self, path): - message = "File name collision: %r" % path - super().__init__(message) - - -class ComponentExistsError(ValueError): - def __init__(self, path): - message = "Component already exists: %r" % path - super().__init__(message) - - -class ComponentNotFoundError(ValueError): - def __init__(self, path): - message = "Component doesn't exist: %r" % path - super().__init__(message) - - -class Item: - def __init__(self, collection, item=None, href=None, last_modified=None, - text=None, etag=None, uid=None, name=None, - component_name=None): - """Initialize an item. - - ``collection`` the parent collection. - - ``href`` the href of the item. - - ``last_modified`` the HTTP-datetime of when the item was modified. - - ``text`` the text representation of the item (optional if ``item`` is - set). - - ``item`` the vobject item (optional if ``text`` is set). - - ``etag`` the etag of the item (optional). See ``get_etag``. - - ``uid`` the UID of the object (optional). See ``get_uid_from_object``. - - """ - if text is None and item is None: - raise ValueError("at least one of 'text' or 'item' must be set") - self.collection = collection - self.href = href - self.last_modified = last_modified - self._text = text - self._item = item - self._etag = etag - self._uid = uid - self._name = name - self._component_name = component_name - - def __getattr__(self, attr): - return getattr(self.item, attr) - - def serialize(self): - if self._text is None: - try: - self._text = self.item.serialize() - except Exception as e: - raise RuntimeError("Failed to serialize item %r from %r: %s" % - (self.href, self.collection.path, e)) from e - return self._text - - @property - def item(self): - if self._item is None: - try: - self._item = vobject.readOne(self._text) - except Exception as e: - raise RuntimeError("Failed to parse item %r from %r: %s" % - (self.href, self.collection.path, e)) from e - return self._item - - @property - def etag(self): - """Encoded as quoted-string (see RFC 2616).""" - if self._etag is None: - self._etag = get_etag(self.serialize()) - return self._etag - - @property - def uid(self): - if self._uid is None: - self._uid = get_uid_from_object(self.item) - return self._uid - - @property - def name(self): - if self._name is not None: - return self._name - return self.item.name - - @property - def component_name(self): - if self._component_name is not None: - return self._component_name - return xmlutils.find_tag(self.item) - - -class BaseCollection: - - # Overriden on copy by the "load" function - configuration = None - logger = None - - # Properties of instance - """The sanitized path of the collection without leading or trailing ``/``. - """ - path = "" - - @property - def owner(self): - """The owner of the collection.""" - return self.path.split("/", maxsplit=1)[0] - - @property - def is_principal(self): - """Collection is a principal.""" - return bool(self.path) and "/" not in self.path - - @owner.setter - def owner(self, value): - # DEPRECATED: Included for compatibility reasons - pass - - @is_principal.setter - def is_principal(self, value): - # DEPRECATED: Included for compatibility reasons - pass - - @classmethod - def discover(cls, path, depth="0"): - """Discover a list of collections under the given ``path``. - - ``path`` is sanitized. - - If ``depth`` is "0", only the actual object under ``path`` is - returned. - - If ``depth`` is anything but "0", it is considered as "1" and direct - children are included in the result. - - The root collection "/" must always exist. - - """ - raise NotImplementedError - - @classmethod - def move(cls, item, to_collection, to_href): - """Move an object. - - ``item`` is the item to move. - - ``to_collection`` is the target collection. - - ``to_href`` is the target name in ``to_collection``. An item with the - same name might already exist. - - """ - if item.collection.path == to_collection.path and item.href == to_href: - return - to_collection.upload(to_href, item.item) - item.collection.delete(item.href) - - @property - def etag(self): - """Encoded as quoted-string (see RFC 2616).""" - etag = md5() - for item in self.get_all(): - etag.update((item.href + "/" + item.etag).encode("utf-8")) - etag.update(json.dumps(self.get_meta(), sort_keys=True).encode()) - return '"%s"' % etag.hexdigest() - - @classmethod - def create_collection(cls, href, collection=None, props=None): - """Create a collection. - - ``href`` is the sanitized path. - - If the collection already exists and neither ``collection`` nor - ``props`` are set, this method shouldn't do anything. Otherwise the - existing collection must be replaced. - - ``collection`` is a list of vobject components. - - ``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. - - """ - raise NotImplementedError - - def sync(self, old_token=None): - """Get the current sync token and changed items for synchronization. - - ``old_token`` an old sync token which is used as the base of the - delta update. If sync token is missing, all items are returned. - ValueError is raised for invalid or old tokens. - - WARNING: This simple default implementation treats all sync-token as - invalid. It adheres to the specification but some clients - (e.g. InfCloud) don't like it. Subclasses should provide a - more sophisticated implementation. - - """ - token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"") - if old_token: - raise ValueError("Sync token are not supported") - return token, self.list() - - def list(self): - """List collection items.""" - raise NotImplementedError - - def get(self, href): - """Fetch a single item.""" - raise NotImplementedError - - def get_multi(self, hrefs): - """Fetch multiple items. Duplicate hrefs must be ignored. - - DEPRECATED: use ``get_multi2`` instead - - """ - return (self.get(href) for href in set(hrefs)) - - def get_multi2(self, hrefs): - """Fetch multiple items. - - Functionally similar to ``get``, but might bring performance benefits - on some storages when used cleverly. It's not required to return the - requested items in the correct order. Duplicated hrefs can be ignored. - - Returns tuples with the href and the item or None if the item doesn't - exist. - - """ - return ((href, self.get(href)) for href in hrefs) - - def get_all(self): - """Fetch all items. - - Functionally similar to ``get``, but might bring performance benefits - on some storages when used cleverly. - - """ - return map(self.get, self.list()) - - def get_all_filtered(self, filters): - """Fetch all items with optional filtering. - - This can largely improve performance of reports depending on - the filters and this implementation. - - Returns tuples in the form ``(item, filters_matched)``. - ``filters_matched`` is a bool that indicates if ``filters`` are fully - matched. - - This returns all events by default - """ - return ((item, False) for item in self.get_all()) - - def pre_filtered_list(self, filters): - """List collection items with optional pre filtering. - - DEPRECATED: use ``get_all_filtered`` instead - - """ - return self.get_all() - - def has(self, href): - """Check if an item exists by its href. - - Functionally similar to ``get``, but might bring performance benefits - on some storages when used cleverly. - - """ - return self.get(href) is not None - - def upload(self, href, vobject_item): - """Upload a new or replace an existing item.""" - raise NotImplementedError - - def delete(self, href=None): - """Delete an item. - - When ``href`` is ``None``, delete the collection. - - """ - raise NotImplementedError - - def get_meta(self, key=None): - """Get metadata value for collection. - - Return the value of the property ``key``. If ``key`` is ``None`` return - a dict with all properties - - """ - raise NotImplementedError - - def set_meta(self, props): - """Set metadata values for collection. - - ``props`` a dict with updates for properties. If a value is empty, the - property must be deleted. - - DEPRECATED: use ``set_meta_all`` instead - - """ - raise NotImplementedError - - def set_meta_all(self, props): - """Set metadata values for collection. - - ``props`` a dict with values for properties. - - """ - delta_props = self.get_meta() - for key in delta_props.keys(): - if key not in props: - delta_props[key] = None - delta_props.update(props) - self.set_meta(self, delta_props) - - @property - def last_modified(self): - """Get the HTTP-datetime of when the collection was modified.""" - raise NotImplementedError - - def serialize(self): - """Get the unicode string representing the whole collection.""" - if self.get_meta("tag") == "VCALENDAR": - in_vcalendar = False - vtimezones = "" - included_tzids = set() - vtimezone = [] - tzid = None - components = "" - # Concatenate all child elements of VCALENDAR from all items - # together, while preventing duplicated VTIMEZONE entries. - # VTIMEZONEs are only distinguished by their TZID, if different - # timezones share the same TZID this produces errornous ouput. - # VObject fails at this too. - for item in self.get_all(): - depth = 0 - for line in item.serialize().split("\r\n"): - if line.startswith("BEGIN:"): - depth += 1 - if depth == 1 and line == "BEGIN:VCALENDAR": - in_vcalendar = True - elif in_vcalendar: - if depth == 1 and line.startswith("END:"): - in_vcalendar = False - if depth == 2 and line == "BEGIN:VTIMEZONE": - vtimezone.append(line + "\r\n") - elif vtimezone: - vtimezone.append(line + "\r\n") - if depth == 2 and line.startswith("TZID:"): - tzid = line[len("TZID:"):] - elif depth == 2 and line.startswith("END:"): - if tzid is None or tzid not in included_tzids: - vtimezones += "".join(vtimezone) - included_tzids.add(tzid) - vtimezone.clear() - tzid = None - elif depth >= 2: - components += line + "\r\n" - if line.startswith("END:"): - depth -= 1 - template = vobject.iCalendar() - displayname = self.get_meta("D:displayname") - if displayname: - template.add("X-WR-CALNAME") - template.x_wr_calname.value_param = "TEXT" - template.x_wr_calname.value = displayname - description = self.get_meta("C:calendar-description") - if description: - template.add("X-WR-CALDESC") - template.x_wr_caldesc.value_param = "TEXT" - template.x_wr_caldesc.value = description - template = template.serialize() - template_insert_pos = template.find("\r\nEND:VCALENDAR\r\n") + 2 - assert template_insert_pos != -1 - return (template[:template_insert_pos] + - vtimezones + components + - template[template_insert_pos:]) - elif self.get_meta("tag") == "VADDRESSBOOK": - return "".join((item.serialize() for item in self.get_all())) - return "" - - @classmethod - @contextmanager - def acquire_lock(cls, mode, user=None): - """Set a context manager to lock the whole storage. - - ``mode`` must either be "r" for shared access or "w" for exclusive - access. - - ``user`` is the name of the logged in user or empty. - - """ - raise NotImplementedError - - @classmethod - def verify(cls): - """Check the storage for errors.""" - return True - - -ITEM_CACHE_VERSION = 1 - - -class Collection(BaseCollection): - """Collection stored in several files per calendar.""" - - def __init__(self, path, principal=None, folder=None, - filesystem_path=None): - # DEPRECATED: Remove principal and folder attributes - if folder is None: - folder = self._get_collection_root_folder() - # Path should already be sanitized - self.path = sanitize_path(path).strip("/") - self._encoding = self.configuration.get("encoding", "stock") - # DEPRECATED: Use ``self._encoding`` instead - self.encoding = self._encoding - if filesystem_path is None: - filesystem_path = path_to_filesystem(folder, self.path) - self._filesystem_path = filesystem_path - self._props_path = os.path.join( - self._filesystem_path, ".Radicale.props") - self._meta_cache = None - self._etag_cache = None - self._item_cache_cleaned = False - - @classmethod - def _get_collection_root_folder(cls): - filesystem_folder = os.path.expanduser( - cls.configuration.get("storage", "filesystem_folder")) - return os.path.join(filesystem_folder, "collection-root") - - @contextmanager - def _atomic_write(self, path, mode="w", newline=None, sync_directory=True): - directory = os.path.dirname(path) - tmp = NamedTemporaryFile( - mode=mode, dir=directory, delete=False, prefix=".Radicale.tmp-", - newline=newline, encoding=None if "b" in mode else self._encoding) - try: - yield tmp - tmp.flush() - try: - self._fsync(tmp.fileno()) - except OSError as e: - raise RuntimeError("Fsync'ing file %r failed: %s" % - (path, e)) from e - tmp.close() - os.replace(tmp.name, path) - except BaseException: - tmp.close() - os.remove(tmp.name) - raise - if sync_directory: - self._sync_directory(directory) - - @staticmethod - def _find_available_file_name(exists_fn, suffix=""): - # Prevent infinite loop - for _ in range(1000): - file_name = random_uuid4() + suffix - if not exists_fn(file_name): - return file_name - # something is wrong with the PRNG - raise RuntimeError("No unique random sequence found") - - @classmethod - def _fsync(cls, fd): - if cls.configuration.getboolean("storage", "filesystem_fsync"): - if os.name == "posix" and hasattr(fcntl, "F_FULLFSYNC"): - fcntl.fcntl(fd, fcntl.F_FULLFSYNC) - else: - os.fsync(fd) - - @classmethod - def _sync_directory(cls, path): - """Sync directory to disk. - - This only works on POSIX and does nothing on other systems. - - """ - if not cls.configuration.getboolean("storage", "filesystem_fsync"): - return - if os.name == "posix": - try: - fd = os.open(path, 0) - try: - cls._fsync(fd) - finally: - os.close(fd) - except OSError as e: - raise RuntimeError("Fsync'ing directory %r failed: %s" % - (path, e)) from e - - @classmethod - def _makedirs_synced(cls, filesystem_path): - """Recursively create a directory and its parents in a sync'ed way. - - This method acts silently when the folder already exists. - - """ - if os.path.isdir(filesystem_path): - return - parent_filesystem_path = os.path.dirname(filesystem_path) - # Prevent infinite loop - if filesystem_path != parent_filesystem_path: - # Create parent dirs recursively - cls._makedirs_synced(parent_filesystem_path) - # Possible race! - os.makedirs(filesystem_path, exist_ok=True) - cls._sync_directory(parent_filesystem_path) - - @classmethod - def discover(cls, path, depth="0", child_context_manager=( - lambda path, href=None: contextlib.ExitStack())): - # Path should already be sanitized - sane_path = sanitize_path(path).strip("/") - attributes = sane_path.split("/") if sane_path else [] - - folder = cls._get_collection_root_folder() - # Create the root collection - cls._makedirs_synced(folder) - try: - filesystem_path = path_to_filesystem(folder, sane_path) - except ValueError as e: - # Path is unsafe - cls.logger.debug("Unsafe path %r requested from storage: %s", - sane_path, e, exc_info=True) - return - - # Check if the path exists and if it leads to a collection or an item - if not os.path.isdir(filesystem_path): - if attributes and os.path.isfile(filesystem_path): - href = attributes.pop() - else: - return - else: - href = None - - sane_path = "/".join(attributes) - collection = cls(sane_path) - - 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): - yield collection.get(href) - - for href in scandir(filesystem_path, only_dirs=True): - if not is_safe_filesystem_path_component(href): - if not href.startswith(".Radicale"): - cls.logger.debug("Skipping collection %r in %r", href, - sane_path) - continue - child_path = posixpath.join(sane_path, href) - with child_context_manager(child_path): - yield cls(child_path) - - @classmethod - def verify(cls): - item_errors = collection_errors = 0 - - @contextlib.contextmanager - def exception_cm(path, href=None): - nonlocal item_errors, collection_errors - try: - yield - except Exception as e: - if href: - item_errors += 1 - name = "item %r in %r" % (href, path.strip("/")) - else: - collection_errors += 1 - name = "collection %r" % path.strip("/") - cls.logger.error("Invalid %s: %s", name, e, exc_info=True) - - remaining_paths = [""] - while remaining_paths: - path = remaining_paths.pop(0) - cls.logger.debug("Verifying collection %r", path) - with exception_cm(path): - saved_item_errors = item_errors - collection = None - for item in cls.discover(path, "1", exception_cm): - if not collection: - collection = item - collection.get_meta() - continue - if isinstance(item, BaseCollection): - remaining_paths.append(item.path) - else: - cls.logger.debug("Verified item %r in %r", - item.href, path) - if item_errors == saved_item_errors: - collection.sync() - return item_errors == 0 and collection_errors == 0 - - @classmethod - def create_collection(cls, href, collection=None, props=None): - folder = cls._get_collection_root_folder() - - # Path should already be sanitized - sane_path = sanitize_path(href).strip("/") - filesystem_path = path_to_filesystem(folder, sane_path) - - if not props: - cls._makedirs_synced(filesystem_path) - return cls(sane_path) - - parent_dir = os.path.dirname(filesystem_path) - cls._makedirs_synced(parent_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) - self = cls(sane_path, filesystem_path=tmp_filesystem_path) - self.set_meta_all(props) - - if collection: - if props.get("tag") == "VCALENDAR": - collection, = collection - items = [] - for content in ("vevent", "vtodo", "vjournal"): - items.extend( - getattr(collection, "%s_list" % content, [])) - items_by_uid = groupby(sorted(items, key=get_uid), get_uid) - vobject_items = {} - for uid, items in items_by_uid: - new_collection = vobject.iCalendar() - for item in items: - new_collection.add(item) - # href must comply to is_safe_filesystem_path_component - # and no file name collisions must exist between hrefs - href = self._find_available_file_name( - vobject_items.get, suffix=".ics") - vobject_items[href] = new_collection - self._upload_all_nonatomic(vobject_items) - elif props.get("tag") == "VADDRESSBOOK": - vobject_items = {} - for card in collection: - # href must comply to is_safe_filesystem_path_component - # and no file name collisions must exist between hrefs - href = self._find_available_file_name( - vobject_items.get, suffix=".vcf") - vobject_items[href] = card - self._upload_all_nonatomic(vobject_items) - - # This operation is not atomic on the filesystem level but it's - # very unlikely that one rename operations succeeds while the - # other fails or that only one gets written to disk. - if os.path.exists(filesystem_path): - os.rename(filesystem_path, os.path.join(tmp_dir, "delete")) - os.rename(tmp_filesystem_path, filesystem_path) - cls._sync_directory(parent_dir) - - return cls(sane_path) - - def upload_all_nonatomic(self, vobject_items): - """DEPRECATED: Use ``_upload_all_nonatomic``""" - return self._upload_all_nonatomic(vobject_items) - - def _upload_all_nonatomic(self, vobject_items): - """Upload a new set of items. - - This takes a mapping of href and vobject items and - uploads them nonatomic and without existence checks. - - """ - cache_folder = os.path.join(self._filesystem_path, - ".Radicale.cache", "item") - self._makedirs_synced(cache_folder) - for href, vobject_item in vobject_items.items(): - if not is_safe_filesystem_path_component(href): - raise UnsafePathError(href) - try: - cache_content = self._item_cache_content(href, vobject_item) - _, _, _, text, _, _, _, _ = cache_content - except Exception as e: - raise ValueError( - "Failed to store item %r in temporary collection %r: %s" % - (href, self.path, e)) from e - with self._atomic_write(os.path.join(cache_folder, href), "wb", - sync_directory=False) as f: - pickle.dump(cache_content, f) - path = path_to_filesystem(self._filesystem_path, href) - with self._atomic_write( - path, newline="", sync_directory=False) as f: - f.write(text) - self._sync_directory(cache_folder) - self._sync_directory(self._filesystem_path) - - @classmethod - def move(cls, item, to_collection, to_href): - if not is_safe_filesystem_path_component(to_href): - raise UnsafePathError(to_href) - os.replace( - path_to_filesystem(item.collection._filesystem_path, item.href), - 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) - # Move the item cache entry - cache_folder = os.path.join(item.collection._filesystem_path, - ".Radicale.cache", "item") - to_cache_folder = os.path.join(to_collection._filesystem_path, - ".Radicale.cache", "item") - cls._makedirs_synced(to_cache_folder) - try: - os.replace(os.path.join(cache_folder, item.href), - os.path.join(to_cache_folder, to_href)) - except FileNotFoundError: - pass - else: - cls._makedirs_synced(to_cache_folder) - if cache_folder != to_cache_folder: - cls._makedirs_synced(cache_folder) - # Track the change - to_collection._update_history_etag(to_href, item) - item.collection._update_history_etag(item.href, None) - to_collection._clean_history_cache() - if item.collection._filesystem_path != to_collection._filesystem_path: - item.collection._clean_history_cache() - - @classmethod - def _clean_cache(cls, folder, names, max_age=None): - """Delete all ``names`` in ``folder`` that are older than ``max_age``. - """ - age_limit = time.time() - max_age if max_age is not None else None - modified = False - for name in names: - if not is_safe_filesystem_path_component(name): - continue - if age_limit is not None: - try: - # Race: Another process might have deleted the file. - mtime = os.path.getmtime(os.path.join(folder, name)) - except FileNotFoundError: - continue - if mtime > age_limit: - continue - cls.logger.debug("Found expired item in cache: %r", name) - # Race: Another process might have deleted or locked the - # file. - try: - os.remove(os.path.join(folder, name)) - except (FileNotFoundError, PermissionError): - continue - modified = True - if modified: - cls._sync_directory(folder) - - def _update_history_etag(self, href, item): - """Updates and retrieves the history etag from the history cache. - - The history cache contains a file for each current and deleted item - of the collection. These files contain the etag of the item (empty - string for deleted items) and a history etag, which is a hash over - the previous history etag and the etag separated by "/". - """ - history_folder = os.path.join(self._filesystem_path, - ".Radicale.cache", "history") - try: - with open(os.path.join(history_folder, href), "rb") as f: - cache_etag, history_etag = pickle.load(f) - except (FileNotFoundError, pickle.UnpicklingError, ValueError) as e: - if isinstance(e, (pickle.UnpicklingError, ValueError)): - self.logger.warning( - "Failed to load history cache entry %r in %r: %s", - href, self.path, e, exc_info=True) - cache_etag = "" - # Initialize with random data to prevent collisions with cleaned - # expired items. - history_etag = binascii.hexlify(os.urandom(16)).decode("ascii") - etag = item.etag if item else "" - if etag != cache_etag: - self._makedirs_synced(history_folder) - history_etag = get_etag(history_etag + "/" + etag).strip("\"") - try: - # Race: Other processes might have created and locked the file. - with self._atomic_write(os.path.join(history_folder, href), - "wb") as f: - pickle.dump([etag, history_etag], f) - except PermissionError: - pass - return history_etag - - def _get_deleted_history_hrefs(self): - """Returns the hrefs of all deleted items that are still in the - history cache.""" - history_folder = os.path.join(self._filesystem_path, - ".Radicale.cache", "history") - try: - for href in scandir(history_folder): - if not is_safe_filesystem_path_component(href): - continue - if os.path.isfile(os.path.join(self._filesystem_path, href)): - continue - yield href - except FileNotFoundError: - pass - - def _clean_history_cache(self): - # Delete all expired cache entries of deleted items. - history_folder = os.path.join(self._filesystem_path, - ".Radicale.cache", "history") - self._clean_cache(history_folder, self._get_deleted_history_hrefs(), - max_age=self.configuration.getint( - "storage", "max_sync_token_age")) - - def sync(self, old_token=None): - # The sync token has the form http://radicale.org/ns/sync/TOKEN_NAME - # where TOKEN_NAME is the md5 hash of all history etags of present and - # past items of the collection. - def check_token_name(token_name): - if len(token_name) != 32: - return False - for c in token_name: - if c not in "0123456789abcdef": - return False - return True - - old_token_name = None - if old_token: - # Extract the token name from the sync token - if not old_token.startswith("http://radicale.org/ns/sync/"): - raise ValueError("Malformed token: %r" % old_token) - old_token_name = old_token[len("http://radicale.org/ns/sync/"):] - if not check_token_name(old_token_name): - raise ValueError("Malformed token: %r" % old_token) - # Get the current state and sync-token of the collection. - state = {} - token_name_hash = md5() - # Find the history of all existing and deleted items - for href, item in chain( - ((item.href, item) for item in self.get_all()), - ((href, None) for href in self._get_deleted_history_hrefs())): - history_etag = self._update_history_etag(href, item) - state[href] = history_etag - token_name_hash.update((href + "/" + history_etag).encode("utf-8")) - token_name = token_name_hash.hexdigest() - token = "http://radicale.org/ns/sync/%s" % token_name - if token_name == old_token_name: - # Nothing changed - return token, () - token_folder = os.path.join(self._filesystem_path, - ".Radicale.cache", "sync-token") - token_path = os.path.join(token_folder, token_name) - old_state = {} - if old_token_name: - # load the old token state - old_token_path = os.path.join(token_folder, old_token_name) - try: - # Race: Another process might have deleted the file. - with open(old_token_path, "rb") as f: - old_state = pickle.load(f) - except (FileNotFoundError, pickle.UnpicklingError, - ValueError) as e: - if isinstance(e, (pickle.UnpicklingError, ValueError)): - self.logger.warning( - "Failed to load stored sync token %r in %r: %s", - old_token_name, self.path, e, exc_info=True) - # Delete the damaged file - try: - os.remove(old_token_path) - except (FileNotFoundError, PermissionError): - pass - raise ValueError("Token not found: %r" % old_token) - # write the new token state or update the modification time of - # existing token state - if not os.path.exists(token_path): - self._makedirs_synced(token_folder) - try: - # Race: Other processes might have created and locked the file. - with self._atomic_write(token_path, "wb") as f: - pickle.dump(state, f) - except PermissionError: - pass - else: - # clean up old sync tokens and item cache - self._clean_cache(token_folder, os.listdir(token_folder), - max_age=self.configuration.getint( - "storage", "max_sync_token_age")) - self._clean_history_cache() - else: - # Try to update the modification time - try: - # Race: Another process might have deleted the file. - os.utime(token_path) - except FileNotFoundError: - pass - changes = [] - # Find all new, changed and deleted (that are still in the item cache) - # items - for href, history_etag in state.items(): - if history_etag != old_state.get(href): - changes.append(href) - # Find all deleted items that are no longer in the item cache - for href, history_etag in old_state.items(): - if href not in state: - changes.append(href) - return token, changes - - def list(self): - for href in scandir(self._filesystem_path, only_files=True): - if not is_safe_filesystem_path_component(href): - if not href.startswith(".Radicale"): - self.logger.debug( - "Skipping item %r in %r", href, self.path) - continue - yield href - - def get(self, href, verify_href=True): - item, metadata = self._get_with_metadata(href, verify_href=verify_href) - return item - - def _item_cache_hash(self, raw_text): - _hash = md5() - _hash.update(left_encode_int(ITEM_CACHE_VERSION)) - _hash.update(raw_text) - return _hash.hexdigest() - - def _item_cache_content(self, href, vobject_item, cache_hash=None): - text = vobject_item.serialize() - if cache_hash is None: - cache_hash = self._item_cache_hash(text.encode(self._encoding)) - etag = get_etag(text) - uid = get_uid_from_object(vobject_item) - name = vobject_item.name - tag, start, end = xmlutils.find_tag_and_time_range(vobject_item) - return cache_hash, uid, etag, text, name, tag, start, end - - def _store_item_cache(self, href, vobject_item, cache_hash=None): - cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", - "item") - content = self._item_cache_content(href, vobject_item, cache_hash) - self._makedirs_synced(cache_folder) - try: - # Race: Other processes might have created and locked the - # file. - with self._atomic_write(os.path.join(cache_folder, href), - "wb") as f: - pickle.dump(content, f) - except PermissionError: - pass - return content - - _cache_locks = {} - _cache_locks_lock = threading.Lock() - - @contextmanager - def _acquire_cache_lock(self, ns=""): - with contextlib.ExitStack() as lock_stack: - with contextlib.ExitStack() as locks_lock_stack: - locks_lock_stack.enter_context(self._cache_locks_lock) - lock_id = ns + "/" + self.path - lock = self._cache_locks.get(lock_id) - if not lock: - cache_folder = os.path.join(self._filesystem_path, - ".Radicale.cache") - self._makedirs_synced(cache_folder) - lock_path = None - if self.configuration.getboolean( - "storage", "filesystem_locking"): - lock_path = os.path.join( - cache_folder, - ".Radicale.lock" + (".%s" % ns if ns else "")) - lock = FileBackedRwLock(lock_path) - self._cache_locks[lock_id] = lock - lock_stack.enter_context(lock.acquire_lock( - "w", lambda: locks_lock_stack.pop_all().close())) - try: - yield - finally: - with self._cache_locks_lock: - lock_stack.pop_all().close() - if not lock.in_use(): - del self._cache_locks[lock_id] - - def _load_item_cache(self, href, input_hash): - cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", - "item") - cache_hash = uid = etag = text = name = tag = start = end = None - try: - with open(os.path.join(cache_folder, href), "rb") as f: - cache_hash, *content = pickle.load(f) - if cache_hash == input_hash: - uid, etag, text, name, tag, start, end = content - except FileNotFoundError as e: - pass - except (pickle.UnpicklingError, ValueError) as e: - self.logger.warning( - "Failed to load item cache entry %r in %r: %s", - href, self.path, e, exc_info=True) - return cache_hash, uid, etag, text, name, tag, start, end - - def _clean_item_cache(self): - cache_folder = os.path.join(self._filesystem_path, ".Radicale.cache", - "item") - self._clean_cache(cache_folder, ( - href for href in scandir(cache_folder) if not - os.path.isfile(os.path.join(self._filesystem_path, href)))) - - def _get_with_metadata(self, href, verify_href=True): - """Like ``get`` but additonally returns the following metadata: - tag, start, end: see ``xmlutils.find_tag_and_time_range``. If - extraction of the metadata failed, the values are all ``None``.""" - if verify_href: - try: - if not is_safe_filesystem_path_component(href): - raise UnsafePathError(href) - path = path_to_filesystem(self._filesystem_path, href) - except ValueError as e: - self.logger.debug( - "Can't translate name %r safely to filesystem in %r: %s", - href, self.path, e, exc_info=True) - return None, None - else: - path = os.path.join(self._filesystem_path, href) - try: - with open(path, "rb") as f: - raw_text = f.read() - except (FileNotFoundError, IsADirectoryError): - return None, None - except PermissionError: - # Windows raises ``PermissionError`` when ``path`` is a directory - if (os.name == "nt" and - os.path.isdir(path) and os.access(path, os.R_OK)): - return None, None - raise - # The hash of the component in the file system. This is used to check, - # if the entry in the cache is still valid. - input_hash = self._item_cache_hash(raw_text) - cache_hash, uid, etag, text, name, tag, start, end = \ - self._load_item_cache(href, input_hash) - vobject_item = None - if input_hash != cache_hash: - with contextlib.ExitStack() as lock_stack: - # Lock the item cache to prevent multpile processes from - # generating the same data in parallel. - # This improves the performance for multiple requests. - if self._lock.locked() == "r": - lock_stack.enter_context(self._acquire_cache_lock("item")) - # Check if another process created the file in the meantime - cache_hash, uid, etag, text, name, tag, start, end = \ - self._load_item_cache(href, input_hash) - if input_hash != cache_hash: - try: - vobject_items = tuple(vobject.readComponents( - raw_text.decode(self._encoding))) - if len(vobject_items) != 1: - raise RuntimeError("Content contains %d components" - % len(vobject_items)) - vobject_item = vobject_items[0] - check_and_sanitize_item(vobject_item, uid=uid, - tag=self.get_meta("tag")) - cache_hash, uid, etag, text, name, tag, start, end = \ - self._store_item_cache( - href, vobject_item, input_hash) - except Exception as e: - raise RuntimeError("Failed to load item %r in %r: %s" % - (href, self.path, e)) from e - # Clean cache entries once after the data in the file - # system was edited externally. - if not self._item_cache_cleaned: - self._item_cache_cleaned = True - self._clean_item_cache() - last_modified = time.strftime( - "%a, %d %b %Y %H:%M:%S GMT", - time.gmtime(os.path.getmtime(path))) - return Item( - self, href=href, last_modified=last_modified, etag=etag, - text=text, item=vobject_item, uid=uid, name=name, - component_name=tag), (tag, start, end) - - def get_multi2(self, hrefs): - # It's faster to check for file name collissions here, because - # we only need to call os.listdir once. - files = None - for href in hrefs: - if files is None: - # List dir after hrefs returned one item, the iterator may be - # empty and the for-loop is never executed. - files = os.listdir(self._filesystem_path) - path = os.path.join(self._filesystem_path, href) - if (not is_safe_filesystem_path_component(href) or - href not in files and os.path.lexists(path)): - self.logger.debug( - "Can't translate name safely to filesystem: %r", href) - yield (href, None) - else: - yield (href, self.get(href, verify_href=False)) - - 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()) - - def get_all_filtered(self, filters): - tag, start, end, simple = xmlutils.simplify_prefilters( - filters, collection_tag=self.get_meta("tag")) - if not tag: - # no filter - yield from ((item, simple) for item in self.get_all()) - return - for item, (itag, istart, iend) in ( - self._get_with_metadata(href, verify_href=False) - for href in self.list()): - if tag == itag and istart < end and iend > start: - yield item, simple and (start <= istart or iend <= end) - - def upload(self, href, vobject_item): - if not is_safe_filesystem_path_component(href): - raise UnsafePathError(href) - try: - cache_hash, uid, etag, text, name, tag, _, _ = \ - self._store_item_cache(href, vobject_item) - except Exception as e: - raise ValueError("Failed to store item %r in collection %r: %s" % - (href, self.path, e)) from e - path = path_to_filesystem(self._filesystem_path, href) - with self._atomic_write(path, newline="") as fd: - fd.write(text) - # Clean the cache after the actual item is stored, or the cache entry - # will be removed again. - self._clean_item_cache() - item = Item(self, href=href, etag=etag, text=text, item=vobject_item, - uid=uid, name=name, component_name=tag) - # Track the change - self._update_history_etag(href, item) - self._clean_history_cache() - return item - - def delete(self, href=None): - if href is None: - # Delete the collection - parent_dir = os.path.dirname(self._filesystem_path) - try: - os.rmdir(self._filesystem_path) - except OSError: - with TemporaryDirectory( - prefix=".Radicale.tmp-", dir=parent_dir) as tmp: - os.rename(self._filesystem_path, os.path.join( - tmp, os.path.basename(self._filesystem_path))) - self._sync_directory(parent_dir) - else: - self._sync_directory(parent_dir) - else: - # Delete an item - if not is_safe_filesystem_path_component(href): - raise UnsafePathError(href) - path = path_to_filesystem(self._filesystem_path, href) - if not os.path.isfile(path): - raise ComponentNotFoundError(href) - os.remove(path) - self._sync_directory(os.path.dirname(path)) - # Track the change - self._update_history_etag(href, None) - self._clean_history_cache() - - def get_meta(self, key=None): - # reuse cached value if the storage is read-only - if self._lock.locked() == "w" or self._meta_cache is None: - try: - try: - with open(self._props_path, encoding=self._encoding) as f: - self._meta_cache = json.load(f) - except FileNotFoundError: - self._meta_cache = {} - check_and_sanitize_props(self._meta_cache) - except ValueError as e: - raise RuntimeError("Failed to load properties of collection " - "%r: %s" % (self.path, e)) from e - return self._meta_cache.get(key) if key else self._meta_cache - - def set_meta_all(self, props): - with self._atomic_write(self._props_path, "w") as f: - json.dump(props, f, sort_keys=True) - - @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())) - last = max(map(os.path.getmtime, relevant_files)) - return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last)) - - @property - def etag(self): - # reuse cached value if the storage is read-only - if self._lock.locked() == "w" or self._etag_cache is None: - self._etag_cache = super().etag - return self._etag_cache - - _lock = None - - @classmethod - @contextmanager - def acquire_lock(cls, mode, user=None): - folder = os.path.expanduser(cls.configuration.get( - "storage", "filesystem_folder")) - if not cls._lock: - cls._makedirs_synced(folder) - lock_path = None - if cls.configuration.getboolean("storage", "filesystem_locking"): - lock_path = os.path.join(folder, ".Radicale.lock") - close_lock_file = cls.configuration.getboolean( - "storage", "filesystem_close_lock_file") - cls._lock = FileBackedRwLock(lock_path, close_lock_file) - with cls._lock.acquire_lock(mode): - yield - # execute hook - hook = cls.configuration.get("storage", "hook") - if mode == "w" and hook: - cls.logger.debug("Running hook") - subprocess.check_call( - hook % {"user": shlex.quote(user or "Anonymous")}, - shell=True, cwd=folder) - - -class FileBackedRwLock: - """A readers-Writer lock that can additionally lock a file. - - All requests are processed in FIFO order. - - """ - - def __init__(self, path=None, close_lock_file=True): - """Initilize a lock. - - ``path`` the file that is used for locking (optional) - - ``close_lock_file`` close the lock file, when unlocked and no requests - are pending - - """ - self._path = path - self._close_lock_file = close_lock_file - - self._lock = threading.Lock() - self._waiters = [] - self._lock_file = None - self._lock_file_locked = False - self._readers = 0 - self._writer = False - - def locked(self): - if self._writer: - return "w" - if self._readers: - return "r" - return "" - - def in_use(self): - with self._lock: - return self._waiters or self._readers or self._writer - - @contextmanager - def acquire_lock(self, mode, sync_callback=None): - def condition(): - if mode == "r": - return not self._writer - else: - return not self._writer and self._readers == 0 - - # Use a primitive lock which only works within one process as a - # precondition for inter-process file-based locking - with self._lock: - if sync_callback: - sync_callback() - if self._waiters or not condition(): - # Use FIFO for access requests - waiter = threading.Condition(lock=self._lock) - self._waiters.append(waiter) - while True: - waiter.wait() - if condition(): - break - self._waiters.pop(0) - if mode == "r": - self._readers += 1 - # Notify additional potential readers - if self._waiters: - self._waiters[0].notify() - else: - self._writer = True - if self._path and not self._lock_file_locked: - if not self._lock_file: - self._lock_file = open(self._path, "w+") - if os.name == "nt": - handle = msvcrt.get_osfhandle(self._lock_file.fileno()) - flags = LOCKFILE_EXCLUSIVE_LOCK if mode == "w" else 0 - overlapped = Overlapped() - if not lock_file_ex(handle, flags, 0, 1, 0, overlapped): - raise RuntimeError("Locking the storage failed " - "(can be disabled in the config): " - "%s" % ctypes.FormatError()) - elif os.name == "posix": - _cmd = fcntl.LOCK_EX if mode == "w" else fcntl.LOCK_SH - try: - fcntl.flock(self._lock_file.fileno(), _cmd) - except OSError as e: - raise RuntimeError("Locking the storage failed " - "(can be disabled in the config): " - "%s" % e) from e - else: - raise RuntimeError("Locking the storage failed " - "(can be disabled in the config): " - "Unsupported operating system") - self._lock_file_locked = True - try: - yield - finally: - with self._lock: - if mode == "r": - self._readers -= 1 - else: - self._writer = False - if self._lock_file_locked and self._readers == 0: - if os.name == "nt": - handle = msvcrt.get_osfhandle(self._lock_file.fileno()) - overlapped = Overlapped() - if not unlock_file_ex(handle, 0, 1, 0, overlapped): - raise RuntimeError("Unlocking the storage failed: " - "%s" % ctypes.FormatError()) - elif os.name == "posix": - try: - fcntl.flock(self._lock_file.fileno(), - fcntl.LOCK_UN) - except OSError as e: - raise RuntimeError("Unlocking the storage failed: " - "%s" % e) from e - else: - raise RuntimeError("Unlocking the storage failed: " - "Unsupported operating system") - if self._close_lock_file and not self._waiters: - self._lock_file.close() - self._lock_file = None - self._lock_file_locked = False - if self._waiters: - self._waiters[0].notify() diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py deleted file mode 100644 index bb8e586..0000000 --- a/radicale/tests/__init__.py +++ /dev/null @@ -1,66 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2012-2017 Guillaume Ayoub -# -# 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 . - -""" -Tests for Radicale. - -""" - -import logging -import os -import sys -from io import BytesIO - -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) - -logger = logging.getLogger("radicale_test") -if not logger.hasHandlers(): - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) - logger.addHandler(handler) -logger.setLevel(logging.DEBUG) - - -class BaseTest: - """Base class for tests.""" - logger = logger - - def request(self, method, path, data=None, **args): - """Send a request.""" - self.application._status = None - self.application._headers = None - self.application._answer = None - - for key in args: - args[key.upper()] = args[key] - args["REQUEST_METHOD"] = method.upper() - args["PATH_INFO"] = path - if data: - data = data.encode("utf-8") - args["wsgi.input"] = BytesIO(data) - args["CONTENT_LENGTH"] = str(len(data)) - self.application._answer = self.application(args, self.start_response) - - return ( - int(self.application._status.split()[0]), - dict(self.application._headers), - self.application._answer[0].decode("utf-8") - if self.application._answer else None) - - def start_response(self, status, headers): - """Put the response values into the current application.""" - self.application._status = status - self.application._headers = headers diff --git a/radicale/tests/custom/__init__.py b/radicale/tests/custom/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/radicale/tests/custom/auth.py b/radicale/tests/custom/auth.py deleted file mode 100644 index c61f637..0000000 --- a/radicale/tests/custom/auth.py +++ /dev/null @@ -1,31 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2008 Nicolas Kandel -# Copyright © 2008 Pascal Halter -# Copyright © 2008-2017 Guillaume Ayoub -# -# 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 . - -""" -Custom authentication. - -Just check username for testing - -""" - -from radicale import auth - - -class Auth(auth.BaseAuth): - def is_authenticated(self, user, password): - return user == "tmp" diff --git a/radicale/tests/custom/rights.py b/radicale/tests/custom/rights.py deleted file mode 100644 index 8fdda24..0000000 --- a/radicale/tests/custom/rights.py +++ /dev/null @@ -1,27 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright (C) 2017 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 . - -""" -Custom rights management. - -""" - -from radicale import rights - - -class Rights(rights.BaseRights): - def authorized(self, user, path, permission): - return path.strip("/") in ("tmp", "other") diff --git a/radicale/tests/custom/storage.py b/radicale/tests/custom/storage.py deleted file mode 100644 index 621fdc2..0000000 --- a/radicale/tests/custom/storage.py +++ /dev/null @@ -1,31 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2012-2017 Guillaume Ayoub -# -# 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 . - -""" -Custom storage backend. - -Copy of filesystem storage backend for testing - -""" - -from radicale import storage - - -# TODO: make something more in this collection (and test it) -class Collection(storage.Collection): - """Collection stored in a folder.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) diff --git a/radicale/tests/helpers.py b/radicale/tests/helpers.py deleted file mode 100644 index feb25ec..0000000 --- a/radicale/tests/helpers.py +++ /dev/null @@ -1,36 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2008 Nicolas Kandel -# Copyright © 2008 Pascal Halter -# Copyright © 2008-2017 Guillaume Ayoub -# -# 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 . - -""" -Radicale Helpers module. - -This module offers helpers to use in tests. - -""" - -import os - -EXAMPLES_FOLDER = os.path.join(os.path.dirname(__file__), "static") - - -def get_file_content(file_name): - try: - with open(os.path.join(EXAMPLES_FOLDER, file_name)) as fd: - return fd.read() - except IOError: - print("Couldn't open the file %s" % file_name) diff --git a/radicale/tests/static/allprop.xml b/radicale/tests/static/allprop.xml deleted file mode 100644 index 1b7692d..0000000 --- a/radicale/tests/static/allprop.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/radicale/tests/static/broken-vcard.vcf b/radicale/tests/static/broken-vcard.vcf deleted file mode 100644 index 140ddc2..0000000 --- a/radicale/tests/static/broken-vcard.vcf +++ /dev/null @@ -1,8 +0,0 @@ -BEGIN:VCARD -VERSION:3.0 -PRODID:-//Inverse inc.//SOGo Connector 1.0//EN -UID:C68582D2-2E60-0001-C2C0-000000000000.vcf -X-MOZILLA-HTML:FALSE -EMAIL;TYPE=work:test-misses-N-or-FN@example.com -X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf -END:VCARD diff --git a/radicale/tests/static/broken-vevent.ics b/radicale/tests/static/broken-vevent.ics deleted file mode 100644 index a6244ea..0000000 --- a/radicale/tests/static/broken-vevent.ics +++ /dev/null @@ -1,15 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Radicale//NONSGML Radicale Server//EN -VERSION:2.0 -BEGIN:VEVENT -CREATED:20160725T060147Z -LAST-MODIFIED:20160727T193435Z -DTSTAMP:20160727T193435Z -UID:040000008200E00074C5B7101A82E00800000000 -SUMMARY:Broken ICS END of VEVENT missing by accident -STATUS:CONFIRMED -X-MOZ-LASTACK:20160727T193435Z -DTSTART;TZID=Europe/Budapest:20160727T170000 -DTEND;TZID=Europe/Budapest:20160727T223000 -CLASS:PUBLIC -X-LIC-ERROR:No value for LOCATION property. Removing entire property: diff --git a/radicale/tests/static/contact1.vcf b/radicale/tests/static/contact1.vcf deleted file mode 100644 index 35472de..0000000 --- a/radicale/tests/static/contact1.vcf +++ /dev/null @@ -1,7 +0,0 @@ -BEGIN:VCARD -VERSION:3.0 -UID:contact1 -N:Contact;;;; -FN:Contact -NICKNAME:test -END:VCARD diff --git a/radicale/tests/static/contact_multiple.vcf b/radicale/tests/static/contact_multiple.vcf deleted file mode 100644 index e353e1c..0000000 --- a/radicale/tests/static/contact_multiple.vcf +++ /dev/null @@ -1,12 +0,0 @@ -BEGIN:VCARD -VERSION:3.0 -UID:contact1 -N:Contact1;;;; -FN:Contact1 -END:VCARD -BEGIN:VCARD -VERSION:3.0 -UID:contact2 -N:Contact2;;;; -FN:Contact2 -END:VCARD diff --git a/radicale/tests/static/event1-prime.ics b/radicale/tests/static/event1-prime.ics deleted file mode 100644 index 92ec904..0000000 --- a/radicale/tests/static/event1-prime.ics +++ /dev/null @@ -1,34 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -CREATED:20130902T150157Z -LAST-MODIFIED:20130902T150158Z -DTSTAMP:20130902T150158Z -UID:event1 -SUMMARY:Event -ORGANIZER:mailto:unclesam@example.com -ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com -ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com -DTSTART;TZID=Europe/Paris:20140901T180000 -DTEND;TZID=Europe/Paris:20140901T210000 -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event1.ics b/radicale/tests/static/event1.ics deleted file mode 100644 index bc04d80..0000000 --- a/radicale/tests/static/event1.ics +++ /dev/null @@ -1,34 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -CREATED:20130902T150157Z -LAST-MODIFIED:20130902T150158Z -DTSTAMP:20130902T150158Z -UID:event1 -SUMMARY:Event -ORGANIZER:mailto:unclesam@example.com -ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com -ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com -DTSTART;TZID=Europe/Paris:20130901T180000 -DTEND;TZID=Europe/Paris:20130901T190000 -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event2.ics b/radicale/tests/static/event2.ics deleted file mode 100644 index 8695944..0000000 --- a/radicale/tests/static/event2.ics +++ /dev/null @@ -1,42 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -CREATED:20130902T150157Z -LAST-MODIFIED:20130902T150158Z -DTSTAMP:20130902T150158Z -UID:event2 -SUMMARY:Event2 -DTSTART;TZID=Europe/Paris:20130902T180000 -DTEND;TZID=Europe/Paris:20130902T190000 -RRULE:FREQ=WEEKLY -SEQUENCE:1 -END:VEVENT -BEGIN:VEVENT -DTSTART;TZID=Europe/Paris:20130910T170000 -DTEND;TZID=Europe/Paris:20130910T180000 -DTSTAMP:20140902T150158Z -SUMMARY:Event2 -UID:event2 -RECURRENCE-ID;TZID=Europe/Paris:20130909T180000 -SEQUENCE:2 -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event3.ics b/radicale/tests/static/event3.ics deleted file mode 100644 index 18bbbe9..0000000 --- a/radicale/tests/static/event3.ics +++ /dev/null @@ -1,31 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -CREATED:20130902T150157Z -LAST-MODIFIED:20130902T150158Z -DTSTAMP:20130902T150158Z -UID:event3 -SUMMARY:Event3 -DTSTART;TZID=Europe/Paris:20130903 -DURATION:PT1H -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event4.ics b/radicale/tests/static/event4.ics deleted file mode 100644 index b4f3f82..0000000 --- a/radicale/tests/static/event4.ics +++ /dev/null @@ -1,30 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -CREATED:20130902T150157Z -LAST-MODIFIED:20130902T150158Z -DTSTAMP:20130902T150158Z -UID:event4 -SUMMARY:Event4 -DTSTART;TZID=Europe/Paris:20130904T180000 -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event5.ics b/radicale/tests/static/event5.ics deleted file mode 100644 index e87af37..0000000 --- a/radicale/tests/static/event5.ics +++ /dev/null @@ -1,30 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -CREATED:20130902T150157Z -LAST-MODIFIED:20130902T150158Z -DTSTAMP:20130902T150158Z -UID:event5 -SUMMARY:Event5 -DTSTART;TZID=Europe/Paris:20130905 -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event6.ics b/radicale/tests/static/event6.ics deleted file mode 100644 index 5d71f11..0000000 --- a/radicale/tests/static/event6.ics +++ /dev/null @@ -1,46 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -BEGIN:VTIMEZONE -TZID:Europe/Paris -BEGIN:STANDARD -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -TZNAME:CET -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -TZNAME:CEST -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -END:DAYLIGHT -END:VTIMEZONE -BEGIN:VEVENT -UID:event6 -DTSTART;TZID=Europe/Paris:20170601T080000 -DTEND;TZID=Europe/Paris:20170601T090000 -CREATED:20170601T060000Z -DTSTAMP:20170601T060000Z -LAST-MODIFIED:20170601T060000Z -RRULE:FREQ=DAILY;UNTIL=20170602T060000Z -SUMMARY:event6 -TRANSP:OPAQUE -X-MOZ-GENERATION:1 -END:VEVENT -BEGIN:VEVENT -UID:event6 -RECURRENCE-ID;TZID=Europe/Paris:20170602T080000 -DTSTART;TZID=Europe/Paris:20170701T080000 -DTEND;TZID=Europe/Paris:20170701T090000 -CREATED:20170601T060000Z -DTSTAMP:20170601T060000Z -LAST-MODIFIED:20170601T060000Z -SEQUENCE:1 -SUMMARY:event6 -TRANSP:OPAQUE -X-MOZ-GENERATION:1 -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event7.ics b/radicale/tests/static/event7.ics deleted file mode 100644 index 734ccbd..0000000 --- a/radicale/tests/static/event7.ics +++ /dev/null @@ -1,59 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -BEGIN:VTIMEZONE -TZID:Europe/Paris -BEGIN:STANDARD -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -TZNAME:CET -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -TZNAME:CEST -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -END:DAYLIGHT -END:VTIMEZONE -BEGIN:VEVENT -UID:event7 -DTSTART;TZID=Europe/Paris:20170701T080000 -DTEND;TZID=Europe/Paris:20170701T090000 -CREATED:20170601T060000Z -DTSTAMP:20170601T060000Z -LAST-MODIFIED:20170601T060000Z -RRULE:FREQ=DAILY -SUMMARY:event7 -TRANSP:OPAQUE -X-MOZ-GENERATION:1 -END:VEVENT -BEGIN:VEVENT -UID:event7 -RECURRENCE-ID;TZID=Europe/Paris:20170702T080000 -DTSTART;TZID=Europe/Paris:20170702T080000 -DTEND;TZID=Europe/Paris:20170702T090000 -CREATED:20170601T060000Z -DTSTAMP:20170601T060000Z -LAST-MODIFIED:20170601T060000Z -SEQUENCE:1 -SUMMARY:event7 -TRANSP:OPAQUE -X-MOZ-GENERATION:1 -END:VEVENT -BEGIN:VEVENT -UID:event7 -RECURRENCE-ID;TZID=Europe/Paris:20170703T080000 -DTSTART;TZID=Europe/Paris:20170601T080000 -DTEND;TZID=Europe/Paris:20170601T090000 -CREATED:20170601T060000Z -DTSTAMP:20170601T060000Z -LAST-MODIFIED:20170601T060000Z -SEQUENCE:1 -SUMMARY:event7 -TRANSP:OPAQUE -X-MOZ-GENERATION:1 -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event8.ics b/radicale/tests/static/event8.ics deleted file mode 100644 index 39136e1..0000000 --- a/radicale/tests/static/event8.ics +++ /dev/null @@ -1,33 +0,0 @@ -BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -BEGIN:VTIMEZONE -TZID:Europe/Paris -BEGIN:STANDARD -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -TZNAME:CET -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -TZNAME:CEST -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -END:DAYLIGHT -END:VTIMEZONE -BEGIN:VEVENT -UID:event8 -DTSTART;TZID=Europe/Paris:20170601T080000 -DTEND;TZID=Europe/Paris:20170601T090000 -CREATED:20170601T060000Z -DTSTAMP:20170601T060000Z -LAST-MODIFIED:20170601T060000Z -RDATE;TZID=Europe/Paris:20170701T080000 -SUMMARY:event8 -TRANSP:OPAQUE -X-MOZ-GENERATION:1 -END:VEVENT -END:VCALENDAR diff --git a/radicale/tests/static/event_multiple.ics b/radicale/tests/static/event_multiple.ics deleted file mode 100644 index c6527c8..0000000 --- a/radicale/tests/static/event_multiple.ics +++ /dev/null @@ -1,34 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VEVENT -UID:event -SUMMARY:Event -DTSTART;TZID=Europe/Paris:20130901T190000 -DTEND;TZID=Europe/Paris:20130901T200000 -END:VEVENT -BEGIN:VTODO -UID:todo -DTSTART;TZID=Europe/Paris:20130901T220000 -DURATION:PT1H -SUMMARY:Todo -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/static/journal1.ics b/radicale/tests/static/journal1.ics deleted file mode 100644 index ab45833..0000000 --- a/radicale/tests/static/journal1.ics +++ /dev/null @@ -1,30 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700101T000000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19700101T000000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE - -BEGIN:VJOURNAL -UID:journal1 -DTSTAMP;TZID=Europe/Paris:19940817T000000 -SUMMARY:happy new year -DESCRIPTION: Happy new year 2000 ! -END:VJOURNAL - -END:VCALENDAR diff --git a/radicale/tests/static/journal2.ics b/radicale/tests/static/journal2.ics deleted file mode 100644 index 27cb7cc..0000000 --- a/radicale/tests/static/journal2.ics +++ /dev/null @@ -1,32 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE - -BEGIN:VJOURNAL -UID:journal2 -DTSTAMP:19950817T000000 -DTSTART;TZID=Europe/Paris:20000101T100000 -SUMMARY:happy new year -DESCRIPTION: Happy new year ! -RRULE:FREQ=YEARLY -END:VJOURNAL - -END:VCALENDAR diff --git a/radicale/tests/static/journal3.ics b/radicale/tests/static/journal3.ics deleted file mode 100644 index a319d3a..0000000 --- a/radicale/tests/static/journal3.ics +++ /dev/null @@ -1,31 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE - -BEGIN:VJOURNAL -UID:journal2 -DTSTAMP:19950817T000000 -DTSTART;VALUE=DATE:20000101 -SUMMARY:happy new year -DESCRIPTION: Happy new year 2001 ! -END:VJOURNAL - -END:VCALENDAR diff --git a/radicale/tests/static/journal4.ics b/radicale/tests/static/journal4.ics deleted file mode 100644 index 5a1eeca..0000000 --- a/radicale/tests/static/journal4.ics +++ /dev/null @@ -1,23 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE - -END:VCALENDAR diff --git a/radicale/tests/static/journal5.ics b/radicale/tests/static/journal5.ics deleted file mode 100644 index 5a1eeca..0000000 --- a/radicale/tests/static/journal5.ics +++ /dev/null @@ -1,23 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE - -END:VCALENDAR diff --git a/radicale/tests/static/propfind1.xml b/radicale/tests/static/propfind1.xml deleted file mode 100644 index 1535f7f..0000000 --- a/radicale/tests/static/propfind1.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/radicale/tests/static/propname.xml b/radicale/tests/static/propname.xml deleted file mode 100644 index 2f56bc0..0000000 --- a/radicale/tests/static/propname.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/radicale/tests/static/proppatch1.xml b/radicale/tests/static/proppatch1.xml deleted file mode 100644 index c549dec..0000000 --- a/radicale/tests/static/proppatch1.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - #BADA55 - - - \ No newline at end of file diff --git a/radicale/tests/static/todo1.ics b/radicale/tests/static/todo1.ics deleted file mode 100644 index 0ffdede..0000000 --- a/radicale/tests/static/todo1.ics +++ /dev/null @@ -1,28 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VTODO -DTSTART;TZID=Europe/Paris:20130901T220000 -DURATION:PT1H -SUMMARY:Todo -UID:todo -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/static/todo2.ics b/radicale/tests/static/todo2.ics deleted file mode 100644 index 32274f7..0000000 --- a/radicale/tests/static/todo2.ics +++ /dev/null @@ -1,28 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VTODO -DTSTART;TZID=Europe/Paris:20130901T180000 -DUE;TZID=Europe/Paris:20130903T180000 -RRULE:FREQ=MONTHLY -UID:todo2 -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/static/todo3.ics b/radicale/tests/static/todo3.ics deleted file mode 100644 index f9252fd..0000000 --- a/radicale/tests/static/todo3.ics +++ /dev/null @@ -1,26 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VTODO -DTSTART;TZID=Europe/Paris:20130901T180000 -UID:todo3 -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/static/todo4.ics b/radicale/tests/static/todo4.ics deleted file mode 100644 index 1c651dc..0000000 --- a/radicale/tests/static/todo4.ics +++ /dev/null @@ -1,26 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VTODO -DUE;TZID=Europe/Paris:20130901T180000 -UID:todo4 -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/static/todo5.ics b/radicale/tests/static/todo5.ics deleted file mode 100644 index 29c307f..0000000 --- a/radicale/tests/static/todo5.ics +++ /dev/null @@ -1,27 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VTODO -CREATED;TZID=Europe/Paris:20130903T180000 -COMPLETED;TZID=Europe/Paris:20130920T180000 -UID:todo5 -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/static/todo6.ics b/radicale/tests/static/todo6.ics deleted file mode 100644 index 805b4cf..0000000 --- a/radicale/tests/static/todo6.ics +++ /dev/null @@ -1,26 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VTODO -COMPLETED;TZID=Europe/Paris:20130920T180000 -UID:todo6 -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/static/todo7.ics b/radicale/tests/static/todo7.ics deleted file mode 100644 index f94b271..0000000 --- a/radicale/tests/static/todo7.ics +++ /dev/null @@ -1,26 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VTODO -CREATED;TZID=Europe/Paris:20130803T180000 -UID:todo7 -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/static/todo8.ics b/radicale/tests/static/todo8.ics deleted file mode 100644 index 27d4962..0000000 --- a/radicale/tests/static/todo8.ics +++ /dev/null @@ -1,25 +0,0 @@ -BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTIMEZONE -TZID:Europe/Paris -X-LIC-LOCATION:Europe/Paris -BEGIN:DAYLIGHT -TZOFFSETFROM:+0100 -TZOFFSETTO:+0200 -TZNAME:CEST -DTSTART:19700329T020000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 -END:DAYLIGHT -BEGIN:STANDARD -TZOFFSETFROM:+0200 -TZOFFSETTO:+0100 -TZNAME:CET -DTSTART:19701025T030000 -RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 -END:STANDARD -END:VTIMEZONE -BEGIN:VTODO -UID:todo8 -END:VTODO -END:VCALENDAR diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py deleted file mode 100644 index a70693b..0000000 --- a/radicale/tests/test_auth.py +++ /dev/null @@ -1,167 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2012-2016 Jean-Marc Martins -# Copyright © 2012-2017 Guillaume Ayoub -# -# 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 . - -""" -Radicale tests with simple requests and authentication. - -""" - -import base64 -import os -import shutil -import tempfile - -import pytest - -from radicale import Application, config - -from .test_base import BaseTest - - -class TestBaseAuthRequests(BaseTest): - """Tests basic requests with auth. - - We should setup auth for each type before creating the Application object. - - """ - def setup(self): - self.configuration = config.load() - self.colpath = tempfile.mkdtemp() - self.configuration["storage"]["filesystem_folder"] = self.colpath - # Disable syncing to disk for better performance - self.configuration["storage"]["filesystem_fsync"] = "False" - # Required on Windows, doesn't matter on Unix - self.configuration["storage"]["filesystem_close_lock_file"] = "True" - # Set incorrect authentication delay to a very low value - self.configuration["auth"]["delay"] = "0.002" - - def teardown(self): - shutil.rmtree(self.colpath) - - def _test_htpasswd(self, htpasswd_encryption, htpasswd_content, - test_matrix=None): - """Test htpasswd authentication with user "tmp" and password "bepo".""" - htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") - with open(htpasswd_file_path, "w") as f: - f.write(htpasswd_content) - self.configuration["auth"]["type"] = "htpasswd" - self.configuration["auth"]["htpasswd_filename"] = htpasswd_file_path - self.configuration["auth"]["htpasswd_encryption"] = htpasswd_encryption - self.application = Application(self.configuration, self.logger) - if test_matrix is None: - test_matrix = ( - ("tmp", "bepo", 207), ("tmp", "tmp", 401), ("tmp", "", 401), - ("unk", "unk", 401), ("unk", "", 401), ("", "", 401)) - for user, password, expected_status in test_matrix: - status, _, answer = self.request( - "PROPFIND", "/", - HTTP_AUTHORIZATION="Basic %s" % base64.b64encode( - ("%s:%s" % (user, password)).encode()).decode()) - assert status == expected_status - - def test_htpasswd_plain(self): - self._test_htpasswd("plain", "tmp:bepo") - - def test_htpasswd_plain_password_split(self): - self._test_htpasswd("plain", "tmp:be:po", ( - ("tmp", "be:po", 207), ("tmp", "bepo", 401))) - - def test_htpasswd_sha1(self): - self._test_htpasswd("sha1", "tmp:{SHA}UWRS3uSJJq2itZQEUyIH8rRajCM=") - - def test_htpasswd_ssha(self): - self._test_htpasswd("ssha", "tmp:{SSHA}qbD1diw9RJKi0DnW4qO8WX9SE18W") - - def test_htpasswd_md5(self): - try: - import passlib # noqa: F401 - except ImportError: - pytest.skip("passlib is not installed") - self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/") - - def test_htpasswd_crypt(self): - try: - import crypt # noqa: F401 - except ImportError: - pytest.skip("crypt is not installed") - self._test_htpasswd("crypt", "tmp:dxUqxoThMs04k") - - def test_htpasswd_bcrypt(self): - try: - from passlib.hash import bcrypt - from passlib.exc import MissingBackendError - except ImportError: - pytest.skip("passlib is not installed") - try: - bcrypt.encrypt("test-bcrypt-backend") - except MissingBackendError: - pytest.skip("bcrypt backend for passlib is not installed") - self._test_htpasswd( - "bcrypt", - "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq") - - def test_htpasswd_multi(self): - self._test_htpasswd("plain", "ign:ign\ntmp:bepo") - - @pytest.mark.skipif(os.name == "nt", reason="leading and trailing " - "whitespaces not allowed in file names") - def test_htpasswd_whitespace_preserved(self): - self._test_htpasswd("plain", " tmp : bepo ", - ((" tmp ", " bepo ", 207),)) - - def test_htpasswd_whitespace_not_trimmed(self): - self._test_htpasswd("plain", " tmp : bepo ", (("tmp", "bepo", 401),)) - - def test_htpasswd_comment(self): - self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n") - - def test_remote_user(self): - self.configuration["auth"]["type"] = "remote_user" - self.application = Application(self.configuration, self.logger) - status, _, answer = self.request( - "PROPFIND", "/", - """ - - - - - """, REMOTE_USER="test") - assert status == 207 - assert ">/test/<" in answer - - def test_http_x_remote_user(self): - self.configuration["auth"]["type"] = "http_x_remote_user" - self.application = Application(self.configuration, self.logger) - status, _, answer = self.request( - "PROPFIND", "/", - """ - - - - - """, HTTP_X_REMOTE_USER="test") - assert status == 207 - assert ">/test/<" in answer - - def test_custom(self): - """Custom authentication.""" - self.configuration["auth"]["type"] = "tests.custom.auth" - self.application = Application(self.configuration, self.logger) - status, _, answer = self.request( - "PROPFIND", "/tmp", HTTP_AUTHORIZATION="Basic %s" % - base64.b64encode(("tmp:").encode()).decode()) - assert status == 207 diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py deleted file mode 100644 index ab0c26d..0000000 --- a/radicale/tests/test_base.py +++ /dev/null @@ -1,1530 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2012-2017 Guillaume Ayoub -# -# 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 . - -""" -Radicale tests with simple requests. - -""" - -import base64 -import os -import posixpath -import shutil -import tempfile -import xml.etree.ElementTree as ET -from functools import partial - -import pytest - -from radicale import Application, config - -from . import BaseTest -from .helpers import get_file_content - - -class BaseRequestsMixIn: - """Tests with simple requests.""" - - def test_root(self): - """GET request at "/".""" - status, _, answer = self.request("GET", "/") - assert status == 302 - assert answer == "Redirected to .web" - - def test_script_name(self): - """GET request at "/" with SCRIPT_NAME.""" - status, _, answer = self.request("GET", "/", SCRIPT_NAME="/radicale") - assert status == 302 - assert answer == "Redirected to .web" - status, _, answer = self.request("GET", "", SCRIPT_NAME="/radicale") - assert status == 302 - assert answer == "Redirected to radicale/.web" - - def test_add_event(self): - """Add an event.""" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - event = get_file_content("event1.ics") - path = "/calendar.ics/event1.ics" - status, _, _ = self.request("PUT", path, event) - assert status == 201 - status, headers, answer = self.request("GET", path) - assert status == 200 - assert "ETag" in headers - assert headers["Content-Type"] == "text/calendar; charset=utf-8" - assert "VEVENT" in answer - assert "Event" in answer - assert "UID:event" in answer - - def test_add_event_without_uid(self): - """Add an event without UID.""" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - event = get_file_content("event1.ics").replace("UID:event1\n", "") - assert "\nUID:" not in event - path = "/calendar.ics/event.ics" - status, _, _ = self.request("PUT", path, event) - assert status == 201 - status, _, answer = self.request("GET", path) - assert status == 200 - uids = [] - for line in answer.split("\r\n"): - if line.startswith("UID:"): - uids.append(line[len("UID:"):]) - assert len(uids) == 1 and uids[0] - # Overwrite the event with an event without UID and check that the UID - # is still the same - status, _, _ = self.request("PUT", path, event) - assert status == 201 - status, _, answer = self.request("GET", path) - assert status == 200 - assert "\r\nUID:%s\r\n" % uids[0] in answer - - def test_add_todo(self): - """Add a todo.""" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - todo = get_file_content("todo1.ics") - path = "/calendar.ics/todo1.ics" - status, _, _ = self.request("PUT", path, todo) - assert status == 201 - status, headers, answer = self.request("GET", path) - assert status == 200 - assert "ETag" in headers - assert headers["Content-Type"] == "text/calendar; charset=utf-8" - assert "VTODO" in answer - assert "Todo" in answer - assert "UID:todo" in answer - - def _create_addressbook(self, path): - return self.request( - "MKCOL", path, """\ - - - - - - - - - - -""") - - def test_add_contact(self): - """Add a contact.""" - status, _, _ = self._create_addressbook("/contacts.vcf/") - assert status == 201 - contact = get_file_content("contact1.vcf") - path = "/contacts.vcf/contact.vcf" - status, _, _ = self.request("PUT", path, contact) - assert status == 201 - status, headers, answer = self.request("GET", path) - assert status == 200 - assert "ETag" in headers - assert headers["Content-Type"] == "text/vcard; charset=utf-8" - assert "VCARD" in answer - assert "UID:contact1" in answer - status, _, answer = self.request("GET", path) - assert status == 200 - assert "UID:contact1" in answer - - def test_add_contact_without_uid(self): - """Add a contact.""" - status, _, _ = self._create_addressbook("/contacts.vcf/") - assert status == 201 - contact = get_file_content("contact1.vcf").replace("UID:contact1\n", - "") - assert "\nUID" not in contact - path = "/contacts.vcf/contact.vcf" - status, _, _ = self.request("PUT", path, contact) - assert status == 201 - status, _, answer = self.request("GET", path) - assert status == 200 - uids = [] - for line in answer.split("\r\n"): - if line.startswith("UID:"): - uids.append(line[len("UID:"):]) - assert len(uids) == 1 and uids[0] - # Overwrite the contact with an contact without UID and check that the - # UID is still the same - status, _, _ = self.request("PUT", path, contact) - assert status == 201 - status, _, answer = self.request("GET", path) - assert status == 200 - assert "\r\nUID:%s\r\n" % uids[0] in answer - - def test_update(self): - """Update an event.""" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - event = get_file_content("event1.ics") - path = "/calendar.ics/event1.ics" - status, _, _ = self.request("PUT", path, event) - assert status == 201 - status, headers, answer = self.request("GET", path) - assert "ETag" in headers - assert status == 200 - assert "VEVENT" in answer - assert "Event" in answer - assert "UID:event" in answer - assert "DTSTART;TZID=Europe/Paris:20130901T180000" in answer - assert "DTEND;TZID=Europe/Paris:20130901T190000" in answer - - # Then we send another PUT request - event = get_file_content("event1-prime.ics") - status, _, _ = self.request("PUT", path, event) - assert status == 201 - status, _, answer = self.request("GET", "/calendar.ics/") - assert status == 200 - assert answer.count("BEGIN:VEVENT") == 1 - - status, headers, answer = self.request("GET", path) - assert status == 200 - assert "ETag" in headers - assert "VEVENT" in answer - assert "Event" in answer - assert "UID:event" in answer - assert "DTSTART;TZID=Europe/Paris:20130901T180000" not in answer - assert "DTEND;TZID=Europe/Paris:20130901T190000" not in answer - assert "DTSTART;TZID=Europe/Paris:20140901T180000" in answer - assert "DTEND;TZID=Europe/Paris:20140901T210000" in answer - - def test_put_whole_calendar(self): - """Create and overwrite a whole calendar.""" - status, _, _ = self.request( - "PUT", "/calendar.ics/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR") - assert status == 201 - event1 = get_file_content("event1.ics") - status, _, _ = self.request( - "PUT", "/calendar.ics/test_event.ics", event1) - assert status == 201 - # Overwrite - events = get_file_content("event_multiple.ics") - status, _, _ = self.request("PUT", "/calendar.ics/", events) - assert status == 201 - status, _, _ = self.request("GET", "/calendar.ics/test_event.ics") - assert status == 404 - status, _, answer = self.request("GET", "/calendar.ics/") - assert status == 200 - assert "\r\nUID:event\r\n" in answer and "\r\nUID:todo\r\n" in answer - assert "\r\nUID:event1\r\n" not in answer - - def test_put_whole_calendar_without_uids(self): - """Create a whole calendar without UID.""" - event = get_file_content("event_multiple.ics") - event = event.replace("UID:event\n", "").replace("UID:todo\n", "") - assert "\nUID:" not in event - status, _, _ = self.request("PUT", "/calendar.ics/", event) - assert status == 201 - status, _, answer = self.request("GET", "/calendar.ics") - assert status == 200 - uids = [] - for line in answer.split("\r\n"): - if line.startswith("UID:"): - uids.append(line[len("UID:"):]) - assert len(uids) == 2 - for i, uid1 in enumerate(uids): - assert uid1 - for uid2 in uids[i + 1:]: - assert uid1 != uid2 - - def test_put_whole_addressbook(self): - """Create and overwrite a whole addressbook.""" - contacts = get_file_content("contact_multiple.vcf") - status, _, _ = self.request("PUT", "/contacts.vcf/", contacts) - assert status == 201 - status, _, answer = self.request("GET", "/contacts.vcf/") - assert status == 200 - assert ("\r\nUID:contact1\r\n" in answer and - "\r\nUID:contact2\r\n" in answer) - - def test_put_whole_addressbook_without_uids(self): - """Create a whole addressbook without UID.""" - contacts = get_file_content("contact_multiple.vcf") - contacts = contacts.replace("UID:contact1\n", "").replace( - "UID:contact2\n", "") - assert "\nUID:" not in contacts - status, _, _ = self.request("PUT", "/contacts.vcf/", contacts) - assert status == 201 - status, _, answer = self.request("GET", "/contacts.vcf") - assert status == 200 - uids = [] - for line in answer.split("\r\n"): - if line.startswith("UID:"): - uids.append(line[len("UID:"):]) - assert len(uids) == 2 - for i, uid1 in enumerate(uids): - assert uid1 - for uid2 in uids[i + 1:]: - assert uid1 != uid2 - - def test_delete(self): - """Delete an event.""" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - event = get_file_content("event1.ics") - path = "/calendar.ics/event1.ics" - status, _, _ = self.request("PUT", path, event) - assert status == 201 - # Then we send a DELETE request - status, _, answer = self.request("DELETE", path) - assert status == 200 - assert "href>%s/calendar.ics///%s%s%s" in answer - status, _, answer = self.request( - "PROPFIND", "/calendar.ics/event.ics", propfind) - assert "" in answer - - def test_propfind_allprop(self): - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - event = get_file_content("event1.ics") - status, _, _ = self.request("PUT", "/calendar.ics/event.ics", event) - assert status == 201 - propfind = get_file_content("allprop.xml") - status, _, answer = self.request( - "PROPFIND", "/calendar.ics/", propfind) - assert "" in answer - status, _, answer = self.request( - "PROPFIND", "/calendar.ics/event.ics", propfind) - assert "" in answer - - def test_proppatch(self): - """Write a property and read it back.""" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - proppatch = get_file_content("proppatch1.xml") - status, _, answer = self.request( - "PROPPATCH", "/calendar.ics/", proppatch) - assert status == 207 - assert "calendar-color" in answer - assert "200 OK#BADA55" in answer - - def test_put_whole_calendar_multiple_events_with_same_uid(self): - """Add two events with the same UID.""" - status, _, _ = self.request( - "PUT", "/calendar.ics/", get_file_content("event2.ics")) - assert status == 201 - status, _, answer = self.request( - "REPORT", "/calendar.ics/", - """ - - - """) - assert status == 207 - assert answer.count("") == 1 - status, _, answer = self.request("GET", "/calendar.ics/") - assert status == 200 - assert answer.count("BEGIN:VEVENT") == 2 - - def _test_filter(self, filters, kind="event", test=None, items=(1,)): - filter_template = "{}" - if kind in ("event", "journal", "todo"): - create_collection_fn = partial(self.request, "MKCALENDAR") - path = "/calendar.ics/" - filename_template = "{}{}.ics" - namespace = "urn:ietf:params:xml:ns:caldav" - report = "calendar-query" - elif kind == "contact": - create_collection_fn = self._create_addressbook - if test: - filter_template = '{{}}'.format( - test) - path = "/contacts.vcf/" - filename_template = "{}{}.vcf" - namespace = "urn:ietf:params:xml:ns:carddav" - report = "addressbook-query" - else: - raise ValueError("Unsupported kind: %r" % kind) - status, _, _ = self.request("DELETE", path) - assert status in (200, 404) - status, _, _ = create_collection_fn(path) - assert status == 201 - for i in items: - filename = filename_template.format(kind, i) - event = get_file_content(filename) - status, _, _ = self.request( - "PUT", posixpath.join(path, filename), event) - assert status == 201 - filters_text = "".join( - filter_template.format(filter_) for filter_ in filters) - status, _, answer = self.request( - "REPORT", path, - """ - - - - - {2} - """.format(namespace, report, filters_text)) - assert status == 207 - return answer - - def test_addressbook_empty_filter(self): - self._test_filter([""], kind="contact") - - def test_addressbook_prop_filter(self): - assert "href>/contacts.vcf/contact1.vcf - es - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - es - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - a - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - test - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - tes - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - est - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - tes - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - est - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - est - """], "contact") - assert "href>/contacts.vcf/contact1.vcf - tes - """], "contact") - - def test_addressbook_prop_filter_any(self): - assert "href>/contacts.vcf/contact1.vcf - test - - - test - """], "contact", test="anyof") - assert "href>/contacts.vcf/contact1.vcf - a - - - test - """], "contact", test="anyof") - assert "href>/contacts.vcf/contact1.vcf - test - - - test - """], "contact") - - def test_addressbook_prop_filter_all(self): - assert "href>/contacts.vcf/contact1.vcf - tes - - - est - """], "contact", test="allof") - assert "href>/contacts.vcf/contact1.vcf - test - - - test - """], "contact", test="allof") - - def test_calendar_empty_filter(self): - self._test_filter([""]) - - def test_calendar_tag_filter(self): - """Report request with tag-based filter on calendar.""" - assert "href>/calendar.ics/event1.ics"""]) - - def test_item_tag_filter(self): - """Report request with tag-based filter on an item.""" - assert "href>/calendar.ics/event1.ics - - """]) - assert "href>/calendar.ics/event1.ics - - """]) - - def test_item_not_tag_filter(self): - """Report request with tag-based is-not filter on an item.""" - assert "href>/calendar.ics/event1.ics - - - - """]) - assert "href>/calendar.ics/event1.ics - - - - """]) - - def test_item_prop_filter(self): - """Report request with prop-based filter on an item.""" - assert "href>/calendar.ics/event1.ics - - - - """]) - assert "href>/calendar.ics/event1.ics - - - - """]) - - def test_item_not_prop_filter(self): - """Report request with prop-based is-not filter on an item.""" - assert "href>/calendar.ics/event1.ics - - - - - - """]) - assert "href>/calendar.ics/event1.ics - - - - - - """]) - - def test_mutiple_filters(self): - """Report request with multiple filters on an item.""" - assert "href>/calendar.ics/event1.ics - - - - - - """, """ - - - - - - - """]) - assert "href>/calendar.ics/event1.ics - - - - """, """ - - - - - - - """]) - assert "href>/calendar.ics/event1.ics - - - - - - - """]) - - def test_text_match_filter(self): - """Report request with text-match filter on calendar.""" - assert "href>/calendar.ics/event1.ics - - - event - - - """]) - assert "href>/calendar.ics/event1.ics - - - event - - - """]) - assert "href>/calendar.ics/event1.ics - - - unknown - - - """]) - assert "href>/calendar.ics/event1.ics - - - event - - - """]) - - def test_param_filter(self): - """Report request with param-filter on calendar.""" - assert "href>/calendar.ics/event1.ics - - - - ACCEPTED - - - - """]) - assert "href>/calendar.ics/event1.ics - - - - UNKNOWN - - - - """]) - assert "href>/calendar.ics/event1.ics - - - - - - - - """]) - assert "href>/calendar.ics/event1.ics - - - - - - - - """]) - - def test_time_range_filter_events(self): - """Report request with time-range filter on events.""" - answer = self._test_filter([""" - - - - - """], "event", items=range(1, 6)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics - - - - """], "event", items=range(1, 6)) - assert "href>/calendar.ics/event1.ics - - - - - - - - - """], items=range(1, 6)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics - - - - """], items=range(1, 6)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics - - - - """], items=range(1, 6)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics - - - - """], items=range(1, 6)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics - - - - """], items=range(1, 6)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics - - - - """], items=(6, 7, 8)) - assert "href>/calendar.ics/event6.ics/calendar.ics/event7.ics/calendar.ics/event8.ics - - - - """], items=(6, 7, 8)) - assert "href>/calendar.ics/event6.ics/calendar.ics/event7.ics/calendar.ics/event8.ics - - - - """], items=(6, 7, 8)) - assert "href>/calendar.ics/event6.ics/calendar.ics/event7.ics/calendar.ics/event8.ics - - - - """], "event", items=(1, 2)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics - - - - """], "event", items=(1, 2)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics - - - - """], "event", items=(1, 2)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics - - - - """], "event", items=(1, 2)) - assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics - - - - """], "todo", items=range(1, 9)) - assert "href>/calendar.ics/todo1.ics/calendar.ics/todo2.ics/calendar.ics/todo3.ics/calendar.ics/todo4.ics/calendar.ics/todo5.ics/calendar.ics/todo6.ics/calendar.ics/todo7.ics/calendar.ics/todo8.ics - - - - """], "todo", items=range(1, 9)) - assert "href>/calendar.ics/todo1.ics/calendar.ics/todo2.ics/calendar.ics/todo3.ics/calendar.ics/todo4.ics/calendar.ics/todo5.ics/calendar.ics/todo6.ics/calendar.ics/todo7.ics/calendar.ics/todo8.ics - - - - """], "todo", items=range(1, 9)) - assert "href>/calendar.ics/todo2.ics - - - - """], "todo", items=range(1, 9)) - assert "href>/calendar.ics/todo2.ics - - - - """], "todo", items=range(1, 9)) - assert "href>/calendar.ics/todo3.ics - - - - """], "todo", items=range(1, 9)) - assert "href>/calendar.ics/todo7.ics - - - - """], "todo", items=(1, 2)) - assert "href>/calendar.ics/todo1.ics/calendar.ics/todo2.ics - - - - """], "todo", items=(1, 2)) - assert "href>/calendar.ics/todo1.ics/calendar.ics/todo2.ics - - - - """], "todo", items=(1, 2)) - assert "href>/calendar.ics/todo1.ics/calendar.ics/todo2.ics - - - - """], "todo", items=(1, 2)) - assert "href>/calendar.ics/todo1.ics/calendar.ics/todo2.ics - - - - """], "journal", items=(1, 2, 3)) - assert "href>/calendar.ics/journal1.ics/calendar.ics/journal2.ics/calendar.ics/journal3.ics - - - - """], "journal", items=(1, 2, 3)) - assert "href>/calendar.ics/journal1.ics/calendar.ics/journal2.ics/calendar.ics/journal3.ics - - - - """], "journal", items=(1, 2, 3)) - assert "href>/calendar.ics/journal1.ics/calendar.ics/journal2.ics/calendar.ics/journal3.ics - - - - """], "journal", items=(1, 2, 3)) - assert "href>/calendar.ics/journal1.ics/calendar.ics/journal2.ics/calendar.ics/journal3.ics - - - - """], "journal", items=(1, 2, 3)) - assert "href>/calendar.ics/journal1.ics/calendar.ics/journal2.ics/calendar.ics/journal3.ics - - - - """], "journal", items=(1, 2)) - assert "href>/calendar.ics/journal1.ics/calendar.ics/journal2.ics - - - - """], "journal", items=(1, 2)) - assert "href>/calendar.ics/journal1.ics/calendar.ics/journal2.ics - - - - """], "journal", items=(1, 2)) - assert "href>/calendar.ics/journal1.ics/calendar.ics/journal2.ics - - - - - """) - assert status == 207 - assert "href>%s<" % event_path in answer - - def _report_sync_token(self, calendar_path, sync_token=None): - sync_token_xml = ( - "" % sync_token - if sync_token else "") - status, _, answer = self.request( - "REPORT", calendar_path, - """ - - - - - %s - """ % sync_token_xml) - if sync_token and status == 412: - return None, None - assert status == 207 - xml = ET.fromstring(answer) - sync_token = xml.find("{DAV:}sync-token").text.strip() - assert sync_token - return sync_token, xml - - def test_report_sync_collection_no_change(self): - """Test sync-collection report without modifying the collection""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - event = get_file_content("event1.ics") - event_path = posixpath.join(calendar_path, "event.ics") - status, _, _ = self.request("PUT", event_path, event) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - assert xml.find("{DAV:}response") is not None - new_sync_token, xml = self._report_sync_token(calendar_path, - sync_token) - assert sync_token == new_sync_token - assert xml.find("{DAV:}response") is None - - def test_report_sync_collection_add(self): - """Test sync-collection report with an added item""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - event = get_file_content("event1.ics") - event_path = posixpath.join(calendar_path, "event.ics") - status, _, _ = self.request("PUT", event_path, event) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path, sync_token) - if not sync_token: - pytest.skip("storage backend does not support sync-token") - assert xml.find("{DAV:}response") is not None - assert xml.find("{DAV:}response/{DAV:}status") is None - - def test_report_sync_collection_delete(self): - """Test sync-collection report with a deleted item""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - event = get_file_content("event1.ics") - event_path = posixpath.join(calendar_path, "event.ics") - status, _, _ = self.request("PUT", event_path, event) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - status, _, _ = self.request("DELETE", event_path) - assert status == 200 - sync_token, xml = self._report_sync_token(calendar_path, sync_token) - if not sync_token: - pytest.skip("storage backend does not support sync-token") - assert "404" in xml.find("{DAV:}response/{DAV:}status").text - - def test_report_sync_collection_create_delete(self): - """Test sync-collection report with a created and deleted item""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - event = get_file_content("event1.ics") - event_path = posixpath.join(calendar_path, "event.ics") - status, _, _ = self.request("PUT", event_path, event) - assert status == 201 - status, _, _ = self.request("DELETE", event_path) - assert status == 200 - sync_token, xml = self._report_sync_token(calendar_path, sync_token) - if not sync_token: - pytest.skip("storage backend does not support sync-token") - assert "404" in xml.find("{DAV:}response/{DAV:}status").text - - def test_report_sync_collection_modify_undo(self): - """Test sync-collection report with a modified and changed back item""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - event1 = get_file_content("event1.ics") - event2 = get_file_content("event2.ics") - event_path = posixpath.join(calendar_path, "event1.ics") - status, _, _ = self.request("PUT", event_path, event1) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - status, _, _ = self.request("PUT", event_path, event2) - assert status == 201 - status, _, _ = self.request("PUT", event_path, event1) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path, sync_token) - if not sync_token: - pytest.skip("storage backend does not support sync-token") - assert xml.find("{DAV:}response") is not None - assert xml.find("{DAV:}response/{DAV:}status") is None - - def test_report_sync_collection_move(self): - """Test sync-collection report a moved item""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - event = get_file_content("event1.ics") - event1_path = posixpath.join(calendar_path, "event1.ics") - event2_path = posixpath.join(calendar_path, "event2.ics") - status, _, _ = self.request("PUT", event1_path, event) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - status, _, _ = self.request( - "MOVE", event1_path, HTTP_DESTINATION=event2_path, HTTP_HOST="") - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path, sync_token) - if not sync_token: - pytest.skip("storage backend does not support sync-token") - for response in xml.findall("{DAV:}response"): - if response.find("{DAV:}status") is None: - assert response.find("{DAV:}href").text == event2_path - else: - assert "404" in response.find("{DAV:}status").text - assert response.find("{DAV:}href").text == event1_path - - def test_report_sync_collection_move_undo(self): - """Test sync-collection report with a moved and moved back item""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - event = get_file_content("event1.ics") - event1_path = posixpath.join(calendar_path, "event1.ics") - event2_path = posixpath.join(calendar_path, "event2.ics") - status, _, _ = self.request("PUT", event1_path, event) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - status, _, _ = self.request( - "MOVE", event1_path, HTTP_DESTINATION=event2_path, HTTP_HOST="") - assert status == 201 - status, _, _ = self.request( - "MOVE", event2_path, HTTP_DESTINATION=event1_path, HTTP_HOST="") - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path, sync_token) - if not sync_token: - pytest.skip("storage backend does not support sync-token") - created = deleted = 0 - for response in xml.findall("{DAV:}response"): - if response.find("{DAV:}status") is None: - assert response.find("{DAV:}href").text == event1_path - created += 1 - else: - assert "404" in response.find("{DAV:}status").text - assert response.find("{DAV:}href").text == event2_path - deleted += 1 - assert created == 1 and deleted == 1 - - def test_report_sync_collection_invalid_sync_token(self): - """Test sync-collection report with an invalid sync token""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - sync_token, xml = self._report_sync_token( - calendar_path, "http://radicale.org/ns/sync/INVALID") - assert not sync_token - - def test_propfind_sync_token(self): - """Retrieve the sync-token with a propfind request""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - event = get_file_content("event1.ics") - event_path = posixpath.join(calendar_path, "event.ics") - status, _, _ = self.request("PUT", event_path, event) - assert status == 201 - new_sync_token, xml = self._report_sync_token(calendar_path, - sync_token) - assert sync_token != new_sync_token - - def test_propfind_same_as_sync_collection_sync_token(self): - """Compare sync-token property with sync-collection sync-token""" - calendar_path = "/calendar.ics/" - status, _, _ = self.request("MKCALENDAR", calendar_path) - assert status == 201 - sync_token, xml = self._report_sync_token(calendar_path) - new_sync_token, xml = self._report_sync_token(calendar_path, - sync_token) - assert sync_token == new_sync_token - - def test_calendar_getcontenttype(self): - """Test report request on an item""" - status, _, _ = self.request("MKCALENDAR", "/test/") - assert status == 201 - for component in ("event", "todo", "journal"): - event = get_file_content("{}1.ics".format(component)) - status, _, _ = self.request("PUT", "/test/test.ics", event) - assert status == 201 - status, _, answer = self.request( - "REPORT", "/test/", - """ - - - - - """) - assert status == 207 - assert ">text/calendar;charset=utf-8;component=V{}<".format( - component.upper()) in answer - - def test_addressbook_getcontenttype(self): - """Test report request on an item""" - status, _, _ = self._create_addressbook("/test/") - assert status == 201 - contact = get_file_content("contact1.vcf") - status, _, _ = self.request("PUT", "/test/test.vcf", contact) - assert status == 201 - status, _, answer = self.request( - "REPORT", "/test/", - """ - - - - - """) - assert status == 207 - assert ">text/vcard;charset=utf-8<" in answer - - def test_authorization(self): - authorization = "Basic " + base64.b64encode(b"user:").decode() - status, _, answer = self.request( - "PROPFIND", "/", - """ - - - - - """, - HTTP_AUTHORIZATION=authorization) - assert status == 207 - assert "href>/user/<" in answer - - def test_authentication(self): - """Test if server sends authentication request.""" - self.configuration["auth"]["type"] = "htpasswd" - self.configuration["auth"]["htpasswd_filename"] = os.devnull - self.configuration["auth"]["htpasswd_encryption"] = "plain" - self.configuration["rights"]["type"] = "owner_only" - self.application = Application(self.configuration, self.logger) - status, headers, _ = self.request("MKCOL", "/user/") - assert status in (401, 403) - assert headers.get("WWW-Authenticate") - - def test_principal_collection_creation(self): - """Verify existence of the principal collection.""" - status, _, _ = self.request("PROPFIND", "/user/", HTTP_AUTHORIZATION=( - "Basic " + base64.b64encode(b"user:").decode())) - assert status == 207 - - def test_existence_of_root_collections(self): - """Verify that the root collection always exists.""" - # Use PROPFIND because GET returns message - status, _, _ = self.request("PROPFIND", "/") - assert status == 207 - # it should still exist after deletion - status, _, _ = self.request("DELETE", "/") - assert status == 200 - status, _, _ = self.request("PROPFIND", "/") - assert status == 207 - - def test_custom_headers(self): - if not self.configuration.has_section("headers"): - self.configuration.add_section("headers") - self.configuration.set("headers", "test", "123") - # Test if header is set on success - status, headers, _ = self.request("OPTIONS", "/") - assert status == 200 - assert headers.get("test") == "123" - # Test if header is set on failure - status, headers, _ = self.request( - "GET", "/.well-known/does not exist") - assert status == 404 - assert headers.get("test") == "123" - - def test_missing_uid(self): - """Verify that missing UIDs are added in a stable manner.""" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - event_without_uid = get_file_content("event1.ics").replace( - "UID:event1\n", "") - assert "UID" not in event_without_uid - path = "/calendar.ics/event1.ics" - status, _, _ = self.request("PUT", path, event_without_uid) - assert status == 201 - status, _, answer = self.request("GET", path) - assert status == 200 - uid = None - for line in answer.split("\r\n"): - if line.startswith("UID:"): - uid = line[len("UID:"):] - assert uid - status, _, _ = self.request("PUT", path, event_without_uid) - assert status == 201 - status, _, answer = self.request("GET", path) - assert status == 200 - assert "UID:%s\r\n" % uid in answer - - -class BaseFileSystemTest(BaseTest): - """Base class for filesystem backend tests.""" - storage_type = None - - def setup(self): - self.configuration = config.load() - self.configuration["storage"]["type"] = self.storage_type - self.colpath = tempfile.mkdtemp() - self.configuration["storage"]["filesystem_folder"] = self.colpath - # Disable syncing to disk for better performance - self.configuration["storage"]["filesystem_fsync"] = "False" - # Required on Windows, doesn't matter on Unix - self.configuration["storage"]["filesystem_close_lock_file"] = "True" - self.application = Application(self.configuration, self.logger) - - def teardown(self): - shutil.rmtree(self.colpath) - - -class TestMultiFileSystem(BaseFileSystemTest, BaseRequestsMixIn): - """Test BaseRequests on multifilesystem.""" - storage_type = "multifilesystem" - - def test_fsync(self): - """Create a directory and file with syncing enabled.""" - self.configuration["storage"]["filesystem_fsync"] = "True" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - - def test_hook(self): - """Run hook.""" - self.configuration["storage"]["hook"] = ( - "mkdir %s" % os.path.join("collection-root", "created_by_hook")) - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - status, _, _ = self.request("PROPFIND", "/created_by_hook/") - assert status == 207 - - def test_hook_read_access(self): - """Verify that hook is not run for read accesses.""" - self.configuration["storage"]["hook"] = ( - "mkdir %s" % os.path.join("collection-root", "created_by_hook")) - status, _, _ = self.request("PROPFIND", "/") - assert status == 207 - status, _, _ = self.request("PROPFIND", "/created_by_hook/") - assert status == 404 - - @pytest.mark.skipif(os.system("type flock") != 0, - reason="flock command not found") - def test_hook_storage_locked(self): - """Verify that the storage is locked when the hook runs.""" - self.configuration["storage"]["hook"] = ( - "flock -n .Radicale.lock || exit 0; exit 1") - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - - def test_hook_principal_collection_creation(self): - """Verify that the hooks runs when a new user is created.""" - self.configuration["storage"]["hook"] = ( - "mkdir %s" % os.path.join("collection-root", "created_by_hook")) - status, _, _ = self.request("PROPFIND", "/", HTTP_AUTHORIZATION=( - "Basic " + base64.b64encode(b"user:").decode())) - assert status == 207 - status, _, _ = self.request("PROPFIND", "/created_by_hook/") - assert status == 207 - - def test_hook_fail(self): - """Verify that a request fails if the hook fails.""" - self.configuration["storage"]["hook"] = "exit 1" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status != 201 - - def test_item_cache_rebuild(self): - """Delete the item cache and verify that it is rebuild.""" - status, _, _ = self.request("MKCALENDAR", "/calendar.ics/") - assert status == 201 - event = get_file_content("event1.ics") - path = "/calendar.ics/event1.ics" - status, _, _ = self.request("PUT", path, event) - assert status == 201 - status, _, answer1 = self.request("GET", path) - assert status == 200 - cache_folder = os.path.join(self.colpath, "collection-root", - "calendar.ics", ".Radicale.cache", "item") - assert os.path.exists(os.path.join(cache_folder, "event1.ics")) - shutil.rmtree(cache_folder) - status, _, answer2 = self.request("GET", path) - assert status == 200 - assert answer1 == answer2 - assert os.path.exists(os.path.join(cache_folder, "event1.ics")) - - -class TestCustomStorageSystem(BaseFileSystemTest): - """Test custom backend loading.""" - storage_type = "tests.custom.storage" - - def test_root(self): - """A simple test to verify that the custom backend works.""" - BaseRequestsMixIn.test_root(self) diff --git a/radicale/tests/test_rights.py b/radicale/tests/test_rights.py deleted file mode 100644 index 36c2833..0000000 --- a/radicale/tests/test_rights.py +++ /dev/null @@ -1,139 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright (C) 2017 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 . - -""" -Radicale tests with simple requests and rights. -""" - -import base64 -import os -import shutil -import tempfile - -from radicale import Application, config - -from .test_base import BaseTest - - -class TestBaseAuthRequests(BaseTest): - """Tests basic requests with rights.""" - - def setup(self): - self.configuration = config.load() - self.colpath = tempfile.mkdtemp() - self.configuration["storage"]["filesystem_folder"] = self.colpath - # Disable syncing to disk for better performance - self.configuration["storage"]["filesystem_fsync"] = "False" - # Required on Windows, doesn't matter on Unix - self.configuration["storage"]["filesystem_close_lock_file"] = "True" - - def teardown(self): - shutil.rmtree(self.colpath) - - def _test_rights(self, rights_type, user, path, mode, expected_status): - assert mode in ("r", "w") - assert user in ("", "tmp") - htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") - with open(htpasswd_file_path, "w") as f: - f.write("tmp:bepo\nother:bepo") - self.configuration["rights"]["type"] = rights_type - self.configuration["auth"]["type"] = "htpasswd" - self.configuration["auth"]["htpasswd_filename"] = htpasswd_file_path - self.configuration["auth"]["htpasswd_encryption"] = "plain" - self.application = Application(self.configuration, self.logger) - for u in ("tmp", "other"): - status, _, _ = self.request( - "PROPFIND", "/%s" % u, HTTP_AUTHORIZATION="Basic %s" % - base64.b64encode(("%s:bepo" % u).encode()).decode()) - assert status == 207 - status, _, _ = self.request( - "PROPFIND" if mode == "r" else "PROPPATCH", path, - HTTP_AUTHORIZATION="Basic %s" % base64.b64encode( - ("tmp:bepo").encode()).decode() if user else "") - assert status == expected_status - - def test_owner_only(self): - self._test_rights("owner_only", "", "/", "r", 401) - self._test_rights("owner_only", "", "/", "w", 401) - self._test_rights("owner_only", "", "/tmp", "r", 401) - self._test_rights("owner_only", "", "/tmp", "w", 401) - self._test_rights("owner_only", "tmp", "/", "r", 207) - self._test_rights("owner_only", "tmp", "/", "w", 403) - self._test_rights("owner_only", "tmp", "/tmp", "r", 207) - self._test_rights("owner_only", "tmp", "/tmp", "w", 207) - self._test_rights("owner_only", "tmp", "/other", "r", 403) - self._test_rights("owner_only", "tmp", "/other", "w", 403) - - def test_owner_write(self): - self._test_rights("owner_write", "", "/", "r", 401) - self._test_rights("owner_write", "", "/", "w", 401) - self._test_rights("owner_write", "", "/tmp", "r", 401) - self._test_rights("owner_write", "", "/tmp", "w", 401) - self._test_rights("owner_write", "tmp", "/", "r", 207) - self._test_rights("owner_write", "tmp", "/", "w", 403) - self._test_rights("owner_write", "tmp", "/tmp", "r", 207) - self._test_rights("owner_write", "tmp", "/tmp", "w", 207) - self._test_rights("owner_write", "tmp", "/other", "r", 207) - self._test_rights("owner_write", "tmp", "/other", "w", 403) - - def test_authenticated(self): - self._test_rights("authenticated", "", "/", "r", 401) - self._test_rights("authenticated", "", "/", "w", 401) - self._test_rights("authenticated", "", "/tmp", "r", 401) - self._test_rights("authenticated", "", "/tmp", "w", 401) - self._test_rights("authenticated", "tmp", "/", "r", 207) - self._test_rights("authenticated", "tmp", "/", "w", 207) - self._test_rights("authenticated", "tmp", "/tmp", "r", 207) - self._test_rights("authenticated", "tmp", "/tmp", "w", 207) - self._test_rights("authenticated", "tmp", "/other", "r", 207) - self._test_rights("authenticated", "tmp", "/other", "w", 207) - - def test_none(self): - self._test_rights("none", "", "/", "r", 207) - self._test_rights("none", "", "/", "w", 207) - self._test_rights("none", "", "/tmp", "r", 207) - self._test_rights("none", "", "/tmp", "w", 207) - self._test_rights("none", "tmp", "/", "r", 207) - self._test_rights("none", "tmp", "/", "w", 207) - self._test_rights("none", "tmp", "/tmp", "r", 207) - self._test_rights("none", "tmp", "/tmp", "w", 207) - self._test_rights("none", "tmp", "/other", "r", 207) - self._test_rights("none", "tmp", "/other", "w", 207) - - def test_from_file(self): - rights_file_path = os.path.join(self.colpath, "rights") - with open(rights_file_path, "w") as f: - f.write("""\ -[owner] -user: .+ -collection: %(login)s(/.*)? -permission: rw -[custom] -user: .* -collection: custom(/.*)? -permission: r""") - self.configuration["rights"]["file"] = rights_file_path - self._test_rights("from_file", "", "/other", "r", 401) - self._test_rights("from_file", "tmp", "/other", "r", 403) - self._test_rights("from_file", "", "/custom/sub", "r", 404) - self._test_rights("from_file", "tmp", "/custom/sub", "r", 404) - self._test_rights("from_file", "", "/custom/sub", "w", 401) - self._test_rights("from_file", "tmp", "/custom/sub", "w", 403) - - def test_custom(self): - """Custom rights management.""" - self._test_rights("tests.custom.rights", "", "/", "r", 401) - self._test_rights("tests.custom.rights", "", "/tmp", "r", 207) diff --git a/radicale/web.py b/radicale/web.py deleted file mode 100644 index acc7ce7..0000000 --- a/radicale/web.py +++ /dev/null @@ -1,124 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright (C) 2017 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 os -import posixpath -import time -from http import client -from importlib import import_module - -import pkg_resources - -from . import storage - -NOT_FOUND = ( - client.NOT_FOUND, (("Content-Type", "text/plain"),), - "The requested resource could not be found.") - -MIMETYPES = { - ".css": "text/css", - ".eot": "application/vnd.ms-fontobject", - ".gif": "image/gif", - ".html": "text/html", - ".js": "application/javascript", - ".manifest": "text/cache-manifest", - ".png": "image/png", - ".svg": "image/svg+xml", - ".ttf": "application/font-sfnt", - ".txt": "text/plain", - ".woff": "application/font-woff", - ".woff2": "font/woff2", - ".xml": "text/xml"} -FALLBACK_MIMETYPE = "application/octet-stream" - -INTERNAL_TYPES = ("None", "none", "internal") - - -def load(configuration, logger): - """Load the web module chosen in configuration.""" - web_type = configuration.get("web", "type") - if web_type in ("None", "none"): # DEPRECATED: use "none" - web_class = NoneWeb - elif web_type == "internal": - web_class = Web - else: - try: - web_class = import_module(web_type).Web - except Exception as e: - raise RuntimeError("Failed to load web module %r: %s" % - (web_type, e)) from e - logger.info("Web type is %r", web_type) - return web_class(configuration, logger) - - -class BaseWeb: - def __init__(self, configuration, logger): - self.configuration = configuration - self.logger = logger - - def get(self, environ, base_prefix, path, user): - """GET request. - - ``base_prefix`` is sanitized and never ends with "/". - - ``path`` is sanitized and always starts with "/.web" - - ``user`` is empty for anonymous users. - - """ - raise NotImplementedError - - -class NoneWeb(BaseWeb): - def get(self, environ, base_prefix, path, user): - if path != "/.web": - return NOT_FOUND - return client.OK, {"Content-Type": "text/plain"}, "Radicale works!" - - -class Web(BaseWeb): - def __init__(self, configuration, logger): - super().__init__(configuration, logger) - self.folder = pkg_resources.resource_filename(__name__, "web") - - def get(self, environ, base_prefix, path, user): - try: - filesystem_path = storage.path_to_filesystem( - self.folder, path[len("/.web"):]) - except ValueError as e: - self.logger.debug("Web content with unsafe path %r requested: %s", - path, e, exc_info=True) - return NOT_FOUND - if os.path.isdir(filesystem_path) and not path.endswith("/"): - location = posixpath.basename(path) + "/" - return (client.FOUND, - {"Location": location, "Content-Type": "text/plain"}, - "Redirected to %s" % location) - if os.path.isdir(filesystem_path): - filesystem_path = os.path.join(filesystem_path, "index.html") - if not os.path.isfile(filesystem_path): - return NOT_FOUND - content_type = MIMETYPES.get( - os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE) - with open(filesystem_path, "rb") as f: - answer = f.read() - last_modified = time.strftime( - "%a, %d %b %Y %H:%M:%S GMT", - time.gmtime(os.fstat(f.fileno()).st_mtime)) - headers = { - "Content-Type": content_type, - "Last-Modified": last_modified} - return client.OK, headers, answer diff --git a/radicale/web/css/icon.png b/radicale/web/css/icon.png deleted file mode 100644 index a9c9c042bbb4535002b829fdcd2d895279a751e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1069 zcmV+|1k(G7P)Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2iXY< z4GAF*J6#q400XT_L_t(o!@ZV&Xj@ej$3N$#?{$gWEM_yb4Q#Tp38R+6C`Dy3Y1K~h z{%}8nQi?-JgsSup^&b_4{pBVy|L7o5#HO7xDh?V%h0T=>whpw@qR!!T%sE8ry3)EW zeQndTFPv8WD}F>S0m##L}h-AgV8*(j%g~@TnyMh7-;_$YDgj1nmXs1yE$a!WE#$ zFMyOX$xd7ARXHT+{lGKB$+@9e%KAb?9|c)6oN#`vxC3_DdcaOwYdj~;13e0pi0rk~ z=0*|yP*oI&S8EGWK*mm6dt)iHBb#(aMd($f5#()<5fL2ViK+l7dIBYc@-oOFJ8kv@ zmw|VHZ9ok85wxW$0HU%-MR%%byTGTabgRmch)x6F0dD{^K*&y;P1P;Jlyw*Ih>GqP zBnq4bHUSP`0o}t1=c9_Z;bJ=`oomBMCks3Vi~-Hw+K@NpzN(x9wMI}bbwj2EpH zVD%jV@LKYBg@nR{0^SJSF@OapoYTvubqL4<5}MMxzk-H=g;6&MfvN!vLft`leA}yY ztAf)i%VyMAcg&6d1eXQQ3Jd^G4?;u5x}YfeTHrN>X9K{`4;qLI;R~*=`~%U&GXw;V z0yxqPXXPco%%@dkX~O0^7Z>Q~}8^jGHnRJu0vf zcx$PeFiNz(0ciK4Yl+Sk=ClKCejT_M*r4UtO`$AozW^2xl=3^auw;{8fSh;n{9mo~ z3or%zc8jg$$n^^_;k8s*HBfX^Px%7qf*aliRB}=mFbo58`Emqq`=7C<3>&{@;XrA2 zZ_|o}AI)HpU}GbZEn8Fq0r@49Im7I%KY%XyW&lP6npbV!EMgd%pPdz*n?no(!?LJZ zv4UWIJ@>S<(9qdQT~m{sP9_P|)}HVU;C*4nh0b-mcW;Pn-AX=}!*v|QFbD*L)P}+2V%=dWLviIXSKsN-CqQhFa={H(S0Ms9LaRn^shk?Xpus_ysu_UXrw$Wy5@FTEv| zftnAX@A!@#>dwy>C%h^7m9a7XyshncKZL2Y-h<$vNZ-+p4$VzWEILt0e(lmF9kK1J zgYYob(z{?D3K1pKHm+;N{R6|V*Qy+i(rz$00000NkvXXu0mjf#S7-n diff --git a/radicale/web/css/main.css b/radicale/web/css/main.css deleted file mode 100644 index 183e743..0000000 --- a/radicale/web/css/main.css +++ /dev/null @@ -1,43 +0,0 @@ -body { background: #e4e9f6; color: #424247; display: flex; flex-direction: column; font-size: 14pt; line-height: 1.4; margin: 0; min-height: 100vh; } - -a { color: inherit; } - -nav, footer { background: #a40000; color: white; padding: 0 20%; } -nav ul, footer ul { display: flex; flex-wrap: wrap; margin: 0; padding: 0; } -nav ul li, footer ul li { display: block; padding: 0 1em 0 0; } -nav ul li a, footer ul li a { color: inherit; display: block; padding: 1em 0.5em 1em 0; text-decoration: inherit; transition: 0.2s; } -nav ul li a:hover, nav ul li a:focus, footer ul li a:hover, footer ul li a:focus { color: black; outline: none; } - -header { background: url(logo.svg), linear-gradient(to bottom right, #050a02, black); background-position: 22% 45%; background-repeat: no-repeat; color: #efdddd; font-size: 1.5em; min-height: 250px; overflow: auto; padding: 3em 22%; text-shadow: 0.2em 0.2em 0.2em rgba(0, 0, 0, 0.5); } -header > * { padding-left: 220px; } -header h1 { font-size: 2.5em; font-weight: lighter; margin: 0.5em 0; } - -main { flex: 1; } - -section { padding: 0 20% 2em; } -section:not(:last-child) { border-bottom: 1px dashed #ccc; } -section h1 { background: linear-gradient(to bottom right, #050a02, black); color: #e5dddd; font-size: 2.5em; margin: 0 -33.33% 1em; padding: 1em 33.33%; } -section h2, section h3, section h4 { font-weight: lighter; margin: 1.5em 0 1em; } - -article { border-top: 1px solid transparent; position: relative; margin: 3em 0; } -article aside { box-sizing: border-box; color: #aaa; font-size: 0.8em; right: -30%; top: 0.5em; position: absolute; } -article:before { border-top: 1px dashed #ccc; content: ""; display: block; left: -33.33%; position: absolute; right: -33.33%; } - -pre { border-radius: 3px; background: black; color: #d3d5db; margin: 0 -1em; overflow-x: auto; padding: 1em; } - -table { border-collapse: collapse; font-size: 0.8em; margin: auto; } -table td { border: 1px solid #ccc; padding: 0.5em; } - -dl dt { margin-bottom: 0.5em; margin-top: 1em; } - -@media (max-width: 800px) { body { font-size: 12pt; } - header, section { padding-left: 2em; padding-right: 2em; } - nav, footer { padding-left: 0; padding-right: 0; } - nav ul, footer ul { justify-content: center; } - nav ul li, footer ul li { padding: 0 0.5em; } - nav ul li a, footer ul li a { padding: 1em 0; } - header { background-position: 50% 30px, 0 0; padding-bottom: 0; padding-top: 330px; text-align: center; } - header > * { margin: 0; padding-left: 0; } - section h1 { margin: 0 -0.8em 1.3em; padding: 0.5em 0; text-align: center; } - article aside { top: 0.5em; right: -1.5em; } - article:before { left: -2em; right: -2em; } } diff --git a/radicale/web/fn.js b/radicale/web/fn.js deleted file mode 100644 index a436ae6..0000000 --- a/radicale/web/fn.js +++ /dev/null @@ -1,1003 +0,0 @@ -/** - * This file is part of Radicale Server - Calendar Server - * Copyright (C) 2017 Unrud - * - * This program 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 program 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 this program. If not, see . - */ - -/** - * Server address - * @const - * @type {string} - */ -var SERVER = (location.protocol + '//' + location.hostname + - (location.port ? ':' + location.port : '')); - -/** - * Path of the root collection on the server (must end with /) - * @const - * @type {string} - */ -var ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\\.html?)?$"), "") + '/'; - -/** - * time between updates of collections (milliseconds) - * @const - * @type {?int} - */ -var UPDATE_INTERVAL = null; - -/** - * Regex to match and normalize color - * @const - */ -var COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$"); - -/** - * Escape string for usage in XML - * @param {string} s - * @return {string} - */ -function escape_xml(s) { - return (s - .replace("&", "&") - .replace('"', """) - .replace("'", "'") - .replace("<", "<") - .replace(">", ">")); -} - -/** - * @enum {string} - */ -var CollectionType = { - PRINCIPAL: "PRINCIPAL", - ADDRESSBOOK: "ADDRESSBOOK", - CALENDAR_JOURNAL_TASKS: "CALENDAR_JOURNAL_TASKS", - CALENDAR_JOURNAL: "CALENDAR_JOURNAL", - CALENDAR_TASKS: "CALENDAR_TASKS", - JOURNAL_TASKS: "JOURNAL_TASKS", - CALENDAR: "CALENDAR", - JOURNAL: "JOURNAL", - TASKS: "TASKS", - is_subset: function(a, b) { - var components = a.split("_"); - var i; - for (i = 0; i < components.length; i++) { - if (b.search(components[i]) === -1) { - return false; - } - } - return true; - }, - union: function(a, b) { - if (a.search(this.ADDRESSBOOK) !== -1 || b.search(this.ADDRESSBOOK) !== -1) { - if (a && a !== this.ADDRESSBOOK || b && b !== this.ADDRESSBOOK) { - throw "Invalid union: " + a + " " + b; - } - return this.ADDRESSBOOK; - } - var union = ""; - if (a.search(this.CALENDAR) !== -1 || b.search(this.CALENDAR) !== -1) { - union += (union ? "_" : "") + this.CALENDAR; - } - if (a.search(this.JOURNAL) !== -1 || b.search(this.JOURNAL) !== -1) { - union += (union ? "_" : "") + this.JOURNAL; - } - if (a.search(this.TASKS) !== -1 || b.search(this.TASKS) !== -1) { - union += (union ? "_" : "") + this.TASKS; - } - return union; - } -}; - -/** - * @constructor - * @struct - * @param {string} href Must always start and end with /. - * @param {CollectionType} type - * @param {string} displayname - * @param {string} description - * @param {string} color - */ -function Collection(href, type, displayname, description, color) { - this.href = href; - this.type = type; - this.displayname = displayname; - this.color = color; - this.description = description; -} - -/** - * Find the principal collection. - * @param {string} user - * @param {string} password - * @param {function(?Collection, ?string)} callback Returns result or error - * @return {XMLHttpRequest} - */ -function get_principal(user, password, callback) { - var request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + ROOT_PATH, true, user, password); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - if (request.status === 207) { - var xml = request.responseXML; - var principal_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|current-user-principal > *|href"); - var displayname_element = xml.querySelector("*|multistatus:root > *|response:first-of-type > *|propstat > *|prop > *|displayname"); - if (principal_element) { - callback(new Collection( - principal_element.textContent, - CollectionType.PRINCIPAL, - displayname_element ? displayname_element.textContent : "", - "", - ""), null); - } else { - callback(null, "Internal error"); - } - } else { - callback(null, request.status + " " + request.statusText); - } - }; - request.send('' + - '' + - '' + - '' + - '' + - '' + - ''); - return request; -} - -/** - * Find all calendars and addressbooks in collection. - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {function(?Array, ?string)} callback Returns result or error - * @return {XMLHttpRequest} - */ -function get_collections(user, password, collection, callback) { - var request = new XMLHttpRequest(); - request.open("PROPFIND", SERVER + collection.href, true, user, password); - request.setRequestHeader("depth", "1"); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - if (request.status === 207) { - var xml = request.responseXML; - var collections = []; - var response_query = "*|multistatus:root > *|response"; - var responses = xml.querySelectorAll(response_query); - var i; - for (i = 0; i < responses.length; i++) { - var response = responses[i]; - var href_element = response.querySelector(response_query + " > *|href"); - var resourcetype_query = response_query + " > *|propstat > *|prop > *|resourcetype"; - var resourcetype_element = response.querySelector(resourcetype_query); - var displayname_element = response.querySelector(response_query + " > *|propstat > *|prop > *|displayname"); - var calendarcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-color"); - var addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color"); - var calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description"); - var addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description"); - var components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set"; - var components_element = response.querySelector(components_query); - var href = href_element ? href_element.textContent : ""; - var displayname = displayname_element ? displayname_element.textContent : ""; - var type = ""; - var color = ""; - var description = ""; - if (resourcetype_element) { - if (resourcetype_element.querySelector(resourcetype_query + " > *|addressbook")) { - type = CollectionType.ADDRESSBOOK; - color = addressbookcolor_element ? addressbookcolor_element.textContent : ""; - description = addressbookdesc_element ? addressbookdesc_element.textContent : ""; - } else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) { - if (components_element) { - if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) { - type = CollectionType.union(type, CollectionType.CALENDAR); - } - if (components_element.querySelector(components_query + " > *|comp[name=VJOURNAL]")) { - type = CollectionType.union(type, CollectionType.JOURNAL); - } - if (components_element.querySelector(components_query + " > *|comp[name=VTODO]")) { - type = CollectionType.union(type, CollectionType.TASKS); - } - } - color = calendarcolor_element ? calendarcolor_element.textContent : ""; - description = calendardesc_element ? calendardesc_element.textContent : ""; - } - } - var sane_color = color.trim(); - if (sane_color) { - var color_match = COLOR_RE.exec(sane_color); - if (color_match) { - sane_color = color_match[1]; - } else { - sane_color = ""; - } - } - if (href.substr(-1) === "/" && href !== collection.href && type) { - collections.push(new Collection(href, type, displayname, description, sane_color)); - } - } - collections.sort(function(a, b) { - /** @type {string} */ var ca = a.displayname || a.href; - /** @type {string} */ var cb = b.displayname || b.href; - return ca.localeCompare(cb); - }); - callback(collections, null); - } else { - callback(null, request.status + " " + request.statusText); - } - }; - request.send('' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''); - return request; -} - -/** - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function delete_collection(user, password, collection, callback) { - var request = new XMLHttpRequest(); - request.open("DELETE", SERVER + collection.href, true, user, password); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - if (200 <= request.status && request.status < 300) { - callback(null); - } else { - callback(request.status + " " + request.statusText); - } - }; - request.send(); - return request; -} - -/** - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {boolean} create - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function create_edit_collection(user, password, collection, create, callback) { - var request = new XMLHttpRequest(); - request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, password); - request.onreadystatechange = function() { - if (request.readyState !== 4) { - return; - } - if (200 <= request.status && request.status < 300) { - callback(null); - } else { - callback(request.status + " " + request.statusText); - } - }; - var displayname = escape_xml(collection.displayname); - var calendar_color = ""; - var addressbook_color = ""; - var calendar_description = ""; - var addressbook_description = ""; - var resourcetype; - var components = ""; - if (collection.type === CollectionType.ADDRESSBOOK) { - addressbook_color = escape_xml(collection.color + (collection.color ? "ff" : "")); - addressbook_description = escape_xml(collection.description); - resourcetype = ''; - } else { - calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); - calendar_description = escape_xml(collection.description); - resourcetype = ''; - if (CollectionType.is_subset(CollectionType.CALENDAR, collection.type)) { - components += ''; - } - if (CollectionType.is_subset(CollectionType.JOURNAL, collection.type)) { - components += ''; - } - if (CollectionType.is_subset(CollectionType.TASKS, collection.type)) { - components += ''; - } - } - var xml_request = create ? "mkcol" : "propertyupdate"; - request.send('' + - '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + - '' + - '' + - (create ? '' + resourcetype + '' : '') + - (components ? '' + components + '' : '') + - (displayname ? '' + displayname + '' : '') + - (calendar_color ? '' + calendar_color + '' : '') + - (addressbook_color ? '' + addressbook_color + '' : '') + - (addressbook_description ? '' + addressbook_description + '' : '') + - (calendar_description ? '' + calendar_description + '' : '') + - '' + - '' + - (!create ? ('' + - '' + - (!components ? '' : '') + - (!displayname ? '' : '') + - (!calendar_color ? '' : '') + - (!addressbook_color ? '' : '') + - (!addressbook_description ? '' : '') + - (!calendar_description ? '' : '') + - '' + - ''): '') + - ''); - return request; -} - -/** - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function create_collection(user, password, collection, callback) { - return create_edit_collection(user, password, collection, true, callback); -} - -/** - * @param {string} user - * @param {string} password - * @param {Collection} collection - * @param {function(?string)} callback Returns error or null - * @return {XMLHttpRequest} - */ -function edit_collection(user, password, collection, callback) { - return create_edit_collection(user, password, collection, false, callback); -} - -/** - * @interface - */ -function Scene() {} -/** - * Scene is on top of stack and visible. - */ -Scene.prototype.show = function() {}; -/** - * Scene is no longer visible. - */ -Scene.prototype.hide = function() {}; -/** - * Scene is removed from scene stack. - */ -Scene.prototype.release = function() {}; - - -/** - * @type {Array} - */ -var scene_stack = []; - -/** - * Push scene onto stack. - * @param {Scene} scene - * @param {boolean} replace Replace the scene on top of the stack. - */ -function push_scene(scene, replace) { - if (scene_stack.length >= 1) { - scene_stack[scene_stack.length - 1].hide(); - if (replace) { - scene_stack.pop().release(); - } - } - scene_stack.push(scene); - scene.show(); -} - -/** - * Remove scenes from stack. - * @param {number} index New top of stack - */ -function pop_scene(index) { - if (scene_stack.length - 1 <= index) { - return; - } - scene_stack[scene_stack.length - 1].hide(); - while (scene_stack.length - 1 > index) { - var old_length = scene_stack.length; - scene_stack.pop().release(); - if (old_length - 1 === index + 1) { - break; - } - } - if (scene_stack.length >= 1) { - var scene = scene_stack[scene_stack.length - 1]; - scene.show(); - } else { - throw "Scene stack is empty"; - } -} - -/** - * @constructor - * @implements {Scene} - */ -function LoginScene() { - var html_scene = document.getElementById("loginscene"); - var form = html_scene.querySelector("[name=form]"); - var user_form = html_scene.querySelector("[name=user]"); - var password_form = html_scene.querySelector("[name=password]"); - var error_form = html_scene.querySelector("[name=error]"); - var logout_view = document.getElementById("logoutview"); - var logout_user_form = logout_view.querySelector("[name=user]"); - var logout_btn = logout_view.querySelector("[name=link]"); - var first_show = true; - - /** @type {?number} */ var scene_index = null; - var user = ""; - var error = ""; - /** @type {?XMLHttpRequest} */ var principal_req = null; - - function read_form() { - user = user_form.value; - } - - function fill_form() { - user_form.value = user; - password_form.value = ""; - error_form.textContent = error ? "Error: " + error : ""; - } - - function onlogin() { - try { - read_form(); - var password = password_form.value; - if (user) { - error = ""; - // setup logout - logout_view.style.display = "block"; - logout_btn.onclick = onlogout; - logout_user_form.textContent = user; - // Fetch principal - var loading_scene = new LoadingScene(); - push_scene(loading_scene, false); - principal_req = get_principal(user, password, function(collection, error1) { - if (scene_index === null) { - return; - } - principal_req = null; - if (error1) { - error = error1; - pop_scene(scene_index); - } else { - // show collections - var saved_user = user; - user = ""; - if (typeof(sessionStorage) !== "undefined") { - sessionStorage.setItem("radicale_user", saved_user); - sessionStorage.setItem("radicale_password", password); - } - var collections_scene = new CollectionsScene( - saved_user, password, collection, function(error1) { - error = error1; - user = saved_user; - }); - push_scene(collections_scene, true); - } - }); - } else { - error = "Username is empty"; - fill_form(); - } - } catch(err) { - console.error(err); - } - return false; - } - - function onlogout() { - try { - if (scene_index === null) { - return false; - } - user = ""; - pop_scene(scene_index); - } catch (err) { - console.error(err); - } - return false; - } - - this.show = function() { - var saved_first_show = first_show; - first_show = false; - this.release(); - fill_form(); - form.onsubmit = onlogin; - html_scene.style.display = "block"; - user_form.focus(); - scene_index = scene_stack.length - 1; - if (typeof(sessionStorage) !== "undefined") { - if (saved_first_show && sessionStorage.getItem("radicale_user")) { - user_form.value = sessionStorage.getItem("radicale_user"); - password_form.value = sessionStorage.getItem("radicale_password"); - onlogin(); - } else { - sessionStorage.setItem("radicale_user", ""); - sessionStorage.setItem("radicale_password", ""); - } - } - }; - this.hide = function() { - read_form(); - html_scene.style.display = "none"; - form.onsubmit = null; - }; - this.release = function() { - scene_index = null; - // cancel pending requests - if (principal_req !== null) { - principal_req.abort(); - principal_req = null; - } - // remove logout - logout_view.style.display = "none"; - logout_btn.onclick = null; - logout_user_form.textContent = ""; - }; -} - -/** - * @constructor - * @implements {Scene} - */ -function LoadingScene() { - var html_scene = document.getElementById("loadingscene"); - this.show = function() { - html_scene.style.display = "block"; - }; - this.hide = function() { - html_scene.style.display = "none"; - }; - this.release = function() {}; -} - -/** - * @constructor - * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection The principal collection. - * @param {function(string)} onerror Called when an error occurs, before the - * scene is popped. - */ -function CollectionsScene(user, password, collection, onerror) { - var html_scene = document.getElementById("collectionsscene"); - var template = html_scene.querySelector("[name=collectiontemplate]"); - var new_btn = html_scene.querySelector("[name=new]"); - - /** @type {?number} */ var scene_index = null; - var saved_template_display = null; - /** @type {?XMLHttpRequest} */ var collections_req = null; - var timer = null; - var from_update = false; - /** @type {?Array} */ var collections = null; - /** @type {Array} */ var nodes = []; - - function onnew() { - try { - var create_collection_scene = new CreateEditCollectionScene(user, password, collection); - push_scene(create_collection_scene, false); - } catch(err) { - console.error(err); - } - return false; - } - - function onedit(collection) { - try { - var edit_collection_scene = new CreateEditCollectionScene(user, password, collection); - push_scene(edit_collection_scene, false); - } catch(err) { - console.error(err); - } - return false; - } - - function ondelete(collection) { - try { - var delete_collection_scene = new DeleteCollectionScene(user, password, collection); - push_scene(delete_collection_scene, false); - } catch(err) { - console.error(err); - } - return false; - } - - function show_collections(collections) { - nodes.forEach(function(node) { - template.parentNode.removeChild(node); - }); - nodes = []; - collections.forEach(function (collection) { - var node = template.cloneNode(true); - var title_form = node.querySelector("[name=title]"); - var description_form = node.querySelector("[name=description]"); - var url_form = node.querySelector("[name=url]"); - var color_form = node.querySelector("[name=color]"); - var delete_btn = node.querySelector("[name=delete]"); - var edit_btn = node.querySelector("[name=edit]"); - if (collection.color) { - color_form.style.color = collection.color; - } else { - color_form.style.display = "none"; - } - var possible_types = [CollectionType.ADDRESSBOOK]; - [CollectionType.CALENDAR, ""].forEach(function(e) { - [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function(e) { - [CollectionType.union(e, CollectionType.TASKS), e].forEach(function(e) { - if (e) { - possible_types.push(e); - } - }); - }); - }); - possible_types.forEach(function(e) { - if (e !== collection.type) { - node.querySelector("[name=" + e + "]").style.display = "none"; - } - }); - title_form.textContent = collection.displayname || collection.href; - description_form.textContent = collection.description; - var href = SERVER + collection.href; - url_form.href = href; - url_form.textContent = href; - delete_btn.onclick = function(ev) {return ondelete(collection);}; - edit_btn.onclick = function(ev) {return onedit(collection);}; - node.style.display = saved_template_display; - nodes.push(node); - template.parentNode.insertBefore(node, template); - }); - } - - function update() { - if (collections === null) { - var loading_scene = new LoadingScene(); - push_scene(loading_scene, false); - } - collections_req = get_collections(user, password, collection, function(collections1, error) { - if (scene_index === null) { - return; - } - collections_req = null; - if (error) { - onerror(error); - pop_scene(scene_index - 1); - } else { - var old_collections = collections; - collections = collections1; - if (UPDATE_INTERVAL !== null) { - timer = window.setTimeout(update, UPDATE_INTERVAL); - } - from_update = true; - if (old_collections === null) { - pop_scene(scene_index); - } else { - show_collections(collections); - } - } - }); - } - - this.show = function() { - saved_template_display = template.style.display; - template.style.display = "none"; - html_scene.style.display = "block"; - new_btn.onclick = onnew; - if (scene_index === null) { - scene_index = scene_stack.length - 1; - if (collections === null && collections_req !== null) { - pop_scene(scene_index - 1); - return; - } - update(); - } else if (collections === null) { - pop_scene(scene_index - 1); - } else { - if (from_update) { - show_collections(collections); - } else { - collections = null; - update(); - } - } - }; - this.hide = function() { - html_scene.style.display = "none"; - template.style.display = saved_template_display; - new_btn.onclick = null; - if (timer !== null) { - window.clearTimeout(timer); - timer = null; - } - from_update = false; - if (collections !== null && collections_req !== null) { - collections_req.abort(); - collections_req = null; - } - show_collections([]); - }; - this.release = function() { - scene_index = null; - if (collections_req !== null) { - collections_req.abort(); - collections_req = null; - } - }; -} - -/** - * @constructor - * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection - */ -function DeleteCollectionScene(user, password, collection) { - var html_scene = document.getElementById("deletecollectionscene"); - var title_form = html_scene.querySelector("[name=title]"); - var error_form = html_scene.querySelector("[name=error]"); - var delete_btn = html_scene.querySelector("[name=delete]"); - var cancel_btn = html_scene.querySelector("[name=cancel]"); - var no_btn = html_scene.querySelector("[name=no]"); - - /** @type {?number} */ var scene_index = null; - /** @type {?XMLHttpRequest} */ var delete_req = null; - var error = ""; - - function ondelete() { - try { - var loading_scene = new LoadingScene(); - push_scene(loading_scene); - delete_req = delete_collection(user, password, collection, function(error1) { - if (scene_index === null) { - return; - } - delete_req = null; - if (error1) { - error = error1; - pop_scene(scene_index); - } else { - pop_scene(scene_index - 1); - } - }); - } catch(err) { - console.error(err); - } - return false; - } - - function oncancel() { - try { - pop_scene(scene_index - 1); - } catch(err) { - console.error(err); - } - return false; - } - - this.show = function() { - this.release(); - scene_index = scene_stack.length - 1; - html_scene.style.display = "block"; - title_form.textContent = collection.displayname || collection.href; - error_form.textContent = error ? "Error: " + error : ""; - delete_btn.onclick = ondelete; - cancel_btn.onclick = oncancel; - }; - this.hide = function() { - html_scene.style.display = "none"; - cancel_btn.onclick = null; - delete_btn.onclick = null; - }; - this.release = function() { - scene_index = null; - if (delete_req !== null) { - delete_req.abort(); - delete_req = null; - } - }; -} - -/** - * Generate random hex number. - * @param {number} length - * @return {string} - */ -function randHex(length) { - var s = Math.floor(Math.random() * Math.pow(16, length)).toString(16); - while (s.length < length) { - s = "0" + s; - } - return s; -} - -/** - * @constructor - * @implements {Scene} - * @param {string} user - * @param {string} password - * @param {Collection} collection if it's a principal collection, a new - * collection will be created inside of it. - * Otherwise the collection will be edited. - */ -function CreateEditCollectionScene(user, password, collection) { - var edit = collection.type !== CollectionType.PRINCIPAL; - var html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene"); - var title_form = edit ? html_scene.querySelector("[name=title]") : null; - var error_form = html_scene.querySelector("[name=error]"); - var displayname_form = html_scene.querySelector("[name=displayname]"); - var description_form = html_scene.querySelector("[name=description]"); - var type_form = html_scene.querySelector("[name=type]"); - var color_form = html_scene.querySelector("[name=color]"); - var submit_btn = html_scene.querySelector("[name=submit]"); - var cancel_btn = html_scene.querySelector("[name=cancel]"); - - /** @type {?number} */ var scene_index = null; - /** @type {?XMLHttpRequest} */ var create_edit_req = null; - var error = ""; - /** @type {?Element} */ var saved_type_form = null; - - var href = edit ? collection.href : ( - collection.href + randHex(8) + "-" + randHex(4) + "-" + randHex(4) + - "-" + randHex(4) + "-" + randHex(12) + "/"); - var displayname = edit ? collection.displayname : ""; - var description = edit ? collection.description : ""; - var type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; - var color = edit && collection.color ? collection.color : "#" + randHex(6); - - function remove_invalid_types() { - if (!edit) { - return; - } - /** @type {HTMLOptionsCollection} */ var options = type_form.options; - // remove all options that are not supersets - var i; - for (i = options.length - 1; i >= 0; i--) { - if (!CollectionType.is_subset(type, options[i].value)) { - options.remove(i); - } - } - } - - function read_form() { - displayname = displayname_form.value; - description = description_form.value; - type = type_form.value; - color = color_form.value; - } - - function fill_form() { - displayname_form.value = displayname; - description_form.value = description; - type_form.value = type; - color_form.value = color; - error_form.textContent = error ? "Error: " + error : ""; - } - - function onsubmit() { - try { - read_form(); - var sane_color = color.trim(); - if (sane_color) { - var color_match = COLOR_RE.exec(sane_color); - if (!color_match) { - error = "Invalid color"; - fill_form(); - return false; - } - sane_color = color_match[1]; - } - var loading_scene = new LoadingScene(); - push_scene(loading_scene); - var collection = new Collection(href, type, displayname, description, sane_color); - var callback = function(error1) { - if (scene_index === null) { - return; - } - create_edit_req = null; - if (error1) { - error = error1; - pop_scene(scene_index); - } else { - pop_scene(scene_index - 1); - } - }; - if (edit) { - create_edit_req = edit_collection(user, password, collection, callback); - } else { - create_edit_req = create_collection(user, password, collection, callback); - } - } catch(err) { - console.error(err); - } - return false; - } - - function oncancel() { - try { - pop_scene(scene_index - 1); - } catch(err) { - console.error(err); - } - return false; - } - - this.show = function() { - this.release(); - scene_index = scene_stack.length - 1; - // Clone type_form because it's impossible to hide options without removing them - saved_type_form = type_form; - type_form = type_form.cloneNode(true); - saved_type_form.parentNode.replaceChild(type_form, saved_type_form); - remove_invalid_types(); - html_scene.style.display = "block"; - if (edit) { - title_form.textContent = collection.displayname || collection.href; - } - fill_form(); - submit_btn.onclick = onsubmit; - cancel_btn.onclick = oncancel; - }; - this.hide = function() { - read_form(); - html_scene.style.display = "none"; - // restore type_form - type_form.parentNode.replaceChild(saved_type_form, type_form); - type_form = saved_type_form; - saved_type_form = null; - submit_btn.onclick = null; - cancel_btn.onclick = null; - }; - this.release = function() { - scene_index = null; - if (create_edit_req !== null) { - create_edit_req.abort(); - create_edit_req = null; - } - }; -} - -function main() { - push_scene(new LoginScene(), false); -} - -window.addEventListener("load", main); diff --git a/radicale/web/index.html b/radicale/web/index.html deleted file mode 100644 index 377e74c..0000000 --- a/radicale/web/index.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - Web interface for Radicale - - - - - - - - - - - - - diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py deleted file mode 100644 index b096bcd..0000000 --- a/radicale/xmlutils.py +++ /dev/null @@ -1,1324 +0,0 @@ -# This file is part of Radicale Server - Calendar Server -# Copyright © 2008 Nicolas Kandel -# Copyright © 2008 Pascal Halter -# Copyright © 2008-2015 Guillaume Ayoub -# -# 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 . - -""" -XML and iCal requests manager. - -Note that all these functions need to receive unicode objects for full -iCal requests (PUT) and string objects with charset correctly defined -in them for XML requests (all but PUT). - -""" - -import copy -import math -import posixpath -import re -import xml.etree.ElementTree as ET -from collections import OrderedDict -from datetime import date, datetime, timedelta, timezone -from http import client -from itertools import chain -from urllib.parse import quote, unquote, urlparse - -from . import storage - -MIMETYPES = { - "VADDRESSBOOK": "text/vcard", - "VCALENDAR": "text/calendar"} - -OBJECT_MIMETYPES = { - "VCARD": "text/vcard", - "VLIST": "text/x-vlist", - "VCALENDAR": "text/calendar"} - -NAMESPACES = { - "C": "urn:ietf:params:xml:ns:caldav", - "CR": "urn:ietf:params:xml:ns:carddav", - "D": "DAV:", - "CS": "http://calendarserver.org/ns/", - "ICAL": "http://apple.com/ns/ical/", - "ME": "http://me.com/_namespace/", - "RADICALE": "http://radicale.org/ns/"} - -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[^}]*)}(?P.*)", re.VERBOSE) -HUMAN_REGEX = re.compile(r"(?P[^:{}]*):(?P.*)", re.VERBOSE) - -DAY = timedelta(days=1) -SECOND = timedelta(seconds=1) -DATETIME_MIN = datetime.min.replace(tzinfo=timezone.utc) -DATETIME_MAX = datetime.max.replace(tzinfo=timezone.utc) -TIMESTAMP_MIN = math.floor(DATETIME_MIN.timestamp()) -TIMESTAMP_MAX = math.ceil(DATETIME_MAX.timestamp()) - - -def pretty_xml(element, level=0): - """Indent an ElementTree ``element`` and its children.""" - if not level: - element = copy.deepcopy(element) - i = "\n" + level * " " - if len(element): - if not element.text or not element.text.strip(): - element.text = i + " " - if not element.tail or not element.tail.strip(): - element.tail = i - for sub_element in element: - pretty_xml(sub_element, level + 1) - if not sub_element.tail or not sub_element.tail.strip(): - sub_element.tail = i - else: - if level and (not element.tail or not element.tail.strip()): - element.tail = i - if not level: - return '\n%s' % ET.tostring(element, "unicode") - - -def _tag(short_name, local): - """Get XML Clark notation {uri(``short_name``)}``local``.""" - return "{%s}%s" % (NAMESPACES[short_name], local) - - -def _tag_from_clark(name): - """Get a human-readable variant of the XML Clark notation tag ``name``. - - For a given name using the XML Clark notation, return a human-readable - variant of the tag name for known namespaces. Otherwise, return the name as - is. - - """ - match = CLARK_TAG_REGEX.match(name) - if match and match.group("namespace") in NAMESPACES_REV: - args = { - "ns": NAMESPACES_REV[match.group("namespace")], - "tag": match.group("tag")} - return "%(ns)s:%(tag)s" % args - return name - - -def _tag_from_human(name): - """Get an XML Clark notation tag from human-readable variant ``name``.""" - match = HUMAN_REGEX.match(name) - if match and match.group("namespace") in NAMESPACES: - return _tag(match.group("namespace"), match.group("tag")) - return name - - -def _response(code): - """Return full W3C names from HTTP status codes.""" - return "HTTP/1.1 %i %s" % (code, client.responses[code]) - - -def _href(base_prefix, href): - """Return prefixed href.""" - return quote("%s%s" % (base_prefix, href)) - - -def _webdav_error(namespace, name): - """Generate XML error message.""" - root = ET.Element(_tag("D", "error")) - root.append(ET.Element(_tag(namespace, name))) - return root - - -def _date_to_datetime(date_): - """Transform a date to a UTC datetime. - - If date_ is a datetime without timezone, return as UTC datetime. If date_ - is already a datetime with timezone, return as is. - - """ - if not isinstance(date_, datetime): - date_ = datetime.combine(date_, datetime.min.time()) - if not date_.tzinfo: - date_ = date_.replace(tzinfo=timezone.utc) - return date_ - - -def _comp_match(item, filter_, level=0): - """Check whether the ``item`` matches the comp ``filter_``. - - If ``level`` is ``0``, the filter is applied on the - item's collection. Otherwise, it's applied on the item. - - See rfc4791-9.7.1. - - """ - - # TODO: Filtering VALARM and VFREEBUSY is not implemented - # HACK: the filters are tested separately against all components - - if level == 0: - tag = item.name - elif level == 1: - tag = item.component_name - else: - item.collection.logger.warning( - "Filters with three levels of comp-filter are not supported") - return True - if not tag: - return False - name = filter_.get("name").upper() - if len(filter_) == 0: - # Point #1 of rfc4791-9.7.1 - return name == tag - if len(filter_) == 1: - if filter_[0].tag == _tag("C", "is-not-defined"): - # Point #2 of rfc4791-9.7.1 - return name != tag - if name != tag: - return False - if (level == 0 and name != "VCALENDAR" or - level == 1 and name not in ("VTODO", "VEVENT", "VJOURNAL")): - item.collection.logger.warning("Filtering %s is not supported" % name) - return True - # Point #3 and #4 of rfc4791-9.7.1 - components = ([item.item] if level == 0 - else list(getattr(item, "%s_list" % tag.lower()))) - for child in filter_: - if child.tag == _tag("C", "prop-filter"): - if not any(_prop_match(comp, child, "C") - for comp in components): - return False - elif child.tag == _tag("C", "time-range"): - if not _time_range_match(item.item, filter_[0], tag): - return False - elif child.tag == _tag("C", "comp-filter"): - if not _comp_match(item, child, level=level + 1): - return False - else: - raise ValueError("Unexpected %r in comp-filter" % child.tag) - return True - - -def _prop_match(vobject_item, filter_, ns): - """Check whether the ``item`` matches the prop ``filter_``. - - See rfc4791-9.7.2 and rfc6352-10.5.1. - - """ - name = filter_.get("name").lower() - if len(filter_) == 0: - # Point #1 of rfc4791-9.7.2 - return name in vobject_item.contents - if len(filter_) == 1: - if filter_[0].tag == _tag("C", "is-not-defined"): - # Point #2 of rfc4791-9.7.2 - return name not in vobject_item.contents - if name not in vobject_item.contents: - return False - # Point #3 and #4 of rfc4791-9.7.2 - for child in filter_: - if ns == "C" and child.tag == _tag("C", "time-range"): - if not _time_range_match(vobject_item, child, name): - return False - elif child.tag == _tag(ns, "text-match"): - if not _text_match(vobject_item, child, name, ns): - return False - elif child.tag == _tag(ns, "param-filter"): - if not _param_filter_match(vobject_item, child, name, ns): - return False - else: - raise ValueError("Unexpected %r in prop-filter" % child.tag) - return True - - -def _time_range_match(vobject_item, filter_, child_name): - """Check whether the component/property ``child_name`` of - ``vobject_item`` matches the time-range ``filter_``.""" - - start = filter_.get("start") - end = filter_.get("end") - if not start and not end: - return False - if start: - start = datetime.strptime(start, "%Y%m%dT%H%M%SZ") - else: - start = datetime.min - if end: - end = datetime.strptime(end, "%Y%m%dT%H%M%SZ") - else: - end = datetime.max - start = start.replace(tzinfo=timezone.utc) - end = end.replace(tzinfo=timezone.utc) - - matched = False - - def range_fn(range_start, range_end, is_recurrence): - nonlocal matched - if start < range_end and range_start < end: - matched = True - return True - if end < range_start and not is_recurrence: - return True - return False - - def infinity_fn(start): - return False - - _visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn) - return matched - - -def _visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn): - """Visit all time ranges in the component/property ``child_name`` of - `vobject_item`` with visitors ``range_fn`` and ``infinity_fn``. - - ``range_fn`` gets called for every time_range with ``start`` and ``end`` - datetimes and ``is_recurrence`` as arguments. If the function returns True, - the operation is cancelled. - - ``infinity_fn`` gets called when an infiite recurrence rule is detected - with ``start`` datetime as argument. If the function returns True, the - operation is cancelled. - - See rfc4791-9.9. - - """ - - # HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled - # with Recurrence ID affects the recurrence itself and all following - # recurrences too. This is not respected and client don't seem to bother - # either. - - def getrruleset(child, ignore=()): - if (hasattr(child, "rrule") and - ";UNTIL=" not in child.rrule.value.upper() and - ";COUNT=" not in child.rrule.value.upper()): - for dtstart in child.getrruleset(addRDate=True): - if dtstart in ignore: - continue - if infinity_fn(_date_to_datetime(dtstart)): - return (), True - break - return filter(lambda dtstart: dtstart not in ignore, - child.getrruleset(addRDate=True)), False - - def get_children(components): - main = None - recurrences = [] - for comp in components: - if hasattr(comp, "recurrence_id") and comp.recurrence_id.value: - recurrences.append(comp.recurrence_id.value) - if comp.rruleset: - # Prevent possible infinite loop - raise ValueError("Overwritten recurrence with RRULESET") - yield comp, True, () - else: - if main is not None: - raise ValueError("Multiple main components") - main = comp - if main is None: - raise ValueError("Main component missing") - yield main, False, recurrences - - # Comments give the lines in the tables of the specification - if child_name == "VEVENT": - for child, is_recurrence, recurrences in get_children( - vobject_item.vevent_list): - # TODO: check if there's a timezone - dtstart = child.dtstart.value - - if child.rruleset: - dtstarts, infinity = getrruleset(child, recurrences) - if infinity: - return - else: - dtstarts = (dtstart,) - - dtend = getattr(child, "dtend", None) - if dtend is not None: - dtend = dtend.value - original_duration = (dtend - dtstart).total_seconds() - dtend = _date_to_datetime(dtend) - - duration = getattr(child, "duration", None) - if duration is not None: - original_duration = duration = duration.value - - for dtstart in dtstarts: - dtstart_is_datetime = isinstance(dtstart, datetime) - dtstart = _date_to_datetime(dtstart) - - if dtend is not None: - # Line 1 - dtend = dtstart + timedelta(seconds=original_duration) - if range_fn(dtstart, dtend, is_recurrence): - return - elif duration is not None: - if original_duration is None: - original_duration = duration.seconds - if duration.seconds > 0: - # Line 2 - if range_fn(dtstart, dtstart + duration, - is_recurrence): - return - else: - # Line 3 - if range_fn(dtstart, dtstart + SECOND, is_recurrence): - return - elif dtstart_is_datetime: - # Line 4 - if range_fn(dtstart, dtstart + SECOND, is_recurrence): - return - else: - # Line 5 - if range_fn(dtstart, dtstart + DAY, is_recurrence): - return - - elif child_name == "VTODO": - for child, is_recurrence, recurrences in get_children( - vobject_item.vtodo_list): - dtstart = getattr(child, "dtstart", None) - duration = getattr(child, "duration", None) - due = getattr(child, "due", None) - completed = getattr(child, "completed", None) - created = getattr(child, "created", None) - - if dtstart is not None: - dtstart = _date_to_datetime(dtstart.value) - if duration is not None: - duration = duration.value - if due is not None: - due = _date_to_datetime(due.value) - if dtstart is not None: - original_duration = (due - dtstart).total_seconds() - if completed is not None: - completed = _date_to_datetime(completed.value) - if created is not None: - created = _date_to_datetime(created.value) - original_duration = (completed - created).total_seconds() - elif created is not None: - created = _date_to_datetime(created.value) - - if child.rruleset: - reference_dates, infinity = getrruleset(child, recurrences) - if infinity: - return - else: - if dtstart is not None: - reference_dates = (dtstart,) - elif due is not None: - reference_dates = (due,) - elif completed is not None: - reference_dates = (completed,) - elif created is not None: - reference_dates = (created,) - else: - # Line 8 - if range_fn(DATETIME_MIN, DATETIME_MAX, is_recurrence): - return - reference_dates = () - - for reference_date in reference_dates: - reference_date = _date_to_datetime(reference_date) - - if dtstart is not None and duration is not None: - # Line 1 - if range_fn(reference_date, - reference_date + duration + SECOND, - is_recurrence): - return - if range_fn(reference_date + duration - SECOND, - reference_date + duration + SECOND, - is_recurrence): - return - elif dtstart is not None and due is not None: - # Line 2 - due = reference_date + timedelta(seconds=original_duration) - if (range_fn(reference_date, due, is_recurrence) or - range_fn(reference_date, - reference_date + SECOND, is_recurrence) or - range_fn(due - SECOND, due, is_recurrence) or - range_fn(due - SECOND, reference_date + SECOND, - is_recurrence)): - return - elif dtstart is not None: - if range_fn(reference_date, reference_date + SECOND, - is_recurrence): - return - elif due is not None: - # Line 4 - if range_fn(reference_date - SECOND, reference_date, - is_recurrence): - return - elif completed is not None and created is not None: - # Line 5 - completed = reference_date + timedelta( - seconds=original_duration) - if (range_fn(reference_date - SECOND, - reference_date + SECOND, - is_recurrence) or - range_fn(completed - SECOND, completed + SECOND, - is_recurrence) or - range_fn(reference_date - SECOND, - reference_date + SECOND, is_recurrence) or - range_fn(completed - SECOND, completed + SECOND, - is_recurrence)): - return - elif completed is not None: - # Line 6 - if range_fn(reference_date - SECOND, - reference_date + SECOND, is_recurrence): - return - elif created is not None: - # Line 7 - if range_fn(reference_date, DATETIME_MAX, is_recurrence): - return - - elif child_name == "VJOURNAL": - for child, is_recurrence, recurrences in get_children( - vobject_item.vjournal_list): - dtstart = getattr(child, "dtstart", None) - - if dtstart is not None: - dtstart = dtstart.value - if child.rruleset: - dtstarts, infinity = getrruleset(child, recurrences) - if infinity: - return - else: - dtstarts = (dtstart,) - - for dtstart in dtstarts: - dtstart_is_datetime = isinstance(dtstart, datetime) - dtstart = _date_to_datetime(dtstart) - - if dtstart_is_datetime: - # Line 1 - if range_fn(dtstart, dtstart + SECOND, is_recurrence): - return - else: - # Line 2 - if range_fn(dtstart, dtstart + DAY, is_recurrence): - return - - else: - # Match a property - child = getattr(vobject_item, child_name.lower()) - if isinstance(child, date): - range_fn(child, child + DAY, False) - elif isinstance(child, datetime): - range_fn(child, child + SECOND, False) - - -def _text_match(vobject_item, filter_, child_name, ns, attrib_name=None): - """Check whether the ``item`` matches the text-match ``filter_``. - - See rfc4791-9.7.5. - - """ - # TODO: collations are not supported, but the default ones needed - # for DAV servers are actually pretty useless. Texts are lowered to - # be case-insensitive, almost as the "i;ascii-casemap" value. - text = next(filter_.itertext()).lower() - match_type = "contains" - if ns == "CR": - match_type = filter_.get("match-type", match_type) - - def match(value): - value = value.lower() - if match_type == "equals": - return value == text - if match_type == "contains": - return text in value - if match_type == "starts-with": - return value.startswith(text) - if match_type == "ends-with": - return value.endswith(text) - raise ValueError("Unexpected text-match match-type: %r" % match_type) - - children = getattr(vobject_item, "%s_list" % child_name, []) - if attrib_name: - condition = any( - match(attrib) for child in children - for attrib in child.params.get(attrib_name, [])) - else: - condition = any(match(child.value) for child in children) - if filter_.get("negate-condition") == "yes": - return not condition - else: - return condition - - -def _param_filter_match(vobject_item, filter_, parent_name, ns): - """Check whether the ``item`` matches the param-filter ``filter_``. - - See rfc4791-9.7.3. - - """ - name = filter_.get("name").upper() - children = getattr(vobject_item, "%s_list" % parent_name, []) - condition = any(name in child.params for child in children) - if len(filter_): - if filter_[0].tag == _tag(ns, "text-match"): - return condition and _text_match( - vobject_item, filter_[0], parent_name, ns, name) - elif filter_[0].tag == _tag(ns, "is-not-defined"): - return not condition - else: - return condition - - -def simplify_prefilters(filters, collection_tag="VCALENDAR"): - """Creates a simplified condition from ``filters``. - - Returns a tuple (``tag``, ``start``, ``end``, ``simple``) where ``tag`` is - a string or None (match all) and ``start`` and ``end`` are POSIX - timestamps (as int). ``simple`` is a bool that indicates that ``filters`` - and the simplified condition are identical. - - """ - flat_filters = tuple(chain.from_iterable(filters)) - simple = len(flat_filters) <= 1 - for col_filter in flat_filters: - if collection_tag != "VCALENDAR": - simple = False - break - if (col_filter.tag != _tag("C", "comp-filter") or - col_filter.get("name").upper() != "VCALENDAR"): - simple = False - continue - simple &= len(col_filter) <= 1 - for comp_filter in col_filter: - if comp_filter.tag != _tag("C", "comp-filter"): - simple = False - continue - tag = comp_filter.get("name").upper() - if comp_filter.find(_tag("C", "is-not-defined")) is not None: - simple = False - continue - simple &= len(comp_filter) <= 1 - for time_filter in comp_filter: - if tag not in ("VTODO", "VEVENT", "VJOURNAL"): - simple = False - break - if time_filter.tag != _tag("C", "time-range"): - simple = False - continue - start = time_filter.get("start") - end = time_filter.get("end") - if start: - start = math.floor(datetime.strptime( - start, "%Y%m%dT%H%M%SZ").replace( - tzinfo=timezone.utc).timestamp()) - else: - start = TIMESTAMP_MIN - if end: - end = math.ceil(datetime.strptime( - end, "%Y%m%dT%H%M%SZ").replace( - tzinfo=timezone.utc).timestamp()) - else: - end = TIMESTAMP_MAX - return tag, start, end, simple - return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple - return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple - - -def get_content_type(item): - """Get the content-type of an item with charset and component parameters. - """ - mimetype = OBJECT_MIMETYPES[item.name] - encoding = item.collection.configuration.get("encoding", "request") - tag = item.component_name - content_type = "%s;charset=%s" % (mimetype, encoding) - if tag: - content_type += ";component=%s" % tag - return content_type - - -def find_tag(vobject_item): - """Find component name from ``vobject_item``.""" - if vobject_item.name == "VCALENDAR": - for component in vobject_item.components(): - if component.name != "VTIMEZONE": - return component.name - return None - - -def find_tag_and_time_range(vobject_item): - """Find component name and enclosing time range from ``vobject item``. - - Returns a tuple (``tag``, ``start``, ``end``) where ``tag`` is a string - and ``start`` and ``end`` are POSIX timestamps (as int). - - This is intened to be used for matching against simplified prefilters. - - """ - tag = find_tag(vobject_item) - if not tag: - return (None, TIMESTAMP_MIN, TIMESTAMP_MAX) - start = end = None - - def range_fn(range_start, range_end, is_recurrence): - nonlocal start, end - if start is None or range_start < start: - start = range_start - if end is None or end < range_end: - end = range_end - return False - - def infinity_fn(range_start): - nonlocal start, end - if start is None or range_start < start: - start = range_start - end = DATETIME_MAX - return True - - _visit_time_ranges(vobject_item, tag, range_fn, infinity_fn) - if start is None: - start = DATETIME_MIN - if end is None: - end = DATETIME_MAX - return tag, math.floor(start.timestamp()), math.ceil(end.timestamp()) - - -def name_from_path(path, collection): - """Return Radicale item name from ``path``.""" - path = path.strip("/") + "/" - start = collection.path + "/" - if not path.startswith(start): - raise ValueError("%r doesn't start with %r" % (path, start)) - name = path[len(start):][:-1] - if name and not storage.is_safe_path_component(name): - raise ValueError("%r is not a component in collection %r" % - (name, collection.path)) - return name - - -def props_from_request(xml_request, actions=("set", "remove")): - """Return a list of properties as a dictionary.""" - result = OrderedDict() - if xml_request is None: - return result - - for action in actions: - action_element = xml_request.find(_tag("D", action)) - if action_element is not None: - break - else: - action_element = xml_request - - prop_element = action_element.find(_tag("D", "prop")) - if prop_element is not None: - for prop in prop_element: - if prop.tag == _tag("D", "resourcetype"): - for resource_type in prop: - if resource_type.tag == _tag("C", "calendar"): - result["tag"] = "VCALENDAR" - break - elif resource_type.tag == _tag("CR", "addressbook"): - result["tag"] = "VADDRESSBOOK" - break - elif prop.tag == _tag("C", "supported-calendar-component-set"): - result[_tag_from_clark(prop.tag)] = ",".join( - supported_comp.attrib["name"] - for supported_comp in prop - if supported_comp.tag == _tag("C", "comp")) - else: - result[_tag_from_clark(prop.tag)] = prop.text - - return result - - -def delete(base_prefix, path, collection, href=None): - """Read and answer DELETE requests. - - Read rfc4918-9.6 for info. - - """ - collection.delete(href) - - multistatus = ET.Element(_tag("D", "multistatus")) - response = ET.Element(_tag("D", "response")) - multistatus.append(response) - - href = ET.Element(_tag("D", "href")) - href.text = _href(base_prefix, path) - response.append(href) - - status = ET.Element(_tag("D", "status")) - status.text = _response(200) - response.append(status) - - return multistatus - - -def propfind(base_prefix, path, xml_request, read_collections, - write_collections, user): - """Read and answer PROPFIND requests. - - Read rfc4918-9.1 for info. - - The collections parameter is a list of collections that are to be included - in the output. - - """ - # A client may choose not to submit a request body. An empty PROPFIND - # request body MUST be treated as if it were an 'allprop' request. - top_tag = (xml_request[0] if xml_request is not None else - ET.Element(_tag("D", "allprop"))) - - props = () - allprop = False - propname = False - if top_tag.tag == _tag("D", "allprop"): - allprop = True - elif top_tag.tag == _tag("D", "propname"): - propname = True - elif top_tag.tag == _tag("D", "prop"): - props = [prop.tag for prop in top_tag] - - if _tag("D", "current-user-principal") in props and not user: - # Ask for authentication - # Returning the DAV:unauthenticated pseudo-principal as specified in - # RFC 5397 doesn't seem to work with DAVdroid. - return client.FORBIDDEN, None - - # Writing answer - multistatus = ET.Element(_tag("D", "multistatus")) - - collections = [] - for collection in write_collections: - collections.append(collection) - response = _propfind_response( - base_prefix, path, collection, props, user, write=True, - allprop=allprop, propname=propname) - if response: - multistatus.append(response) - for collection in read_collections: - if collection in collections: - continue - response = _propfind_response( - base_prefix, path, collection, props, user, write=False, - allprop=allprop, propname=propname) - if response: - multistatus.append(response) - - return client.MULTI_STATUS, multistatus - - -def _propfind_response(base_prefix, path, item, props, user, write=False, - propname=False, allprop=False): - """Build and return a PROPFIND response.""" - if propname and allprop or (props and (propname or allprop)): - raise ValueError("Only use one of props, propname and allprops") - is_collection = isinstance(item, storage.BaseCollection) - if is_collection: - is_leaf = item.get_meta("tag") in ("VADDRESSBOOK", "VCALENDAR") - collection = item - else: - collection = item.collection - - response = ET.Element(_tag("D", "response")) - - href = ET.Element(_tag("D", "href")) - if is_collection: - # Some clients expect collections to end with / - uri = "/%s/" % item.path if item.path else "/" - else: - uri = "/" + posixpath.join(collection.path, item.href) - - href.text = _href(base_prefix, uri) - response.append(href) - - propstat404 = ET.Element(_tag("D", "propstat")) - propstat200 = ET.Element(_tag("D", "propstat")) - response.append(propstat200) - - prop200 = ET.Element(_tag("D", "prop")) - propstat200.append(prop200) - - prop404 = ET.Element(_tag("D", "prop")) - propstat404.append(prop404) - - if propname or allprop: - props = [] - # Should list all properties that can be retrieved by the code below - props.append(_tag("D", "principal-collection-set")) - props.append(_tag("D", "current-user-principal")) - props.append(_tag("D", "current-user-privilege-set")) - props.append(_tag("D", "supported-report-set")) - props.append(_tag("D", "resourcetype")) - props.append(_tag("D", "owner")) - - if is_collection and collection.is_principal: - props.append(_tag("C", "calendar-user-address-set")) - props.append(_tag("D", "principal-URL")) - props.append(_tag("CR", "addressbook-home-set")) - props.append(_tag("C", "calendar-home-set")) - - if not is_collection or is_leaf: - props.append(_tag("D", "getetag")) - props.append(_tag("D", "getlastmodified")) - props.append(_tag("D", "getcontenttype")) - props.append(_tag("D", "getcontentlength")) - - if is_collection: - if is_leaf: - props.append(_tag("D", "displayname")) - props.append(_tag("D", "sync-token")) - if collection.get_meta("tag") == "VCALENDAR": - props.append(_tag("CS", "getctag")) - props.append(_tag("C", "supported-calendar-component-set")) - - meta = item.get_meta() - for tag in meta: - if tag == "tag": - continue - clark_tag = _tag_from_human(tag) - if clark_tag not in props: - props.append(clark_tag) - - if propname: - for tag in props: - prop200.append(ET.Element(tag)) - props = () - - for tag in props: - element = ET.Element(tag) - is404 = False - if tag == _tag("D", "getetag"): - if not is_collection or is_leaf: - element.text = item.etag - else: - is404 = True - elif tag == _tag("D", "getlastmodified"): - if not is_collection or is_leaf: - element.text = item.last_modified - else: - is404 = True - elif tag == _tag("D", "principal-collection-set"): - tag = ET.Element(_tag("D", "href")) - tag.text = _href(base_prefix, "/") - element.append(tag) - elif (tag in (_tag("C", "calendar-user-address-set"), - _tag("D", "principal-URL"), - _tag("CR", "addressbook-home-set"), - _tag("C", "calendar-home-set")) and - collection.is_principal and is_collection): - tag = ET.Element(_tag("D", "href")) - tag.text = _href(base_prefix, path) - element.append(tag) - elif tag == _tag("C", "supported-calendar-component-set"): - human_tag = _tag_from_clark(tag) - if is_collection and is_leaf: - meta = item.get_meta(human_tag) - if meta: - components = meta.split(",") - else: - components = ("VTODO", "VEVENT", "VJOURNAL") - for component in components: - comp = ET.Element(_tag("C", "comp")) - comp.set("name", component) - element.append(comp) - else: - is404 = True - elif tag == _tag("D", "current-user-principal"): - if user: - tag = ET.Element(_tag("D", "href")) - tag.text = _href(base_prefix, "/%s/" % user) - element.append(tag) - else: - element.append(ET.Element(_tag("D", "unauthenticated"))) - elif tag == _tag("D", "current-user-privilege-set"): - privileges = [("D", "read")] - if write: - privileges.append(("D", "all")) - privileges.append(("D", "write")) - privileges.append(("D", "write-properties")) - privileges.append(("D", "write-content")) - for ns, privilege_name in privileges: - privilege = ET.Element(_tag("D", "privilege")) - privilege.append(ET.Element(_tag(ns, privilege_name))) - element.append(privilege) - elif tag == _tag("D", "supported-report-set"): - # These 3 reports are not implemented - reports = [ - ("D", "expand-property"), - ("D", "principal-search-property-set"), - ("D", "principal-property-search")] - if is_collection and is_leaf: - reports.append(("D", "sync-collection")) - if item.get_meta("tag") == "VADDRESSBOOK": - reports.append(("CR", "addressbook-multiget")) - reports.append(("CR", "addressbook-query")) - elif item.get_meta("tag") == "VCALENDAR": - reports.append(("C", "calendar-multiget")) - reports.append(("C", "calendar-query")) - for ns, report_name in reports: - supported = ET.Element(_tag("D", "supported-report")) - report_tag = ET.Element(_tag("D", "report")) - supported_report_tag = ET.Element(_tag(ns, report_name)) - report_tag.append(supported_report_tag) - supported.append(report_tag) - element.append(supported) - elif tag == _tag("D", "getcontentlength"): - if not is_collection or is_leaf: - encoding = collection.configuration.get("encoding", "request") - element.text = str(len(item.serialize().encode(encoding))) - else: - is404 = True - elif tag == _tag("D", "owner"): - # return empty elment, if no owner available (rfc3744-5.1) - if collection.owner: - tag = ET.Element(_tag("D", "href")) - tag.text = _href(base_prefix, "/%s/" % collection.owner) - element.append(tag) - elif is_collection: - if tag == _tag("D", "getcontenttype"): - if is_leaf: - element.text = MIMETYPES[item.get_meta("tag")] - else: - is404 = True - elif tag == _tag("D", "resourcetype"): - if item.is_principal: - tag = ET.Element(_tag("D", "principal")) - element.append(tag) - if is_leaf: - if item.get_meta("tag") == "VADDRESSBOOK": - tag = ET.Element(_tag("CR", "addressbook")) - element.append(tag) - elif item.get_meta("tag") == "VCALENDAR": - tag = ET.Element(_tag("C", "calendar")) - element.append(tag) - tag = ET.Element(_tag("D", "collection")) - element.append(tag) - elif tag == _tag("RADICALE", "displayname"): - # Only for internal use by the web interface - displayname = item.get_meta("D:displayname") - if displayname is not None: - element.text = displayname - else: - is404 = True - elif tag == _tag("D", "displayname"): - displayname = item.get_meta("D:displayname") - if not displayname and is_leaf: - displayname = item.path - if displayname is not None: - element.text = displayname - else: - is404 = True - elif tag == _tag("CS", "getctag"): - if is_leaf: - element.text = item.etag - else: - is404 = True - elif tag == _tag("D", "sync-token"): - if is_leaf: - element.text, _ = item.sync() - else: - is404 = True - else: - human_tag = _tag_from_clark(tag) - meta = item.get_meta(human_tag) - if meta is not None: - element.text = meta - else: - is404 = True - # Not for collections - elif tag == _tag("D", "getcontenttype"): - element.text = get_content_type(item) - elif tag == _tag("D", "resourcetype"): - # resourcetype must be returned empty for non-collection elements - pass - else: - is404 = True - - if is404: - prop404.append(element) - else: - prop200.append(element) - - status200 = ET.Element(_tag("D", "status")) - status200.text = _response(200) - propstat200.append(status200) - - status404 = ET.Element(_tag("D", "status")) - status404.text = _response(404) - propstat404.append(status404) - if len(prop404): - response.append(propstat404) - - return response - - -def _add_propstat_to(element, tag, status_number): - """Add a PROPSTAT response structure to an element. - - The PROPSTAT answer structure is defined in rfc4918-9.1. It is added to the - given ``element``, for the following ``tag`` with the given - ``status_number``. - - """ - propstat = ET.Element(_tag("D", "propstat")) - element.append(propstat) - - prop = ET.Element(_tag("D", "prop")) - propstat.append(prop) - - clark_tag = tag if "{" in tag else _tag(*tag.split(":", 1)) - prop_tag = ET.Element(clark_tag) - prop.append(prop_tag) - - status = ET.Element(_tag("D", "status")) - status.text = _response(status_number) - propstat.append(status) - - -def proppatch(base_prefix, path, xml_request, collection): - """Read and answer PROPPATCH requests. - - Read rfc4918-9.2 for info. - - """ - props_to_set = props_from_request(xml_request, actions=("set",)) - props_to_remove = props_from_request(xml_request, actions=("remove",)) - - multistatus = ET.Element(_tag("D", "multistatus")) - response = ET.Element(_tag("D", "response")) - multistatus.append(response) - - href = ET.Element(_tag("D", "href")) - href.text = _href(base_prefix, path) - response.append(href) - - new_props = collection.get_meta() - for short_name, value in props_to_set.items(): - new_props[short_name] = value - _add_propstat_to(response, short_name, 200) - for short_name in props_to_remove: - try: - del new_props[short_name] - except KeyError: - pass - _add_propstat_to(response, short_name, 200) - storage.check_and_sanitize_props(new_props) - collection.set_meta_all(new_props) - - return multistatus - - -def report(base_prefix, path, xml_request, collection): - """Read and answer REPORT requests. - - Read rfc3253-3.6 for info. - - """ - logger = collection.logger - multistatus = ET.Element(_tag("D", "multistatus")) - if xml_request is None: - return client.MULTI_STATUS, multistatus - root = xml_request - if root.tag in ( - _tag("D", "principal-search-property-set"), - _tag("D", "principal-property-search"), - _tag("D", "expand-property")): - # We don't support searching for principals or indirect retrieving of - # properties, just return an empty result. - # InfCloud asks for expand-property reports (even if we don't announce - # support for them) and stops working if an error code is returned. - logger.warning("Unsupported REPORT method %r on %r requested", - _tag_from_clark(root.tag), path) - return client.MULTI_STATUS, multistatus - if (root.tag == _tag("C", "calendar-multiget") and - collection.get_meta("tag") != "VCALENDAR" or - root.tag == _tag("CR", "addressbook-multiget") and - collection.get_meta("tag") != "VADDRESSBOOK" or - root.tag == _tag("D", "sync-collection") and - collection.get_meta("tag") not in ("VADDRESSBOOK", "VCALENDAR")): - logger.warning("Invalid REPORT method %r on %r requested", - _tag_from_clark(root.tag), path) - return (client.PRECONDITION_FAILED, - _webdav_error("D", "supported-report")) - prop_element = root.find(_tag("D", "prop")) - props = ( - [prop.tag for prop in prop_element] - if prop_element is not None else []) - - if root.tag in ( - _tag("C", "calendar-multiget"), - _tag("CR", "addressbook-multiget")): - # Read rfc4791-7.9 for info - hreferences = set() - for href_element in root.findall(_tag("D", "href")): - href_path = storage.sanitize_path( - unquote(urlparse(href_element.text).path)) - if (href_path + "/").startswith(base_prefix + "/"): - hreferences.add(href_path[len(base_prefix):]) - else: - logger.warning("Skipping invalid path %r in REPORT request on " - "%r", href_path, path) - elif root.tag == _tag("D", "sync-collection"): - old_sync_token_element = root.find(_tag("D", "sync-token")) - old_sync_token = "" - if old_sync_token_element is not None and old_sync_token_element.text: - old_sync_token = old_sync_token_element.text.strip() - logger.debug("Client provided sync token: %r", old_sync_token) - try: - sync_token, names = collection.sync(old_sync_token) - except ValueError as e: - # Invalid sync token - logger.warning("Client provided invalid sync token %r: %s", - old_sync_token, e, exc_info=True) - return (client.PRECONDITION_FAILED, - _webdav_error("D", "valid-sync-token")) - hreferences = ("/" + posixpath.join(collection.path, n) for n in names) - # Append current sync token to response - sync_token_element = ET.Element(_tag("D", "sync-token")) - sync_token_element.text = sync_token - multistatus.append(sync_token_element) - else: - hreferences = (path,) - filters = ( - root.findall("./%s" % _tag("C", "filter")) + - root.findall("./%s" % _tag("CR", "filter"))) - - def retrieve_items(collection, hreferences, multistatus): - """Retrieves all items that are referenced in ``hreferences`` from - ``collection`` and adds 404 responses for missing and invalid items - to ``multistatus``.""" - collection_requested = False - - def get_names(): - """Extracts all names from references in ``hreferences`` and adds - 404 responses for invalid references to ``multistatus``. - If the whole collections is referenced ``collection_requested`` - gets set to ``True``.""" - nonlocal collection_requested - for hreference in hreferences: - try: - name = name_from_path(hreference, collection) - except ValueError as e: - logger.warning("Skipping invalid path %r in REPORT request" - " on %r: %s", hreference, path, e) - response = _item_response(base_prefix, hreference, - found_item=False) - multistatus.append(response) - continue - if name: - # Reference is an item - yield name - else: - # Reference is a collection - collection_requested = True - - for name, item in collection.get_multi2(get_names()): - if not item: - uri = "/" + posixpath.join(collection.path, name) - response = _item_response(base_prefix, uri, - found_item=False) - multistatus.append(response) - else: - yield item, False - if collection_requested: - yield from collection.get_all_filtered(filters) - - def match(item, filter_): - tag = collection.get_meta("tag") - if (tag == "VCALENDAR" and filter_.tag != _tag("C", filter_)): - if len(filter_) == 0: - return True - if len(filter_) > 1: - raise ValueError("Filter with %d children" % len(filter_)) - if filter_[0].tag != _tag("C", "comp-filter"): - raise ValueError("Unexpected %r in filter" % filter_[0].tag) - return _comp_match(item, filter_[0]) - if tag == "VADDRESSBOOK" and filter_.tag != _tag("CR", filter_): - for child in filter_: - if child.tag != _tag("CR", "prop-filter"): - raise ValueError("Unexpected %r in filter" % child.tag) - test = filter_.get("test", "anyof") - if test == "anyof": - return any(_prop_match(item.item, f, "CR") for f in filter_) - if test == "allof": - return all(_prop_match(item.item, f, "CR") for f in filter_) - raise ValueError("Unsupported filter test: %r" % test) - return all(_prop_match(item.item, f, "CR") for f in filter_) - raise ValueError("unsupported filter %r for %r" % (filter_.tag, tag)) - - for item, filters_matched in retrieve_items(collection, hreferences, - multistatus): - if filters and not filters_matched: - try: - if not all(match(item, filter_) for filter_ in filters): - continue - except ValueError as e: - raise ValueError("Failed to filter item %r from %r: %s" % - (item.href, collection.path, e)) from e - except Exception as e: - raise RuntimeError("Failed to filter item %r from %r: %s" % - (item.href, collection.path, e)) from e - - found_props = [] - not_found_props = [] - - for tag in props: - element = ET.Element(tag) - if tag == _tag("D", "getetag"): - element.text = item.etag - found_props.append(element) - elif tag == _tag("D", "getcontenttype"): - element.text = get_content_type(item) - found_props.append(element) - elif tag in ( - _tag("C", "calendar-data"), - _tag("CR", "address-data")): - element.text = item.serialize() - found_props.append(element) - else: - not_found_props.append(element) - - uri = "/" + posixpath.join(collection.path, item.href) - multistatus.append(_item_response( - base_prefix, uri, found_props=found_props, - not_found_props=not_found_props, found_item=True)) - - return client.MULTI_STATUS, multistatus - - -def _item_response(base_prefix, href, found_props=(), not_found_props=(), - found_item=True): - response = ET.Element(_tag("D", "response")) - - href_tag = ET.Element(_tag("D", "href")) - href_tag.text = _href(base_prefix, href) - response.append(href_tag) - - if found_item: - 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) - response.append(status) - - return response