Skip to content

Commit bd66d58

Browse files
authored
Merge pull request #1337 from react0r-com/react0r
Add basic free/busy reporting
2 parents 2d0496b + 408a03a commit bd66d58

File tree

9 files changed

+300
-45
lines changed

9 files changed

+300
-45
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# Changelog
22

33
## 3.dev
4+
* Fix: Using icalendar's tzinfo on created datetime to fix issue with icalendar
5+
* Enhancement: Added free-busy report
6+
* Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DOS on reports
47
* Enhancement: remove unexpected control codes from uploaded items
58
* Drop: remove unused requirement "typeguard"
9+
* Improve: Refactored some date parsing code
610

711
## 3.2.2
812
* Enhancement: add support for auth.type=denyall (will be default for security reasons in upcoming releases)

DOCUMENTATION.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,18 @@ RabbitMQ queue type for the topic.
10231023

10241024
Default: classic
10251025

1026+
#### reporting
1027+
##### max_freebusy_occurrence
1028+
1029+
When returning a free-busy report, a list of busy time occurrences are
1030+
generated based on a given time frame. Large time frames could
1031+
generate a lot of occurrences based on the time frame supplied. This
1032+
setting limits the lookup to prevent potential denial of service
1033+
attacks on large time frames. If the limit is reached, an HTTP error
1034+
is thrown instead of returning the results.
1035+
1036+
Default: 10000
1037+
10261038
## Supported Clients
10271039

10281040
Radicale has been tested with:

config

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,9 @@
172172
#rabbitmq_endpoint =
173173
#rabbitmq_topic =
174174
#rabbitmq_queue_type = classic
175+
176+
[reporting]
177+
178+
# When returning a free-busy report, limit the number of returned
179+
# occurences per event to prevent DOS attacks.
180+
#max_freebusy_occurrence = 10000

radicale/app/report.py

Lines changed: 126 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
Sequence, Tuple, Union)
2929
from urllib.parse import unquote, urlparse
3030

31+
import vobject
3132
import vobject.base
3233
from vobject.base import ContentLine
3334

@@ -38,11 +39,110 @@
3839
from radicale.log import logger
3940

4041

42+
def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
43+
collection: storage.BaseCollection, encoding: str,
44+
unlock_storage_fn: Callable[[], None],
45+
max_occurrence: int
46+
) -> Tuple[int, Union[ET.Element, str]]:
47+
# NOTE: this function returns both an Element and a string because
48+
# free-busy reports are an edge-case on the return type according
49+
# to the spec.
50+
51+
multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
52+
if xml_request is None:
53+
return client.MULTI_STATUS, multistatus
54+
root = xml_request
55+
if (root.tag == xmlutils.make_clark("C:free-busy-query") and
56+
collection.tag != "VCALENDAR"):
57+
logger.warning("Invalid REPORT method %r on %r requested",
58+
xmlutils.make_human_tag(root.tag), path)
59+
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
60+
61+
time_range_element = root.find(xmlutils.make_clark("C:time-range"))
62+
assert isinstance(time_range_element, ET.Element)
63+
64+
# Build a single filter from the free busy query for retrieval
65+
# TODO: filter for VFREEBUSY in additional to VEVENT but
66+
# test_filter doesn't support that yet.
67+
vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
68+
attrib={'name': 'VEVENT'})
69+
vevent_cf_element.append(time_range_element)
70+
vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
71+
attrib={'name': 'VCALENDAR'})
72+
vcalendar_cf_element.append(vevent_cf_element)
73+
filter_element = ET.Element(xmlutils.make_clark("C:filter"))
74+
filter_element.append(vcalendar_cf_element)
75+
filters = (filter_element,)
76+
77+
# First pull from storage
78+
retrieved_items = list(collection.get_filtered(filters))
79+
# !!! Don't access storage after this !!!
80+
unlock_storage_fn()
81+
82+
cal = vobject.iCalendar()
83+
collection_tag = collection.tag
84+
while retrieved_items:
85+
# Second filtering before evaluating occurrences.
86+
# ``item.vobject_item`` might be accessed during filtering.
87+
# Don't keep reference to ``item``, because VObject requires a lot of
88+
# memory.
89+
item, filter_matched = retrieved_items.pop(0)
90+
if not filter_matched:
91+
try:
92+
if not test_filter(collection_tag, item, filter_element):
93+
continue
94+
except ValueError as e:
95+
raise ValueError("Failed to free-busy filter item %r from %r: %s" %
96+
(item.href, collection.path, e)) from e
97+
except Exception as e:
98+
raise RuntimeError("Failed to free-busy filter item %r from %r: %s" %
99+
(item.href, collection.path, e)) from e
100+
101+
fbtype = None
102+
if item.component_name == 'VEVENT':
103+
transp = getattr(item.vobject_item.vevent, 'transp', None)
104+
if transp and transp.value != 'OPAQUE':
105+
continue
106+
107+
status = getattr(item.vobject_item.vevent, 'status', None)
108+
if not status or status.value == 'CONFIRMED':
109+
fbtype = 'BUSY'
110+
elif status.value == 'CANCELLED':
111+
fbtype = 'FREE'
112+
elif status.value == 'TENTATIVE':
113+
fbtype = 'BUSY-TENTATIVE'
114+
else:
115+
# Could do fbtype = status.value for x-name, I prefer this
116+
fbtype = 'BUSY'
117+
118+
# TODO: coalesce overlapping periods
119+
120+
if max_occurrence > 0:
121+
n_occurrences = max_occurrence+1
122+
else:
123+
n_occurrences = 0
124+
occurrences = radicale_filter.time_range_fill(item.vobject_item,
125+
time_range_element,
126+
"VEVENT",
127+
n=n_occurrences)
128+
if len(occurrences) >= max_occurrence:
129+
raise ValueError("FREEBUSY occurrences limit of {} hit"
130+
.format(max_occurrence))
131+
132+
for occurrence in occurrences:
133+
vfb = cal.add('vfreebusy')
134+
vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value
135+
vfb.add('dtstart').value, vfb.add('dtend').value = occurrence
136+
if fbtype:
137+
vfb.add('fbtype').value = fbtype
138+
return (client.OK, cal.serialize())
139+
140+
41141
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
42142
collection: storage.BaseCollection, encoding: str,
43143
unlock_storage_fn: Callable[[], None]
44144
) -> Tuple[int, ET.Element]:
45-
"""Read and answer REPORT requests.
145+
"""Read and answer REPORT requests that return XML.
46146
47147
Read rfc3253-3.6 for info.
48148
@@ -426,13 +526,28 @@ def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
426526
else:
427527
assert item.collection is not None
428528
collection = item.collection
429-
try:
430-
status, xml_answer = xml_report(
431-
base_prefix, path, xml_content, collection, self._encoding,
432-
lock_stack.close)
433-
except ValueError as e:
434-
logger.warning(
435-
"Bad REPORT request on %r: %s", path, e, exc_info=True)
436-
return httputils.BAD_REQUEST
437-
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
438-
return status, headers, self._xml_response(xml_answer)
529+
530+
if xml_content is not None and \
531+
xml_content.tag == xmlutils.make_clark("C:free-busy-query"):
532+
max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence")
533+
try:
534+
status, body = free_busy_report(
535+
base_prefix, path, xml_content, collection, self._encoding,
536+
lock_stack.close, max_occurrence)
537+
except ValueError as e:
538+
logger.warning(
539+
"Bad REPORT request on %r: %s", path, e, exc_info=True)
540+
return httputils.BAD_REQUEST
541+
headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
542+
return status, headers, str(body)
543+
else:
544+
try:
545+
status, xml_answer = xml_report(
546+
base_prefix, path, xml_content, collection, self._encoding,
547+
lock_stack.close)
548+
except ValueError as e:
549+
logger.warning(
550+
"Bad REPORT request on %r: %s", path, e, exc_info=True)
551+
return httputils.BAD_REQUEST
552+
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
553+
return status, headers, self._xml_response(xml_answer)

radicale/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,13 @@ def json_str(value: Any) -> dict:
297297
"help": "mask passwords in logs",
298298
"type": bool})])),
299299
("headers", OrderedDict([
300-
("_allow_extra", str)]))])
300+
("_allow_extra", str)])),
301+
("reporting", OrderedDict([
302+
("max_freebusy_occurrence", {
303+
"value": "10000",
304+
"help": "number of occurrences per event when reporting",
305+
"type": positive_int})]))
306+
])
301307

302308

303309
def parse_compound_paths(*compound_paths: Optional[str]

radicale/item/filter.py

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,34 @@ def date_to_datetime(d: date) -> datetime:
4848
if not isinstance(d, datetime):
4949
d = datetime.combine(d, datetime.min.time())
5050
if not d.tzinfo:
51-
d = d.replace(tzinfo=timezone.utc)
51+
# NOTE: using vobject's UTC as it wasn't playing well with datetime's.
52+
d = d.replace(tzinfo=vobject.icalendar.utc)
5253
return d
5354

5455

56+
def parse_time_range(time_filter: ET.Element) -> Tuple[datetime, datetime]:
57+
start_text = time_filter.get("start")
58+
end_text = time_filter.get("end")
59+
if start_text:
60+
start = datetime.strptime(
61+
start_text, "%Y%m%dT%H%M%SZ").replace(
62+
tzinfo=timezone.utc)
63+
else:
64+
start = DATETIME_MIN
65+
if end_text:
66+
end = datetime.strptime(
67+
end_text, "%Y%m%dT%H%M%SZ").replace(
68+
tzinfo=timezone.utc)
69+
else:
70+
end = DATETIME_MAX
71+
return start, end
72+
73+
74+
def time_range_timestamps(time_filter: ET.Element) -> Tuple[int, int]:
75+
start, end = parse_time_range(time_filter)
76+
return (math.floor(start.timestamp()), math.ceil(end.timestamp()))
77+
78+
5579
def comp_match(item: "item.Item", filter_: ET.Element, level: int = 0) -> bool:
5680
"""Check whether the ``item`` matches the comp ``filter_``.
5781
@@ -147,21 +171,10 @@ def time_range_match(vobject_item: vobject.base.Component,
147171
"""Check whether the component/property ``child_name`` of
148172
``vobject_item`` matches the time-range ``filter_``."""
149173

150-
start_text = filter_.get("start")
151-
end_text = filter_.get("end")
152-
if not start_text and not end_text:
174+
if not filter_.get("start") and not filter_.get("end"):
153175
return False
154-
if start_text:
155-
start = datetime.strptime(start_text, "%Y%m%dT%H%M%SZ")
156-
else:
157-
start = datetime.min
158-
if end_text:
159-
end = datetime.strptime(end_text, "%Y%m%dT%H%M%SZ")
160-
else:
161-
end = datetime.max
162-
start = start.replace(tzinfo=timezone.utc)
163-
end = end.replace(tzinfo=timezone.utc)
164176

177+
start, end = parse_time_range(filter_)
165178
matched = False
166179

167180
def range_fn(range_start: datetime, range_end: datetime,
@@ -181,6 +194,35 @@ def infinity_fn(start: datetime) -> bool:
181194
return matched
182195

183196

197+
def time_range_fill(vobject_item: vobject.base.Component,
198+
filter_: ET.Element, child_name: str, n: int = 1
199+
) -> List[Tuple[datetime, datetime]]:
200+
"""Create a list of ``n`` occurances from the component/property ``child_name``
201+
of ``vobject_item``."""
202+
if not filter_.get("start") and not filter_.get("end"):
203+
return []
204+
205+
start, end = parse_time_range(filter_)
206+
ranges: List[Tuple[datetime, datetime]] = []
207+
208+
def range_fn(range_start: datetime, range_end: datetime,
209+
is_recurrence: bool) -> bool:
210+
nonlocal ranges
211+
if start < range_end and range_start < end:
212+
ranges.append((range_start, range_end))
213+
if n > 0 and len(ranges) >= n:
214+
return True
215+
if end < range_start and not is_recurrence:
216+
return True
217+
return False
218+
219+
def infinity_fn(range_start: datetime) -> bool:
220+
return False
221+
222+
visit_time_ranges(vobject_item, child_name, range_fn, infinity_fn)
223+
return ranges
224+
225+
184226
def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
185227
range_fn: Callable[[datetime, datetime, bool], bool],
186228
infinity_fn: Callable[[datetime], bool]) -> None:
@@ -543,20 +585,7 @@ def simplify_prefilters(filters: Iterable[ET.Element], collection_tag: str
543585
if time_filter.tag != xmlutils.make_clark("C:time-range"):
544586
simple = False
545587
continue
546-
start_text = time_filter.get("start")
547-
end_text = time_filter.get("end")
548-
if start_text:
549-
start = math.floor(datetime.strptime(
550-
start_text, "%Y%m%dT%H%M%SZ").replace(
551-
tzinfo=timezone.utc).timestamp())
552-
else:
553-
start = TIMESTAMP_MIN
554-
if end_text:
555-
end = math.ceil(datetime.strptime(
556-
end_text, "%Y%m%dT%H%M%SZ").replace(
557-
tzinfo=timezone.utc).timestamp())
558-
else:
559-
end = TIMESTAMP_MAX
588+
start, end = time_range_timestamps(time_filter)
560589
return tag, start, end, simple
561590
return tag, TIMESTAMP_MIN, TIMESTAMP_MAX, simple
562591
return None, TIMESTAMP_MIN, TIMESTAMP_MAX, simple

radicale/tests/__init__.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@
3131
from typing import Any, Dict, List, Optional, Tuple, Union
3232

3333
import defusedxml.ElementTree as DefusedET
34+
import vobject
3435

3536
import radicale
3637
from radicale import app, config, types, xmlutils
3738

38-
RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]]]]
39+
RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]]
3940

4041
# Enable debug output
4142
radicale.log.logger.setLevel(logging.DEBUG)
@@ -107,8 +108,7 @@ def start_response(status_: str, headers_: List[Tuple[str, str]]
107108
def parse_responses(text: str) -> RESPONSES:
108109
xml = DefusedET.fromstring(text)
109110
assert xml.tag == xmlutils.make_clark("D:multistatus")
110-
path_responses: Dict[str, Union[
111-
int, Dict[str, Tuple[int, ET.Element]]]] = {}
111+
path_responses: RESPONSES = {}
112112
for response in xml.findall(xmlutils.make_clark("D:response")):
113113
href = response.find(xmlutils.make_clark("D:href"))
114114
assert href.text not in path_responses
@@ -133,6 +133,12 @@ def parse_responses(text: str) -> RESPONSES:
133133
path_responses[href.text] = prop_responses
134134
return path_responses
135135

136+
@staticmethod
137+
def parse_free_busy(text: str) -> RESPONSES:
138+
path_responses: RESPONSES = {}
139+
path_responses[""] = vobject.readOne(text)
140+
return path_responses
141+
136142
def get(self, path: str, check: Optional[int] = 200, **kwargs
137143
) -> Tuple[int, str]:
138144
assert "data" not in kwargs
@@ -177,13 +183,18 @@ def proppatch(self, path: str, data: Optional[str] = None,
177183
return status, responses
178184

179185
def report(self, path: str, data: str, check: Optional[int] = 207,
186+
is_xml: Optional[bool] = True,
180187
**kwargs) -> Tuple[int, RESPONSES]:
181188
status, _, answer = self.request("REPORT", path, data, check=check,
182189
**kwargs)
183190
if status < 200 or 300 <= status:
184191
return status, {}
185192
assert answer is not None
186-
return status, self.parse_responses(answer)
193+
if is_xml:
194+
parsed = self.parse_responses(answer)
195+
else:
196+
parsed = self.parse_free_busy(answer)
197+
return status, parsed
187198

188199
def delete(self, path: str, check: Optional[int] = 200, **kwargs
189200
) -> Tuple[int, RESPONSES]:

0 commit comments

Comments
 (0)