Upgrading to radicale 3x
This commit is contained in:
parent
4d29cfd9ba
commit
b7832edd83
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Collections/
|
||||||
|
config.ini
|
20
config
20
config
@ -1,20 +0,0 @@
|
|||||||
[server]
|
|
||||||
hosts = 0.0.0.0:5232
|
|
||||||
max_connections = 50
|
|
||||||
dns_lookup = True
|
|
||||||
|
|
||||||
|
|
||||||
[auth]
|
|
||||||
type = radicale_stamm_auth
|
|
||||||
server = https://auth.stamm.me
|
|
||||||
client_id = 02fcf12f66a73e0d816677a6d1c669b383ac4e656a3f02ae262207cb236a8bf9f973c6ff194f27e6f40ac53a3001949c8f2de66d586bdd70991ada25f68591e8
|
|
||||||
client_secret = fe9696c800bf59346e895ae8e95edf8b18b14f5c305a53aca1d66b2b6f1a91c4a324fb7d910ede0f691087d99649e28fc606bf9312f2a7c9f65f06f8c2bbb49d5663c2306c5f3446f335f03f3371dea61fa96815376147e30d7dc0b06afe25e2a02f7aec42d07bc6e89f2ef6bcb664c4d6bd867a7f4a67e9fac7e0568440fb409535927703f5c2272624918f7be9f1e34a7178a9d0ed8c0e39ed2b587436087fc74d1b9917c30c803940240837e429166aba6935ffe4c770f7bf45201296ecc9
|
|
||||||
|
|
||||||
[rights]
|
|
||||||
type = owner_only
|
|
||||||
|
|
||||||
[storage]
|
|
||||||
filesystem_folder = .\\collection
|
|
||||||
|
|
||||||
[logging]
|
|
||||||
debug = True
|
|
@ -3,24 +3,37 @@ import urllib.request
|
|||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
import requests
|
import requests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from radicale.log import logger
|
||||||
|
|
||||||
class Auth(BaseAuth):
|
class Auth(BaseAuth):
|
||||||
def get_server(self):
|
def get_server(self):
|
||||||
return self.configuration.get("auth", "server")
|
return self.configuration.get("auth", "server")
|
||||||
|
|
||||||
def is_authenticated(self, user, password):
|
def login(self, login, password):
|
||||||
if user is None:
|
logger.debug("Things %s %s", login, password)
|
||||||
return False
|
|
||||||
|
|
||||||
|
# Get uid from username
|
||||||
|
if login is None or login is "":
|
||||||
|
return ""
|
||||||
|
res = requests.post(self.get_server() + "/api/login?type=username&username=" + login)
|
||||||
|
data = res.json()
|
||||||
|
if "error" in data:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
user = data["uid"]
|
||||||
|
|
||||||
|
# Get salt
|
||||||
res1 = requests.post(self.get_server() + "/api/login?type=username&uid=" + user)
|
res1 = requests.post(self.get_server() + "/api/login?type=username&uid=" + user)
|
||||||
data1 = res1.json()
|
data1 = res1.json()
|
||||||
|
|
||||||
if "error" in data1:
|
if "error" in data1:
|
||||||
return False
|
return ""
|
||||||
|
|
||||||
salt = data1["salt"].encode()
|
salt = data1["salt"].encode()
|
||||||
|
|
||||||
|
# Check password
|
||||||
id = self.configuration.get("auth", "client_id")
|
id = self.configuration.get("auth", "client_id")
|
||||||
secret = self.configuration.get("auth", "client_secret")
|
secret = self.configuration.get("auth", "client_secret")
|
||||||
password = hashlib.sha512(salt + password.encode()).hexdigest()
|
password = hashlib.sha512(salt + password.encode()).hexdigest()
|
||||||
@ -29,16 +42,5 @@ class Auth(BaseAuth):
|
|||||||
data2 = res2.json()
|
data2 = res2.json()
|
||||||
|
|
||||||
if "success" in data2 and data2["success"] is True:
|
if "success" in data2 and data2["success"] is True:
|
||||||
return True
|
return user
|
||||||
return False
|
return ""
|
||||||
|
|
||||||
def map_login_to_user(self, login):
|
|
||||||
# Get uid from username
|
|
||||||
if login is None or login is "":
|
|
||||||
return None
|
|
||||||
req_data = dict()
|
|
||||||
res = requests.post(self.get_server() + "/api/login?type=username&username=" + login)
|
|
||||||
data = res.json()
|
|
||||||
if "error" in data:
|
|
||||||
return None
|
|
||||||
return data["uid"]
|
|
@ -1,949 +0,0 @@
|
|||||||
# 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))
|
|
@ -1,291 +0,0 @@
|
|||||||
# 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
274
radicale/auth.py
@ -1,274 +0,0 @@
|
|||||||
# 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", ""), ""
|
|
@ -1,259 +0,0 @@
|
|||||||
# 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
|
|
@ -1,75 +0,0 @@
|
|||||||
# 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
|
|
@ -1,176 +0,0 @@
|
|||||||
# 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
1683
radicale/storage.py
File diff suppressed because it is too large
Load Diff
@ -1,66 +0,0 @@
|
|||||||
# 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
|
|
@ -1,31 +0,0 @@
|
|||||||
# 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"
|
|
@ -1,27 +0,0 @@
|
|||||||
# 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")
|
|
@ -1,31 +0,0 @@
|
|||||||
# 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)
|
|
@ -1,36 +0,0 @@
|
|||||||
# 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)
|
|
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<D:propfind xmlns:D="DAV:">
|
|
||||||
<D:allprop />
|
|
||||||
</D:propfind>
|
|
@ -1,8 +0,0 @@
|
|||||||
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
|
|
@ -1,15 +0,0 @@
|
|||||||
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:
|
|
@ -1,7 +0,0 @@
|
|||||||
BEGIN:VCARD
|
|
||||||
VERSION:3.0
|
|
||||||
UID:contact1
|
|
||||||
N:Contact;;;;
|
|
||||||
FN:Contact
|
|
||||||
NICKNAME:test
|
|
||||||
END:VCARD
|
|
@ -1,12 +0,0 @@
|
|||||||
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
|
|
@ -1,34 +0,0 @@
|
|||||||
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
|
|
@ -1,34 +0,0 @@
|
|||||||
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
|
|
@ -1,42 +0,0 @@
|
|||||||
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
|
|
@ -1,31 +0,0 @@
|
|||||||
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
|
|
@ -1,30 +0,0 @@
|
|||||||
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
|
|
@ -1,30 +0,0 @@
|
|||||||
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
|
|
@ -1,46 +0,0 @@
|
|||||||
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
|
|
@ -1,59 +0,0 @@
|
|||||||
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
|
|
@ -1,33 +0,0 @@
|
|||||||
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
|
|
@ -1,34 +0,0 @@
|
|||||||
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
|
|
@ -1,30 +0,0 @@
|
|||||||
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
|
|
@ -1,32 +0,0 @@
|
|||||||
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
|
|
@ -1,31 +0,0 @@
|
|||||||
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
|
|
@ -1,23 +0,0 @@
|
|||||||
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
|
|
@ -1,23 +0,0 @@
|
|||||||
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
|
|
@ -1,6 +0,0 @@
|
|||||||
<?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>
|
|
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<D:propfind xmlns:D="DAV:">
|
|
||||||
<D:propname />
|
|
||||||
</D:propfind>
|
|
@ -1,8 +0,0 @@
|
|||||||
<?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>
|
|
@ -1,28 +0,0 @@
|
|||||||
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
|
|
@ -1,28 +0,0 @@
|
|||||||
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
|
|
@ -1,26 +0,0 @@
|
|||||||
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
|
|
@ -1,26 +0,0 @@
|
|||||||
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
|
|
@ -1,27 +0,0 @@
|
|||||||
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
|
|
@ -1,26 +0,0 @@
|
|||||||
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
|
|
@ -1,26 +0,0 @@
|
|||||||
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
|
|
@ -1,25 +0,0 @@
|
|||||||
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
|
|
@ -1,167 +0,0 @@
|
|||||||
# 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
|
|
File diff suppressed because it is too large
Load Diff
@ -1,139 +0,0 @@
|
|||||||
# 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
124
radicale/web.py
@ -1,124 +0,0 @@
|
|||||||
# 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
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.0 KiB |
@ -1,43 +0,0 @@
|
|||||||
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
1003
radicale/web/fn.js
File diff suppressed because it is too large
Load Diff
@ -1,105 +0,0 @@
|
|||||||
<!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
1324
radicale/xmlutils.py
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user