Skip to content

Commit

Permalink
Merge branch 'v3.2-devel'
Browse files Browse the repository at this point in the history
  • Loading branch information
pbiering committed Apr 5, 2024
2 parents df874a2 + b8f4010 commit 2741d73
Show file tree
Hide file tree
Showing 25 changed files with 1,324 additions and 213 deletions.
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
# Changelog

## master
## 3.2.0 (upcoming)

* Enhancement: add hook support for event changes+deletion hooks (initial support: "rabbitmq")
* Dependency: pika >= 1.1.0
* Enhancement: add support for webcal subscriptions
* Enhancement: major update of WebUI (design+features)

## 3.1.9

* Add: support for Python 3.11 + 3.12
* Drop: support for Python 3.6
* Fix: MOVE in case listen on non-standard ports or behind reverse proxy
* Fix: stricter requirements of Python 3.11
* Fix: HTML pages
* Fix: Main Component is missing when only recurrence id exists
* Fix: passlib don't support bcrypt>=4.1
* Fix: web login now proper encodes passwords containing %XX (hexdigits)
* Enhancement: user-selectable log formats
* Enhancement: autodetect logging to systemd journal
* Enhancement: test code
* Enhancement: option for global permit to delete collection
* Enhancement: auth type 'htpasswd' supports now 'htpasswd_encryption' sha256/sha512 and "autodetect" for smooth transition
* Improve: Dockerfiles
* Improve: server socket listen code + address format in log
* Update: documentations + examples
* Dependency: limit typegard version < 3
* General: code cosmetics

* Adjust: change default loglevel to "info"

Expand Down
36 changes: 35 additions & 1 deletion DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,41 @@ An example to relax the same-origin policy:
Access-Control-Allow-Origin = *
```

### Supported Clients
#### hook
##### type

Hook binding for event changes and deletion notifications.

Available types:

`none`
: Disabled. Nothing will be notified.

`rabbitmq`
: Push the message to the rabbitmq server.

Default: `none`

#### rabbitmq_endpoint

End-point address for rabbitmq server.
Ex: amqp://user:password@localhost:5672/

Default:

#### rabbitmq_topic

RabbitMQ topic to publish message.

Default:

#### rabbitmq_queue_type

RabbitMQ queue type for the topic.

Default: classic

## Supported Clients

Radicale has been tested with:

Expand Down
9 changes: 9 additions & 0 deletions config
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,12 @@

# Additional HTTP headers
#Access-Control-Allow-Origin = *

[hook]

# Hook types
# Value: none | rabbitmq
#type = none
#rabbitmq_endpoint =
#rabbitmq_topic =
#rabbitmq_queue_type = classic
6 changes: 4 additions & 2 deletions radicale/app/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
import xml.etree.ElementTree as ET
from typing import Optional

from radicale import (auth, config, httputils, pathutils, rights, storage,
types, web, xmlutils)
from radicale import (auth, config, hook, httputils, pathutils, rights,
storage, types, web, xmlutils)
from radicale.log import logger

# HACK: https://github.com/tiran/defusedxml/issues/54
Expand All @@ -39,6 +39,7 @@ class ApplicationBase:
_web: web.BaseWeb
_encoding: str
_permit_delete_collection: bool
_hook: hook.BaseHook

def __init__(self, configuration: config.Configuration) -> None:
self.configuration = configuration
Expand All @@ -47,6 +48,7 @@ def __init__(self, configuration: config.Configuration) -> None:
self._rights = rights.load(configuration)
self._web = web.load(configuration)
self._encoding = configuration.get("encoding", "request")
self._hook = hook.load(configuration)

def _read_xml_request_body(self, environ: types.WSGIEnviron
) -> Optional[ET.Element]:
Expand Down
19 changes: 19 additions & 0 deletions radicale/app/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from radicale import httputils, storage, types, xmlutils
from radicale.app.base import Access, ApplicationBase
from radicale.hook import HookNotificationItem, HookNotificationItemTypes


def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection,
Expand Down Expand Up @@ -67,15 +68,33 @@ def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str,
if if_match not in ("*", item.etag):
# ETag precondition not verified, do not delete item
return httputils.PRECONDITION_FAILED
hook_notification_item_list = []
if isinstance(item, storage.BaseCollection):
if self._permit_delete_collection:
for i in item.get_all():
hook_notification_item_list.append(
HookNotificationItem(
HookNotificationItemTypes.DELETE,
access.path,
i.uid
)
)
xml_answer = xml_delete(base_prefix, path, item)
else:
return httputils.NOT_ALLOWED
else:
assert item.collection is not None
assert item.href is not None
hook_notification_item_list.append(
HookNotificationItem(
HookNotificationItemTypes.DELETE,
access.path,
item.uid
)
)
xml_answer = xml_delete(
base_prefix, path, item.collection, item.href)
for notification_item in hook_notification_item_list:
self._hook.notify(notification_item)
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
return client.OK, headers, self._xml_response(xml_answer)
19 changes: 18 additions & 1 deletion radicale/app/propfind.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def xml_propfind_response(

if isinstance(item, storage.BaseCollection):
is_collection = True
is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR")
is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR", "VSUBSCRIBED")
collection = item
# Some clients expect collections to end with `/`
uri = pathutils.unstrip_path(item.path, True)
Expand Down Expand Up @@ -259,6 +259,10 @@ def xml_propfind_response(
child_element = ET.Element(
xmlutils.make_clark("C:calendar"))
element.append(child_element)
elif collection.tag == "VSUBSCRIBED":
child_element = ET.Element(
xmlutils.make_clark("CS:subscribed"))
element.append(child_element)
child_element = ET.Element(xmlutils.make_clark("D:collection"))
element.append(child_element)
elif tag == xmlutils.make_clark("RADICALE:displayname"):
Expand All @@ -268,6 +272,12 @@ def xml_propfind_response(
element.text = displayname
else:
is404 = True
elif tag == xmlutils.make_clark("RADICALE:getcontentcount"):
# Only for internal use by the web interface
if isinstance(item, storage.BaseCollection) and not collection.is_principal:
element.text = str(sum(1 for x in item.get_all()))
else:
is404 = True
elif tag == xmlutils.make_clark("D:displayname"):
displayname = collection.get_meta("D:displayname")
if not displayname and is_leaf:
Expand All @@ -286,6 +296,13 @@ def xml_propfind_response(
element.text, _ = collection.sync()
else:
is404 = True
elif tag == xmlutils.make_clark("CS:source"):
if is_leaf:
child_element = ET.Element(xmlutils.make_clark("D:href"))
child_element.text = collection.get_meta('CS:source')
element.append(child_element)
else:
is404 = True
else:
human_tag = xmlutils.make_human_tag(tag)
tag_text = collection.get_meta(human_tag)
Expand Down
13 changes: 13 additions & 0 deletions radicale/app/proppatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@
from http import client
from typing import Dict, Optional, cast

import defusedxml.ElementTree as DefusedET

import radicale.item as radicale_item
from radicale import httputils, storage, types, xmlutils
from radicale.app.base import Access, ApplicationBase
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
from radicale.log import logger


Expand Down Expand Up @@ -93,6 +96,16 @@ def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str,
try:
xml_answer = xml_proppatch(base_prefix, path, xml_content,
item)
if xml_content is not None:
hook_notification_item = HookNotificationItem(
HookNotificationItemTypes.CPATCH,
access.path,
DefusedET.tostring(
xml_content,
encoding=self._encoding
).decode(encoding=self._encoding)
)
self._hook.notify(hook_notification_item)
except ValueError as e:
logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
Expand Down
14 changes: 14 additions & 0 deletions radicale/app/put.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import radicale.item as radicale_item
from radicale import httputils, pathutils, rights, storage, types, xmlutils
from radicale.app.base import Access, ApplicationBase
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
from radicale.log import logger

MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in
Expand Down Expand Up @@ -206,6 +207,13 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str,
try:
etag = self._storage.create_collection(
path, prepared_items, props).etag
for item in prepared_items:
hook_notification_item = HookNotificationItem(
HookNotificationItemTypes.UPSERT,
access.path,
item.serialize()
)
self._hook.notify(hook_notification_item)
except ValueError as e:
logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
Expand All @@ -222,6 +230,12 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str,
href = posixpath.basename(pathutils.strip_path(path))
try:
etag = parent_item.upload(href, prepared_item).etag
hook_notification_item = HookNotificationItem(
HookNotificationItemTypes.UPSERT,
access.path,
prepared_item.serialize()
)
self._hook.notify(hook_notification_item)
except ValueError as e:
logger.warning(
"Bad PUT request on %r: %s", path, e, exc_info=True)
Expand Down
20 changes: 19 additions & 1 deletion radicale/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from typing import (Any, Callable, ClassVar, Iterable, List, Optional,
Sequence, Tuple, TypeVar, Union)

from radicale import auth, rights, storage, types, web
from radicale import auth, hook, rights, storage, types, web

DEFAULT_CONFIG_PATH: str = os.pathsep.join([
"?/etc/radicale/config",
Expand Down Expand Up @@ -214,6 +214,24 @@ def _convert_to_bool(value: Any) -> bool:
"value": "True",
"help": "sync all changes to filesystem during requests",
"type": bool})])),
("hook", OrderedDict([
("type", {
"value": "none",
"help": "hook backend",
"type": str,
"internal": hook.INTERNAL_TYPES}),
("rabbitmq_endpoint", {
"value": "",
"help": "endpoint where rabbitmq server is running",
"type": str}),
("rabbitmq_topic", {
"value": "",
"help": "topic to declare queue",
"type": str}),
("rabbitmq_queue_type", {
"value": "",
"help": "queue type for topic declaration",
"type": str})])),
("web", OrderedDict([
("type", {
"value": "internal",
Expand Down
60 changes: 60 additions & 0 deletions radicale/hook/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import json
from enum import Enum
from typing import Sequence

from radicale import pathutils, utils

INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq")


def load(configuration):
"""Load the storage module chosen in configuration."""
return utils.load_plugin(
INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration)


class BaseHook:
def __init__(self, configuration):
"""Initialize BaseHook.
``configuration`` see ``radicale.config`` module.
The ``configuration`` must not change during the lifetime of
this object, it is kept as an internal reference.
"""
self.configuration = configuration

def notify(self, notification_item):
"""Upload a new or replace an existing item."""
raise NotImplementedError


class HookNotificationItemTypes(Enum):
CPATCH = "cpatch"
UPSERT = "upsert"
DELETE = "delete"


def _cleanup(path):
sane_path = pathutils.strip_path(path)
attributes = sane_path.split("/") if sane_path else []

if len(attributes) < 2:
return ""
return attributes[0] + "/" + attributes[1]


class HookNotificationItem:

def __init__(self, notification_item_type, path, content):
self.type = notification_item_type.value
self.point = _cleanup(path)
self.content = content

def to_json(self):
return json.dumps(
self,
default=lambda o: o.__dict__,
sort_keys=True,
indent=4
)
6 changes: 6 additions & 0 deletions radicale/hook/none.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from radicale import hook


class Hook(hook.BaseHook):
def notify(self, notification_item):
"""Notify nothing. Empty hook."""
Loading

0 comments on commit 2741d73

Please sign in to comment.