Sync with LDAP
This commit is contained in:
parent
6a56a6026f
commit
fdc595693c
16
Dockerfile
16
Dockerfile
|
|
@ -5,13 +5,19 @@ FROM python:3-alpine
|
|||
# Version of Radicale (e.g. v3)
|
||||
ARG VERSION=master
|
||||
# Persistent storage for data
|
||||
VOLUME /var/lib/radicale
|
||||
COPY radicale ./radicale
|
||||
COPY radicale3-auth-ldap ./radicale3-auth-ldap
|
||||
COPY config ./config
|
||||
COPY requirements.txt ./requirements.txt
|
||||
COPY main.py ./main.py
|
||||
|
||||
# TCP port of Radicale
|
||||
EXPOSE 5232
|
||||
# Run Radicale
|
||||
CMD ["radicale", "--hosts", "0.0.0.0:5232"]
|
||||
|
||||
RUN apk add --no-cache ca-certificates openssl \
|
||||
&& apk add --no-cache --virtual .build-deps gcc libffi-dev musl-dev \
|
||||
&& pip install --no-cache-dir "Radicale[bcrypt] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz" \
|
||||
&& apk del .build-deps
|
||||
&& pip install "./radicale3-auth-ldap"
|
||||
|
||||
RUN pip3 install -r requirements.txt
|
||||
|
||||
CMD ["python", "main.py", "--config", "./config", "--debug"]
|
||||
|
|
|
|||
|
|
@ -18,3 +18,5 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV
|
|||
|
||||
For the complete documentation, please visit
|
||||
[Radicale master Documentation](https://radicale.org/master.html).
|
||||
|
||||
This fork using LDAP for sync CardDAV contacts and check LDAP authorization with this plugin [radicale3-auth-ldap.](https://github.com/jdupouy/radicale3-auth-ldap)
|
||||
|
|
|
|||
39
config
39
config
|
|
@ -15,7 +15,7 @@
|
|||
# IPv4 syntax: address:port
|
||||
# IPv6 syntax: [address]:port
|
||||
# For example: 0.0.0.0:9999, [::]:9999
|
||||
#hosts = localhost:5232
|
||||
hosts = 0.0.0.0:5232, [::]:5232
|
||||
|
||||
# Max parallel connections
|
||||
#max_connections = 8
|
||||
|
|
@ -50,24 +50,35 @@
|
|||
|
||||
|
||||
[auth]
|
||||
type = radicale3_auth_ldap
|
||||
|
||||
# Authentication method
|
||||
# Value: none | htpasswd | remote_user | http_x_remote_user
|
||||
#type = none
|
||||
# LDAP server URL, with protocol and port
|
||||
ldap_url = ldap://ldap:389
|
||||
|
||||
# Htpasswd filename
|
||||
#htpasswd_filename = /etc/radicale/users
|
||||
# LDAP base path
|
||||
ldap_base = ou=Users,dc=TESTDOMAIN
|
||||
|
||||
# Htpasswd encryption method
|
||||
# Value: plain | bcrypt | md5
|
||||
# bcrypt requires the installation of radicale[bcrypt].
|
||||
#htpasswd_encryption = md5
|
||||
# LDAP login attribute
|
||||
ldap_attribute = uid
|
||||
|
||||
# Incorrect authentication delay (seconds)
|
||||
#delay = 1
|
||||
# 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)
|
||||
ldap_filter = (objectClass=person)
|
||||
|
||||
# Message displayed in the client when a password is needed
|
||||
#realm = Radicale - Password Required
|
||||
# LDAP dn for initial login, used if LDAP server does not allow anonymous searches
|
||||
# Leave empty if searches are anonymous
|
||||
ldap_binddn = cn=admin,dc=TESTDOMAIN
|
||||
|
||||
# LDAP password for initial login, used with ldap_binddn
|
||||
ldap_password = verysecurepassword
|
||||
|
||||
# LDAP scope of the search
|
||||
ldap_scope = LEVEL
|
||||
|
||||
# LDAP extended option
|
||||
# If the server is samba, ldap_support_extended is should be no
|
||||
ldap_support_extended = yes
|
||||
|
||||
|
||||
[rights]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
ADDRESS_BOOK_NAME=
|
||||
LDAP_URL=
|
||||
LDAP_BASE=
|
||||
LDAP_FILTER=
|
||||
LDAP_BINDDN=
|
||||
LDAP_PASSWORD=
|
||||
REDIS_CACHE_LIVE_SECONDS=7200
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import sys
|
||||
|
||||
from radicale.__main__ import run
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(run())
|
||||
|
|
@ -71,6 +71,11 @@ def xml_propfind(base_prefix: str, path: str,
|
|||
multistatus.append(xml_propfind_response(
|
||||
base_prefix, path, item, props, user, encoding, write=write,
|
||||
allprop=allprop, propname=propname))
|
||||
if len(path) > len("/") and isinstance(item, storage.BaseCollection):
|
||||
for i in item.get_all():
|
||||
multistatus.append(xml_propfind_response(
|
||||
base_prefix, path, i, props, user, encoding, write=write,
|
||||
allprop=allprop, propname=propname))
|
||||
|
||||
return multistatus
|
||||
|
||||
|
|
@ -282,10 +287,7 @@ def xml_propfind_response(
|
|||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("D:sync-token"):
|
||||
if is_leaf:
|
||||
element.text, _ = collection.sync()
|
||||
else:
|
||||
is404 = True
|
||||
element.text = "1213"
|
||||
else:
|
||||
human_tag = xmlutils.make_human_tag(tag)
|
||||
tag_text = collection.get_meta(human_tag)
|
||||
|
|
@ -323,7 +325,7 @@ def xml_propfind_response(
|
|||
class ApplicationPartPropfind(ApplicationBase):
|
||||
|
||||
def _collect_allowed_items(
|
||||
self, items: Iterable[types.CollectionOrItem], user: str
|
||||
self, items: Iterable[types.CollectionOrItem], user: str, dept: str,
|
||||
) -> Iterator[Tuple[types.CollectionOrItem, str]]:
|
||||
"""Get items from request that user is allowed to access."""
|
||||
for item in items:
|
||||
|
|
@ -352,6 +354,10 @@ class ApplicationPartPropfind(ApplicationBase):
|
|||
else:
|
||||
permission = ""
|
||||
status = "NO"
|
||||
if isinstance(item, storage.BaseCollection) \
|
||||
and ((item.path.startswith("touchin") and item.get_meta("D:displayname") == "touchin") or item.path == "touchin"):
|
||||
permission = "r"
|
||||
status = "read"
|
||||
logger.debug(
|
||||
"%s has %s access to %s",
|
||||
repr(user) if user else "anonymous user", status, target)
|
||||
|
|
@ -361,9 +367,11 @@ class ApplicationPartPropfind(ApplicationBase):
|
|||
def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
"""Manage PROPFIND request."""
|
||||
access = Access(self._rights, user, path)
|
||||
if not access.check("r"):
|
||||
return httputils.NOT_ALLOWED
|
||||
if path not in ("/", "", "/touchin/", "/touchin"):
|
||||
attributes = path.split("/")
|
||||
attributes[1] = "touchin"
|
||||
path = "/".join(attributes)
|
||||
|
||||
try:
|
||||
xml_content = self._read_xml_request_body(environ)
|
||||
except RuntimeError as e:
|
||||
|
|
@ -380,11 +388,11 @@ class ApplicationPartPropfind(ApplicationBase):
|
|||
item = next(items_iter, None)
|
||||
if not item:
|
||||
return httputils.NOT_FOUND
|
||||
if not access.check("r", item):
|
||||
return httputils.NOT_ALLOWED
|
||||
# if access and not access.check("r", item):
|
||||
# return httputils.NOT_ALLOWED
|
||||
# put item back
|
||||
items_iter = itertools.chain([item], items_iter)
|
||||
allowed_items = self._collect_allowed_items(items_iter, user)
|
||||
allowed_items = self._collect_allowed_items(items_iter, user, environ.get("HTTP_DEPTH", "0"))
|
||||
headers = {"DAV": httputils.DAV_HEADERS,
|
||||
"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||
xml_answer = xml_propfind(base_prefix, path, xml_content,
|
||||
|
|
|
|||
|
|
@ -45,75 +45,20 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
|||
if xml_request is None:
|
||||
return client.MULTI_STATUS, multistatus
|
||||
root = xml_request
|
||||
if root.tag in (xmlutils.make_clark("D:principal-search-property-set"),
|
||||
xmlutils.make_clark("D:principal-property-search"),
|
||||
xmlutils.make_clark("D:expand-property")):
|
||||
# We don't support searching for principals or indirect retrieving of
|
||||
# properties, just return an empty result.
|
||||
# InfCloud asks for expand-property reports (even if we don't announce
|
||||
# support for them) and stops working if an error code is returned.
|
||||
logger.warning("Unsupported REPORT method %r on %r requested",
|
||||
xmlutils.make_human_tag(root.tag), path)
|
||||
return client.MULTI_STATUS, multistatus
|
||||
if (root.tag == xmlutils.make_clark("C:calendar-multiget") and
|
||||
collection.tag != "VCALENDAR" or
|
||||
root.tag == xmlutils.make_clark("CR:addressbook-multiget") and
|
||||
collection.tag != "VADDRESSBOOK" or
|
||||
root.tag == xmlutils.make_clark("D:sync-collection") and
|
||||
collection.tag not in ("VADDRESSBOOK", "VCALENDAR")):
|
||||
logger.warning("Invalid REPORT method %r on %r requested",
|
||||
xmlutils.make_human_tag(root.tag), path)
|
||||
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
|
||||
|
||||
prop_element = root.find(xmlutils.make_clark("D:prop"))
|
||||
props = ([prop.tag for prop in prop_element]
|
||||
if prop_element is not None else [])
|
||||
|
||||
hreferences: Iterable[str]
|
||||
if root.tag in (
|
||||
xmlutils.make_clark("C:calendar-multiget"),
|
||||
xmlutils.make_clark("CR:addressbook-multiget")):
|
||||
# Read rfc4791-7.9 for info
|
||||
hreferences = set()
|
||||
for href_element in root.findall(xmlutils.make_clark("D:href")):
|
||||
temp_url_path = urlparse(href_element.text).path
|
||||
assert isinstance(temp_url_path, str)
|
||||
href_path = pathutils.sanitize_path(unquote(temp_url_path))
|
||||
if (href_path + "/").startswith(base_prefix + "/"):
|
||||
hreferences.add(href_path[len(base_prefix):])
|
||||
else:
|
||||
logger.warning("Skipping invalid path %r in REPORT request on "
|
||||
"%r", href_path, path)
|
||||
elif root.tag == xmlutils.make_clark("D:sync-collection"):
|
||||
old_sync_token_element = root.find(
|
||||
xmlutils.make_clark("D:sync-token"))
|
||||
old_sync_token = ""
|
||||
if old_sync_token_element is not None and old_sync_token_element.text:
|
||||
old_sync_token = old_sync_token_element.text.strip()
|
||||
logger.debug("Client provided sync token: %r", old_sync_token)
|
||||
try:
|
||||
sync_token, names = collection.sync(old_sync_token)
|
||||
except ValueError as e:
|
||||
# Invalid sync token
|
||||
logger.warning("Client provided invalid sync token %r: %s",
|
||||
old_sync_token, e, exc_info=True)
|
||||
# client.CONFLICT doesn't work with some clients (e.g. InfCloud)
|
||||
return (client.FORBIDDEN,
|
||||
xmlutils.webdav_error("D:valid-sync-token"))
|
||||
hreferences = (pathutils.unstrip_path(
|
||||
posixpath.join(collection.path, n)) for n in names)
|
||||
if root.tag == xmlutils.make_clark("D:sync-collection"):
|
||||
# Append current sync token to response
|
||||
sync_token_element = ET.Element(xmlutils.make_clark("D:sync-token"))
|
||||
sync_token_element.text = sync_token
|
||||
sync_token_element.text = "sync_token"
|
||||
multistatus.append(sync_token_element)
|
||||
else:
|
||||
hreferences = (path,)
|
||||
filters = (
|
||||
root.findall(xmlutils.make_clark("C:filter")) +
|
||||
root.findall(xmlutils.make_clark("CR:filter")))
|
||||
|
||||
# Retrieve everything required for finishing the request.
|
||||
retrieved_items = list(retrieve_items(
|
||||
base_prefix, path, collection, hreferences, filters, multistatus))
|
||||
retrieved_items = list(retrieve_items(collection))
|
||||
collection_tag = collection.tag
|
||||
# !!! Don't access storage after this !!!
|
||||
unlock_storage_fn()
|
||||
|
|
@ -123,17 +68,6 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
|||
# Don't keep reference to ``item``, because VObject requires a lot of
|
||||
# memory.
|
||||
item, filters_matched = retrieved_items.pop(0)
|
||||
if filters and not filters_matched:
|
||||
try:
|
||||
if not all(test_filter(collection_tag, item, filter_)
|
||||
for filter_ in filters):
|
||||
continue
|
||||
except ValueError as e:
|
||||
raise ValueError("Failed to filter item %r from %r: %s" %
|
||||
(item.href, collection.path, e)) from e
|
||||
except Exception as e:
|
||||
raise RuntimeError("Failed to filter item %r from %r: %s" %
|
||||
(item.href, collection.path, e)) from e
|
||||
|
||||
found_props = []
|
||||
not_found_props = []
|
||||
|
|
@ -194,47 +128,12 @@ def xml_item_response(base_prefix: str, href: str,
|
|||
return response
|
||||
|
||||
|
||||
def retrieve_items(
|
||||
base_prefix: str, path: str, collection: storage.BaseCollection,
|
||||
hreferences: Iterable[str], filters: Sequence[ET.Element],
|
||||
multistatus: ET.Element) -> Iterator[Tuple[radicale_item.Item, bool]]:
|
||||
def retrieve_items( collection: storage.BaseCollection) -> Iterator[Tuple[radicale_item.Item, bool]]:
|
||||
"""Retrieves all items that are referenced in ``hreferences`` from
|
||||
``collection`` and adds 404 responses for missing and invalid items
|
||||
to ``multistatus``."""
|
||||
collection_requested = False
|
||||
|
||||
def get_names() -> Iterator[str]:
|
||||
"""Extracts all names from references in ``hreferences`` and adds
|
||||
404 responses for invalid references to ``multistatus``.
|
||||
If the whole collections is referenced ``collection_requested``
|
||||
gets set to ``True``."""
|
||||
nonlocal collection_requested
|
||||
for hreference in hreferences:
|
||||
try:
|
||||
name = pathutils.name_from_path(hreference, collection)
|
||||
except ValueError as e:
|
||||
logger.warning("Skipping invalid path %r in REPORT request on "
|
||||
"%r: %s", hreference, path, e)
|
||||
response = xml_item_response(base_prefix, hreference,
|
||||
found_item=False)
|
||||
multistatus.append(response)
|
||||
continue
|
||||
if name:
|
||||
# Reference is an item
|
||||
yield name
|
||||
else:
|
||||
# Reference is a collection
|
||||
collection_requested = True
|
||||
|
||||
for name, item in collection.get_multi(get_names()):
|
||||
if not item:
|
||||
uri = pathutils.unstrip_path(posixpath.join(collection.path, name))
|
||||
response = xml_item_response(base_prefix, uri, found_item=False)
|
||||
multistatus.append(response)
|
||||
else:
|
||||
yield item, False
|
||||
if collection_requested:
|
||||
yield from collection.get_filtered(filters)
|
||||
for item in collection.get_all():
|
||||
yield item, True
|
||||
|
||||
|
||||
def test_filter(collection_tag: str, item: radicale_item.Item,
|
||||
|
|
@ -271,9 +170,6 @@ class ApplicationPartReport(ApplicationBase):
|
|||
def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
"""Manage REPORT request."""
|
||||
access = Access(self._rights, user, path)
|
||||
if not access.check("r"):
|
||||
return httputils.NOT_ALLOWED
|
||||
try:
|
||||
xml_content = self._read_xml_request_body(environ)
|
||||
except RuntimeError as e:
|
||||
|
|
@ -288,8 +184,6 @@ class ApplicationPartReport(ApplicationBase):
|
|||
item = next(iter(self._storage.discover(path)), None)
|
||||
if not item:
|
||||
return httputils.NOT_FOUND
|
||||
if not access.check("r", item):
|
||||
return httputils.NOT_ALLOWED
|
||||
if isinstance(item, storage.BaseCollection):
|
||||
collection = item
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import datetime
|
||||
import json
|
||||
import os
|
||||
|
||||
import ldap3
|
||||
|
||||
from dateutil.parser import parse
|
||||
from radicale import logger
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List
|
||||
from radicale.redis import client as redis_client
|
||||
|
||||
LDAP_FIELD_NAMES = \
|
||||
("uid", "ruSn", "ruGivenName", "mobile", "skype", "telegram", "birthdate", "mail", "title", "modifyTimestamp")
|
||||
|
||||
REDIS_CACHE_KEY = "ldap_users"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LdapUser:
|
||||
uid: str
|
||||
ruSn: Optional[str]
|
||||
ruGivenName: Optional[str]
|
||||
mobile: Optional[str]
|
||||
skype: Optional[str]
|
||||
telegram: Optional[str]
|
||||
birthdate: Optional[str]
|
||||
mail: Optional[str]
|
||||
title: Optional[str]
|
||||
modifyTimestamp: datetime.datetime
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
if key in LDAP_FIELD_NAMES:
|
||||
if isinstance(value, List) and len(value) > 0:
|
||||
setattr(self, key, value[0])
|
||||
elif not value:
|
||||
setattr(self, key, None)
|
||||
elif key == "modifyTimestamp":
|
||||
setattr(self, key, parse(value))
|
||||
|
||||
else:
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
class LdapService:
|
||||
|
||||
@staticmethod
|
||||
def _get_ldap_users() -> List:
|
||||
server = ldap3.Server(os.getenv("LDAP_URL"))
|
||||
conn = ldap3.Connection(server, os.getenv("LDAP_BINDDN"), password=os.getenv("LDAP_PASSWORD"))
|
||||
|
||||
conn.bind()
|
||||
|
||||
try:
|
||||
logger.debug("LDAP whoami: %s" % conn.extend.standard.who_am_i())
|
||||
except Exception as err:
|
||||
logger.debug("LDAP error: %s" % err)
|
||||
|
||||
conn.search(search_base=os.getenv("LDAP_BASE"),
|
||||
search_scope="LEVEL",
|
||||
search_filter=os.getenv("LDAP_FILTER"),
|
||||
attributes=LDAP_FIELD_NAMES)
|
||||
|
||||
return conn.response
|
||||
|
||||
def sync(self) -> List[LdapUser]:
|
||||
cached = redis_client.get(REDIS_CACHE_KEY)
|
||||
if cached:
|
||||
users = json.loads(cached)
|
||||
else:
|
||||
users = self._get_ldap_users()
|
||||
users = [dict(u["attributes"]) for u in users]
|
||||
json_objects = json.dumps(users, default=str)
|
||||
redis_client.setex(name=REDIS_CACHE_KEY, value=json_objects,
|
||||
time=datetime.timedelta(seconds=int(os.getenv("REDIS_CACHE_LIVE_SECONDS", 7200))))
|
||||
|
||||
return [LdapUser(**u) for u in users]
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
import redis
|
||||
|
||||
from radicale import logger
|
||||
|
||||
|
||||
def redis_connect() -> redis.client.Redis:
|
||||
try:
|
||||
redis_client = redis.Redis(
|
||||
host=os.getenv("REDIS_HOST", "localhost"),
|
||||
port=int(os.getenv("REDIS_PORT", 6379)),
|
||||
password=os.getenv("REDIS_PASSWORD"),
|
||||
db=0,
|
||||
socket_timeout=5,
|
||||
decode_responses=True,
|
||||
)
|
||||
ping = redis_client.ping()
|
||||
if ping is True:
|
||||
return redis_client
|
||||
except redis.AuthenticationError as e:
|
||||
logger.debug("Redis auth error:\n%s", e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
client = redis_connect()
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ Storage backend that stores data in the file system.
|
|||
Uses one folder per collection and one file per collection entry.
|
||||
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
from typing import ClassVar, Iterator, Optional, Type
|
||||
|
|
@ -63,14 +63,7 @@ class Collection(
|
|||
|
||||
@property
|
||||
def last_modified(self) -> str:
|
||||
def relevant_files_iter() -> Iterator[str]:
|
||||
yield self._filesystem_path
|
||||
if os.path.exists(self._props_path):
|
||||
yield self._props_path
|
||||
for href in self._list():
|
||||
yield os.path.join(self._filesystem_path, href)
|
||||
last = max(map(os.path.getmtime, relevant_files_iter()))
|
||||
return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last))
|
||||
return datetime.datetime.now().strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
|
||||
@property
|
||||
def etag(self) -> str:
|
||||
|
|
|
|||
|
|
@ -39,66 +39,26 @@ class StoragePartDiscover(StorageBase):
|
|||
Callable[[str, Optional[str]], ContextManager[None]]] = None
|
||||
) -> Iterator[types.CollectionOrItem]:
|
||||
# assert isinstance(self, multifilesystem.Storage)
|
||||
if child_context_manager is None:
|
||||
child_context_manager = _null_child_context_manager
|
||||
# Path should already be sanitized
|
||||
sane_path = pathutils.strip_path(path)
|
||||
attributes = sane_path.split("/") if sane_path else []
|
||||
|
||||
folder = self._get_collection_root_folder()
|
||||
# Create the root collection
|
||||
self._makedirs_synced(folder)
|
||||
try:
|
||||
filesystem_path = pathutils.path_to_filesystem(folder, sane_path)
|
||||
except ValueError as e:
|
||||
# Path is unsafe
|
||||
logger.debug("Unsafe path %r requested from storage: %s",
|
||||
sane_path, e, exc_info=True)
|
||||
return
|
||||
|
||||
# Check if the path exists and if it leads to a collection or an item
|
||||
href: Optional[str]
|
||||
if not os.path.isdir(filesystem_path):
|
||||
if attributes and os.path.isfile(filesystem_path):
|
||||
href = attributes.pop()
|
||||
else:
|
||||
return
|
||||
else:
|
||||
href = None
|
||||
|
||||
sane_path = "/".join(attributes)
|
||||
collection = self._collection_class(
|
||||
cast(multifilesystem.Storage, self),
|
||||
pathutils.unstrip_path(sane_path, True))
|
||||
|
||||
if href:
|
||||
item = collection._get(href)
|
||||
if item is not None:
|
||||
yield item
|
||||
return
|
||||
collection = multifilesystem.Collection(
|
||||
storage_=cast(multifilesystem.Storage, self),
|
||||
path="/default/",
|
||||
)
|
||||
|
||||
yield collection
|
||||
|
||||
if depth == "0":
|
||||
return
|
||||
|
||||
for href in collection._list():
|
||||
with child_context_manager(sane_path, href):
|
||||
item = collection._get(href)
|
||||
if item is not None:
|
||||
yield item
|
||||
address_book = multifilesystem.Collection(
|
||||
storage_=cast(multifilesystem.Storage, self),
|
||||
path="/default/address_book.vcf",
|
||||
)
|
||||
address_book.set_meta(
|
||||
{
|
||||
"D:displayname": os.getenv("ADDRESS_BOOK_NAME", "default"),
|
||||
"tag": "VADDRESSBOOK"
|
||||
}
|
||||
)
|
||||
|
||||
for entry in os.scandir(filesystem_path):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
href = entry.name
|
||||
if not pathutils.is_safe_filesystem_path_component(href):
|
||||
if not href.startswith(".Radicale"):
|
||||
logger.debug("Skipping collection %r in %r",
|
||||
href, sane_path)
|
||||
continue
|
||||
sane_child_path = posixpath.join(sane_path, href)
|
||||
child_path = pathutils.unstrip_path(sane_child_path, True)
|
||||
with child_context_manager(sane_child_path, None):
|
||||
yield self._collection_class(
|
||||
cast(multifilesystem.Storage, self), child_path)
|
||||
yield address_book
|
||||
|
|
|
|||
|
|
@ -19,8 +19,12 @@
|
|||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from dateutil.parser import parse
|
||||
|
||||
from typing import Iterable, Iterator, Optional, Tuple
|
||||
|
||||
from radicale.ldap import LdapService
|
||||
import radicale.item as radicale_item
|
||||
from radicale import pathutils
|
||||
from radicale.log import logger
|
||||
|
|
@ -32,7 +36,6 @@ from radicale.storage.multifilesystem.lock import CollectionPartLock
|
|||
|
||||
class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
||||
CollectionBase):
|
||||
|
||||
_item_cache_cleaned: bool
|
||||
|
||||
def __init__(self, storage_: "multifilesystem.Storage", path: str,
|
||||
|
|
@ -140,9 +143,41 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
|||
yield (href, self._get(href, verify_href=False))
|
||||
|
||||
def get_all(self) -> Iterator[radicale_item.Item]:
|
||||
for href in self._list():
|
||||
# We don't need to check for collissions, because the file names
|
||||
# are from os.listdir.
|
||||
item = self._get(href, verify_href=False)
|
||||
if item is not None:
|
||||
yield item
|
||||
|
||||
for ldap_user in LdapService().sync():
|
||||
if not ldap_user.ruSn and not ldap_user.ruGivenName:
|
||||
continue
|
||||
|
||||
if ldap_user.birthdate:
|
||||
birthdate = parse(ldap_user.birthdate).strftime("%Y-%m-%d")
|
||||
else:
|
||||
birthdate = "-"
|
||||
|
||||
vcard_text = "\n".join(("BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
f"UID:{ldap_user.uid}",
|
||||
f"EMAIL;TYPE=INTERNET:{ldap_user.mail or '-'}",
|
||||
f"FN:{ldap_user.ruGivenName or ''} {ldap_user.ruSn or ''}",
|
||||
f"REV:{ldap_user.modifyTimestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')}",
|
||||
f"N:{ldap_user.ruSn or ''};{ldap_user.ruGivenName or ''};;;",
|
||||
f"ROLE:{ldap_user.title or '-'}",
|
||||
f"TEL;TYPE=WORK,MSG:{ldap_user.mobile or '-'}",
|
||||
f"BDAY:{birthdate}",
|
||||
f"IMPP;X-SERVICE-TYPE=Skype;TYPE=WORK:skype:{ldap_user.skype or '-'}",
|
||||
f"IMPP;X-SERVICE-TYPE=Telegram;TYPE=WORK:telegram:{ldap_user.telegram or '-'}",
|
||||
f"END:VCARD",
|
||||
))
|
||||
|
||||
item = radicale_item.Item(
|
||||
collection_path=self.path,
|
||||
collection=self,
|
||||
vobject_item=None,
|
||||
href=f"{ldap_user.uid}.vcf",
|
||||
last_modified=ldap_user.modifyTimestamp.strftime("%a, %d %b %Y %H:%M:%S GMT"),
|
||||
text=vcard_text,
|
||||
etag=radicale_item.get_etag(vcard_text),
|
||||
uid=ldap_user.uid,
|
||||
name="VCARD",
|
||||
component_name="VCARD",
|
||||
)
|
||||
yield item
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class CollectionPartMeta(CollectionBase):
|
|||
def __init__(self, storage_: "multifilesystem.Storage", path: str,
|
||||
filesystem_path: Optional[str] = None) -> None:
|
||||
super().__init__(storage_, path, filesystem_path)
|
||||
self._meta_cache = None
|
||||
self._meta_cache = {}
|
||||
self._props_path = os.path.join(
|
||||
self._filesystem_path, ".Radicale.props")
|
||||
|
||||
|
|
@ -45,22 +45,8 @@ class CollectionPartMeta(CollectionBase):
|
|||
|
||||
def get_meta(self, key: Optional[str] = None) -> Union[Mapping[str, str],
|
||||
Optional[str]]:
|
||||
# reuse cached value if the storage is read-only
|
||||
if self._storage._lock.locked == "w" or self._meta_cache is None:
|
||||
try:
|
||||
try:
|
||||
with open(self._props_path, encoding=self._encoding) as f:
|
||||
temp_meta = json.load(f)
|
||||
except FileNotFoundError:
|
||||
temp_meta = {}
|
||||
self._meta_cache = radicale_item.check_and_sanitize_props(
|
||||
temp_meta)
|
||||
except ValueError as e:
|
||||
raise RuntimeError("Failed to load properties of collection "
|
||||
"%r: %s" % (self.path, e)) from e
|
||||
return self._meta_cache if key is None else self._meta_cache.get(key)
|
||||
|
||||
def set_meta(self, props: Mapping[str, str]) -> None:
|
||||
with self._atomic_write(self._props_path, "w") as fo:
|
||||
f = cast(TextIO, fo)
|
||||
json.dump(props, f, sort_keys=True)
|
||||
self._meta_cache = dict(self._meta_cache, **props)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Python template
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# dotenv
|
||||
.env
|
||||
|
||||
# virtualenv
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# Files used for local debugging (integration test)
|
||||
test/configuration.py
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# What is this?
|
||||
This is an authentication plugin for Radicale 3. It adds an LDAP authentication backend which can be used for authenticating users against an LDAP server.
|
||||
|
||||
# How to configure
|
||||
You will need to set a few options inside your radicale config file. Example:
|
||||
|
||||
```
|
||||
[auth]
|
||||
type = radicale3_auth_ldap
|
||||
|
||||
# LDAP server URL, with protocol and port
|
||||
ldap_url = ldap://ldap:389
|
||||
|
||||
# LDAP base path
|
||||
ldap_base = ou=Users,dc=TESTDOMAIN
|
||||
|
||||
# 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)
|
||||
ldap_filter = (objectClass=person)
|
||||
|
||||
# LDAP dn for initial login, used if LDAP server does not allow anonymous searches
|
||||
# Leave empty if searches are anonymous
|
||||
ldap_binddn = cn=admin,dc=TESTDOMAIN
|
||||
|
||||
# LDAP password for initial login, used with ldap_binddn
|
||||
ldap_password = verysecurepassword
|
||||
|
||||
# LDAP scope of the search
|
||||
ldap_scope = LEVEL
|
||||
|
||||
# LDAP extended option
|
||||
# If the server is samba, ldap_support_extended is should be no
|
||||
ldap_support_extended = yes
|
||||
```
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# This file is part of Radicale Server - Calendar Server
|
||||
# Copyright © 2011 Corentin Le Bail
|
||||
# Copyright © 2011-2013 Guillaume Ayoub
|
||||
# Copyright © 2015 Raoul Thill
|
||||
# Copyright © 2017 Marco Huenseler
|
||||
#
|
||||
# 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 ``ldap3`` module
|
||||
(https://github.com/cannatag/ldap3/).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
|
||||
from radicale.auth import BaseAuth
|
||||
from radicale.log import logger
|
||||
|
||||
import radicale3_auth_ldap.ldap3imports
|
||||
|
||||
PLUGIN_CONFIG_SCHEMA = {"auth": {
|
||||
"ldap_url": {
|
||||
"value": "ldap://localhost:389",
|
||||
"type": str},
|
||||
"ldap_base": {
|
||||
"value": "ou=Users",
|
||||
"type": str},
|
||||
"ldap_attribute": {
|
||||
"value": "uid",
|
||||
"type": str},
|
||||
"ldap_filter": {
|
||||
"value": "(objectClass=*)",
|
||||
"type": str},
|
||||
"ldap_binddn": {
|
||||
"value": None,
|
||||
"type": str},
|
||||
"ldap_password": {
|
||||
"value": None,
|
||||
"type": str},
|
||||
"ldap_scope": {
|
||||
"value": "SUBTREE",
|
||||
"type": str},
|
||||
"ldap_support_extended": {
|
||||
"value": True,
|
||||
"type": bool}}}
|
||||
|
||||
|
||||
class Auth(BaseAuth):
|
||||
def __init__(self, configuration):
|
||||
super().__init__(configuration.copy(PLUGIN_CONFIG_SCHEMA))
|
||||
|
||||
def login(self, user, password):
|
||||
"""Check if ``user``/``password`` couple is valid."""
|
||||
SERVER = ldap3.Server(os.getenv("LDAP_URL") or self.configuration.get("auth", "ldap_url"))
|
||||
BASE = os.getenv("LDAP_BASE") or self.configuration.get("auth", "ldap_base")
|
||||
ATTRIBUTE = os.getenv("LDAP_ATTRIBUTE") or self.configuration.get("auth", "ldap_attribute")
|
||||
FILTER = os.getenv("LDAP_FILTER") or self.configuration.get("auth", "ldap_filter")
|
||||
BINDDN = os.getenv("LDAP_BINDDN") or self.configuration.get("auth", "ldap_binddn")
|
||||
PASSWORD = os.getenv("LDAP_PASSWORD") or self.configuration.get("auth", "ldap_password")
|
||||
SCOPE = self.configuration.get("auth", "ldap_scope")
|
||||
SUPPORT_EXTENDED = self.configuration.get("auth", "ldap_support_extended")
|
||||
|
||||
if BINDDN and PASSWORD:
|
||||
conn = ldap3.Connection(SERVER, BINDDN, PASSWORD)
|
||||
else:
|
||||
conn = ldap3.Connection(SERVER)
|
||||
conn.bind()
|
||||
|
||||
try:
|
||||
logger.debug("LDAP whoami: %s" % conn.extend.standard.who_am_i())
|
||||
except Exception as err:
|
||||
logger.debug("LDAP error: %s" % err)
|
||||
|
||||
distinguished_name = "%s=%s" % (ATTRIBUTE, ldap3imports.escape_attribute_value(user))
|
||||
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
|
||||
logger.debug("LDAP filter: %s" % filter_string)
|
||||
|
||||
conn.search(search_base=BASE,
|
||||
search_scope=SCOPE,
|
||||
search_filter=filter_string,
|
||||
attributes=[ATTRIBUTE])
|
||||
|
||||
users = conn.response
|
||||
|
||||
if users:
|
||||
user_dn = users[0]['dn']
|
||||
uid = users[0]['attributes'][ATTRIBUTE]
|
||||
logger.debug("LDAP user %s (%s) found" % (uid, user_dn))
|
||||
try:
|
||||
conn = ldap3.Connection(SERVER, user_dn, password)
|
||||
conn.bind()
|
||||
logger.debug(conn.result)
|
||||
if SUPPORT_EXTENDED:
|
||||
whoami = conn.extend.standard.who_am_i()
|
||||
logger.debug("LDAP whoami: %s" % whoami)
|
||||
else:
|
||||
logger.debug("LDAP skip extended: call whoami")
|
||||
whoami = conn.result['result'] == 0
|
||||
if whoami:
|
||||
logger.debug("LDAP bind OK")
|
||||
return uid[0]
|
||||
else:
|
||||
logger.debug("LDAP bind failed")
|
||||
return ""
|
||||
except ldap3.core.exceptions.LDAPInvalidCredentialsResult:
|
||||
logger.debug("LDAP invalid credentials")
|
||||
except Exception as err:
|
||||
logger.debug("LDAP error %s" % err)
|
||||
return ""
|
||||
else:
|
||||
logger.debug("LDAP user %s not found" % user)
|
||||
return ""
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
This code was imported from ldap3, because upstream obviously deprecated this.
|
||||
Until I come around finding a better solution, this will stay here.
|
||||
All this code was written by people from the ldap3 project and keeps the original license, of course.
|
||||
|
||||
Original header below:
|
||||
|
||||
# Created on 2014.09.08
|
||||
#
|
||||
# Author: Giovanni Cannata
|
||||
#
|
||||
# Copyright 2014, 2015, 2016 Giovanni Cannata
|
||||
#
|
||||
# This file is part of ldap3.
|
||||
#
|
||||
# ldap3 is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# ldap3 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 Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with ldap3 in the COPYING and COPYING.LESSER files.
|
||||
# If not, see <http://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from string import hexdigits
|
||||
|
||||
STATE_ANY = 0
|
||||
STATE_ESCAPE = 1
|
||||
STATE_ESCAPE_HEX = 2
|
||||
|
||||
|
||||
def escape_attribute_value(attribute_value):
|
||||
if not attribute_value:
|
||||
return ''
|
||||
|
||||
if attribute_value[0] == '#': # with leading SHARP only pairs of hex characters are valid
|
||||
valid_hex = True
|
||||
if len(attribute_value) % 2 == 0: # string must be # + HEX HEX (an odd number of chars)
|
||||
valid_hex = False
|
||||
|
||||
if valid_hex:
|
||||
for c in attribute_value:
|
||||
if c not in hexdigits: # allowed only hex digits as per RFC 4514
|
||||
valid_hex = False
|
||||
break
|
||||
|
||||
if valid_hex:
|
||||
return attribute_value
|
||||
|
||||
state = STATE_ANY
|
||||
escaped = ''
|
||||
tmp_buffer = ''
|
||||
for c in attribute_value:
|
||||
if state == STATE_ANY:
|
||||
if c == '\\':
|
||||
state = STATE_ESCAPE
|
||||
elif c in '"#+,;<=>\00':
|
||||
escaped += '\\' + c
|
||||
else:
|
||||
escaped += c
|
||||
elif state == STATE_ESCAPE:
|
||||
if c in hexdigits:
|
||||
tmp_buffer = c
|
||||
state = STATE_ESCAPE_HEX
|
||||
elif c in ' "#+,;<=>\\\00':
|
||||
escaped += '\\' + c
|
||||
state = STATE_ANY
|
||||
else:
|
||||
escaped += '\\\\' + c
|
||||
elif state == STATE_ESCAPE_HEX:
|
||||
if c in hexdigits:
|
||||
escaped += '\\' + tmp_buffer + c
|
||||
else:
|
||||
escaped += '\\\\' + tmp_buffer + c
|
||||
tmp_buffer = ''
|
||||
state = STATE_ANY
|
||||
|
||||
# final state
|
||||
if state == STATE_ESCAPE:
|
||||
escaped += '\\\\'
|
||||
elif state == STATE_ESCAPE_HEX:
|
||||
escaped += '\\\\' + tmp_buffer
|
||||
|
||||
if escaped[0] == ' ': # leading SPACE must be escaped
|
||||
escaped = '\\' + escaped
|
||||
|
||||
if escaped[-1] == ' ' and len(escaped) > 1 and escaped[-2] != '\\': # trailing SPACE must be escaped
|
||||
escaped = escaped[:-1] + '\\ '
|
||||
|
||||
return escaped
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name="radicale3-auth-ldap",
|
||||
version="3.0",
|
||||
description="LDAP Authentication Plugin for Radicale 3",
|
||||
author="Raoul Thill",
|
||||
license="GNU GPL v3",
|
||||
install_requires=["radicale >= 3.0", "ldap3 >= 2.3"],
|
||||
packages=["radicale3_auth_ldap"])
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import unittest
|
||||
|
||||
import radicale3_auth_ldap
|
||||
|
||||
|
||||
class References(unittest.TestCase):
|
||||
def test_invalid_credentials_exception_exists(self):
|
||||
successful_test = False
|
||||
|
||||
try:
|
||||
import ldap3.core.exceptions
|
||||
except ImportError:
|
||||
self.fail('ldap3 module was not found at all!')
|
||||
try:
|
||||
raise ldap3.core.exceptions.LDAPInvalidCredentialsResult()
|
||||
except ldap3.core.exceptions.LDAPInvalidCredentialsResult:
|
||||
successful_test = True
|
||||
|
||||
self.assertTrue(successful_test)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
import radicale3_auth_ldap
|
||||
from test.configuration import TEST_CONFIGURATION, VALID_USER, VALID_PASS
|
||||
from test.util import ConfigMock
|
||||
|
||||
|
||||
class Authentication(unittest.TestCase):
|
||||
configuration = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.configuration = ConfigMock(TEST_CONFIGURATION)
|
||||
|
||||
def test_authentication_works(self):
|
||||
auth = radicale3_auth_ldap.Auth(self.__class__.configuration)
|
||||
self.assertTrue(auth.login(VALID_USER, VALID_PASS))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
class ConfigMock:
|
||||
def __init__(self, configuration):
|
||||
assert isinstance(configuration, Mapping)
|
||||
assert all(isinstance(x, Mapping) for x in configuration.values())
|
||||
self.configuration = configuration
|
||||
|
||||
def get(self, a, b):
|
||||
return self.configuration[a][b]
|
||||
|
||||
def copy(self, c):
|
||||
return self
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
defusedxml==0.7.1
|
||||
passlib==1.7.4
|
||||
vobject>=0.9.6
|
||||
python-dateutil==2.8.2
|
||||
redis==4.3.4
|
||||
56
setup.cfg
56
setup.cfg
|
|
@ -1,56 +0,0 @@
|
|||
[tool:pytest]
|
||||
addopts = --typeguard-packages=radicale
|
||||
|
||||
[tox:tox]
|
||||
|
||||
[testenv]
|
||||
extras = test
|
||||
deps =
|
||||
flake8
|
||||
isort
|
||||
# mypy installation fails with pypy<3.9
|
||||
mypy; implementation_name!='pypy' or python_version>='3.9'
|
||||
types-setuptools
|
||||
pytest-cov
|
||||
commands =
|
||||
flake8 .
|
||||
isort --check --diff .
|
||||
# Run mypy if it's installed
|
||||
python -c 'import importlib.util, subprocess, sys; \
|
||||
importlib.util.find_spec("mypy") \
|
||||
and sys.exit(subprocess.run(["mypy", "."]).returncode) \
|
||||
or print("Skipped: mypy is not installed")'
|
||||
pytest -r s --cov --cov-report=term --cov-report=xml .
|
||||
|
||||
[tool:isort]
|
||||
known_standard_library = _dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib
|
||||
known_third_party = defusedxml,passlib,pkg_resources,pytest,vobject
|
||||
|
||||
[flake8]
|
||||
# Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398)
|
||||
select = E,F,W,C90,DOES-NOT-EXIST
|
||||
ignore = E121,E123,E126,E226,E24,E704,W503,W504,DOES-NOT-EXIST
|
||||
extend-exclude = build
|
||||
|
||||
[mypy]
|
||||
ignore_missing_imports = True
|
||||
show_error_codes = True
|
||||
exclude = (^|/)build($|/)
|
||||
|
||||
[coverage:run]
|
||||
branch = True
|
||||
source = radicale
|
||||
omit = tests/*,*/tests/*
|
||||
|
||||
[coverage:report]
|
||||
# Regexes for lines to exclude from consideration
|
||||
exclude_lines =
|
||||
# Have to re-enable the standard pragma
|
||||
pragma: no cover
|
||||
|
||||
# Don't complain if tests don't hit defensive assertion code:
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
|
||||
# Don't complain if non-runnable code isn't run:
|
||||
if __name__ == .__main__.:
|
||||
73
setup.py
73
setup.py
|
|
@ -1,73 +0,0 @@
|
|||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2009-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
# When the version is updated, a new section in the CHANGELOG.md file must be
|
||||
# added too.
|
||||
VERSION = "master"
|
||||
|
||||
with open("README.md", encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
web_files = ["web/internal_data/css/icon.png",
|
||||
"web/internal_data/css/main.css",
|
||||
"web/internal_data/fn.js",
|
||||
"web/internal_data/index.html"]
|
||||
|
||||
install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
|
||||
"python-dateutil>=2.7.3",
|
||||
"setuptools; python_version<'3.9'"]
|
||||
bcrypt_requires = ["passlib[bcrypt]", "bcrypt"]
|
||||
# typeguard requires pytest<7
|
||||
test_requires = ["pytest<7", "typeguard", "waitress", *bcrypt_requires]
|
||||
|
||||
setup(
|
||||
name="Radicale",
|
||||
version=VERSION,
|
||||
description="CalDAV and CardDAV Server",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
author="Guillaume Ayoub",
|
||||
author_email="guillaume.ayoub@kozea.fr",
|
||||
url="https://radicale.org/",
|
||||
license="GNU GPL v3",
|
||||
platforms="Any",
|
||||
packages=find_packages(
|
||||
exclude=["*.tests", "*.tests.*", "tests.*", "tests"]),
|
||||
package_data={"radicale": [*web_files, "py.typed"]},
|
||||
entry_points={"console_scripts": ["radicale = radicale.__main__:run"]},
|
||||
install_requires=install_requires,
|
||||
extras_require={"test": test_requires, "bcrypt": bcrypt_requires},
|
||||
keywords=["calendar", "addressbook", "CalDAV", "CardDAV"],
|
||||
python_requires=">=3.6.0",
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Intended Audience :: Information Technology",
|
||||
"License :: OSI Approved :: GNU General Public License (GPL)",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Topic :: Office/Business :: Groupware"])
|
||||
Loading…
Reference in New Issue