Remove global state about configuration and logs
Many things have been changed to make this possible, probably leading to many hidden bugs waiting to be found. Related to #122.
This commit is contained in:
parent
8ac19ae0fc
commit
2f97d7d1e1
@ -23,14 +23,12 @@ Launch a Radicale FastCGI server according to configuration.
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
import os
|
||||||
from flup.server.fcgi import WSGIServer
|
|
||||||
except ImportError:
|
|
||||||
from flipflop import WSGIServer
|
|
||||||
import radicale
|
import radicale
|
||||||
|
from flipflop import WSGIServer
|
||||||
|
|
||||||
|
|
||||||
radicale.log.start()
|
configuration = radicale.config.load([os.environ.get("RADICALE_CONFIG")])
|
||||||
radicale.log.LOGGER.info("Starting Radicale FastCGI server")
|
logger = radicale.log.start()
|
||||||
WSGIServer(radicale.Application()).run()
|
WSGIServer(radicale.Application(configuration, logger)).run()
|
||||||
radicale.log.LOGGER.info("Stopping Radicale FastCGI server")
|
|
||||||
|
@ -21,8 +21,10 @@ Radicale WSGI file (mod_wsgi and uWSGI compliant).
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import radicale
|
import radicale
|
||||||
|
|
||||||
|
|
||||||
radicale.log.start()
|
configuration = radicale.config.load([os.environ.get("RADICALE_CONFIG")])
|
||||||
application = radicale.Application()
|
logger = radicale.log.start()
|
||||||
|
application = radicale.Application(configuration, logger)
|
||||||
|
@ -36,7 +36,9 @@ import re
|
|||||||
from http import client
|
from http import client
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from . import auth, config, log, rights, storage, xmlutils
|
import vobject
|
||||||
|
|
||||||
|
from . import auth, rights, storage, xmlutils
|
||||||
|
|
||||||
|
|
||||||
VERSION = "2.0.0-pre"
|
VERSION = "2.0.0-pre"
|
||||||
@ -71,30 +73,20 @@ class HTTPServer(wsgiref.simple_server.WSGIServer, object):
|
|||||||
|
|
||||||
class HTTPSServer(HTTPServer):
|
class HTTPSServer(HTTPServer):
|
||||||
"""HTTPS server."""
|
"""HTTPS server."""
|
||||||
|
|
||||||
|
# These class attributes must be set before creating instance
|
||||||
|
certificate = None
|
||||||
|
key = None
|
||||||
|
protocol = None
|
||||||
|
cyphers = None
|
||||||
|
|
||||||
def __init__(self, address, handler):
|
def __init__(self, address, handler):
|
||||||
"""Create server by wrapping HTTP socket in an SSL socket."""
|
"""Create server by wrapping HTTP socket in an SSL socket."""
|
||||||
super().__init__(address, handler, False)
|
super().__init__(address, handler, bind_and_activate=False)
|
||||||
|
|
||||||
# Test if the SSL files can be read
|
self.socket = ssl.wrap_socket(
|
||||||
for name in ("certificate", "key"):
|
self.socket, self.key, self.certificate, server_side=True,
|
||||||
filename = config.get("server", name)
|
ssl_version=self.protocol, cyphers=self.cyphers)
|
||||||
try:
|
|
||||||
open(filename, "r").close()
|
|
||||||
except IOError as exception:
|
|
||||||
log.LOGGER.warning(
|
|
||||||
"Error while reading SSL %s %r: %s" % (
|
|
||||||
name, filename, exception))
|
|
||||||
|
|
||||||
ssl_kwargs = dict(
|
|
||||||
server_side=True,
|
|
||||||
certfile=config.get("server", "certificate"),
|
|
||||||
keyfile=config.get("server", "key"),
|
|
||||||
ssl_version=getattr(
|
|
||||||
ssl, config.get("server", "protocol"), ssl.PROTOCOL_SSLv23))
|
|
||||||
|
|
||||||
ssl_kwargs["ciphers"] = config.get("server", "ciphers") or None
|
|
||||||
|
|
||||||
self.socket = ssl.wrap_socket(self.socket, **ssl_kwargs)
|
|
||||||
|
|
||||||
self.server_bind()
|
self.server_bind()
|
||||||
self.server_activate()
|
self.server_activate()
|
||||||
@ -105,25 +97,19 @@ class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
|
|||||||
def log_message(self, *args, **kwargs):
|
def log_message(self, *args, **kwargs):
|
||||||
"""Disable inner logging management."""
|
"""Disable inner logging management."""
|
||||||
|
|
||||||
def address_string(self):
|
|
||||||
"""Client address, formatted for logging."""
|
|
||||||
if config.getboolean("server", "dns_lookup"):
|
|
||||||
return (
|
|
||||||
wsgiref.simple_server.WSGIRequestHandler.address_string(self))
|
|
||||||
else:
|
|
||||||
return self.client_address[0]
|
|
||||||
|
|
||||||
|
class Application:
|
||||||
class Application(object):
|
|
||||||
"""WSGI application managing collections."""
|
"""WSGI application managing collections."""
|
||||||
def __init__(self):
|
def __init__(self, configuration, logger):
|
||||||
"""Initialize application."""
|
"""Initialize application."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
auth._load()
|
self.configuration = configuration
|
||||||
storage._load()
|
self.logger = logger
|
||||||
rights._load()
|
self.is_authenticated = auth.load(configuration, logger)
|
||||||
self.encoding = config.get("encoding", "request")
|
self.Collection = storage.load(configuration, logger)
|
||||||
if config.getboolean("logging", "full_environment"):
|
self.authorized = rights.load(configuration, logger)
|
||||||
|
self.encoding = configuration.get("encoding", "request")
|
||||||
|
if configuration.getboolean("logging", "full_environment"):
|
||||||
self.headers_log = lambda environ: environ
|
self.headers_log = lambda environ: environ
|
||||||
|
|
||||||
# This method is overriden in __init__ if full_environment is set
|
# This method is overriden in __init__ if full_environment is set
|
||||||
@ -170,27 +156,27 @@ class Application(object):
|
|||||||
write_allowed_items = []
|
write_allowed_items = []
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
if isinstance(item, storage.Collection):
|
if isinstance(item, self.Collection):
|
||||||
if rights.authorized(user, item, "r"):
|
if self.authorized(user, item, "r"):
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"%s has read access to collection %s" %
|
"%s has read access to collection %s" %
|
||||||
(user or "Anonymous", item.path or "/"))
|
(user or "Anonymous", item.path or "/"))
|
||||||
read_last_collection_allowed = True
|
read_last_collection_allowed = True
|
||||||
read_allowed_items.append(item)
|
read_allowed_items.append(item)
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"%s has NO read access to collection %s" %
|
"%s has NO read access to collection %s" %
|
||||||
(user or "Anonymous", item.path or "/"))
|
(user or "Anonymous", item.path or "/"))
|
||||||
read_last_collection_allowed = False
|
read_last_collection_allowed = False
|
||||||
|
|
||||||
if rights.authorized(user, item, "w"):
|
if self.authorized(user, item, "w"):
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"%s has write access to collection %s" %
|
"%s has write access to collection %s" %
|
||||||
(user or "Anonymous", item.path or "/"))
|
(user or "Anonymous", item.path or "/"))
|
||||||
write_last_collection_allowed = True
|
write_last_collection_allowed = True
|
||||||
write_allowed_items.append(item)
|
write_allowed_items.append(item)
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"%s has NO write access to collection %s" %
|
"%s has NO write access to collection %s" %
|
||||||
(user or "Anonymous", item.path or "/"))
|
(user or "Anonymous", item.path or "/"))
|
||||||
write_last_collection_allowed = False
|
write_last_collection_allowed = False
|
||||||
@ -199,22 +185,22 @@ class Application(object):
|
|||||||
# collection we've met in the loop. Only add this item
|
# collection we've met in the loop. Only add this item
|
||||||
# if this last collection was allowed.
|
# if this last collection was allowed.
|
||||||
if read_last_collection_allowed:
|
if read_last_collection_allowed:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"%s has read access to item %s" %
|
"%s has read access to item %s" %
|
||||||
(user or "Anonymous", item.href))
|
(user or "Anonymous", item.href))
|
||||||
read_allowed_items.append(item)
|
read_allowed_items.append(item)
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"%s has NO read access to item %s" %
|
"%s has NO read access to item %s" %
|
||||||
(user or "Anonymous", item.href))
|
(user or "Anonymous", item.href))
|
||||||
|
|
||||||
if write_last_collection_allowed:
|
if write_last_collection_allowed:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"%s has write access to item %s" %
|
"%s has write access to item %s" %
|
||||||
(user or "Anonymous", item.href))
|
(user or "Anonymous", item.href))
|
||||||
write_allowed_items.append(item)
|
write_allowed_items.append(item)
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"%s has NO write access to item %s" %
|
"%s has NO write access to item %s" %
|
||||||
(user or "Anonymous", item.href))
|
(user or "Anonymous", item.href))
|
||||||
|
|
||||||
@ -222,21 +208,21 @@ class Application(object):
|
|||||||
|
|
||||||
def __call__(self, environ, start_response):
|
def __call__(self, environ, start_response):
|
||||||
"""Manage a request."""
|
"""Manage a request."""
|
||||||
log.LOGGER.info("%s request at %s received" % (
|
self.logger.info("%s request at %s received" % (
|
||||||
environ["REQUEST_METHOD"], environ["PATH_INFO"]))
|
environ["REQUEST_METHOD"], environ["PATH_INFO"]))
|
||||||
headers = pprint.pformat(self.headers_log(environ))
|
headers = pprint.pformat(self.headers_log(environ))
|
||||||
log.LOGGER.debug("Request headers:\n%s" % headers)
|
self.logger.debug("Request headers:\n%s" % headers)
|
||||||
|
|
||||||
# Strip base_prefix from request URI
|
# Strip base_prefix from request URI
|
||||||
base_prefix = config.get("server", "base_prefix")
|
base_prefix = self.configuration.get("server", "base_prefix")
|
||||||
if environ["PATH_INFO"].startswith(base_prefix):
|
if environ["PATH_INFO"].startswith(base_prefix):
|
||||||
environ["PATH_INFO"] = environ["PATH_INFO"][len(base_prefix):]
|
environ["PATH_INFO"] = environ["PATH_INFO"][len(base_prefix):]
|
||||||
elif config.get("server", "can_skip_base_prefix"):
|
elif self.configuration.get("server", "can_skip_base_prefix"):
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"Prefix already stripped from path: %s", environ["PATH_INFO"])
|
"Prefix already stripped from path: %s", environ["PATH_INFO"])
|
||||||
else:
|
else:
|
||||||
# Request path not starting with base_prefix, not allowed
|
# Request path not starting with base_prefix, not allowed
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"Path not starting with prefix: %s", environ["PATH_INFO"])
|
"Path not starting with prefix: %s", environ["PATH_INFO"])
|
||||||
status, headers, _ = NOT_ALLOWED
|
status, headers, _ = NOT_ALLOWED
|
||||||
start_response(status, list(headers.items()))
|
start_response(status, list(headers.items()))
|
||||||
@ -245,7 +231,7 @@ class Application(object):
|
|||||||
# Sanitize request URI
|
# Sanitize request URI
|
||||||
environ["PATH_INFO"] = storage.sanitize_path(
|
environ["PATH_INFO"] = storage.sanitize_path(
|
||||||
unquote(environ["PATH_INFO"]))
|
unquote(environ["PATH_INFO"]))
|
||||||
log.LOGGER.debug("Sanitized path: %s", environ["PATH_INFO"])
|
self.logger.debug("Sanitized path: %s", environ["PATH_INFO"])
|
||||||
|
|
||||||
path = environ["PATH_INFO"]
|
path = environ["PATH_INFO"]
|
||||||
|
|
||||||
@ -265,30 +251,32 @@ class Application(object):
|
|||||||
|
|
||||||
well_known = WELL_KNOWN_RE.match(path)
|
well_known = WELL_KNOWN_RE.match(path)
|
||||||
if well_known:
|
if well_known:
|
||||||
redirect = config.get("well-known", well_known.group(1))
|
redirect = self.configuration.get(
|
||||||
|
"well-known", well_known.group(1))
|
||||||
try:
|
try:
|
||||||
redirect = redirect % ({"user": user} if user else {})
|
redirect = redirect % ({"user": user} if user else {})
|
||||||
except KeyError:
|
except KeyError:
|
||||||
status = client.UNAUTHORIZED
|
status = client.UNAUTHORIZED
|
||||||
|
realm = self.configuration.get("server", "realm")
|
||||||
headers = {
|
headers = {
|
||||||
"WWW-Authenticate":
|
"WWW-Authenticate":
|
||||||
"Basic realm=\"%s\"" % config.get("server", "realm")}
|
"Basic realm=\"%s\"" % realm}
|
||||||
log.LOGGER.info(
|
self.logger.info(
|
||||||
"Refused /.well-known/ redirection to anonymous user")
|
"Refused /.well-known/ redirection to anonymous user")
|
||||||
else:
|
else:
|
||||||
status = client.SEE_OTHER
|
status = client.SEE_OTHER
|
||||||
log.LOGGER.info("/.well-known/ redirection to: %s" % redirect)
|
self.logger.info("/.well-known/ redirection to: %s" % redirect)
|
||||||
headers = {"Location": redirect}
|
headers = {"Location": redirect}
|
||||||
status = "%i %s" % (
|
status = "%i %s" % (
|
||||||
status, client.responses.get(status, "Unknown"))
|
status, client.responses.get(status, "Unknown"))
|
||||||
start_response(status, list(headers.items()))
|
start_response(status, list(headers.items()))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
is_authenticated = auth.is_authenticated(user, password)
|
is_authenticated = self.is_authenticated(user, password)
|
||||||
is_valid_user = is_authenticated or not user
|
is_valid_user = is_authenticated or not user
|
||||||
|
|
||||||
if is_valid_user:
|
if is_valid_user:
|
||||||
items = storage.Collection.discover(
|
items = self.Collection.discover(
|
||||||
path, environ.get("HTTP_DEPTH", "0"))
|
path, environ.get("HTTP_DEPTH", "0"))
|
||||||
read_allowed_items, write_allowed_items = (
|
read_allowed_items, write_allowed_items = (
|
||||||
self.collect_allowed_items(items, user))
|
self.collect_allowed_items(items, user))
|
||||||
@ -300,7 +288,7 @@ class Application(object):
|
|||||||
if content_length:
|
if content_length:
|
||||||
content = self.decode(
|
content = self.decode(
|
||||||
environ["wsgi.input"].read(content_length), environ)
|
environ["wsgi.input"].read(content_length), environ)
|
||||||
log.LOGGER.debug("Request content:\n%s" % content)
|
self.logger.debug("Request content:\n%s" % content)
|
||||||
else:
|
else:
|
||||||
content = None
|
content = None
|
||||||
|
|
||||||
@ -314,30 +302,29 @@ class Application(object):
|
|||||||
else:
|
else:
|
||||||
status, headers, answer = NOT_ALLOWED
|
status, headers, answer = NOT_ALLOWED
|
||||||
|
|
||||||
if ((status, headers, answer) == NOT_ALLOWED and
|
if (status, headers, answer) == NOT_ALLOWED and not is_authenticated:
|
||||||
not auth.is_authenticated(user, password) and
|
|
||||||
config.get("auth", "type") != "None"):
|
|
||||||
# Unknown or unauthorized user
|
# Unknown or unauthorized user
|
||||||
log.LOGGER.info("%s refused" % (user or "Anonymous user"))
|
self.logger.info("%s refused" % (user or "Anonymous user"))
|
||||||
status = client.UNAUTHORIZED
|
status = client.UNAUTHORIZED
|
||||||
|
realm = self.configuration.get("server", "realm")
|
||||||
headers = {
|
headers = {
|
||||||
"WWW-Authenticate":
|
"WWW-Authenticate":
|
||||||
"Basic realm=\"%s\"" % config.get("server", "realm")}
|
"Basic realm=\"%s\"" % realm}
|
||||||
answer = None
|
answer = None
|
||||||
|
|
||||||
# Set content length
|
# Set content length
|
||||||
if answer:
|
if answer:
|
||||||
log.LOGGER.debug(
|
self.logger.debug("Response content:\n%s" % answer, environ)
|
||||||
"Response content:\n%s" % self.decode(answer, environ))
|
answer = answer.encode(self.encoding)
|
||||||
headers["Content-Length"] = str(len(answer))
|
headers["Content-Length"] = str(len(answer))
|
||||||
|
|
||||||
if config.has_section("headers"):
|
if self.configuration.has_section("headers"):
|
||||||
for key in config.options("headers"):
|
for key in self.configuration.options("headers"):
|
||||||
headers[key] = config.get("headers", key)
|
headers[key] = self.configuration.get("headers", key)
|
||||||
|
|
||||||
# Start response
|
# Start response
|
||||||
status = "%i %s" % (status, client.responses.get(status, "Unknown"))
|
status = "%i %s" % (status, client.responses.get(status, "Unknown"))
|
||||||
log.LOGGER.debug("Answer status: %s" % status)
|
self.logger.debug("Answer status: %s" % status)
|
||||||
start_response(status, list(headers.items()))
|
start_response(status, list(headers.items()))
|
||||||
|
|
||||||
# Return response content
|
# Return response content
|
||||||
@ -378,7 +365,7 @@ class Application(object):
|
|||||||
# Display a "Radicale works!" message if the root URL is requested
|
# Display a "Radicale works!" message if the root URL is requested
|
||||||
if environ["PATH_INFO"] == "/":
|
if environ["PATH_INFO"] == "/":
|
||||||
headers = {"Content-type": "text/html"}
|
headers = {"Content-type": "text/html"}
|
||||||
answer = b"<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
|
answer = "<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
|
||||||
return client.OK, headers, answer
|
return client.OK, headers, answer
|
||||||
|
|
||||||
if not read_collections:
|
if not read_collections:
|
||||||
@ -400,7 +387,7 @@ class Application(object):
|
|||||||
# Get whole collection
|
# Get whole collection
|
||||||
answer_text = collection.serialize()
|
answer_text = collection.serialize()
|
||||||
if not answer_text:
|
if not answer_text:
|
||||||
log.LOGGER.debug("Collection at %s unknown" % environ["PATH_INFO"])
|
self.logger.debug("Collection at %s unknown" % environ["PATH_INFO"])
|
||||||
return client.NOT_FOUND, {}, None
|
return client.NOT_FOUND, {}, None
|
||||||
etag = collection.etag
|
etag = collection.etag
|
||||||
|
|
||||||
@ -408,7 +395,7 @@ class Application(object):
|
|||||||
"Content-Type": storage.MIMETYPES[collection.get_meta("tag")],
|
"Content-Type": storage.MIMETYPES[collection.get_meta("tag")],
|
||||||
"Last-Modified": collection.last_modified,
|
"Last-Modified": collection.last_modified,
|
||||||
"ETag": etag}
|
"ETag": etag}
|
||||||
answer = answer_text.encode(self.encoding)
|
answer = answer_text
|
||||||
return client.OK, headers, answer
|
return client.OK, headers, answer
|
||||||
|
|
||||||
def do_HEAD(self, environ, read_collections, write_collections, content,
|
def do_HEAD(self, environ, read_collections, write_collections, content,
|
||||||
@ -429,7 +416,7 @@ class Application(object):
|
|||||||
props = xmlutils.props_from_request(content)
|
props = xmlutils.props_from_request(content)
|
||||||
# TODO: use this?
|
# TODO: use this?
|
||||||
# timezone = props.get("C:calendar-timezone")
|
# timezone = props.get("C:calendar-timezone")
|
||||||
collection = storage.Collection.create_collection(
|
collection = self.Collection.create_collection(
|
||||||
environ["PATH_INFO"], tag="VCALENDAR")
|
environ["PATH_INFO"], tag="VCALENDAR")
|
||||||
for key, value in props.items():
|
for key, value in props.items():
|
||||||
collection.set_meta(key, value)
|
collection.set_meta(key, value)
|
||||||
@ -444,7 +431,7 @@ class Application(object):
|
|||||||
collection = write_collections[0]
|
collection = write_collections[0]
|
||||||
|
|
||||||
props = xmlutils.props_from_request(content)
|
props = xmlutils.props_from_request(content)
|
||||||
collection = storage.Collection.create_collection(environ["PATH_INFO"])
|
collection = self.Collection.create_collection(environ["PATH_INFO"])
|
||||||
for key, value in props.items():
|
for key, value in props.items():
|
||||||
collection.set_meta(key, value)
|
collection.set_meta(key, value)
|
||||||
return client.CREATED, {}, None
|
return client.CREATED, {}, None
|
||||||
@ -465,7 +452,7 @@ class Application(object):
|
|||||||
if to_url_parts.netloc == environ["HTTP_HOST"]:
|
if to_url_parts.netloc == environ["HTTP_HOST"]:
|
||||||
to_url = to_url_parts.path
|
to_url = to_url_parts.path
|
||||||
to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
|
to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
|
||||||
for to_collection in storage.Collection.discover(
|
for to_collection in self.Collection.discover(
|
||||||
to_path, depth="0"):
|
to_path, depth="0"):
|
||||||
if to_collection in write_collections:
|
if to_collection in write_collections:
|
||||||
to_collection.upload(to_name, item)
|
to_collection.upload(to_name, item)
|
||||||
@ -509,8 +496,7 @@ class Application(object):
|
|||||||
|
|
||||||
collection = write_collections[0]
|
collection = write_collections[0]
|
||||||
|
|
||||||
answer = xmlutils.proppatch(
|
answer = xmlutils.proppatch(environ["PATH_INFO"], content, collection)
|
||||||
environ["PATH_INFO"], content, collection)
|
|
||||||
headers = {
|
headers = {
|
||||||
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
|
"DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
|
||||||
"Content-Type": "text/xml"}
|
"Content-Type": "text/xml"}
|
||||||
@ -540,10 +526,22 @@ class Application(object):
|
|||||||
# Case 1: No item and no ETag precondition: Add new item
|
# Case 1: No item and no ETag precondition: Add new item
|
||||||
# Case 2: Item and ETag precondition verified: Modify item
|
# Case 2: Item and ETag precondition verified: Modify item
|
||||||
# Case 3: Item and no Etag precondition: Force modifying item
|
# Case 3: Item and no Etag precondition: Force modifying item
|
||||||
new_item = xmlutils.put(environ["PATH_INFO"], content, collection)
|
items = list(vobject.readComponents(content))
|
||||||
status = client.CREATED
|
if items:
|
||||||
|
if item:
|
||||||
|
# PUT is modifying an existing item
|
||||||
|
new_item = collection.update(item_name, items[0])
|
||||||
|
elif item_name:
|
||||||
|
# PUT is adding a new item
|
||||||
|
new_item = collection.upload(item_name, items[0])
|
||||||
|
else:
|
||||||
|
# PUT is replacing the whole collection
|
||||||
|
collection.delete()
|
||||||
|
new_item = self.Collection.create_collection(
|
||||||
|
environ["PATH_INFO"], items)
|
||||||
if new_item:
|
if new_item:
|
||||||
headers["ETag"] = new_item.etag
|
headers["ETag"] = new_item.etag
|
||||||
|
status = client.CREATED
|
||||||
else:
|
else:
|
||||||
# PUT rejected in all other cases
|
# PUT rejected in all other cases
|
||||||
status = client.PRECONDITION_FAILED
|
status = client.PRECONDITION_FAILED
|
||||||
|
@ -29,6 +29,7 @@ import optparse
|
|||||||
import select
|
import select
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
from wsgiref.simple_server import make_server
|
from wsgiref.simple_server import make_server
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
@ -75,9 +76,17 @@ def run():
|
|||||||
|
|
||||||
options = parser.parse_args()[0]
|
options = parser.parse_args()[0]
|
||||||
|
|
||||||
# Read in the configuration specified by the command line (if specified)
|
if options.config:
|
||||||
configuration_found = (
|
configuration = config.load()
|
||||||
config.read(options.config) if options.config else True)
|
configuration_found = configuration.read(options.config)
|
||||||
|
else:
|
||||||
|
configuration_paths = [
|
||||||
|
"/etc/radicale/config",
|
||||||
|
os.path.expanduser("~/.config/radicale/config")]
|
||||||
|
if "RADICALE_CONFIG" in os.environ:
|
||||||
|
configuration_paths.append(os.environ["RADICALE_CONFIG"])
|
||||||
|
configuration = config.load(configuration_paths)
|
||||||
|
configuration_found = True
|
||||||
|
|
||||||
# Update Radicale configuration according to options
|
# Update Radicale configuration according to options
|
||||||
for option in parser.option_list:
|
for option in parser.option_list:
|
||||||
@ -86,32 +95,33 @@ def run():
|
|||||||
section = "logging" if key == "debug" else "server"
|
section = "logging" if key == "debug" else "server"
|
||||||
value = getattr(options, key)
|
value = getattr(options, key)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
config.set(section, key, str(value))
|
configuration.set(section, key, str(value))
|
||||||
|
|
||||||
# Start logging
|
# Start logging
|
||||||
log.start()
|
filename = os.path.expanduser(configuration.get("logging", "config"))
|
||||||
|
debug = configuration.getboolean("logging", "debug")
|
||||||
|
logger = log.start("radicale", filename, debug)
|
||||||
|
|
||||||
# Log a warning if the configuration file of the command line is not found
|
# Log a warning if the configuration file of the command line is not found
|
||||||
if not configuration_found:
|
if not configuration_found:
|
||||||
log.LOGGER.warning(
|
logger.warning("Configuration file '%s' not found" % options.config)
|
||||||
"Configuration file '%s' not found" % options.config)
|
|
||||||
|
|
||||||
# Fork if Radicale is launched as daemon
|
# Fork if Radicale is launched as daemon
|
||||||
if config.getboolean("server", "daemon"):
|
if configuration.getboolean("server", "daemon"):
|
||||||
# Check and create PID file in a race-free manner
|
# Check and create PID file in a race-free manner
|
||||||
if config.get("server", "pid"):
|
if configuration.get("server", "pid"):
|
||||||
try:
|
try:
|
||||||
pid_fd = os.open(
|
pid_fd = os.open(
|
||||||
config.get("server", "pid"),
|
configuration.get("server", "pid"),
|
||||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
except:
|
except:
|
||||||
raise OSError(
|
raise OSError(
|
||||||
"PID file exists: %s" % config.get("server", "pid"))
|
"PID file exists: %s" % configuration.get("server", "pid"))
|
||||||
pid = os.fork()
|
pid = os.fork()
|
||||||
if pid:
|
if pid:
|
||||||
sys.exit()
|
sys.exit()
|
||||||
# Write PID
|
# Write PID
|
||||||
if config.get("server", "pid"):
|
if configuration.get("server", "pid"):
|
||||||
with os.fdopen(pid_fd, "w") as pid_file:
|
with os.fdopen(pid_fd, "w") as pid_file:
|
||||||
pid_file.write(str(os.getpid()))
|
pid_file.write(str(os.getpid()))
|
||||||
# Decouple environment
|
# Decouple environment
|
||||||
@ -127,35 +137,55 @@ def run():
|
|||||||
# Register exit function
|
# Register exit function
|
||||||
def cleanup():
|
def cleanup():
|
||||||
"""Remove the PID files."""
|
"""Remove the PID files."""
|
||||||
log.LOGGER.debug("Cleaning up")
|
logger.debug("Cleaning up")
|
||||||
# Remove PID file
|
# Remove PID file
|
||||||
if (config.get("server", "pid") and
|
if (configuration.get("server", "pid") and
|
||||||
config.getboolean("server", "daemon")):
|
configuration.getboolean("server", "daemon")):
|
||||||
os.unlink(config.get("server", "pid"))
|
os.unlink(configuration.get("server", "pid"))
|
||||||
|
|
||||||
atexit.register(cleanup)
|
atexit.register(cleanup)
|
||||||
log.LOGGER.info("Starting Radicale")
|
logger.info("Starting Radicale")
|
||||||
|
|
||||||
log.LOGGER.debug(
|
logger.debug(
|
||||||
"Base URL prefix: %s" % config.get("server", "base_prefix"))
|
"Base URL prefix: %s" % configuration.get("server", "base_prefix"))
|
||||||
|
|
||||||
# Create collection servers
|
# Create collection servers
|
||||||
servers = {}
|
servers = {}
|
||||||
server_class = (
|
if configuration.getboolean("server", "ssl"):
|
||||||
HTTPSServer if config.getboolean("server", "ssl") else HTTPServer)
|
server_class = HTTPSServer
|
||||||
|
server_class.certificate = configuration.get("server", "certificate")
|
||||||
|
server_class.key = configuration.get("server", "key")
|
||||||
|
server_class.cyphers = configuration.get("server", "cyphers")
|
||||||
|
server_class.certificate = getattr(
|
||||||
|
ssl, configuration.get("server", "protocol"), ssl.PROTOCOL_SSLv23)
|
||||||
|
# Test if the SSL files can be read
|
||||||
|
for name in ("certificate", "key"):
|
||||||
|
filename = getattr(server_class, name)
|
||||||
|
try:
|
||||||
|
open(filename, "r").close()
|
||||||
|
except IOError as exception:
|
||||||
|
logger.warning(
|
||||||
|
"Error while reading SSL %s %r: %s" % (
|
||||||
|
name, filename, exception))
|
||||||
|
else:
|
||||||
|
server_class = HTTPServer
|
||||||
|
|
||||||
|
if not configuration.getboolean("server", "dns_lookup"):
|
||||||
|
RequestHandler.address_string = lambda self: self.client_address[0]
|
||||||
|
|
||||||
shutdown_program = [False]
|
shutdown_program = [False]
|
||||||
|
|
||||||
for host in config.get("server", "hosts").split(","):
|
for host in configuration.get("server", "hosts").split(","):
|
||||||
address, port = host.strip().rsplit(":", 1)
|
address, port = host.strip().rsplit(":", 1)
|
||||||
address, port = address.strip("[] "), int(port)
|
address, port = address.strip("[] "), int(port)
|
||||||
server = make_server(address, port, Application(),
|
application = Application(configuration, logger)
|
||||||
server_class, RequestHandler)
|
server = make_server(
|
||||||
|
address, port, application, server_class, RequestHandler)
|
||||||
servers[server.socket] = server
|
servers[server.socket] = server
|
||||||
log.LOGGER.debug(
|
logger.debug("Listening to %s port %s" % (
|
||||||
"Listening to %s port %s" % (
|
|
||||||
server.server_name, server.server_port))
|
server.server_name, server.server_port))
|
||||||
if config.getboolean("server", "ssl"):
|
if configuration.getboolean("server", "ssl"):
|
||||||
log.LOGGER.debug("Using SSL")
|
logger.debug("Using SSL")
|
||||||
|
|
||||||
# Create a socket pair to notify the select syscall of program shutdown
|
# Create a socket pair to notify the select syscall of program shutdown
|
||||||
# This is not available in python < 3.5 on Windows
|
# This is not available in python < 3.5 on Windows
|
||||||
@ -171,7 +201,7 @@ def run():
|
|||||||
if shutdown_program[0]:
|
if shutdown_program[0]:
|
||||||
# Ignore following signals
|
# Ignore following signals
|
||||||
return
|
return
|
||||||
log.LOGGER.info("Stopping Radicale")
|
logger.info("Stopping Radicale")
|
||||||
shutdown_program[0] = True
|
shutdown_program[0] = True
|
||||||
if shutdown_program_socket_in:
|
if shutdown_program_socket_in:
|
||||||
shutdown_program_socket_in.sendall(b"goodbye")
|
shutdown_program_socket_in.sendall(b"goodbye")
|
||||||
@ -187,7 +217,7 @@ def run():
|
|||||||
else:
|
else:
|
||||||
# Fallback to busy waiting
|
# Fallback to busy waiting
|
||||||
select_timeout = 1.0
|
select_timeout = 1.0
|
||||||
log.LOGGER.debug("Radicale server ready")
|
logger.debug("Radicale server ready")
|
||||||
while not shutdown_program[0]:
|
while not shutdown_program[0]:
|
||||||
try:
|
try:
|
||||||
rlist, _, xlist = select.select(
|
rlist, _, xlist = select.select(
|
||||||
|
168
radicale/auth.py
168
radicale/auth.py
@ -28,8 +28,8 @@ by using the system's CRYPT routine. The CRYPT and SHA1 encryption methods
|
|||||||
implemented by htpasswd are considered as insecure. MD5-APR1 provides medium
|
implemented by htpasswd are considered as insecure. MD5-APR1 provides medium
|
||||||
security as of 2015. Only BCRYPT can be considered secure by current standards.
|
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 (its
|
MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it
|
||||||
the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer.
|
is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer.
|
||||||
|
|
||||||
The `is_authenticated(user, password)` function provided by this module
|
The `is_authenticated(user, password)` function provided by this module
|
||||||
verifies the user-given credentials by parsing the htpasswd credential file
|
verifies the user-given credentials by parsing the htpasswd credential file
|
||||||
@ -55,55 +55,110 @@ following significantly more secure schemes are parsable by Radicale:
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import sys
|
from importlib import import_module
|
||||||
|
|
||||||
from . import config, log
|
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
def load(configuration, logger):
|
||||||
"""Load the authentication manager chosen in configuration."""
|
"""Load the authentication manager chosen in configuration."""
|
||||||
auth_type = config.get("auth", "type")
|
auth_type = configuration.get("auth", "type")
|
||||||
log.LOGGER.debug("Authentication type is %s" % auth_type)
|
logger.debug("Authentication type is %s" % auth_type)
|
||||||
if auth_type == "None":
|
if auth_type == "None":
|
||||||
sys.modules[__name__].is_authenticated = lambda user, password: True
|
return lambda user, password: True
|
||||||
elif auth_type == "htpasswd":
|
elif auth_type == "htpasswd":
|
||||||
pass # is_authenticated is already defined
|
return Auth(configuration, logger).is_authenticated
|
||||||
else:
|
else:
|
||||||
__import__(auth_type)
|
module = import_module(auth_type)
|
||||||
sys.modules[__name__].is_authenticated = (
|
return module.Auth(configuration, logger).is_authenticated
|
||||||
sys.modules[auth_type].is_authenticated)
|
|
||||||
|
|
||||||
|
|
||||||
FILENAME = os.path.expanduser(config.get("auth", "htpasswd_filename"))
|
class BaseAuth:
|
||||||
ENCRYPTION = config.get("auth", "htpasswd_encryption")
|
def __init__(self, configuration, logger):
|
||||||
|
self.configuration = configuration
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
def _plain(hash_value, password):
|
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 as _passlib_md5apr1
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The htpasswd encryption method 'md5' requires "
|
||||||
|
"the passlib module.")
|
||||||
|
self.verify = self._md5apr1
|
||||||
|
elif self.encryption == "bcrypt":
|
||||||
|
try:
|
||||||
|
from passlib.hash import bcrypt as _passlib_bcrypt
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The htpasswd encryption method 'bcrypt' requires "
|
||||||
|
"the passlib module with bcrypt support.")
|
||||||
|
# A call to `encrypt` raises passlib.exc.MissingBackendError with a
|
||||||
|
# good error message if bcrypt backend is not available. Trigger
|
||||||
|
# this here.
|
||||||
|
_passlib_bcrypt.encrypt("test-bcrypt-backend")
|
||||||
|
self.verify = self._bcrypt
|
||||||
|
elif self.encryption == "crypt":
|
||||||
|
try:
|
||||||
|
import crypt
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The htpasswd encryption method 'crypt' requires "
|
||||||
|
"the crypt() system support.")
|
||||||
|
self.verify = self._crypt
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The htpasswd encryption method '%s' is not "
|
||||||
|
"supported." % self.encryption)
|
||||||
|
|
||||||
|
def _plain(self, hash_value, password):
|
||||||
"""Check if ``hash_value`` and ``password`` match, using plain method."""
|
"""Check if ``hash_value`` and ``password`` match, using plain method."""
|
||||||
return hash_value == password
|
return hash_value == password
|
||||||
|
|
||||||
|
|
||||||
def _crypt(hash_value, password):
|
def _crypt(self, hash_value, password):
|
||||||
"""Check if ``hash_value`` and ``password`` match, using crypt method."""
|
"""Check if ``hash_value`` and ``password`` match, using crypt method."""
|
||||||
return crypt.crypt(password, hash_value) == hash_value
|
return crypt.crypt(password, hash_value) == hash_value
|
||||||
|
|
||||||
|
|
||||||
def _sha1(hash_value, password):
|
def _sha1(self, hash_value, password):
|
||||||
"""Check if ``hash_value`` and ``password`` match, using sha1 method."""
|
"""Check if ``hash_value`` and ``password`` match, using sha1 method."""
|
||||||
hash_value = hash_value.replace("{SHA}", "").encode("ascii")
|
hash_value = hash_value.replace("{SHA}", "").encode("ascii")
|
||||||
password = password.encode(config.get("encoding", "stock"))
|
password = password.encode(self.configuration.get("encoding", "stock"))
|
||||||
sha1 = hashlib.sha1() # pylint: disable=E1101
|
sha1 = hashlib.sha1() # pylint: disable=E1101
|
||||||
sha1.update(password)
|
sha1.update(password)
|
||||||
return sha1.digest() == base64.b64decode(hash_value)
|
return sha1.digest() == base64.b64decode(hash_value)
|
||||||
|
|
||||||
|
|
||||||
def _ssha(hash_salt_value, password):
|
def _ssha(self, hash_salt_value, password):
|
||||||
"""Check if ``hash_salt_value`` and ``password`` match, using salted sha1
|
"""Check if ``hash_salt_value`` and ``password`` match, using salted sha1
|
||||||
method. This method is not directly supported by htpasswd, but it can be
|
method. This method is not directly supported by htpasswd, but it can be
|
||||||
written with e.g. openssl, and nginx can parse it."""
|
written with e.g. openssl, and nginx can parse it."""
|
||||||
hash_salt_value = hash_salt_value.replace(
|
hash_salt_value = hash_salt_value.replace(
|
||||||
"{SSHA}", "").encode("ascii").decode('base64')
|
"{SSHA}", "").encode("ascii").decode('base64')
|
||||||
password = password.encode(config.get("encoding", "stock"))
|
password = password.encode(self.configuration.get("encoding", "stock"))
|
||||||
hash_value = hash_salt_value[:20]
|
hash_value = hash_salt_value[:20]
|
||||||
salt_value = hash_salt_value[20:]
|
salt_value = hash_salt_value[20:]
|
||||||
sha1 = hashlib.sha1() # pylint: disable=E1101
|
sha1 = hashlib.sha1() # pylint: disable=E1101
|
||||||
@ -112,70 +167,23 @@ def _ssha(hash_salt_value, password):
|
|||||||
return sha1.digest() == hash_value
|
return sha1.digest() == hash_value
|
||||||
|
|
||||||
|
|
||||||
def _bcrypt(hash_value, password):
|
def _bcrypt(self, hash_value, password):
|
||||||
return _passlib_bcrypt.verify(password, hash_value)
|
return _passlib_bcrypt.verify(password, hash_value)
|
||||||
|
|
||||||
|
|
||||||
def _md5apr1(hash_value, password):
|
def _md5apr1(self, hash_value, password):
|
||||||
return _passlib_md5apr1.verify(password, hash_value)
|
return _passlib_md5apr1.verify(password, hash_value)
|
||||||
|
|
||||||
|
def is_authenticated(self, user, password):
|
||||||
# Prepare mapping between encryption names and verification functions.
|
# The content of the file is not cached because reading is generally a
|
||||||
# Pre-fill with methods that do not have external dependencies.
|
# very cheap operation, and it's useful to get live updates of the
|
||||||
_verifuncs = {
|
# htpasswd file.
|
||||||
"ssha": _ssha,
|
with open(self.filename) as fd:
|
||||||
"sha1": _sha1,
|
for line in fd:
|
||||||
"plain": _plain}
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
login, hash_value = line.split(":")
|
||||||
# Conditionally attempt to import external dependencies.
|
|
||||||
if ENCRYPTION == "md5":
|
|
||||||
try:
|
|
||||||
from passlib.hash import apr_md5_crypt as _passlib_md5apr1
|
|
||||||
except ImportError:
|
|
||||||
raise RuntimeError(("The htpasswd_encryption method 'md5' requires "
|
|
||||||
"availability of the passlib module."))
|
|
||||||
_verifuncs["md5"] = _md5apr1
|
|
||||||
elif ENCRYPTION == "bcrypt":
|
|
||||||
try:
|
|
||||||
from passlib.hash import bcrypt as _passlib_bcrypt
|
|
||||||
except ImportError:
|
|
||||||
raise RuntimeError(("The htpasswd_encryption method 'bcrypt' requires "
|
|
||||||
"availability of the passlib module with bcrypt support."))
|
|
||||||
# A call to `encrypt` raises passlib.exc.MissingBackendError with a good
|
|
||||||
# error message if bcrypt backend is not available. Trigger this here.
|
|
||||||
_passlib_bcrypt.encrypt("test-bcrypt-backend")
|
|
||||||
_verifuncs["bcrypt"] = _bcrypt
|
|
||||||
elif ENCRYPTION == "crypt":
|
|
||||||
try:
|
|
||||||
import crypt
|
|
||||||
except ImportError:
|
|
||||||
raise RuntimeError(("The htpasswd_encryption method 'crypt' requires "
|
|
||||||
"crypt() system support."))
|
|
||||||
_verifuncs["crypt"] = _crypt
|
|
||||||
|
|
||||||
|
|
||||||
# Validate initial configuration.
|
|
||||||
if ENCRYPTION not in _verifuncs:
|
|
||||||
raise RuntimeError(("The htpasswd encryption method '%s' is not "
|
|
||||||
"supported." % ENCRYPTION))
|
|
||||||
|
|
||||||
|
|
||||||
def is_authenticated(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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
with open(FILENAME) as f:
|
|
||||||
for line in f:
|
|
||||||
strippedline = line.strip()
|
|
||||||
if strippedline:
|
|
||||||
login, hash_value = strippedline.split(":")
|
|
||||||
if login == user:
|
if login == user:
|
||||||
# Allow encryption method to be overridden at runtime.
|
return self.verify(hash_value, password)
|
||||||
return _verifuncs[ENCRYPTION](hash_value, password)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -24,7 +24,6 @@ Give a configparser-like interface to read and write configuration.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
|
|
||||||
from configparser import RawConfigParser as ConfigParser
|
from configparser import RawConfigParser as ConfigParser
|
||||||
|
|
||||||
@ -66,18 +65,14 @@ INITIAL_CONFIG = {
|
|||||||
"debug": "False",
|
"debug": "False",
|
||||||
"full_environment": "False"}}
|
"full_environment": "False"}}
|
||||||
|
|
||||||
# Create a ConfigParser and configure it
|
|
||||||
_CONFIG_PARSER = ConfigParser()
|
|
||||||
|
|
||||||
for section, values in INITIAL_CONFIG.items():
|
def load(paths=()):
|
||||||
_CONFIG_PARSER.add_section(section)
|
config = ConfigParser()
|
||||||
|
for section, values in INITIAL_CONFIG.items():
|
||||||
|
config.add_section(section)
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
_CONFIG_PARSER.set(section, key, value)
|
config.set(section, key, value)
|
||||||
|
for path in paths:
|
||||||
_CONFIG_PARSER.read("/etc/radicale/config")
|
if path:
|
||||||
_CONFIG_PARSER.read(os.path.expanduser("~/.config/radicale/config"))
|
config.read(path)
|
||||||
if "RADICALE_CONFIG" in os.environ:
|
return config
|
||||||
_CONFIG_PARSER.read(os.environ["RADICALE_CONFIG"])
|
|
||||||
|
|
||||||
# Wrap config module into ConfigParser instance
|
|
||||||
sys.modules[__name__] = _CONFIG_PARSER
|
|
||||||
|
@ -28,40 +28,38 @@ import logging
|
|||||||
import logging.config
|
import logging.config
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
from . import config
|
|
||||||
|
|
||||||
|
def configure_from_file(filename, debug, logger):
|
||||||
LOGGER = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def configure_from_file(filename, debug):
|
|
||||||
logging.config.fileConfig(filename)
|
logging.config.fileConfig(filename)
|
||||||
if debug:
|
if debug:
|
||||||
LOGGER.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
for handler in LOGGER.handlers:
|
for handler in logger.handlers:
|
||||||
handler.setLevel(logging.DEBUG)
|
handler.setLevel(logging.DEBUG)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def start(name="radicale", filename=None, debug=False):
|
||||||
"""Start the logging according to the configuration."""
|
"""Start the logging according to the configuration."""
|
||||||
filename = os.path.expanduser(config.get("logging", "config"))
|
logger = logging.getLogger(name)
|
||||||
debug = config.getboolean("logging", "debug")
|
|
||||||
|
|
||||||
if os.path.exists(filename):
|
if os.path.exists(filename):
|
||||||
# Configuration taken from file
|
# Configuration taken from file
|
||||||
configure_from_file(filename, debug)
|
configure_from_file(logger, filename, debug)
|
||||||
# Reload config on SIGHUP (UNIX only)
|
# Reload config on SIGHUP (UNIX only)
|
||||||
if hasattr(signal, 'SIGHUP'):
|
if hasattr(signal, 'SIGHUP'):
|
||||||
|
def handler_generator(logger, filename, debug):
|
||||||
def handler(signum, frame):
|
def handler(signum, frame):
|
||||||
configure_from_file(filename, debug)
|
configure_from_file(logger, filename, debug)
|
||||||
|
handler = handler_generator(logger, filename, debug)
|
||||||
signal.signal(signal.SIGHUP, handler)
|
signal.signal(signal.SIGHUP, handler)
|
||||||
else:
|
else:
|
||||||
# Default configuration, standard output
|
# Default configuration, standard output
|
||||||
handler = logging.StreamHandler(sys.stdout)
|
if filename:
|
||||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
logger.warning(
|
||||||
LOGGER.addHandler(handler)
|
|
||||||
if debug:
|
|
||||||
LOGGER.setLevel(logging.DEBUG)
|
|
||||||
LOGGER.debug(
|
|
||||||
"Logging configuration file '%s' not found, using stdout." %
|
"Logging configuration file '%s' not found, using stdout." %
|
||||||
filename)
|
filename)
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
if debug:
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
return logger
|
||||||
|
@ -39,24 +39,21 @@ Leading or ending slashes are trimmed from collection's path.
|
|||||||
|
|
||||||
import os.path
|
import os.path
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from importlib import import_module
|
||||||
from . import config, log
|
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
def load(configuration, logger):
|
||||||
"""Load the rights manager chosen in configuration."""
|
"""Load the rights manager chosen in configuration."""
|
||||||
rights_type = config.get("rights", "type")
|
rights_type = configuration.get("rights", "type")
|
||||||
if rights_type == "None":
|
if rights_type == "None":
|
||||||
sys.modules[__name__].authorized = (
|
return lambda user, collection, permission: True
|
||||||
lambda user, collection, permission: True)
|
|
||||||
elif rights_type in DEFINED_RIGHTS or rights_type == "from_file":
|
elif rights_type in DEFINED_RIGHTS or rights_type == "from_file":
|
||||||
pass # authorized is already defined
|
return Rights(configuration, logger).authorized
|
||||||
else:
|
else:
|
||||||
__import__(rights_type)
|
module = import_module(rights_type)
|
||||||
sys.modules[__name__].authorized = sys.modules[rights_type].authorized
|
return module.Rights(configuration, logger).authorized
|
||||||
|
|
||||||
|
|
||||||
DEFINED_RIGHTS = {
|
DEFINED_RIGHTS = {
|
||||||
@ -84,53 +81,57 @@ permission:rw
|
|||||||
"""}
|
"""}
|
||||||
|
|
||||||
|
|
||||||
def _read_from_sections(user, collection_url, permission):
|
class BaseRights:
|
||||||
"""Get regex sections."""
|
def __init__(self, configuration, logger):
|
||||||
filename = os.path.expanduser(config.get("rights", "file"))
|
self.configuration = configuration
|
||||||
rights_type = config.get("rights", "type").lower()
|
self.logger = logger
|
||||||
|
|
||||||
|
def authorized(self, user, collection, permission):
|
||||||
|
"""Check if the user is allowed to read or write the collection.
|
||||||
|
|
||||||
|
If the user is empty, check for anonymous rights.
|
||||||
|
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class Rights(BaseRights):
|
||||||
|
def __init__(self, configuration, logger):
|
||||||
|
super().__init__()
|
||||||
|
self.filename = os.path.expanduser(configuration.get("rights", "file"))
|
||||||
|
self.rights_type = configuration.get("rights", "type").lower()
|
||||||
|
|
||||||
|
def authorized(self, user, collection, permission):
|
||||||
|
collection_url = collection.path.rstrip("/") or "/"
|
||||||
|
if collection_url in (".well-known/carddav", ".well-known/caldav"):
|
||||||
|
return permission == "r"
|
||||||
# Prevent "regex injection"
|
# Prevent "regex injection"
|
||||||
user_escaped = re.escape(user)
|
user_escaped = re.escape(user)
|
||||||
collection_url_escaped = re.escape(collection_url)
|
collection_url_escaped = re.escape(collection_url)
|
||||||
regex = ConfigParser({"login": user_escaped, "path": collection_url_escaped})
|
regex = ConfigParser(
|
||||||
if rights_type in DEFINED_RIGHTS:
|
{"login": user_escaped, "path": collection_url_escaped})
|
||||||
log.LOGGER.debug("Rights type '%s'" % rights_type)
|
if self.rights_type in DEFINED_RIGHTS:
|
||||||
regex.readfp(StringIO(DEFINED_RIGHTS[rights_type]))
|
self.logger.debug("Rights type '%s'" % self.rights_type)
|
||||||
elif rights_type == "from_file":
|
regex.readfp(StringIO(DEFINED_RIGHTS[self.rights_type]))
|
||||||
log.LOGGER.debug("Reading rights from file %s" % filename)
|
|
||||||
if not regex.read(filename):
|
|
||||||
log.LOGGER.error("File '%s' not found for rights" % filename)
|
|
||||||
return False
|
|
||||||
else:
|
else:
|
||||||
log.LOGGER.error("Unknown rights type '%s'" % rights_type)
|
self.logger.debug("Reading rights from file '%s'" % self.filename)
|
||||||
|
if not regex.read(self.filename):
|
||||||
|
self.logger.error(
|
||||||
|
"File '%s' not found for rights" % self.filename)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for section in regex.sections():
|
for section in regex.sections():
|
||||||
re_user = regex.get(section, "user")
|
re_user = regex.get(section, "user")
|
||||||
re_collection = regex.get(section, "collection")
|
re_collection = regex.get(section, "collection")
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"Test if '%s:%s' matches against '%s:%s' from section '%s'" % (
|
"Test if '%s:%s' matches against '%s:%s' from section '%s'" % (
|
||||||
user, collection_url, re_user, re_collection, section))
|
user, collection_url, re_user, re_collection, section))
|
||||||
user_match = re.match(re_user, user)
|
user_match = re.match(re_user, user)
|
||||||
if user_match:
|
if user_match:
|
||||||
re_collection = re_collection.format(*user_match.groups())
|
re_collection = re_collection.format(*user_match.groups())
|
||||||
if re.match(re_collection, collection_url):
|
if re.match(re_collection, collection_url):
|
||||||
log.LOGGER.debug("Section '%s' matches" % section)
|
self.logger.debug("Section '%s' matches" % section)
|
||||||
return permission in regex.get(section, "permission")
|
return permission in regex.get(section, "permission")
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug("Section '%s' does not match" % section)
|
self.logger.debug("Section '%s' does not match" % section)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def authorized(user, collection, permission):
|
|
||||||
"""Check if the user is allowed to read or write the collection.
|
|
||||||
|
|
||||||
If the user is empty, check for anonymous rights.
|
|
||||||
|
|
||||||
"""
|
|
||||||
collection_url = collection.path.rstrip("/") or "/"
|
|
||||||
if collection_url in (".well-known/carddav", ".well-known/caldav"):
|
|
||||||
return permission == "r"
|
|
||||||
rights_type = config.get("rights", "type").lower()
|
|
||||||
return (
|
|
||||||
rights_type == "none" or
|
|
||||||
_read_from_sections(user or "", collection_url, permission))
|
|
||||||
|
@ -29,31 +29,29 @@ import json
|
|||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
|
from importlib import import_module
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import vobject
|
import vobject
|
||||||
|
|
||||||
from . import config, log
|
|
||||||
|
|
||||||
|
def load(configuration, logger):
|
||||||
def _load():
|
|
||||||
"""Load the storage manager chosen in configuration."""
|
"""Load the storage manager chosen in configuration."""
|
||||||
storage_type = config.get("storage", "type")
|
storage_type = configuration.get("storage", "type")
|
||||||
if storage_type == "multifilesystem":
|
if storage_type == "multifilesystem":
|
||||||
module = sys.modules[__name__]
|
collection_class = Collection
|
||||||
else:
|
else:
|
||||||
__import__(storage_type)
|
collection_class = import_module(storage_type).Collection
|
||||||
module = sys.modules[storage_type]
|
class CollectionCopy(collection_class):
|
||||||
sys.modules[__name__].Collection = module.Collection
|
"""Collection copy, avoids overriding the original class attributes."""
|
||||||
|
CollectionCopy.configuration = configuration
|
||||||
|
CollectionCopy.logger = logger
|
||||||
|
return CollectionCopy
|
||||||
|
|
||||||
|
|
||||||
FOLDER = os.path.expanduser(config.get("storage", "filesystem_folder"))
|
|
||||||
FILESYSTEM_ENCODING = sys.getfilesystemencoding()
|
|
||||||
STORAGE_ENCODING = config.get("encoding", "stock")
|
|
||||||
MIMETYPES = {"VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"}
|
MIMETYPES = {"VADDRESSBOOK": "text/vcard", "VCALENDAR": "text/calendar"}
|
||||||
|
|
||||||
|
|
||||||
@ -106,15 +104,14 @@ def path_to_filesystem(root, *paths):
|
|||||||
continue
|
continue
|
||||||
for part in path.split("/"):
|
for part in path.split("/"):
|
||||||
if not is_safe_filesystem_path_component(part):
|
if not is_safe_filesystem_path_component(part):
|
||||||
log.LOGGER.debug(
|
|
||||||
"Can't translate path safely to filesystem: %s", path)
|
|
||||||
raise ValueError("Unsafe path")
|
raise ValueError("Unsafe path")
|
||||||
safe_path = os.path.join(safe_path, part)
|
safe_path = os.path.join(safe_path, part)
|
||||||
return safe_path
|
return safe_path
|
||||||
|
|
||||||
|
|
||||||
class Item:
|
class Item:
|
||||||
def __init__(self, item, href, last_modified=None):
|
def __init__(self, collection, item, href, last_modified=None):
|
||||||
|
self.collection = collection
|
||||||
self.item = item
|
self.item = item
|
||||||
self.href = href
|
self.href = href
|
||||||
self.last_modified = last_modified
|
self.last_modified = last_modified
|
||||||
@ -122,17 +119,17 @@ class Item:
|
|||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr):
|
||||||
return getattr(self.item, attr)
|
return getattr(self.item, attr)
|
||||||
|
|
||||||
@property
|
|
||||||
def content_length(self):
|
|
||||||
return len(self.serialize().encode(config.get("encoding", "request")))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def etag(self):
|
def etag(self):
|
||||||
return get_etag(self.serialize())
|
return get_etag(self.serialize())
|
||||||
|
|
||||||
|
|
||||||
class Collection:
|
class BaseCollection:
|
||||||
"""Collection stored in several files per calendar."""
|
|
||||||
|
# Overriden on copy by the "load" function
|
||||||
|
configuration = None
|
||||||
|
logger = None
|
||||||
|
|
||||||
def __init__(self, path, principal=False):
|
def __init__(self, path, principal=False):
|
||||||
"""Initialize the collection.
|
"""Initialize the collection.
|
||||||
|
|
||||||
@ -140,17 +137,7 @@ class Collection:
|
|||||||
the slash as the folder delimiter, with no leading nor trailing slash.
|
the slash as the folder delimiter, with no leading nor trailing slash.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.encoding = "utf-8"
|
raise NotImplementedError
|
||||||
# path should already be sanitized
|
|
||||||
self.path = sanitize_path(path).strip("/")
|
|
||||||
self._filesystem_path = path_to_filesystem(FOLDER, self.path)
|
|
||||||
split_path = self.path.split("/")
|
|
||||||
if len(split_path) > 1:
|
|
||||||
# URL with at least one folder
|
|
||||||
self.owner = split_path[0]
|
|
||||||
else:
|
|
||||||
self.owner = None
|
|
||||||
self.is_principal = principal
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def discover(cls, path, depth="1"):
|
def discover(cls, path, depth="1"):
|
||||||
@ -167,6 +154,117 @@ class Collection:
|
|||||||
The ``path`` is relative.
|
The ``path`` is relative.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def etag(self):
|
||||||
|
return get_etag(self.serialize())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_collection(cls, href, collection=None, tag=None):
|
||||||
|
"""Create a collection.
|
||||||
|
|
||||||
|
``collection`` is a list of vobject components.
|
||||||
|
|
||||||
|
``tag`` is the type of collection (VCALENDAR or VADDRESSBOOK). If
|
||||||
|
``tag`` is not given, it is guessed from the collection.
|
||||||
|
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
"""List collection items."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get(self, href):
|
||||||
|
"""Fetch a single item."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_multi(self, hrefs):
|
||||||
|
"""Fetch multiple items. Duplicate hrefs must be ignored.
|
||||||
|
|
||||||
|
Functionally similar to ``get``, but might bring performance benefits
|
||||||
|
on some storages when used cleverly.
|
||||||
|
|
||||||
|
"""
|
||||||
|
for href in set(hrefs):
|
||||||
|
yield self.get(href)
|
||||||
|
|
||||||
|
def has(self, href):
|
||||||
|
"""Check if an item exists by its href.
|
||||||
|
|
||||||
|
Functionally similar to ``get``, but might bring performance benefits
|
||||||
|
on some storages when used cleverly.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self.get(href) is not None
|
||||||
|
|
||||||
|
def upload(self, href, vobject_item):
|
||||||
|
"""Upload a new item."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update(self, href, vobject_item, etag=None):
|
||||||
|
"""Update an item.
|
||||||
|
|
||||||
|
Functionally similar to ``delete`` plus ``upload``, but might bring
|
||||||
|
performance benefits on some storages when used cleverly.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.delete(href, etag)
|
||||||
|
self.upload(href, vobject_item)
|
||||||
|
|
||||||
|
def delete(self, href=None, etag=None):
|
||||||
|
"""Delete an item.
|
||||||
|
|
||||||
|
When ``href`` is ``None``, delete the collection.
|
||||||
|
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def at_once(self):
|
||||||
|
"""Set a context manager buffering the reads and writes."""
|
||||||
|
# TODO: use in code
|
||||||
|
yield
|
||||||
|
|
||||||
|
def get_meta(self, key):
|
||||||
|
"""Get metadata value for collection."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def set_meta(self, key, value):
|
||||||
|
"""Set metadata value for collection."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_modified(self):
|
||||||
|
"""Get the HTTP-datetime of when the collection was modified."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
"""Get the unicode string representing the whole collection."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class Collection(BaseCollection):
|
||||||
|
"""Collection stored in several files per calendar."""
|
||||||
|
|
||||||
|
def __init__(self, path, principal=False):
|
||||||
|
folder = os.path.expanduser(
|
||||||
|
self.configuration.get("storage", "filesystem_folder"))
|
||||||
|
# path should already be sanitized
|
||||||
|
self.path = sanitize_path(path).strip("/")
|
||||||
|
self.storage_encoding = self.configuration.get("encoding", "stock")
|
||||||
|
self._filesystem_path = path_to_filesystem(folder, self.path)
|
||||||
|
split_path = self.path.split("/")
|
||||||
|
if len(split_path) > 1:
|
||||||
|
# URL with at least one folder
|
||||||
|
self.owner = split_path[0]
|
||||||
|
else:
|
||||||
|
self.owner = None
|
||||||
|
self.is_principal = principal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def discover(cls, path, depth="1"):
|
||||||
# path == None means wrong URL
|
# path == None means wrong URL
|
||||||
if path is None:
|
if path is None:
|
||||||
return
|
return
|
||||||
@ -178,12 +276,14 @@ class Collection:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Try to guess if the path leads to a collection or an item
|
# Try to guess if the path leads to a collection or an item
|
||||||
if not os.path.isdir(path_to_filesystem(FOLDER, sane_path)):
|
folder = os.path.expanduser(
|
||||||
|
cls.configuration.get("storage", "filesystem_folder"))
|
||||||
|
if not os.path.isdir(path_to_filesystem(folder, sane_path)):
|
||||||
# path is not a collection
|
# path is not a collection
|
||||||
if os.path.isfile(path_to_filesystem(FOLDER, sane_path)):
|
if os.path.isfile(path_to_filesystem(folder, sane_path)):
|
||||||
# path is an item
|
# path is an item
|
||||||
attributes.pop()
|
attributes.pop()
|
||||||
elif os.path.isdir(path_to_filesystem(FOLDER, *attributes[:-1])):
|
elif os.path.isdir(path_to_filesystem(folder, *attributes[:-1])):
|
||||||
# path parent is a collection
|
# path parent is a collection
|
||||||
attributes.pop()
|
attributes.pop()
|
||||||
# TODO: else: return?
|
# TODO: else: return?
|
||||||
@ -207,15 +307,9 @@ class Collection:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_collection(cls, href, collection=None, tag=None):
|
def create_collection(cls, href, collection=None, tag=None):
|
||||||
"""Create a collection.
|
folder = os.path.expanduser(
|
||||||
|
cls.configuration.get("storage", "filesystem_folder"))
|
||||||
``collection`` is a list of vobject components.
|
path = path_to_filesystem(folder, href)
|
||||||
|
|
||||||
``tag`` is the type of collection (VCALENDAR or VADDRESSBOOK). If
|
|
||||||
``tag`` is not given, it is guessed from the collection.
|
|
||||||
|
|
||||||
"""
|
|
||||||
path = path_to_filesystem(FOLDER, href)
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
os.makedirs(path)
|
os.makedirs(path)
|
||||||
if not tag and collection:
|
if not tag and collection:
|
||||||
@ -239,7 +333,6 @@ class Collection:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def list(self):
|
def list(self):
|
||||||
"""List collection items."""
|
|
||||||
try:
|
try:
|
||||||
hrefs = os.listdir(self._filesystem_path)
|
hrefs = os.listdir(self._filesystem_path)
|
||||||
except IOError:
|
except IOError:
|
||||||
@ -248,82 +341,63 @@ class Collection:
|
|||||||
for href in hrefs:
|
for href in hrefs:
|
||||||
path = os.path.join(self._filesystem_path, href)
|
path = os.path.join(self._filesystem_path, href)
|
||||||
if not href.endswith(".props") and os.path.isfile(path):
|
if not href.endswith(".props") and os.path.isfile(path):
|
||||||
with open(path, encoding=STORAGE_ENCODING) as fd:
|
with open(path, encoding=self.storage_encoding) as fd:
|
||||||
yield href, get_etag(fd.read())
|
yield href, get_etag(fd.read())
|
||||||
|
|
||||||
def get(self, href):
|
def get(self, href):
|
||||||
"""Fetch a single item."""
|
|
||||||
if not href:
|
if not href:
|
||||||
return
|
return
|
||||||
href = href.strip("{}").replace("/", "_")
|
href = href.strip("{}").replace("/", "_")
|
||||||
if is_safe_filesystem_path_component(href):
|
if is_safe_filesystem_path_component(href):
|
||||||
path = os.path.join(self._filesystem_path, href)
|
path = os.path.join(self._filesystem_path, href)
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
with open(path, encoding=STORAGE_ENCODING) as fd:
|
with open(path, encoding=self.storage_encoding) as fd:
|
||||||
text = fd.read()
|
text = fd.read()
|
||||||
last_modified = time.strftime(
|
last_modified = time.strftime(
|
||||||
"%a, %d %b %Y %H:%M:%S GMT",
|
"%a, %d %b %Y %H:%M:%S GMT",
|
||||||
time.gmtime(os.path.getmtime(path)))
|
time.gmtime(os.path.getmtime(path)))
|
||||||
return Item(vobject.readOne(text), href, last_modified)
|
return Item(self, vobject.readOne(text), href, last_modified)
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"Can't tranlate name safely to filesystem, "
|
"Can't tranlate name safely to filesystem, "
|
||||||
"skipping component: %s", href)
|
"skipping component: %s", href)
|
||||||
|
|
||||||
def get_multi(self, hrefs):
|
|
||||||
"""Fetch multiple items. Duplicate hrefs must be ignored.
|
|
||||||
|
|
||||||
Functionally similar to ``get``, but might bring performance benefits
|
|
||||||
on some storages when used cleverly.
|
|
||||||
|
|
||||||
"""
|
|
||||||
for href in set(hrefs):
|
|
||||||
yield self.get(href)
|
|
||||||
|
|
||||||
def has(self, href):
|
def has(self, href):
|
||||||
"""Check if an item exists by its href."""
|
|
||||||
return self.get(href) is not None
|
return self.get(href) is not None
|
||||||
|
|
||||||
def upload(self, href, vobject_item):
|
def upload(self, href, vobject_item):
|
||||||
"""Upload a new item."""
|
|
||||||
# TODO: use returned object in code
|
# TODO: use returned object in code
|
||||||
if is_safe_filesystem_path_component(href):
|
if is_safe_filesystem_path_component(href):
|
||||||
path = path_to_filesystem(self._filesystem_path, href)
|
path = path_to_filesystem(self._filesystem_path, href)
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
item = Item(vobject_item, href)
|
item = Item(self, vobject_item, href)
|
||||||
with open(path, "w", encoding=STORAGE_ENCODING) as fd:
|
with open(path, "w", encoding=self.storage_encoding) as fd:
|
||||||
fd.write(item.serialize())
|
fd.write(item.serialize())
|
||||||
return item
|
return item
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"Can't tranlate name safely to filesystem, "
|
"Can't tranlate name safely to filesystem, "
|
||||||
"skipping component: %s", href)
|
"skipping component: %s", href)
|
||||||
|
|
||||||
def update(self, href, vobject_item, etag=None):
|
def update(self, href, vobject_item, etag=None):
|
||||||
"""Update an item."""
|
|
||||||
# TODO: use etag in code and test it here
|
# TODO: use etag in code and test it here
|
||||||
# TODO: use returned object in code
|
# TODO: use returned object in code
|
||||||
if is_safe_filesystem_path_component(href):
|
if is_safe_filesystem_path_component(href):
|
||||||
path = path_to_filesystem(self._filesystem_path, href)
|
path = path_to_filesystem(self._filesystem_path, href)
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
with open(path, encoding=STORAGE_ENCODING) as fd:
|
with open(path, encoding=self.storage_encoding) as fd:
|
||||||
text = fd.read()
|
text = fd.read()
|
||||||
if not etag or etag == get_etag(text):
|
if not etag or etag == get_etag(text):
|
||||||
item = Item(vobject_item, href)
|
item = Item(self, vobject_item, href)
|
||||||
with open(path, "w", encoding=STORAGE_ENCODING) as fd:
|
with open(path, "w", encoding=self.storage_encoding) as fd:
|
||||||
fd.write(item.serialize())
|
fd.write(item.serialize())
|
||||||
return item
|
return item
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"Can't tranlate name safely to filesystem, "
|
"Can't tranlate name safely to filesystem, "
|
||||||
"skipping component: %s", href)
|
"skipping component: %s", href)
|
||||||
|
|
||||||
def delete(self, href=None, etag=None):
|
def delete(self, href=None, etag=None):
|
||||||
"""Delete an item.
|
|
||||||
|
|
||||||
When ``href`` is ``None``, delete the collection.
|
|
||||||
|
|
||||||
"""
|
|
||||||
# TODO: use etag in code and test it here
|
# TODO: use etag in code and test it here
|
||||||
# TODO: use returned object in code
|
# TODO: use returned object in code
|
||||||
if href is None:
|
if href is None:
|
||||||
@ -338,49 +412,44 @@ class Collection:
|
|||||||
# Delete an item
|
# Delete an item
|
||||||
path = path_to_filesystem(self._filesystem_path, href)
|
path = path_to_filesystem(self._filesystem_path, href)
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
with open(path, encoding=STORAGE_ENCODING) as fd:
|
with open(path, encoding=self.storage_encoding) as fd:
|
||||||
text = fd.read()
|
text = fd.read()
|
||||||
if not etag or etag == get_etag(text):
|
if not etag or etag == get_etag(text):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
log.LOGGER.debug(
|
self.logger.debug(
|
||||||
"Can't tranlate name safely to filesystem, "
|
"Can't tranlate name safely to filesystem, "
|
||||||
"skipping component: %s", href)
|
"skipping component: %s", href)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def at_once(self):
|
def at_once(self):
|
||||||
"""Set a context manager buffering the reads and writes."""
|
|
||||||
# TODO: use in code
|
|
||||||
# TODO: use a file locker
|
# TODO: use a file locker
|
||||||
yield
|
yield
|
||||||
|
|
||||||
def get_meta(self, key):
|
def get_meta(self, key):
|
||||||
"""Get metadata value for collection."""
|
|
||||||
props_path = self._filesystem_path + ".props"
|
props_path = self._filesystem_path + ".props"
|
||||||
if os.path.exists(props_path):
|
if os.path.exists(props_path):
|
||||||
with open(props_path, encoding=STORAGE_ENCODING) as prop_file:
|
with open(props_path, encoding=self.storage_encoding) as prop:
|
||||||
return json.load(prop_file).get(key)
|
return json.load(prop).get(key)
|
||||||
|
|
||||||
def set_meta(self, key, value):
|
def set_meta(self, key, value):
|
||||||
"""Get metadata value for collection."""
|
|
||||||
props_path = self._filesystem_path + ".props"
|
props_path = self._filesystem_path + ".props"
|
||||||
properties = {}
|
properties = {}
|
||||||
if os.path.exists(props_path):
|
if os.path.exists(props_path):
|
||||||
with open(props_path, encoding=STORAGE_ENCODING) as prop_file:
|
with open(props_path, encoding=self.storage_encoding) as prop:
|
||||||
properties.update(json.load(prop_file))
|
properties.update(json.load(prop))
|
||||||
|
|
||||||
if value:
|
if value:
|
||||||
properties[key] = value
|
properties[key] = value
|
||||||
else:
|
else:
|
||||||
properties.pop(key, None)
|
properties.pop(key, None)
|
||||||
|
|
||||||
with open(props_path, "w+", encoding=STORAGE_ENCODING) as prop_file:
|
with open(props_path, "w+", encoding=self.storage_encoding) as prop:
|
||||||
json.dump(properties, prop_file)
|
json.dump(properties, prop)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_modified(self):
|
def last_modified(self):
|
||||||
"""Get the HTTP-datetime of when the collection was modified."""
|
|
||||||
last = max([os.path.getmtime(self._filesystem_path)] + [
|
last = max([os.path.getmtime(self._filesystem_path)] + [
|
||||||
os.path.getmtime(os.path.join(self._filesystem_path, filename))
|
os.path.getmtime(os.path.join(self._filesystem_path, filename))
|
||||||
for filename in os.listdir(self._filesystem_path)] or [0])
|
for filename in os.listdir(self._filesystem_path)] or [0])
|
||||||
@ -391,7 +460,7 @@ class Collection:
|
|||||||
for href in os.listdir(self._filesystem_path):
|
for href in os.listdir(self._filesystem_path):
|
||||||
path = os.path.join(self._filesystem_path, href)
|
path = os.path.join(self._filesystem_path, href)
|
||||||
if os.path.isfile(path) and not path.endswith(".props"):
|
if os.path.isfile(path) and not path.endswith(".props"):
|
||||||
with open(path, encoding=STORAGE_ENCODING) as fd:
|
with open(path, encoding=self.storage_encoding) as fd:
|
||||||
items.append(vobject.readOne(fd.read()))
|
items.append(vobject.readOne(fd.read()))
|
||||||
if self.get_meta("tag") == "VCALENDAR":
|
if self.get_meta("tag") == "VCALENDAR":
|
||||||
collection = vobject.iCalendar()
|
collection = vobject.iCalendar()
|
||||||
@ -404,7 +473,3 @@ class Collection:
|
|||||||
elif self.get_meta("tag") == "VADDRESSBOOK":
|
elif self.get_meta("tag") == "VADDRESSBOOK":
|
||||||
return "".join([item.serialize() for item in items])
|
return "".join([item.serialize() for item in items])
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
|
||||||
def etag(self):
|
|
||||||
return get_etag(self.serialize())
|
|
||||||
|
@ -33,7 +33,7 @@ from urllib.parse import unquote, urlparse
|
|||||||
|
|
||||||
import vobject
|
import vobject
|
||||||
|
|
||||||
from . import client, config, storage
|
from . import client, storage
|
||||||
|
|
||||||
|
|
||||||
NAMESPACES = {
|
NAMESPACES = {
|
||||||
@ -80,9 +80,7 @@ def _pretty_xml(element, level=0):
|
|||||||
if level and (not element.tail or not element.tail.strip()):
|
if level and (not element.tail or not element.tail.strip()):
|
||||||
element.tail = i
|
element.tail = i
|
||||||
if not level:
|
if not level:
|
||||||
output_encoding = config.get("encoding", "request")
|
return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode")
|
||||||
return ('<?xml version="1.0"?>\n' + ET.tostring(
|
|
||||||
element, "utf-8").decode("utf-8")).encode(output_encoding)
|
|
||||||
|
|
||||||
|
|
||||||
def _tag(short_name, local):
|
def _tag(short_name, local):
|
||||||
@ -112,9 +110,11 @@ def _response(code):
|
|||||||
return "HTTP/1.1 %i %s" % (code, client.responses[code])
|
return "HTTP/1.1 %i %s" % (code, client.responses[code])
|
||||||
|
|
||||||
|
|
||||||
def _href(href):
|
def _href(collection, href):
|
||||||
"""Return prefixed href."""
|
"""Return prefixed href."""
|
||||||
return "%s%s" % (config.get("server", "base_prefix"), href.lstrip("/"))
|
return "%s%s" % (
|
||||||
|
collection.configuration.get("server", "base_prefix"),
|
||||||
|
href.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
def name_from_path(path, collection):
|
def name_from_path(path, collection):
|
||||||
@ -183,7 +183,7 @@ def delete(path, collection):
|
|||||||
multistatus.append(response)
|
multistatus.append(response)
|
||||||
|
|
||||||
href = ET.Element(_tag("D", "href"))
|
href = ET.Element(_tag("D", "href"))
|
||||||
href.text = _href(path)
|
href.text = _href(collection, path)
|
||||||
response.append(href)
|
response.append(href)
|
||||||
|
|
||||||
status = ET.Element(_tag("D", "status"))
|
status = ET.Element(_tag("D", "status"))
|
||||||
@ -234,10 +234,13 @@ def propfind(path, xml_request, read_collections, write_collections, user=None):
|
|||||||
|
|
||||||
def _propfind_response(path, item, props, user, write=False):
|
def _propfind_response(path, item, props, user, write=False):
|
||||||
"""Build and return a PROPFIND response."""
|
"""Build and return a PROPFIND response."""
|
||||||
is_collection = isinstance(item, storage.Collection)
|
|
||||||
if is_collection:
|
|
||||||
# TODO: fix this
|
# TODO: fix this
|
||||||
|
is_collection = hasattr(item, "list")
|
||||||
|
if is_collection:
|
||||||
is_leaf = bool(item.list())
|
is_leaf = bool(item.list())
|
||||||
|
collection = item
|
||||||
|
else:
|
||||||
|
collection = item.collection
|
||||||
|
|
||||||
response = ET.Element(_tag("D", "response"))
|
response = ET.Element(_tag("D", "response"))
|
||||||
|
|
||||||
@ -254,7 +257,7 @@ def _propfind_response(path, item, props, user, write=False):
|
|||||||
uri = "/".join((path, item.href))
|
uri = "/".join((path, item.href))
|
||||||
|
|
||||||
# TODO: fix this
|
# TODO: fix this
|
||||||
href.text = _href(uri.replace("//", "/"))
|
href.text = _href(collection, uri.replace("//", "/"))
|
||||||
response.append(href)
|
response.append(href)
|
||||||
|
|
||||||
propstat404 = ET.Element(_tag("D", "propstat"))
|
propstat404 = ET.Element(_tag("D", "propstat"))
|
||||||
@ -274,7 +277,7 @@ def _propfind_response(path, item, props, user, write=False):
|
|||||||
element.text = item.etag
|
element.text = item.etag
|
||||||
elif tag == _tag("D", "principal-URL"):
|
elif tag == _tag("D", "principal-URL"):
|
||||||
tag = ET.Element(_tag("D", "href"))
|
tag = ET.Element(_tag("D", "href"))
|
||||||
tag.text = _href(path)
|
tag.text = _href(collection, path)
|
||||||
element.append(tag)
|
element.append(tag)
|
||||||
elif tag == _tag("D", "getlastmodified"):
|
elif tag == _tag("D", "getlastmodified"):
|
||||||
element.text = item.last_modified
|
element.text = item.last_modified
|
||||||
@ -283,7 +286,7 @@ def _propfind_response(path, item, props, user, write=False):
|
|||||||
_tag("CR", "addressbook-home-set"),
|
_tag("CR", "addressbook-home-set"),
|
||||||
_tag("C", "calendar-home-set")):
|
_tag("C", "calendar-home-set")):
|
||||||
tag = ET.Element(_tag("D", "href"))
|
tag = ET.Element(_tag("D", "href"))
|
||||||
tag.text = _href(path)
|
tag.text = _href(collection, path)
|
||||||
element.append(tag)
|
element.append(tag)
|
||||||
elif tag == _tag("C", "supported-calendar-component-set"):
|
elif tag == _tag("C", "supported-calendar-component-set"):
|
||||||
# This is not a Todo
|
# This is not a Todo
|
||||||
@ -304,7 +307,7 @@ def _propfind_response(path, item, props, user, write=False):
|
|||||||
# pylint: enable=W0511
|
# pylint: enable=W0511
|
||||||
elif tag == _tag("D", "current-user-principal") and user:
|
elif tag == _tag("D", "current-user-principal") and user:
|
||||||
tag = ET.Element(_tag("D", "href"))
|
tag = ET.Element(_tag("D", "href"))
|
||||||
tag.text = _href("/%s/" % user)
|
tag.text = _href(collection, "/%s/" % user)
|
||||||
element.append(tag)
|
element.append(tag)
|
||||||
elif tag == _tag("D", "current-user-privilege-set"):
|
elif tag == _tag("D", "current-user-privilege-set"):
|
||||||
privilege = ET.Element(_tag("D", "privilege"))
|
privilege = ET.Element(_tag("D", "privilege"))
|
||||||
@ -381,7 +384,8 @@ def _propfind_response(path, item, props, user, write=False):
|
|||||||
# resourcetype must be returned empty for non-collection elements
|
# resourcetype must be returned empty for non-collection elements
|
||||||
pass
|
pass
|
||||||
elif tag == _tag("D", "getcontentlength"):
|
elif tag == _tag("D", "getcontentlength"):
|
||||||
element.text = str(item.content_length)
|
encoding = collection.configuration.get("encoding", "request")
|
||||||
|
element.text = str(len(item.serialize().encode(encoding)))
|
||||||
else:
|
else:
|
||||||
is404 = True
|
is404 = True
|
||||||
|
|
||||||
@ -447,7 +451,7 @@ def proppatch(path, xml_request, collection):
|
|||||||
multistatus.append(response)
|
multistatus.append(response)
|
||||||
|
|
||||||
href = ET.Element(_tag("D", "href"))
|
href = ET.Element(_tag("D", "href"))
|
||||||
href.text = _href(path)
|
href.text = _href(collection, path)
|
||||||
response.append(href)
|
response.append(href)
|
||||||
|
|
||||||
for short_name, value in props_to_set.items():
|
for short_name, value in props_to_set.items():
|
||||||
@ -461,23 +465,6 @@ def proppatch(path, xml_request, collection):
|
|||||||
return _pretty_xml(multistatus)
|
return _pretty_xml(multistatus)
|
||||||
|
|
||||||
|
|
||||||
def put(path, ical_request, collection):
|
|
||||||
"""Read PUT requests."""
|
|
||||||
name = name_from_path(path, collection)
|
|
||||||
items = list(vobject.readComponents(ical_request))
|
|
||||||
if items:
|
|
||||||
if collection.has(name):
|
|
||||||
# PUT is modifying an existing item
|
|
||||||
return collection.update(name, items[0])
|
|
||||||
elif name:
|
|
||||||
# PUT is adding a new item
|
|
||||||
return collection.upload(name, items[0])
|
|
||||||
else:
|
|
||||||
# PUT is replacing the whole collection
|
|
||||||
collection.delete()
|
|
||||||
return storage.Collection.create_collection(path, items)
|
|
||||||
|
|
||||||
|
|
||||||
def report(path, xml_request, collection):
|
def report(path, xml_request, collection):
|
||||||
"""Read and answer REPORT requests.
|
"""Read and answer REPORT requests.
|
||||||
|
|
||||||
@ -496,7 +483,7 @@ def report(path, xml_request, collection):
|
|||||||
if root.tag in (_tag("C", "calendar-multiget"),
|
if root.tag in (_tag("C", "calendar-multiget"),
|
||||||
_tag("CR", "addressbook-multiget")):
|
_tag("CR", "addressbook-multiget")):
|
||||||
# Read rfc4791-7.9 for info
|
# Read rfc4791-7.9 for info
|
||||||
base_prefix = config.get("server", "base_prefix")
|
base_prefix = collection.configuration.get("server", "base_prefix")
|
||||||
hreferences = set()
|
hreferences = set()
|
||||||
for href_element in root.findall(_tag("D", "href")):
|
for href_element in root.findall(_tag("D", "href")):
|
||||||
href_path = unquote(urlparse(href_element.text).path)
|
href_path = unquote(urlparse(href_element.text).path)
|
||||||
@ -560,7 +547,6 @@ def report(path, xml_request, collection):
|
|||||||
found_props.append(element)
|
found_props.append(element)
|
||||||
elif tag in (_tag("C", "calendar-data"),
|
elif tag in (_tag("C", "calendar-data"),
|
||||||
_tag("CR", "address-data")):
|
_tag("CR", "address-data")):
|
||||||
if isinstance(item, (storage.Item, storage.Collection)):
|
|
||||||
element.text = item.serialize()
|
element.text = item.serialize()
|
||||||
found_props.append(element)
|
found_props.append(element)
|
||||||
else:
|
else:
|
||||||
|
@ -25,11 +25,8 @@ from io import BytesIO
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
os.environ["RADICALE_CONFIG"] = os.path.join(os.path.dirname(
|
|
||||||
os.path.dirname(__file__)), "config")
|
|
||||||
|
|
||||||
|
class BaseTest:
|
||||||
class BaseTest(object):
|
|
||||||
"""Base class for tests."""
|
"""Base class for tests."""
|
||||||
def request(self, method, path, data=None, **args):
|
def request(self, method, path, data=None, **args):
|
||||||
"""Send a request."""
|
"""Send a request."""
|
||||||
|
@ -23,6 +23,9 @@ Just check username for testing
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from radicale import auth
|
||||||
|
|
||||||
def is_authenticated(user, password):
|
|
||||||
|
class Auth(auth.BaseAuth):
|
||||||
|
def is_authenticated(self, user, password):
|
||||||
return user == 'tmp'
|
return user == 'tmp'
|
||||||
|
@ -24,5 +24,10 @@ Copy of filesystem storage backend for testing
|
|||||||
from radicale import storage
|
from radicale import storage
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: make something more in this collection (and test it)
|
||||||
class Collection(storage.Collection):
|
class Collection(storage.Collection):
|
||||||
"""Collection stored in a folder."""
|
"""Collection stored in a folder."""
|
||||||
|
def __init__(self, path, principal=False):
|
||||||
|
super().__init__(path, principal)
|
||||||
|
self._filesystem_path = storage.path_to_filesystem(
|
||||||
|
self.configuration.get("storage", "test_folder"), self.path)
|
||||||
|
@ -22,11 +22,12 @@ Radicale tests with simple requests and authentication.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import radicale
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from radicale import config, auth
|
from radicale import Application, config
|
||||||
|
|
||||||
from . import BaseTest
|
from . import BaseTest
|
||||||
|
|
||||||
@ -37,38 +38,40 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
We should setup auth for each type before creating the Application object.
|
We should setup auth for each type before creating the Application object.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
self.userpass = "dG1wOmJlcG8="
|
self.colpath = tempfile.mkdtemp()
|
||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
config.set("auth", "type", "None")
|
shutil.rmtree(self.colpath)
|
||||||
radicale.auth.is_authenticated = lambda *_: True
|
|
||||||
|
|
||||||
def test_root(self):
|
def test_root(self):
|
||||||
"""Htpasswd authentication."""
|
"""Htpasswd authentication."""
|
||||||
self.colpath = tempfile.mkdtemp()
|
|
||||||
htpasswd_file_path = os.path.join(self.colpath, ".htpasswd")
|
htpasswd_file_path = os.path.join(self.colpath, ".htpasswd")
|
||||||
with open(htpasswd_file_path, "wb") as fd:
|
with open(htpasswd_file_path, "wb") as fd:
|
||||||
fd.write(b"tmp:{SHA}" + base64.b64encode(
|
fd.write(b"tmp:{SHA}" + base64.b64encode(
|
||||||
hashlib.sha1(b"bepo").digest()))
|
hashlib.sha1(b"bepo").digest()))
|
||||||
config.set("auth", "type", "htpasswd")
|
|
||||||
|
|
||||||
auth.FILENAME = htpasswd_file_path
|
configuration = config.load()
|
||||||
auth.ENCRYPTION = "sha1"
|
configuration.set("auth", "type", "htpasswd")
|
||||||
|
configuration.set("auth", "htpasswd_filename", htpasswd_file_path)
|
||||||
|
configuration.set("auth", "htpasswd_encryption", "sha1")
|
||||||
|
|
||||||
self.application = radicale.Application()
|
self.application = Application(
|
||||||
|
configuration, logging.getLogger("radicale_test"))
|
||||||
|
|
||||||
status, headers, answer = self.request(
|
status, headers, answer = self.request(
|
||||||
"GET", "/", HTTP_AUTHORIZATION=self.userpass)
|
"GET", "/", HTTP_AUTHORIZATION="dG1wOmJlcG8=")
|
||||||
assert status == 200
|
assert status == 200
|
||||||
assert "Radicale works!" in answer
|
assert "Radicale works!" in answer
|
||||||
|
|
||||||
def test_custom(self):
|
def test_custom(self):
|
||||||
"""Custom authentication."""
|
"""Custom authentication."""
|
||||||
config.set("auth", "type", "tests.custom.auth")
|
configuration = config.load()
|
||||||
self.application = radicale.Application()
|
configuration.set("auth", "type", "tests.custom.auth")
|
||||||
|
self.application = Application(
|
||||||
|
configuration, logging.getLogger("radicale_test"))
|
||||||
|
|
||||||
status, headers, answer = self.request(
|
status, headers, answer = self.request(
|
||||||
"GET", "/", HTTP_AUTHORIZATION=self.userpass)
|
"GET", "/", HTTP_AUTHORIZATION="dG1wOmJlcG8=")
|
||||||
assert status == 200
|
assert status == 200
|
||||||
assert "Radicale works!" in answer
|
assert "Radicale works!" in answer
|
||||||
|
@ -19,20 +19,24 @@ Radicale tests with simple requests.
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import radicale
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
from radicale import Application, config
|
||||||
|
|
||||||
from . import BaseTest
|
from . import BaseTest
|
||||||
from .helpers import get_file_content
|
from .helpers import get_file_content
|
||||||
|
|
||||||
|
|
||||||
class BaseRequests(object):
|
class BaseRequests:
|
||||||
"""Tests with simple requests."""
|
"""Tests with simple requests."""
|
||||||
storage_type = None
|
storage_type = None
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
radicale.config.set("storage", "type", self.storage_type)
|
self.configuration = config.load()
|
||||||
|
self.configuration.set("storage", "type", self.storage_type)
|
||||||
|
self.logger = logging.getLogger("radicale_test")
|
||||||
|
|
||||||
def test_root(self):
|
def test_root(self):
|
||||||
"""GET request at "/"."""
|
"""GET request at "/"."""
|
||||||
@ -95,30 +99,25 @@ class TestMultiFileSystem(BaseRequests, BaseTest):
|
|||||||
storage_type = "multifilesystem"
|
storage_type = "multifilesystem"
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
"""Setup function for each test."""
|
super().setup()
|
||||||
self.colpath = tempfile.mkdtemp()
|
self.colpath = tempfile.mkdtemp()
|
||||||
from radicale import storage
|
self.configuration.set("storage", "filesystem_folder", self.colpath)
|
||||||
storage.FOLDER = self.colpath
|
self.application = Application(self.configuration, self.logger)
|
||||||
self.application = radicale.Application()
|
|
||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
"""Teardown function for each test."""
|
|
||||||
shutil.rmtree(self.colpath)
|
shutil.rmtree(self.colpath)
|
||||||
|
|
||||||
|
|
||||||
class TestCustomStorageSystem(BaseRequests, BaseTest):
|
class TestCustomStorageSystem(BaseRequests, BaseTest):
|
||||||
"""Base class for custom backend tests."""
|
"""Base class for custom backend tests."""
|
||||||
storage_type = "custom"
|
storage_type = "tests.custom.storage"
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
"""Setup function for each test."""
|
|
||||||
super().setup()
|
super().setup()
|
||||||
self.colpath = tempfile.mkdtemp()
|
self.colpath = tempfile.mkdtemp()
|
||||||
radicale.config.set("storage", "type", "tests.custom.storage")
|
self.configuration.set("storage", "filesystem_folder", self.colpath)
|
||||||
from tests.custom import storage
|
self.configuration.set("storage", "test_folder", self.colpath)
|
||||||
storage.FOLDER = self.colpath
|
self.application = Application(self.configuration, self.logger)
|
||||||
self.application = radicale.Application()
|
|
||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
"""Teardown function for each test."""
|
|
||||||
shutil.rmtree(self.colpath)
|
shutil.rmtree(self.colpath)
|
||||||
|
Loading…
Reference in New Issue
Block a user