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
|
return True
|
||||||
|
|
||||||
def map_login_to_user(self, login):
|
def map_login_to_user(self, login):
|
||||||
# Get uuid from username
|
# Get uid from username
|
||||||
if login is None or login is "":
|
if login is None or login is "":
|
||||||
return None
|
return None
|
||||||
main_uri = self.generate_base_uri(
|
main_uri = self.generate_base_uri(
|
||||||
"/client/uuid") + "&username=" + login
|
"/client/uid") + "&username=" + login
|
||||||
req = urllib.request.urlopen(main_uri, data=None)
|
req = urllib.request.urlopen(main_uri, data=None)
|
||||||
jsons = req.read()
|
jsons = req.read()
|
||||||
data = json.loads(jsons)
|
data = json.loads(jsons)
|
||||||
print(data)
|
print(data)
|
||||||
if "error" in data:
|
if "error" in data:
|
||||||
return None
|
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
|
from distutils.core import setup
|
||||||
|
|
||||||
setup(name="radicale_stamm_auth", packages=["radicale_stamm_auth"])
|
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