diff --git a/Dockerfile b/Dockerfile
index 1bfc82a..3b95563 100644
--- a/Dockerfile
+++ b/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"]
diff --git a/README.md b/README.md
index aed74fc..4a94f21 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/config b/config
index 7c77f5f..02246fd 100644
--- a/config
+++ b/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]
diff --git a/env b/env
new file mode 100644
index 0000000..7495359
--- /dev/null
+++ b/env
@@ -0,0 +1,7 @@
+ADDRESS_BOOK_NAME=
+LDAP_URL=
+LDAP_BASE=
+LDAP_FILTER=
+LDAP_BINDDN=
+LDAP_PASSWORD=
+REDIS_CACHE_LIVE_SECONDS=7200
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..f82149c
--- /dev/null
+++ b/main.py
@@ -0,0 +1,6 @@
+import sys
+
+from radicale.__main__ import run
+
+if __name__ == '__main__':
+ sys.exit(run())
diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py
index 52d0b00..d33b5bb 100644
--- a/radicale/app/propfind.py
+++ b/radicale/app/propfind.py
@@ -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,
diff --git a/radicale/app/report.py b/radicale/app/report.py
index 5807f6e..8dcd967 100644
--- a/radicale/app/report.py
+++ b/radicale/app/report.py
@@ -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:
diff --git a/radicale/ldap/__init__.py b/radicale/ldap/__init__.py
new file mode 100644
index 0000000..7ff9beb
--- /dev/null
+++ b/radicale/ldap/__init__.py
@@ -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]
diff --git a/radicale/redis/__init__.py b/radicale/redis/__init__.py
new file mode 100644
index 0000000..9b7e445
--- /dev/null
+++ b/radicale/redis/__init__.py
@@ -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()
+
diff --git a/radicale/storage/multifilesystem/__init__.py b/radicale/storage/multifilesystem/__init__.py
index 67aa6a5..d4ebb55 100644
--- a/radicale/storage/multifilesystem/__init__.py
+++ b/radicale/storage/multifilesystem/__init__.py
@@ -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:
diff --git a/radicale/storage/multifilesystem/discover.py b/radicale/storage/multifilesystem/discover.py
index 0031614..f7d068c 100644
--- a/radicale/storage/multifilesystem/discover.py
+++ b/radicale/storage/multifilesystem/discover.py
@@ -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
diff --git a/radicale/storage/multifilesystem/get.py b/radicale/storage/multifilesystem/get.py
index 0a1fd73..e8859bc 100644
--- a/radicale/storage/multifilesystem/get.py
+++ b/radicale/storage/multifilesystem/get.py
@@ -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
diff --git a/radicale/storage/multifilesystem/meta.py b/radicale/storage/multifilesystem/meta.py
index edce651..dbc193f 100644
--- a/radicale/storage/multifilesystem/meta.py
+++ b/radicale/storage/multifilesystem/meta.py
@@ -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)
+
diff --git a/radicale3-auth-ldap/.gitignore b/radicale3-auth-ldap/.gitignore
new file mode 100644
index 0000000..24f4d7b
--- /dev/null
+++ b/radicale3-auth-ldap/.gitignore
@@ -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/
diff --git a/radicale3-auth-ldap/README.md b/radicale3-auth-ldap/README.md
new file mode 100644
index 0000000..536c0db
--- /dev/null
+++ b/radicale3-auth-ldap/README.md
@@ -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
+```
diff --git a/radicale3-auth-ldap/radicale3_auth_ldap/__init__.py b/radicale3-auth-ldap/radicale3_auth_ldap/__init__.py
new file mode 100644
index 0000000..0d40c06
--- /dev/null
+++ b/radicale3-auth-ldap/radicale3_auth_ldap/__init__.py
@@ -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 .
+
+"""
+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 ""
diff --git a/radicale3-auth-ldap/radicale3_auth_ldap/ldap3imports.py b/radicale3-auth-ldap/radicale3_auth_ldap/ldap3imports.py
new file mode 100644
index 0000000..840ff21
--- /dev/null
+++ b/radicale3-auth-ldap/radicale3_auth_ldap/ldap3imports.py
@@ -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 .
+"""
+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
diff --git a/radicale3-auth-ldap/setup.py b/radicale3-auth-ldap/setup.py
new file mode 100644
index 0000000..2d0fee9
--- /dev/null
+++ b/radicale3-auth-ldap/setup.py
@@ -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"])
diff --git a/radicale3-auth-ldap/test/__init__.py b/radicale3-auth-ldap/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/radicale3-auth-ldap/test/basic_tests.py b/radicale3-auth-ldap/test/basic_tests.py
new file mode 100644
index 0000000..b152f0f
--- /dev/null
+++ b/radicale3-auth-ldap/test/basic_tests.py
@@ -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)
diff --git a/radicale3-auth-ldap/test/integration_tests.py b/radicale3-auth-ldap/test/integration_tests.py
new file mode 100644
index 0000000..4ad649e
--- /dev/null
+++ b/radicale3-auth-ldap/test/integration_tests.py
@@ -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()
diff --git a/radicale3-auth-ldap/test/util.py b/radicale3-auth-ldap/test/util.py
new file mode 100644
index 0000000..4d2084a
--- /dev/null
+++ b/radicale3-auth-ldap/test/util.py
@@ -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
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..75f1720
--- /dev/null
+++ b/requirements.txt
@@ -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
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index a77b43b..0000000
--- a/setup.cfg
+++ /dev/null
@@ -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__.:
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 7e14299..0000000
--- a/setup.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# This file is part of Radicale - CalDAV and CardDAV server
-# Copyright © 2009-2017 Guillaume Ayoub
-# Copyright © 2017-2018 Unrud
-#
-# 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 .
-
-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"])