Remove extra auth, rights and storage modules

This commit is contained in:
Guillaume Ayoub 2016-04-07 19:02:52 +02:00
parent 1c4acc44a8
commit 1001bcb676
17 changed files with 134 additions and 1119 deletions

74
config
View File

@ -73,12 +73,9 @@
[auth] [auth]
# Authentication method # Authentication method
# Value: None | htpasswd | IMAP | LDAP | PAM | courier | http | remote_user | custom # Value: None | htpasswd
#type = None #type = None
# Custom authentication handler
#custom_handler =
# Htpasswd filename # Htpasswd filename
#htpasswd_filename = /etc/radicale/users #htpasswd_filename = /etc/radicale/users
@ -86,65 +83,13 @@
# Value: plain | sha1 | ssha | crypt | bcrypt | md5 # Value: plain | sha1 | ssha | crypt | bcrypt | md5
#htpasswd_encryption = crypt #htpasswd_encryption = crypt
# LDAP server URL, with protocol and port
#ldap_url = ldap://localhost:389/
# LDAP base path
#ldap_base = ou=users,dc=example,dc=com
# LDAP login attribute
#ldap_attribute = uid
# LDAP filter string
# placed as X in a query of the form (&(...)X)
# example: (objectCategory=Person)(objectClass=User)(memberOf=cn=calenderusers,ou=users,dc=example,dc=org)
# leave empty if no additional filter is needed
#ldap_filter =
# LDAP dn for initial login, used if LDAP server does not allow anonymous searches
# Leave empty if searches are anonymous
#ldap_binddn =
# LDAP password for initial login, used with ldap_binddn
#ldap_password =
# LDAP scope of the search
#ldap_scope = OneLevel
# IMAP Configuration
#imap_hostname = localhost
#imap_port = 143
#imap_ssl = False
# PAM group user should be member of
#pam_group_membership =
# Path to the Courier Authdaemon socket
#courier_socket =
# HTTP authentication request URL endpoint
#http_url =
# POST parameter to use for username
#http_user_parameter =
# POST parameter to use for password
#http_password_parameter =
[git]
# Git default options
#committer = Radicale <radicale@example.com>
[rights] [rights]
# Rights backend # Rights backend
# Value: None | authenticated | owner_only | owner_write | from_file | custom # Value: None | authenticated | owner_only | owner_write | from_file
#type = None #type = None
# Custom rights handler
#custom_handler =
# File for rights management from_file # File for rights management from_file
#file = ~/.config/radicale/rights #file = ~/.config/radicale/rights
@ -152,25 +97,12 @@
[storage] [storage]
# Storage backend # Storage backend
# ------- # Value: multifilesystem
# WARNING: ONLY "filesystem" IS DOCUMENTED AND TESTED,
# OTHER BACKENDS ARE NOT READY FOR PRODUCTION.
# -------
# Value: filesystem | multifilesystem | database | custom
#type = filesystem #type = filesystem
# Custom storage handler
#custom_handler =
# Folder for storing local collections, created if not present # Folder for storing local collections, created if not present
#filesystem_folder = ~/.config/radicale/collections #filesystem_folder = ~/.config/radicale/collections
# Database URL for SQLAlchemy
# dialect+driver://user:password@host/dbname[?key=value..]
# For example: sqlite:///var/db/radicale.db, postgresql://user:password@localhost/radicale
# See http://docs.sqlalchemy.org/en/rel_0_8/core/engines.html#sqlalchemy.create_engine
#database_url =
[logging] [logging]

View File

@ -17,7 +17,9 @@
# along with Radicale. If not, see <http://www.gnu.org/licenses/>. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
""" """
Implement htpasswd authentication. Authentication management.
Default is htpasswd authentication.
Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html) manages Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html) manages
a file for storing user credentials. It can encrypt passwords using different a file for storing user credentials. It can encrypt passwords using different
@ -50,13 +52,26 @@ following significantly more secure schemes are parsable by Radicale:
""" """
import base64 import base64
import hashlib import hashlib
import os import os
import sys
from . import config, log
from .. import config def _load():
"""Load the authentication manager chosen in configuration."""
auth_type = config.get("auth", "type")
log.LOGGER.debug("Authentication type is %s" % auth_type)
if auth_type == "None":
sys.modules[__name__].is_authenticated = lambda user, password: True
elif auth_type == "htpasswd":
pass # is_authenticated is already defined
else:
__import__(auth_type)
sys.modules[__name__].is_authenticated = (
sys.modules[auth_type].is_authenticated)
FILENAME = os.path.expanduser(config.get("auth", "htpasswd_filename")) FILENAME = os.path.expanduser(config.get("auth", "htpasswd_filename"))

View File

@ -1,97 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012 Daniel Aleksandersen
# Copyright © 2013 Nikita Koshikov
# Copyright © 2013-2016 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/>.
"""
IMAP authentication.
Secure authentication based on the ``imaplib`` module.
Validating users against a modern IMAP4rev1 server that awaits STARTTLS on
port 143. Legacy SSL (often on legacy port 993) is deprecated and thus
unsupported. STARTTLS is enforced except if host is ``localhost`` as
passwords are sent in PLAIN.
"""
import imaplib
from .. import config, log
IMAP_SERVER = config.get("auth", "imap_hostname")
IMAP_SERVER_PORT = config.getint("auth", "imap_port")
IMAP_USE_SSL = config.getboolean("auth", "imap_ssl")
IMAP_WARNED_UNENCRYPTED = False
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
global IMAP_WARNED_UNENCRYPTED
if not user or not password:
return False
log.LOGGER.debug(
"Connecting to IMAP server %s:%s." % (IMAP_SERVER, IMAP_SERVER_PORT,))
connection_is_secure = False
if IMAP_USE_SSL:
connection = imaplib.IMAP4_SSL(host=IMAP_SERVER, port=IMAP_SERVER_PORT)
connection_is_secure = True
else:
connection = imaplib.IMAP4(host=IMAP_SERVER, port=IMAP_SERVER_PORT)
server_is_local = (IMAP_SERVER == "localhost")
if not connection_is_secure:
try:
connection.starttls()
log.LOGGER.debug("IMAP server connection changed to TLS.")
connection_is_secure = True
except AttributeError:
if not server_is_local:
log.LOGGER.error(
"Python 3.2 or newer is required for IMAP + TLS.")
except (imaplib.IMAP4.error, imaplib.IMAP4.abort) as exception:
log.LOGGER.warning(
"IMAP server at %s failed to accept TLS connection "
"because of: %s" % (IMAP_SERVER, exception))
if server_is_local and not connection_is_secure and not IMAP_WARNED_UNENCRYPTED:
IMAP_WARNED_UNENCRYPTED = True
log.LOGGER.warning(
"IMAP server is local. "
"Will allow transmitting unencrypted credentials.")
if connection_is_secure or server_is_local:
try:
connection.login(user, password)
connection.logout()
log.LOGGER.debug(
"Authenticated IMAP user %s "
"via %s." % (user, IMAP_SERVER))
return True
except (imaplib.IMAP4.error, imaplib.IMAP4.abort) as exception:
log.LOGGER.error(
"IMAP server could not authenticate user %s "
"because of: %s" % (user, exception))
else:
log.LOGGER.critical(
"IMAP server did not support TLS and is not ``localhost``. "
"Refusing to transmit passwords under these conditions. "
"Authentication attempt aborted.")
return False # authentication failed

View File

@ -1,78 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011 Corentin Le Bail
# Copyright © 2011-2016 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/>.
"""
LDAP authentication.
Authentication based on the ``python-ldap`` module
(http://www.python-ldap.org/).
"""
import ldap
from .. import config, log
BASE = config.get("auth", "ldap_base")
ATTRIBUTE = config.get("auth", "ldap_attribute")
FILTER = config.get("auth", "ldap_filter")
CONNEXION = ldap.initialize(config.get("auth", "ldap_url"))
BINDDN = config.get("auth", "ldap_binddn")
PASSWORD = config.get("auth", "ldap_password")
SCOPE = getattr(ldap, "SCOPE_%s" % config.get("auth", "ldap_scope").upper())
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
global CONNEXION
try:
CONNEXION.whoami_s()
except:
log.LOGGER.debug("Reconnecting the LDAP server")
CONNEXION = ldap.initialize(config.get("auth", "ldap_url"))
if BINDDN and PASSWORD:
log.LOGGER.debug("Initial LDAP bind as %s" % BINDDN)
CONNEXION.simple_bind_s(BINDDN, PASSWORD)
distinguished_name = "%s=%s" % (ATTRIBUTE, ldap.dn.escape_dn_chars(user))
log.LOGGER.debug(
"LDAP bind for %s in base %s" % (distinguished_name, BASE))
if FILTER:
filter_string = "(&(%s)%s)" % (distinguished_name, FILTER)
else:
filter_string = distinguished_name
log.LOGGER.debug("Used LDAP filter: %s" % filter_string)
users = CONNEXION.search_s(BASE, SCOPE, filter_string)
if users:
log.LOGGER.debug("User %s found" % user)
try:
CONNEXION.simple_bind_s(users[0][0], password or "")
except ldap.LDAPError:
log.LOGGER.debug("Invalid credentials")
else:
log.LOGGER.debug("LDAP bind OK")
return True
else:
log.LOGGER.debug("User %s not found" % user)
log.LOGGER.debug("LDAP bind failed")
return False

View File

@ -1,93 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011 Henry-Nicolas Tourneur
# Copyright © 2016 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/>.
"""
PAM authentication.
Authentication based on the ``pam-python`` module.
"""
import grp
import pwd
import pam
from .. import config, log
GROUP_MEMBERSHIP = config.get("auth", "pam_group_membership")
# Compatibility for old versions of python-pam.
if hasattr(pam, "pam"):
def pam_authenticate(*args, **kwargs):
return pam.pam().authenticate(*args, **kwargs)
else:
def pam_authenticate(*args, **kwargs):
return pam.authenticate(*args, **kwargs)
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
if user is None or password is None:
return False
# Check whether the user exists in the PAM system
try:
pwd.getpwnam(user).pw_uid
except KeyError:
log.LOGGER.debug("User %s not found" % user)
return False
else:
log.LOGGER.debug("User %s found" % user)
# Check whether the group exists
try:
# Obtain supplementary groups
members = grp.getgrnam(GROUP_MEMBERSHIP).gr_mem
except KeyError:
log.LOGGER.debug(
"The PAM membership required group (%s) doesn't exist" %
GROUP_MEMBERSHIP)
return False
# Check whether the user exists
try:
# Get user primary group
primary_group = grp.getgrgid(pwd.getpwnam(user).pw_gid).gr_name
except KeyError:
log.LOGGER.debug("The PAM user (%s) doesn't exist" % user)
return False
# Check whether the user belongs to the required group
# (primary or supplementary)
if primary_group == GROUP_MEMBERSHIP or user in members:
log.LOGGER.debug(
"The PAM user belongs to the required group (%s)" %
GROUP_MEMBERSHIP)
# Check the password
if pam_authenticate(user, password, service='radicale'):
return True
else:
log.LOGGER.debug("Wrong PAM password")
else:
log.LOGGER.debug(
"The PAM user doesn't belong to the required group (%s)" %
GROUP_MEMBERSHIP)
return False

View File

@ -1,54 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2016 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.
"""
import sys
from .. import config, log
def load():
"""Load list of available authentication managers."""
auth_type = config.get("auth", "type")
log.LOGGER.debug("Authentication type is %s" % auth_type)
if auth_type == "None":
return None
elif auth_type == 'custom':
auth_module = config.get("auth", "custom_handler")
__import__(auth_module)
module = sys.modules[auth_module]
else:
root_module = __import__(
"auth.%s" % auth_type, globals=globals(), level=2)
module = getattr(root_module, auth_type)
# Override auth.is_authenticated
sys.modules[__name__].is_authenticated = module.is_authenticated
return module
def is_authenticated(user, password):
"""Check if the user is authenticated.
This method is overriden if an auth module is loaded.
"""
return True # Default is always True: no authentication

View File

@ -1,61 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2011 Henry-Nicolas Tourneur
#
# 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/>.
"""
Courier-Authdaemon authentication.
"""
import sys
import socket
from .. import config, log
COURIER_SOCKET = config.get("auth", "courier_socket")
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
if not user or not password:
return False
line = "%s\nlogin\n%s\n%s" % (sys.argv[0], user, password)
line = "AUTH %i\n%s" % (len(line), line)
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(COURIER_SOCKET)
log.LOGGER.debug("Sending to Courier socket the request: %s" % line)
sock.send(line)
data = sock.recv(1024)
sock.close()
except socket.error as exception:
log.LOGGER.debug(
"Unable to communicate with Courier socket: %s" % exception)
return False
log.LOGGER.debug("Got Courier socket response: %r" % data)
# Address, HOME, GID, and either UID or USERNAME are mandatory in resposne
# see http://www.courier-mta.org/authlib/README_authlib.html#authpipeproto
for line in data.split():
if "GID" in line:
return True
# default is reject
# this alleviates the problem of a possibly empty reply from authlib
# see http://www.courier-mta.org/authlib/README_authlib.html#authpipeproto
return False

View File

@ -1,41 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012 Ehsanul Hoque
# Copyright © 2013 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/>.
"""
HTTP authentication.
Authentication based on the ``requests`` module.
Post a request to an authentication server with the username/password.
Anything other than a 200/201 response is considered auth failure.
"""
import requests
from .. import config, log
AUTH_URL = config.get("auth", "http_url")
USER_PARAM = config.get("auth", "http_user_parameter")
PASSWORD_PARAM = config.get("auth", "http_password_parameter")
def is_authenticated(user, password):
"""Check if ``user``/``password`` couple is valid."""
log.LOGGER.debug("HTTP-based auth on %s." % AUTH_URL)
payload = {USER_PARAM: user, PASSWORD_PARAM: password}
return requests.post(AUTH_URL, data=payload).status_code in (200, 201)

View File

@ -1,29 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012 Ehsanul Hoque
# Copyright © 2013 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/>.
"""
Trusting the HTTP server auth mechanism.
"""
from .. import log
def is_authenticated(user, password):
"""Check if ``user`` is defined and assuming it's valid."""
log.LOGGER.debug("Got user %r from HTTP server." % user)
return user is not None

View File

@ -45,43 +45,22 @@ INITIAL_CONFIG = {
"can_skip_base_prefix": "False", "can_skip_base_prefix": "False",
"realm": "Radicale - Password Required"}, "realm": "Radicale - Password Required"},
"well-known": { "well-known": {
"caldav": "/%(user)s/caldav/", "caldav": "/caldav/",
"carddav": "/%(user)s/carddav/"}, "carddav": "/carddav/"},
"encoding": { "encoding": {
"request": "utf-8", "request": "utf-8",
"stock": "utf-8"}, "stock": "utf-8"},
"auth": { "auth": {
"type": "None", "type": "None",
"custom_handler": "",
"htpasswd_filename": "/etc/radicale/users", "htpasswd_filename": "/etc/radicale/users",
"htpasswd_encryption": "crypt", "htpasswd_encryption": "crypt"},
"imap_hostname": "localhost",
"imap_port": "143",
"imap_ssl": "False",
"ldap_url": "ldap://localhost:389/",
"ldap_base": "ou=users,dc=example,dc=com",
"ldap_attribute": "uid",
"ldap_filter": "",
"ldap_binddn": "",
"ldap_password": "",
"ldap_scope": "OneLevel",
"pam_group_membership": "",
"courier_socket": "",
"http_url": "",
"http_user_parameter": "",
"http_password_parameter": ""},
"git": {
"committer": "Radicale <radicale@example.com>"},
"rights": { "rights": {
"type": "None", "type": "None",
"custom_handler": "",
"file": "~/.config/radicale/rights"}, "file": "~/.config/radicale/rights"},
"storage": { "storage": {
"type": "filesystem", "type": "multifilesystem",
"custom_handler": "",
"filesystem_folder": os.path.expanduser( "filesystem_folder": os.path.expanduser(
"~/.config/radicale/collections"), "~/.config/radicale/collections")},
"database_url": ""},
"logging": { "logging": {
"config": "/etc/radicale/logging", "config": "/etc/radicale/logging",
"debug": "False", "debug": "False",

View File

@ -49,8 +49,7 @@ def is_safe_path_component(path):
""" """
if not path: if not path:
return False return False
head, _ = posixpath.split(path) if posixpath.split(path)[0]:
if head:
return False return False
if path in (".", ".."): if path in (".", ".."):
return False return False

View File

@ -1,7 +1,5 @@
# This file is part of Radicale Server - Calendar Server # This file is part of Radicale Server - Calendar Server
# Copyright © 2008 Nicolas Kandel # Copyright © 2012-2016 Guillaume Ayoub
# Copyright © 2008 Pascal Halter
# Copyright © 2008-2016 Guillaume Ayoub
# #
# This library is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
@ -17,10 +15,13 @@
# along with Radicale. If not, see <http://www.gnu.org/licenses/>. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
""" """
Rights management. Rights backends.
Rights are based on a regex-based file whose name is specified in the config This module loads the rights backend, according to the rights
(section "right", key "file"). 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 Authentication login is matched against the "user" key, and collection's path
is matched against the "collection" key. You can use Python's ConfigParser is matched against the "collection" key. You can use Python's ConfigParser
@ -36,19 +37,26 @@ Leading or ending slashes are trimmed from collection's path.
""" """
import os.path
import re import re
import sys import sys
import os.path from configparser import ConfigParser
from io import StringIO
from .. import config, log from . import config, log
# Manage Python2/3 different modules
if sys.version_info[0] == 2: def _load():
from ConfigParser import ConfigParser """Load the rights manager chosen in configuration."""
from StringIO import StringIO rights_type = config.get("rights", "type")
else: if rights_type == "None":
from configparser import ConfigParser sys.modules[__name__].authorized = (
from io import StringIO lambda user, collection, permission: True)
elif rights_type in DEFINED_RIGHTS or rights_type == "from_file":
pass # authorized is already defined
else:
__import__(rights_type)
sys.modules[__name__].authorized = sys.modules[rights_type].authorized
DEFINED_RIGHTS = { DEFINED_RIGHTS = {

View File

@ -1,50 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012-2016 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.
"""
import sys
from .. import config
def load():
"""Load list of available storage managers."""
storage_type = config.get("rights", "type")
if storage_type == "custom":
rights_module = config.get("rights", "custom_handler")
__import__(rights_module)
module = sys.modules[rights_module]
else:
root_module = __import__("rights.regex", globals=globals(), level=2)
module = root_module.regex
sys.modules[__name__].authorized = module.authorized
return module
def authorized(user, collection, right):
"""Check that an user has rights on a collection.
This method is overriden when the appropriate rights backend is loaded.
"""
raise NotImplementedError()

View File

@ -1,6 +1,6 @@
# This file is part of Radicale Server - Calendar Server # This file is part of Radicale Server - Calendar Server
# Copyright © 2014 Jean-Marc Martins # Copyright © 2014 Jean-Marc Martins
# Copyright © 2014-2016 Guillaume Ayoub # Copyright © 2012-2016 Guillaume Ayoub
# #
# This library is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
@ -16,50 +16,88 @@
# along with Radicale. If not, see <http://www.gnu.org/licenses/>. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
""" """
Multi files per calendar filesystem storage backend. Storage backends.
This module loads the storage backend, according to the storage
configuration.
Default storage uses one folder per collection and one file per collection
entry.
""" """
import os
import json import json
import os
import posixpath
import shutil import shutil
import time
import sys import sys
import time
from contextlib import contextmanager from contextlib import contextmanager
from . import filesystem from . import config, ical, log, pathutils
from .. import ical
from .. import log
from .. import pathutils
class Collection(filesystem.Collection): def _load():
"""Load the storage manager chosen in configuration."""
storage_type = config.get("storage", "type")
if storage_type == "multifilesystem":
module = sys.modules[__name__]
else:
__import__(storage_type)
module = sys.modules[storage_type]
ical.Collection = module.Collection
FOLDER = os.path.expanduser(config.get("storage", "filesystem_folder"))
FILESYSTEM_ENCODING = sys.getfilesystemencoding()
@contextmanager
def _open(path, mode="r"):
"""Open a file at ``path`` with encoding set in the configuration."""
abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
with open(abs_path, mode, encoding=config.get("encoding", "stock")) as fd:
yield fd
class Collection(ical.Collection):
"""Collection stored in several files per calendar.""" """Collection stored in several files per calendar."""
@property
def _filesystem_path(self):
"""Absolute path of the file at local ``path``."""
return pathutils.path_to_filesystem(self.path, FOLDER)
@property
def _props_path(self):
"""Absolute path of the file storing the collection properties."""
return self._filesystem_path + ".props"
def _create_dirs(self): def _create_dirs(self):
"""Create folder storing the collection if absent."""
if not os.path.exists(self._filesystem_path): if not os.path.exists(self._filesystem_path):
os.makedirs(self._filesystem_path) os.makedirs(self._filesystem_path)
def save(self, text):
self._create_dirs()
item_types = (
ical.Timezone, ical.Event, ical.Todo, ical.Journal, ical.Card)
for name, component in self._parse(text, item_types).items():
if not pathutils.is_safe_filesystem_path_component(name):
# TODO: Timezones with slashes can't be saved
log.LOGGER.debug(
"Can't tranlate name safely to filesystem, "
"skipping component: %s", name)
continue
filename = os.path.join(self._filesystem_path, name)
with _open(filename, "w") as fd:
fd.write(component.text)
@property @property
def headers(self): def headers(self):
return ( return (
ical.Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"), ical.Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"),
ical.Header("VERSION:%s" % self.version)) ical.Header("VERSION:%s" % self.version))
def write(self):
self._create_dirs()
for component in self.components:
text = ical.serialize(
self.tag, self.headers, [component] + self.timezones)
name = component.name
if not pathutils.is_safe_filesystem_path_component(name):
log.LOGGER.debug(
"Can't tranlate name safely to filesystem, "
"skipping component: %s", name)
continue
filesystem_path = os.path.join(self._filesystem_path, name)
with filesystem.open(filesystem_path, "w") as fd:
fd.write(text)
def delete(self): def delete(self):
shutil.rmtree(self._filesystem_path) shutil.rmtree(self._filesystem_path)
os.remove(self._props_path) os.remove(self._props_path)
@ -92,7 +130,7 @@ class Collection(filesystem.Collection):
for filename in filenames: for filename in filenames:
path = os.path.join(self._filesystem_path, filename) path = os.path.join(self._filesystem_path, filename)
try: try:
with filesystem.open(path) as fd: with _open(path) as fd:
items.update(self._parse(fd.read(), components)) items.update(self._parse(fd.read(), components))
except (OSError, IOError) as e: except (OSError, IOError) as e:
log.LOGGER.warning( log.LOGGER.warning(
@ -101,16 +139,30 @@ class Collection(filesystem.Collection):
return ical.serialize( return ical.serialize(
self.tag, self.headers, sorted(items.values(), key=lambda x: x.name)) self.tag, self.headers, sorted(items.values(), key=lambda x: x.name))
@classmethod
def children(cls, path):
filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
_, directories, files = next(os.walk(filesystem_path))
for filename in directories + files:
# make sure that the local filename can be translated
# into an internal path
if not pathutils.is_safe_path_component(filename):
log.LOGGER.debug("Skipping unsupported filename: %s", filename)
continue
rel_filename = posixpath.join(path, filename)
if cls.is_node(rel_filename) or cls.is_leaf(rel_filename):
yield cls(rel_filename)
@classmethod @classmethod
def is_node(cls, path): def is_node(cls, path):
filesystem_path = pathutils.path_to_filesystem(path, filesystem.FOLDER) filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
return ( return (
os.path.isdir(filesystem_path) and os.path.isdir(filesystem_path) and
not os.path.exists(filesystem_path + ".props")) not os.path.exists(filesystem_path + ".props"))
@classmethod @classmethod
def is_leaf(cls, path): def is_leaf(cls, path):
filesystem_path = pathutils.path_to_filesystem(path, filesystem.FOLDER) filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
return ( return (
os.path.isdir(filesystem_path) and os.path.exists(path + ".props")) os.path.isdir(filesystem_path) and os.path.exists(path + ".props"))
@ -132,8 +184,7 @@ class Collection(filesystem.Collection):
old_properties = properties.copy() old_properties = properties.copy()
yield properties yield properties
# On exit # On exit
if os.path.exists(self._props_path): self._create_dirs()
self._create_dirs() if old_properties != properties:
if old_properties != properties: with open(self._props_path, "w") as prop_file:
with open(self._props_path, "w") as prop_file: json.dump(properties, prop_file)
json.dump(properties, prop_file)

View File

@ -1,42 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012-2016 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/>.
"""
Storage backends.
This module loads the storage backend, according to the storage
configuration.
"""
import sys
from .. import config, ical
def load():
"""Load list of available storage managers."""
storage_type = config.get("storage", "type")
if storage_type == "custom":
storage_module = config.get("storage", "custom_handler")
__import__(storage_module)
module = sys.modules[storage_module]
else:
root_module = __import__(
"storage.%s" % storage_type, globals=globals(), level=2)
module = getattr(root_module, storage_type)
ical.Collection = module.Collection
return module

View File

@ -1,282 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2013 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/>.
"""
SQLAlchemy storage backend.
"""
import time
from datetime import datetime
from contextlib import contextmanager
from sqlalchemy import create_engine, Column, Unicode, Integer, ForeignKey
from sqlalchemy import func
from sqlalchemy.orm import sessionmaker, relationship
from sqlalchemy.ext.declarative import declarative_base
from .. import config, ical
# These are classes, not constants
# pylint: disable=C0103
Base = declarative_base()
Session = sessionmaker()
Session.configure(bind=create_engine(config.get("storage", "database_url")))
# pylint: enable=C0103
class DBCollection(Base):
"""Table of collections."""
__tablename__ = "collection"
path = Column(Unicode, primary_key=True)
parent_path = Column(Unicode, ForeignKey("collection.path"))
parent = relationship(
"DBCollection", backref="children", remote_side=[path])
class DBItem(Base):
"""Table of collection's items."""
__tablename__ = "item"
name = Column(Unicode, primary_key=True)
tag = Column(Unicode)
collection_path = Column(Unicode, ForeignKey("collection.path"))
collection = relationship("DBCollection", backref="items")
class DBHeader(Base):
"""Table of item's headers."""
__tablename__ = "header"
name = Column(Unicode, primary_key=True)
value = Column(Unicode)
collection_path = Column(
Unicode, ForeignKey("collection.path"), primary_key=True)
collection = relationship("DBCollection", backref="headers")
class DBLine(Base):
"""Table of item's lines."""
__tablename__ = "line"
name = Column(Unicode)
value = Column(Unicode)
item_name = Column(Unicode, ForeignKey("item.name"))
timestamp = Column(
Integer, default=lambda: time.time() * 10 ** 6, primary_key=True)
item = relationship("DBItem", backref="lines", order_by=timestamp)
class DBProperty(Base):
"""Table of collection's properties."""
__tablename__ = "property"
name = Column(Unicode, primary_key=True)
value = Column(Unicode)
collection_path = Column(
Unicode, ForeignKey("collection.path"), primary_key=True)
collection = relationship(
"DBCollection", backref="properties", cascade="delete")
class Collection(ical.Collection):
"""Collection stored in a database."""
def __init__(self, path, principal=False):
self.session = Session()
super().__init__(path, principal)
def __del__(self):
self.session.commit()
def _query(self, item_types):
"""Get collection's items matching ``item_types``."""
item_objects = []
for item_type in item_types:
items = (
self.session.query(DBItem)
.filter_by(collection_path=self.path, tag=item_type.tag)
.order_by(DBItem.name).all())
for item in items:
text = "\n".join(
"%s:%s" % (line.name, line.value) for line in item.lines)
item_objects.append(item_type(text, item.name))
return item_objects
@property
def _modification_time(self):
"""Collection's last modification time."""
timestamp = (
self.session.query(func.max(DBLine.timestamp))
.join(DBItem).filter_by(collection_path=self.path).first()[0])
if timestamp:
return datetime.fromtimestamp(float(timestamp) / 10 ** 6)
else:
return datetime.now()
@property
def _db_collection(self):
"""Collection's object mapped to the table line."""
return self.session.query(DBCollection).get(self.path)
def write(self):
if self._db_collection:
for item in self._db_collection.items:
for line in item.lines:
self.session.delete(line)
self.session.delete(item)
for header in self._db_collection.headers:
self.session.delete(header)
else:
db_collection = DBCollection()
db_collection.path = self.path
db_collection.parent_path = "/".join(self.path.split("/")[:-1])
self.session.add(db_collection)
for header in self.headers:
db_header = DBHeader()
db_header.name, db_header.value = header.text.split(":", 1)
db_header.collection_path = self.path
self.session.add(db_header)
for item in self.items.values():
db_item = DBItem()
db_item.name = item.name
db_item.tag = item.tag
db_item.collection_path = self.path
self.session.add(db_item)
for line in ical.unfold(item.text):
db_line = DBLine()
db_line.name, db_line.value = line.split(":", 1)
db_line.item_name = item.name
self.session.add(db_line)
def delete(self):
self.session.delete(self._db_collection)
@property
def text(self):
return ical.serialize(self.tag, self.headers, self.components)
@property
def etag(self):
return '"%s"' % hash(self._modification_time)
@property
def headers(self):
headers = (
self.session.query(DBHeader)
.filter_by(collection_path=self.path)
.order_by(DBHeader.name).all())
return [
ical.Header("%s:%s" % (header.name, header.value))
for header in headers]
@classmethod
def children(cls, path):
session = Session()
children = (
session.query(DBCollection)
.filter_by(parent_path=path or "").all())
collections = [cls(child.path) for child in children]
session.close()
return collections
@classmethod
def is_node(cls, path):
if not path:
return True
session = Session()
result = (
session.query(DBCollection)
.filter_by(parent_path=path or "").count() > 0)
session.close()
return result
@classmethod
def is_leaf(cls, path):
if not path:
return False
session = Session()
result = (
session.query(DBItem)
.filter_by(collection_path=path or "").count() > 0)
session.close()
return result
@property
def last_modified(self):
return time.strftime(
"%a, %d %b %Y %H:%M:%S +0000", self._modification_time.timetuple())
@property
@contextmanager
def props(self):
# On enter
properties = {}
db_properties = (
self.session.query(DBProperty)
.filter_by(collection_path=self.path).all())
for prop in db_properties:
properties[prop.name] = prop.value
old_properties = properties.copy()
yield properties
# On exit
if old_properties != properties:
for prop in db_properties:
self.session.delete(prop)
for name, value in properties.items():
prop = DBProperty(name=name, value=value or '',
collection_path=self.path)
self.session.add(prop)
@property
def components(self):
return self._query((ical.Event, ical.Todo, ical.Journal, ical.Card))
@property
def events(self):
return self._query((ical.Event,))
@property
def todos(self):
return self._query((ical.Todo,))
@property
def journals(self):
return self._query((ical.Journal,))
@property
def timezones(self):
return self._query((ical.Timezone,))
@property
def cards(self):
return self._query((ical.Card,))
def save(self):
"""Save the text into the collection.
This method is not used for databases.
"""

View File

@ -1,142 +0,0 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2012-2016 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/>.
"""
Filesystem storage backend.
"""
import codecs
import os
import posixpath
import json
import time
import sys
from contextlib import contextmanager
from .. import config, ical, log, pathutils
FOLDER = os.path.expanduser(config.get("storage", "filesystem_folder"))
FILESYSTEM_ENCODING = sys.getfilesystemencoding()
try:
from dulwich.repo import Repo
GIT_REPOSITORY = Repo(FOLDER)
except:
GIT_REPOSITORY = None
# This function overrides the builtin ``open`` function for this module
# pylint: disable=W0622
@contextmanager
def open(path, mode="r"):
"""Open a file at ``path`` with encoding set in the configuration."""
# On enter
abs_path = os.path.join(FOLDER, path.replace("/", os.sep))
with codecs.open(abs_path, mode, config.get("encoding", "stock")) as fd:
yield fd
# On exit
if GIT_REPOSITORY and mode == "w":
path = os.path.relpath(abs_path, FOLDER)
GIT_REPOSITORY.stage([path])
committer = config.get("git", "committer")
GIT_REPOSITORY.do_commit(
path.encode("utf-8"), committer=committer.encode("utf-8"))
# pylint: enable=W0622
class Collection(ical.Collection):
"""Collection stored in a flat ical file."""
@property
def _filesystem_path(self):
"""Absolute path of the file at local ``path``."""
return pathutils.path_to_filesystem(self.path, FOLDER)
@property
def _props_path(self):
"""Absolute path of the file storing the collection properties."""
return self._filesystem_path + ".props"
def _create_dirs(self):
"""Create folder storing the collection if absent."""
if not os.path.exists(os.path.dirname(self._filesystem_path)):
os.makedirs(os.path.dirname(self._filesystem_path))
def save(self, text):
self._create_dirs()
with open(self._filesystem_path, "w") as fd:
fd.write(text)
def delete(self):
os.remove(self._filesystem_path)
os.remove(self._props_path)
@property
def text(self):
try:
with open(self._filesystem_path) as fd:
return fd.read()
except IOError:
return ""
@classmethod
def children(cls, path):
filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
_, directories, files = next(os.walk(filesystem_path))
for filename in directories + files:
# make sure that the local filename can be translated
# into an internal path
if not pathutils.is_safe_path_component(filename):
log.LOGGER.debug("Skipping unsupported filename: %s", filename)
continue
rel_filename = posixpath.join(path, filename)
if cls.is_node(rel_filename) or cls.is_leaf(rel_filename):
yield cls(rel_filename)
@classmethod
def is_node(cls, path):
filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
return os.path.isdir(filesystem_path)
@classmethod
def is_leaf(cls, path):
filesystem_path = pathutils.path_to_filesystem(path, FOLDER)
return (
os.path.isfile(filesystem_path) and not
filesystem_path.endswith(".props"))
@property
def last_modified(self):
modification_time = time.gmtime(
os.path.getmtime(self._filesystem_path))
return time.strftime("%a, %d %b %Y %H:%M:%S +0000", modification_time)
@property
@contextmanager
def props(self):
# On enter
properties = {}
if os.path.exists(self._props_path):
with open(self._props_path) as prop_file:
properties.update(json.load(prop_file))
old_properties = properties.copy()
yield properties
# On exit
self._create_dirs()
if old_properties != properties:
with open(self._props_path, "w") as prop_file:
json.dump(properties, prop_file)