diff --git a/config b/config index d1a5405..f3a3a46 100644 --- a/config +++ b/config @@ -73,12 +73,9 @@ [auth] # Authentication method -# Value: None | htpasswd | IMAP | LDAP | PAM | courier | http | remote_user | custom +# Value: None | htpasswd #type = None -# Custom authentication handler -#custom_handler = - # Htpasswd filename #htpasswd_filename = /etc/radicale/users @@ -86,65 +83,13 @@ # Value: plain | sha1 | ssha | crypt | bcrypt | md5 #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 - [rights] # Rights backend -# Value: None | authenticated | owner_only | owner_write | from_file | custom +# Value: None | authenticated | owner_only | owner_write | from_file #type = None -# Custom rights handler -#custom_handler = - # File for rights management from_file #file = ~/.config/radicale/rights @@ -152,25 +97,12 @@ [storage] # Storage backend -# ------- -# WARNING: ONLY "filesystem" IS DOCUMENTED AND TESTED, -# OTHER BACKENDS ARE NOT READY FOR PRODUCTION. -# ------- -# Value: filesystem | multifilesystem | database | custom +# Value: multifilesystem #type = filesystem -# Custom storage handler -#custom_handler = - # Folder for storing local collections, created if not present #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] diff --git a/radicale/auth/htpasswd.py b/radicale/auth.py similarity index 90% rename from radicale/auth/htpasswd.py rename to radicale/auth.py index f65a779..eb66d90 100644 --- a/radicale/auth/htpasswd.py +++ b/radicale/auth.py @@ -17,7 +17,9 @@ # along with Radicale. If not, see . """ -Implement htpasswd authentication. +Authentication management. + +Default is htpasswd authentication. Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html) manages a file for storing user credentials. It can encrypt passwords using different @@ -50,13 +52,26 @@ following significantly more secure schemes are parsable by Radicale: """ - import base64 import hashlib 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")) @@ -144,7 +159,7 @@ elif ENCRYPTION == "crypt": if ENCRYPTION not in _verifuncs: raise RuntimeError(("The htpasswd encryption method '%s' is not " "supported." % ENCRYPTION)) - + def is_authenticated(user, password): """Validate credentials. diff --git a/radicale/auth/IMAP.py b/radicale/auth/IMAP.py deleted file mode 100644 index 088a9b9..0000000 --- a/radicale/auth/IMAP.py +++ /dev/null @@ -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 . - -""" -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 diff --git a/radicale/auth/LDAP.py b/radicale/auth/LDAP.py deleted file mode 100644 index 732a93f..0000000 --- a/radicale/auth/LDAP.py +++ /dev/null @@ -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 . - -""" -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 diff --git a/radicale/auth/PAM.py b/radicale/auth/PAM.py deleted file mode 100644 index b0178d5..0000000 --- a/radicale/auth/PAM.py +++ /dev/null @@ -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 . - -""" -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 diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py deleted file mode 100644 index 0ecafe8..0000000 --- a/radicale/auth/__init__.py +++ /dev/null @@ -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 . - -""" -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 diff --git a/radicale/auth/courier.py b/radicale/auth/courier.py deleted file mode 100644 index 77d9482..0000000 --- a/radicale/auth/courier.py +++ /dev/null @@ -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 . - -""" -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 diff --git a/radicale/auth/http.py b/radicale/auth/http.py deleted file mode 100644 index 0ab524f..0000000 --- a/radicale/auth/http.py +++ /dev/null @@ -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 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) diff --git a/radicale/auth/remote_user.py b/radicale/auth/remote_user.py deleted file mode 100644 index 46ed24e..0000000 --- a/radicale/auth/remote_user.py +++ /dev/null @@ -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 . - -""" -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 diff --git a/radicale/config.py b/radicale/config.py index 518c7d4..b31e76d 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -45,43 +45,22 @@ INITIAL_CONFIG = { "can_skip_base_prefix": "False", "realm": "Radicale - Password Required"}, "well-known": { - "caldav": "/%(user)s/caldav/", - "carddav": "/%(user)s/carddav/"}, + "caldav": "/caldav/", + "carddav": "/carddav/"}, "encoding": { "request": "utf-8", "stock": "utf-8"}, "auth": { "type": "None", - "custom_handler": "", "htpasswd_filename": "/etc/radicale/users", - "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 "}, + "htpasswd_encryption": "crypt"}, "rights": { "type": "None", - "custom_handler": "", "file": "~/.config/radicale/rights"}, "storage": { - "type": "filesystem", - "custom_handler": "", + "type": "multifilesystem", "filesystem_folder": os.path.expanduser( - "~/.config/radicale/collections"), - "database_url": ""}, + "~/.config/radicale/collections")}, "logging": { "config": "/etc/radicale/logging", "debug": "False", diff --git a/radicale/pathutils.py b/radicale/pathutils.py index 3cb355c..cddb11d 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -49,8 +49,7 @@ def is_safe_path_component(path): """ if not path: return False - head, _ = posixpath.split(path) - if head: + if posixpath.split(path)[0]: return False if path in (".", ".."): return False diff --git a/radicale/rights/regex.py b/radicale/rights.py similarity index 81% rename from radicale/rights/regex.py rename to radicale/rights.py index 0b6d7df..9c40bc5 100644 --- a/radicale/rights/regex.py +++ b/radicale/rights.py @@ -1,7 +1,5 @@ # This file is part of Radicale Server - Calendar Server -# Copyright © 2008 Nicolas Kandel -# Copyright © 2008 Pascal Halter -# Copyright © 2008-2016 Guillaume Ayoub +# 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 @@ -17,10 +15,13 @@ # along with Radicale. If not, see . """ -Rights management. +Rights backends. -Rights are based on a regex-based file whose name is specified in the config -(section "right", key "file"). +This module loads the rights backend, according to the rights +configuration. + +Default rights are based on a regex-based file whose name is specified in the +config (section "right", key "file"). Authentication login is matched against the "user" key, and collection's path is matched against the "collection" key. You can use Python's ConfigParser @@ -36,19 +37,26 @@ Leading or ending slashes are trimmed from collection's path. """ +import os.path import re 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: - from ConfigParser import ConfigParser - from StringIO import StringIO -else: - from configparser import ConfigParser - from io import StringIO + +def _load(): + """Load the rights manager chosen in configuration.""" + rights_type = config.get("rights", "type") + if rights_type == "None": + sys.modules[__name__].authorized = ( + 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 = { diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py deleted file mode 100644 index a04bcd8..0000000 --- a/radicale/rights/__init__.py +++ /dev/null @@ -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 . - -""" -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() diff --git a/radicale/storage/multifilesystem.py b/radicale/storage.py similarity index 57% rename from radicale/storage/multifilesystem.py rename to radicale/storage.py index 327ec92..8f07904 100644 --- a/radicale/storage/multifilesystem.py +++ b/radicale/storage.py @@ -1,6 +1,6 @@ # This file is part of Radicale Server - Calendar Server # 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 # it under the terms of the GNU General Public License as published by @@ -16,50 +16,88 @@ # along with Radicale. If not, see . """ -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 os +import posixpath import shutil -import time import sys +import time from contextlib import contextmanager -from . import filesystem -from .. import ical -from .. import log -from .. import pathutils +from . import config, ical, log, 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.""" + @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(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 def headers(self): return ( ical.Header("PRODID:-//Radicale//NONSGML Radicale Server//EN"), 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): shutil.rmtree(self._filesystem_path) os.remove(self._props_path) @@ -92,7 +130,7 @@ class Collection(filesystem.Collection): for filename in filenames: path = os.path.join(self._filesystem_path, filename) try: - with filesystem.open(path) as fd: + with _open(path) as fd: items.update(self._parse(fd.read(), components)) except (OSError, IOError) as e: log.LOGGER.warning( @@ -101,16 +139,30 @@ class Collection(filesystem.Collection): return ical.serialize( 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 def is_node(cls, path): - filesystem_path = pathutils.path_to_filesystem(path, filesystem.FOLDER) + filesystem_path = pathutils.path_to_filesystem(path, FOLDER) return ( os.path.isdir(filesystem_path) and not os.path.exists(filesystem_path + ".props")) @classmethod def is_leaf(cls, path): - filesystem_path = pathutils.path_to_filesystem(path, filesystem.FOLDER) + filesystem_path = pathutils.path_to_filesystem(path, FOLDER) return ( os.path.isdir(filesystem_path) and os.path.exists(path + ".props")) @@ -132,8 +184,7 @@ class Collection(filesystem.Collection): old_properties = properties.copy() yield properties # On exit - if os.path.exists(self._props_path): - self._create_dirs() - if old_properties != properties: - with open(self._props_path, "w") as prop_file: - json.dump(properties, prop_file) + self._create_dirs() + if old_properties != properties: + with open(self._props_path, "w") as prop_file: + json.dump(properties, prop_file) diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py deleted file mode 100644 index d477123..0000000 --- a/radicale/storage/__init__.py +++ /dev/null @@ -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 . - -""" -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 diff --git a/radicale/storage/database.py b/radicale/storage/database.py deleted file mode 100644 index 7007601..0000000 --- a/radicale/storage/database.py +++ /dev/null @@ -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 . - -""" -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. - - """ diff --git a/radicale/storage/filesystem.py b/radicale/storage/filesystem.py deleted file mode 100644 index 293560a..0000000 --- a/radicale/storage/filesystem.py +++ /dev/null @@ -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 . - -""" -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)