This commit is contained in:
Unrud
2018-08-28 16:19:36 +02:00
parent 1bdc47bf44
commit 8869b34470
51 changed files with 4091 additions and 3335 deletions

376
radicale/app/__init__.py Normal file
View File

@@ -0,0 +1,376 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import base64
import datetime
import io
import logging
import pkg_resources
import posixpath
import pprint
import random
import time
import zlib
from http import client
from xml.etree import ElementTree as ET
from radicale import (
auth, httputils, log, pathutils, rights, storage, web, xmlutils)
from radicale.app.delete import ApplicationDeleteMixin
from radicale.app.get import ApplicationGetMixin
from radicale.app.head import ApplicationHeadMixin
from radicale.app.mkcalendar import ApplicationMkcalendarMixin
from radicale.app.mkcol import ApplicationMkcolMixin
from radicale.app.move import ApplicationMoveMixin
from radicale.app.options import ApplicationOptionsMixin
from radicale.app.propfind import ApplicationPropfindMixin
from radicale.app.proppatch import ApplicationProppatchMixin
from radicale.app.put import ApplicationPutMixin
from radicale.app.report import ApplicationReportMixin
from radicale.log import logger
VERSION = pkg_resources.get_distribution("radicale").version
class Application(
ApplicationDeleteMixin, ApplicationGetMixin, ApplicationHeadMixin,
ApplicationMkcalendarMixin, ApplicationMkcolMixin,
ApplicationMoveMixin, ApplicationOptionsMixin,
ApplicationPropfindMixin, ApplicationProppatchMixin,
ApplicationPutMixin, ApplicationReportMixin):
"""WSGI application managing collections."""
def __init__(self, configuration):
"""Initialize application."""
super().__init__()
self.configuration = configuration
self.Auth = auth.load(configuration)
self.Collection = storage.load(configuration)
self.Rights = rights.load(configuration)
self.Web = web.load(configuration)
self.encoding = configuration.get("encoding", "request")
def _headers_log(self, environ):
"""Sanitize headers for logging."""
request_environ = dict(environ)
# Mask passwords
mask_passwords = self.configuration.getboolean(
"logging", "mask_passwords")
authorization = request_environ.get("HTTP_AUTHORIZATION", "")
if mask_passwords and authorization.startswith("Basic"):
request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
if request_environ.get("HTTP_COOKIE"):
request_environ["HTTP_COOKIE"] = "**masked**"
return request_environ
def decode(self, text, environ):
"""Try to magically decode ``text`` according to given ``environ``."""
# List of charsets to try
charsets = []
# First append content charset given in the request
content_type = environ.get("CONTENT_TYPE")
if content_type and "charset=" in content_type:
charsets.append(
content_type.split("charset=")[1].split(";")[0].strip())
# Then append default Radicale charset
charsets.append(self.encoding)
# Then append various fallbacks
charsets.append("utf-8")
charsets.append("iso8859-1")
# Try to decode
for charset in charsets:
try:
return text.decode(charset)
except UnicodeDecodeError:
pass
raise UnicodeDecodeError
def __call__(self, environ, start_response):
with log.register_stream(environ["wsgi.errors"]):
try:
status, headers, answers = self._handle_request(environ)
except Exception as e:
try:
method = str(environ["REQUEST_METHOD"])
except Exception:
method = "unknown"
try:
path = str(environ.get("PATH_INFO", ""))
except Exception:
path = ""
logger.error("An exception occurred during %s request on %r: "
"%s", method, path, e, exc_info=True)
status, headers, answer = httputils.INTERNAL_SERVER_ERROR
answer = answer.encode("ascii")
status = "%d %s" % (
status, client.responses.get(status, "Unknown"))
headers = [
("Content-Length", str(len(answer)))] + list(headers)
answers = [answer]
start_response(status, headers)
return answers
def _handle_request(self, environ):
"""Manage a request."""
def response(status, headers=(), answer=None):
headers = dict(headers)
# Set content length
if answer:
if hasattr(answer, "encode"):
logger.debug("Response content:\n%s", answer)
headers["Content-Type"] += "; charset=%s" % self.encoding
answer = answer.encode(self.encoding)
accept_encoding = [
encoding.strip() for encoding in
environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
if encoding.strip()]
if "gzip" in accept_encoding:
zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
answer = zcomp.compress(answer) + zcomp.flush()
headers["Content-Encoding"] = "gzip"
headers["Content-Length"] = str(len(answer))
# Add extra headers set in configuration
if self.configuration.has_section("headers"):
for key in self.configuration.options("headers"):
headers[key] = self.configuration.get("headers", key)
# Start response
time_end = datetime.datetime.now()
status = "%d %s" % (
status, client.responses.get(status, "Unknown"))
logger.info(
"%s response status for %r%s in %.3f seconds: %s",
environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""),
depthinfo, (time_end - time_begin).total_seconds(), status)
# Return response content
return status, list(headers.items()), [answer] if answer else []
remote_host = "unknown"
if environ.get("REMOTE_HOST"):
remote_host = repr(environ["REMOTE_HOST"])
elif environ.get("REMOTE_ADDR"):
remote_host = environ["REMOTE_ADDR"]
if environ.get("HTTP_X_FORWARDED_FOR"):
remote_host = "%r (forwarded by %s)" % (
environ["HTTP_X_FORWARDED_FOR"], remote_host)
remote_useragent = ""
if environ.get("HTTP_USER_AGENT"):
remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
depthinfo = ""
if environ.get("HTTP_DEPTH"):
depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
time_begin = datetime.datetime.now()
logger.info(
"%s request for %r%s received from %s%s",
environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo,
remote_host, remote_useragent)
headers = pprint.pformat(self._headers_log(environ))
logger.debug("Request headers:\n%s", headers)
# Let reverse proxies overwrite SCRIPT_NAME
if "HTTP_X_SCRIPT_NAME" in environ:
# script_name must be removed from PATH_INFO by the client.
unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"]
logger.debug("Script name overwritten by client: %r",
unsafe_base_prefix)
else:
# SCRIPT_NAME is already removed from PATH_INFO, according to the
# WSGI specification.
unsafe_base_prefix = environ.get("SCRIPT_NAME", "")
# Sanitize base prefix
base_prefix = pathutils.sanitize_path(unsafe_base_prefix).rstrip("/")
logger.debug("Sanitized script name: %r", base_prefix)
# Sanitize request URI (a WSGI server indicates with an empty path,
# that the URL targets the application root without a trailing slash)
path = pathutils.sanitize_path(environ.get("PATH_INFO", ""))
logger.debug("Sanitized path: %r", path)
# Get function corresponding to method
function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())
# If "/.well-known" is not available, clients query "/"
if path == "/.well-known" or path.startswith("/.well-known/"):
return response(*httputils.NOT_FOUND)
# Ask authentication backend to check rights
login = password = ""
external_login = self.Auth.get_external_login(environ)
authorization = environ.get("HTTP_AUTHORIZATION", "")
if external_login:
login, password = external_login
login, password = login or "", password or ""
elif authorization.startswith("Basic"):
authorization = authorization[len("Basic"):].strip()
login, password = self.decode(base64.b64decode(
authorization.encode("ascii")), environ).split(":", 1)
user = self.Auth.login(login, password) or "" if login else ""
if user and login == user:
logger.info("Successful login: %r", user)
elif user:
logger.info("Successful login: %r -> %r", login, user)
elif login:
logger.info("Failed login attempt: %r", login)
# Random delay to avoid timing oracles and bruteforce attacks
delay = self.configuration.getfloat("auth", "delay")
if delay > 0:
random_delay = delay * (0.5 + random.random())
logger.debug("Sleeping %.3f seconds", random_delay)
time.sleep(random_delay)
if user and not pathutils.is_safe_path_component(user):
# Prevent usernames like "user/calendar.ics"
logger.info("Refused unsafe username: %r", user)
user = ""
# Create principal collection
if user:
principal_path = "/%s/" % user
if self.Rights.authorized(user, principal_path, "W"):
with self.Collection.acquire_lock("r", user):
principal = next(
self.Collection.discover(principal_path, depth="1"),
None)
if not principal:
with self.Collection.acquire_lock("w", user):
try:
self.Collection.create_collection(principal_path)
except ValueError as e:
logger.warning("Failed to create principal "
"collection %r: %s", user, e)
user = ""
else:
logger.warning("Access to principal path %r denied by "
"rights backend", principal_path)
if self.configuration.getboolean("internal", "internal_server"):
# Verify content length
content_length = int(environ.get("CONTENT_LENGTH") or 0)
if content_length:
max_content_length = self.configuration.getint(
"server", "max_content_length")
if max_content_length and content_length > max_content_length:
logger.info("Request body too large: %d", content_length)
return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
if not login or user:
status, headers, answer = function(
environ, base_prefix, path, user)
if (status, headers, answer) == httputils.NOT_ALLOWED:
logger.info("Access to %r denied for %s", path,
repr(user) if user else "anonymous user")
else:
status, headers, answer = httputils.NOT_ALLOWED
if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and
not external_login):
# Unknown or unauthorized user
logger.debug("Asking client for authentication")
status = client.UNAUTHORIZED
realm = self.configuration.get("auth", "realm")
headers = dict(headers)
headers.update({
"WWW-Authenticate":
"Basic realm=\"%s\"" % realm})
return response(status, headers, answer)
def access(self, user, path, permission, item=None):
if permission not in "rw":
raise ValueError("Invalid permission argument: %r" % permission)
if not item:
permissions = permission + permission.upper()
parent_permissions = permission
elif isinstance(item, storage.BaseCollection):
if item.get_meta("tag"):
permissions = permission
else:
permissions = permission.upper()
parent_permissions = ""
else:
permissions = ""
parent_permissions = permission
if permissions and self.Rights.authorized(user, path, permissions):
return True
if parent_permissions:
parent_path = pathutils.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
if self.Rights.authorized(user, parent_path, parent_permissions):
return True
return False
def read_raw_content(self, environ):
content_length = int(environ.get("CONTENT_LENGTH") or 0)
if not content_length:
return b""
content = environ["wsgi.input"].read(content_length)
if len(content) < content_length:
raise RuntimeError("Request body too short: %d" % len(content))
return content
def read_content(self, environ):
content = self.decode(self.read_raw_content(environ), environ)
logger.debug("Request content:\n%s", content)
return content
def read_xml_content(self, environ):
content = self.decode(self.read_raw_content(environ), environ)
if not content:
return None
try:
xml_content = ET.fromstring(content)
except ET.ParseError as e:
logger.debug("Request content (Invalid XML):\n%s", content)
raise RuntimeError("Failed to parse XML: %s" % e) from e
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Request content:\n%s",
xmlutils.pretty_xml(xml_content))
return xml_content
def write_xml_content(self, xml_content):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response content:\n%s",
xmlutils.pretty_xml(xml_content))
f = io.BytesIO()
ET.ElementTree(xml_content).write(f, encoding=self.encoding,
xml_declaration=True)
return f.getvalue()
def webdav_error_response(self, namespace, name,
status=httputils.WEBDAV_PRECONDITION_FAILED[0]):
"""Generate XML error response."""
headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
content = self.write_xml_content(
xmlutils.webdav_error(namespace, name))
return status, headers, content

70
radicale/app/delete.py Normal file
View File

@@ -0,0 +1,70 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
from http import client
from xml.etree import ElementTree as ET
from radicale import httputils, storage, xmlutils
def xml_delete(base_prefix, path, collection, href=None):
"""Read and answer DELETE requests.
Read rfc4918-9.6 for info.
"""
collection.delete(href)
multistatus = ET.Element(xmlutils.make_tag("D", "multistatus"))
response = ET.Element(xmlutils.make_tag("D", "response"))
multistatus.append(response)
href = ET.Element(xmlutils.make_tag("D", "href"))
href.text = xmlutils.make_href(base_prefix, path)
response.append(href)
status = ET.Element(xmlutils.make_tag("D", "status"))
status.text = xmlutils.make_response(200)
response.append(status)
return multistatus
class ApplicationDeleteMixin:
def do_DELETE(self, environ, base_prefix, path, user):
"""Manage DELETE request."""
if not self.access(user, path, "w"):
return httputils.NOT_ALLOWED
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if not item:
return httputils.NOT_FOUND
if not self.access(user, path, "w", item):
return httputils.NOT_ALLOWED
if_match = environ.get("HTTP_IF_MATCH", "*")
if if_match not in ("*", item.etag):
# ETag precondition not verified, do not delete item
return httputils.PRECONDITION_FAILED
if isinstance(item, storage.BaseCollection):
xml_answer = xml_delete(base_prefix, path, item)
else:
xml_answer = xml_delete(
base_prefix, path, item.collection, item.href)
headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
return client.OK, headers, self.write_xml_content(xml_answer)

105
radicale/app/get.py Normal file
View File

@@ -0,0 +1,105 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import posixpath
from http import client
from urllib.parse import quote
from radicale import httputils, storage, xmlutils
from radicale.log import logger
def propose_filename(collection):
"""Propose a filename for a collection."""
tag = collection.get_meta("tag")
if tag == "VADDRESSBOOK":
fallback_title = "Address book"
suffix = ".vcf"
elif tag == "VCALENDAR":
fallback_title = "Calendar"
suffix = ".ics"
else:
fallback_title = posixpath.basename(collection.path)
suffix = ""
title = collection.get_meta("D:displayname") or fallback_title
if title and not title.lower().endswith(suffix.lower()):
title += suffix
return title
class ApplicationGetMixin:
def _content_disposition_attachement(self, filename):
value = "attachement"
try:
encoded_filename = quote(filename, encoding=self.encoding)
except UnicodeEncodeError as e:
logger.warning("Failed to encode filename: %r", filename,
exc_info=True)
encoded_filename = ""
if encoded_filename:
value += "; filename*=%s''%s" % (self.encoding, encoded_filename)
return value
def do_GET(self, environ, base_prefix, path, user):
"""Manage GET request."""
# Redirect to .web if the root URL is requested
if not path.strip("/"):
web_path = ".web"
if not environ.get("PATH_INFO"):
web_path = posixpath.join(posixpath.basename(base_prefix),
web_path)
return (client.FOUND,
{"Location": web_path, "Content-Type": "text/plain"},
"Redirected to %s" % web_path)
# Dispatch .web URL to web module
if path == "/.web" or path.startswith("/.web/"):
return self.Web.get(environ, base_prefix, path, user)
if not self.access(user, path, "r"):
return httputils.NOT_ALLOWED
with self.Collection.acquire_lock("r", user):
item = next(self.Collection.discover(path), None)
if not item:
return httputils.NOT_FOUND
if not self.access(user, path, "r", item):
return httputils.NOT_ALLOWED
if isinstance(item, storage.BaseCollection):
tag = item.get_meta("tag")
if not tag:
return httputils.DIRECTORY_LISTING
content_type = xmlutils.MIMETYPES[tag]
content_disposition = self._content_disposition_attachement(
propose_filename(item))
else:
content_type = xmlutils.OBJECT_MIMETYPES[item.name]
content_disposition = ""
headers = {
"Content-Type": content_type,
"Last-Modified": item.last_modified,
"ETag": item.etag}
if content_disposition:
headers["Content-Disposition"] = content_disposition
answer = item.serialize()
return client.OK, headers, answer

33
radicale/app/head.py Normal file
View File

@@ -0,0 +1,33 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
class ApplicationHeadMixin:
def do_HEAD(self, environ, base_prefix, path, user):
"""Manage HEAD request."""
status, headers, answer = self.do_GET(
environ, base_prefix, path, user)
return status, headers, None

View File

@@ -0,0 +1,80 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import posixpath
import socket
from http import client
from radicale import httputils
from radicale import item as radicale_item
from radicale import pathutils, storage, xmlutils
from radicale.log import logger
class ApplicationMkcalendarMixin:
def do_MKCALENDAR(self, environ, base_prefix, path, user):
"""Manage MKCALENDAR request."""
if not self.Rights.authorized(user, path, "w"):
return httputils.NOT_ALLOWED
try:
xml_content = self.read_xml_content(environ)
except RuntimeError as e:
logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
except socket.timeout as e:
logger.debug("client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT
# Prepare before locking
props = xmlutils.props_from_request(xml_content)
props["tag"] = "VCALENDAR"
# TODO: use this?
# timezone = props.get("C:calendar-timezone")
try:
radicale_item.check_and_sanitize_props(props)
except ValueError as e:
logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if item:
return self.webdav_error_response(
"D", "resource-must-be-null")
parent_path = pathutils.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
parent_item = next(self.Collection.discover(parent_path), None)
if not parent_item:
return httputils.CONFLICT
if (not isinstance(parent_item, storage.BaseCollection) or
parent_item.get_meta("tag")):
return httputils.FORBIDDEN
try:
self.Collection.create_collection(path, props=props)
except ValueError as e:
logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return client.CREATED, {}, None

81
radicale/app/mkcol.py Normal file
View File

@@ -0,0 +1,81 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import posixpath
import socket
from http import client
from radicale import httputils
from radicale import item as radicale_item
from radicale import pathutils, storage, xmlutils
from radicale.log import logger
class ApplicationMkcolMixin:
def do_MKCOL(self, environ, base_prefix, path, user):
"""Manage MKCOL request."""
permissions = self.Rights.authorized(user, path, "Ww")
if not permissions:
return httputils.NOT_ALLOWED
try:
xml_content = self.read_xml_content(environ)
except RuntimeError as e:
logger.warning(
"Bad MKCOL request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
except socket.timeout as e:
logger.debug("client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT
# Prepare before locking
props = xmlutils.props_from_request(xml_content)
try:
radicale_item.check_and_sanitize_props(props)
except ValueError as e:
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):
return httputils.NOT_ALLOWED
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if item:
return httputils.METHOD_NOT_ALLOWED
parent_path = pathutils.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
parent_item = next(self.Collection.discover(parent_path), None)
if not parent_item:
return httputils.CONFLICT
if (not isinstance(parent_item, storage.BaseCollection) or
parent_item.get_meta("tag")):
return httputils.FORBIDDEN
try:
self.Collection.create_collection(path, props=props)
except ValueError as e:
logger.warning(
"Bad MKCOL request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return client.CREATED, {}, None

93
radicale/app/move.py Normal file
View File

@@ -0,0 +1,93 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import posixpath
from http import client
from urllib.parse import urlparse
from radicale import httputils, pathutils, storage
from radicale.log import logger
class ApplicationMoveMixin:
def do_MOVE(self, environ, base_prefix, path, user):
"""Manage MOVE request."""
raw_dest = environ.get("HTTP_DESTINATION", "")
to_url = urlparse(raw_dest)
if to_url.netloc != environ["HTTP_HOST"]:
logger.info("Unsupported destination address: %r", raw_dest)
# Remote destination server, not supported
return httputils.REMOTE_DESTINATION
if not self.access(user, path, "w"):
return httputils.NOT_ALLOWED
to_path = pathutils.sanitize_path(to_url.path)
if not (to_path + "/").startswith(base_prefix + "/"):
logger.warning("Destination %r from MOVE request on %r doesn't "
"start with base prefix", to_path, path)
return httputils.NOT_ALLOWED
to_path = to_path[len(base_prefix):]
if not self.access(user, to_path, "w"):
return httputils.NOT_ALLOWED
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if not item:
return httputils.NOT_FOUND
if (not self.access(user, path, "w", item) or
not self.access(user, to_path, "w", item)):
return httputils.NOT_ALLOWED
if isinstance(item, storage.BaseCollection):
# TODO: support moving collections
return httputils.METHOD_NOT_ALLOWED
to_item = next(self.Collection.discover(to_path), None)
if isinstance(to_item, storage.BaseCollection):
return httputils.FORBIDDEN
to_parent_path = pathutils.sanitize_path(
"/%s/" % posixpath.dirname(to_path.strip("/")))
to_collection = next(
self.Collection.discover(to_parent_path), None)
if not to_collection:
return httputils.CONFLICT
tag = item.collection.get_meta("tag")
if not tag or tag != to_collection.get_meta("tag"):
return httputils.FORBIDDEN
if to_item and environ.get("HTTP_OVERWRITE", "F") != "T":
return httputils.PRECONDITION_FAILED
if (to_item and item.uid != to_item.uid or
not to_item and
to_collection.path != item.collection.path and
to_collection.has_uid(item.uid)):
return self.webdav_error_response(
"C" if tag == "VCALENDAR" else "CR", "no-uid-conflict")
to_href = posixpath.basename(to_path.strip("/"))
try:
self.Collection.move(item, to_collection, to_href)
except ValueError as e:
logger.warning(
"Bad MOVE request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return client.NO_CONTENT if to_item else client.CREATED, {}, None

39
radicale/app/options.py Normal file
View File

@@ -0,0 +1,39 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
from http import client
from radicale import httputils
class ApplicationOptionsMixin:
def do_OPTIONS(self, environ, base_prefix, path, user):
"""Manage OPTIONS request."""
headers = {
"Allow": ", ".join(
name[3:] for name in dir(self) if name.startswith("do_")),
"DAV": httputils.DAV_HEADERS}
return client.OK, headers, None

395
radicale/app/propfind.py Normal file
View File

@@ -0,0 +1,395 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import itertools
import posixpath
import socket
from http import client
from xml.etree import ElementTree as ET
from radicale import httputils, pathutils, rights, storage, xmlutils
from radicale.log import logger
def xml_propfind(base_prefix, path, xml_request, allowed_items, user):
"""Read and answer PROPFIND requests.
Read rfc4918-9.1 for info.
The collections parameter is a list of collections that are to be included
in the output.
"""
# A client may choose not to submit a request body. An empty PROPFIND
# request body MUST be treated as if it were an 'allprop' request.
top_tag = (xml_request[0] if xml_request is not None else
ET.Element(xmlutils.make_tag("D", "allprop")))
props = ()
allprop = False
propname = False
if top_tag.tag == xmlutils.make_tag("D", "allprop"):
allprop = True
elif top_tag.tag == xmlutils.make_tag("D", "propname"):
propname = True
elif top_tag.tag == xmlutils.make_tag("D", "prop"):
props = [prop.tag for prop in top_tag]
if xmlutils.make_tag("D", "current-user-principal") in props and not user:
# Ask for authentication
# Returning the DAV:unauthenticated pseudo-principal as specified in
# RFC 5397 doesn't seem to work with DAVdroid.
return client.FORBIDDEN, None
# Writing answer
multistatus = ET.Element(xmlutils.make_tag("D", "multistatus"))
for item, permission in allowed_items:
write = permission == "w"
response = xml_propfind_response(
base_prefix, path, item, props, user, write=write,
allprop=allprop, propname=propname)
if response:
multistatus.append(response)
return client.MULTI_STATUS, multistatus
def xml_propfind_response(base_prefix, path, item, props, user, write=False,
propname=False, allprop=False):
"""Build and return a PROPFIND response."""
if propname and allprop or (props and (propname or allprop)):
raise ValueError("Only use one of props, propname and allprops")
is_collection = isinstance(item, storage.BaseCollection)
if is_collection:
is_leaf = item.get_meta("tag") in ("VADDRESSBOOK", "VCALENDAR")
collection = item
else:
collection = item.collection
response = ET.Element(xmlutils.make_tag("D", "response"))
href = ET.Element(xmlutils.make_tag("D", "href"))
if is_collection:
# Some clients expect collections to end with /
uri = "/%s/" % item.path if item.path else "/"
else:
uri = "/" + posixpath.join(collection.path, item.href)
href.text = xmlutils.make_href(base_prefix, uri)
response.append(href)
propstat404 = ET.Element(xmlutils.make_tag("D", "propstat"))
propstat200 = ET.Element(xmlutils.make_tag("D", "propstat"))
response.append(propstat200)
prop200 = ET.Element(xmlutils.make_tag("D", "prop"))
propstat200.append(prop200)
prop404 = ET.Element(xmlutils.make_tag("D", "prop"))
propstat404.append(prop404)
if propname or allprop:
props = []
# Should list all properties that can be retrieved by the code below
props.append(xmlutils.make_tag("D", "principal-collection-set"))
props.append(xmlutils.make_tag("D", "current-user-principal"))
props.append(xmlutils.make_tag("D", "current-user-privilege-set"))
props.append(xmlutils.make_tag("D", "supported-report-set"))
props.append(xmlutils.make_tag("D", "resourcetype"))
props.append(xmlutils.make_tag("D", "owner"))
if is_collection and collection.is_principal:
props.append(xmlutils.make_tag("C", "calendar-user-address-set"))
props.append(xmlutils.make_tag("D", "principal-URL"))
props.append(xmlutils.make_tag("CR", "addressbook-home-set"))
props.append(xmlutils.make_tag("C", "calendar-home-set"))
if not is_collection or is_leaf:
props.append(xmlutils.make_tag("D", "getetag"))
props.append(xmlutils.make_tag("D", "getlastmodified"))
props.append(xmlutils.make_tag("D", "getcontenttype"))
props.append(xmlutils.make_tag("D", "getcontentlength"))
if is_collection:
if is_leaf:
props.append(xmlutils.make_tag("D", "displayname"))
props.append(xmlutils.make_tag("D", "sync-token"))
if collection.get_meta("tag") == "VCALENDAR":
props.append(xmlutils.make_tag("CS", "getctag"))
props.append(
xmlutils.make_tag("C", "supported-calendar-component-set"))
meta = item.get_meta()
for tag in meta:
if tag == "tag":
continue
clark_tag = xmlutils.tag_from_human(tag)
if clark_tag not in props:
props.append(clark_tag)
if propname:
for tag in props:
prop200.append(ET.Element(tag))
props = ()
for tag in props:
element = ET.Element(tag)
is404 = False
if tag == xmlutils.make_tag("D", "getetag"):
if not is_collection or is_leaf:
element.text = item.etag
else:
is404 = True
elif tag == xmlutils.make_tag("D", "getlastmodified"):
if not is_collection or is_leaf:
element.text = item.last_modified
else:
is404 = True
elif tag == xmlutils.make_tag("D", "principal-collection-set"):
tag = ET.Element(xmlutils.make_tag("D", "href"))
tag.text = xmlutils.make_href(base_prefix, "/")
element.append(tag)
elif (tag in (xmlutils.make_tag("C", "calendar-user-address-set"),
xmlutils.make_tag("D", "principal-URL"),
xmlutils.make_tag("CR", "addressbook-home-set"),
xmlutils.make_tag("C", "calendar-home-set")) and
collection.is_principal and is_collection):
tag = ET.Element(xmlutils.make_tag("D", "href"))
tag.text = xmlutils.make_href(base_prefix, path)
element.append(tag)
elif tag == xmlutils.make_tag("C", "supported-calendar-component-set"):
human_tag = xmlutils.tag_from_clark(tag)
if is_collection and is_leaf:
meta = item.get_meta(human_tag)
if meta:
components = meta.split(",")
else:
components = ("VTODO", "VEVENT", "VJOURNAL")
for component in components:
comp = ET.Element(xmlutils.make_tag("C", "comp"))
comp.set("name", component)
element.append(comp)
else:
is404 = True
elif tag == xmlutils.make_tag("D", "current-user-principal"):
if user:
tag = ET.Element(xmlutils.make_tag("D", "href"))
tag.text = xmlutils.make_href(base_prefix, "/%s/" % user)
element.append(tag)
else:
element.append(ET.Element(
xmlutils.make_tag("D", "unauthenticated")))
elif tag == xmlutils.make_tag("D", "current-user-privilege-set"):
privileges = [("D", "read")]
if write:
privileges.append(("D", "all"))
privileges.append(("D", "write"))
privileges.append(("D", "write-properties"))
privileges.append(("D", "write-content"))
for ns, privilege_name in privileges:
privilege = ET.Element(xmlutils.make_tag("D", "privilege"))
privilege.append(ET.Element(
xmlutils.make_tag(ns, privilege_name)))
element.append(privilege)
elif tag == xmlutils.make_tag("D", "supported-report-set"):
# These 3 reports are not implemented
reports = [
("D", "expand-property"),
("D", "principal-search-property-set"),
("D", "principal-property-search")]
if is_collection and is_leaf:
reports.append(("D", "sync-collection"))
if item.get_meta("tag") == "VADDRESSBOOK":
reports.append(("CR", "addressbook-multiget"))
reports.append(("CR", "addressbook-query"))
elif item.get_meta("tag") == "VCALENDAR":
reports.append(("C", "calendar-multiget"))
reports.append(("C", "calendar-query"))
for ns, report_name in reports:
supported = ET.Element(
xmlutils.make_tag("D", "supported-report"))
report_tag = ET.Element(xmlutils.make_tag("D", "report"))
supported_report_tag = ET.Element(
xmlutils.make_tag(ns, report_name))
report_tag.append(supported_report_tag)
supported.append(report_tag)
element.append(supported)
elif tag == xmlutils.make_tag("D", "getcontentlength"):
if not is_collection or is_leaf:
encoding = collection.configuration.get("encoding", "request")
element.text = str(len(item.serialize().encode(encoding)))
else:
is404 = True
elif tag == xmlutils.make_tag("D", "owner"):
# return empty elment, if no owner available (rfc3744-5.1)
if collection.owner:
tag = ET.Element(xmlutils.make_tag("D", "href"))
tag.text = xmlutils.make_href(
base_prefix, "/%s/" % collection.owner)
element.append(tag)
elif is_collection:
if tag == xmlutils.make_tag("D", "getcontenttype"):
if is_leaf:
element.text = xmlutils.MIMETYPES[item.get_meta("tag")]
else:
is404 = True
elif tag == xmlutils.make_tag("D", "resourcetype"):
if item.is_principal:
tag = ET.Element(xmlutils.make_tag("D", "principal"))
element.append(tag)
if is_leaf:
if item.get_meta("tag") == "VADDRESSBOOK":
tag = ET.Element(
xmlutils.make_tag("CR", "addressbook"))
element.append(tag)
elif item.get_meta("tag") == "VCALENDAR":
tag = ET.Element(xmlutils.make_tag("C", "calendar"))
element.append(tag)
tag = ET.Element(xmlutils.make_tag("D", "collection"))
element.append(tag)
elif tag == xmlutils.make_tag("RADICALE", "displayname"):
# Only for internal use by the web interface
displayname = item.get_meta("D:displayname")
if displayname is not None:
element.text = displayname
else:
is404 = True
elif tag == xmlutils.make_tag("D", "displayname"):
displayname = item.get_meta("D:displayname")
if not displayname and is_leaf:
displayname = item.path
if displayname is not None:
element.text = displayname
else:
is404 = True
elif tag == xmlutils.make_tag("CS", "getctag"):
if is_leaf:
element.text = item.etag
else:
is404 = True
elif tag == xmlutils.make_tag("D", "sync-token"):
if is_leaf:
element.text, _ = item.sync()
else:
is404 = True
else:
human_tag = xmlutils.tag_from_clark(tag)
meta = item.get_meta(human_tag)
if meta is not None:
element.text = meta
else:
is404 = True
# Not for collections
elif tag == xmlutils.make_tag("D", "getcontenttype"):
element.text = xmlutils.get_content_type(item)
elif tag == xmlutils.make_tag("D", "resourcetype"):
# resourcetype must be returned empty for non-collection elements
pass
else:
is404 = True
if is404:
prop404.append(element)
else:
prop200.append(element)
status200 = ET.Element(xmlutils.make_tag("D", "status"))
status200.text = xmlutils.make_response(200)
propstat200.append(status200)
status404 = ET.Element(xmlutils.make_tag("D", "status"))
status404.text = xmlutils.make_response(404)
propstat404.append(status404)
if len(prop404):
response.append(propstat404)
return response
class ApplicationPropfindMixin:
def _collect_allowed_items(self, items, user):
"""Get items from request that user is allowed to access."""
for item in items:
if isinstance(item, storage.BaseCollection):
path = pathutils.sanitize_path("/%s/" % item.path)
if item.get_meta("tag"):
permissions = self.Rights.authorized(user, path, "rw")
target = "collection with tag %r" % item.path
else:
permissions = self.Rights.authorized(user, path, "RW")
target = "collection %r" % item.path
else:
path = pathutils.sanitize_path("/%s/" % item.collection.path)
permissions = self.Rights.authorized(user, path, "rw")
target = "item %r from %r" % (item.href, item.collection.path)
if rights.intersect_permissions(permissions, "Ww"):
permission = "w"
status = "write"
elif rights.intersect_permissions(permissions, "Rr"):
permission = "r"
status = "read"
else:
permission = ""
status = "NO"
logger.debug(
"%s has %s access to %s",
repr(user) if user else "anonymous user", status, target)
if permission:
yield item, permission
def do_PROPFIND(self, environ, base_prefix, path, user):
"""Manage PROPFIND request."""
if not self.access(user, path, "r"):
return httputils.NOT_ALLOWED
try:
xml_content = self.read_xml_content(environ)
except RuntimeError as e:
logger.warning(
"Bad PROPFIND request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
except socket.timeout as e:
logger.debug("client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT
with self.Collection.acquire_lock("r", user):
items = self.Collection.discover(
path, environ.get("HTTP_DEPTH", "0"))
# take root item for rights checking
item = next(items, None)
if not item:
return httputils.NOT_FOUND
if not self.access(user, path, "r", item):
return httputils.NOT_ALLOWED
# put item back
items = itertools.chain([item], items)
allowed_items = self._collect_allowed_items(items, user)
headers = {"DAV": httputils.DAV_HEADERS,
"Content-Type": "text/xml; charset=%s" % self.encoding}
status, xml_answer = xml_propfind(
base_prefix, path, xml_content, allowed_items, user)
if status == client.FORBIDDEN:
return httputils.NOT_ALLOWED
return status, headers, self.write_xml_content(xml_answer)

126
radicale/app/proppatch.py Normal file
View File

@@ -0,0 +1,126 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import socket
from http import client
from xml.etree import ElementTree as ET
from radicale import httputils
from radicale import item as radicale_item
from radicale import storage, xmlutils
from radicale.log import logger
def xml_add_propstat_to(element, tag, status_number):
"""Add a PROPSTAT response structure to an element.
The PROPSTAT answer structure is defined in rfc4918-9.1. It is added to the
given ``element``, for the following ``tag`` with the given
``status_number``.
"""
propstat = ET.Element(xmlutils.make_tag("D", "propstat"))
element.append(propstat)
prop = ET.Element(xmlutils.make_tag("D", "prop"))
propstat.append(prop)
clark_tag = tag if "{" in tag else xmlutils.make_tag(*tag.split(":", 1))
prop_tag = ET.Element(clark_tag)
prop.append(prop_tag)
status = ET.Element(xmlutils.make_tag("D", "status"))
status.text = xmlutils.make_response(status_number)
propstat.append(status)
def xml_proppatch(base_prefix, path, xml_request, collection):
"""Read and answer PROPPATCH requests.
Read rfc4918-9.2 for info.
"""
props_to_set = xmlutils.props_from_request(xml_request, actions=("set",))
props_to_remove = xmlutils.props_from_request(xml_request,
actions=("remove",))
multistatus = ET.Element(xmlutils.make_tag("D", "multistatus"))
response = ET.Element(xmlutils.make_tag("D", "response"))
multistatus.append(response)
href = ET.Element(xmlutils.make_tag("D", "href"))
href.text = xmlutils.make_href(base_prefix, path)
response.append(href)
new_props = collection.get_meta()
for short_name, value in props_to_set.items():
new_props[short_name] = value
xml_add_propstat_to(response, short_name, 200)
for short_name in props_to_remove:
try:
del new_props[short_name]
except KeyError:
pass
xml_add_propstat_to(response, short_name, 200)
radicale_item.check_and_sanitize_props(new_props)
collection.set_meta(new_props)
return multistatus
class ApplicationProppatchMixin:
def do_PROPPATCH(self, environ, base_prefix, path, user):
"""Manage PROPPATCH request."""
if not self.access(user, path, "w"):
return httputils.NOT_ALLOWED
try:
xml_content = self.read_xml_content(environ)
except RuntimeError as e:
logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
except socket.timeout as e:
logger.debug("client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if not item:
return httputils.NOT_FOUND
if not self.access(user, path, "w", item):
return httputils.NOT_ALLOWED
if not isinstance(item, storage.BaseCollection):
return httputils.FORBIDDEN
headers = {"DAV": httputils.DAV_HEADERS,
"Content-Type": "text/xml; charset=%s" % self.encoding}
try:
xml_answer = xml_proppatch(base_prefix, path, xml_content,
item)
except ValueError as e:
logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return (client.MULTI_STATUS, headers,
self.write_xml_content(xml_answer))

230
radicale/app/put.py Normal file
View File

@@ -0,0 +1,230 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import itertools
import posixpath
import socket
import sys
from http import client
import vobject
from radicale import httputils
from radicale import item as radicale_item
from radicale import pathutils, storage, xmlutils
from radicale.log import logger
class ApplicationPutMixin:
def do_PUT(self, environ, base_prefix, path, user):
"""Manage PUT request."""
if not self.access(user, path, "w"):
return httputils.NOT_ALLOWED
try:
content = self.read_content(environ)
except RuntimeError as e:
logger.warning("Bad PUT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
except socket.timeout as e:
logger.debug("client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT
# Prepare before locking
parent_path = pathutils.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
permissions = self.Rights.authorized(user, path, "Ww")
parent_permissions = self.Rights.authorized(user, parent_path, "w")
def prepare(vobject_items, tag=None, write_whole_collection=None):
if (write_whole_collection or
permissions and not parent_permissions):
write_whole_collection = True
content_type = environ.get("CONTENT_TYPE",
"").split(";")[0]
tags = {value: key
for key, value in xmlutils.MIMETYPES.items()}
tag = radicale_item.predict_tag_of_whole_collection(
vobject_items, tags.get(content_type))
if not tag:
raise ValueError("Can't determine collection tag")
collection_path = pathutils.sanitize_path(path).strip("/")
elif (write_whole_collection is not None and
not write_whole_collection or
not permissions and parent_permissions):
write_whole_collection = False
if tag is None:
tag = storage.predict_tag_of_parent_collection(
vobject_items)
collection_path = posixpath.dirname(
pathutils.sanitize_path(path).strip("/"))
props = None
stored_exc_info = None
items = []
try:
if tag:
radicale_item.check_and_sanitize_items(
vobject_items, is_collection=write_whole_collection,
tag=tag)
if write_whole_collection and tag == "VCALENDAR":
vobject_components = []
vobject_item, = vobject_items
for content in ("vevent", "vtodo", "vjournal"):
vobject_components.extend(
getattr(vobject_item, "%s_list" % content, []))
vobject_components_by_uid = itertools.groupby(
sorted(vobject_components,
key=radicale_item.get_uid),
radicale_item.get_uid)
for uid, components in vobject_components_by_uid:
vobject_collection = vobject.iCalendar()
for component in components:
vobject_collection.add(component)
item = radicale_item.Item(
collection_path=collection_path,
vobject_item=vobject_collection)
item.prepare()
items.append(item)
elif write_whole_collection and tag == "VADDRESSBOOK":
for vobject_item in vobject_items:
item = radicale_item.Item(
collection_path=collection_path,
vobject_item=vobject_item)
item.prepare()
items.append(item)
elif not write_whole_collection:
vobject_item, = vobject_items
item = radicale_item.Item(
collection_path=collection_path,
vobject_item=vobject_item)
item.prepare()
items.append(item)
if write_whole_collection:
props = {}
if tag:
props["tag"] = tag
if tag == "VCALENDAR" and vobject_items:
if hasattr(vobject_items[0], "x_wr_calname"):
calname = vobject_items[0].x_wr_calname.value
if calname:
props["D:displayname"] = calname
if hasattr(vobject_items[0], "x_wr_caldesc"):
caldesc = vobject_items[0].x_wr_caldesc.value
if caldesc:
props["C:calendar-description"] = caldesc
radicale_item.check_and_sanitize_props(props)
except Exception:
stored_exc_info = sys.exc_info()
# Use generator for items and delete references to free memory
# early
def items_generator():
while items:
yield items.pop(0)
return (items_generator(), tag, write_whole_collection, props,
stored_exc_info)
try:
vobject_items = tuple(vobject.readComponents(content or ""))
except Exception as e:
logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
(prepared_items, prepared_tag, prepared_write_whole_collection,
prepared_props, prepared_exc_info) = prepare(vobject_items)
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
parent_item = next(self.Collection.discover(parent_path), None)
if not parent_item:
return httputils.CONFLICT
write_whole_collection = (
isinstance(item, storage.BaseCollection) or
not parent_item.get_meta("tag"))
if write_whole_collection:
tag = prepared_tag
else:
tag = parent_item.get_meta("tag")
if write_whole_collection:
if not self.Rights.authorized(user, path, "w" if tag else "W"):
return httputils.NOT_ALLOWED
elif not self.Rights.authorized(user, parent_path, "w"):
return httputils.NOT_ALLOWED
etag = environ.get("HTTP_IF_MATCH", "")
if not item and etag:
# Etag asked but no item found: item has been removed
return httputils.PRECONDITION_FAILED
if item and etag and item.etag != etag:
# Etag asked but item not matching: item has changed
return httputils.PRECONDITION_FAILED
match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
if item and match:
# Creation asked but item found: item can't be replaced
return httputils.PRECONDITION_FAILED
if (tag != prepared_tag or
prepared_write_whole_collection != write_whole_collection):
(prepared_items, prepared_tag, prepared_write_whole_collection,
prepared_props, prepared_exc_info) = prepare(
vobject_items, tag, write_whole_collection)
props = prepared_props
if prepared_exc_info:
logger.warning(
"Bad PUT request on %r: %s", path, prepared_exc_info[1],
exc_info=prepared_exc_info)
return httputils.BAD_REQUEST
if write_whole_collection:
try:
etag = self.Collection.create_collection(
path, prepared_items, props).etag
except ValueError as e:
logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
else:
prepared_item, = prepared_items
if (item and item.uid != prepared_item.uid or
not item and parent_item.has_uid(prepared_item.uid)):
return self.webdav_error_response(
"C" if tag == "VCALENDAR" else "CR",
"no-uid-conflict")
href = posixpath.basename(path.strip("/"))
try:
etag = parent_item.upload(href, prepared_item).etag
except ValueError as e:
logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
headers = {"ETag": etag}
return client.CREATED, headers, None

296
radicale/app/report.py Normal file
View File

@@ -0,0 +1,296 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
"""
Radicale WSGI application.
Can be used with an external WSGI server or the built-in server.
"""
import contextlib
import posixpath
import socket
from http import client
from urllib.parse import unquote, urlparse
from xml.etree import ElementTree as ET
from radicale import httputils, pathutils, storage, xmlutils
from radicale.item import filter as radicale_filter
from radicale.log import logger
def xml_report(base_prefix, path, xml_request, collection, unlock_storage_fn):
"""Read and answer REPORT requests.
Read rfc3253-3.6 for info.
"""
multistatus = ET.Element(xmlutils.make_tag("D", "multistatus"))
if xml_request is None:
return client.MULTI_STATUS, multistatus
root = xml_request
if root.tag in (
xmlutils.make_tag("D", "principal-search-property-set"),
xmlutils.make_tag("D", "principal-property-search"),
xmlutils.make_tag("D", "expand-property")):
# We don't support searching for principals or indirect retrieving of
# properties, just return an empty result.
# InfCloud asks for expand-property reports (even if we don't announce
# support for them) and stops working if an error code is returned.
logger.warning("Unsupported REPORT method %r on %r requested",
xmlutils.tag_from_clark(root.tag), path)
return client.MULTI_STATUS, multistatus
if (root.tag == xmlutils.make_tag("C", "calendar-multiget") and
collection.get_meta("tag") != "VCALENDAR" or
root.tag == xmlutils.make_tag("CR", "addressbook-multiget") and
collection.get_meta("tag") != "VADDRESSBOOK" or
root.tag == xmlutils.make_tag("D", "sync-collection") and
collection.get_meta("tag") not in ("VADDRESSBOOK", "VCALENDAR")):
logger.warning("Invalid REPORT method %r on %r requested",
xmlutils.tag_from_clark(root.tag), path)
return (client.CONFLICT,
xmlutils.webdav_error("D", "supported-report"))
prop_element = root.find(xmlutils.make_tag("D", "prop"))
props = (
[prop.tag for prop in prop_element]
if prop_element is not None else [])
if root.tag in (
xmlutils.make_tag("C", "calendar-multiget"),
xmlutils.make_tag("CR", "addressbook-multiget")):
# Read rfc4791-7.9 for info
hreferences = set()
for href_element in root.findall(xmlutils.make_tag("D", "href")):
href_path = pathutils.sanitize_path(
unquote(urlparse(href_element.text).path))
if (href_path + "/").startswith(base_prefix + "/"):
hreferences.add(href_path[len(base_prefix):])
else:
logger.warning("Skipping invalid path %r in REPORT request on "
"%r", href_path, path)
elif root.tag == xmlutils.make_tag("D", "sync-collection"):
old_sync_token_element = root.find(
xmlutils.make_tag("D", "sync-token"))
old_sync_token = ""
if old_sync_token_element is not None and old_sync_token_element.text:
old_sync_token = old_sync_token_element.text.strip()
logger.debug("Client provided sync token: %r", old_sync_token)
try:
sync_token, names = collection.sync(old_sync_token)
except ValueError as e:
# Invalid sync token
logger.warning("Client provided invalid sync token %r: %s",
old_sync_token, e, exc_info=True)
return (client.CONFLICT,
xmlutils.webdav_error("D", "valid-sync-token"))
hreferences = ("/" + posixpath.join(collection.path, n) for n in names)
# Append current sync token to response
sync_token_element = ET.Element(xmlutils.make_tag("D", "sync-token"))
sync_token_element.text = sync_token
multistatus.append(sync_token_element)
else:
hreferences = (path,)
filters = (
root.findall("./%s" % xmlutils.make_tag("C", "filter")) +
root.findall("./%s" % xmlutils.make_tag("CR", "filter")))
def retrieve_items(collection, hreferences, multistatus):
"""Retrieves all items that are referenced in ``hreferences`` from
``collection`` and adds 404 responses for missing and invalid items
to ``multistatus``."""
collection_requested = False
def get_names():
"""Extracts all names from references in ``hreferences`` and adds
404 responses for invalid references to ``multistatus``.
If the whole collections is referenced ``collection_requested``
gets set to ``True``."""
nonlocal collection_requested
for hreference in hreferences:
try:
name = pathutils.name_from_path(hreference, collection)
except ValueError as e:
logger.warning("Skipping invalid path %r in REPORT request"
" on %r: %s", hreference, path, e)
response = xml_item_response(base_prefix, hreference,
found_item=False)
multistatus.append(response)
continue
if name:
# Reference is an item
yield name
else:
# Reference is a collection
collection_requested = True
for name, item in collection.get_multi(get_names()):
if not item:
uri = "/" + posixpath.join(collection.path, name)
response = xml_item_response(base_prefix, uri,
found_item=False)
multistatus.append(response)
else:
yield item, False
if collection_requested:
yield from collection.get_all_filtered(filters)
# Retrieve everything required for finishing the request.
retrieved_items = list(retrieve_items(collection, hreferences,
multistatus))
collection_tag = collection.get_meta("tag")
# Don't access storage after this!
unlock_storage_fn()
def match(item, filter_):
tag = collection_tag
if (tag == "VCALENDAR" and
filter_.tag != xmlutils.make_tag("C", filter_)):
if len(filter_) == 0:
return True
if len(filter_) > 1:
raise ValueError("Filter with %d children" % len(filter_))
if filter_[0].tag != xmlutils.make_tag("C", "comp-filter"):
raise ValueError("Unexpected %r in filter" % filter_[0].tag)
return radicale_filter.comp_match(item, filter_[0])
if (tag == "VADDRESSBOOK" and
filter_.tag != xmlutils.make_tag("CR", filter_)):
for child in filter_:
if child.tag != xmlutils.make_tag("CR", "prop-filter"):
raise ValueError("Unexpected %r in filter" % child.tag)
test = filter_.get("test", "anyof")
if test == "anyof":
return any(
radicale_filter.prop_match(item.vobject_item, f, "CR")
for f in filter_)
if test == "allof":
return all(
radicale_filter.prop_match(item.vobject_item, f, "CR")
for f in filter_)
raise ValueError("Unsupported filter test: %r" % test)
return all(radicale_filter.prop_match(item.vobject_item, f, "CR")
for f in filter_)
raise ValueError("unsupported filter %r for %r" % (filter_.tag, tag))
while retrieved_items:
# ``item.vobject_item`` might be accessed during filtering.
# Don't keep reference to ``item``, because VObject requires a lot of
# memory.
item, filters_matched = retrieved_items.pop(0)
if filters and not filters_matched:
try:
if not all(match(item, filter_) for filter_ in filters):
continue
except ValueError as e:
raise ValueError("Failed to filter item %r from %r: %s" %
(item.href, collection.path, e)) from e
except Exception as e:
raise RuntimeError("Failed to filter item %r from %r: %s" %
(item.href, collection.path, e)) from e
found_props = []
not_found_props = []
for tag in props:
element = ET.Element(tag)
if tag == xmlutils.make_tag("D", "getetag"):
element.text = item.etag
found_props.append(element)
elif tag == xmlutils.make_tag("D", "getcontenttype"):
element.text = xmlutils.get_content_type(item)
found_props.append(element)
elif tag in (
xmlutils.make_tag("C", "calendar-data"),
xmlutils.make_tag("CR", "address-data")):
element.text = item.serialize()
found_props.append(element)
else:
not_found_props.append(element)
uri = "/" + posixpath.join(collection.path, item.href)
multistatus.append(xml_item_response(
base_prefix, uri, found_props=found_props,
not_found_props=not_found_props, found_item=True))
return client.MULTI_STATUS, multistatus
def xml_item_response(base_prefix, href, found_props=(), not_found_props=(),
found_item=True):
response = ET.Element(xmlutils.make_tag("D", "response"))
href_tag = ET.Element(xmlutils.make_tag("D", "href"))
href_tag.text = xmlutils.make_href(base_prefix, href)
response.append(href_tag)
if found_item:
for code, props in ((200, found_props), (404, not_found_props)):
if props:
propstat = ET.Element(xmlutils.make_tag("D", "propstat"))
status = ET.Element(xmlutils.make_tag("D", "status"))
status.text = xmlutils.make_response(code)
prop_tag = ET.Element(xmlutils.make_tag("D", "prop"))
for prop in props:
prop_tag.append(prop)
propstat.append(prop_tag)
propstat.append(status)
response.append(propstat)
else:
status = ET.Element(xmlutils.make_tag("D", "status"))
status.text = xmlutils.make_response(404)
response.append(status)
return response
class ApplicationReportMixin:
def do_REPORT(self, environ, base_prefix, path, user):
"""Manage REPORT request."""
if not self.access(user, path, "r"):
return httputils.NOT_ALLOWED
try:
xml_content = self.read_xml_content(environ)
except RuntimeError as e:
logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
except socket.timeout as e:
logger.debug("client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT
with contextlib.ExitStack() as lock_stack:
lock_stack.enter_context(self.Collection.acquire_lock("r", user))
item = next(self.Collection.discover(path), None)
if not item:
return httputils.NOT_FOUND
if not self.access(user, path, "r", item):
return httputils.NOT_ALLOWED
if isinstance(item, storage.BaseCollection):
collection = item
else:
collection = item.collection
headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
try:
status, xml_answer = xml_report(
base_prefix, path, xml_content, collection,
lock_stack.close)
except ValueError as e:
logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return (status, headers, self.write_xml_content(xml_answer))