Adding password hashing
This commit is contained in:
commit
e68646c235
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"python.linting.pylintEnabled": false,
|
||||
"python.formatting.provider": "autopep8",
|
||||
"python.pythonPath": "C:\\Program Files\\Python36\\python.exe",
|
||||
"editor.tabSize": 4
|
||||
}
|
@ -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"]
|
||||
|
26
plugins/radicale_stamm_rights/__init__.py
Normal file
26
plugins/radicale_stamm_rights/__init__.py
Normal 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)
|
317
plugins/radicale_stamm_storage/__init__.py
Normal file
317
plugins/radicale_stamm_storage/__init__.py
Normal 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
|
@ -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
949
radicale/__init__.py
Normal 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
291
radicale/__main__.py
Normal 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
274
radicale/auth.py
Normal 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
259
radicale/config.py
Normal 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
75
radicale/log.py
Normal 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
176
radicale/rights.py
Normal 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
1683
radicale/storage.py
Normal file
File diff suppressed because it is too large
Load Diff
66
radicale/tests/__init__.py
Normal file
66
radicale/tests/__init__.py
Normal 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
|
0
radicale/tests/custom/__init__.py
Normal file
0
radicale/tests/custom/__init__.py
Normal file
31
radicale/tests/custom/auth.py
Normal file
31
radicale/tests/custom/auth.py
Normal 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"
|
27
radicale/tests/custom/rights.py
Normal file
27
radicale/tests/custom/rights.py
Normal 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")
|
31
radicale/tests/custom/storage.py
Normal file
31
radicale/tests/custom/storage.py
Normal 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
36
radicale/tests/helpers.py
Normal 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)
|
4
radicale/tests/static/allprop.xml
Normal file
4
radicale/tests/static/allprop.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop />
|
||||
</D:propfind>
|
8
radicale/tests/static/broken-vcard.vcf
Normal file
8
radicale/tests/static/broken-vcard.vcf
Normal 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
|
15
radicale/tests/static/broken-vevent.ics
Normal file
15
radicale/tests/static/broken-vevent.ics
Normal 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:
|
7
radicale/tests/static/contact1.vcf
Normal file
7
radicale/tests/static/contact1.vcf
Normal file
@ -0,0 +1,7 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
UID:contact1
|
||||
N:Contact;;;;
|
||||
FN:Contact
|
||||
NICKNAME:test
|
||||
END:VCARD
|
12
radicale/tests/static/contact_multiple.vcf
Normal file
12
radicale/tests/static/contact_multiple.vcf
Normal 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
|
34
radicale/tests/static/event1-prime.ics
Normal file
34
radicale/tests/static/event1-prime.ics
Normal 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
|
34
radicale/tests/static/event1.ics
Normal file
34
radicale/tests/static/event1.ics
Normal 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
|
42
radicale/tests/static/event2.ics
Normal file
42
radicale/tests/static/event2.ics
Normal 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
|
31
radicale/tests/static/event3.ics
Normal file
31
radicale/tests/static/event3.ics
Normal 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
|
30
radicale/tests/static/event4.ics
Normal file
30
radicale/tests/static/event4.ics
Normal 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
|
30
radicale/tests/static/event5.ics
Normal file
30
radicale/tests/static/event5.ics
Normal 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
|
46
radicale/tests/static/event6.ics
Normal file
46
radicale/tests/static/event6.ics
Normal 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
|
59
radicale/tests/static/event7.ics
Normal file
59
radicale/tests/static/event7.ics
Normal 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
|
33
radicale/tests/static/event8.ics
Normal file
33
radicale/tests/static/event8.ics
Normal 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
|
34
radicale/tests/static/event_multiple.ics
Normal file
34
radicale/tests/static/event_multiple.ics
Normal 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
|
30
radicale/tests/static/journal1.ics
Normal file
30
radicale/tests/static/journal1.ics
Normal 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
|
32
radicale/tests/static/journal2.ics
Normal file
32
radicale/tests/static/journal2.ics
Normal 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
|
31
radicale/tests/static/journal3.ics
Normal file
31
radicale/tests/static/journal3.ics
Normal 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
|
23
radicale/tests/static/journal4.ics
Normal file
23
radicale/tests/static/journal4.ics
Normal 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
|
23
radicale/tests/static/journal5.ics
Normal file
23
radicale/tests/static/journal5.ics
Normal 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
|
6
radicale/tests/static/propfind1.xml
Normal file
6
radicale/tests/static/propfind1.xml
Normal 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>
|
4
radicale/tests/static/propname.xml
Normal file
4
radicale/tests/static/propname.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:propname />
|
||||
</D:propfind>
|
8
radicale/tests/static/proppatch1.xml
Normal file
8
radicale/tests/static/proppatch1.xml
Normal 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>
|
28
radicale/tests/static/todo1.ics
Normal file
28
radicale/tests/static/todo1.ics
Normal 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
|
28
radicale/tests/static/todo2.ics
Normal file
28
radicale/tests/static/todo2.ics
Normal 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
|
26
radicale/tests/static/todo3.ics
Normal file
26
radicale/tests/static/todo3.ics
Normal 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
|
26
radicale/tests/static/todo4.ics
Normal file
26
radicale/tests/static/todo4.ics
Normal 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
|
27
radicale/tests/static/todo5.ics
Normal file
27
radicale/tests/static/todo5.ics
Normal 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
|
26
radicale/tests/static/todo6.ics
Normal file
26
radicale/tests/static/todo6.ics
Normal 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
|
26
radicale/tests/static/todo7.ics
Normal file
26
radicale/tests/static/todo7.ics
Normal 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
|
25
radicale/tests/static/todo8.ics
Normal file
25
radicale/tests/static/todo8.ics
Normal 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
167
radicale/tests/test_auth.py
Normal 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
1530
radicale/tests/test_base.py
Normal file
File diff suppressed because it is too large
Load Diff
139
radicale/tests/test_rights.py
Normal file
139
radicale/tests/test_rights.py
Normal 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
124
radicale/web.py
Normal 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
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
43
radicale/web/css/main.css
Normal 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
1003
radicale/web/fn.js
Normal file
File diff suppressed because it is too large
Load Diff
105
radicale/web/index.html
Normal file
105
radicale/web/index.html
Normal 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
1324
radicale/xmlutils.py
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user