Adding password hashing

This commit is contained in:
Fabian Stamm 2018-04-10 19:57:45 +02:00
commit e68646c235
58 changed files with 9475 additions and 3 deletions

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"python.linting.pylintEnabled": false,
"python.formatting.provider": "autopep8",
"python.pythonPath": "C:\\Program Files\\Python36\\python.exe",
"editor.tabSize": 4
}

View File

@ -24,15 +24,15 @@ class Auth(BaseAuth):
return True
def map_login_to_user(self, login):
# Get uuid from username
# Get uid from username
if login is None or login is "":
return None
main_uri = self.generate_base_uri(
"/client/uuid") + "&username=" + login
"/client/uid") + "&username=" + login
req = urllib.request.urlopen(main_uri, data=None)
jsons = req.read()
data = json.loads(jsons)
print(data)
if "error" in data:
return None
return data["uuid"]
return data["uid"]

View File

@ -0,0 +1,26 @@
from radicale.rights import BaseRights
from radicale.
import urllib.request
import json
import hashlib
class Rights(BaseRights):
def __init__(self, configuration, logger):
self.configuration = configuration
self.logger = logger
def authorized(self, user, path, permission):
"""Check if the user is allowed to read or write the collection.
If ``user`` is empty, check for anonymous rights.
``path`` is sanitized.
``permission`` is "r" or "w".
"""
raise NotImplementedError
def authorized_item(self, user, path, permission):
"""Check if the user is allowed to read or write the item."""
path = storage.sanitize_path(path)
parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
return self.authorized(user, parent_path, permission)

View File

@ -0,0 +1,317 @@
from radicale.storage import BaseCollection
import urllib.request
import json
import hashlib
import couchdb
class Collection(BaseCollection):
# Overriden on copy by the "load" function
configuration = None
logger = None
# Properties of instance
"""The sanitized path of the collection without leading or trailing ``/``.
"""
path = ""
@property
def owner(self):
"""The owner of the collection."""
return self.path.split("/", maxsplit=1)[0]
@property
def is_principal(self):
"""Collection is a principal."""
return bool(self.path) and "/" not in self.path
@owner.setter
def owner(self, value):
# DEPRECATED: Included for compatibility reasons
pass
@is_principal.setter
def is_principal(self, value):
# DEPRECATED: Included for compatibility reasons
pass
@classmethod
def discover(cls, path, depth="0"):
"""Discover a list of collections under the given ``path``.
``path`` is sanitized.
If ``depth`` is "0", only the actual object under ``path`` is
returned.
If ``depth`` is anything but "0", it is considered as "1" and direct
children are included in the result.
The root collection "/" must always exist.
"""
raise NotImplementedError
@classmethod
def move(cls, item, to_collection, to_href):
"""Move an object.
``item`` is the item to move.
``to_collection`` is the target collection.
``to_href`` is the target name in ``to_collection``. An item with the
same name might already exist.
"""
if item.collection.path == to_collection.path and item.href == to_href:
return
to_collection.upload(to_href, item.item)
item.collection.delete(item.href)
@property
def etag(self):
"""Encoded as quoted-string (see RFC 2616)."""
etag = md5()
for item in self.get_all():
etag.update((item.href + "/" + item.etag).encode("utf-8"))
etag.update(json.dumps(self.get_meta(), sort_keys=True).encode())
return '"%s"' % etag.hexdigest()
@classmethod
def create_collection(cls, href, collection=None, props=None):
"""Create a collection.
``href`` is the sanitized path.
If the collection already exists and neither ``collection`` nor
``props`` are set, this method shouldn't do anything. Otherwise the
existing collection must be replaced.
``collection`` is a list of vobject components.
``props`` are metadata values for the collection.
``props["tag"]`` is the type of collection (VCALENDAR or
VADDRESSBOOK). If the key ``tag`` is missing, it is guessed from the
collection.
"""
raise NotImplementedError
def sync(self, old_token=None):
"""Get the current sync token and changed items for synchronization.
``old_token`` an old sync token which is used as the base of the
delta update. If sync token is missing, all items are returned.
ValueError is raised for invalid or old tokens.
WARNING: This simple default implementation treats all sync-token as
invalid. It adheres to the specification but some clients
(e.g. InfCloud) don't like it. Subclasses should provide a
more sophisticated implementation.
"""
token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"")
if old_token:
raise ValueError("Sync token are not supported")
return token, self.list()
def list(self):
"""List collection items."""
raise NotImplementedError
def get(self, href):
"""Fetch a single item."""
raise NotImplementedError
def get_multi(self, hrefs):
"""Fetch multiple items. Duplicate hrefs must be ignored.
DEPRECATED: use ``get_multi2`` instead
"""
return (self.get(href) for href in set(hrefs))
def get_multi2(self, hrefs):
"""Fetch multiple items.
Functionally similar to ``get``, but might bring performance benefits
on some storages when used cleverly. It's not required to return the
requested items in the correct order. Duplicated hrefs can be ignored.
Returns tuples with the href and the item or None if the item doesn't
exist.
"""
return ((href, self.get(href)) for href in hrefs)
def get_all(self):
"""Fetch all items.
Functionally similar to ``get``, but might bring performance benefits
on some storages when used cleverly.
"""
return map(self.get, self.list())
def get_all_filtered(self, filters):
"""Fetch all items with optional filtering.
This can largely improve performance of reports depending on
the filters and this implementation.
Returns tuples in the form ``(item, filters_matched)``.
``filters_matched`` is a bool that indicates if ``filters`` are fully
matched.
This returns all events by default
"""
return ((item, False) for item in self.get_all())
def pre_filtered_list(self, filters):
"""List collection items with optional pre filtering.
DEPRECATED: use ``get_all_filtered`` instead
"""
return self.get_all()
def has(self, href):
"""Check if an item exists by its href.
Functionally similar to ``get``, but might bring performance benefits
on some storages when used cleverly.
"""
return self.get(href) is not None
def upload(self, href, vobject_item):
"""Upload a new or replace an existing item."""
raise NotImplementedError
def delete(self, href=None):
"""Delete an item.
When ``href`` is ``None``, delete the collection.
"""
raise NotImplementedError
def get_meta(self, key=None):
"""Get metadata value for collection.
Return the value of the property ``key``. If ``key`` is ``None`` return
a dict with all properties
"""
raise NotImplementedError
def set_meta(self, props):
"""Set metadata values for collection.
``props`` a dict with updates for properties. If a value is empty, the
property must be deleted.
DEPRECATED: use ``set_meta_all`` instead
"""
raise NotImplementedError
def set_meta_all(self, props):
"""Set metadata values for collection.
``props`` a dict with values for properties.
"""
delta_props = self.get_meta()
for key in delta_props.keys():
if key not in props:
delta_props[key] = None
delta_props.update(props)
self.set_meta(self, delta_props)
@property
def last_modified(self):
"""Get the HTTP-datetime of when the collection was modified."""
raise NotImplementedError
def serialize(self):
"""Get the unicode string representing the whole collection."""
if self.get_meta("tag") == "VCALENDAR":
in_vcalendar = False
vtimezones = ""
included_tzids = set()
vtimezone = []
tzid = None
components = ""
# Concatenate all child elements of VCALENDAR from all items
# together, while preventing duplicated VTIMEZONE entries.
# VTIMEZONEs are only distinguished by their TZID, if different
# timezones share the same TZID this produces errornous ouput.
# VObject fails at this too.
for item in self.get_all():
depth = 0
for line in item.serialize().split("\r\n"):
if line.startswith("BEGIN:"):
depth += 1
if depth == 1 and line == "BEGIN:VCALENDAR":
in_vcalendar = True
elif in_vcalendar:
if depth == 1 and line.startswith("END:"):
in_vcalendar = False
if depth == 2 and line == "BEGIN:VTIMEZONE":
vtimezone.append(line + "\r\n")
elif vtimezone:
vtimezone.append(line + "\r\n")
if depth == 2 and line.startswith("TZID:"):
tzid = line[len("TZID:"):]
elif depth == 2 and line.startswith("END:"):
if tzid is None or tzid not in included_tzids:
vtimezones += "".join(vtimezone)
included_tzids.add(tzid)
vtimezone.clear()
tzid = None
elif depth >= 2:
components += line + "\r\n"
if line.startswith("END:"):
depth -= 1
template = vobject.iCalendar()
displayname = self.get_meta("D:displayname")
if displayname:
template.add("X-WR-CALNAME")
template.x_wr_calname.value_param = "TEXT"
template.x_wr_calname.value = displayname
description = self.get_meta("C:calendar-description")
if description:
template.add("X-WR-CALDESC")
template.x_wr_caldesc.value_param = "TEXT"
template.x_wr_caldesc.value = description
template = template.serialize()
template_insert_pos = template.find("\r\nEND:VCALENDAR\r\n") + 2
assert template_insert_pos != -1
return (template[:template_insert_pos] +
vtimezones + components +
template[template_insert_pos:])
elif self.get_meta("tag") == "VADDRESSBOOK":
return "".join((item.serialize() for item in self.get_all()))
return ""
@classmethod
@contextmanager
def acquire_lock(cls, mode, user=None):
"""Set a context manager to lock the whole storage.
``mode`` must either be "r" for shared access or "w" for exclusive
access.
``user`` is the name of the logged in user or empty.
"""
raise NotImplementedError
@classmethod
def verify(cls):
"""Check the storage for errors."""
return True

View File

@ -3,3 +3,5 @@
from distutils.core import setup
setup(name="radicale_stamm_auth", packages=["radicale_stamm_auth"])
setup(name="radicale_stamm_rights", packages=["radicale_stamm_rights"])

949
radicale/__init__.py Normal file
View File

@ -0,0 +1,949 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale Server module.
This module offers a WSGI application class.
To use this module, you should take a look at the file ``radicale.py`` that
should have been included in this package.
"""
import base64
import contextlib
import datetime
import io
import itertools
import logging
import os
import posixpath
import pprint
import random
import socket
import socketserver
import ssl
import sys
import threading
import time
import wsgiref.simple_server
import zlib
from http import client
from urllib.parse import unquote, urlparse
from xml.etree import ElementTree as ET
import vobject
from . import auth, rights, storage, web, xmlutils
VERSION = "2.1.8"
NOT_ALLOWED = (
client.FORBIDDEN, (("Content-Type", "text/plain"),),
"Access to the requested resource forbidden.")
BAD_REQUEST = (
client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request")
NOT_FOUND = (
client.NOT_FOUND, (("Content-Type", "text/plain"),),
"The requested resource could not be found.")
WEBDAV_PRECONDITION_FAILED = (
client.CONFLICT, (("Content-Type", "text/plain"),),
"WebDAV precondition failed.")
PRECONDITION_FAILED = (
client.PRECONDITION_FAILED,
(("Content-Type", "text/plain"),), "Precondition failed.")
REQUEST_TIMEOUT = (
client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
"Connection timed out.")
REQUEST_ENTITY_TOO_LARGE = (
client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
"Request body too large.")
REMOTE_DESTINATION = (
client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
"Remote destination not supported.")
DIRECTORY_LISTING = (
client.FORBIDDEN, (("Content-Type", "text/plain"),),
"Directory listings are not supported.")
INTERNAL_SERVER_ERROR = (
client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
"A server error occurred. Please contact the administrator.")
DAV_HEADERS = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
class HTTPServer(wsgiref.simple_server.WSGIServer):
"""HTTP server."""
# These class attributes must be set before creating instance
client_timeout = None
max_connections = None
logger = None
def __init__(self, address, handler, bind_and_activate=True):
"""Create server."""
ipv6 = ":" in address[0]
if ipv6:
self.address_family = socket.AF_INET6
# Do not bind and activate, as we might change socket options
super().__init__(address, handler, False)
if ipv6:
# Only allow IPv6 connections to the IPv6 socket
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
if self.max_connections:
self.connections_guard = threading.BoundedSemaphore(
self.max_connections)
else:
# use dummy context manager
self.connections_guard = contextlib.ExitStack()
if bind_and_activate:
try:
self.server_bind()
self.server_activate()
except BaseException:
self.server_close()
raise
if self.client_timeout and sys.version_info < (3, 5, 2):
self.logger.warning("Using server.timeout with Python < 3.5.2 "
"can cause network connection failures")
def get_request(self):
# Set timeout for client
_socket, address = super().get_request()
if self.client_timeout:
_socket.settimeout(self.client_timeout)
return _socket, address
def handle_error(self, request, client_address):
if issubclass(sys.exc_info()[0], socket.timeout):
self.logger.info("client timed out", exc_info=True)
else:
self.logger.error("An exception occurred during request: %s",
sys.exc_info()[1], exc_info=True)
class HTTPSServer(HTTPServer):
"""HTTPS server."""
# These class attributes must be set before creating instance
certificate = None
key = None
protocol = None
ciphers = None
certificate_authority = None
def __init__(self, address, handler):
"""Create server by wrapping HTTP socket in an SSL socket."""
super().__init__(address, handler, bind_and_activate=False)
self.socket = ssl.wrap_socket(
self.socket, self.key, self.certificate, server_side=True,
cert_reqs=ssl.CERT_REQUIRED if self.certificate_authority else
ssl.CERT_NONE,
ca_certs=self.certificate_authority or None,
ssl_version=self.protocol, ciphers=self.ciphers,
do_handshake_on_connect=False)
self.server_bind()
self.server_activate()
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
def process_request_thread(self, request, client_address):
with self.connections_guard:
return super().process_request_thread(request, client_address)
class ThreadedHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer):
def process_request_thread(self, request, client_address):
try:
try:
request.do_handshake()
except socket.timeout:
raise
except Exception as e:
raise RuntimeError("SSL handshake failed: %s" % e) from e
except Exception:
try:
self.handle_error(request, client_address)
finally:
self.shutdown_request(request)
return
with self.connections_guard:
return super().process_request_thread(request, client_address)
class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
"""HTTP requests handler."""
# These class attributes must be set before creating instance
logger = None
def __init__(self, *args, **kwargs):
# Store exception for logging
self.error_stream = io.StringIO()
super().__init__(*args, **kwargs)
def get_stderr(self):
return self.error_stream
def log_message(self, *args, **kwargs):
"""Disable inner logging management."""
def get_environ(self):
env = super().get_environ()
if hasattr(self.connection, "getpeercert"):
# The certificate can be evaluated by the auth module
env["REMOTE_CERTIFICATE"] = self.connection.getpeercert()
# Parent class only tries latin1 encoding
env["PATH_INFO"] = unquote(self.path.split("?", 1)[0])
return env
def handle(self):
super().handle()
# Log exception
error = self.error_stream.getvalue().strip("\n")
if error:
self.logger.error(
"An unhandled exception occurred during request:\n%s" % error)
class Application:
"""WSGI application managing collections."""
def __init__(self, configuration, logger):
"""Initialize application."""
super().__init__()
self.configuration = configuration
self.logger = logger
self.Auth = auth.load(configuration, logger)
self.Collection = storage.load(configuration, logger)
self.Rights = rights.load(configuration, logger)
self.Web = web.load(configuration, logger)
self.encoding = configuration.get("encoding", "request")
def headers_log(self, environ):
"""Sanitize headers for logging."""
request_environ = dict(environ)
# Remove environment variables
if not self.configuration.getboolean("logging", "full_environment"):
for shell_variable in os.environ:
request_environ.pop(shell_variable, None)
# Mask passwords
mask_passwords = self.configuration.getboolean(
"logging", "mask_passwords")
authorization = request_environ.get("HTTP_AUTHORIZATION", "")
if mask_passwords and authorization.startswith("Basic"):
request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
if request_environ.get("HTTP_COOKIE"):
request_environ["HTTP_COOKIE"] = "**masked**"
return request_environ
def decode(self, text, environ):
"""Try to magically decode ``text`` according to given ``environ``."""
# List of charsets to try
charsets = []
# First append content charset given in the request
content_type = environ.get("CONTENT_TYPE")
if content_type and "charset=" in content_type:
charsets.append(
content_type.split("charset=")[1].split(";")[0].strip())
# Then append default Radicale charset
charsets.append(self.encoding)
# Then append various fallbacks
charsets.append("utf-8")
charsets.append("iso8859-1")
# Try to decode
for charset in charsets:
try:
return text.decode(charset)
except UnicodeDecodeError:
pass
raise UnicodeDecodeError
def collect_allowed_items(self, items, user):
"""Get items from request that user is allowed to access."""
read_allowed_items = []
write_allowed_items = []
for item in items:
if isinstance(item, storage.BaseCollection):
path = storage.sanitize_path("/%s/" % item.path)
can_read = self.Rights.authorized(user, path, "r")
can_write = self.Rights.authorized(user, path, "w")
target = "collection %r" % item.path
else:
path = storage.sanitize_path("/%s/%s" % (item.collection.path,
item.href))
can_read = self.Rights.authorized_item(user, path, "r")
can_write = self.Rights.authorized_item(user, path, "w")
target = "item %r from %r" % (item.href, item.collection.path)
text_status = []
if can_read:
text_status.append("read")
read_allowed_items.append(item)
if can_write:
text_status.append("write")
write_allowed_items.append(item)
self.logger.debug(
"%s has %s access to %s",
repr(user) if user else "anonymous user",
" and ".join(text_status) if text_status else "NO", target)
return read_allowed_items, write_allowed_items
def __call__(self, environ, start_response):
try:
status, headers, answers = self._handle_request(environ)
except Exception as e:
try:
method = str(environ["REQUEST_METHOD"])
except Exception:
method = "unknown"
try:
path = str(environ.get("PATH_INFO", ""))
except Exception:
path = ""
self.logger.error("An exception occurred during %s request on %r: "
"%s", method, path, e, exc_info=True)
status, headers, answer = INTERNAL_SERVER_ERROR
answer = answer.encode("ascii")
status = "%d %s" % (
status, client.responses.get(status, "Unknown"))
headers = [("Content-Length", str(len(answer)))] + list(headers)
answers = [answer]
start_response(status, headers)
return answers
def _handle_request(self, environ):
"""Manage a request."""
def response(status, headers=(), answer=None):
headers = dict(headers)
# Set content length
if answer:
if hasattr(answer, "encode"):
self.logger.debug("Response content:\n%s", answer)
headers["Content-Type"] += "; charset=%s" % self.encoding
answer = answer.encode(self.encoding)
accept_encoding = [
encoding.strip() for encoding in
environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
if encoding.strip()]
if "gzip" in accept_encoding:
zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
answer = zcomp.compress(answer) + zcomp.flush()
headers["Content-Encoding"] = "gzip"
headers["Content-Length"] = str(len(answer))
# Add extra headers set in configuration
if self.configuration.has_section("headers"):
for key in self.configuration.options("headers"):
headers[key] = self.configuration.get("headers", key)
# Start response
time_end = datetime.datetime.now()
status = "%d %s" % (
status, client.responses.get(status, "Unknown"))
self.logger.info(
"%s response status for %r%s in %.3f seconds: %s",
environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""),
depthinfo, (time_end - time_begin).total_seconds(), status)
# Return response content
return status, list(headers.items()), [answer] if answer else []
remote_host = "unknown"
if environ.get("REMOTE_HOST"):
remote_host = repr(environ["REMOTE_HOST"])
elif environ.get("REMOTE_ADDR"):
remote_host = environ["REMOTE_ADDR"]
if environ.get("HTTP_X_FORWARDED_FOR"):
remote_host = "%r (forwarded by %s)" % (
environ["HTTP_X_FORWARDED_FOR"], remote_host)
remote_useragent = ""
if environ.get("HTTP_USER_AGENT"):
remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
depthinfo = ""
if environ.get("HTTP_DEPTH"):
depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
time_begin = datetime.datetime.now()
self.logger.info(
"%s request for %r%s received from %s%s",
environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo,
remote_host, remote_useragent)
headers = pprint.pformat(self.headers_log(environ))
self.logger.debug("Request headers:\n%s", headers)
# Let reverse proxies overwrite SCRIPT_NAME
if "HTTP_X_SCRIPT_NAME" in environ:
# script_name must be removed from PATH_INFO by the client.
unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"]
self.logger.debug("Script name overwritten by client: %r",
unsafe_base_prefix)
else:
# SCRIPT_NAME is already removed from PATH_INFO, according to the
# WSGI specification.
unsafe_base_prefix = environ.get("SCRIPT_NAME", "")
# Sanitize base prefix
base_prefix = storage.sanitize_path(unsafe_base_prefix).rstrip("/")
self.logger.debug("Sanitized script name: %r", base_prefix)
# Sanitize request URI (a WSGI server indicates with an empty path,
# that the URL targets the application root without a trailing slash)
path = storage.sanitize_path(environ.get("PATH_INFO", ""))
self.logger.debug("Sanitized path: %r", path)
# Get function corresponding to method
function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())
# If "/.well-known" is not available, clients query "/"
if path == "/.well-known" or path.startswith("/.well-known/"):
return response(*NOT_FOUND)
# Ask authentication backend to check rights
external_login = self.Auth.get_external_login(environ)
authorization = environ.get("HTTP_AUTHORIZATION", "")
if external_login:
login, password = external_login
elif authorization.startswith("Basic"):
authorization = authorization[len("Basic"):].strip()
login, password = self.decode(base64.b64decode(
authorization.encode("ascii")), environ).split(":", 1)
else:
# DEPRECATED: use remote_user backend instead
login = environ.get("REMOTE_USER", "")
password = ""
user = self.Auth.map_login_to_user(login)
if not user:
is_authenticated = True
elif not storage.is_safe_path_component(user):
# Prevent usernames like "user/calendar.ics"
self.logger.info("Refused unsafe username: %r", user)
is_authenticated = False
else:
is_authenticated = self.Auth.is_authenticated2(login, user,
password)
if not is_authenticated:
self.logger.info("Failed login attempt: %r", user)
# Random delay to avoid timing oracles and bruteforce attacks
delay = self.configuration.getfloat("auth", "delay")
if delay > 0:
random_delay = delay * (0.5 + random.random())
self.logger.debug("Sleeping %.3f seconds", random_delay)
time.sleep(random_delay)
else:
self.logger.info("Successful login: %r", user)
# Create principal collection
if user and is_authenticated:
principal_path = "/%s/" % user
if self.Rights.authorized(user, principal_path, "w"):
with self.Collection.acquire_lock("r", user):
principal = next(
self.Collection.discover(principal_path, depth="1"),
None)
if not principal:
with self.Collection.acquire_lock("w", user):
try:
self.Collection.create_collection(principal_path)
except ValueError as e:
self.logger.warning("Failed to create principal "
"collection %r: %s", user, e)
is_authenticated = False
else:
self.logger.warning("Access to principal path %r denied by "
"rights backend", principal_path)
# Verify content length
content_length = int(environ.get("CONTENT_LENGTH") or 0)
if content_length:
max_content_length = self.configuration.getint(
"server", "max_content_length")
if max_content_length and content_length > max_content_length:
self.logger.info(
"Request body too large: %d", content_length)
return response(*REQUEST_ENTITY_TOO_LARGE)
if is_authenticated:
status, headers, answer = function(
environ, base_prefix, path, user)
if (status, headers, answer) == NOT_ALLOWED:
self.logger.info("Access to %r denied for %s", path,
repr(user) if user else "anonymous user")
else:
status, headers, answer = NOT_ALLOWED
if (status, headers, answer) == NOT_ALLOWED and not (
user and is_authenticated) and not external_login:
# Unknown or unauthorized user
self.logger.debug("Asking client for authentication")
status = client.UNAUTHORIZED
realm = self.configuration.get("server", "realm")
headers = dict(headers)
headers.update({
"WWW-Authenticate":
"Basic realm=\"%s\"" % realm})
return response(status, headers, answer)
def _access(self, user, path, permission, item=None):
"""Check if ``user`` can access ``path`` or the parent collection.
``permission`` must either be "r" or "w".
If ``item`` is given, only access to that class of item is checked.
"""
allowed = False
if not item or isinstance(item, storage.BaseCollection):
allowed |= self.Rights.authorized(user, path, permission)
if not item or not isinstance(item, storage.BaseCollection):
allowed |= self.Rights.authorized_item(user, path, permission)
return allowed
def _read_raw_content(self, environ):
content_length = int(environ.get("CONTENT_LENGTH") or 0)
if not content_length:
return b""
content = environ["wsgi.input"].read(content_length)
if len(content) < content_length:
raise RuntimeError("Request body too short: %d" % len(content))
return content
def _read_content(self, environ):
content = self.decode(self._read_raw_content(environ), environ)
self.logger.debug("Request content:\n%s", content)
return content
def _read_xml_content(self, environ):
content = self.decode(self._read_raw_content(environ), environ)
if not content:
return None
try:
xml_content = ET.fromstring(content)
except ET.ParseError as e:
self.logger.debug("Request content (Invalid XML):\n%s", content)
raise RuntimeError("Failed to parse XML: %s" % e) from e
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug("Request content:\n%s",
xmlutils.pretty_xml(xml_content))
return xml_content
def _write_xml_content(self, xml_content):
if self.logger.isEnabledFor(logging.DEBUG):
self.logger.debug("Response content:\n%s",
xmlutils.pretty_xml(xml_content))
f = io.BytesIO()
ET.ElementTree(xml_content).write(f, encoding=self.encoding,
xml_declaration=True)
return f.getvalue()
def do_DELETE(self, environ, base_prefix, path, user):
"""Manage DELETE request."""
if not self._access(user, path, "w"):
return NOT_ALLOWED
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if not item:
return NOT_FOUND
if_match = environ.get("HTTP_IF_MATCH", "*")
if if_match not in ("*", item.etag):
# ETag precondition not verified, do not delete item
return PRECONDITION_FAILED
if isinstance(item, storage.BaseCollection):
xml_answer = xmlutils.delete(base_prefix, path, item)
else:
xml_answer = xmlutils.delete(
base_prefix, path, item.collection, item.href)
headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
return client.OK, headers, self._write_xml_content(xml_answer)
def do_GET(self, environ, base_prefix, path, user):
"""Manage GET request."""
# Redirect to .web if the root URL is requested
if not path.strip("/"):
web_path = ".web"
if not environ.get("PATH_INFO"):
web_path = posixpath.join(posixpath.basename(base_prefix),
web_path)
return (client.FOUND,
{"Location": web_path, "Content-Type": "text/plain"},
"Redirected to %s" % web_path)
# Dispatch .web URL to web module
if path == "/.web" or path.startswith("/.web/"):
return self.Web.get(environ, base_prefix, path, user)
if not self._access(user, path, "r"):
return NOT_ALLOWED
with self.Collection.acquire_lock("r", user):
item = next(self.Collection.discover(path), None)
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if not item:
return NOT_FOUND
if isinstance(item, storage.BaseCollection):
tag = item.get_meta("tag")
if not tag:
return DIRECTORY_LISTING
content_type = xmlutils.MIMETYPES[tag]
else:
content_type = xmlutils.OBJECT_MIMETYPES[item.name]
headers = {
"Content-Type": content_type,
"Last-Modified": item.last_modified,
"ETag": item.etag}
answer = item.serialize()
return client.OK, headers, answer
def do_HEAD(self, environ, base_prefix, path, user):
"""Manage HEAD request."""
status, headers, answer = self.do_GET(
environ, base_prefix, path, user)
return status, headers, None
def do_MKCALENDAR(self, environ, base_prefix, path, user):
"""Manage MKCALENDAR request."""
if not self.Rights.authorized(user, path, "w"):
return NOT_ALLOWED
try:
xml_content = self._read_xml_content(environ)
except RuntimeError as e:
self.logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
except socket.timeout as e:
self.logger.debug("client timed out", exc_info=True)
return REQUEST_TIMEOUT
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if item:
return WEBDAV_PRECONDITION_FAILED
props = xmlutils.props_from_request(xml_content)
props["tag"] = "VCALENDAR"
# TODO: use this?
# timezone = props.get("C:calendar-timezone")
try:
storage.check_and_sanitize_props(props)
self.Collection.create_collection(path, props=props)
except ValueError as e:
self.logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
return client.CREATED, {}, None
def do_MKCOL(self, environ, base_prefix, path, user):
"""Manage MKCOL request."""
if not self.Rights.authorized(user, path, "w"):
return NOT_ALLOWED
try:
xml_content = self._read_xml_content(environ)
except RuntimeError as e:
self.logger.warning(
"Bad MKCOL request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
except socket.timeout as e:
self.logger.debug("client timed out", exc_info=True)
return REQUEST_TIMEOUT
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if item:
return WEBDAV_PRECONDITION_FAILED
props = xmlutils.props_from_request(xml_content)
try:
storage.check_and_sanitize_props(props)
self.Collection.create_collection(path, props=props)
except ValueError as e:
self.logger.warning(
"Bad MKCOL request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
return client.CREATED, {}, None
def do_MOVE(self, environ, base_prefix, path, user):
"""Manage MOVE request."""
raw_dest = environ.get("HTTP_DESTINATION", "")
to_url = urlparse(raw_dest)
if to_url.netloc != environ["HTTP_HOST"]:
self.logger.info("Unsupported destination address: %r", raw_dest)
# Remote destination server, not supported
return REMOTE_DESTINATION
if not self._access(user, path, "w"):
return NOT_ALLOWED
to_path = storage.sanitize_path(to_url.path)
if not (to_path + "/").startswith(base_prefix + "/"):
self.logger.warning("Destination %r from MOVE request on %r does"
"n't start with base prefix", to_path, path)
return NOT_ALLOWED
to_path = to_path[len(base_prefix):]
if not self._access(user, to_path, "w"):
return NOT_ALLOWED
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if not self._access(user, to_path, "w", item):
return NOT_ALLOWED
if not item:
return NOT_FOUND
if isinstance(item, storage.BaseCollection):
return WEBDAV_PRECONDITION_FAILED
to_item = next(self.Collection.discover(to_path), None)
if (isinstance(to_item, storage.BaseCollection) or
to_item and environ.get("HTTP_OVERWRITE", "F") != "T"):
return WEBDAV_PRECONDITION_FAILED
to_parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(to_path.strip("/")))
to_collection = next(
self.Collection.discover(to_parent_path), None)
if not to_collection:
return WEBDAV_PRECONDITION_FAILED
to_href = posixpath.basename(to_path.strip("/"))
try:
self.Collection.move(item, to_collection, to_href)
except ValueError as e:
self.logger.warning(
"Bad MOVE request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
return client.CREATED, {}, None
def do_OPTIONS(self, environ, base_prefix, path, user):
"""Manage OPTIONS request."""
headers = {
"Allow": ", ".join(
name[3:] for name in dir(self) if name.startswith("do_")),
"DAV": DAV_HEADERS}
return client.OK, headers, None
def do_PROPFIND(self, environ, base_prefix, path, user):
"""Manage PROPFIND request."""
if not self._access(user, path, "r"):
return NOT_ALLOWED
try:
xml_content = self._read_xml_content(environ)
except RuntimeError as e:
self.logger.warning(
"Bad PROPFIND request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
except socket.timeout as e:
self.logger.debug("client timed out", exc_info=True)
return REQUEST_TIMEOUT
with self.Collection.acquire_lock("r", user):
items = self.Collection.discover(
path, environ.get("HTTP_DEPTH", "0"))
# take root item for rights checking
item = next(items, None)
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if not item:
return NOT_FOUND
# put item back
items = itertools.chain([item], items)
read_items, write_items = self.collect_allowed_items(items, user)
headers = {"DAV": DAV_HEADERS,
"Content-Type": "text/xml; charset=%s" % self.encoding}
status, xml_answer = xmlutils.propfind(
base_prefix, path, xml_content, read_items, write_items, user)
if status == client.FORBIDDEN:
return NOT_ALLOWED
else:
return status, headers, self._write_xml_content(xml_answer)
def do_PROPPATCH(self, environ, base_prefix, path, user):
"""Manage PROPPATCH request."""
if not self.Rights.authorized(user, path, "w"):
return NOT_ALLOWED
try:
xml_content = self._read_xml_content(environ)
except RuntimeError as e:
self.logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
except socket.timeout as e:
self.logger.debug("client timed out", exc_info=True)
return REQUEST_TIMEOUT
with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None)
if not isinstance(item, storage.BaseCollection):
return WEBDAV_PRECONDITION_FAILED
headers = {"DAV": DAV_HEADERS,
"Content-Type": "text/xml; charset=%s" % self.encoding}
try:
xml_answer = xmlutils.proppatch(base_prefix, path, xml_content,
item)
except ValueError as e:
self.logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
return (client.MULTI_STATUS, headers,
self._write_xml_content(xml_answer))
def do_PUT(self, environ, base_prefix, path, user):
"""Manage PUT request."""
if not self._access(user, path, "w"):
return NOT_ALLOWED
try:
content = self._read_content(environ)
except RuntimeError as e:
self.logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
except socket.timeout as e:
self.logger.debug("client timed out", exc_info=True)
return REQUEST_TIMEOUT
with self.Collection.acquire_lock("w", user):
parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
item = next(self.Collection.discover(path), None)
parent_item = next(self.Collection.discover(parent_path), None)
write_whole_collection = (
isinstance(item, storage.BaseCollection) or
not parent_item or (
not next(parent_item.list(), None) and
parent_item.get_meta("tag") not in (
"VADDRESSBOOK", "VCALENDAR")))
if write_whole_collection:
if not self.Rights.authorized(user, path, "w"):
return NOT_ALLOWED
elif not self.Rights.authorized_item(user, path, "w"):
return NOT_ALLOWED
etag = environ.get("HTTP_IF_MATCH", "")
if not item and etag:
# Etag asked but no item found: item has been removed
return PRECONDITION_FAILED
if item and etag and item.etag != etag:
# Etag asked but item not matching: item has changed
return PRECONDITION_FAILED
match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
if item and match:
# Creation asked but item found: item can't be replaced
return PRECONDITION_FAILED
try:
items = tuple(vobject.readComponents(content or ""))
if not write_whole_collection and len(items) != 1:
raise RuntimeError(
"Item contains %d components" % len(items))
if write_whole_collection or not parent_item.get_meta("tag"):
content_type = environ.get("CONTENT_TYPE",
"").split(";")[0]
tags = {value: key
for key, value in xmlutils.MIMETYPES.items()}
tag = tags.get(content_type)
if items and items[0].name == "VCALENDAR":
tag = "VCALENDAR"
elif items and items[0].name in ("VCARD", "VLIST"):
tag = "VADDRESSBOOK"
else:
tag = parent_item.get_meta("tag")
if tag == "VCALENDAR" and len(items) > 1:
raise RuntimeError("VCALENDAR collection contains %d "
"components" % len(items))
for i in items:
storage.check_and_sanitize_item(
i, is_collection=write_whole_collection, uid=item.uid
if not write_whole_collection and item else None,
tag=tag)
except Exception as e:
self.logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
if write_whole_collection:
props = {}
if tag:
props["tag"] = tag
if tag == "VCALENDAR" and items:
if hasattr(items[0], "x_wr_calname"):
calname = items[0].x_wr_calname.value
if calname:
props["D:displayname"] = calname
if hasattr(items[0], "x_wr_caldesc"):
caldesc = items[0].x_wr_caldesc.value
if caldesc:
props["C:calendar-description"] = caldesc
try:
storage.check_and_sanitize_props(props)
new_item = self.Collection.create_collection(
path, items, props)
except ValueError as e:
self.logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
else:
href = posixpath.basename(path.strip("/"))
try:
if tag and not parent_item.get_meta("tag"):
new_props = parent_item.get_meta()
new_props["tag"] = tag
storage.check_and_sanitize_props(new_props)
parent_item.set_meta_all(new_props)
new_item = parent_item.upload(href, items[0])
except ValueError as e:
self.logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
headers = {"ETag": new_item.etag}
return client.CREATED, headers, None
def do_REPORT(self, environ, base_prefix, path, user):
"""Manage REPORT request."""
if not self._access(user, path, "r"):
return NOT_ALLOWED
try:
xml_content = self._read_xml_content(environ)
except RuntimeError as e:
self.logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
except socket.timeout as e:
self.logger.debug("client timed out", exc_info=True)
return REQUEST_TIMEOUT
with self.Collection.acquire_lock("r", user):
item = next(self.Collection.discover(path), None)
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if not item:
return NOT_FOUND
if isinstance(item, storage.BaseCollection):
collection = item
else:
collection = item.collection
headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
try:
status, xml_answer = xmlutils.report(
base_prefix, path, xml_content, collection)
except ValueError as e:
self.logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True)
return BAD_REQUEST
return (status, headers, self._write_xml_content(xml_answer))

291
radicale/__main__.py Normal file
View File

@ -0,0 +1,291 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale executable module.
This module can be executed from a command line with ``$python -m radicale`` or
from a python programme with ``radicale.__main__.run()``.
"""
import argparse
import atexit
import os
import select
import signal
import socket
import ssl
import sys
from wsgiref.simple_server import make_server
from . import (VERSION, Application, RequestHandler, ThreadedHTTPServer,
ThreadedHTTPSServer, config, log, storage)
def run():
"""Run Radicale as a standalone server."""
# Get command-line arguments
parser = argparse.ArgumentParser(usage="radicale [OPTIONS]")
parser.add_argument("--version", action="version", version=VERSION)
parser.add_argument("--verify-storage", action="store_true",
help="check the storage for errors and exit")
parser.add_argument(
"-C", "--config", help="use a specific configuration file")
groups = {}
for section, values in config.INITIAL_CONFIG.items():
group = parser.add_argument_group(section)
groups[group] = []
for option, data in values.items():
kwargs = data.copy()
long_name = "--{0}-{1}".format(
section, option.replace("_", "-"))
args = kwargs.pop("aliases", [])
args.append(long_name)
kwargs["dest"] = "{0}_{1}".format(section, option)
groups[group].append(kwargs["dest"])
del kwargs["value"]
if "internal" in kwargs:
del kwargs["internal"]
if kwargs["type"] == bool:
del kwargs["type"]
kwargs["action"] = "store_const"
kwargs["const"] = "True"
opposite_args = kwargs.pop("opposite", [])
opposite_args.append("--no{0}".format(long_name[1:]))
group.add_argument(*args, **kwargs)
kwargs["const"] = "False"
kwargs["help"] = "do not {0} (opposite of {1})".format(
kwargs["help"], long_name)
group.add_argument(*opposite_args, **kwargs)
else:
group.add_argument(*args, **kwargs)
args = parser.parse_args()
if args.config is not None:
config_paths = [args.config] if args.config else []
ignore_missing_paths = False
else:
config_paths = ["/etc/radicale/config",
os.path.expanduser("~/.config/radicale/config")]
if "RADICALE_CONFIG" in os.environ:
config_paths.append(os.environ["RADICALE_CONFIG"])
ignore_missing_paths = True
try:
configuration = config.load(config_paths,
ignore_missing_paths=ignore_missing_paths)
except Exception as e:
print("ERROR: Invalid configuration: %s" % e, file=sys.stderr)
if args.logging_debug:
raise
exit(1)
# Update Radicale configuration according to arguments
for group, actions in groups.items():
section = group.title
for action in actions:
value = getattr(args, action)
if value is not None:
configuration.set(section, action.split('_', 1)[1], value)
if args.verify_storage:
# Write to stderr when storage verification is requested
configuration["logging"]["config"] = ""
# Start logging
filename = os.path.expanduser(configuration.get("logging", "config"))
debug = configuration.getboolean("logging", "debug")
try:
logger = log.start("radicale", filename, debug)
except Exception as e:
print("ERROR: Failed to start logger: %s" % e, file=sys.stderr)
if debug:
raise
exit(1)
if args.verify_storage:
logger.info("Verifying storage")
try:
Collection = storage.load(configuration, logger)
with Collection.acquire_lock("r"):
if not Collection.verify():
logger.error("Storage verifcation failed")
exit(1)
except Exception as e:
logger.error("An exception occurred during storage verification: "
"%s", e, exc_info=True)
exit(1)
return
try:
serve(configuration, logger)
except Exception as e:
logger.error("An exception occurred during server startup: %s", e,
exc_info=True)
exit(1)
def daemonize(configuration, logger):
"""Fork and decouple if Radicale is configured as daemon."""
# Check and create PID file in a race-free manner
if configuration.get("server", "pid"):
try:
pid_path = os.path.abspath(os.path.expanduser(
configuration.get("server", "pid")))
pid_fd = os.open(
pid_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except OSError as e:
raise OSError("PID file exists: %r" %
configuration.get("server", "pid")) from e
pid = os.fork()
if pid:
# Write PID
if configuration.get("server", "pid"):
with os.fdopen(pid_fd, "w") as pid_file:
pid_file.write(str(pid))
sys.exit()
if configuration.get("server", "pid"):
os.close(pid_fd)
# Register exit function
def cleanup():
"""Remove the PID files."""
logger.debug("Cleaning up")
# Remove PID file
os.unlink(pid_path)
atexit.register(cleanup)
# Decouple environment
os.chdir("/")
os.setsid()
with open(os.devnull, "r") as null_in:
os.dup2(null_in.fileno(), sys.stdin.fileno())
with open(os.devnull, "w") as null_out:
os.dup2(null_out.fileno(), sys.stdout.fileno())
os.dup2(null_out.fileno(), sys.stderr.fileno())
def serve(configuration, logger):
"""Serve radicale from configuration."""
logger.info("Starting Radicale")
# Create collection servers
servers = {}
if configuration.getboolean("server", "ssl"):
server_class = ThreadedHTTPSServer
server_class.certificate = configuration.get("server", "certificate")
server_class.key = configuration.get("server", "key")
server_class.certificate_authority = configuration.get(
"server", "certificate_authority")
server_class.ciphers = configuration.get("server", "ciphers")
server_class.protocol = getattr(
ssl, configuration.get("server", "protocol"), ssl.PROTOCOL_SSLv23)
# Test if the SSL files can be read
for name in ["certificate", "key"] + (
["certificate_authority"]
if server_class.certificate_authority else []):
filename = getattr(server_class, name)
try:
open(filename, "r").close()
except OSError as e:
raise RuntimeError("Failed to read SSL %s %r: %s" %
(name, filename, e)) from e
else:
server_class = ThreadedHTTPServer
server_class.client_timeout = configuration.getint("server", "timeout")
server_class.max_connections = configuration.getint(
"server", "max_connections")
server_class.logger = logger
RequestHandler.logger = logger
if not configuration.getboolean("server", "dns_lookup"):
RequestHandler.address_string = lambda self: self.client_address[0]
shutdown_program = False
for host in configuration.get("server", "hosts").split(","):
try:
address, port = host.strip().rsplit(":", 1)
address, port = address.strip("[] "), int(port)
except ValueError as e:
raise RuntimeError(
"Failed to parse address %r: %s" % (host, e)) from e
application = Application(configuration, logger)
try:
server = make_server(
address, port, application, server_class, RequestHandler)
except OSError as e:
raise RuntimeError(
"Failed to start server %r: %s" % (host, e)) from e
servers[server.socket] = server
logger.info("Listening to %r on port %d%s",
server.server_name, server.server_port, " using SSL"
if configuration.getboolean("server", "ssl") else "")
# Create a socket pair to notify the select syscall of program shutdown
# This is not available in python < 3.5 on Windows
if hasattr(socket, "socketpair"):
shutdown_program_socket_in, shutdown_program_socket_out = (
socket.socketpair())
else:
shutdown_program_socket_in, shutdown_program_socket_out = None, None
# SIGTERM and SIGINT (aka KeyboardInterrupt) should just mark this for
# shutdown
def shutdown(*args):
nonlocal shutdown_program
if shutdown_program:
# Ignore following signals
return
logger.info("Stopping Radicale")
shutdown_program = True
if shutdown_program_socket_in:
shutdown_program_socket_in.sendall(b"goodbye")
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
# Main loop: wait for requests on any of the servers or program shutdown
sockets = list(servers.keys())
if shutdown_program_socket_out:
# Use socket pair to get notified of program shutdown
sockets.append(shutdown_program_socket_out)
select_timeout = None
if not shutdown_program_socket_out or os.name == "nt":
# Fallback to busy waiting. (select.select blocks SIGINT on Windows.)
select_timeout = 1.0
if configuration.getboolean("server", "daemon"):
daemonize(configuration, logger)
logger.info("Radicale server ready")
while not shutdown_program:
try:
rlist, _, xlist = select.select(
sockets, [], sockets, select_timeout)
except (KeyboardInterrupt, select.error):
# SIGINT is handled by signal handler above
rlist, xlist = [], []
if xlist:
raise RuntimeError("unhandled socket error")
if rlist:
server = servers.get(rlist[0])
if server:
server.handle_request()
if __name__ == "__main__":
run()

274
radicale/auth.py Normal file
View File

@ -0,0 +1,274 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Authentication management.
Default is htpasswd authentication.
Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html)
manages a file for storing user credentials. It can encrypt passwords using
different methods, e.g. BCRYPT, MD5-APR1 (a version of MD5 modified for
Apache), SHA1, or by using the system's CRYPT routine. The CRYPT and SHA1
encryption methods implemented by htpasswd are considered as insecure. MD5-APR1
provides medium security as of 2015. Only BCRYPT can be considered secure by
current standards.
MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it
is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer.
The `is_authenticated(user, password)` function provided by this module
verifies the user-given credentials by parsing the htpasswd credential file
pointed to by the ``htpasswd_filename`` configuration value while assuming
the password encryption method specified via the ``htpasswd_encryption``
configuration value.
The following htpasswd password encrpytion methods are supported by Radicale
out-of-the-box:
- plain-text (created by htpasswd -p...) -- INSECURE
- CRYPT (created by htpasswd -d...) -- INSECURE
- SHA1 (created by htpasswd -s...) -- INSECURE
When passlib (https://pypi.python.org/pypi/passlib) is importable, the
following significantly more secure schemes are parsable by Radicale:
- MD5-APR1 (htpasswd -m...) -- htpasswd's default method
- BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x
"""
import base64
import functools
import hashlib
import hmac
import os
from importlib import import_module
INTERNAL_TYPES = ("None", "none", "remote_user", "http_x_remote_user",
"htpasswd")
def load(configuration, logger):
"""Load the authentication manager chosen in configuration."""
auth_type = configuration.get("auth", "type")
if auth_type in ("None", "none"): # DEPRECATED: use "none"
class_ = NoneAuth
elif auth_type == "remote_user":
class_ = RemoteUserAuth
elif auth_type == "http_x_remote_user":
class_ = HttpXRemoteUserAuth
elif auth_type == "htpasswd":
class_ = Auth
else:
try:
class_ = import_module(auth_type).Auth
except Exception as e:
raise RuntimeError("Failed to load authentication module %r: %s" %
(auth_type, e)) from e
logger.info("Authentication type is %r", auth_type)
return class_(configuration, logger)
class BaseAuth:
def __init__(self, configuration, logger):
self.configuration = configuration
self.logger = logger
def get_external_login(self, environ):
"""Optionally provide the login and password externally.
``environ`` a dict with the WSGI environment
If ``()`` is returned, Radicale handles HTTP authentication.
Otherwise, returns a tuple ``(login, password)``. For anonymous users
``login`` must be ``""``.
"""
return ()
def is_authenticated2(self, login, user, password):
"""Validate credentials.
``login`` the login name
``user`` the user from ``map_login_to_user(login)``.
``password`` the login password
"""
return self.is_authenticated(user, password)
def is_authenticated(self, user, password):
"""Validate credentials.
DEPRECATED: use ``is_authenticated2`` instead
"""
raise NotImplementedError
def map_login_to_user(self, login):
"""Map login name to internal user.
``login`` the login name, ``""`` for anonymous users
Returns a string with the user name.
If a login can't be mapped to an user, return ``login`` and
return ``False`` in ``is_authenticated2(...)``.
"""
return login
class NoneAuth(BaseAuth):
def is_authenticated(self, user, password):
return True
class Auth(BaseAuth):
def __init__(self, configuration, logger):
super().__init__(configuration, logger)
self.filename = os.path.expanduser(
configuration.get("auth", "htpasswd_filename"))
self.encryption = configuration.get("auth", "htpasswd_encryption")
if self.encryption == "ssha":
self.verify = self._ssha
elif self.encryption == "sha1":
self.verify = self._sha1
elif self.encryption == "plain":
self.verify = self._plain
elif self.encryption == "md5":
try:
from passlib.hash import apr_md5_crypt
except ImportError as e:
raise RuntimeError(
"The htpasswd encryption method 'md5' requires "
"the passlib module.") from e
self.verify = functools.partial(self._md5apr1, apr_md5_crypt)
elif self.encryption == "bcrypt":
try:
from passlib.hash import bcrypt
except ImportError as e:
raise RuntimeError(
"The htpasswd encryption method 'bcrypt' requires "
"the passlib module with bcrypt support.") from e
# A call to `encrypt` raises passlib.exc.MissingBackendError with a
# good error message if bcrypt backend is not available. Trigger
# this here.
bcrypt.encrypt("test-bcrypt-backend")
self.verify = functools.partial(self._bcrypt, bcrypt)
elif self.encryption == "crypt":
try:
import crypt
except ImportError as e:
raise RuntimeError(
"The htpasswd encryption method 'crypt' requires "
"the crypt() system support.") from e
self.verify = functools.partial(self._crypt, crypt)
else:
raise RuntimeError(
"The htpasswd encryption method %r is not "
"supported." % self.encryption)
def _plain(self, hash_value, password):
"""Check if ``hash_value`` and ``password`` match, plain method."""
return hmac.compare_digest(hash_value, password)
def _crypt(self, crypt, hash_value, password):
"""Check if ``hash_value`` and ``password`` match, crypt method."""
hash_value = hash_value.strip()
return hmac.compare_digest(crypt.crypt(password, hash_value),
hash_value)
def _sha1(self, hash_value, password):
"""Check if ``hash_value`` and ``password`` match, sha1 method."""
hash_value = base64.b64decode(hash_value.strip().replace(
"{SHA}", "").encode("ascii"))
password = password.encode(self.configuration.get("encoding", "stock"))
sha1 = hashlib.sha1()
sha1.update(password)
return hmac.compare_digest(sha1.digest(), hash_value)
def _ssha(self, hash_value, password):
"""Check if ``hash_value`` and ``password`` match, salted sha1 method.
This method is not directly supported by htpasswd, but it can be
written with e.g. openssl, and nginx can parse it.
"""
hash_value = base64.b64decode(hash_value.strip().replace(
"{SSHA}", "").encode("ascii"))
password = password.encode(self.configuration.get("encoding", "stock"))
salt_value = hash_value[20:]
hash_value = hash_value[:20]
sha1 = hashlib.sha1()
sha1.update(password)
sha1.update(salt_value)
return hmac.compare_digest(sha1.digest(), hash_value)
def _bcrypt(self, bcrypt, hash_value, password):
hash_value = hash_value.strip()
return bcrypt.verify(password, hash_value)
def _md5apr1(self, md5_apr1, hash_value, password):
hash_value = hash_value.strip()
return md5_apr1.verify(password, hash_value)
def is_authenticated(self, user, password):
"""Validate credentials.
Iterate through htpasswd credential file until user matches, extract
hash (encrypted password) and check hash against user-given password,
using the method specified in the Radicale config.
The content of the file is not cached because reading is generally a
very cheap operation, and it's useful to get live updates of the
htpasswd file.
"""
try:
with open(self.filename) as f:
for line in f:
line = line.rstrip("\n")
if line.lstrip() and not line.lstrip().startswith("#"):
try:
login, hash_value = line.split(":", maxsplit=1)
# Always compare both login and password to avoid
# timing attacks, see #591.
login_ok = hmac.compare_digest(login, user)
password_ok = self.verify(hash_value, password)
if login_ok and password_ok:
return True
except ValueError as e:
raise RuntimeError("Invalid htpasswd file %r: %s" %
(self.filename, e)) from e
except OSError as e:
raise RuntimeError("Failed to load htpasswd file %r: %s" %
(self.filename, e)) from e
return False
class RemoteUserAuth(NoneAuth):
def get_external_login(self, environ):
return environ.get("REMOTE_USER", ""), ""
class HttpXRemoteUserAuth(NoneAuth):
def get_external_login(self, environ):
return environ.get("HTTP_X_REMOTE_USER", ""), ""

259
radicale/config.py Normal file
View File

@ -0,0 +1,259 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale configuration module.
Give a configparser-like interface to read and write configuration.
"""
import math
import os
from collections import OrderedDict
from configparser import RawConfigParser as ConfigParser
from . import auth, rights, storage, web
def positive_int(value):
value = int(value)
if value < 0:
raise ValueError("value is negative: %d" % value)
return value
def positive_float(value):
value = float(value)
if not math.isfinite(value):
raise ValueError("value is infinite")
if math.isnan(value):
raise ValueError("value is not a number")
if value < 0:
raise ValueError("value is negative: %f" % value)
return value
# Default configuration
INITIAL_CONFIG = OrderedDict([
("server", OrderedDict([
("hosts", {
"value": "127.0.0.1:5232",
"help": "set server hostnames including ports",
"aliases": ["-H", "--hosts"],
"type": str}),
("daemon", {
"value": "False",
"help": "launch as daemon",
"aliases": ["-d", "--daemon"],
"opposite": ["-f", "--foreground"],
"type": bool}),
("pid", {
"value": "",
"help": "set PID filename for daemon mode",
"aliases": ["-p", "--pid"],
"type": str}),
("max_connections", {
"value": "20",
"help": "maximum number of parallel connections",
"type": positive_int}),
("max_content_length", {
"value": "10000000",
"help": "maximum size of request body in bytes",
"type": positive_int}),
("timeout", {
"value": "10",
"help": "socket timeout",
"type": positive_int}),
("ssl", {
"value": "False",
"help": "use SSL connection",
"aliases": ["-s", "--ssl"],
"opposite": ["-S", "--no-ssl"],
"type": bool}),
("certificate", {
"value": "/etc/ssl/radicale.cert.pem",
"help": "set certificate file",
"aliases": ["-c", "--certificate"],
"type": str}),
("key", {
"value": "/etc/ssl/radicale.key.pem",
"help": "set private key file",
"aliases": ["-k", "--key"],
"type": str}),
("certificate_authority", {
"value": "",
"help": "set CA certificate for validating clients",
"aliases": ["--certificate-authority"],
"type": str}),
("protocol", {
"value": "PROTOCOL_TLSv1_2",
"help": "SSL protocol used",
"type": str}),
("ciphers", {
"value": "",
"help": "available ciphers",
"type": str}),
("dns_lookup", {
"value": "True",
"help": "use reverse DNS to resolve client address in logs",
"type": bool}),
("realm", {
"value": "Radicale - Password Required",
"help": "message displayed when a password is needed",
"type": str})])),
("encoding", OrderedDict([
("request", {
"value": "utf-8",
"help": "encoding for responding requests",
"type": str}),
("stock", {
"value": "utf-8",
"help": "encoding for storing local collections",
"type": str})])),
("auth", OrderedDict([
("type", {
"value": "none",
"help": "authentication method",
"type": str,
"internal": auth.INTERNAL_TYPES}),
("htpasswd_filename", {
"value": "/etc/radicale/users",
"help": "htpasswd filename",
"type": str}),
("htpasswd_encryption", {
"value": "bcrypt",
"help": "htpasswd encryption method",
"type": str}),
("delay", {
"value": "1",
"help": "incorrect authentication delay",
"type": positive_float})])),
("rights", OrderedDict([
("type", {
"value": "owner_only",
"help": "rights backend",
"type": str,
"internal": rights.INTERNAL_TYPES}),
("file", {
"value": "/etc/radicale/rights",
"help": "file for rights management from_file",
"type": str})])),
("storage", OrderedDict([
("type", {
"value": "multifilesystem",
"help": "storage backend",
"type": str,
"internal": storage.INTERNAL_TYPES}),
("filesystem_folder", {
"value": os.path.expanduser(
"/var/lib/radicale/collections"),
"help": "path where collections are stored",
"type": str}),
("max_sync_token_age", {
"value": 2592000, # 30 days
"help": "delete sync token that are older",
"type": int}),
("filesystem_fsync", {
"value": "True",
"help": "sync all changes to filesystem during requests",
"type": bool}),
("filesystem_locking", {
"value": "True",
"help": "lock the storage while accessing it",
"type": bool}),
("filesystem_close_lock_file", {
"value": "False",
"help": "close the lock file when no more clients are waiting",
"type": bool}),
("hook", {
"value": "",
"help": "command that is run after changes to storage",
"type": str})])),
("web", OrderedDict([
("type", {
"value": "internal",
"help": "web interface backend",
"type": str,
"internal": web.INTERNAL_TYPES})])),
("logging", OrderedDict([
("config", {
"value": "",
"help": "logging configuration file",
"type": str}),
("debug", {
"value": "False",
"help": "print debug information",
"aliases": ["-D", "--debug"],
"type": bool}),
("full_environment", {
"value": "False",
"help": "store all environment variables",
"type": bool}),
("mask_passwords", {
"value": "True",
"help": "mask passwords in logs",
"type": bool})]))])
def load(paths=(), extra_config=None, ignore_missing_paths=True):
config = ConfigParser()
for section, values in INITIAL_CONFIG.items():
config.add_section(section)
for key, data in values.items():
config.set(section, key, data["value"])
if extra_config:
for section, values in extra_config.items():
for key, value in values.items():
config.set(section, key, value)
for path in paths:
if path or not ignore_missing_paths:
try:
if not config.read(path) and not ignore_missing_paths:
raise RuntimeError("No such file: %r" % path)
except Exception as e:
raise RuntimeError(
"Failed to load config file %r: %s" % (path, e)) from e
# Check the configuration
for section in config.sections():
if section == "headers":
continue
if section not in INITIAL_CONFIG:
raise RuntimeError("Invalid section %r in config" % section)
allow_extra_options = ("type" in INITIAL_CONFIG[section] and
config.get(section, "type") not in
INITIAL_CONFIG[section]["type"].get("internal",
()))
for option in config[section]:
if option not in INITIAL_CONFIG[section]:
if allow_extra_options:
continue
raise RuntimeError("Invalid option %r in section %r in "
"config" % (option, section))
type_ = INITIAL_CONFIG[section][option]["type"]
try:
if type_ == bool:
config.getboolean(section, option)
else:
type_(config.get(section, option))
except Exception as e:
raise RuntimeError(
"Invalid %s value for option %r in section %r in config: "
"%r" % (type_.__name__, option, section,
config.get(section, option))) from e
return config

75
radicale/log.py Normal file
View File

@ -0,0 +1,75 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale logging module.
Manage logging from a configuration file. For more information, see:
http://docs.python.org/library/logging.config.html
"""
import logging
import logging.config
import signal
import sys
def configure_from_file(logger, filename, debug):
logging.config.fileConfig(filename, disable_existing_loggers=False)
if debug:
logger.setLevel(logging.DEBUG)
for handler in logger.handlers:
handler.setLevel(logging.DEBUG)
return logger
class RemoveTracebackFilter(logging.Filter):
def filter(self, record):
record.exc_info = None
return True
def start(name="radicale", filename=None, debug=False):
"""Start the logging according to the configuration."""
logger = logging.getLogger(name)
if debug:
logger.setLevel(logging.DEBUG)
else:
logger.addFilter(RemoveTracebackFilter())
if filename:
# Configuration taken from file
try:
configure_from_file(logger, filename, debug)
except Exception as e:
raise RuntimeError("Failed to load logging configuration file %r: "
"%s" % (filename, e)) from e
# Reload config on SIGHUP (UNIX only)
if hasattr(signal, "SIGHUP"):
def handler(signum, frame):
try:
configure_from_file(logger, filename, debug)
except Exception as e:
logger.error("Failed to reload logging configuration file "
"%r: %s", filename, e, exc_info=True)
signal.signal(signal.SIGHUP, handler)
else:
# Default configuration, standard output
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(
logging.Formatter("[%(thread)x] %(levelname)s: %(message)s"))
logger.addHandler(handler)
return logger

176
radicale/rights.py Normal file
View File

@ -0,0 +1,176 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Rights backends.
This module loads the rights backend, according to the rights
configuration.
Default rights are based on a regex-based file whose name is specified in the
config (section "right", key "file").
Authentication login is matched against the "user" key, and collection's path
is matched against the "collection" key. You can use Python's ConfigParser
interpolation values %(login)s and %(path)s. You can also get groups from the
user regex in the collection with {0}, {1}, etc.
For example, for the "user" key, ".+" means "authenticated user" and ".*"
means "anybody" (including anonymous users).
Section names are only used for naming the rule.
Leading or ending slashes are trimmed from collection's path.
"""
import configparser
import os.path
import posixpath
import re
from importlib import import_module
from . import storage
INTERNAL_TYPES = ("None", "none", "authenticated", "owner_write", "owner_only",
"from_file")
def load(configuration, logger):
"""Load the rights manager chosen in configuration."""
rights_type = configuration.get("rights", "type")
if configuration.get("auth", "type") in ("None", "none"): # DEPRECATED
rights_type = "None"
if rights_type in ("None", "none"): # DEPRECATED: use "none"
rights_class = NoneRights
elif rights_type == "authenticated":
rights_class = AuthenticatedRights
elif rights_type == "owner_write":
rights_class = OwnerWriteRights
elif rights_type == "owner_only":
rights_class = OwnerOnlyRights
elif rights_type == "from_file":
rights_class = Rights
else:
try:
rights_class = import_module(rights_type).Rights
except Exception as e:
raise RuntimeError("Failed to load rights module %r: %s" %
(rights_type, e)) from e
logger.info("Rights type is %r", rights_type)
return rights_class(configuration, logger)
class BaseRights:
def __init__(self, configuration, logger):
self.configuration = configuration
self.logger = logger
def authorized(self, user, path, permission):
"""Check if the user is allowed to read or write the collection.
If ``user`` is empty, check for anonymous rights.
``path`` is sanitized.
``permission`` is "r" or "w".
"""
raise NotImplementedError
def authorized_item(self, user, path, permission):
"""Check if the user is allowed to read or write the item."""
path = storage.sanitize_path(path)
parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
return self.authorized(user, parent_path, permission)
class NoneRights(BaseRights):
def authorized(self, user, path, permission):
return True
class AuthenticatedRights(BaseRights):
def authorized(self, user, path, permission):
return bool(user)
class OwnerWriteRights(BaseRights):
def authorized(self, user, path, permission):
sane_path = storage.sanitize_path(path).strip("/")
return bool(user) and (permission == "r" or
user == sane_path.split("/", maxsplit=1)[0])
class OwnerOnlyRights(BaseRights):
def authorized(self, user, path, permission):
sane_path = storage.sanitize_path(path).strip("/")
return bool(user) and (
permission == "r" and not sane_path or
user == sane_path.split("/", maxsplit=1)[0])
def authorized_item(self, user, path, permission):
sane_path = storage.sanitize_path(path).strip("/")
if "/" not in sane_path:
return False
return super().authorized_item(user, path, permission)
class Rights(BaseRights):
def __init__(self, configuration, logger):
super().__init__(configuration, logger)
self.filename = os.path.expanduser(configuration.get("rights", "file"))
def authorized(self, user, path, permission):
user = user or ""
sane_path = storage.sanitize_path(path).strip("/")
# Prevent "regex injection"
user_escaped = re.escape(user)
sane_path_escaped = re.escape(sane_path)
regex = configparser.ConfigParser(
{"login": user_escaped, "path": sane_path_escaped})
try:
if not regex.read(self.filename):
raise RuntimeError("No such file: %r" %
self.filename)
except Exception as e:
raise RuntimeError("Failed to load rights file %r: %s" %
(self.filename, e)) from e
for section in regex.sections():
try:
re_user_pattern = regex.get(section, "user")
re_collection_pattern = regex.get(section, "collection")
# Emulate fullmatch
user_match = re.match(r"(?:%s)\Z" % re_user_pattern, user)
collection_match = user_match and re.match(
r"(?:%s)\Z" % re_collection_pattern.format(
*map(re.escape, user_match.groups())), sane_path)
except Exception as e:
raise RuntimeError("Error in section %r of rights file %r: "
"%s" % (section, self.filename, e)) from e
if user_match and collection_match:
self.logger.debug("Rule %r:%r matches %r:%r from section %r",
user, sane_path, re_user_pattern,
re_collection_pattern, section)
return permission in regex.get(section, "permission")
else:
self.logger.debug("Rule %r:%r doesn't match %r:%r from section"
" %r", user, sane_path, re_user_pattern,
re_collection_pattern, section)
self.logger.info(
"Rights: %r:%r doesn't match any section", user, sane_path)
return False

1683
radicale/storage.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Tests for Radicale.
"""
import logging
import os
import sys
from io import BytesIO
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
logger = logging.getLogger("radicale_test")
if not logger.hasHandlers():
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
class BaseTest:
"""Base class for tests."""
logger = logger
def request(self, method, path, data=None, **args):
"""Send a request."""
self.application._status = None
self.application._headers = None
self.application._answer = None
for key in args:
args[key.upper()] = args[key]
args["REQUEST_METHOD"] = method.upper()
args["PATH_INFO"] = path
if data:
data = data.encode("utf-8")
args["wsgi.input"] = BytesIO(data)
args["CONTENT_LENGTH"] = str(len(data))
self.application._answer = self.application(args, self.start_response)
return (
int(self.application._status.split()[0]),
dict(self.application._headers),
self.application._answer[0].decode("utf-8")
if self.application._answer else None)
def start_response(self, status, headers):
"""Put the response values into the current application."""
self.application._status = status
self.application._headers = headers

View File

View File

@ -0,0 +1,31 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Custom authentication.
Just check username for testing
"""
from radicale import auth
class Auth(auth.BaseAuth):
def is_authenticated(self, user, password):
return user == "tmp"

View File

@ -0,0 +1,27 @@
# This file is part of Radicale Server - Calendar Server
# Copyright (C) 2017 Unrud <unrud@openaliasbox.org>
#
# 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/>.
"""
Custom rights management.
"""
from radicale import rights
class Rights(rights.BaseRights):
def authorized(self, user, path, permission):
return path.strip("/") in ("tmp", "other")

View File

@ -0,0 +1,31 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Custom storage backend.
Copy of filesystem storage backend for testing
"""
from radicale import storage
# TODO: make something more in this collection (and test it)
class Collection(storage.Collection):
"""Collection stored in a folder."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

36
radicale/tests/helpers.py Normal file
View File

@ -0,0 +1,36 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale Helpers module.
This module offers helpers to use in tests.
"""
import os
EXAMPLES_FOLDER = os.path.join(os.path.dirname(__file__), "static")
def get_file_content(file_name):
try:
with open(os.path.join(EXAMPLES_FOLDER, file_name)) as fd:
return fd.read()
except IOError:
print("Couldn't open the file %s" % file_name)

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:allprop />
</D:propfind>

View File

@ -0,0 +1,8 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
X-MOZILLA-HTML:FALSE
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
END:VCARD

View File

@ -0,0 +1,15 @@
BEGIN:VCALENDAR
PRODID:-//Radicale//NONSGML Radicale Server//EN
VERSION:2.0
BEGIN:VEVENT
CREATED:20160725T060147Z
LAST-MODIFIED:20160727T193435Z
DTSTAMP:20160727T193435Z
UID:040000008200E00074C5B7101A82E00800000000
SUMMARY:Broken ICS END of VEVENT missing by accident
STATUS:CONFIRMED
X-MOZ-LASTACK:20160727T193435Z
DTSTART;TZID=Europe/Budapest:20160727T170000
DTEND;TZID=Europe/Budapest:20160727T223000
CLASS:PUBLIC
X-LIC-ERROR:No value for LOCATION property. Removing entire property:

View File

@ -0,0 +1,7 @@
BEGIN:VCARD
VERSION:3.0
UID:contact1
N:Contact;;;;
FN:Contact
NICKNAME:test
END:VCARD

View File

@ -0,0 +1,12 @@
BEGIN:VCARD
VERSION:3.0
UID:contact1
N:Contact1;;;;
FN:Contact1
END:VCARD
BEGIN:VCARD
VERSION:3.0
UID:contact2
N:Contact2;;;;
FN:Contact2
END:VCARD

View File

@ -0,0 +1,34 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20130902T150157Z
LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event1
SUMMARY:Event
ORGANIZER:mailto:unclesam@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
DTSTART;TZID=Europe/Paris:20140901T180000
DTEND;TZID=Europe/Paris:20140901T210000
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,34 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20130902T150157Z
LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event1
SUMMARY:Event
ORGANIZER:mailto:unclesam@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
DTSTART;TZID=Europe/Paris:20130901T180000
DTEND;TZID=Europe/Paris:20130901T190000
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,42 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20130902T150157Z
LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event2
SUMMARY:Event2
DTSTART;TZID=Europe/Paris:20130902T180000
DTEND;TZID=Europe/Paris:20130902T190000
RRULE:FREQ=WEEKLY
SEQUENCE:1
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Europe/Paris:20130910T170000
DTEND;TZID=Europe/Paris:20130910T180000
DTSTAMP:20140902T150158Z
SUMMARY:Event2
UID:event2
RECURRENCE-ID;TZID=Europe/Paris:20130909T180000
SEQUENCE:2
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,31 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20130902T150157Z
LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event3
SUMMARY:Event3
DTSTART;TZID=Europe/Paris:20130903
DURATION:PT1H
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,30 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20130902T150157Z
LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event4
SUMMARY:Event4
DTSTART;TZID=Europe/Paris:20130904T180000
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,30 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20130902T150157Z
LAST-MODIFIED:20130902T150158Z
DTSTAMP:20130902T150158Z
UID:event5
SUMMARY:Event5
DTSTART;TZID=Europe/Paris:20130905
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,46 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTIMEZONE
TZID:Europe/Paris
BEGIN:STANDARD
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:event6
DTSTART;TZID=Europe/Paris:20170601T080000
DTEND;TZID=Europe/Paris:20170601T090000
CREATED:20170601T060000Z
DTSTAMP:20170601T060000Z
LAST-MODIFIED:20170601T060000Z
RRULE:FREQ=DAILY;UNTIL=20170602T060000Z
SUMMARY:event6
TRANSP:OPAQUE
X-MOZ-GENERATION:1
END:VEVENT
BEGIN:VEVENT
UID:event6
RECURRENCE-ID;TZID=Europe/Paris:20170602T080000
DTSTART;TZID=Europe/Paris:20170701T080000
DTEND;TZID=Europe/Paris:20170701T090000
CREATED:20170601T060000Z
DTSTAMP:20170601T060000Z
LAST-MODIFIED:20170601T060000Z
SEQUENCE:1
SUMMARY:event6
TRANSP:OPAQUE
X-MOZ-GENERATION:1
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,59 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTIMEZONE
TZID:Europe/Paris
BEGIN:STANDARD
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:event7
DTSTART;TZID=Europe/Paris:20170701T080000
DTEND;TZID=Europe/Paris:20170701T090000
CREATED:20170601T060000Z
DTSTAMP:20170601T060000Z
LAST-MODIFIED:20170601T060000Z
RRULE:FREQ=DAILY
SUMMARY:event7
TRANSP:OPAQUE
X-MOZ-GENERATION:1
END:VEVENT
BEGIN:VEVENT
UID:event7
RECURRENCE-ID;TZID=Europe/Paris:20170702T080000
DTSTART;TZID=Europe/Paris:20170702T080000
DTEND;TZID=Europe/Paris:20170702T090000
CREATED:20170601T060000Z
DTSTAMP:20170601T060000Z
LAST-MODIFIED:20170601T060000Z
SEQUENCE:1
SUMMARY:event7
TRANSP:OPAQUE
X-MOZ-GENERATION:1
END:VEVENT
BEGIN:VEVENT
UID:event7
RECURRENCE-ID;TZID=Europe/Paris:20170703T080000
DTSTART;TZID=Europe/Paris:20170601T080000
DTEND;TZID=Europe/Paris:20170601T090000
CREATED:20170601T060000Z
DTSTAMP:20170601T060000Z
LAST-MODIFIED:20170601T060000Z
SEQUENCE:1
SUMMARY:event7
TRANSP:OPAQUE
X-MOZ-GENERATION:1
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,33 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTIMEZONE
TZID:Europe/Paris
BEGIN:STANDARD
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:event8
DTSTART;TZID=Europe/Paris:20170601T080000
DTEND;TZID=Europe/Paris:20170601T090000
CREATED:20170601T060000Z
DTSTAMP:20170601T060000Z
LAST-MODIFIED:20170601T060000Z
RDATE;TZID=Europe/Paris:20170701T080000
SUMMARY:event8
TRANSP:OPAQUE
X-MOZ-GENERATION:1
END:VEVENT
END:VCALENDAR

View File

@ -0,0 +1,34 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:event
SUMMARY:Event
DTSTART;TZID=Europe/Paris:20130901T190000
DTEND;TZID=Europe/Paris:20130901T200000
END:VEVENT
BEGIN:VTODO
UID:todo
DTSTART;TZID=Europe/Paris:20130901T220000
DURATION:PT1H
SUMMARY:Todo
END:VTODO
END:VCALENDAR

View File

@ -0,0 +1,30 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700101T000000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19700101T000000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VJOURNAL
UID:journal1
DTSTAMP;TZID=Europe/Paris:19940817T000000
SUMMARY:happy new year
DESCRIPTION: Happy new year 2000 !
END:VJOURNAL
END:VCALENDAR

View File

@ -0,0 +1,32 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VJOURNAL
UID:journal2
DTSTAMP:19950817T000000
DTSTART;TZID=Europe/Paris:20000101T100000
SUMMARY:happy new year
DESCRIPTION: Happy new year !
RRULE:FREQ=YEARLY
END:VJOURNAL
END:VCALENDAR

View File

@ -0,0 +1,31 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VJOURNAL
UID:journal2
DTSTAMP:19950817T000000
DTSTART;VALUE=DATE:20000101
SUMMARY:happy new year
DESCRIPTION: Happy new year 2001 !
END:VJOURNAL
END:VCALENDAR

View File

@ -0,0 +1,23 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
END:VCALENDAR

View File

@ -0,0 +1,23 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
END:VCALENDAR

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<I:calendar-color xmlns:I="http://apple.com/ns/ical/" />
</D:prop>
</D:propfind>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:propname />
</D:propfind>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:">
<D:set>
<D:prop>
<I:calendar-color xmlns:I="http://apple.com/ns/ical/">#BADA55</I:calendar-color>
</D:prop>
</D:set>
</D:propertyupdate>

View File

@ -0,0 +1,28 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
DTSTART;TZID=Europe/Paris:20130901T220000
DURATION:PT1H
SUMMARY:Todo
UID:todo
END:VTODO
END:VCALENDAR

View File

@ -0,0 +1,28 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
DTSTART;TZID=Europe/Paris:20130901T180000
DUE;TZID=Europe/Paris:20130903T180000
RRULE:FREQ=MONTHLY
UID:todo2
END:VTODO
END:VCALENDAR

View File

@ -0,0 +1,26 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
DTSTART;TZID=Europe/Paris:20130901T180000
UID:todo3
END:VTODO
END:VCALENDAR

View File

@ -0,0 +1,26 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
DUE;TZID=Europe/Paris:20130901T180000
UID:todo4
END:VTODO
END:VCALENDAR

View File

@ -0,0 +1,27 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
CREATED;TZID=Europe/Paris:20130903T180000
COMPLETED;TZID=Europe/Paris:20130920T180000
UID:todo5
END:VTODO
END:VCALENDAR

View File

@ -0,0 +1,26 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
COMPLETED;TZID=Europe/Paris:20130920T180000
UID:todo6
END:VTODO
END:VCALENDAR

View File

@ -0,0 +1,26 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
CREATED;TZID=Europe/Paris:20130803T180000
UID:todo7
END:VTODO
END:VCALENDAR

View File

@ -0,0 +1,25 @@
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Paris
X-LIC-LOCATION:Europe/Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
UID:todo8
END:VTODO
END:VCALENDAR

167
radicale/tests/test_auth.py Normal file
View File

@ -0,0 +1,167 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012-2016 Jean-Marc Martins
# Copyright © 2012-2017 Guillaume Ayoub
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
Radicale tests with simple requests and authentication.
"""
import base64
import os
import shutil
import tempfile
import pytest
from radicale import Application, config
from .test_base import BaseTest
class TestBaseAuthRequests(BaseTest):
"""Tests basic requests with auth.
We should setup auth for each type before creating the Application object.
"""
def setup(self):
self.configuration = config.load()
self.colpath = tempfile.mkdtemp()
self.configuration["storage"]["filesystem_folder"] = self.colpath
# Disable syncing to disk for better performance
self.configuration["storage"]["filesystem_fsync"] = "False"
# Required on Windows, doesn't matter on Unix
self.configuration["storage"]["filesystem_close_lock_file"] = "True"
# Set incorrect authentication delay to a very low value
self.configuration["auth"]["delay"] = "0.002"
def teardown(self):
shutil.rmtree(self.colpath)
def _test_htpasswd(self, htpasswd_encryption, htpasswd_content,
test_matrix=None):
"""Test htpasswd authentication with user "tmp" and password "bepo"."""
htpasswd_file_path = os.path.join(self.colpath, ".htpasswd")
with open(htpasswd_file_path, "w") as f:
f.write(htpasswd_content)
self.configuration["auth"]["type"] = "htpasswd"
self.configuration["auth"]["htpasswd_filename"] = htpasswd_file_path
self.configuration["auth"]["htpasswd_encryption"] = htpasswd_encryption
self.application = Application(self.configuration, self.logger)
if test_matrix is None:
test_matrix = (
("tmp", "bepo", 207), ("tmp", "tmp", 401), ("tmp", "", 401),
("unk", "unk", 401), ("unk", "", 401), ("", "", 401))
for user, password, expected_status in test_matrix:
status, _, answer = self.request(
"PROPFIND", "/",
HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(
("%s:%s" % (user, password)).encode()).decode())
assert status == expected_status
def test_htpasswd_plain(self):
self._test_htpasswd("plain", "tmp:bepo")
def test_htpasswd_plain_password_split(self):
self._test_htpasswd("plain", "tmp:be:po", (
("tmp", "be:po", 207), ("tmp", "bepo", 401)))
def test_htpasswd_sha1(self):
self._test_htpasswd("sha1", "tmp:{SHA}UWRS3uSJJq2itZQEUyIH8rRajCM=")
def test_htpasswd_ssha(self):
self._test_htpasswd("ssha", "tmp:{SSHA}qbD1diw9RJKi0DnW4qO8WX9SE18W")
def test_htpasswd_md5(self):
try:
import passlib # noqa: F401
except ImportError:
pytest.skip("passlib is not installed")
self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/")
def test_htpasswd_crypt(self):
try:
import crypt # noqa: F401
except ImportError:
pytest.skip("crypt is not installed")
self._test_htpasswd("crypt", "tmp:dxUqxoThMs04k")
def test_htpasswd_bcrypt(self):
try:
from passlib.hash import bcrypt
from passlib.exc import MissingBackendError
except ImportError:
pytest.skip("passlib is not installed")
try:
bcrypt.encrypt("test-bcrypt-backend")
except MissingBackendError:
pytest.skip("bcrypt backend for passlib is not installed")
self._test_htpasswd(
"bcrypt",
"tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
def test_htpasswd_multi(self):
self._test_htpasswd("plain", "ign:ign\ntmp:bepo")
@pytest.mark.skipif(os.name == "nt", reason="leading and trailing "
"whitespaces not allowed in file names")
def test_htpasswd_whitespace_preserved(self):
self._test_htpasswd("plain", " tmp : bepo ",
((" tmp ", " bepo ", 207),))
def test_htpasswd_whitespace_not_trimmed(self):
self._test_htpasswd("plain", " tmp : bepo ", (("tmp", "bepo", 401),))
def test_htpasswd_comment(self):
self._test_htpasswd("plain", "#comment\n #comment\n \ntmp:bepo\n\n")
def test_remote_user(self):
self.configuration["auth"]["type"] = "remote_user"
self.application = Application(self.configuration, self.logger)
status, _, answer = self.request(
"PROPFIND", "/",
"""<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<current-user-principal />
</prop>
</propfind>""", REMOTE_USER="test")
assert status == 207
assert ">/test/<" in answer
def test_http_x_remote_user(self):
self.configuration["auth"]["type"] = "http_x_remote_user"
self.application = Application(self.configuration, self.logger)
status, _, answer = self.request(
"PROPFIND", "/",
"""<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<current-user-principal />
</prop>
</propfind>""", HTTP_X_REMOTE_USER="test")
assert status == 207
assert ">/test/<" in answer
def test_custom(self):
"""Custom authentication."""
self.configuration["auth"]["type"] = "tests.custom.auth"
self.application = Application(self.configuration, self.logger)
status, _, answer = self.request(
"PROPFIND", "/tmp", HTTP_AUTHORIZATION="Basic %s" %
base64.b64encode(("tmp:").encode()).decode())
assert status == 207

1530
radicale/tests/test_base.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,139 @@
# This file is part of Radicale Server - Calendar Server
# Copyright (C) 2017 Unrud <unrud@openaliasbox.org>
#
# 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 tests with simple requests and rights.
"""
import base64
import os
import shutil
import tempfile
from radicale import Application, config
from .test_base import BaseTest
class TestBaseAuthRequests(BaseTest):
"""Tests basic requests with rights."""
def setup(self):
self.configuration = config.load()
self.colpath = tempfile.mkdtemp()
self.configuration["storage"]["filesystem_folder"] = self.colpath
# Disable syncing to disk for better performance
self.configuration["storage"]["filesystem_fsync"] = "False"
# Required on Windows, doesn't matter on Unix
self.configuration["storage"]["filesystem_close_lock_file"] = "True"
def teardown(self):
shutil.rmtree(self.colpath)
def _test_rights(self, rights_type, user, path, mode, expected_status):
assert mode in ("r", "w")
assert user in ("", "tmp")
htpasswd_file_path = os.path.join(self.colpath, ".htpasswd")
with open(htpasswd_file_path, "w") as f:
f.write("tmp:bepo\nother:bepo")
self.configuration["rights"]["type"] = rights_type
self.configuration["auth"]["type"] = "htpasswd"
self.configuration["auth"]["htpasswd_filename"] = htpasswd_file_path
self.configuration["auth"]["htpasswd_encryption"] = "plain"
self.application = Application(self.configuration, self.logger)
for u in ("tmp", "other"):
status, _, _ = self.request(
"PROPFIND", "/%s" % u, HTTP_AUTHORIZATION="Basic %s" %
base64.b64encode(("%s:bepo" % u).encode()).decode())
assert status == 207
status, _, _ = self.request(
"PROPFIND" if mode == "r" else "PROPPATCH", path,
HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(
("tmp:bepo").encode()).decode() if user else "")
assert status == expected_status
def test_owner_only(self):
self._test_rights("owner_only", "", "/", "r", 401)
self._test_rights("owner_only", "", "/", "w", 401)
self._test_rights("owner_only", "", "/tmp", "r", 401)
self._test_rights("owner_only", "", "/tmp", "w", 401)
self._test_rights("owner_only", "tmp", "/", "r", 207)
self._test_rights("owner_only", "tmp", "/", "w", 403)
self._test_rights("owner_only", "tmp", "/tmp", "r", 207)
self._test_rights("owner_only", "tmp", "/tmp", "w", 207)
self._test_rights("owner_only", "tmp", "/other", "r", 403)
self._test_rights("owner_only", "tmp", "/other", "w", 403)
def test_owner_write(self):
self._test_rights("owner_write", "", "/", "r", 401)
self._test_rights("owner_write", "", "/", "w", 401)
self._test_rights("owner_write", "", "/tmp", "r", 401)
self._test_rights("owner_write", "", "/tmp", "w", 401)
self._test_rights("owner_write", "tmp", "/", "r", 207)
self._test_rights("owner_write", "tmp", "/", "w", 403)
self._test_rights("owner_write", "tmp", "/tmp", "r", 207)
self._test_rights("owner_write", "tmp", "/tmp", "w", 207)
self._test_rights("owner_write", "tmp", "/other", "r", 207)
self._test_rights("owner_write", "tmp", "/other", "w", 403)
def test_authenticated(self):
self._test_rights("authenticated", "", "/", "r", 401)
self._test_rights("authenticated", "", "/", "w", 401)
self._test_rights("authenticated", "", "/tmp", "r", 401)
self._test_rights("authenticated", "", "/tmp", "w", 401)
self._test_rights("authenticated", "tmp", "/", "r", 207)
self._test_rights("authenticated", "tmp", "/", "w", 207)
self._test_rights("authenticated", "tmp", "/tmp", "r", 207)
self._test_rights("authenticated", "tmp", "/tmp", "w", 207)
self._test_rights("authenticated", "tmp", "/other", "r", 207)
self._test_rights("authenticated", "tmp", "/other", "w", 207)
def test_none(self):
self._test_rights("none", "", "/", "r", 207)
self._test_rights("none", "", "/", "w", 207)
self._test_rights("none", "", "/tmp", "r", 207)
self._test_rights("none", "", "/tmp", "w", 207)
self._test_rights("none", "tmp", "/", "r", 207)
self._test_rights("none", "tmp", "/", "w", 207)
self._test_rights("none", "tmp", "/tmp", "r", 207)
self._test_rights("none", "tmp", "/tmp", "w", 207)
self._test_rights("none", "tmp", "/other", "r", 207)
self._test_rights("none", "tmp", "/other", "w", 207)
def test_from_file(self):
rights_file_path = os.path.join(self.colpath, "rights")
with open(rights_file_path, "w") as f:
f.write("""\
[owner]
user: .+
collection: %(login)s(/.*)?
permission: rw
[custom]
user: .*
collection: custom(/.*)?
permission: r""")
self.configuration["rights"]["file"] = rights_file_path
self._test_rights("from_file", "", "/other", "r", 401)
self._test_rights("from_file", "tmp", "/other", "r", 403)
self._test_rights("from_file", "", "/custom/sub", "r", 404)
self._test_rights("from_file", "tmp", "/custom/sub", "r", 404)
self._test_rights("from_file", "", "/custom/sub", "w", 401)
self._test_rights("from_file", "tmp", "/custom/sub", "w", 403)
def test_custom(self):
"""Custom rights management."""
self._test_rights("tests.custom.rights", "", "/", "r", 401)
self._test_rights("tests.custom.rights", "", "/tmp", "r", 207)

124
radicale/web.py Normal file
View File

@ -0,0 +1,124 @@
# This file is part of Radicale Server - Calendar Server
# Copyright (C) 2017 Unrud <unrud@openaliasbox.org>
#
# 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/>.
import os
import posixpath
import time
from http import client
from importlib import import_module
import pkg_resources
from . import storage
NOT_FOUND = (
client.NOT_FOUND, (("Content-Type", "text/plain"),),
"The requested resource could not be found.")
MIMETYPES = {
".css": "text/css",
".eot": "application/vnd.ms-fontobject",
".gif": "image/gif",
".html": "text/html",
".js": "application/javascript",
".manifest": "text/cache-manifest",
".png": "image/png",
".svg": "image/svg+xml",
".ttf": "application/font-sfnt",
".txt": "text/plain",
".woff": "application/font-woff",
".woff2": "font/woff2",
".xml": "text/xml"}
FALLBACK_MIMETYPE = "application/octet-stream"
INTERNAL_TYPES = ("None", "none", "internal")
def load(configuration, logger):
"""Load the web module chosen in configuration."""
web_type = configuration.get("web", "type")
if web_type in ("None", "none"): # DEPRECATED: use "none"
web_class = NoneWeb
elif web_type == "internal":
web_class = Web
else:
try:
web_class = import_module(web_type).Web
except Exception as e:
raise RuntimeError("Failed to load web module %r: %s" %
(web_type, e)) from e
logger.info("Web type is %r", web_type)
return web_class(configuration, logger)
class BaseWeb:
def __init__(self, configuration, logger):
self.configuration = configuration
self.logger = logger
def get(self, environ, base_prefix, path, user):
"""GET request.
``base_prefix`` is sanitized and never ends with "/".
``path`` is sanitized and always starts with "/.web"
``user`` is empty for anonymous users.
"""
raise NotImplementedError
class NoneWeb(BaseWeb):
def get(self, environ, base_prefix, path, user):
if path != "/.web":
return NOT_FOUND
return client.OK, {"Content-Type": "text/plain"}, "Radicale works!"
class Web(BaseWeb):
def __init__(self, configuration, logger):
super().__init__(configuration, logger)
self.folder = pkg_resources.resource_filename(__name__, "web")
def get(self, environ, base_prefix, path, user):
try:
filesystem_path = storage.path_to_filesystem(
self.folder, path[len("/.web"):])
except ValueError as e:
self.logger.debug("Web content with unsafe path %r requested: %s",
path, e, exc_info=True)
return NOT_FOUND
if os.path.isdir(filesystem_path) and not path.endswith("/"):
location = posixpath.basename(path) + "/"
return (client.FOUND,
{"Location": location, "Content-Type": "text/plain"},
"Redirected to %s" % location)
if os.path.isdir(filesystem_path):
filesystem_path = os.path.join(filesystem_path, "index.html")
if not os.path.isfile(filesystem_path):
return NOT_FOUND
content_type = MIMETYPES.get(
os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE)
with open(filesystem_path, "rb") as f:
answer = f.read()
last_modified = time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
time.gmtime(os.fstat(f.fileno()).st_mtime))
headers = {
"Content-Type": content_type,
"Last-Modified": last_modified}
return client.OK, headers, answer

BIN
radicale/web/css/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

43
radicale/web/css/main.css Normal file
View File

@ -0,0 +1,43 @@
body { background: #e4e9f6; color: #424247; display: flex; flex-direction: column; font-size: 14pt; line-height: 1.4; margin: 0; min-height: 100vh; }
a { color: inherit; }
nav, footer { background: #a40000; color: white; padding: 0 20%; }
nav ul, footer ul { display: flex; flex-wrap: wrap; margin: 0; padding: 0; }
nav ul li, footer ul li { display: block; padding: 0 1em 0 0; }
nav ul li a, footer ul li a { color: inherit; display: block; padding: 1em 0.5em 1em 0; text-decoration: inherit; transition: 0.2s; }
nav ul li a:hover, nav ul li a:focus, footer ul li a:hover, footer ul li a:focus { color: black; outline: none; }
header { background: url(logo.svg), linear-gradient(to bottom right, #050a02, black); background-position: 22% 45%; background-repeat: no-repeat; color: #efdddd; font-size: 1.5em; min-height: 250px; overflow: auto; padding: 3em 22%; text-shadow: 0.2em 0.2em 0.2em rgba(0, 0, 0, 0.5); }
header > * { padding-left: 220px; }
header h1 { font-size: 2.5em; font-weight: lighter; margin: 0.5em 0; }
main { flex: 1; }
section { padding: 0 20% 2em; }
section:not(:last-child) { border-bottom: 1px dashed #ccc; }
section h1 { background: linear-gradient(to bottom right, #050a02, black); color: #e5dddd; font-size: 2.5em; margin: 0 -33.33% 1em; padding: 1em 33.33%; }
section h2, section h3, section h4 { font-weight: lighter; margin: 1.5em 0 1em; }
article { border-top: 1px solid transparent; position: relative; margin: 3em 0; }
article aside { box-sizing: border-box; color: #aaa; font-size: 0.8em; right: -30%; top: 0.5em; position: absolute; }
article:before { border-top: 1px dashed #ccc; content: ""; display: block; left: -33.33%; position: absolute; right: -33.33%; }
pre { border-radius: 3px; background: black; color: #d3d5db; margin: 0 -1em; overflow-x: auto; padding: 1em; }
table { border-collapse: collapse; font-size: 0.8em; margin: auto; }
table td { border: 1px solid #ccc; padding: 0.5em; }
dl dt { margin-bottom: 0.5em; margin-top: 1em; }
@media (max-width: 800px) { body { font-size: 12pt; }
header, section { padding-left: 2em; padding-right: 2em; }
nav, footer { padding-left: 0; padding-right: 0; }
nav ul, footer ul { justify-content: center; }
nav ul li, footer ul li { padding: 0 0.5em; }
nav ul li a, footer ul li a { padding: 1em 0; }
header { background-position: 50% 30px, 0 0; padding-bottom: 0; padding-top: 330px; text-align: center; }
header > * { margin: 0; padding-left: 0; }
section h1 { margin: 0 -0.8em 1.3em; padding: 0.5em 0; text-align: center; }
article aside { top: 0.5em; right: -1.5em; }
article:before { left: -2em; right: -2em; } }

1003
radicale/web/fn.js Normal file

File diff suppressed because it is too large Load Diff

105
radicale/web/index.html Normal file
View File

@ -0,0 +1,105 @@
<!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" />
<script src="fn.js"></script>
<title>Web interface for Radicale</title>
<link href="css/main.css" media="screen" rel="stylesheet" />
<link href="css/icon.png" type="image/png" rel="shortcut icon" />
</head>
<body>
<nav>
<ul>
<li id="logoutview" style="display: none;"><a href="" name="link">Logout [<span name="user" style="word-wrap:break-word;"></span>]</a></li>
</ul>
</nav>
<section id="loginscene" style="display: none;">
<h1>Login</h1>
<form name="form">
<input name="user" type="text" placeholder="Username"><br>
<input name="password" type="password" placeholder="Password"><br>
<span style="color: #A40000;" name="error"></span><br>
<button type="submit">Next</button>
</form>
</section>
<section id="loadingscene" style="display: none;">
<h1>Loading</h1>
Please wait...
</section>
<section id="collectionsscene" style="display: none;">
<h1>Collections</h1>
<a href="" name="new">Create new addressbook or calendar</a>
<article name="collectiontemplate">
<h2><span name="color"></span><span name="title" style="word-wrap:break-word;">Title</span> <small>[<span name="ADDRESSBOOK">addressbook</span><span name="CALENDAR_JOURNAL_TASKS">calendar, journal and tasks</span><span name="CALENDAR_JOURNAL">calendar and journal</span><span name="CALENDAR_TASKS">calendar and tasks</span><span name="JOURNAL_TASKS">journal and tasks</span><span name="CALENDAR">calendar</span><span name="JOURNAL">journal</span><span name="TASKS">tasks</span>]</small></h2>
<span name="description" style="word-wrap:break-word;">Description</span>
<ul>
<li>URL: <a name="url" style="word-wrap:break-word;">url</a></li>
<li><a href="" name="edit">Edit</a></li>
<li><a href="" name="delete">Delete</a></li>
</ul>
</article>
</section>
<section id="editcollectionscene" style="display: none;">
<h1>Edit collection</h1>
<h2>Edit <span name="title" style="word-wrap:break-word;">title</span>:</h2>
<form>
Title:<br>
<input name="displayname" type="text"><br>
Description:<br>
<input name="description" type="text"><br>
Type:<br>
<select 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 name="color" type="color"><br>
<span style="color: #A40000;" name="error"></span><br>
<button type="submit" name="submit">Save</button>
<button type="button" name="cancel">Cancel</button>
</form>
</section>
<section id="createcollectionscene" style="display: none;">
<h1>Create new collection</h1>
<form>
Title:<br>
<input name="displayname" type="text"><br>
Description:<br>
<input name="description" type="text"><br>
Type:<br>
<select 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 name="color" type="color"><br>
<span style="color: #A40000;" name="error"></span><br>
<button type="submit" name="submit">Create</button>
<button type="button" name="cancel">Cancel</button>
</form>
</section>
<section id="deletecollectionscene" style="display: none;">
<h1>Delete collection</h1>
<h2>Delete <span name="title" style="word-wrap:break-word;">title</span>?</h2>
<span style="color: #A40000;" name="error"></span><br>
<form>
<button type="button" name="delete">Yes</button>
<button type="button" name="cancel">No</button>
</form>
</section>
</body>
</html>

1324
radicale/xmlutils.py Normal file

File diff suppressed because it is too large Load Diff