Remove extra auth, rights and storage modules
This commit is contained in:
parent
1c4acc44a8
commit
1001bcb676
74
config
74
config
@ -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]
|
||||||
|
|
||||||
|
@ -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"))
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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 = {
|
@ -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()
|
|
@ -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)
|
|
@ -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
|
|
@ -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.
|
|
||||||
|
|
||||||
"""
|
|
@ -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)
|
|
Loading…
Reference in New Issue
Block a user