mirror of
https://github.com/Kozea/Radicale.git
synced 2025-07-02 16:58:30 +00:00
Merge pull request #1092 from freakmaxi/master
Hook capability for event changes and deletions
This commit is contained in:
commit
989cbefc64
11 changed files with 230 additions and 5 deletions
|
@ -891,7 +891,41 @@ An example to relax the same-origin policy:
|
||||||
Access-Control-Allow-Origin = *
|
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:
|
Radicale has been tested with:
|
||||||
|
|
||||||
|
|
9
config
9
config
|
@ -118,3 +118,12 @@
|
||||||
|
|
||||||
# Additional HTTP headers
|
# Additional HTTP headers
|
||||||
#Access-Control-Allow-Origin = *
|
#Access-Control-Allow-Origin = *
|
||||||
|
|
||||||
|
[hook]
|
||||||
|
|
||||||
|
# Hook types
|
||||||
|
# Value: none | rabbitmq
|
||||||
|
#type = none
|
||||||
|
#rabbitmq_endpoint =
|
||||||
|
#rabbitmq_topic =
|
||||||
|
#rabbitmq_queue_type = classic
|
|
@ -21,8 +21,8 @@ import sys
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from radicale import (auth, config, httputils, pathutils, rights, storage,
|
from radicale import (auth, config, hook, httputils, pathutils, rights,
|
||||||
types, web, xmlutils)
|
storage, types, web, xmlutils)
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
# HACK: https://github.com/tiran/defusedxml/issues/54
|
# HACK: https://github.com/tiran/defusedxml/issues/54
|
||||||
|
@ -38,6 +38,7 @@ class ApplicationBase:
|
||||||
_rights: rights.BaseRights
|
_rights: rights.BaseRights
|
||||||
_web: web.BaseWeb
|
_web: web.BaseWeb
|
||||||
_encoding: str
|
_encoding: str
|
||||||
|
_hook: hook.BaseHook
|
||||||
|
|
||||||
def __init__(self, configuration: config.Configuration) -> None:
|
def __init__(self, configuration: config.Configuration) -> None:
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
|
@ -46,6 +47,7 @@ class ApplicationBase:
|
||||||
self._rights = rights.load(configuration)
|
self._rights = rights.load(configuration)
|
||||||
self._web = web.load(configuration)
|
self._web = web.load(configuration)
|
||||||
self._encoding = configuration.get("encoding", "request")
|
self._encoding = configuration.get("encoding", "request")
|
||||||
|
self._hook = hook.load(configuration)
|
||||||
|
|
||||||
def _read_xml_request_body(self, environ: types.WSGIEnviron
|
def _read_xml_request_body(self, environ: types.WSGIEnviron
|
||||||
) -> Optional[ET.Element]:
|
) -> Optional[ET.Element]:
|
||||||
|
|
|
@ -23,6 +23,7 @@ from typing import Optional
|
||||||
|
|
||||||
from radicale import httputils, storage, types, xmlutils
|
from radicale import httputils, storage, types, xmlutils
|
||||||
from radicale.app.base import Access, ApplicationBase
|
from radicale.app.base import Access, ApplicationBase
|
||||||
|
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
|
||||||
|
|
||||||
|
|
||||||
def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection,
|
def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection,
|
||||||
|
@ -67,12 +68,30 @@ class ApplicationPartDelete(ApplicationBase):
|
||||||
if if_match not in ("*", item.etag):
|
if if_match not in ("*", item.etag):
|
||||||
# ETag precondition not verified, do not delete item
|
# ETag precondition not verified, do not delete item
|
||||||
return httputils.PRECONDITION_FAILED
|
return httputils.PRECONDITION_FAILED
|
||||||
|
hook_notification_item_list = []
|
||||||
if isinstance(item, storage.BaseCollection):
|
if isinstance(item, storage.BaseCollection):
|
||||||
|
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)
|
xml_answer = xml_delete(base_prefix, path, item)
|
||||||
else:
|
else:
|
||||||
assert item.collection is not None
|
assert item.collection is not None
|
||||||
assert item.href 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(
|
xml_answer = xml_delete(
|
||||||
base_prefix, path, item.collection, item.href)
|
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}
|
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
return client.OK, headers, self._xml_response(xml_answer)
|
return client.OK, headers, self._xml_response(xml_answer)
|
||||||
|
|
|
@ -22,9 +22,12 @@ import xml.etree.ElementTree as ET
|
||||||
from http import client
|
from http import client
|
||||||
from typing import Dict, Optional, cast
|
from typing import Dict, Optional, cast
|
||||||
|
|
||||||
|
import defusedxml.ElementTree as DefusedET
|
||||||
|
|
||||||
import radicale.item as radicale_item
|
import radicale.item as radicale_item
|
||||||
from radicale import httputils, storage, types, xmlutils
|
from radicale import httputils, storage, types, xmlutils
|
||||||
from radicale.app.base import Access, ApplicationBase
|
from radicale.app.base import Access, ApplicationBase
|
||||||
|
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
@ -93,6 +96,16 @@ class ApplicationPartProppatch(ApplicationBase):
|
||||||
try:
|
try:
|
||||||
xml_answer = xml_proppatch(base_prefix, path, xml_content,
|
xml_answer = xml_proppatch(base_prefix, path, xml_content,
|
||||||
item)
|
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:
|
except ValueError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
|
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
|
||||||
|
|
|
@ -30,6 +30,7 @@ import vobject
|
||||||
import radicale.item as radicale_item
|
import radicale.item as radicale_item
|
||||||
from radicale import httputils, pathutils, rights, storage, types, xmlutils
|
from radicale import httputils, pathutils, rights, storage, types, xmlutils
|
||||||
from radicale.app.base import Access, ApplicationBase
|
from radicale.app.base import Access, ApplicationBase
|
||||||
|
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in
|
MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in
|
||||||
|
@ -206,6 +207,13 @@ class ApplicationPartPut(ApplicationBase):
|
||||||
try:
|
try:
|
||||||
etag = self._storage.create_collection(
|
etag = self._storage.create_collection(
|
||||||
path, prepared_items, props).etag
|
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:
|
except ValueError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad PUT request on %r: %s", path, e, exc_info=True)
|
"Bad PUT request on %r: %s", path, e, exc_info=True)
|
||||||
|
@ -222,6 +230,12 @@ class ApplicationPartPut(ApplicationBase):
|
||||||
href = posixpath.basename(pathutils.strip_path(path))
|
href = posixpath.basename(pathutils.strip_path(path))
|
||||||
try:
|
try:
|
||||||
etag = parent_item.upload(href, prepared_item).etag
|
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:
|
except ValueError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad PUT request on %r: %s", path, e, exc_info=True)
|
"Bad PUT request on %r: %s", path, e, exc_info=True)
|
||||||
|
|
|
@ -35,7 +35,7 @@ from configparser import RawConfigParser
|
||||||
from typing import (Any, Callable, ClassVar, Iterable, List, Optional,
|
from typing import (Any, Callable, ClassVar, Iterable, List, Optional,
|
||||||
Sequence, Tuple, TypeVar, Union)
|
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([
|
DEFAULT_CONFIG_PATH: str = os.pathsep.join([
|
||||||
"?/etc/radicale/config",
|
"?/etc/radicale/config",
|
||||||
|
@ -210,6 +210,24 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||||
"value": "True",
|
"value": "True",
|
||||||
"help": "sync all changes to filesystem during requests",
|
"help": "sync all changes to filesystem during requests",
|
||||||
"type": bool})])),
|
"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([
|
("web", OrderedDict([
|
||||||
("type", {
|
("type", {
|
||||||
"value": "internal",
|
"value": "internal",
|
||||||
|
|
60
radicale/hook/__init__.py
Normal file
60
radicale/hook/__init__.py
Normal file
|
@ -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
radicale/hook/none.py
Normal file
6
radicale/hook/none.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from radicale import hook
|
||||||
|
|
||||||
|
|
||||||
|
class Hook(hook.BaseHook):
|
||||||
|
def notify(self, notification_item):
|
||||||
|
"""Notify nothing. Empty hook."""
|
50
radicale/hook/rabbitmq/__init__.py
Normal file
50
radicale/hook/rabbitmq/__init__.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import pika
|
||||||
|
from pika.exceptions import ChannelWrongStateError, StreamLostError
|
||||||
|
|
||||||
|
from radicale import hook
|
||||||
|
from radicale.hook import HookNotificationItem
|
||||||
|
from radicale.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class Hook(hook.BaseHook):
|
||||||
|
|
||||||
|
def __init__(self, configuration):
|
||||||
|
super().__init__(configuration)
|
||||||
|
self._endpoint = configuration.get("hook", "rabbitmq_endpoint")
|
||||||
|
self._topic = configuration.get("hook", "rabbitmq_topic")
|
||||||
|
self._queue_type = configuration.get("hook", "rabbitmq_queue_type")
|
||||||
|
self._encoding = configuration.get("encoding", "stock")
|
||||||
|
|
||||||
|
self._make_connection_synced()
|
||||||
|
self._make_declare_queue_synced()
|
||||||
|
|
||||||
|
def _make_connection_synced(self):
|
||||||
|
parameters = pika.URLParameters(self._endpoint)
|
||||||
|
connection = pika.BlockingConnection(parameters)
|
||||||
|
self._channel = connection.channel()
|
||||||
|
|
||||||
|
def _make_declare_queue_synced(self):
|
||||||
|
self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type})
|
||||||
|
|
||||||
|
def notify(self, notification_item):
|
||||||
|
if isinstance(notification_item, HookNotificationItem):
|
||||||
|
self._notify(notification_item, True)
|
||||||
|
|
||||||
|
def _notify(self, notification_item, recall):
|
||||||
|
try:
|
||||||
|
self._channel.basic_publish(
|
||||||
|
exchange='',
|
||||||
|
routing_key=self._topic,
|
||||||
|
body=notification_item.to_json().encode(
|
||||||
|
encoding=self._encoding
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if (isinstance(e, ChannelWrongStateError) or
|
||||||
|
isinstance(e, StreamLostError)) and recall:
|
||||||
|
self._make_connection_synced()
|
||||||
|
self._notify(notification_item, False)
|
||||||
|
return
|
||||||
|
logger.error("An exception occurred during "
|
||||||
|
"publishing hook notification item: %s",
|
||||||
|
e, exc_info=True)
|
2
setup.py
2
setup.py
|
@ -29,7 +29,7 @@ web_files = ["web/internal_data/css/icon.png",
|
||||||
"web/internal_data/index.html"]
|
"web/internal_data/index.html"]
|
||||||
|
|
||||||
install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
|
install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
|
||||||
"python-dateutil>=2.7.3",
|
"python-dateutil>=2.7.3", "pika>=1.1.0",
|
||||||
"setuptools; python_version<'3.9'"]
|
"setuptools; python_version<'3.9'"]
|
||||||
bcrypt_requires = ["passlib[bcrypt]", "bcrypt"]
|
bcrypt_requires = ["passlib[bcrypt]", "bcrypt"]
|
||||||
# typeguard requires pytest<7
|
# typeguard requires pytest<7
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue