Fix merge conflicts.
|
@ -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" %
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
@ -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 ""
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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
|
@ -0,0 +1,6 @@
|
|||
from radicale import hook
|
||||
|
||||
|
||||
class Hook(hook.BaseHook):
|
||||
def notify(self, notification_item):
|
||||
"""Notify nothing. Empty hook."""
|
50
radicale/hook/rabbitmq/__init__.py
Normal 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)
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
147
radicale/log.py
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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
|
||||
|
|
36
radicale/tests/static/event10.ics
Normal 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
|
28
radicale/tests/static/event_daily_rrule.ics
Normal 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
|
31
radicale/tests/static/event_full_day_rrule.ics
Normal 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
|
||||
|
16
radicale/tests/static/event_multiple_case_sensitive_uids.ics
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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/"
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}})
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
1
radicale/web/internal_data/css/icons/delete.svg
Normal 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 |
1
radicale/web/internal_data/css/icons/download.svg
Normal 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 |
1
radicale/web/internal_data/css/icons/edit.svg
Normal 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 |
1
radicale/web/internal_data/css/icons/new.svg
Normal 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 |
1
radicale/web/internal_data/css/icons/upload.svg
Normal 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 |
72
radicale/web/internal_data/css/loading.svg
Normal 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 |
10
radicale/web/internal_data/css/logo.svg
Normal 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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|