1
0
Fork 0
mirror of https://github.com/Kozea/Radicale.git synced 2025-09-12 20:30:57 +00:00

Fix merge conflicts.

This commit is contained in:
Dipl. Ing. Péter Varkoly 2024-08-25 14:11:48 +02:00
commit 19e5972b4f
76 changed files with 4135 additions and 1365 deletions

View file

@ -2,7 +2,8 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -29,13 +30,11 @@ import os
import threading
from typing import Iterable, Optional, cast
import pkg_resources
from radicale import config, log, types
from radicale import config, log, types, utils
from radicale.app import Application
from radicale.log import logger
VERSION: str = pkg_resources.get_distribution("radicale").version
VERSION: str = utils.package_version("radicale")
_application_instance: Optional[Application] = None
_application_config_path: Optional[str] = None
@ -53,11 +52,16 @@ def _get_application_instance(config_path: str, wsgi_errors: types.ErrorStream
configuration = config.load(config.parse_compound_paths(
config.DEFAULT_CONFIG_PATH,
config_path))
log.set_level(cast(str, configuration.get("logging", "level")))
log.set_level(cast(str, configuration.get("logging", "level")), configuration.get("logging", "backtrace_on_debug"))
# Log configuration after logger is configured
default_config_active = True
for source, miss in configuration.sources():
logger.info("%s %s", "Skipped missing" if miss
logger.info("%s %s", "Skipped missing/unreadable" if miss
else "Loaded", source)
if not miss and source != "default config":
default_config_active = False
if default_config_active:
logger.warning("%s", "No config file found/readable - only default config is active")
_application_instance = Application(configuration)
if _application_config_path != config_path:
raise ValueError("RADICALE_CONFIG must not change: %r != %r" %

View file

@ -1,6 +1,7 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2011-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -141,7 +142,7 @@ def run() -> None:
# Preliminary configure logging
with contextlib.suppress(ValueError):
log.set_level(config.DEFAULT_CONFIG_SCHEMA["logging"]["level"]["type"](
vars(args_ns).get("c:logging:level", "")))
vars(args_ns).get("c:logging:level", "")), True)
# Update Radicale configuration according to arguments
arguments_config: types.MUTABLE_CONFIG = {}
@ -164,11 +165,17 @@ def run() -> None:
sys.exit(1)
# Configure logging
log.set_level(cast(str, configuration.get("logging", "level")))
log.set_level(cast(str, configuration.get("logging", "level")), configuration.get("logging", "backtrace_on_debug"))
# Log configuration after logger is configured
default_config_active = True
for source, miss in configuration.sources():
logger.info("%s %s", "Skipped missing" if miss else "Loaded", source)
logger.info("%s %s", "Skipped missing/unreadable" if miss else "Loaded", source)
if not miss and source != "default config":
default_config_active = False
if default_config_active:
logger.warning("%s", "No config file found/readable - only default config is active")
if args_ns.verify_storage:
logger.info("Verifying storage")
@ -176,7 +183,7 @@ def run() -> None:
storage_ = storage.load(configuration)
with storage_.acquire_lock("r"):
if not storage_.verify():
logger.critical("Storage verifcation failed")
logger.critical("Storage verification failed")
sys.exit(1)
except Exception as e:
logger.critical("An exception occurred during storage "
@ -198,7 +205,7 @@ def run() -> None:
server.serve(configuration, shutdown_socket_out)
except Exception as e:
logger.critical("An exception occurred during server startup: %s", e,
exc_info=True)
exc_info=False)
sys.exit(1)

View file

@ -3,6 +3,7 @@
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -68,6 +69,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
_max_content_length: int
_auth_realm: str
_extra_headers: Mapping[str, str]
_permit_delete_collection: bool
def __init__(self, configuration: config.Configuration) -> None:
"""Initialize Application.
@ -79,11 +81,16 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
"""
super().__init__(configuration)
self._mask_passwords = configuration.get("logging", "mask_passwords")
self._bad_put_request_content = configuration.get("logging", "bad_put_request_content")
self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
self._auth_delay = configuration.get("auth", "delay")
self._internal_server = configuration.get("server", "_internal_server")
self._max_content_length = configuration.get(
"server", "max_content_length")
self._auth_realm = configuration.get("auth", "realm")
self._permit_delete_collection = configuration.get("rights", "permit_delete_collection")
logger.info("permit delete of collection: %s", self._permit_delete_collection)
self._extra_headers = dict()
for key in self.configuration.options("headers"):
self._extra_headers[key] = configuration.get("headers", key)
@ -136,7 +143,10 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
answers = []
if answer is not None:
if isinstance(answer, str):
logger.debug("Response content:\n%s", answer)
if self._response_content_on_debug:
logger.debug("Response content:\n%s", answer)
else:
logger.debug("Response content: suppressed by config/option [auth] response_content_on_debug")
headers["Content-Type"] += "; charset=%s" % self._encoding
answer = answer.encode(self._encoding)
accept_encoding = [
@ -182,8 +192,11 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
logger.info("%s request for %r%s received from %s%s",
request_method, unsafe_path, depthinfo,
remote_host, remote_useragent)
logger.debug("Request headers:\n%s",
pprint.pformat(self._scrub_headers(environ)))
if self._request_header_on_debug:
logger.debug("Request header:\n%s",
pprint.pformat(self._scrub_headers(environ)))
else:
logger.debug("Request header: suppressed by config/option [auth] request_header_on_debug")
# SCRIPT_NAME is already removed from PATH_INFO, according to the
# WSGI specification.
@ -219,7 +232,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
path.rstrip("/").endswith("/.well-known/carddav")):
return response(*httputils.redirect(
base_prefix + "/", client.MOVED_PERMANENTLY))
# Return NOT FOUND for all other paths containing ".well-knwon"
# Return NOT FOUND for all other paths containing ".well-known"
if path.endswith("/.well-known") or "/.well-known/" in path:
return response(*httputils.NOT_FOUND)
@ -270,7 +283,14 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
if "W" in self._rights.authorization(user, principal_path):
with self._storage.acquire_lock("w", user):
try:
self._storage.create_collection(principal_path)
new_coll = self._storage.create_collection(principal_path)
if new_coll:
jsn_coll = self.configuration.get("storage", "predefined_collections")
for (name_coll, props) in jsn_coll.items():
try:
self._storage.create_collection(principal_path + name_coll, props=props)
except ValueError as e:
logger.warning("Failed to create predefined collection %r: %s", name_coll, e)
except ValueError as e:
logger.warning("Failed to create principal "
"collection %r: %s", user, e)

View file

@ -1,5 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2020 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -21,8 +22,8 @@ import sys
import xml.etree.ElementTree as ET
from typing import Optional
from radicale import (auth, config, httputils, pathutils, rights, storage,
types, web, xmlutils)
from radicale import (auth, config, hook, httputils, pathutils, rights,
storage, types, web, xmlutils)
from radicale.log import logger
# HACK: https://github.com/tiran/defusedxml/issues/54
@ -38,6 +39,8 @@ class ApplicationBase:
_rights: rights.BaseRights
_web: web.BaseWeb
_encoding: str
_permit_delete_collection: bool
_hook: hook.BaseHook
def __init__(self, configuration: config.Configuration) -> None:
self.configuration = configuration
@ -46,6 +49,9 @@ class ApplicationBase:
self._rights = rights.load(configuration)
self._web = web.load(configuration)
self._encoding = configuration.get("encoding", "request")
self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content")
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
self._hook = hook.load(configuration)
def _read_xml_request_body(self, environ: types.WSGIEnviron
) -> Optional[ET.Element]:
@ -66,8 +72,11 @@ class ApplicationBase:
def _xml_response(self, xml_content: ET.Element) -> bytes:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response content:\n%s",
xmlutils.pretty_xml(xml_content))
if self._response_content_on_debug:
logger.debug("Response content:\n%s",
xmlutils.pretty_xml(xml_content))
else:
logger.debug("Response content: suppressed by config/option [auth] response_content_on_debug")
f = io.BytesIO()
ET.ElementTree(xml_content).write(f, encoding=self._encoding,
xml_declaration=True)

View file

@ -23,6 +23,7 @@ from typing import Optional
from radicale import httputils, storage, types, xmlutils
from radicale.app.base import Access, ApplicationBase
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection,
@ -67,12 +68,33 @@ class ApplicationPartDelete(ApplicationBase):
if if_match not in ("*", item.etag):
# ETag precondition not verified, do not delete item
return httputils.PRECONDITION_FAILED
hook_notification_item_list = []
if isinstance(item, storage.BaseCollection):
xml_answer = xml_delete(base_prefix, path, item)
if self._permit_delete_collection:
for i in item.get_all():
hook_notification_item_list.append(
HookNotificationItem(
HookNotificationItemTypes.DELETE,
access.path,
i.uid
)
)
xml_answer = xml_delete(base_prefix, path, item)
else:
return httputils.NOT_ALLOWED
else:
assert item.collection is not None
assert item.href is not None
hook_notification_item_list.append(
HookNotificationItem(
HookNotificationItemTypes.DELETE,
access.path,
item.uid
)
)
xml_answer = xml_delete(
base_prefix, path, item.collection, item.href)
for notification_item in hook_notification_item_list:
self._hook.notify(notification_item)
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
return client.OK, headers, self._xml_response(xml_answer)

View file

@ -45,8 +45,8 @@ def propose_filename(collection: storage.BaseCollection) -> str:
class ApplicationPartGet(ApplicationBase):
def _content_disposition_attachement(self, filename: str) -> str:
value = "attachement"
def _content_disposition_attachment(self, filename: str) -> str:
value = "attachment"
try:
encoded_filename = quote(filename, encoding=self._encoding)
except UnicodeEncodeError:
@ -91,7 +91,7 @@ class ApplicationPartGet(ApplicationBase):
return (httputils.NOT_ALLOWED if limited_access else
httputils.DIRECTORY_LISTING)
content_type = xmlutils.MIMETYPES[item.tag]
content_disposition = self._content_disposition_attachement(
content_disposition = self._content_disposition_attachment(
propose_filename(item))
elif limited_access:
return httputils.NOT_ALLOWED

View file

@ -52,8 +52,12 @@ class ApplicationPartMkcol(ApplicationBase):
logger.warning(
"Bad MKCOL request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
if (props.get("tag") and "w" not in permissions or
not props.get("tag") and "W" not in permissions):
collection_type = props.get("tag") or "UNKNOWN"
if props.get("tag") and "w" not in permissions:
logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'w'")
return httputils.NOT_ALLOWED
if not props.get("tag") and "W" not in permissions:
logger.warning("MKCOL request %r (type:%s): %s", path, collection_type, "rejected because of missing rights 'W'")
return httputils.NOT_ALLOWED
with self._storage.acquire_lock("w", user):
item = next(iter(self._storage.discover(path)), None)
@ -71,6 +75,7 @@ class ApplicationPartMkcol(ApplicationBase):
self._storage.create_collection(path, props=props)
except ValueError as e:
logger.warning(
"Bad MKCOL request on %r: %s", path, e, exc_info=True)
"Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)
return httputils.BAD_REQUEST
logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful")
return client.CREATED, {}, None

View file

@ -18,6 +18,7 @@
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import posixpath
import re
from http import client
from urllib.parse import urlparse
@ -26,6 +27,22 @@ from radicale.app.base import Access, ApplicationBase
from radicale.log import logger
def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False):
if environ.get("HTTP_X_FORWARDED_HOST"):
host = environ["HTTP_X_FORWARDED_HOST"]
proto = environ.get("HTTP_X_FORWARDED_PROTO") or "http"
port = "443" if proto == "https" else "80"
port = environ["HTTP_X_FORWARDED_PORT"] or port
else:
host = environ.get("HTTP_HOST") or environ["SERVER_NAME"]
proto = environ["wsgi.url_scheme"]
port = environ["SERVER_PORT"]
if (not force_port and port == ("443" if proto == "https" else "80") or
re.search(r":\d+$", host)):
return host
return host + ":" + port
class ApplicationPartMove(ApplicationBase):
def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str,
@ -33,7 +50,11 @@ class ApplicationPartMove(ApplicationBase):
"""Manage MOVE request."""
raw_dest = environ.get("HTTP_DESTINATION", "")
to_url = urlparse(raw_dest)
if to_url.netloc != environ["HTTP_HOST"]:
to_netloc_with_port = to_url.netloc
if to_url.port is None:
to_netloc_with_port += (":443" if to_url.scheme == "https"
else ":80")
if to_netloc_with_port != get_server_netloc(environ, force_port=True):
logger.info("Unsupported destination address: %r", raw_dest)
# Remote destination server, not supported
return httputils.REMOTE_DESTINATION

View file

@ -85,7 +85,7 @@ def xml_propfind_response(
if isinstance(item, storage.BaseCollection):
is_collection = True
is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR")
is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR", "VSUBSCRIBED")
collection = item
# Some clients expect collections to end with `/`
uri = pathutils.unstrip_path(item.path, True)
@ -259,6 +259,10 @@ def xml_propfind_response(
child_element = ET.Element(
xmlutils.make_clark("C:calendar"))
element.append(child_element)
elif collection.tag == "VSUBSCRIBED":
child_element = ET.Element(
xmlutils.make_clark("CS:subscribed"))
element.append(child_element)
child_element = ET.Element(xmlutils.make_clark("D:collection"))
element.append(child_element)
elif tag == xmlutils.make_clark("RADICALE:displayname"):
@ -268,6 +272,12 @@ def xml_propfind_response(
element.text = displayname
else:
is404 = True
elif tag == xmlutils.make_clark("RADICALE:getcontentcount"):
# Only for internal use by the web interface
if isinstance(item, storage.BaseCollection) and not collection.is_principal:
element.text = str(sum(1 for x in item.get_all()))
else:
is404 = True
elif tag == xmlutils.make_clark("D:displayname"):
displayname = collection.get_meta("D:displayname")
if not displayname and is_leaf:
@ -286,6 +296,13 @@ def xml_propfind_response(
element.text, _ = collection.sync()
else:
is404 = True
elif tag == xmlutils.make_clark("CS:source"):
if is_leaf:
child_element = ET.Element(xmlutils.make_clark("D:href"))
child_element.text = collection.get_meta('CS:source')
element.append(child_element)
else:
is404 = True
else:
human_tag = xmlutils.make_human_tag(tag)
tag_text = collection.get_meta(human_tag)
@ -305,13 +322,13 @@ def xml_propfind_response(
responses[404 if is404 else 200].append(element)
for status_code, childs in responses.items():
if not childs:
for status_code, children in responses.items():
if not children:
continue
propstat = ET.Element(xmlutils.make_clark("D:propstat"))
response.append(propstat)
prop = ET.Element(xmlutils.make_clark("D:prop"))
prop.extend(childs)
prop.extend(children)
propstat.append(prop)
status = ET.Element(xmlutils.make_clark("D:status"))
status.text = xmlutils.make_response(status_code)

View file

@ -22,9 +22,12 @@ import xml.etree.ElementTree as ET
from http import client
from typing import Dict, Optional, cast
import defusedxml.ElementTree as DefusedET
import radicale.item as radicale_item
from radicale import httputils, storage, types, xmlutils
from radicale.app.base import Access, ApplicationBase
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
from radicale.log import logger
@ -93,6 +96,16 @@ class ApplicationPartProppatch(ApplicationBase):
try:
xml_answer = xml_proppatch(base_prefix, path, xml_content,
item)
if xml_content is not None:
hook_notification_item = HookNotificationItem(
HookNotificationItemTypes.CPATCH,
access.path,
DefusedET.tostring(
xml_content,
encoding=self._encoding
).decode(encoding=self._encoding)
)
self._hook.notify(hook_notification_item)
except ValueError as e:
logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)

View file

@ -3,6 +3,7 @@
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -30,6 +31,7 @@ import vobject
import radicale.item as radicale_item
from radicale import httputils, pathutils, rights, storage, types, xmlutils
from radicale.app.base import Access, ApplicationBase
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
from radicale.log import logger
MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in
@ -132,7 +134,7 @@ class ApplicationPartPut(ApplicationBase):
try:
content = httputils.read_request_body(self.configuration, environ)
except RuntimeError as e:
logger.warning("Bad PUT request on %r: %s", path, e, exc_info=True)
logger.warning("Bad PUT request on %r (read_request_body): %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
except socket.timeout:
logger.debug("Client timed out", exc_info=True)
@ -144,7 +146,11 @@ class ApplicationPartPut(ApplicationBase):
vobject_items = radicale_item.read_components(content or "")
except Exception as e:
logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
"Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
if self._log_bad_put_request_content:
logger.warning("Bad PUT request content of %r:\n%s", path, content)
else:
logger.debug("Bad PUT request content: suppressed by config/option [auth] bad_put_request_content")
return httputils.BAD_REQUEST
(prepared_items, prepared_tag, prepared_write_whole_collection,
prepared_props, prepared_exc_info) = prepare(
@ -198,7 +204,7 @@ class ApplicationPartPut(ApplicationBase):
props = prepared_props
if prepared_exc_info:
logger.warning(
"Bad PUT request on %r: %s", path, prepared_exc_info[1],
"Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1],
exc_info=prepared_exc_info)
return httputils.BAD_REQUEST
@ -206,9 +212,16 @@ class ApplicationPartPut(ApplicationBase):
try:
etag = self._storage.create_collection(
path, prepared_items, props).etag
for item in prepared_items:
hook_notification_item = HookNotificationItem(
HookNotificationItemTypes.UPSERT,
access.path,
item.serialize()
)
self._hook.notify(hook_notification_item)
except ValueError as e:
logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
"Bad PUT request on %r (create_collection): %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
else:
assert not isinstance(item, storage.BaseCollection)
@ -222,9 +235,15 @@ class ApplicationPartPut(ApplicationBase):
href = posixpath.basename(pathutils.strip_path(path))
try:
etag = parent_item.upload(href, prepared_item).etag
hook_notification_item = HookNotificationItem(
HookNotificationItemTypes.UPSERT,
access.path,
prepared_item.serialize()
)
self._hook.notify(hook_notification_item)
except ValueError as e:
logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
"Bad PUT request on %r (upload): %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
headers = {"ETag": etag}

View file

@ -18,13 +18,20 @@
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import contextlib
import copy
import datetime
import posixpath
import socket
import xml.etree.ElementTree as ET
from http import client
from typing import Callable, Iterable, Iterator, Optional, Sequence, Tuple
from typing import (Any, Callable, Iterable, Iterator, List, Optional,
Sequence, Tuple, Union)
from urllib.parse import unquote, urlparse
import vobject
import vobject.base
from vobject.base import ContentLine
import radicale.item as radicale_item
from radicale import httputils, pathutils, storage, types, xmlutils
from radicale.app.base import Access, ApplicationBase
@ -32,11 +39,110 @@ from radicale.item import filter as radicale_filter
from radicale.log import logger
def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
collection: storage.BaseCollection, encoding: str,
unlock_storage_fn: Callable[[], None],
max_occurrence: int
) -> Tuple[int, Union[ET.Element, str]]:
# NOTE: this function returns both an Element and a string because
# free-busy reports are an edge-case on the return type according
# to the spec.
multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
if xml_request is None:
return client.MULTI_STATUS, multistatus
root = xml_request
if (root.tag == xmlutils.make_clark("C:free-busy-query") and
collection.tag != "VCALENDAR"):
logger.warning("Invalid REPORT method %r on %r requested",
xmlutils.make_human_tag(root.tag), path)
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
time_range_element = root.find(xmlutils.make_clark("C:time-range"))
assert isinstance(time_range_element, ET.Element)
# Build a single filter from the free busy query for retrieval
# TODO: filter for VFREEBUSY in additional to VEVENT but
# test_filter doesn't support that yet.
vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
attrib={'name': 'VEVENT'})
vevent_cf_element.append(time_range_element)
vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
attrib={'name': 'VCALENDAR'})
vcalendar_cf_element.append(vevent_cf_element)
filter_element = ET.Element(xmlutils.make_clark("C:filter"))
filter_element.append(vcalendar_cf_element)
filters = (filter_element,)
# First pull from storage
retrieved_items = list(collection.get_filtered(filters))
# !!! Don't access storage after this !!!
unlock_storage_fn()
cal = vobject.iCalendar()
collection_tag = collection.tag
while retrieved_items:
# Second filtering before evaluating occurrences.
# ``item.vobject_item`` might be accessed during filtering.
# Don't keep reference to ``item``, because VObject requires a lot of
# memory.
item, filter_matched = retrieved_items.pop(0)
if not filter_matched:
try:
if not test_filter(collection_tag, item, filter_element):
continue
except ValueError as e:
raise ValueError("Failed to free-busy filter item %r from %r: %s" %
(item.href, collection.path, e)) from e
except Exception as e:
raise RuntimeError("Failed to free-busy filter item %r from %r: %s" %
(item.href, collection.path, e)) from e
fbtype = None
if item.component_name == 'VEVENT':
transp = getattr(item.vobject_item.vevent, 'transp', None)
if transp and transp.value != 'OPAQUE':
continue
status = getattr(item.vobject_item.vevent, 'status', None)
if not status or status.value == 'CONFIRMED':
fbtype = 'BUSY'
elif status.value == 'CANCELLED':
fbtype = 'FREE'
elif status.value == 'TENTATIVE':
fbtype = 'BUSY-TENTATIVE'
else:
# Could do fbtype = status.value for x-name, I prefer this
fbtype = 'BUSY'
# TODO: coalesce overlapping periods
if max_occurrence > 0:
n_occurrences = max_occurrence+1
else:
n_occurrences = 0
occurrences = radicale_filter.time_range_fill(item.vobject_item,
time_range_element,
"VEVENT",
n=n_occurrences)
if len(occurrences) >= max_occurrence:
raise ValueError("FREEBUSY occurrences limit of {} hit"
.format(max_occurrence))
for occurrence in occurrences:
vfb = cal.add('vfreebusy')
vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value
vfb.add('dtstart').value, vfb.add('dtend').value = occurrence
if fbtype:
vfb.add('fbtype').value = fbtype
return (client.OK, cal.serialize())
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
collection: storage.BaseCollection, encoding: str,
unlock_storage_fn: Callable[[], None]
) -> Tuple[int, ET.Element]:
"""Read and answer REPORT requests.
"""Read and answer REPORT requests that return XML.
Read rfc3253-3.6 for info.
@ -64,9 +170,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
logger.warning("Invalid REPORT method %r on %r requested",
xmlutils.make_human_tag(root.tag), path)
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
prop_element = root.find(xmlutils.make_clark("D:prop"))
props = ([prop.tag for prop in prop_element]
if prop_element is not None else [])
props: Union[ET.Element, List] = root.find(xmlutils.make_clark("D:prop")) or []
hreferences: Iterable[str]
if root.tag in (
@ -138,19 +243,40 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
found_props = []
not_found_props = []
for tag in props:
element = ET.Element(tag)
if tag == xmlutils.make_clark("D:getetag"):
for prop in props:
element = ET.Element(prop.tag)
if prop.tag == xmlutils.make_clark("D:getetag"):
element.text = item.etag
found_props.append(element)
elif tag == xmlutils.make_clark("D:getcontenttype"):
elif prop.tag == xmlutils.make_clark("D:getcontenttype"):
element.text = xmlutils.get_content_type(item, encoding)
found_props.append(element)
elif tag in (
elif prop.tag in (
xmlutils.make_clark("C:calendar-data"),
xmlutils.make_clark("CR:address-data")):
element.text = item.serialize()
found_props.append(element)
expand = prop.find(xmlutils.make_clark("C:expand"))
if expand is not None:
start = expand.get('start')
end = expand.get('end')
if (start is None) or (end is None):
return client.FORBIDDEN, \
xmlutils.webdav_error("C:expand")
start = datetime.datetime.strptime(
start, '%Y%m%dT%H%M%SZ'
).replace(tzinfo=datetime.timezone.utc)
end = datetime.datetime.strptime(
end, '%Y%m%dT%H%M%SZ'
).replace(tzinfo=datetime.timezone.utc)
expanded_element = _expand(
element, copy.copy(item), start, end)
found_props.append(expanded_element)
else:
found_props.append(element)
else:
not_found_props.append(element)
@ -164,6 +290,111 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
return client.MULTI_STATUS, multistatus
def _expand(
element: ET.Element,
item: radicale_item.Item,
start: datetime.datetime,
end: datetime.datetime,
) -> ET.Element:
dt_format = '%Y%m%dT%H%M%SZ'
if type(item.vobject_item.vevent.dtstart.value) is datetime.date:
# If an event comes to us with a dt_start specified as a date
# then in the response we return the date, not datetime
dt_format = '%Y%m%d'
expanded_item, rruleset = _make_vobject_expanded_item(item, dt_format)
if rruleset:
recurrences = rruleset.between(start, end, inc=True)
expanded: vobject.base.Component = copy.copy(expanded_item.vobject_item)
is_expanded_filled: bool = False
for recurrence_dt in recurrences:
recurrence_utc = recurrence_dt.astimezone(datetime.timezone.utc)
vevent = copy.deepcopy(expanded.vevent)
vevent.recurrence_id = ContentLine(
name='RECURRENCE-ID',
value=recurrence_utc.strftime(dt_format), params={}
)
if is_expanded_filled is False:
expanded.vevent = vevent
is_expanded_filled = True
else:
expanded.add(vevent)
element.text = expanded.serialize()
else:
element.text = expanded_item.vobject_item.serialize()
return element
def _make_vobject_expanded_item(
item: radicale_item.Item,
dt_format: str,
) -> Tuple[radicale_item.Item, Optional[Any]]:
# https://www.rfc-editor.org/rfc/rfc4791#section-9.6.5
# The returned calendar components MUST NOT use recurrence
# properties (i.e., EXDATE, EXRULE, RDATE, and RRULE) and MUST NOT
# have reference to or include VTIMEZONE components. Date and local
# time with reference to time zone information MUST be converted
# into date with UTC time.
item = copy.copy(item)
vevent = item.vobject_item.vevent
if type(vevent.dtstart.value) is datetime.date:
start_utc = datetime.datetime.fromordinal(
vevent.dtstart.value.toordinal()
).replace(tzinfo=datetime.timezone.utc)
else:
start_utc = vevent.dtstart.value.astimezone(datetime.timezone.utc)
vevent.dtstart = ContentLine(name='DTSTART', value=start_utc, params=[])
dt_end = getattr(vevent, 'dtend', None)
if dt_end is not None:
if type(vevent.dtend.value) is datetime.date:
end_utc = datetime.datetime.fromordinal(
dt_end.value.toordinal()
).replace(tzinfo=datetime.timezone.utc)
else:
end_utc = dt_end.value.astimezone(datetime.timezone.utc)
vevent.dtend = ContentLine(name='DTEND', value=end_utc, params={})
rruleset = None
if hasattr(item.vobject_item.vevent, 'rrule'):
rruleset = vevent.getrruleset()
# There is something strange behaviour during serialization native datetime, so converting manually
vevent.dtstart.value = vevent.dtstart.value.strftime(dt_format)
if dt_end is not None:
vevent.dtend.value = vevent.dtend.value.strftime(dt_format)
timezones_to_remove = []
for component in item.vobject_item.components():
if component.name == 'VTIMEZONE':
timezones_to_remove.append(component)
for timezone in timezones_to_remove:
item.vobject_item.remove(timezone)
try:
delattr(item.vobject_item.vevent, 'rrule')
delattr(item.vobject_item.vevent, 'exdate')
delattr(item.vobject_item.vevent, 'exrule')
delattr(item.vobject_item.vevent, 'rdate')
except AttributeError:
pass
return item, rruleset
def xml_item_response(base_prefix: str, href: str,
found_props: Sequence[ET.Element] = (),
not_found_props: Sequence[ET.Element] = (),
@ -295,13 +526,28 @@ class ApplicationPartReport(ApplicationBase):
else:
assert item.collection is not None
collection = item.collection
try:
status, xml_answer = xml_report(
base_prefix, path, xml_content, collection, self._encoding,
lock_stack.close)
except ValueError as e:
logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
return status, headers, self._xml_response(xml_answer)
if xml_content is not None and \
xml_content.tag == xmlutils.make_clark("C:free-busy-query"):
max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence")
try:
status, body = free_busy_report(
base_prefix, path, xml_content, collection, self._encoding,
lock_stack.close, max_occurrence)
except ValueError as e:
logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
return status, headers, str(body)
else:
try:
status, xml_answer = xml_report(
base_prefix, path, xml_content, collection, self._encoding,
lock_stack.close)
except ValueError as e:
logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
return status, headers, self._xml_response(xml_answer)

View file

@ -2,7 +2,8 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -31,13 +32,20 @@ Take a look at the class ``BaseAuth`` if you want to implement your own.
from typing import Sequence, Tuple, Union
from radicale import config, types, utils
from radicale.log import logger
INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
"htpasswd", "ldap")
"denyall",
"htpasswd",
"ldap")
def load(configuration: "config.Configuration") -> "BaseAuth":
"""Load the authentication module chosen in configuration."""
if configuration.get("auth", "type") == "none":
logger.warning("No user authentication is selected: '[auth] type=none' (insecure)")
if configuration.get("auth", "type") == "denyall":
logger.warning("All access is blocked by: '[auth] type=denyall'")
return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", BaseAuth,
configuration)
@ -45,6 +53,8 @@ def load(configuration: "config.Configuration") -> "BaseAuth":
class BaseAuth:
_ldap_groups: set
_lc_username: bool
_strip_domain: bool
def __init__(self, configuration: "config.Configuration") -> None:
"""Initialize BaseAuth.
@ -55,6 +65,8 @@ class BaseAuth:
"""
self.configuration = configuration
self._lc_username = configuration.get("auth", "lc_username")
self._strip_domain = configuration.get("auth", "strip_domain")
def get_external_login(self, environ: types.WSGIEnviron) -> Union[
Tuple[()], Tuple[str, str]]:
@ -69,7 +81,7 @@ class BaseAuth:
"""
return ()
def login(self, login: str, password: str) -> str:
def _login(self, login: str, password: str) -> str:
"""Check credentials and map login to internal user
``login`` the login name
@ -81,3 +93,10 @@ class BaseAuth:
"""
raise NotImplementedError
def login(self, login: str, password: str) -> str:
if self._lc_username:
login = login.lower()
if self._strip_domain:
login = login.split('@')[0]
return self._login(login, password)

30
radicale/auth/denyall.py Normal file
View file

@ -0,0 +1,30 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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 <http://www.gnu.org/licenses/>.
"""
A dummy backend that denies any username and password.
Used as default for security reasons.
"""
from radicale import auth
class Auth(auth.BaseAuth):
def _login(self, login: str, password: str) -> str:
return ""

View file

@ -3,6 +3,7 @@
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -22,12 +23,12 @@ Authentication backend that checks credentials with a htpasswd file.
Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html)
manages a file for storing user credentials. It can encrypt passwords using
different the methods BCRYPT or MD5-APR1 (a version of MD5 modified for
Apache). MD5-APR1 provides medium security as of 2015. Only BCRYPT can be
different the methods BCRYPT/SHA256/SHA512 or MD5-APR1 (a version of MD5 modified for
Apache). MD5-APR1 provides medium security as of 2015. Only BCRYPT/SHA256/SHA512 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.
is the default, in fact), whereas BCRYPT/SHA256/SHA512 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
@ -35,15 +36,15 @@ 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
The following htpasswd password encryption methods are supported by Radicale
out-of-the-box:
- plain-text (created by htpasswd -p ...) -- INSECURE
- MD5-APR1 (htpasswd -m ...) -- htpasswd's default method, INSECURE
- SHA256 (htpasswd -2 ...)
- SHA512 (htpasswd -5 ...)
- plain-text (created by htpasswd -p...) -- INSECURE
- MD5-APR1 (htpasswd -m...) -- htpasswd's default method
When passlib[bcrypt] is installed:
- BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x
When bcrypt is installed:
- BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x
"""
@ -51,9 +52,9 @@ import functools
import hmac
from typing import Any
from passlib.hash import apr_md5_crypt
from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt
from radicale import auth, config
from radicale import auth, config, logger
class Auth(auth.BaseAuth):
@ -67,22 +68,28 @@ class Auth(auth.BaseAuth):
self._encoding = configuration.get("encoding", "stock")
encryption: str = configuration.get("auth", "htpasswd_encryption")
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s'", encryption)
if encryption == "plain":
self._verify = self._plain
elif encryption == "md5":
self._verify = self._md5apr1
elif encryption == "bcrypt":
elif encryption == "sha256":
self._verify = self._sha256
elif encryption == "sha512":
self._verify = self._sha512
elif encryption == "bcrypt" or encryption == "autodetect":
try:
from passlib.hash import bcrypt
import bcrypt
except ImportError as e:
raise RuntimeError(
"The htpasswd encryption method 'bcrypt' requires "
"the passlib[bcrypt] module.") 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.hash("test-bcrypt-backend")
self._verify = functools.partial(self._bcrypt, bcrypt)
"The htpasswd encryption method 'bcrypt' or 'autodetect' requires "
"the bcrypt module.") from e
if encryption == "bcrypt":
self._verify = functools.partial(self._bcrypt, bcrypt)
else:
self._verify = self._autodetect
self._verify_bcrypt = functools.partial(self._bcrypt, bcrypt)
else:
raise RuntimeError("The htpasswd encryption method %r is not "
"supported." % encryption)
@ -92,12 +99,35 @@ class Auth(auth.BaseAuth):
return hmac.compare_digest(hash_value.encode(), password.encode())
def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> bool:
return bcrypt.verify(password, hash_value.strip())
return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode())
def _md5apr1(self, hash_value: str, password: str) -> bool:
return apr_md5_crypt.verify(password, hash_value.strip())
def login(self, login: str, password: str) -> str:
def _sha256(self, hash_value: str, password: str) -> bool:
return sha256_crypt.verify(password, hash_value.strip())
def _sha512(self, hash_value: str, password: str) -> bool:
return sha512_crypt.verify(password, hash_value.strip())
def _autodetect(self, hash_value: str, password: str) -> bool:
if hash_value.startswith("$apr1$", 0, 6) and len(hash_value) == 37:
# MD5-APR1
return self._md5apr1(hash_value, password)
elif hash_value.startswith("$2y$", 0, 4) and len(hash_value) == 60:
# BCRYPT
return self._verify_bcrypt(hash_value, password)
elif hash_value.startswith("$5$", 0, 3) and len(hash_value) == 63:
# SHA-256
return self._sha256(hash_value, password)
elif hash_value.startswith("$6$", 0, 3) and len(hash_value) == 106:
# SHA-512
return self._sha512(hash_value, password)
else:
# assumed plaintext
return self._plain(hash_value, password)
def _login(self, login: str, password: str) -> str:
"""Validate credentials.
Iterate through htpasswd credential file until login matches, extract

View file

@ -27,5 +27,5 @@ from radicale import auth
class Auth(auth.BaseAuth):
def login(self, login: str, password: str) -> str:
def _login(self, login: str, password: str) -> str:
return login

View file

@ -2,7 +2,8 @@
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2017-2020 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -26,6 +27,7 @@ Use ``load()`` to obtain an instance of ``Configuration`` for use with
"""
import contextlib
import json
import math
import os
import string
@ -35,7 +37,8 @@ from configparser import RawConfigParser
from typing import (Any, Callable, ClassVar, Iterable, List, Optional,
Sequence, Tuple, TypeVar, Union)
from radicale import auth, rights, storage, types, web
from radicale import auth, hook, rights, storage, types, web
from radicale.item import check_and_sanitize_props
DEFAULT_CONFIG_PATH: str = os.pathsep.join([
"?/etc/radicale/config",
@ -101,6 +104,16 @@ def _convert_to_bool(value: Any) -> bool:
return RawConfigParser.BOOLEAN_STATES[value.lower()]
def json_str(value: Any) -> dict:
if not value:
return {}
ret = json.loads(value)
for (name_coll, props) in ret.items():
checked_props = check_and_sanitize_props(props)
ret[name_coll] = checked_props
return ret
INTERNAL_OPTIONS: Sequence[str] = ("_allow_extra",)
# Default configuration
DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
@ -202,13 +215,24 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "False",
"help": "load the ldap groups of the authenticated user",
"type": bool}),
])),
("strip_domain", {
"value": "False",
"help": "strip domain from username",
"type": bool}),
("lc_username", {
"value": "False",
"help": "convert username to lowercase, must be true for case-insensitive auth providers",
"type": bool})])),
("rights", OrderedDict([
("type", {
"value": "owner_only",
"help": "rights backend",
"type": str_or_callable,
"internal": rights.INTERNAL_TYPES}),
("permit_delete_collection", {
"value": "True",
"help": "permit delete of a collection",
"type": bool}),
("file", {
"value": "/etc/radicale/rights",
"help": "file for rights management from_file",
@ -227,6 +251,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "2592000", # 30 days
"help": "delete sync token that are older",
"type": positive_int}),
("skip_broken_item", {
"value": "True",
"help": "skip broken item instead of triggering exception",
"type": bool}),
("hook", {
"value": "",
"help": "command that is run after changes to storage",
@ -234,7 +262,29 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
("_filesystem_fsync", {
"value": "True",
"help": "sync all changes to filesystem during requests",
"type": bool})])),
"type": bool}),
("predefined_collections", {
"value": "",
"help": "predefined user collections",
"type": json_str})])),
("hook", OrderedDict([
("type", {
"value": "none",
"help": "hook backend",
"type": str,
"internal": hook.INTERNAL_TYPES}),
("rabbitmq_endpoint", {
"value": "",
"help": "endpoint where rabbitmq server is running",
"type": str}),
("rabbitmq_topic", {
"value": "",
"help": "topic to declare queue",
"type": str}),
("rabbitmq_queue_type", {
"value": "",
"help": "queue type for topic declaration",
"type": str})])),
("web", OrderedDict([
("type", {
"value": "internal",
@ -243,15 +293,41 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"internal": web.INTERNAL_TYPES})])),
("logging", OrderedDict([
("level", {
"value": "warning",
"value": "info",
"help": "threshold for the logger",
"type": logging_level}),
("bad_put_request_content", {
"value": "False",
"help": "log bad PUT request content",
"type": bool}),
("backtrace_on_debug", {
"value": "False",
"help": "log backtrace on level=debug",
"type": bool}),
("request_header_on_debug", {
"value": "False",
"help": "log request header on level=debug",
"type": bool}),
("request_content_on_debug", {
"value": "False",
"help": "log request content on level=debug",
"type": bool}),
("response_content_on_debug", {
"value": "False",
"help": "log response content on level=debug",
"type": bool}),
("mask_passwords", {
"value": "True",
"help": "mask passwords in logs",
"type": bool})])),
("headers", OrderedDict([
("_allow_extra", str)]))])
("_allow_extra", str)])),
("reporting", OrderedDict([
("max_freebusy_occurrence", {
"value": "10000",
"help": "number of occurrences per event when reporting",
"type": positive_int})]))
])
def parse_compound_paths(*compound_paths: Optional[str]
@ -308,8 +384,8 @@ def load(paths: Optional[Iterable[Tuple[str, bool]]] = None
config = {s: {o: parser[s][o] for o in parser.options(s)}
for s in parser.sections()}
except Exception as e:
if not (ignore_if_missing and
isinstance(e, (FileNotFoundError, PermissionError))):
if not (ignore_if_missing and isinstance(e, (
FileNotFoundError, NotADirectoryError, PermissionError))):
raise RuntimeError("Failed to load %s: %s" % (config_source, e)
) from e
config = Configuration.SOURCE_MISSING

69
radicale/hook/__init__.py Normal file
View file

@ -0,0 +1,69 @@
import json
from enum import Enum
from typing import Sequence
from radicale import pathutils, utils
from radicale.log import logger
INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq")
def load(configuration):
"""Load the storage module chosen in configuration."""
try:
return utils.load_plugin(
INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
except Exception as e:
logger.warn(e)
logger.warn("Hook \"%s\" failed to load, falling back to \"none\"." % configuration.get("hook", "type"))
configuration = configuration.copy()
configuration.update({"hook": {"type": "none"}}, "hook", privileged=True)
return utils.load_plugin(
INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)
class BaseHook:
def __init__(self, configuration):
"""Initialize BaseHook.
``configuration`` see ``radicale.config`` module.
The ``configuration`` must not change during the lifetime of
this object, it is kept as an internal reference.
"""
self.configuration = configuration
def notify(self, notification_item):
"""Upload a new or replace an existing item."""
raise NotImplementedError
class HookNotificationItemTypes(Enum):
CPATCH = "cpatch"
UPSERT = "upsert"
DELETE = "delete"
def _cleanup(path):
sane_path = pathutils.strip_path(path)
attributes = sane_path.split("/") if sane_path else []
if len(attributes) < 2:
return ""
return attributes[0] + "/" + attributes[1]
class HookNotificationItem:
def __init__(self, notification_item_type, path, content):
self.type = notification_item_type.value
self.point = _cleanup(path)
self.content = content
def to_json(self):
return json.dumps(
self,
default=lambda o: o.__dict__,
sort_keys=True,
indent=4
)

6
radicale/hook/none.py Normal file
View file

@ -0,0 +1,6 @@
from radicale import hook
class Hook(hook.BaseHook):
def notify(self, notification_item):
"""Notify nothing. Empty hook."""

View file

@ -0,0 +1,50 @@
import pika
from pika.exceptions import ChannelWrongStateError, StreamLostError
from radicale import hook
from radicale.hook import HookNotificationItem
from radicale.log import logger
class Hook(hook.BaseHook):
def __init__(self, configuration):
super().__init__(configuration)
self._endpoint = configuration.get("hook", "rabbitmq_endpoint")
self._topic = configuration.get("hook", "rabbitmq_topic")
self._queue_type = configuration.get("hook", "rabbitmq_queue_type")
self._encoding = configuration.get("encoding", "stock")
self._make_connection_synced()
self._make_declare_queue_synced()
def _make_connection_synced(self):
parameters = pika.URLParameters(self._endpoint)
connection = pika.BlockingConnection(parameters)
self._channel = connection.channel()
def _make_declare_queue_synced(self):
self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type})
def notify(self, notification_item):
if isinstance(notification_item, HookNotificationItem):
self._notify(notification_item, True)
def _notify(self, notification_item, recall):
try:
self._channel.basic_publish(
exchange='',
routing_key=self._topic,
body=notification_item.to_json().encode(
encoding=self._encoding
)
)
except Exception as e:
if (isinstance(e, ChannelWrongStateError) or
isinstance(e, StreamLostError)) and recall:
self._make_connection_synced()
self._notify(notification_item, False)
return
logger.error("An exception occurred during "
"publishing hook notification item: %s",
e, exc_info=True)

View file

@ -2,7 +2,8 @@
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -24,13 +25,25 @@ Helper functions for HTTP.
import contextlib
import os
import pathlib
import sys
import time
from http import client
from typing import List, Mapping, cast
from typing import List, Mapping, Union, cast
from radicale import config, pathutils, types
from radicale.log import logger
if sys.version_info < (3, 9):
import pkg_resources
_TRAVERSABLE_LIKE_TYPE = pathlib.Path
else:
import importlib.abc
from importlib import resources
_TRAVERSABLE_LIKE_TYPE = Union[importlib.abc.Traversable, pathlib.Path]
NOT_ALLOWED: types.WSGIResponse = (
client.FORBIDDEN, (("Content-Type", "text/plain"),),
"Access to the requested resource forbidden.")
@ -130,7 +143,10 @@ def read_request_body(configuration: "config.Configuration",
environ: types.WSGIEnviron) -> str:
content = decode_request(configuration, environ,
read_raw_request_body(configuration, environ))
logger.debug("Request content:\n%s", content)
if configuration.get("logging", "request_content_on_debug"):
logger.debug("Request content:\n%s", content)
else:
logger.debug("Request content: suppressed by config/option [auth] request_content_on_debug")
return content
@ -140,36 +156,63 @@ def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
"Redirected to %s" % location)
def serve_folder(folder: str, base_prefix: str, path: str,
path_prefix: str = "/.web", index_file: str = "index.html",
mimetypes: Mapping[str, str] = MIMETYPES,
fallback_mimetype: str = FALLBACK_MIMETYPE,
) -> types.WSGIResponse:
def _serve_traversable(
traversable: _TRAVERSABLE_LIKE_TYPE, base_prefix: str, path: str,
path_prefix: str, index_file: str, mimetypes: Mapping[str, str],
fallback_mimetype: str) -> types.WSGIResponse:
if path != path_prefix and not path.startswith(path_prefix):
raise ValueError("path must start with path_prefix: %r --> %r" %
(path_prefix, path))
assert pathutils.sanitize_path(path) == path
try:
filesystem_path = pathutils.path_to_filesystem(
folder, path[len(path_prefix):].strip("/"))
except ValueError as e:
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("/"):
return redirect(base_prefix + path + "/")
if os.path.isdir(filesystem_path) and index_file:
filesystem_path = os.path.join(filesystem_path, index_file)
if not os.path.isfile(filesystem_path):
parts_path = path[len(path_prefix):].strip('/')
parts = parts_path.split("/") if parts_path else []
for part in parts:
if not pathutils.is_safe_filesystem_path_component(part):
logger.debug("Web content with unsafe path %r requested", path)
return NOT_FOUND
if (not traversable.is_dir() or
all(part != entry.name for entry in traversable.iterdir())):
return NOT_FOUND
traversable = traversable.joinpath(part)
if traversable.is_dir():
if not path.endswith("/"):
return redirect(base_prefix + path + "/")
if not index_file:
return NOT_FOUND
traversable = traversable.joinpath(index_file)
if not traversable.is_file():
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(
os.path.splitext(traversable.name)[1].lower(), FALLBACK_MIMETYPE)
headers = {"Content-Type": content_type}
if isinstance(traversable, pathlib.Path):
headers["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}
time.gmtime(traversable.stat().st_mtime))
answer = traversable.read_bytes()
return client.OK, headers, answer
def serve_resource(
package: str, resource: str, base_prefix: str, path: str,
path_prefix: str = "/.web", index_file: str = "index.html",
mimetypes: Mapping[str, str] = MIMETYPES,
fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse:
if sys.version_info < (3, 9):
traversable = pathlib.Path(
pkg_resources.resource_filename(package, resource))
else:
traversable = resources.files(package).joinpath(resource)
return _serve_traversable(traversable, base_prefix, path, path_prefix,
index_file, mimetypes, fallback_mimetype)
def serve_folder(
folder: str, base_prefix: str, path: str,
path_prefix: str = "/.web", index_file: str = "index.html",
mimetypes: Mapping[str, str] = MIMETYPES,
fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse:
# deprecated: use `serve_resource` instead
traversable = pathlib.Path(folder)
return _serve_traversable(traversable, base_prefix, path, path_prefix,
index_file, mimetypes, fallback_mimetype)

View file

@ -49,7 +49,13 @@ def read_components(s: str) -> List[vobject.base.Component]:
s = re.sub(r"^(PHOTO(?:;[^:\r\n]*)?;ENCODING=b(?:;[^:\r\n]*)?:)"
r"data:[^;,\r\n]*;base64,", r"\1", s,
flags=re.MULTILINE | re.IGNORECASE)
return list(vobject.readComponents(s))
# Workaround for bug with malformed ICS files containing control codes
# Filter out all control codes except those we expect to find:
# * 0x09 Horizontal Tab
# * 0x0A Line Feed
# * 0x0D Carriage Return
s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s)
return list(vobject.readComponents(s, allowQP=True))
def predict_tag_of_parent_collection(
@ -91,7 +97,7 @@ def check_and_sanitize_items(
The ``tag`` of the collection.
"""
if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"):
if tag and tag not in ("VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"):
raise ValueError("Unsupported collection tag: %r" % tag)
if not is_collection and len(vobject_items) != 1:
raise ValueError("Item contains %d components" % len(vobject_items))
@ -164,7 +170,7 @@ def check_and_sanitize_items(
ref_value_param = component.dtstart.params.get("VALUE")
for dates in chain(component.contents.get("exdate", []),
component.contents.get("rdate", [])):
if all(type(d) == type(ref_date) for d in dates.value):
if all(type(d) is type(ref_date) for d in dates.value):
continue
for i, date in enumerate(dates.value):
dates.value[i] = ref_date.replace(
@ -230,7 +236,7 @@ def check_and_sanitize_props(props: MutableMapping[Any, Any]
raise ValueError("Value of %r must be %r not %r: %r" % (
k, str.__name__, type(v).__name__, v))
if k == "tag":
if v not in ("", "VCALENDAR", "VADDRESSBOOK"):
if v not in ("", "VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"):
raise ValueError("Unsupported collection tag: %r" % v)
return props
@ -245,8 +251,8 @@ def find_available_uid(exists_fn: Callable[[str], bool], suffix: str = ""
r[:8], r[8:12], r[12:16], r[16:20], r[20:], suffix)
if not exists_fn(name):
return name
# something is wrong with the PRNG
raise RuntimeError("No unique random sequence found")
# Something is wrong with the PRNG or `exists_fn`
raise RuntimeError("No available random UID found")
def get_etag(text: str) -> str:
@ -298,7 +304,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
Returns a tuple (``start``, ``end``) where ``start`` and ``end`` are
POSIX timestamps.
This is intened to be used for matching against simplified prefilters.
This is intended to be used for matching against simplified prefilters.
"""
if not tag:

View file

@ -48,10 +48,34 @@ def date_to_datetime(d: date) -> datetime:
if not isinstance(d, datetime):
d = datetime.combine(d, datetime.min.time())
if not d.tzinfo:
d = d.replace(tzinfo=timezone.utc)
# NOTE: using vobject's UTC as it wasn't playing well with datetime's.
d = d.replace(tzinfo=vobject.icalendar.utc)
return d
def parse_time_range(time_filter: ET.Element) -> Tuple[datetime, datetime]:
start_text = time_filter.get("start")
end_text = time_filter.get("end")
if start_text:
start = datetime.strptime(
start_text, "%Y%m%dT%H%M%SZ").replace(
tzinfo=timezone.utc)
else:
start = DATETIME_MIN
if end_text:
end = datetime.strptime(
end_text, "%Y%m%dT%H%M%SZ").replace(
tzinfo=timezone.utc)
else:
end = DATETIME_MAX
return start, end
def time_range_timestamps(time_filter: ET.Element) -> Tuple[int, int]:
start, end = parse_time_range(time_filter)
return (math.floor(start.timestamp()), math.ceil(end.timestamp()))
def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
"""Check whether the ``item`` matches the comp ``filter_``.
@ -147,21 +171,10 @@ def time_range_match(vobject_item: vobject.base.Component,
"""Check whether the component/property ``child_name`` of
``vobject_item`` matches the time-range ``filter_``."""
start_text = filter_.get("start")
end_text = filter_.get("end")
if not start_text and not end_text:
if not filter_.get("start") and not filter_.get("end"):
return False
if start_text:
start = datetime.strptime(start_text, "%Y%m%dT%H%M%SZ")
else:
start = datetime.min
if end_text:
end = datetime.strptime(end_text, "%Y%m%dT%H%M%SZ")
else:
end = datetime.max
start = start.replace(tzinfo=timezone.utc)
end = end.replace(tzinfo=timezone.utc)
start, end = parse_time_range(filter_)
matched = False
def range_fn(range_start: datetime, range_end: datetime,
@ -181,6 +194,35 @@ def time_range_match(vobject_item: vobject.base.Component,
return matched
def time_range_fill(vobject_item: vobject.base.Component,
filter_: ET.Element, child_name: str, n: int = 1
) -> List[Tuple[datetime, datetime]]:
"""Create a list of ``n`` occurances from the component/property ``child_name``
of ``vobject_item``."""
if not filter_.get("start") and not filter_.get("end"):
return []
start, end = parse_time_range(filter_)
ranges: List[Tuple[datetime, datetime]] = []
def range_fn(range_start: datetime, range_end: datetime,
is_recurrence: bool) -> bool:
nonlocal ranges
if start < range_end and range_start < end:
ranges.append((range_start, range_end))
if n > 0 and len(ranges) >= n:
return True
if end < range_start and not is_recurrence:
return True
return False
def infinity_fn(range_start: datetime) -> bool:
return False
visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
return ranges
def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
range_fn: Callable[[datetime, datetime, bool], bool],
infinity_fn: Callable[[datetime], bool]) -> None:
@ -199,7 +241,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
"""
# HACK: According to rfc5545-3.8.4.4 an recurrance that is resheduled
# HACK: According to rfc5545-3.8.4.4 a recurrence that is rescheduled
# with Recurrence ID affects the recurrence itself and all following
# recurrences too. This is not respected and client don't seem to bother
# either.
@ -225,6 +267,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
def get_children(components: Iterable[vobject.base.Component]) -> Iterator[
Tuple[vobject.base.Component, bool, List[date]]]:
main = None
rec_main = None
recurrences = []
for comp in components:
if hasattr(comp, "recurrence_id") and comp.recurrence_id.value:
@ -232,11 +275,14 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
if comp.rruleset:
# Prevent possible infinite loop
raise ValueError("Overwritten recurrence with RRULESET")
rec_main = comp
yield comp, True, []
else:
if main is not None:
raise ValueError("Multiple main components")
main = comp
if main is None and len(recurrences) == 1:
main = rec_main
if main is None:
raise ValueError("Main component missing")
yield main, False, recurrences
@ -468,7 +514,15 @@ def text_match(vobject_item: vobject.base.Component,
match(attrib) for child in children
for attrib in child.params.get(attrib_name, []))
else:
condition = any(match(child.value) for child in children)
res = []
for child in children:
# Some filters such as CATEGORIES provide a list in child.value
if type(child.value) is list:
for value in child.value:
res.append(match(value))
else:
res.append(match(child.value))
condition = any(res)
if filter_.get("negate-condition") == "yes":
return not condition
return condition
@ -531,20 +585,7 @@ def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str
if time_filter.tag != xmlutils.make_clark("C:time-range"):
simple = False
continue
start_text = time_filter.get("start")
end_text = time_filter.get("end")
if start_text:
start = math.floor(datetime.strptime(
start_text, "%Y%m%dT%H%M%SZ").replace(
tzinfo=timezone.utc).timestamp())
else:
start = TIMESTAMP_MIN
if end_text:
end = math.ceil(datetime.strptime(
end_text, "%Y%m%dT%H%M%SZ").replace(
tzinfo=timezone.utc).timestamp())
else:
end = TIMESTAMP_MAX
start, end = time_range_timestamps(time_filter)
return tag, start, end, simple
return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple

View file

@ -1,6 +1,7 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2011-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2017-2023 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -25,16 +26,25 @@ Log messages are sent to the first available target of:
"""
import contextlib
import io
import logging
import os
import socket
import struct
import sys
import threading
from typing import Any, Callable, ClassVar, Dict, Iterator, Union
import time
from typing import (Any, Callable, ClassVar, Dict, Iterator, Mapping, Optional,
Tuple, Union, cast)
from radicale import types
LOGGER_NAME: str = "radicale"
LOGGER_FORMAT: str = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s"
LOGGER_FORMATS: Mapping[str, str] = {
"verbose": "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s",
"journal": "[%(ident)s] [%(levelname)s] %(message)s",
}
DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z"
logger: logging.Logger = logging.getLogger(LOGGER_NAME)
@ -59,12 +69,17 @@ class IdentLogRecordFactory:
def __call__(self, *args: Any, **kwargs: Any) -> logging.LogRecord:
record = self._upstream_factory(*args, **kwargs)
ident = "%d" % os.getpid()
main_thread = threading.main_thread()
current_thread = threading.current_thread()
if current_thread.name and main_thread != current_thread:
ident += "/%s" % current_thread.name
ident = ("%d" % record.process if record.process is not None
else record.processName or "unknown")
tid = None
if record.thread is not None:
if record.thread != threading.main_thread().ident:
ident += "/%s" % (record.threadName or "unknown")
if (sys.version_info >= (3, 8) and
record.thread == threading.get_ident()):
tid = threading.get_native_id()
record.ident = ident # type:ignore[attr-defined]
record.tid = tid # type:ignore[attr-defined]
return record
@ -75,19 +90,102 @@ class ThreadedStreamHandler(logging.Handler):
terminator: ClassVar[str] = "\n"
_streams: Dict[int, types.ErrorStream]
_journal_stream_id: Optional[Tuple[int, int]]
_journal_socket: Optional[socket.socket]
_journal_socket_failed: bool
_formatters: Mapping[str, logging.Formatter]
_formatter: Optional[logging.Formatter]
def __init__(self) -> None:
def __init__(self, format_name: Optional[str] = None) -> None:
super().__init__()
self._streams = {}
self._journal_stream_id = None
with contextlib.suppress(TypeError, ValueError):
dev, inode = os.environ.get("JOURNAL_STREAM", "").split(":", 1)
self._journal_stream_id = (int(dev), int(inode))
self._journal_socket = None
self._journal_socket_failed = False
self._formatters = {name: logging.Formatter(fmt, DATE_FORMAT)
for name, fmt in LOGGER_FORMATS.items()}
self._formatter = (self._formatters[format_name]
if format_name is not None else None)
def _get_formatter(self, default_format_name: str) -> logging.Formatter:
return self._formatter or self._formatters[default_format_name]
def _detect_journal(self, stream: types.ErrorStream) -> bool:
if not self._journal_stream_id or not isinstance(stream, io.IOBase):
return False
try:
stat = os.fstat(stream.fileno())
except OSError:
return False
return self._journal_stream_id == (stat.st_dev, stat.st_ino)
@staticmethod
def _encode_journal(data: Mapping[str, Optional[Union[str, int]]]
) -> bytes:
msg = b""
for key, value in data.items():
if value is None:
continue
keyb = key.encode()
valueb = str(value).encode()
if b"\n" in valueb:
msg += (keyb + b"\n" +
struct.pack("<Q", len(valueb)) + valueb + b"\n")
else:
msg += keyb + b"=" + valueb + b"\n"
return msg
def _try_emit_journal(self, record: logging.LogRecord) -> bool:
if not self._journal_socket:
# Try to connect to systemd journal socket
if self._journal_socket_failed or not hasattr(socket, "AF_UNIX"):
return False
journal_socket = None
try:
journal_socket = socket.socket(
socket.AF_UNIX, socket.SOCK_DGRAM)
journal_socket.connect("/run/systemd/journal/socket")
except OSError as e:
self._journal_socket_failed = True
if journal_socket:
journal_socket.close()
# Log after setting `_journal_socket_failed` to prevent loop!
logger.error("Failed to connect to systemd journal: %s",
e, exc_info=True)
return False
self._journal_socket = journal_socket
priority = {"DEBUG": 7,
"INFO": 6,
"WARNING": 4,
"ERROR": 3,
"CRITICAL": 2}.get(record.levelname, 4)
timestamp = time.strftime("%Y-%m-%dT%H:%M:%S.%%03dZ",
time.gmtime(record.created)) % record.msecs
data = {"PRIORITY": priority,
"TID": cast(Optional[int], getattr(record, "tid", None)),
"SYSLOG_IDENTIFIER": record.name,
"SYSLOG_FACILITY": 1,
"SYSLOG_PID": record.process,
"SYSLOG_TIMESTAMP": timestamp,
"CODE_FILE": record.pathname,
"CODE_LINE": record.lineno,
"CODE_FUNC": record.funcName,
"MESSAGE": self._get_formatter("journal").format(record)}
self._journal_socket.sendall(self._encode_journal(data))
return True
def emit(self, record: logging.LogRecord) -> None:
try:
stream = self._streams.get(threading.get_ident(), sys.stderr)
msg = self.format(record)
stream.write(msg)
stream.write(self.terminator)
if hasattr(stream, "flush"):
stream.flush()
if self._detect_journal(stream) and self._try_emit_journal(record):
return
msg = self._get_formatter("verbose").format(record)
stream.write(msg + self.terminator)
stream.flush()
except Exception:
self.handleError(record)
@ -111,21 +209,30 @@ def register_stream(stream: types.ErrorStream) -> Iterator[None]:
def setup() -> None:
"""Set global logging up."""
global register_stream
handler = ThreadedStreamHandler()
logging.basicConfig(format=LOGGER_FORMAT, datefmt=DATE_FORMAT,
handlers=[handler])
format_name = os.environ.get("RADICALE_LOG_FORMAT") or None
sane_format_name = format_name if format_name in LOGGER_FORMATS else None
handler = ThreadedStreamHandler(sane_format_name)
logging.basicConfig(handlers=[handler])
register_stream = handler.register_stream
log_record_factory = IdentLogRecordFactory(logging.getLogRecordFactory())
logging.setLogRecordFactory(log_record_factory)
set_level(logging.WARNING)
set_level(logging.INFO, True)
if format_name != sane_format_name:
logger.error("Invalid RADICALE_LOG_FORMAT: %r", format_name)
def set_level(level: Union[int, str]) -> None:
def set_level(level: Union[int, str], backtrace_on_debug: bool) -> None:
"""Set logging level for global logger."""
if isinstance(level, str):
level = getattr(logging, level.upper())
assert isinstance(level, int)
logger.setLevel(level)
logger.removeFilter(REMOVE_TRACEBACK_FILTER)
if level > logging.DEBUG:
logger.info("Logging of backtrace is disabled in this loglevel")
logger.addFilter(REMOVE_TRACEBACK_FILTER)
else:
if not backtrace_on_debug:
logger.debug("Logging of backtrace is disabled by option in this loglevel")
logger.addFilter(REMOVE_TRACEBACK_FILTER)
else:
logger.removeFilter(REMOVE_TRACEBACK_FILTER)

View file

@ -257,6 +257,7 @@ def is_safe_filesystem_path_component(path: str) -> bool:
"""
return (
bool(path) and not os.path.splitdrive(path)[0] and
(sys.platform != "win32" or ":" not in path) and # Block NTFS-ADS
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))

View file

@ -22,7 +22,7 @@ config (section "rights", key "file").
The login is matched against the "user" key, and the collection path
is matched against the "collection" key. In the "collection" regex you can use
`{user}` and get groups from the "user" regex with `{0}`, `{1}`, etc.
In consequence of the parameter subsitution you have to write `{{` and `}}`
In consequence of the parameter substitution you have to write `{{` and `}}`
if you want to use regular curly braces in the "user" and "collection" regexes.
For example, for the "user" key, ".+" means "authenticated user" and ".*"
@ -98,6 +98,12 @@ class Rights(rights.BaseRights):
group_match, sane_path,
collection_pattern, section)
return self._rights_config.get(section, "permissions")
#if user_match and collection_match:
# permission = rights_config.get(section, "permissions")
# logger.debug("Rule %r:%r matches %r:%r from section %r permission %r",
# user, sane_path, user_pattern,
# collection_pattern, section, permission)
# return permission
logger.debug("Rule %r:%r doesn't match %r:%r from section %r",
user, sane_path, user_pattern, collection_pattern,
section)

View file

@ -3,6 +3,7 @@
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -22,7 +23,6 @@ Built-in WSGI server.
"""
import errno
import http
import select
import socket
@ -58,11 +58,19 @@ elif sys.platform == "win32":
# IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid)
ADDRESS_TYPE = Union[Tuple[str, int], Tuple[str, int, int, int]]
ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int],
Tuple[str, int, int, int]]
def format_address(address: ADDRESS_TYPE) -> str:
return "[%s]:%d" % address[:2]
host, port, *_ = address
if not isinstance(host, str):
raise NotImplementedError("Unsupported address format: %r" %
(address,))
if host.find(":") == -1:
return "%s:%d" % (host, port)
else:
return "[%s]:%d" % (host, port)
class ParallelHTTPServer(socketserver.ThreadingMixIn,
@ -206,7 +214,7 @@ class ServerHandler(wsgiref.simple_server.ServerHandler):
# Don't pollute WSGI environ with OS environment
os_environ: MutableMapping[str, str] = {}
def log_exception(self, exc_info: "wsgiref.handlers._exc_info") -> None:
def log_exception(self, exc_info) -> None:
logger.error("An exception occurred during request: %s",
exc_info[1], exc_info=exc_info) # type:ignore[arg-type]
@ -278,41 +286,22 @@ def serve(configuration: config.Configuration,
servers = {}
try:
hosts: List[Tuple[str, int]] = configuration.get("server", "hosts")
for address in hosts:
# Try to bind sockets for IPv4 and IPv6
possible_families = (socket.AF_INET, socket.AF_INET6)
bind_ok = False
for i, family in enumerate(possible_families):
is_last = i == len(possible_families) - 1
for address_port in hosts:
# retrieve IPv4/IPv6 address of address
try:
getaddrinfo = socket.getaddrinfo(address_port[0], address_port[1], 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
except OSError as e:
logger.warning("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e))
continue
logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo))
for (address_family, socket_kind, socket_proto, socket_flags, socket_address) in getaddrinfo:
logger.debug("try to create server socket on '%s'" % (format_address(socket_address)))
try:
server = server_class(configuration, family, address,
RequestHandler)
server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler)
except OSError as e:
# Ignore unsupported families (only one must work)
if ((bind_ok or not is_last) and (
isinstance(e, socket.gaierror) and (
# Hostname does not exist or doesn't have
# address for address family
# macOS: IPv6 address for INET address family
e.errno == socket.EAI_NONAME or
# Address not for address family
e.errno == COMPAT_EAI_ADDRFAMILY or
e.errno == COMPAT_EAI_NODATA) or
# Workaround for PyPy
str(e) == "address family mismatched" or
# Address family not available (e.g. IPv6 disabled)
# macOS: IPv4 address for INET6 address family with
# IPV6_V6ONLY set
e.errno == errno.EADDRNOTAVAIL or
# Address family not supported
e.errno == errno.EAFNOSUPPORT or
# Protocol not supported
e.errno == errno.EPROTONOSUPPORT)):
continue
raise RuntimeError("Failed to start server %r: %s" % (
format_address(address), e)) from e
logger.warning("cannot create server socket on '%s': %s" % (format_address(socket_address), e))
continue
servers[server.socket] = server
bind_ok = True
server.set_app(application)
logger.info("Listening on %r%s",
format_address(server.server_address),

View file

@ -29,7 +29,6 @@ from hashlib import sha256
from typing import (Iterable, Iterator, Mapping, Optional, Sequence, Set,
Tuple, Union, overload)
import pkg_resources
import vobject
from radicale import config
@ -41,7 +40,7 @@ INTERNAL_TYPES: Sequence[str] = ("multifilesystem", "multifilesystem_nolock",)
CACHE_DEPS: Sequence[str] = ("radicale", "vobject", "python-dateutil",)
CACHE_VERSION: bytes = "".join(
"%s=%s;" % (pkg, pkg_resources.get_distribution(pkg).version)
"%s=%s;" % (pkg, utils.package_version(pkg))
for pkg in CACHE_DEPS).encode()

View file

@ -1,7 +1,8 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2014 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -40,11 +41,13 @@ class CollectionBase(storage.BaseCollection):
# Path should already be sanitized
self._path = pathutils.strip_path(path)
self._encoding = storage_.configuration.get("encoding", "stock")
self._skip_broken_item = storage_.configuration.get("storage", "skip_broken_item")
if filesystem_path is None:
filesystem_path = pathutils.path_to_filesystem(folder, self.path)
self._filesystem_path = filesystem_path
@types.contextmanager
# TODO: better fix for "mypy"
@types.contextmanager # type: ignore
def _atomic_write(self, path: str, mode: str = "w",
newline: Optional[str] = None) -> Iterator[IO[AnyStr]]:
# TODO: Overload with Literal when dropping support for Python < 3.8

View file

@ -86,7 +86,8 @@ class CollectionPartCache(CollectionBase):
content = self._item_cache_content(item)
self._storage._makedirs_synced(cache_folder)
# Race: Other processes might have created and locked the file.
with contextlib.suppress(PermissionError), self._atomic_write(
# TODO: better fix for "mypy"
with contextlib.suppress(PermissionError), self._atomic_write( # type: ignore
os.path.join(cache_folder, href), "wb") as fo:
fb = cast(BinaryIO, fo)
pickle.dump((cache_hash, *content), fb)

View file

@ -1,7 +1,8 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2014 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -83,7 +84,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
cache_content = self._load_item_cache(href, cache_hash)
if cache_content is None:
with self._acquire_cache_lock("item"):
# Lock the item cache to prevent multpile processes from
# Lock the item cache to prevent multiple processes from
# generating the same data in parallel.
# This improves the performance for multiple requests.
if self._storage._lock.locked == "r":
@ -101,8 +102,12 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
cache_content = self._store_item_cache(
href, temp_item, cache_hash)
except Exception as e:
raise RuntimeError("Failed to load item %r in %r: %s" %
(href, self.path, e)) from e
if self._skip_broken_item:
logger.warning("Skip broken item %r in %r: %s", href, self.path, e)
return None
else:
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:
@ -122,7 +127,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
def get_multi(self, hrefs: Iterable[str]
) -> Iterator[Tuple[str, Optional[radicale_item.Item]]]:
# It's faster to check for file name collissions here, because
# It's faster to check for file name collisions here, because
# we only need to call os.listdir once.
files = None
for href in hrefs:
@ -141,7 +146,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
def get_all(self) -> Iterator[radicale_item.Item]:
for href in self._list():
# We don't need to check for collissions, because the file names
# We don't need to check for collisions, because the file names
# are from os.listdir.
item = self._get(href, verify_href=False)
if item is not None:

View file

@ -61,6 +61,7 @@ class CollectionPartMeta(CollectionBase):
return self._meta_cache if key is None else self._meta_cache.get(key)
def set_meta(self, props: Mapping[str, str]) -> None:
with self._atomic_write(self._props_path, "w") as fo:
# TODO: better fix for "mypy"
with self._atomic_write(self._props_path, "w") as fo: # type: ignore
f = cast(TextIO, fo)
json.dump(props, f, sort_keys=True)

View file

@ -95,7 +95,8 @@ class CollectionPartSync(CollectionPartCache, CollectionPartHistory,
self._storage._makedirs_synced(token_folder)
try:
# Race: Other processes might have created and locked the file.
with self._atomic_write(token_path, "wb") as fo:
# TODO: better fix for "mypy"
with self._atomic_write(token_path, "wb") as fo: # type: ignore
fb = cast(BinaryIO, fo)
pickle.dump(state, fb)
except PermissionError:

View file

@ -20,7 +20,7 @@ import errno
import os
import pickle
import sys
from typing import Iterable, Set, TextIO, cast
from typing import Iterable, Iterator, TextIO, cast
import radicale.item as radicale_item
from radicale import pathutils
@ -43,7 +43,8 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
raise ValueError("Failed to store item %r in collection %r: %s" %
(href, self.path, e)) from e
path = pathutils.path_to_filesystem(self._filesystem_path, href)
with self._atomic_write(path, newline="") as fo:
# TODO: better fix for "mypy"
with self._atomic_write(path, newline="") as fo: # type: ignore
f = cast(TextIO, fo)
f.write(item.serialize())
# Clean the cache after the actual item is stored, or the cache entry
@ -59,16 +60,24 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
def _upload_all_nonatomic(self, items: Iterable[radicale_item.Item],
suffix: str = "") -> None:
"""Upload a new set of items.
"""Upload a new set of items non-atomic"""
def is_safe_free_href(href: str) -> bool:
return (pathutils.is_safe_filesystem_path_component(href) and
not os.path.lexists(
os.path.join(self._filesystem_path, href)))
This takes a list of vobject items and
uploads them nonatomic and without existence checks.
def get_safe_free_hrefs(uid: str) -> Iterator[str]:
for href in [uid if uid.lower().endswith(suffix.lower())
else uid + suffix,
radicale_item.get_etag(uid).strip('"') + suffix]:
if is_safe_free_href(href):
yield href
yield radicale_item.find_available_uid(
lambda href: not is_safe_free_href(href), suffix)
"""
cache_folder = os.path.join(self._filesystem_path,
".Radicale.cache", "item")
self._storage._makedirs_synced(cache_folder)
hrefs: Set[str] = set()
for item in items:
uid = item.uid
try:
@ -77,39 +86,24 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
raise ValueError(
"Failed to store item %r in temporary collection %r: %s" %
(uid, self.path, e)) from e
href_candidate_funtions = [
lambda: uid if uid.lower().endswith(suffix.lower())
else uid + suffix,
lambda: radicale_item.get_etag(uid).strip('"') + suffix,
lambda: radicale_item.find_available_uid(
hrefs.__contains__, suffix)]
href = f = None
while href_candidate_funtions:
href = href_candidate_funtions.pop(0)()
if href in hrefs:
continue
if not pathutils.is_safe_filesystem_path_component(href):
if not href_candidate_funtions:
raise pathutils.UnsafePathError(href)
continue
for href in get_safe_free_hrefs(uid):
try:
f = open(pathutils.path_to_filesystem(
self._filesystem_path, href),
"w", newline="", encoding=self._encoding)
break
f = open(os.path.join(self._filesystem_path, href),
"w", newline="", encoding=self._encoding)
except OSError as e:
if href_candidate_funtions and (
sys.platform != "win32" and
e.errno == errno.EINVAL or
if (sys.platform != "win32" and e.errno == errno.EINVAL or
sys.platform == "win32" and e.errno == 123):
# not a valid filename
continue
raise
assert href is not None and f is not None
break
else:
raise RuntimeError("No href found for item %r in temporary "
"collection %r" % (uid, self.path))
with f:
f.write(item.serialize())
f.flush()
self._storage._fsync(f)
hrefs.add(href)
with open(os.path.join(cache_folder, href), "wb") as fb:
pickle.dump(cache_content, fb)
fb.flush()

View file

@ -1,7 +1,8 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2014 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -48,7 +49,9 @@ class StoragePartVerify(StoragePartDiscover, StorageBase):
while remaining_sane_paths:
sane_path = remaining_sane_paths.pop(0)
path = pathutils.unstrip_path(sane_path, True)
logger.debug("Verifying collection %r", sane_path)
logger.info("Verifying path %r", sane_path)
count = 0
is_collection = True
with exception_cm(sane_path, None):
saved_item_errors = item_errors
collection: Optional[storage.BaseCollection] = None
@ -59,6 +62,9 @@ class StoragePartVerify(StoragePartDiscover, StorageBase):
assert isinstance(item, storage.BaseCollection)
collection = item
collection.get_meta()
if not collection.tag:
is_collection = False
logger.info("Skip !collection %r", sane_path)
continue
if isinstance(item, storage.BaseCollection):
has_child_collections = True
@ -68,13 +74,17 @@ class StoragePartVerify(StoragePartDiscover, StorageBase):
item.href, sane_path, item.uid)
else:
uids.add(item.uid)
logger.debug("Verified item %r in %r",
item.href, sane_path)
count += 1
logger.debug("Verified in %r item %r",
sane_path, item.href)
assert collection
if item_errors == saved_item_errors:
collection.sync()
if is_collection:
collection.sync()
if has_child_collections and collection.tag:
logger.error("Invalid collection %r: %r must not have "
"child collections", sane_path,
collection.tag)
if is_collection:
logger.info("Verified collect %r (items: %d)", sane_path, count)
return item_errors == 0 and collection_errors == 0

View file

@ -25,16 +25,18 @@ import logging
import shutil
import sys
import tempfile
import wsgiref.util
import xml.etree.ElementTree as ET
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple, Union
import defusedxml.ElementTree as DefusedET
import vobject
import radicale
from radicale import app, config, types, xmlutils
RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]]]]
RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]]
# Enable debug output
radicale.log.logger.setLevel(logging.DEBUG)
@ -47,7 +49,7 @@ class BaseTest:
configuration: config.Configuration
application: app.Application
def setup(self) -> None:
def setup_method(self) -> None:
self.configuration = config.load()
self.colpath = tempfile.mkdtemp()
self.configure({
@ -61,7 +63,7 @@ class BaseTest:
self.configuration.update(config_, "test", privileged=True)
self.application = app.Application(self.configuration)
def teardown(self) -> None:
def teardown_method(self) -> None:
shutil.rmtree(self.colpath)
def request(self, method: str, path: str, data: Optional[str] = None,
@ -83,11 +85,12 @@ class BaseTest:
login.encode(encoding)).decode()
environ["REQUEST_METHOD"] = method.upper()
environ["PATH_INFO"] = path
if data:
if data is not None:
data_bytes = data.encode(encoding)
environ["wsgi.input"] = BytesIO(data_bytes)
environ["CONTENT_LENGTH"] = str(len(data_bytes))
environ["wsgi.errors"] = sys.stderr
wsgiref.util.setup_testing_defaults(environ)
status = headers = None
def start_response(status_: str, headers_: List[Tuple[str, str]]
@ -105,12 +108,11 @@ class BaseTest:
def parse_responses(text: str) -> RESPONSES:
xml = DefusedET.fromstring(text)
assert xml.tag == xmlutils.make_clark("D:multistatus")
path_responses: Dict[str, Union[
int, Dict[str, Tuple[int, ET.Element]]]] = {}
path_responses: RESPONSES = {}
for response in xml.findall(xmlutils.make_clark("D:response")):
href = response.find(xmlutils.make_clark("D:href"))
assert href.text not in path_responses
prop_respones: Dict[str, Tuple[int, ET.Element]] = {}
prop_responses: Dict[str, Tuple[int, ET.Element]] = {}
for propstat in response.findall(
xmlutils.make_clark("D:propstat")):
status = propstat.find(xmlutils.make_clark("D:status"))
@ -119,16 +121,22 @@ class BaseTest:
for element in propstat.findall(
"./%s/*" % xmlutils.make_clark("D:prop")):
human_tag = xmlutils.make_human_tag(element.tag)
assert human_tag not in prop_respones
prop_respones[human_tag] = (status_code, element)
assert human_tag not in prop_responses
prop_responses[human_tag] = (status_code, element)
status = response.find(xmlutils.make_clark("D:status"))
if status is not None:
assert not prop_respones
assert not prop_responses
assert status.text.startswith("HTTP/1.1 ")
status_code = int(status.text.split(" ")[1])
path_responses[href.text] = status_code
else:
path_responses[href.text] = prop_respones
path_responses[href.text] = prop_responses
return path_responses
@staticmethod
def parse_free_busy(text: str) -> RESPONSES:
path_responses: RESPONSES = {}
path_responses[""] = vobject.readOne(text)
return path_responses
def get(self, path: str, check: Optional[int] = 200, **kwargs
@ -137,8 +145,8 @@ class BaseTest:
status, _, answer = self.request("GET", path, check=check, **kwargs)
return status, answer
def post(self, path: str, data: str = None, check: Optional[int] = 200,
**kwargs) -> Tuple[int, str]:
def post(self, path: str, data: Optional[str] = None,
check: Optional[int] = 200, **kwargs) -> Tuple[int, str]:
status, _, answer = self.request("POST", path, data, check=check,
**kwargs)
return status, answer
@ -175,13 +183,18 @@ class BaseTest:
return status, responses
def report(self, path: str, data: str, check: Optional[int] = 207,
is_xml: Optional[bool] = True,
**kwargs) -> Tuple[int, RESPONSES]:
status, _, answer = self.request("REPORT", path, data, check=check,
**kwargs)
if status < 200 or 300 <= status:
return status, {}
assert answer is not None
return status, self.parse_responses(answer)
if is_xml:
parsed = self.parse_responses(answer)
else:
parsed = self.parse_free_busy(answer)
return status, parsed
def delete(self, path: str, check: Optional[int] = 200, **kwargs
) -> Tuple[int, RESPONSES]:

View file

@ -25,6 +25,7 @@ LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event1
SUMMARY:Event
CATEGORIES:some_category1,another_category2
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

View file

@ -0,0 +1,36 @@
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:event10
SUMMARY:Event
CATEGORIES:some_category1,another_category2
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
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,28 @@
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20060102T120000
DURATION:PT1H
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Recurring event
UID:event_daily_rrule
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,31 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20060102
DTEND;TZID=US/Eastern:20060103
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Recurring event
UID:event_full_day_rrule
DTSTAMP:20060102T094829Z
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,16 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VEVENT
UID:event
SUMMARY:Event 1
DTSTART:20130901T190000
DTEND:20130901T200000
END:VEVENT
BEGIN:VEVENT
UID:EVENT
SUMMARY:Event 2
DTSTART:20130901T200000
DTEND:20130901T210000
END:VEVENT
END:VCALENDAR

View file

@ -1,7 +1,8 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2012-2016 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
#
# 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
@ -44,16 +45,6 @@ class TestBaseAuthRequests(BaseTest):
"""Test htpasswd authentication with user "tmp" and password "bepo" for
``test_matrix`` "ascii" or user "😀" and password "🔑" for
``test_matrix`` "unicode"."""
if htpasswd_encryption == "bcrypt":
try:
from passlib.exc import MissingBackendError
from passlib.hash import bcrypt
except ImportError:
pytest.skip("passlib[bcrypt] is not installed")
try:
bcrypt.hash("test-bcrypt-backend")
except MissingBackendError:
pytest.skip("bcrypt backend for passlib is not installed")
htpasswd_file_path = os.path.join(self.colpath, ".htpasswd")
encoding: str = self.configuration.get("encoding", "stock")
with open(htpasswd_file_path, "w", encoding=encoding) as f:
@ -92,6 +83,12 @@ class TestBaseAuthRequests(BaseTest):
self._test_htpasswd(
"md5", "😀:$apr1$w4ev89r1$29xO8EvJmS2HEAadQ5qy11", "unicode")
def test_htpasswd_sha256(self) -> None:
self._test_htpasswd("sha256", "tmp:$5$i4Ni4TQq6L5FKss5$ilpTjkmnxkwZeV35GB9cYSsDXTALBn6KtWRJAzNlCL/")
def test_htpasswd_sha512(self) -> None:
self._test_htpasswd("sha512", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
def test_htpasswd_bcrypt(self) -> None:
self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3V"
"NTRI3w5KDnj8NTUKJNWfVpvRq")
@ -118,6 +115,16 @@ class TestBaseAuthRequests(BaseTest):
def test_htpasswd_comment(self) -> None:
self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n")
def test_htpasswd_lc_username(self) -> None:
self.configure({"auth": {"lc_username": "True"}})
self._test_htpasswd("plain", "tmp:bepo", (
("tmp", "bepo", True), ("TMP", "bepo", True), ("tmp1", "bepo", False)))
def test_htpasswd_strip_domain(self) -> None:
self.configure({"auth": {"strip_domain": "True"}})
self._test_htpasswd("plain", "tmp:bepo", (
("tmp", "bepo", True), ("tmp@domain.example", "bepo", True), ("tmp1", "bepo", False)))
def test_remote_user(self) -> None:
self.configure({"auth": {"type": "remote_user"}})
_, responses = self.propfind("/", """\
@ -156,3 +163,11 @@ class TestBaseAuthRequests(BaseTest):
"""Custom authentication."""
self.configure({"auth": {"type": "radicale.tests.custom.auth"}})
self.propfind("/tmp/", login="tmp:")
def test_none(self) -> None:
self.configure({"auth": {"type": "none"}})
self.propfind("/tmp/", login="tmp:")
def test_denyall(self) -> None:
self.configure({"auth": {"type": "denyall"}})
self.propfind("/tmp/", login="tmp:", check=401)

View file

@ -25,6 +25,7 @@ import posixpath
from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
import defusedxml.ElementTree as DefusedET
import vobject
from radicale import storage, xmlutils
from radicale.tests import RESPONSES, BaseTest
@ -37,8 +38,8 @@ class TestBaseRequests(BaseTest):
# Allow skipping sync-token tests, when not fully supported by the backend
full_sync_token_support: ClassVar[bool] = True
def setup(self) -> None:
BaseTest.setup(self)
def setup_method(self) -> None:
BaseTest.setup_method(self)
rights_file_path = os.path.join(self.colpath, "rights")
with open(rights_file_path, "w") as f:
f.write("""\
@ -243,6 +244,13 @@ permissions: RrWw""")
for uid2 in uids[i + 1:]:
assert uid1 != uid2
def test_put_whole_calendar_case_sensitive_uids(self) -> None:
"""Create a whole calendar with case-sensitive UIDs."""
events = get_file_content("event_multiple_case_sensitive_uids.ics")
self.put("/calendar.ics/", events)
_, answer = self.get("/calendar.ics/")
assert "\r\nUID:event\r\n" in answer and "\r\nUID:EVENT\r\n" in answer
def test_put_whole_addressbook(self) -> None:
"""Create and overwrite a whole addressbook."""
contacts = get_file_content("contact_multiple.vcf")
@ -348,11 +356,11 @@ permissions: RrWw""")
path2 = "/calendar.ics/event2.ics"
self.put(path1, event)
self.request("MOVE", path1, check=201,
HTTP_DESTINATION=path2, HTTP_HOST="")
HTTP_DESTINATION="http://127.0.0.1/"+path2)
self.get(path1, check=404)
self.get(path2)
def test_move_between_colections(self) -> None:
def test_move_between_collections(self) -> None:
"""Move a item."""
self.mkcalendar("/calendar1.ics/")
self.mkcalendar("/calendar2.ics/")
@ -361,11 +369,11 @@ permissions: RrWw""")
path2 = "/calendar2.ics/event2.ics"
self.put(path1, event)
self.request("MOVE", path1, check=201,
HTTP_DESTINATION=path2, HTTP_HOST="")
HTTP_DESTINATION="http://127.0.0.1/"+path2)
self.get(path1, check=404)
self.get(path2)
def test_move_between_colections_duplicate_uid(self) -> None:
def test_move_between_collections_duplicate_uid(self) -> None:
"""Move a item to a collection which already contains the UID."""
self.mkcalendar("/calendar1.ics/")
self.mkcalendar("/calendar2.ics/")
@ -375,13 +383,13 @@ permissions: RrWw""")
self.put(path1, event)
self.put("/calendar2.ics/event1.ics", event)
status, _, answer = self.request(
"MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="")
"MOVE", path1, HTTP_DESTINATION="http://127.0.0.1/"+path2)
assert status in (403, 409)
xml = DefusedET.fromstring(answer)
assert xml.tag == xmlutils.make_clark("D:error")
assert xml.find(xmlutils.make_clark("C:no-uid-conflict")) is not None
def test_move_between_colections_overwrite(self) -> None:
def test_move_between_collections_overwrite(self) -> None:
"""Move a item to a collection which already contains the item."""
self.mkcalendar("/calendar1.ics/")
self.mkcalendar("/calendar2.ics/")
@ -391,12 +399,12 @@ permissions: RrWw""")
self.put(path1, event)
self.put(path2, event)
self.request("MOVE", path1, check=412,
HTTP_DESTINATION=path2, HTTP_HOST="")
self.request("MOVE", path1, check=204,
HTTP_DESTINATION=path2, HTTP_HOST="", HTTP_OVERWRITE="T")
HTTP_DESTINATION="http://127.0.0.1/"+path2)
self.request("MOVE", path1, check=204, HTTP_OVERWRITE="T",
HTTP_DESTINATION="http://127.0.0.1/"+path2)
def test_move_between_colections_overwrite_uid_conflict(self) -> None:
"""Move a item to a collection which already contains the item with
def test_move_between_collections_overwrite_uid_conflict(self) -> None:
"""Move an item to a collection which already contains the item with
a different UID."""
self.mkcalendar("/calendar1.ics/")
self.mkcalendar("/calendar2.ics/")
@ -406,8 +414,9 @@ permissions: RrWw""")
path2 = "/calendar2.ics/event2.ics"
self.put(path1, event1)
self.put(path2, event2)
status, _, answer = self.request("MOVE", path1, HTTP_DESTINATION=path2,
HTTP_HOST="", HTTP_OVERWRITE="T")
status, _, answer = self.request(
"MOVE", path1, HTTP_OVERWRITE="T",
HTTP_DESTINATION="http://127.0.0.1/"+path2)
assert status in (403, 409)
xml = DefusedET.fromstring(answer)
assert xml.tag == xmlutils.make_clark("D:error")
@ -909,6 +918,22 @@ permissions: RrWw""")
<C:text-match>event</C:text-match>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>"""])
assert "/calendar.ics/event1.ics" in self._test_filter(["""\
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="CATEGORIES">
<C:text-match>some_category1</C:text-match>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>"""])
assert "/calendar.ics/event1.ics" in self._test_filter(["""\
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="CATEGORIES">
<C:text-match collation="i;octet">some_category1</C:text-match>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>"""])
assert "/calendar.ics/event1.ics" not in self._test_filter(["""\
<C:comp-filter name="VCALENDAR">
@ -1336,10 +1361,45 @@ permissions: RrWw""")
</C:calendar-query>""")
assert len(responses) == 1
response = responses[event_path]
assert not isinstance(response, int)
assert isinstance(response, dict)
status, prop = response["D:getetag"]
assert status == 200 and prop.text
def test_report_free_busy(self) -> None:
"""Test free busy report on a few items"""
calendar_path = "/calendar.ics/"
self.mkcalendar(calendar_path)
for i in (1, 2, 10):
filename = "event{}.ics".format(i)
event = get_file_content(filename)
self.put(posixpath.join(calendar_path, filename), event)
code, responses = self.report(calendar_path, """\
<?xml version="1.0" encoding="utf-8" ?>
<C:free-busy-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<C:time-range start="20130901T140000Z" end="20130908T220000Z"/>
</C:free-busy-query>""", 200, is_xml=False)
for response in responses.values():
assert isinstance(response, vobject.base.Component)
assert len(responses) == 1
vcalendar = list(responses.values())[0]
assert isinstance(vcalendar, vobject.base.Component)
assert len(vcalendar.vfreebusy_list) == 3
types = {}
for vfb in vcalendar.vfreebusy_list:
fbtype_val = vfb.fbtype.value
if fbtype_val not in types:
types[fbtype_val] = 0
types[fbtype_val] += 1
assert types == {'BUSY': 2, 'FREE': 1}
# Test max_freebusy_occurrence limit
self.configure({"reporting": {"max_freebusy_occurrence": 1}})
code, responses = self.report(calendar_path, """\
<?xml version="1.0" encoding="utf-8" ?>
<C:free-busy-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<C:time-range start="20130901T140000Z" end="20130908T220000Z"/>
</C:free-busy-query>""", 400, is_xml=False)
def _report_sync_token(
self, calendar_path: str, sync_token: Optional[str] = None
) -> Tuple[str, RESPONSES]:
@ -1464,7 +1524,7 @@ permissions: RrWw""")
sync_token, responses = self._report_sync_token(calendar_path)
assert len(responses) == 1 and responses[event1_path] == 200
self.request("MOVE", event1_path, check=201,
HTTP_DESTINATION=event2_path, HTTP_HOST="")
HTTP_DESTINATION="http://127.0.0.1/"+event2_path)
sync_token, responses = self._report_sync_token(
calendar_path, sync_token)
if not self.full_sync_token_support and not sync_token:
@ -1483,9 +1543,9 @@ permissions: RrWw""")
sync_token, responses = self._report_sync_token(calendar_path)
assert len(responses) == 1 and responses[event1_path] == 200
self.request("MOVE", event1_path, check=201,
HTTP_DESTINATION=event2_path, HTTP_HOST="")
HTTP_DESTINATION="http://127.0.0.1/"+event2_path)
self.request("MOVE", event2_path, check=201,
HTTP_DESTINATION=event1_path, HTTP_HOST="")
HTTP_DESTINATION="http://127.0.0.1/"+event1_path)
sync_token, responses = self._report_sync_token(
calendar_path, sync_token)
if not self.full_sync_token_support and not sync_token:
@ -1501,6 +1561,184 @@ permissions: RrWw""")
calendar_path, "http://radicale.org/ns/sync/INVALID")
assert not sync_token
def test_report_with_expand_property(self) -> None:
"""Test report with expand property"""
self.put("/calendar.ics/", get_file_content("event_daily_rrule.ics"))
req_body_without_expand = \
"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060103T000000Z" end="20060105T000000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
_, responses = self.report("/calendar.ics/", req_body_without_expand)
assert len(responses) == 1
response_without_expand = responses['/calendar.ics/event_daily_rrule.ics']
assert not isinstance(response_without_expand, int)
status, element = response_without_expand["C:calendar-data"]
assert status == 200 and element.text
assert "RRULE" in element.text
assert "BEGIN:VTIMEZONE" in element.text
assert "RECURRENCE-ID" not in element.text
uids: List[str] = []
for line in element.text.split("\n"):
if line.startswith("UID:"):
uid = line[len("UID:"):]
assert uid == "event_daily_rrule"
uids.append(uid)
assert len(uids) == 1
req_body_with_expand = \
"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="20060103T000000Z" end="20060105T000000Z"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060103T000000Z" end="20060105T000000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
_, responses = self.report("/calendar.ics/", req_body_with_expand)
assert len(responses) == 1
response_with_expand = responses['/calendar.ics/event_daily_rrule.ics']
assert not isinstance(response_with_expand, int)
status, element = response_with_expand["C:calendar-data"]
assert status == 200 and element.text
assert "RRULE" not in element.text
assert "BEGIN:VTIMEZONE" not in element.text
uids = []
recurrence_ids = []
for line in element.text.split("\n"):
if line.startswith("UID:"):
assert line == "UID:event_daily_rrule"
uids.append(line)
if line.startswith("RECURRENCE-ID:"):
assert line in ["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"]
recurrence_ids.append(line)
if line.startswith("DTSTART:"):
assert line == "DTSTART:20060102T170000Z"
assert len(uids) == 2
assert len(set(recurrence_ids)) == 2
def test_report_with_expand_property_all_day_event(self) -> None:
"""Test report with expand property"""
self.put("/calendar.ics/", get_file_content("event_full_day_rrule.ics"))
req_body_without_expand = \
"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060103T000000Z" end="20060105T000000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
_, responses = self.report("/calendar.ics/", req_body_without_expand)
assert len(responses) == 1
response_without_expand = responses['/calendar.ics/event_full_day_rrule.ics']
assert not isinstance(response_without_expand, int)
status, element = response_without_expand["C:calendar-data"]
assert status == 200 and element.text
assert "RRULE" in element.text
assert "RECURRENCE-ID" not in element.text
uids: List[str] = []
for line in element.text.split("\n"):
if line.startswith("UID:"):
uid = line[len("UID:"):]
assert uid == "event_full_day_rrule"
uids.append(uid)
assert len(uids) == 1
req_body_with_expand = \
"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="20060103T000000Z" end="20060105T000000Z"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060103T000000Z" end="20060105T000000Z"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"""
_, responses = self.report("/calendar.ics/", req_body_with_expand)
assert len(responses) == 1
response_with_expand = responses['/calendar.ics/event_full_day_rrule.ics']
assert not isinstance(response_with_expand, int)
status, element = response_with_expand["C:calendar-data"]
assert status == 200 and element.text
assert "RRULE" not in element.text
assert "BEGIN:VTIMEZONE" not in element.text
uids = []
recurrence_ids = []
for line in element.text.split("\n"):
if line.startswith("UID:"):
assert line == "UID:event_full_day_rrule"
uids.append(line)
if line.startswith("RECURRENCE-ID:"):
assert line in ["RECURRENCE-ID:20060103", "RECURRENCE-ID:20060104", "RECURRENCE-ID:20060105"]
recurrence_ids.append(line)
if line.startswith("DTSTART:"):
assert line == "DTSTART:20060102"
if line.startswith("DTEND:"):
assert line == "DTEND:20060103"
assert len(uids) == 3
assert len(set(recurrence_ids)) == 3
def test_propfind_sync_token(self) -> None:
"""Retrieve the sync-token with a propfind request"""
calendar_path = "/calendar.ics/"

View file

@ -31,10 +31,10 @@ class TestConfig:
colpath: str
def setup(self) -> None:
def setup_method(self) -> None:
self.colpath = tempfile.mkdtemp()
def teardown(self) -> None:
def teardown_method(self) -> None:
shutil.rmtree(self.colpath)
def _write_config(self, config_dict: types.CONFIG, name: str) -> str:

View file

@ -28,7 +28,8 @@ import sys
import threading
import time
from configparser import RawConfigParser
from typing import Callable, Dict, NoReturn, Optional, Tuple, cast
from http.client import HTTPMessage
from typing import IO, Callable, Dict, Optional, Tuple, cast
from urllib import request
from urllib.error import HTTPError, URLError
@ -40,26 +41,10 @@ from radicale.tests.helpers import configuration_to_dict, get_file_path
class DisabledRedirectHandler(request.HTTPRedirectHandler):
# HACK: typeshed annotation are wrong for `fp` and `msg`
# (https://github.com/python/typeshed/pull/5728)
# `headers` is incompatible with `http.client.HTTPMessage`
# (https://github.com/python/typeshed/issues/5729)
def http_error_301(self, req: request.Request, fp, code: int,
msg, headers) -> NoReturn:
raise HTTPError(req.full_url, code, msg, headers, fp)
def http_error_302(self, req: request.Request, fp, code: int,
msg, headers) -> NoReturn:
raise HTTPError(req.full_url, code, msg, headers, fp)
def http_error_303(self, req: request.Request, fp, code: int,
msg, headers) -> NoReturn:
raise HTTPError(req.full_url, code, msg, headers, fp)
def http_error_307(self, req: request.Request, fp, code: int,
msg, headers) -> NoReturn:
raise HTTPError(req.full_url, code, msg, headers, fp)
def redirect_request(
self, req: request.Request, fp: IO[bytes], code: int, msg: str,
headers: HTTPMessage, newurl: str) -> None:
return None
class TestBaseServerRequests(BaseTest):
@ -69,14 +54,15 @@ class TestBaseServerRequests(BaseTest):
thread: threading.Thread
opener: request.OpenerDirector
def setup(self) -> None:
super().setup()
def setup_method(self) -> None:
super().setup_method()
self.shutdown_socket, shutdown_socket_out = socket.socketpair()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
# Find available port
sock.bind(("127.0.0.1", 0))
self.sockfamily = socket.AF_INET
self.sockname = sock.getsockname()
self.configure({"server": {"hosts": "[%s]:%d" % self.sockname},
self.configure({"server": {"hosts": "%s:%d" % self.sockname},
# Enable debugging for new processes
"logging": {"level": "debug"}})
self.thread = threading.Thread(target=server.serve, args=(
@ -88,13 +74,13 @@ class TestBaseServerRequests(BaseTest):
request.HTTPSHandler(context=ssl_context),
DisabledRedirectHandler)
def teardown(self) -> None:
def teardown_method(self) -> None:
self.shutdown_socket.close()
try:
self.thread.join()
except RuntimeError: # Thread never started
pass
super().teardown()
super().teardown_method()
def request(self, method: str, path: str, data: Optional[str] = None,
check: Optional[int] = None, **kwargs
@ -120,8 +106,12 @@ class TestBaseServerRequests(BaseTest):
data_bytes = None
if data:
data_bytes = data.encode(encoding)
if self.sockfamily == socket.AF_INET6:
req_host = ("[%s]" % self.sockname[0])
else:
req_host = self.sockname[0]
req = request.Request(
"%s://[%s]:%d%s" % (scheme, *self.sockname, path),
"%s://%s:%d%s" % (scheme, req_host, self.sockname[1], path),
data=data_bytes, headers=headers, method=method)
while True:
assert is_alive_fn()
@ -176,6 +166,7 @@ class TestBaseServerRequests(BaseTest):
server.COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
# Find available port
sock.bind(("::1", 0))
self.sockfamily = socket.AF_INET6
self.sockname = sock.getsockname()[:2]
except OSError as e:
if e.errno in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT,

View file

@ -35,8 +35,8 @@ from radicale.tests.test_base import TestBaseRequests as _TestBaseRequests
class TestMultiFileSystem(BaseTest):
"""Tests for multifilesystem."""
def setup(self) -> None:
_TestBaseRequests.setup(cast(_TestBaseRequests, self))
def setup_method(self) -> None:
_TestBaseRequests.setup_method(cast(_TestBaseRequests, self))
self.configure({"storage": {"type": "multifilesystem"}})
def test_folder_creation(self) -> None:
@ -150,8 +150,8 @@ class TestMultiFileSystem(BaseTest):
class TestMultiFileSystemNoLock(BaseTest):
"""Tests for multifilesystem_nolock."""
def setup(self) -> None:
_TestBaseRequests.setup(cast(_TestBaseRequests, self))
def setup_method(self) -> None:
_TestBaseRequests.setup_method(cast(_TestBaseRequests, self))
self.configure({"storage": {"type": "multifilesystem_nolock"}})
test_add_event = _TestBaseRequests.test_add_event
@ -161,8 +161,8 @@ class TestMultiFileSystemNoLock(BaseTest):
class TestCustomStorageSystem(BaseTest):
"""Test custom backend loading."""
def setup(self) -> None:
_TestBaseRequests.setup(cast(_TestBaseRequests, self))
def setup_method(self) -> None:
_TestBaseRequests.setup_method(cast(_TestBaseRequests, self))
self.configure({"storage": {
"type": "radicale.tests.custom.storage_simple_sync"}})
@ -181,8 +181,8 @@ class TestCustomStorageSystem(BaseTest):
class TestCustomStorageSystemCallable(BaseTest):
"""Test custom backend loading with ``callable``."""
def setup(self) -> None:
_TestBaseRequests.setup(cast(_TestBaseRequests, self))
def setup_method(self) -> None:
_TestBaseRequests.setup_method(cast(_TestBaseRequests, self))
self.configure({"storage": {
"type": radicale.tests.custom.storage_simple_sync.Storage}})

View file

@ -50,8 +50,8 @@ if sys.version_info >= (3, 8):
@runtime_checkable
class ErrorStream(Protocol):
def flush(self) -> None: ...
def write(self, s: str) -> None: ...
def flush(self) -> object: ...
def write(self, s: str) -> object: ...
else:
ErrorStream = Any
InputStream = Any

View file

@ -16,12 +16,18 @@
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
import sys
from importlib import import_module
from typing import Callable, Sequence, Type, TypeVar, Union
from radicale import config
from radicale.log import logger
if sys.version_info < (3, 8):
import pkg_resources
else:
from importlib import metadata
_T_co = TypeVar("_T_co", covariant=True)
@ -43,3 +49,9 @@ def load_plugin(internal_types: Sequence[str], module_name: str,
(module_name, module, e)) from e
logger.info("%s type is %r", module_name, module)
return class_(configuration)
def package_version(name):
if sys.version_info < (3, 8):
return pkg_resources.get_distribution(name).version
return metadata.version(name)

View file

@ -25,9 +25,7 @@ Features:
"""
import pkg_resources
from radicale import config, httputils, types, web
from radicale import httputils, types, web
MIMETYPES = httputils.MIMETYPES # deprecated
FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated
@ -35,13 +33,7 @@ FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated
class Web(web.BaseWeb):
folder: str
def __init__(self, configuration: config.Configuration) -> None:
super().__init__(configuration)
self.folder = pkg_resources.resource_filename(
__name__, "internal_data")
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse:
return httputils.serve_folder(self.folder, base_prefix, path)
return httputils.serve_resource("radicale.web", "internal_data",
base_prefix, path)

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M20 9l-1.995 11.346A2 2 0 0116.035 22h-8.07a2 2 0 01-1.97-1.654L4 9M21 6h-5.625M3 6h5.625m0 0V4a2 2 0 012-2h2.75a2 2 0 012 2v2m-6.75 0h6.75" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 418 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6 20h12M12 4v12m0 0l3.5-3.5M12 16l-3.5-3.5" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M14.363 5.652l1.48-1.48a2 2 0 012.829 0l1.414 1.414a2 2 0 010 2.828l-1.48 1.48m-4.243-4.242l-9.616 9.615a2 2 0 00-.578 1.238l-.242 2.74a1 1 0 001.084 1.085l2.74-.242a2 2 0 001.24-.578l9.615-9.616m-4.243-4.242l4.243 4.242" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 499 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6 12h6m6 0h-6m0 0V6m0 6v6" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 305 B

View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6 20h12M12 16V4m0 0l3.5 3.5M12 4L8.5 7.5" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 320 B

View file

@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1080" height="1080" viewBox="0 0 1080 1080" xml:space="preserve">
<g transform="matrix(10.8 0 0 10.8 540 540)">
<g style="">
<g transform="matrix(2.64 0 0 2.64 0 -42.24)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(78,154,6); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.8026755852842808s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(2.34 1.23 -1.23 2.34 19.63 -37.4)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(113,204,26); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.7357859531772575s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(1.5 2.17 -2.17 1.5 34.76 -24)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(140,225,57); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.6688963210702341s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(0.32 2.62 -2.62 0.32 41.93 -5.09)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(205,255,156); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.6020066889632106s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(-0.94 2.47 -2.47 -0.94 39.5 14.98)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(205,247,166); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.5351170568561873s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(-1.98 1.75 -1.75 -1.98 28.01 31.62)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(252,252,252); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.46822742474916385s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(-2.56 0.63 -0.63 -2.56 10.11 41.01)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(254,254,254); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.4013377926421404s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(-2.56 -0.63 0.63 -2.56 -10.11 41.01)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(244,244,244); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.33444816053511706s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(-1.98 -1.75 1.75 -1.98 -28.01 31.62)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(255,214,214); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.26755852842809363s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(-0.94 -2.47 2.47 -0.94 -39.5 14.98)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(248,111,111); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.2006688963210702s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(0.32 -2.62 2.62 0.32 -41.93 -5.09)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(231,60,60); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.13377926421404682s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(1.5 -2.17 2.17 1.5 -34.76 -24)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(218,33,33); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.06688963210702341s" repeatCount="indefinite"></animate>
</rect>
</g>
<g transform="matrix(2.34 -1.23 1.23 2.34 -19.63 -37.4)">
<rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(164,0,0); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="0s" repeatCount="indefinite"></animate>
</rect>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
<path fill="#a40000" d="M 186,188 C 184,98 34,105 47,192 C 59,279 130,296 130,296 C 130,296 189,277 186,188 z" />
<path fill="#ffffff" d="M 73,238 C 119,242 140,241 177,222 C 172,270 131,288 131,288 C 131,288 88,276 74,238 z" />
<g fill="none" stroke="#4e9a06" stroke-width="15">
<path d="M 103,137 C 77,69 13,62 13,62" />
<path d="M 105,136 C 105,86 37,20 37,20" />
<path d="M 105,135 C 112,73 83,17 83,17" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 564 B

View file

@ -1 +1,428 @@
body{background:#e4e9f6;color:#424247;display:flex;flex-direction:column;font-family:sans;font-size:14pt;line-height:1.4;margin:0;min-height:100vh}a{color:inherit}nav,footer{background:#a40000;color:#fff;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 .5em 1em 0;text-decoration:inherit;transition:.2s}nav ul li a:hover,nav ul li a:focus,footer ul li a:hover,footer ul li a:focus{color:#000;outline:none}header{background:url(logo.svg),linear-gradient(to bottom right, #050a02, #000);background-position:22% 45%;background-repeat:no-repeat;color:#efdddd;font-size:1.5em;min-height:250px;overflow:auto;padding:3em 22%;text-shadow:.2em .2em .2em rgba(0,0,0,0.5)}header>*{padding-left:220px}header h1{font-size:2.5em;font-weight:lighter;margin:.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, #000);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:.8em;right:-30%;top:.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:#000;color:#d3d5db;margin:0 -1em;overflow-x:auto;padding:1em}table{border-collapse:collapse;font-size:.8em;margin:auto}table td{border:1px solid #ccc;padding:.5em}dl dt{margin-bottom:.5em;margin-top:1em}p>code,li>code,dt>code{background:#d1daf0}@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 .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 -.8em 1.3em;padding:.5em 0;text-align:center}article aside{top:.5em;right:-1.5em}article:before{left:-2em;right:-2em}}
body{
background: #ffffff;
color: #424247;
font-family: sans-serif;
font-size: 14pt;
margin: 0;
min-height: 100vh;
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-content: center;
align-items: flex-start;
justify-content: space-around;
}
main{
width: 100%;
}
.container{
height: auto;
min-height: 450px;
width: 350px;
transition: .2s;
overflow: hidden;
padding: 20px 40px;
background: #fff;
border: 1px solid #dadce0;
border-radius: 8px;
display: block;
flex-shrink: 0;
margin: 0 auto;
}
.container h1{
margin: 0;
width: 100%;
text-align: center;
color: #484848;
}
#loginscene input{
}
#loginscene .logocontainer{
width: 100%;
text-align: center;
}
#loginscene .logocontainer img{
width: 75px;
}
#loginscene h1{
text-align: center;
font-family: sans-serif;
font-weight: normal;
}
#loginscene button{
float: right;
}
#loadingscene{
width: 100%;
height: 100%;
background: rgb(237 237 237);
position: absolute;
top: 0;
left: 0;
display: flex;
flex-wrap: nowrap;
justify-content: center;
align-items: center;
flex-direction: column;
overflow: hidden;
z-index: 999;
}
#loadingscene h2{
font-size: 2em;
font-weight: bold;
}
#logoutview{
width: 100%;
display: block;
background: white;
text-align: center;
padding: 10px 0px;
color: #666;
border-bottom: 2px solid #dadce0;
position: fixed;
}
#logoutview span{
width: calc(100% - 60px);
display: inline-block;
}
#logoutview a{
color: white;
text-decoration: none;
padding: 3px 10px;
position: relative;
border-radius: 4px;
}
#logoutview a[data-name=logout]{
right: 25px;
float: right;
}
#logoutview a[data-name=refresh]{
left: 25px;
float: left;
}
#collectionsscene{
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
align-items: center;
margin-top: 50px;
width: 100%;
height: 100vh;
}
#collectionsscene article{
width: 275px;
background: rgb(250, 250, 250);
border-radius: 8px;
box-shadow: 2px 2px 3px #0000001a;
border: 1px solid #dadce0;
padding: 5px 10px;
padding-top: 0;
margin: 10px;
float: left;
min-height: 375px;
overflow: hidden;
}
#collectionsscene article .colorbar{
width: 500%;
height: 15px;
margin: 0px -100%;
background: #000000;
}
#collectionsscene article .title{
width: 100%;
text-align: center;
font-size: 1.5em;
display: block;
padding: 10px 0;
margin: 0;
}
#collectionsscene article small{
font-size: 15px;
float: left;
font-weight: normal;
font-style: italic;
padding-bottom: 10px;
width: 100%;
text-align: center;
}
#collectionsscene article input[type=text]{
margin-bottom: 0 !important;
}
#collectionsscene article p{
font-size: 1em;
max-height: 130px;
overflow: overlay;
}
#collectionsscene article:hover ul{
visibility: visible;
}
#collectionsscene ul{
visibility: hidden;
display: flex;
justify-content: space-evenly;
width: 60%;
margin: 0 20%;
padding: 0;
}
#collectionsscene li{
list-style: none;
display: block;
}
#collectionsscene li a{
text-decoration: none !important;
padding: 5px;
float: left;
border-radius: 5px;
width: 25px;
height: 25px;
text-align: center;
}
#collectionsscene article small[data-name=contentcount]{
font-weight: bold;
font-style: normal;
}
#editcollectionscene p span{
word-wrap:break-word;
font-weight: bold;
color: #4e9a06;
}
#deletecollectionscene p span{
word-wrap:break-word;
font-weight: bold;
color: #a40000;
}
#uploadcollectionscene ul{
margin: 10px -30px;
max-height: 600px;
overflow-y: scroll;
}
#uploadcollectionscene li{
border-bottom: 1px dashed #d5d5d5;
margin-bottom: 10px;
padding-bottom: 10px;
}
#uploadcollectionscene div[data-name=pending]{
width: 100%;
text-align: center;
}
#uploadcollectionscene .successmessage{
color: #4e9a06;
width: 100%;
text-align: center;
display: block;
margin-top: 15px;
}
.deleteconfirmationtxt{
text-align: center;
font-size: 1em;
font-weight: bold;
}
.fabcontainer{
display: flex;
flex-direction: column-reverse;
position: fixed;
bottom: 5px;
right: 0;
}
.fabcontainer a{
width: 30px;
height: 30px;
text-decoration: none;
color: white;
border: none !important;
border-radius: 100%;
margin: 5px 10px;
background: black;
text-align: center;
display: flex;
align-content: center;
justify-content: center;
align-items: center;
font-size: 30px;
padding: 10px;
box-shadow: 2px 2px 7px #000000d6;
}
.title{
word-wrap: break-word;
font-weight: bold;
}
.icon{
width: 100%;
height: 100%;
filter: invert(1);
}
.smalltext{
font-size: 75% !important;
}
.error{
width: 100%;
display: block;
text-align: center;
color: rgb(217,48,37);
font-family: sans-serif;
clear: both;
padding-top: 15px;
}
img.loading{
width: 150px;
height: 150px;
}
.error::before{
content: "!";
height: 1em;
color: white;
background: rgb(217,48,37);
font-weight: bold;
border-radius: 100%;
display: inline-block;
width: 1.1em;
margin-right: 5px;
font-size: 1em;
text-align: center;
}
button{
font-size: 1em;
padding: 7px 21px;
color: white;
border-radius: 4px;
float: right;
margin-left: 10px;
background: black;
cursor: pointer;
}
input, select{
width: 100%;
height: 3em;
border-style: solid;
border-color: #e6e6e6;
border-width: 1px;
border-radius: 7px;
margin-bottom: 25px;
padding-left: 15px;
padding-right: 15px;
outline: none !important;
}
input[type=text], input[type=password]{
width: calc(100% - 30px);
}
input:active, input:focus, input:focus-visible{
border-color: #2494fe !important;
border-width: 1px !important;
}
p.red, span.red{
color: #b50202;
}
button.red, a.red{
background: #b50202;
border: 1px solid #a40000;
}
button.red:hover, a.red:hover{
background: #a40000;
}
button.red:active, a.red:active{
background: #8f0000;
}
button.green, a.green{
background: #4e9a06;
border: 1px solid #377200;
}
button.green:hover, a.green:hover{
background: #377200;
}
button.green:active, a.green:active{
background: #285200;
}
button.blue, a.blue{
background: #2494fe;
border: 1px solid #055fb5;
}
button.blue:hover, a.blue:hover{
background: #1578d6;
cursor: pointer !important;
}
button.blue:active, a.blue:active{
background: #055fb5;
cursor: pointer !important;
}
@media only screen and (max-width: 600px) {
#collectionsscene{
flex-direction: column !important;
flex-wrap: nowrap;
}
#collectionsscene article{
height: auto;
min-height: 375px;
}
.container{
max-width: 280px !important;
}
#collectionsscene ul{
visibility: visible !important;
}
#logoutview span{
padding: 0 5px;
}
}

View file

@ -1,6 +1,6 @@
/**
* This file is part of Radicale Server - Calendar Server
* Copyright © 2017-2018 Unrud <unrud@outlook.com>
* Copyright © 2017-2024 Unrud <unrud@outlook.com>
*
* 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
@ -28,7 +28,7 @@ const SERVER = location.origin;
* @const
* @type {string}
*/
const ROOT_PATH = (new URL("..", location.href)).pathname;
const ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\\.html?)?$"), "") + '/';
/**
* Regex to match and normalize color
@ -36,6 +36,13 @@ const ROOT_PATH = (new URL("..", location.href)).pathname;
*/
const COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$");
/**
* The text needed to confirm deleting a collection
* @const
*/
const DELETE_CONFIRMATION_TEXT = "DELETE";
/**
* Escape string for usage in XML
* @param {string} s
@ -63,6 +70,7 @@ const CollectionType = {
CALENDAR: "CALENDAR",
JOURNAL: "JOURNAL",
TASKS: "TASKS",
WEBCAL: "WEBCAL",
is_subset: function(a, b) {
let components = a.split("_");
for (let i = 0; i < components.length; i++) {
@ -89,7 +97,27 @@ const CollectionType = {
if (a.search(this.TASKS) !== -1 || b.search(this.TASKS) !== -1) {
union.push(this.TASKS);
}
if (a.search(this.WEBCAL) !== -1 || b.search(this.WEBCAL) !== -1) {
union.push(this.WEBCAL);
}
return union.join("_");
},
valid_options_for_type: function(a){
a = a.trim().toUpperCase();
switch(a){
case CollectionType.CALENDAR_JOURNAL_TASKS:
case CollectionType.CALENDAR_JOURNAL:
case CollectionType.CALENDAR_TASKS:
case CollectionType.JOURNAL_TASKS:
case CollectionType.CALENDAR:
case CollectionType.JOURNAL:
case CollectionType.TASKS:
return [CollectionType.CALENDAR_JOURNAL_TASKS, CollectionType.CALENDAR_JOURNAL, CollectionType.CALENDAR_TASKS, CollectionType.JOURNAL_TASKS, CollectionType.CALENDAR, CollectionType.JOURNAL, CollectionType.TASKS];
case CollectionType.ADDRESSBOOK:
case CollectionType.WEBCAL:
default:
return [a];
}
}
};
@ -102,12 +130,15 @@ const CollectionType = {
* @param {string} description
* @param {string} color
*/
function Collection(href, type, displayname, description, color) {
function Collection(href, type, displayname, description, color, contentcount, size, source) {
this.href = href;
this.type = type;
this.displayname = displayname;
this.color = color;
this.description = description;
this.source = source;
this.contentcount = contentcount;
this.size = size;
}
/**
@ -119,7 +150,7 @@ function Collection(href, type, displayname, description, color) {
*/
function get_principal(user, password, callback) {
let request = new XMLHttpRequest();
request.open("PROPFIND", SERVER + ROOT_PATH, true, user, password);
request.open("PROPFIND", SERVER + ROOT_PATH, true, user, encodeURIComponent(password));
request.onreadystatechange = function() {
if (request.readyState !== 4) {
return;
@ -134,6 +165,7 @@ function get_principal(user, password, callback) {
CollectionType.PRINCIPAL,
displayname_element ? displayname_element.textContent : "",
"",
0,
""), null);
} else {
callback(null, "Internal error");
@ -162,7 +194,7 @@ function get_principal(user, password, callback) {
*/
function get_collections(user, password, collection, callback) {
let request = new XMLHttpRequest();
request.open("PROPFIND", SERVER + collection.href, true, user, password);
request.open("PROPFIND", SERVER + collection.href, true, user, encodeURIComponent(password));
request.setRequestHeader("depth", "1");
request.onreadystatechange = function() {
if (request.readyState !== 4) {
@ -183,6 +215,9 @@ function get_collections(user, password, collection, callback) {
let addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color");
let calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description");
let addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description");
let contentcount_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentcount");
let contentlength_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentlength");
let webcalsource_element = response.querySelector(response_query + " > *|propstat > *|prop > *|source");
let components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set";
let components_element = response.querySelector(components_query);
let href = href_element ? href_element.textContent : "";
@ -190,11 +225,21 @@ function get_collections(user, password, collection, callback) {
let type = "";
let color = "";
let description = "";
let source = "";
let count = 0;
let size = 0;
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 : "";
count = contentcount_element ? parseInt(contentcount_element.textContent) : 0;
size = contentlength_element ? parseInt(contentlength_element.textContent) : 0;
} else if (resourcetype_element.querySelector(resourcetype_query + " > *|subscribed")) {
type = CollectionType.WEBCAL;
source = webcalsource_element ? webcalsource_element.textContent : "";
color = calendarcolor_element ? calendarcolor_element.textContent : "";
description = calendardesc_element ? calendardesc_element.textContent : "";
} else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) {
if (components_element) {
if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) {
@ -209,6 +254,8 @@ function get_collections(user, password, collection, callback) {
}
color = calendarcolor_element ? calendarcolor_element.textContent : "";
description = calendardesc_element ? calendardesc_element.textContent : "";
count = contentcount_element ? parseInt(contentcount_element.textContent) : 0;
size = contentlength_element ? parseInt(contentlength_element.textContent) : 0;
}
}
let sane_color = color.trim();
@ -221,7 +268,7 @@ function get_collections(user, password, collection, callback) {
}
}
if (href.substr(-1) === "/" && href !== collection.href && type) {
collections.push(new Collection(href, type, displayname, description, sane_color));
collections.push(new Collection(href, type, displayname, description, sane_color, count, size, source));
}
}
collections.sort(function(a, b) {
@ -235,11 +282,15 @@ function get_collections(user, password, collection, callback) {
}
};
request.send('<?xml version="1.0" encoding="utf-8" ?>' +
'<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" ' +
'<propfind ' +
'xmlns="DAV:" ' +
'xmlns:C="urn:ietf:params:xml:ns:caldav" ' +
'xmlns:CR="urn:ietf:params:xml:ns:carddav" ' +
'xmlns:CS="http://calendarserver.org/ns/" ' +
'xmlns:I="http://apple.com/ns/ical/" ' +
'xmlns:INF="http://inf-it.com/ns/ab/" ' +
'xmlns:RADICALE="http://radicale.org/ns/">' +
'xmlns:RADICALE="http://radicale.org/ns/"' +
'>' +
'<prop>' +
'<resourcetype />' +
'<RADICALE:displayname />' +
@ -248,6 +299,9 @@ function get_collections(user, password, collection, callback) {
'<C:calendar-description />' +
'<C:supported-calendar-component-set />' +
'<CR:addressbook-description />' +
'<CS:source />' +
'<RADICALE:getcontentcount />' +
'<getcontentlength />' +
'</prop>' +
'</propfind>');
return request;
@ -263,7 +317,7 @@ function get_collections(user, password, collection, callback) {
*/
function upload_collection(user, password, collection_href, file, callback) {
let request = new XMLHttpRequest();
request.open("PUT", SERVER + collection_href, true, user, password);
request.open("PUT", SERVER + collection_href, true, user, encodeURIComponent(password));
request.onreadystatechange = function() {
if (request.readyState !== 4) {
return;
@ -288,7 +342,7 @@ function upload_collection(user, password, collection_href, file, callback) {
*/
function delete_collection(user, password, collection, callback) {
let request = new XMLHttpRequest();
request.open("DELETE", SERVER + collection.href, true, user, password);
request.open("DELETE", SERVER + collection.href, true, user, encodeURIComponent(password));
request.onreadystatechange = function() {
if (request.readyState !== 4) {
return;
@ -313,7 +367,7 @@ function delete_collection(user, password, collection, callback) {
*/
function create_edit_collection(user, password, collection, create, callback) {
let request = new XMLHttpRequest();
request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, password);
request.open(create ? "MKCOL" : "PROPPATCH", SERVER + collection.href, true, user, encodeURIComponent(password));
request.onreadystatechange = function() {
if (request.readyState !== 4) {
return;
@ -329,12 +383,18 @@ function create_edit_collection(user, password, collection, create, callback) {
let addressbook_color = "";
let calendar_description = "";
let addressbook_description = "";
let calendar_source = "";
let resourcetype;
let components = "";
if (collection.type === CollectionType.ADDRESSBOOK) {
addressbook_color = escape_xml(collection.color + (collection.color ? "ff" : ""));
addressbook_description = escape_xml(collection.description);
resourcetype = '<CR:addressbook />';
} else if (collection.type === CollectionType.WEBCAL) {
calendar_color = escape_xml(collection.color + (collection.color ? "ff" : ""));
calendar_description = escape_xml(collection.description);
resourcetype = '<CS:subscribed />';
calendar_source = collection.source;
} else {
calendar_color = escape_xml(collection.color + (collection.color ? "ff" : ""));
calendar_description = escape_xml(collection.description);
@ -351,7 +411,7 @@ function create_edit_collection(user, password, collection, create, callback) {
}
let xml_request = create ? "mkcol" : "propertyupdate";
request.send('<?xml version="1.0" encoding="UTF-8" ?>' +
'<' + 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/">' +
'<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' +
'<set>' +
'<prop>' +
(create ? '<resourcetype><collection />' + resourcetype + '</resourcetype>' : '') +
@ -361,6 +421,7 @@ function create_edit_collection(user, password, collection, create, callback) {
(addressbook_color ? '<INF:addressbook-color>' + addressbook_color + '</INF:addressbook-color>' : '') +
(addressbook_description ? '<CR:addressbook-description>' + addressbook_description + '</CR:addressbook-description>' : '') +
(calendar_description ? '<C:calendar-description>' + calendar_description + '</C:calendar-description>' : '') +
(calendar_source ? '<CS:source>' + calendar_source + '</CS:source>' : '') +
'</prop>' +
'</set>' +
(!create ? ('<remove>' +
@ -481,7 +542,8 @@ function LoginScene() {
let error_form = html_scene.querySelector("[data-name=error]");
let logout_view = document.getElementById("logoutview");
let logout_user_form = logout_view.querySelector("[data-name=user]");
let logout_btn = logout_view.querySelector("[data-name=link]");
let logout_btn = logout_view.querySelector("[data-name=logout]");
let refresh_btn = logout_view.querySelector("[data-name=refresh]");
/** @type {?number} */ let scene_index = null;
let user = "";
@ -495,7 +557,12 @@ function LoginScene() {
function fill_form() {
user_form.value = user;
password_form.value = "";
error_form.textContent = error ? "Error: " + error : "";
if(error){
error_form.textContent = "Error: " + error;
error_form.classList.remove("hidden");
}else{
error_form.classList.add("hidden");
}
}
function onlogin() {
@ -507,7 +574,8 @@ function LoginScene() {
// setup logout
logout_view.classList.remove("hidden");
logout_btn.onclick = onlogout;
logout_user_form.textContent = user;
refresh_btn.onclick = refresh;
logout_user_form.textContent = user + "'s Collections";
// Fetch principal
let loading_scene = new LoadingScene();
push_scene(loading_scene, false);
@ -557,9 +625,17 @@ function LoginScene() {
function remove_logout() {
logout_view.classList.add("hidden");
logout_btn.onclick = null;
refresh_btn.onclick = null;
logout_user_form.textContent = "";
}
function refresh(){
//The easiest way to refresh is to push a LoadingScene onto the stack and then pop it
//forcing the scene below it, the Collections Scene to refresh itself.
push_scene(new LoadingScene(), false);
pop_scene(scene_stack.length-2);
}
this.show = function() {
remove_logout();
fill_form();
@ -618,12 +694,6 @@ function CollectionsScene(user, password, collection, onerror) {
/** @type {?XMLHttpRequest} */ let collections_req = null;
/** @type {?Array<Collection>} */ let collections = null;
/** @type {Array<Node>} */ let nodes = [];
let filesInput = document.createElement("input");
filesInput.setAttribute("type", "file");
filesInput.setAttribute("accept", ".ics, .vcf");
filesInput.setAttribute("multiple", "");
let filesInputForm = document.createElement("form");
filesInputForm.appendChild(filesInput);
function onnew() {
try {
@ -636,17 +706,9 @@ function CollectionsScene(user, password, collection, onerror) {
}
function onupload() {
filesInput.click();
return false;
}
function onfileschange() {
try {
let files = filesInput.files;
if (files.length > 0) {
let upload_scene = new UploadCollectionScene(user, password, collection, files);
push_scene(upload_scene);
}
let upload_scene = new UploadCollectionScene(user, password, collection);
push_scene(upload_scene);
} catch(err) {
console.error(err);
}
@ -674,21 +736,24 @@ function CollectionsScene(user, password, collection, onerror) {
}
function show_collections(collections) {
let heightOfNavBar = document.querySelector("#logoutview").offsetHeight + "px";
html_scene.style.marginTop = heightOfNavBar;
html_scene.style.height = "calc(100vh - " + heightOfNavBar +")";
collections.forEach(function (collection) {
let node = template.cloneNode(true);
node.classList.remove("hidden");
let title_form = node.querySelector("[data-name=title]");
let description_form = node.querySelector("[data-name=description]");
let contentcount_form = node.querySelector("[data-name=contentcount]");
let url_form = node.querySelector("[data-name=url]");
let color_form = node.querySelector("[data-name=color]");
let delete_btn = node.querySelector("[data-name=delete]");
let edit_btn = node.querySelector("[data-name=edit]");
let download_btn = node.querySelector("[data-name=download]");
if (collection.color) {
color_form.style.color = collection.color;
} else {
color_form.classList.add("hidden");
color_form.style.background = collection.color;
}
let possible_types = [CollectionType.ADDRESSBOOK];
let possible_types = [CollectionType.ADDRESSBOOK, CollectionType.WEBCAL];
[CollectionType.CALENDAR, ""].forEach(function(e) {
[CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function(e) {
[CollectionType.union(e, CollectionType.TASKS), e].forEach(function(e) {
@ -704,10 +769,26 @@ function CollectionsScene(user, password, collection, onerror) {
}
});
title_form.textContent = collection.displayname || collection.href;
if(title_form.textContent.length > 30){
title_form.classList.add("smalltext");
}
description_form.textContent = collection.description;
if(description_form.textContent.length > 150){
description_form.classList.add("smalltext");
}
if(collection.type != CollectionType.WEBCAL){
let contentcount_form_txt = (collection.contentcount > 0 ? Number(collection.contentcount).toLocaleString() : "No") + " item" + (collection.contentcount == 1 ? "" : "s") + " in collection";
if(collection.contentcount > 0){
contentcount_form_txt += " (" + bytesToHumanReadable(collection.size) + ")";
}
contentcount_form.textContent = contentcount_form_txt;
}
let href = SERVER + collection.href;
url_form.href = href;
url_form.textContent = href;
url_form.value = href;
download_btn.href = href;
if(collection.type == CollectionType.WEBCAL){
download_btn.parentElement.classList.add("hidden");
}
delete_btn.onclick = function() {return ondelete(collection);};
edit_btn.onclick = function() {return onedit(collection);};
node.classList.remove("hidden");
@ -738,8 +819,6 @@ function CollectionsScene(user, password, collection, onerror) {
html_scene.classList.remove("hidden");
new_btn.onclick = onnew;
upload_btn.onclick = onupload;
filesInputForm.reset();
filesInput.onchange = onfileschange;
if (collections === null) {
update();
} else {
@ -752,7 +831,6 @@ function CollectionsScene(user, password, collection, onerror) {
scene_index = scene_stack.length - 1;
new_btn.onclick = null;
upload_btn.onclick = null;
filesInput.onchange = null;
collections = null;
// remove collection
nodes.forEach(function(node) {
@ -767,7 +845,6 @@ function CollectionsScene(user, password, collection, onerror) {
collections_req = null;
}
collections = null;
filesInputForm.reset();
};
}
@ -779,43 +856,89 @@ function CollectionsScene(user, password, collection, onerror) {
* @param {Collection} collection parent collection
* @param {Array<File>} files
*/
function UploadCollectionScene(user, password, collection, files) {
function UploadCollectionScene(user, password, collection) {
let html_scene = document.getElementById("uploadcollectionscene");
let template = html_scene.querySelector("[data-name=filetemplate]");
let upload_btn = html_scene.querySelector("[data-name=submit]");
let close_btn = html_scene.querySelector("[data-name=close]");
let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]");
let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]");
let href_form = html_scene.querySelector("[data-name=href]");
let href_label = html_scene.querySelector("label[for=href]");
let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]");
let pending_html = html_scene.querySelector("[data-name=pending]");
let files = uploadfile_form.files;
href_form.addEventListener("keydown", cleanHREFinput);
upload_btn.onclick = upload_start;
uploadfile_form.onchange = onfileschange;
let href = random_uuid();
href_form.value = href;
/** @type {?number} */ let scene_index = null;
/** @type {?XMLHttpRequest} */ let upload_req = null;
/** @type {Array<string>} */ let errors = [];
/** @type {Array<string>} */ let results = [];
/** @type {?Array<Node>} */ let nodes = null;
function upload_next() {
function upload_start() {
try {
if (files.length === errors.length) {
if (errors.every(error => error === null)) {
pop_scene(scene_index - 1);
} else {
close_btn.classList.remove("hidden");
}
} else {
let file = files[errors.length];
let upload_href = collection.href + random_uuid() + "/";
upload_req = upload_collection(user, password, upload_href, file, function(error) {
if (scene_index === null) {
return;
}
upload_req = null;
errors.push(error);
updateFileStatus(errors.length - 1);
upload_next();
});
if(!read_form()){
return false;
}
uploadfile_form.classList.add("hidden");
uploadfile_lbl.classList.add("hidden");
href_form.classList.add("hidden");
href_label.classList.add("hidden");
hreflimitmsg_html.classList.add("hidden");
upload_btn.classList.add("hidden");
close_btn.classList.add("hidden");
pending_html.classList.remove("hidden");
nodes = [];
for (let i = 0; i < files.length; i++) {
let file = files[i];
let node = template.cloneNode(true);
node.classList.remove("hidden");
let name_form = node.querySelector("[data-name=name]");
name_form.textContent = file.name;
node.classList.remove("hidden");
nodes.push(node);
updateFileStatus(i);
template.parentNode.insertBefore(node, template);
}
upload_next();
} catch(err) {
console.error(err);
}
return false;
}
function upload_next(){
try{
if (files.length === results.length) {
pending_html.classList.add("hidden");
close_btn.classList.remove("hidden");
return;
} else {
let file = files[results.length];
if(files.length > 1 || href.length == 0){
href = random_uuid();
}
let upload_href = collection.href + "/" + href + "/";
upload_req = upload_collection(user, password, upload_href, file, function(result) {
upload_req = null;
results.push(result);
updateFileStatus(results.length - 1);
upload_next();
});
}
}catch(err){
console.error(err);
}
}
function onclose() {
try {
pop_scene(scene_index - 1);
@ -829,54 +952,77 @@ function UploadCollectionScene(user, password, collection, files) {
if (nodes === null) {
return;
}
let pending_form = nodes[i].querySelector("[data-name=pending]");
let success_form = nodes[i].querySelector("[data-name=success]");
let error_form = nodes[i].querySelector("[data-name=error]");
if (errors.length > i) {
pending_form.classList.add("hidden");
if (errors[i]) {
if (results.length > i) {
if (results[i]) {
success_form.classList.add("hidden");
error_form.textContent = "Error: " + errors[i];
error_form.textContent = "Error: " + results[i];
error_form.classList.remove("hidden");
} else {
success_form.classList.remove("hidden");
error_form.classList.add("hidden");
}
} else {
pending_form.classList.remove("hidden");
success_form.classList.add("hidden");
error_form.classList.add("hidden");
}
}
function read_form() {
cleanHREFinput(href_form);
let newhreftxtvalue = href_form.value.trim().toLowerCase();
if(!isValidHREF(newhreftxtvalue)){
alert("You must enter a valid HREF");
return false;
}
href = newhreftxtvalue;
if(uploadfile_form.files.length == 0){
alert("You must select at least one file to upload");
return false;
}
files = uploadfile_form.files;
return true;
}
function onfileschange() {
files = uploadfile_form.files;
if(files.length > 1){
hreflimitmsg_html.classList.remove("hidden");
href_form.classList.add("hidden");
href_label.classList.add("hidden");
}else{
hreflimitmsg_html.classList.add("hidden");
href_form.classList.remove("hidden");
href_label.classList.remove("hidden");
}
return false;
}
this.show = function() {
scene_index = scene_stack.length - 1;
html_scene.classList.remove("hidden");
if (errors.length < files.length) {
close_btn.classList.add("hidden");
}
close_btn.onclick = onclose;
nodes = [];
for (let i = 0; i < files.length; i++) {
let file = files[i];
let node = template.cloneNode(true);
node.classList.remove("hidden");
let name_form = node.querySelector("[data-name=name]");
name_form.textContent = file.name;
node.classList.remove("hidden");
nodes.push(node);
updateFileStatus(i);
template.parentNode.insertBefore(node, template);
}
if (scene_index === null) {
scene_index = scene_stack.length - 1;
upload_next();
}
};
this.hide = function() {
html_scene.classList.add("hidden");
close_btn.classList.remove("hidden");
upload_btn.classList.remove("hidden");
uploadfile_form.classList.remove("hidden");
uploadfile_lbl.classList.remove("hidden");
href_form.classList.remove("hidden");
href_label.classList.remove("hidden");
hreflimitmsg_html.classList.add("hidden");
pending_html.classList.add("hidden");
close_btn.onclick = null;
upload_btn.onclick = null;
href_form.value = "";
uploadfile_form.value = "";
if(nodes == null){
return;
}
nodes.forEach(function(node) {
node.parentNode.removeChild(node);
});
@ -902,14 +1048,25 @@ function DeleteCollectionScene(user, password, collection) {
let html_scene = document.getElementById("deletecollectionscene");
let title_form = html_scene.querySelector("[data-name=title]");
let error_form = html_scene.querySelector("[data-name=error]");
let confirmation_txt = html_scene.querySelector("[data-name=confirmationtxt]");
let delete_confirmation_lbl = html_scene.querySelector("[data-name=deleteconfirmationtext]");
let delete_btn = html_scene.querySelector("[data-name=delete]");
let cancel_btn = html_scene.querySelector("[data-name=cancel]");
delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT;
confirmation_txt.value = "";
confirmation_txt.addEventListener("keydown", onkeydown);
/** @type {?number} */ let scene_index = null;
/** @type {?XMLHttpRequest} */ let delete_req = null;
let error = "";
function ondelete() {
let confirmation_text_value = confirmation_txt.value;
if(confirmation_text_value != DELETE_CONFIRMATION_TEXT){
alert("Please type the confirmation text to delete this collection.");
return;
}
try {
let loading_scene = new LoadingScene();
push_scene(loading_scene);
@ -940,14 +1097,27 @@ function DeleteCollectionScene(user, password, collection) {
return false;
}
function onkeydown(event){
if (event.keyCode !== 13) {
return;
}
ondelete();
}
this.show = function() {
this.release();
scene_index = scene_stack.length - 1;
html_scene.classList.remove("hidden");
title_form.textContent = collection.displayname || collection.href;
error_form.textContent = error ? "Error: " + error : "";
delete_btn.onclick = ondelete;
cancel_btn.onclick = oncancel;
if(error){
error_form.textContent = "Error: " + error;
error_form.classList.remove("hidden");
}else{
error_form.classList.add("hidden");
}
};
this.hide = function() {
html_scene.classList.add("hidden");
@ -988,13 +1158,22 @@ function CreateEditCollectionScene(user, password, collection) {
let html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene");
let title_form = edit ? html_scene.querySelector("[data-name=title]") : null;
let error_form = html_scene.querySelector("[data-name=error]");
let href_form = html_scene.querySelector("[data-name=href]");
let href_label = html_scene.querySelector("label[for=href]");
let displayname_form = html_scene.querySelector("[data-name=displayname]");
let displayname_label = html_scene.querySelector("label[for=displayname]");
let description_form = html_scene.querySelector("[data-name=description]");
let description_label = html_scene.querySelector("label[for=description]");
let source_form = html_scene.querySelector("[data-name=source]");
let source_label = html_scene.querySelector("label[for=source]");
let type_form = html_scene.querySelector("[data-name=type]");
let type_label = html_scene.querySelector("label[for=type]");
let color_form = html_scene.querySelector("[data-name=color]");
let color_label = html_scene.querySelector("label[for=color]");
let submit_btn = html_scene.querySelector("[data-name=submit]");
let cancel_btn = html_scene.querySelector("[data-name=cancel]");
/** @type {?number} */ let scene_index = null;
/** @type {?XMLHttpRequest} */ let create_edit_req = null;
let error = "";
@ -1003,40 +1182,69 @@ function CreateEditCollectionScene(user, password, collection) {
let href = edit ? collection.href : collection.href + random_uuid() + "/";
let displayname = edit ? collection.displayname : "";
let description = edit ? collection.description : "";
let source = edit ? collection.source : "";
let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS;
let color = edit && collection.color ? collection.color : "#" + random_hex(6);
if(!edit){
href_form.addEventListener("keydown", cleanHREFinput);
}
function remove_invalid_types() {
if (!edit) {
return;
}
/** @type {HTMLOptionsCollection} */ let options = type_form.options;
// remove all options that are not supersets
let valid_type_options = CollectionType.valid_options_for_type(type);
for (let i = options.length - 1; i >= 0; i--) {
if (!CollectionType.is_subset(type, options[i].value)) {
if (valid_type_options.indexOf(options[i].value) < 0) {
options.remove(i);
}
}
}
function read_form() {
if(!edit){
cleanHREFinput(href_form);
let newhreftxtvalue = href_form.value.trim().toLowerCase();
if(!isValidHREF(newhreftxtvalue)){
alert("You must enter a valid HREF");
return false;
}
href = collection.href + "/" + newhreftxtvalue + "/";
}
displayname = displayname_form.value;
description = description_form.value;
source = source_form.value;
type = type_form.value;
color = color_form.value;
return true;
}
function fill_form() {
if(!edit){
href_form.value = random_uuid();
}
displayname_form.value = displayname;
description_form.value = description;
source_form.value = source;
type_form.value = type;
color_form.value = color;
error_form.textContent = error ? "Error: " + error : "";
if(error){
error_form.textContent = "Error: " + error;
error_form.classList.remove("hidden");
}
error_form.classList.add("hidden");
onTypeChange();
type_form.addEventListener("change", onTypeChange);
}
function onsubmit() {
try {
read_form();
if(!read_form()){
return false;
}
let sane_color = color.trim();
if (sane_color) {
let color_match = COLOR_RE.exec(sane_color);
@ -1049,7 +1257,7 @@ function CreateEditCollectionScene(user, password, collection) {
}
let loading_scene = new LoadingScene();
push_scene(loading_scene);
let collection = new Collection(href, type, displayname, description, sane_color);
let collection = new Collection(href, type, displayname, description, sane_color, 0, 0, source);
let callback = function(error1) {
if (scene_index === null) {
return;
@ -1082,6 +1290,17 @@ function CreateEditCollectionScene(user, password, collection) {
return false;
}
function onTypeChange(e){
if(type_form.value == CollectionType.WEBCAL){
source_label.classList.remove("hidden");
source_form.classList.remove("hidden");
}else{
source_label.classList.add("hidden");
source_form.classList.add("hidden");
}
}
this.show = function() {
this.release();
scene_index = scene_stack.length - 1;
@ -1117,6 +1336,57 @@ function CreateEditCollectionScene(user, password, collection) {
};
}
/**
* Removed invalid HREF characters for a collection HREF.
*
* @param a A valid Input element or an onchange Event of an Input element.
*/
function cleanHREFinput(a) {
let href_form = a;
if (a.target) {
href_form = a.target;
}
let currentTxtVal = href_form.value.trim().toLowerCase();
//Clean the HREF to remove non lowercase letters and dashes
currentTxtVal = currentTxtVal.replace(/(?![0-9a-z\-\_])./g, '');
href_form.value = currentTxtVal;
}
/**
* Checks if a proposed HREF for a collection has a valid format and syntax.
*
* @param href String of the porposed HREF.
*
* @return Boolean results if the HREF is valid.
*/
function isValidHREF(href) {
if (href.length < 1) {
return false;
}
if (href.indexOf("/") != -1) {
return false;
}
return true;
}
/**
* Format bytes to human-readable text.
*
* @param bytes Number of bytes.
*
* @return Formatted string.
*/
function bytesToHumanReadable(bytes, dp=1) {
let isNumber = !isNaN(parseFloat(bytes)) && !isNaN(bytes - 0);
if(!isNumber){
return "";
}
var i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(dp) * 1 + ' ' + ['b', 'kb', 'mb', 'gb', 'tb'][i];
}
function main() {
// Hide startup loading message
document.getElementById("loadingscene").classList.add("hidden");

View file

@ -1,130 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Radicale Web Interface</title>
<link href="css/main.css" type="text/css" media="screen" rel="stylesheet">
<link href="css/icon.png" type="image/png" rel="icon">
<style>.hidden {display: none !important;}</style>
<script src="fn.js"></script>
</head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<script src="fn.js"></script>
<title>Radicale Web Interface</title>
<link href="css/main.css" media="screen" rel="stylesheet">
<link href="css/icon.png" type="image/png" rel="shortcut icon">
<style>
.hidden {display:none;}
</style>
<body>
<nav id="logoutview" class="hidden">
<span data-name="user" style="word-wrap:break-word;"></span>
<a href="#" class="green" data-name="refresh" title="Refresh">Refresh</a>
<a href="#" class="red" data-name="logout" title="Logout">Logout</a>
</nav>
<nav>
<ul>
<li id="logoutview" class="hidden"><a href="" data-name="link">Logout [<span data-name="user" style="word-wrap:break-word;"></span>]</a></li>
</ul>
</nav>
<main>
<section id="loadingscene">
<img src="css/loading.svg" alt="Loading..." class="loading">
<h2>Loading</h2>
<p>Please wait...</p>
<noscript>JavaScript is required</noscript>
</section>
<section id="loadingscene">
<h1>Loading</h1>
<p>Please wait...</p>
<noscript>JavaScript is required</noscript>
</section>
<section id="loginscene" class="container hidden">
<div class="logocontainer">
<img src="css/logo.svg" alt="Radicale">
</div>
<h1>Sign in</h1>
<br>
<form data-name="form">
<input data-name="user" type="text" placeholder="Username">
<input data-name="password" type="password" placeholder="Password">
<button class="green" type="submit">Next</button>
<span class="error" data-name="error"></span>
</form>
</section>
<section id="loginscene" class="hidden">
<h1>Login</h1>
<form data-name="form">
<input data-name="user" type="text" placeholder="Username"><br>
<input data-name="password" type="password" placeholder="Password"><br>
<span style="color: #A40000;" data-name="error"></span><br>
<button type="submit">Next</button>
</form>
</section>
<section id="collectionsscene" class="hidden">
<div class="fabcontainer">
<a href="" class="green" data-name="new" title="Create a new addressbook or calendar">
<img src="css/icons/new.svg" class="icon" alt="">
</a>
<a href="" class="blue" data-name="upload" title="Upload an addressbook or calendar">
<img src="css/icons/upload.svg" class="icon" alt="⬆️">
</a>
</div>
<article data-name="collectiontemplate" class="hidden">
<div class="colorbar" data-name="color"></div>
<h3 class="title" data-name="title">Title</h3>
<small>
<span data-name="ADDRESSBOOK">Address book</span>
<span data-name="CALENDAR_JOURNAL_TASKS">Calendar, journal and tasks</span>
<span data-name="CALENDAR_JOURNAL">Calendar and journal</span>
<span data-name="CALENDAR_TASKS">Calendar and tasks</span>
<span data-name="JOURNAL_TASKS">Journal and tasks</span>
<span data-name="CALENDAR">Calendar</span>
<span data-name="JOURNAL">Journal</span>
<span data-name="TASKS">Tasks</span>
<span data-name="WEBCAL">Webcal</span>
</small>
<small data-name="contentcount"></small>
<input type="text" data-name="url" value="" readonly="" onfocus="this.setSelectionRange(0, 99999);">
<p data-name="description" style="word-wrap:break-word;">Description</p>
<ul>
<li>
<a href="" title="Download" class="green" data-name="download">
<img src="css/icons/download.svg" class="icon" alt="🔗">
</a>
</li>
<li>
<a href="" title="Edit" class="blue" data-name="edit">
<img src="css/icons/edit.svg" class="icon" alt="✏️">
</a>
</li>
<li>
<a href="" title="Delete" class="red" data-name="delete">
<img src="css/icons/delete.svg" class="icon" alt="❌">
</a>
</li>
</ul>
</article>
</section>
<section id="collectionsscene" class="hidden">
<h1>Collections</h1>
<ul>
<li><a href="" data-name="new">Create new addressbook or calendar</a></li>
<li><a href="" data-name="upload">Upload addressbook or calendar</a></li>
</ul>
<article data-name="collectiontemplate" class="hidden">
<h2><span data-name="color"></span><span data-name="title" style="word-wrap:break-word;">Title</span> <small>[<span data-name="ADDRESSBOOK">addressbook</span><span data-name="CALENDAR_JOURNAL_TASKS">calendar, journal and tasks</span><span data-name="CALENDAR_JOURNAL">calendar and journal</span><span data-name="CALENDAR_TASKS">calendar and tasks</span><span data-name="JOURNAL_TASKS">journal and tasks</span><span data-name="CALENDAR">calendar</span><span data-name="JOURNAL">journal</span><span data-name="TASKS">tasks</span>]</small></h2>
<span data-name="description" style="word-wrap:break-word;">Description</span>
<section id="editcollectionscene" class="container hidden">
<h1>Edit Collection</h1>
<p>Editing collection <span class="title" data-name="title">title</span>
</p>
<form> Type: <br>
<select data-name="type">
<option value="ADDRESSBOOK">addressbook</option>
<option value="CALENDAR_JOURNAL_TASKS">calendar, journal and tasks</option>
<option value="CALENDAR_JOURNAL">calendar and journal</option>
<option value="CALENDAR_TASKS">calendar and tasks</option>
<option value="JOURNAL_TASKS">journal and tasks</option>
<option value="CALENDAR">calendar</option>
<option value="JOURNAL">journal</option>
<option value="TASKS">tasks</option>
<option value="WEBCAL">webcal</option>
</select>
<label for="displayname">Title:</label>
<input data-name="displayname" type="text">
<label for="description">Description:</label>
<input data-name="description" type="text">
<label for="source">Source:</label>
<input data-name="source" type="url">
<label for="color">Color:</label>
<input data-name="color" type="color">
<br>
<span class="error hidden" data-name="error"></span>
<br>
<button type="submit" class="green" data-name="submit">Save</button>
<button type="button" class="red" data-name="cancel">Cancel</button>
</form>
</section>
<section id="createcollectionscene" class="container hidden">
<h1>Create a new Collection</h1>
<p>Enter the details of your new collection.</p>
<form> Type: <br>
<select data-name="type">
<option value="ADDRESSBOOK">Address book</option>
<option value="CALENDAR_JOURNAL_TASKS">Calendar, journal and tasks</option>
<option value="CALENDAR_JOURNAL">Calendar and journal</option>
<option value="CALENDAR_TASKS">Calendar and tasks</option>
<option value="JOURNAL_TASKS">Journal and tasks</option>
<option value="CALENDAR">Calendar</option>
<option value="JOURNAL">Journal</option>
<option value="TASKS">Tasks</option>
<option value="WEBCAL">Webcal</option>
</select>
<label for="href">HREF:</label>
<input data-name="href" type="text">
<label for="displayname">Title:</label>
<input data-name="displayname" type="text">
<label for="description">Description:</label>
<input data-name="description" type="text">
<label for="source">Source:</label>
<input data-name="source" type="url">
<label for="color">Color:</label>
<input data-name="color" type="color">
<br>
<span class="error" data-name="error"></span>
<br>
<button type="submit" class="green" data-name="submit">Create</button>
<button type="button" class="red" data-name="cancel">Cancel</button>
</form>
</section>
<section id="uploadcollectionscene" class="container hidden">
<h1>Upload Collection</h1>
<ul>
<li>URL: <a data-name="url" style="word-wrap:break-word;">url</a></li>
<li><a href="" data-name="edit">Edit</a></li>
<li><a href="" data-name="delete">Delete</a></li>
<li data-name="filetemplate" class="hidden"> Uploading <span data-name="name">name</span>
<br>
<span class="successmessage" data-name="success">Uploaded Successfully!</span>
<span class="error" data-name="error"></span>
</li>
</ul>
</article>
</section>
<div data-name="pending" class="hidden">
<img src="css/loading.svg" class="loading" alt="Please wait..."/>
</div>
<form>
<label for="uploadfile">File:</label>
<input data-name="uploadfile" type="file" accept=".ics, .vcf" multiple>
<label for="href">HREF:</label>
<input data-name="href" type="text">
<small data-name="hreflimitmsg" class="hidden">You can only specify the HREF if you upload 1 file.</small>
<button type="submit" class="green" data-name="submit">Upload</button>
<button type="button" class="red" data-name="close">Close</button>
</form>
</section>
<section id="editcollectionscene" class="hidden">
<h1>Edit collection</h1>
<h2>Edit <span data-name="title" style="word-wrap:break-word;font-weight:bold;">title</span>:</h2>
<form>
Title:<br>
<input data-name="displayname" type="text"><br>
Description:<br>
<input data-name="description" type="text"><br>
Type:<br>
<select data-name="type">
<option value="ADDRESSBOOK">addressbook</option>
<option value="CALENDAR_JOURNAL_TASKS">calendar, journal and tasks</option>
<option value="CALENDAR_JOURNAL">calendar and journal</option>
<option value="CALENDAR_TASKS">calendar and tasks</option>
<option value="JOURNAL_TASKS">journal and tasks</option>
<option value="CALENDAR">calendar</option>
<option value="JOURNAL">journal</option>
<option value="TASKS">tasks</option>
</select><br>
Color:<br>
<input data-name="color" type="color"><br>
<span style="color: #A40000;" data-name="error"></span><br>
<button type="submit" data-name="submit">Save</button>
<button type="button" data-name="cancel">Cancel</button>
</form>
</section>
<section id="deletecollectionscene" class="container hidden">
<h1>Delete Collection</h1>
<p>To delete the collection <span class="title" data-name="title">title</span> please enter the phrase <strong data-name="deleteconfirmationtext"></strong> in the box below:</p>
<input type="text" class="deleteconfirmationtxt" data-name="confirmationtxt" />
<p class="red">WARNING: This action cannot be reversed.</p>
<form>
<button type="button" class="red" data-name="delete">Delete</button>
<button type="button" class="blue" data-name="cancel">Cancel</button>
</form>
<span class="error hidden" data-name="error"></span>
<br>
</section>
<section id="createcollectionscene" class="hidden">
<h1>Create new collection</h1>
<form>
Title:<br>
<input data-name="displayname" type="text"><br>
Description:<br>
<input data-name="description" type="text"><br>
Type:<br>
<select data-name="type">
<option value="ADDRESSBOOK">addressbook</option>
<option value="CALENDAR_JOURNAL_TASKS">calendar, journal and tasks</option>
<option value="CALENDAR_JOURNAL">calendar and journal</option>
<option value="CALENDAR_TASKS">calendar and tasks</option>
<option value="JOURNAL_TASKS">journal and tasks</option>
<option value="CALENDAR">calendar</option>
<option value="JOURNAL">journal</option>
<option value="TASKS">tasks</option>
</select><br>
Color:<br>
<input data-name="color" type="color"><br>
<span style="color: #A40000;" data-name="error"></span><br>
<button type="submit" data-name="submit">Create</button>
<button type="button" data-name="cancel">Cancel</button>
</form>
</section>
<section id="uploadcollectionscene" class="hidden">
<h1>Upload collection</h1>
<ul>
<li data-name="filetemplate" class="hidden">
Upload <span data-name="name" style="word-wrap:break-word;font-weight:bold;">name</span>:<br>
<span data-name="pending">Please wait...</span>
<span style="color: #00A400;" data-name="success">Finished</span>
<span style="color: #A40000;" data-name="error"></span>
</li>
</ul>
<form>
<button type="button" data-name="close">Close</button>
</form>
</section>
<section id="deletecollectionscene" class="hidden">
<h1>Delete collection</h1>
<h2>Delete <span data-name="title" style="word-wrap:break-word;font-weight:bold;">title</span>?</h2>
<span style="color: #A40000;" data-name="error"></span><br>
<form>
<button type="button" data-name="delete">Yes</button>
<button type="button" data-name="cancel">No</button>
</form>
</section>
</main>
</body>
</html>

View file

@ -33,7 +33,8 @@ from radicale import item, pathutils
MIMETYPES: Mapping[str, str] = {
"VADDRESSBOOK": "text/vcard",
"VCALENDAR": "text/calendar"}
"VCALENDAR": "text/calendar",
"VSUBSCRIBED": "text/calendar"}
OBJECT_MIMETYPES: Mapping[str, str] = {
"VCARD": "text/vcard",
@ -177,6 +178,9 @@ def props_from_request(xml_request: Optional[ET.Element]
if resource_type.tag == make_clark("C:calendar"):
value = "VCALENDAR"
break
if resource_type.tag == make_clark("CS:subscribed"):
value = "VSUBSCRIBED"
break
if resource_type.tag == make_clark("CR:addressbook"):
value = "VADDRESSBOOK"
break