report.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829
  1. # This file is part of Radicale - CalDAV and CardDAV server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2017 Guillaume Ayoub
  5. # Copyright © 2017-2021 Unrud <unrud@outlook.com>
  6. # Copyright © 2024-2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
  7. # Copyright © 2024-2024 Ray <ray@react0r.com>
  8. # Copyright © 2024-2025 Georgiy <metallerok@gmail.com>
  9. # Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
  10. # Copyright © 2025-2025 David Greaves <david@dgreaves.com>
  11. #
  12. # This library is free software: you can redistribute it and/or modify
  13. # it under the terms of the GNU General Public License as published by
  14. # the Free Software Foundation, either version 3 of the License, or
  15. # (at your option) any later version.
  16. #
  17. # This library is distributed in the hope that it will be useful,
  18. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  19. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  20. # GNU General Public License for more details.
  21. #
  22. # You should have received a copy of the GNU General Public License
  23. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  24. import contextlib
  25. import copy
  26. import datetime
  27. import posixpath
  28. import socket
  29. import xml.etree.ElementTree as ET
  30. from http import client
  31. from typing import (Callable, Iterable, Iterator, List, Optional, Sequence,
  32. Tuple, Union)
  33. from urllib.parse import unquote, urlparse
  34. import vobject
  35. import vobject.base
  36. from vobject.base import ContentLine
  37. import radicale.item as radicale_item
  38. from radicale import httputils, pathutils, storage, types, xmlutils
  39. from radicale.app.base import Access, ApplicationBase
  40. from radicale.item import filter as radicale_filter
  41. from radicale.log import logger
  42. DT_FORMAT_TIMESTAMP: str = '%Y%m%dT%H%M%SZ'
  43. DT_FORMAT_DATE: str = '%Y%m%d'
  44. def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
  45. collection: storage.BaseCollection, encoding: str,
  46. unlock_storage_fn: Callable[[], None],
  47. max_occurrence: int
  48. ) -> Tuple[int, Union[ET.Element, str]]:
  49. # NOTE: this function returns both an Element and a string because
  50. # free-busy reports are an edge-case on the return type according
  51. # to the spec.
  52. multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
  53. if xml_request is None:
  54. return client.MULTI_STATUS, multistatus
  55. root = xml_request
  56. if (root.tag == xmlutils.make_clark("C:free-busy-query") and
  57. collection.tag != "VCALENDAR"):
  58. logger.warning("Invalid REPORT method %r on %r requested",
  59. xmlutils.make_human_tag(root.tag), path)
  60. return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
  61. time_range_element = root.find(xmlutils.make_clark("C:time-range"))
  62. assert isinstance(time_range_element, ET.Element)
  63. # Build a single filter from the free busy query for retrieval
  64. # TODO: filter for VFREEBUSY in additional to VEVENT but
  65. # test_filter doesn't support that yet.
  66. vevent_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
  67. attrib={'name': 'VEVENT'})
  68. vevent_cf_element.append(time_range_element)
  69. vcalendar_cf_element = ET.Element(xmlutils.make_clark("C:comp-filter"),
  70. attrib={'name': 'VCALENDAR'})
  71. vcalendar_cf_element.append(vevent_cf_element)
  72. filter_element = ET.Element(xmlutils.make_clark("C:filter"))
  73. filter_element.append(vcalendar_cf_element)
  74. filters = (filter_element,)
  75. # First pull from storage
  76. retrieved_items = list(collection.get_filtered(filters))
  77. # !!! Don't access storage after this !!!
  78. unlock_storage_fn()
  79. cal = vobject.iCalendar()
  80. collection_tag = collection.tag
  81. while retrieved_items:
  82. # Second filtering before evaluating occurrences.
  83. # ``item.vobject_item`` might be accessed during filtering.
  84. # Don't keep reference to ``item``, because VObject requires a lot of
  85. # memory.
  86. item, filter_matched = retrieved_items.pop(0)
  87. if not filter_matched:
  88. try:
  89. if not test_filter(collection_tag, item, filter_element):
  90. continue
  91. except ValueError as e:
  92. raise ValueError("Failed to free-busy filter item %r from %r: %s" %
  93. (item.href, collection.path, e)) from e
  94. except Exception as e:
  95. raise RuntimeError("Failed to free-busy filter item %r from %r: %s" %
  96. (item.href, collection.path, e)) from e
  97. fbtype = None
  98. if item.component_name == 'VEVENT':
  99. transp = getattr(item.vobject_item.vevent, 'transp', None)
  100. if transp and transp.value != 'OPAQUE':
  101. continue
  102. status = getattr(item.vobject_item.vevent, 'status', None)
  103. if not status or status.value == 'CONFIRMED':
  104. fbtype = 'BUSY'
  105. elif status.value == 'CANCELLED':
  106. fbtype = 'FREE'
  107. elif status.value == 'TENTATIVE':
  108. fbtype = 'BUSY-TENTATIVE'
  109. else:
  110. # Could do fbtype = status.value for x-name, I prefer this
  111. fbtype = 'BUSY'
  112. # TODO: coalesce overlapping periods
  113. if max_occurrence > 0:
  114. n_occurrences = max_occurrence+1
  115. else:
  116. n_occurrences = 0
  117. occurrences = radicale_filter.time_range_fill(item.vobject_item,
  118. time_range_element,
  119. "VEVENT",
  120. n=n_occurrences)
  121. if len(occurrences) >= max_occurrence:
  122. raise ValueError("FREEBUSY occurrences limit of {} hit"
  123. .format(max_occurrence))
  124. for occurrence in occurrences:
  125. vfb = cal.add('vfreebusy')
  126. vfb.add('dtstamp').value = item.vobject_item.vevent.dtstamp.value
  127. vfb.add('dtstart').value, vfb.add('dtend').value = occurrence
  128. if fbtype:
  129. vfb.add('fbtype').value = fbtype
  130. return (client.OK, cal.serialize())
  131. def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
  132. collection: storage.BaseCollection, encoding: str,
  133. unlock_storage_fn: Callable[[], None],
  134. max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = ""
  135. ) -> Tuple[int, ET.Element]:
  136. """Read and answer REPORT requests that return XML.
  137. Read rfc3253-3.6 for info.
  138. """
  139. logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path)
  140. multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
  141. if xml_request is None:
  142. return client.MULTI_STATUS, multistatus
  143. root = xml_request
  144. if root.tag in (xmlutils.make_clark("D:principal-search-property-set"),
  145. xmlutils.make_clark("D:principal-property-search"),
  146. xmlutils.make_clark("D:expand-property")):
  147. # We don't support searching for principals or indirect retrieving of
  148. # properties, just return an empty result.
  149. # InfCloud asks for expand-property reports (even if we don't announce
  150. # support for them) and stops working if an error code is returned.
  151. logger.warning("Unsupported REPORT method %r on %r requested",
  152. xmlutils.make_human_tag(root.tag), path)
  153. return client.MULTI_STATUS, multistatus
  154. if (root.tag == xmlutils.make_clark("C:calendar-multiget") and
  155. collection.tag != "VCALENDAR" or
  156. root.tag == xmlutils.make_clark("CR:addressbook-multiget") and
  157. collection.tag != "VADDRESSBOOK" or
  158. root.tag == xmlutils.make_clark("D:sync-collection") and
  159. collection.tag not in ("VADDRESSBOOK", "VCALENDAR")):
  160. logger.warning("Invalid REPORT method %r on %r requested",
  161. xmlutils.make_human_tag(root.tag), path)
  162. return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
  163. props: Union[ET.Element, List]
  164. if root.find(xmlutils.make_clark("D:prop")) is not None:
  165. props = root.find(xmlutils.make_clark("D:prop")) # type: ignore[assignment]
  166. else:
  167. props = []
  168. hreferences: Iterable[str]
  169. if root.tag in (
  170. xmlutils.make_clark("C:calendar-multiget"),
  171. xmlutils.make_clark("CR:addressbook-multiget")):
  172. # Read rfc4791-7.9 for info
  173. hreferences = set()
  174. for href_element in root.findall(xmlutils.make_clark("D:href")):
  175. temp_url_path = urlparse(href_element.text).path
  176. assert isinstance(temp_url_path, str)
  177. href_path = pathutils.sanitize_path(unquote(temp_url_path))
  178. if (href_path + "/").startswith(base_prefix + "/"):
  179. hreferences.add(href_path[len(base_prefix):])
  180. else:
  181. logger.warning("Skipping invalid path %r in REPORT request on "
  182. "%r", href_path, path)
  183. elif root.tag == xmlutils.make_clark("D:sync-collection"):
  184. old_sync_token_element = root.find(
  185. xmlutils.make_clark("D:sync-token"))
  186. old_sync_token = ""
  187. if old_sync_token_element is not None and old_sync_token_element.text:
  188. old_sync_token = old_sync_token_element.text.strip()
  189. logger.debug("Client provided sync token: %r", old_sync_token)
  190. try:
  191. sync_token, names = collection.sync(old_sync_token)
  192. except ValueError as e:
  193. # Invalid sync token
  194. logger.warning("Client provided invalid sync token for path %r (user %r from %s%s): %s",
  195. path, user, remote_addr, remote_useragent, e, exc_info=True)
  196. # client.CONFLICT doesn't work with some clients (e.g. InfCloud)
  197. return (client.FORBIDDEN,
  198. xmlutils.webdav_error("D:valid-sync-token"))
  199. hreferences = (pathutils.unstrip_path(
  200. posixpath.join(collection.path, n)) for n in names)
  201. # Append current sync token to response
  202. sync_token_element = ET.Element(xmlutils.make_clark("D:sync-token"))
  203. sync_token_element.text = sync_token
  204. multistatus.append(sync_token_element)
  205. else:
  206. hreferences = (path,)
  207. filters = (
  208. root.findall(xmlutils.make_clark("C:filter")) +
  209. root.findall(xmlutils.make_clark("CR:filter")))
  210. expand = root.find(".//" + xmlutils.make_clark("C:expand"))
  211. # if we have expand prop we use "filter (except time range) -> expand -> filter (only time range)" approach
  212. time_range_element = None
  213. main_filters = []
  214. for filter_ in filters:
  215. # extract time-range filter for processing after main filters
  216. # for expand request
  217. filter_copy = copy.deepcopy(filter_)
  218. if expand is not None:
  219. logger.debug("TRACE/REPORT/xml_report: expand")
  220. for comp_filter in filter_copy.findall(".//" + xmlutils.make_clark("C:comp-filter")):
  221. if comp_filter.get("name", "").upper() == "VCALENDAR":
  222. continue
  223. time_range_element = comp_filter.find(xmlutils.make_clark("C:time-range"))
  224. if time_range_element is not None:
  225. comp_filter.remove(time_range_element)
  226. main_filters.append(filter_copy)
  227. # Retrieve everything required for finishing the request.
  228. retrieved_items = list(retrieve_items(
  229. base_prefix, path, collection, hreferences, main_filters, multistatus))
  230. collection_tag = collection.tag
  231. # !!! Don't access storage after this !!!
  232. unlock_storage_fn()
  233. n_vevents = 0
  234. while retrieved_items:
  235. # ``item.vobject_item`` might be accessed during filtering.
  236. # Don't keep reference to ``item``, because VObject requires a lot of
  237. # memory.
  238. item, filters_matched = retrieved_items.pop(0)
  239. if filters and not filters_matched:
  240. try:
  241. if not all(test_filter(collection_tag, item, filter_)
  242. for filter_ in main_filters):
  243. continue
  244. except ValueError as e:
  245. raise ValueError("Failed to filter item %r from %r: %s" %
  246. (item.href, collection.path, e)) from e
  247. except Exception as e:
  248. raise RuntimeError("Failed to filter item %r from %r: %s" %
  249. (item.href, collection.path, e)) from e
  250. found_props = []
  251. not_found_props = []
  252. for prop in props:
  253. element = ET.Element(prop.tag)
  254. if prop.tag == xmlutils.make_clark("D:getcontenttype"):
  255. element.text = xmlutils.get_content_type(item, encoding)
  256. found_props.append(element)
  257. elif prop.tag in (
  258. xmlutils.make_clark("C:calendar-data"),
  259. xmlutils.make_clark("D:getetag"),
  260. xmlutils.make_clark("CR:address-data")):
  261. element.text = item.serialize()
  262. if (expand is not None) and item.component_name == 'VEVENT':
  263. starts = expand.get('start')
  264. ends = expand.get('end')
  265. if (starts is None) or (ends is None):
  266. return client.FORBIDDEN, \
  267. xmlutils.webdav_error("C:expand")
  268. start = datetime.datetime.strptime(
  269. starts, DT_FORMAT_TIMESTAMP
  270. ).replace(tzinfo=datetime.timezone.utc)
  271. end = datetime.datetime.strptime(
  272. ends, DT_FORMAT_TIMESTAMP
  273. ).replace(tzinfo=datetime.timezone.utc)
  274. time_range_start = None
  275. time_range_end = None
  276. if time_range_element is not None:
  277. time_range_start, time_range_end = radicale_filter.parse_time_range(time_range_element)
  278. (expanded_element, n_vev) = _expand(
  279. element=element, item=copy.copy(item),
  280. start=start, end=end,
  281. time_range_start=time_range_start, time_range_end=time_range_end,
  282. max_occurrence=max_occurrence,
  283. )
  284. if n_vev == 0:
  285. logger.debug("No VEVENTs found after expansion for %r, skipping", item.href)
  286. continue
  287. n_vevents += n_vev
  288. if prop.tag == xmlutils.make_clark("D:getetag"):
  289. if n_vev > 0:
  290. logger.debug("TRACE/REPORT/xml_report: getetag/expanded element")
  291. element.text = item.etag
  292. found_props.append(element)
  293. else:
  294. logger.debug("TRACE/REPORT/xml_report: getetag/no expanded element")
  295. else:
  296. logger.debug("TRACE/REPORT/xml_report: default")
  297. found_props.append(expanded_element)
  298. else:
  299. if prop.tag == xmlutils.make_clark("D:getetag"):
  300. element.text = item.etag
  301. found_props.append(element)
  302. else:
  303. found_props.append(element)
  304. if hasattr(item.vobject_item, "vevent_list"):
  305. n_vevents += len(item.vobject_item.vevent_list)
  306. # Avoid DoS with too many events
  307. if max_occurrence and n_vevents > max_occurrence:
  308. raise ValueError("REPORT occurrences limit of {} hit"
  309. .format(max_occurrence))
  310. else:
  311. not_found_props.append(element)
  312. assert item.href
  313. uri = pathutils.unstrip_path(
  314. posixpath.join(collection.path, item.href))
  315. if found_props or not_found_props:
  316. multistatus.append(xml_item_response(
  317. base_prefix, uri, found_props=found_props,
  318. not_found_props=not_found_props, found_item=True))
  319. return client.MULTI_STATUS, multistatus
  320. def _expand(
  321. element: ET.Element,
  322. item: radicale_item.Item,
  323. start: datetime.datetime,
  324. end: datetime.datetime,
  325. time_range_start: Optional[datetime.datetime] = None,
  326. time_range_end: Optional[datetime.datetime] = None,
  327. max_occurrence: int = 0,
  328. ) -> Tuple[ET.Element, int]:
  329. vevent_component: vobject.base.Component = copy.copy(item.vobject_item)
  330. logger.info("Expanding event %s", item.href)
  331. logger.debug(f"Expand range: {start} to {end}")
  332. logger.debug(f"Time range: {time_range_start} to {time_range_end}")
  333. # Split the vevents included in the component into one that contains the
  334. # recurrence information and others that contain a recurrence id to
  335. # override instances.
  336. base_vevent, vevents_overridden = _split_overridden_vevents(vevent_component)
  337. dt_format = DT_FORMAT_TIMESTAMP
  338. all_day_event = False
  339. if type(base_vevent.dtstart.value) is datetime.date:
  340. # If an event comes to us with a dtstart specified as a date
  341. # then in the response we return the date, not datetime
  342. dt_format = DT_FORMAT_DATE
  343. all_day_event = True
  344. # In case of dates, we need to remove timezone information since
  345. # rruleset.between computes with datetimes without timezone information
  346. start = start.replace(tzinfo=None)
  347. end = end.replace(tzinfo=None)
  348. if time_range_start is not None and time_range_end is not None:
  349. time_range_start = time_range_start.replace(tzinfo=None)
  350. time_range_end = time_range_end.replace(tzinfo=None)
  351. for vevent in vevents_overridden:
  352. _strip_single_event(vevent, dt_format)
  353. duration = None
  354. if hasattr(base_vevent, "dtend"):
  355. duration = base_vevent.dtend.value - base_vevent.dtstart.value
  356. elif hasattr(base_vevent, "duration"):
  357. try:
  358. duration = base_vevent.duration.value
  359. if duration.total_seconds() <= 0:
  360. logger.warning("Invalid DURATION: %s", duration)
  361. duration = None
  362. except (AttributeError, TypeError) as e:
  363. logger.warning("Failed to parse DURATION: %s", e)
  364. duration = None
  365. # Generate EXDATE to remove from expansion range
  366. exdates_set: set[datetime.datetime] = set()
  367. if hasattr(base_vevent, 'exdate'):
  368. exdates = base_vevent.exdate.value
  369. if not isinstance(exdates, list):
  370. exdates = [exdates]
  371. exdates_set = {
  372. exdate.astimezone(datetime.timezone.utc) if isinstance(exdate, datetime.datetime)
  373. else datetime.datetime.fromordinal(exdate.toordinal()).replace(tzinfo=None)
  374. for exdate in exdates
  375. }
  376. logger.debug("EXDATE values: %s", exdates_set)
  377. events_for_filtering = vevents_overridden
  378. rruleset = None
  379. if hasattr(base_vevent, 'rrule'):
  380. rruleset = base_vevent.getrruleset()
  381. else:
  382. # if event does not have rrule, only include base event
  383. events_for_filtering = [base_vevent]
  384. filtered_vevents = []
  385. if rruleset:
  386. # This function uses datetimes internally without timezone info for dates
  387. # A vobject rruleset is for the event dtstart.
  388. # Expanded over a given time range this will not include
  389. # events which started before the time range but are still
  390. # ongoing at the start of the range
  391. # To accomodate this, reduce the start time by the duration of
  392. # the event. If this introduces an extra reccurence point then
  393. # that event should be included as it is still ongoing. If no
  394. # extra point is generated then it was a no-op.
  395. rstart = start - duration if duration and duration.total_seconds() > 0 else start
  396. recurrences = rruleset.between(rstart, end, inc=True, count=max_occurrence)
  397. if max_occurrence and len(recurrences) >= max_occurrence:
  398. # this shouldn't be > and if it's == then assume a limit
  399. # was hit and ignore that maybe some would be filtered out
  400. # by EXDATE etc. This is anti-DoS, not precise limits
  401. raise ValueError("REPORT occurrences limit of {} hit"
  402. .format(max_occurrence))
  403. _strip_component(vevent_component)
  404. _strip_single_event(base_vevent, dt_format)
  405. i_overridden = 0
  406. for recurrence_dt in recurrences:
  407. recurrence_utc = recurrence_dt if all_day_event else recurrence_dt.astimezone(datetime.timezone.utc)
  408. logger.debug("Processing recurrence: %s (all_day_event: %s)", recurrence_utc, all_day_event)
  409. # Apply time-range filter
  410. if time_range_start is not None and time_range_end is not None:
  411. dtstart = recurrence_utc
  412. dtend = dtstart + duration if duration else dtstart
  413. # Start includes the time, end does not
  414. if not (dtstart <= time_range_end and dtend > time_range_start):
  415. logger.debug("Recurrence %s filtered out by time-range", recurrence_utc)
  416. continue
  417. # Check exdate
  418. if recurrence_utc in exdates_set:
  419. logger.debug("Recurrence %s excluded by EXDATE", recurrence_utc)
  420. continue
  421. # Check for overridden instances
  422. i_overridden, vevent = _find_overridden(i_overridden, vevents_overridden, recurrence_utc, dt_format)
  423. if not vevent:
  424. # Create new instance from recurrence
  425. vevent = base_vevent.duplicate(base_vevent)
  426. # For all day events, the system timezone may influence the
  427. # results, so use recurrence_dt
  428. recurrence_id = recurrence_dt if all_day_event else recurrence_utc
  429. logger.debug("Creating new VEVENT with RECURRENCE-ID: %s", recurrence_id)
  430. vevent.recurrence_id = ContentLine(
  431. name='RECURRENCE-ID',
  432. value=recurrence_id, params={}
  433. )
  434. _convert_to_utc(vevent, 'recurrence_id', dt_format)
  435. suffix = ''
  436. if (dt_format == DT_FORMAT_DATE):
  437. suffix = ';VALUE=DATE'
  438. else:
  439. suffix = ''
  440. vevent.dtstart = ContentLine(
  441. name='DTSTART' + suffix,
  442. value=recurrence_id.strftime(dt_format), params={}
  443. )
  444. # if there is a DTEND, override it. Duration does not need changing
  445. if hasattr(vevent, "dtend"):
  446. vevent.dtend = ContentLine(
  447. name='DTEND' + suffix,
  448. value=(recurrence_id + duration).strftime(dt_format), params={}
  449. )
  450. filtered_vevents.append(vevent)
  451. # Filter overridden and non-recurring events
  452. if time_range_start is not None and time_range_end is not None:
  453. for vevent in events_for_filtering:
  454. dtstart = vevent.dtstart.value
  455. # Handle string values for DTSTART/DTEND
  456. if isinstance(dtstart, str):
  457. try:
  458. dtstart = datetime.datetime.strptime(dtstart, dt_format)
  459. if all_day_event:
  460. dtstart = dtstart.date()
  461. except ValueError as e:
  462. logger.warning("Invalid DTSTART format: %s, error: %s", dtstart, e)
  463. continue
  464. dtend = dtstart + duration if duration else dtstart
  465. logger.debug(
  466. "Filtering VEVENT with DTSTART: %s (type: %s), DTEND: %s (type: %s)",
  467. dtstart, type(dtstart), dtend, type(dtend))
  468. # Convert to datetime for comparison
  469. if all_day_event and isinstance(dtstart, datetime.date) and not isinstance(dtstart, datetime.datetime):
  470. dtstart = datetime.datetime.fromordinal(dtstart.toordinal()).replace(tzinfo=None)
  471. dtend = datetime.datetime.fromordinal(dtend.toordinal()).replace(tzinfo=None)
  472. elif not all_day_event and isinstance(dtstart, datetime.datetime) \
  473. and isinstance(dtend, datetime.datetime):
  474. dtstart = dtstart.replace(tzinfo=datetime.timezone.utc)
  475. dtend = dtend.replace(tzinfo=datetime.timezone.utc)
  476. else:
  477. logger.warning("Unexpected DTSTART/DTEND type: dtstart=%s, dtend=%s", type(dtstart), type(dtend))
  478. continue
  479. if dtstart < time_range_end and dtend > time_range_start:
  480. if vevent not in filtered_vevents: # Avoid duplicates
  481. logger.debug("VEVENT passed time-range filter: %s", dtstart)
  482. filtered_vevents.append(vevent)
  483. else:
  484. logger.debug("VEVENT filtered out: %s", dtstart)
  485. # Rebuild component
  486. if not filtered_vevents:
  487. element.text = ""
  488. return element, 0
  489. else:
  490. vevent_component.vevent_list = filtered_vevents
  491. logger.debug("lbt: vevent_component %s", vevent_component)
  492. element.text = vevent_component.serialize()
  493. return element, len(filtered_vevents)
  494. def _convert_timezone(vevent: vobject.icalendar.RecurringComponent,
  495. name_prop: str,
  496. name_content_line: str):
  497. prop = getattr(vevent, name_prop, None)
  498. if prop:
  499. if type(prop.value) is datetime.date:
  500. date_time = datetime.datetime.fromordinal(
  501. prop.value.toordinal()
  502. ).replace(tzinfo=datetime.timezone.utc)
  503. else:
  504. date_time = prop.value.astimezone(datetime.timezone.utc)
  505. setattr(vevent, name_prop, ContentLine(name=name_content_line, value=date_time, params=[]))
  506. def _convert_to_utc(vevent: vobject.icalendar.RecurringComponent,
  507. name_prop: str,
  508. dt_format: str):
  509. prop = getattr(vevent, name_prop, None)
  510. if prop:
  511. setattr(vevent, name_prop, ContentLine(name=prop.name, value=prop.value.strftime(dt_format), params=[]))
  512. def _strip_single_event(vevent: vobject.icalendar.RecurringComponent, dt_format: str) -> None:
  513. _convert_timezone(vevent, 'dtstart', 'DTSTART')
  514. _convert_timezone(vevent, 'dtend', 'DTEND')
  515. _convert_timezone(vevent, 'recurrence_id', 'RECURRENCE-ID')
  516. # There is something strange behaviour during serialization native datetime, so converting manually
  517. _convert_to_utc(vevent, 'dtstart', dt_format)
  518. _convert_to_utc(vevent, 'dtend', dt_format)
  519. _convert_to_utc(vevent, 'recurrence_id', dt_format)
  520. try:
  521. delattr(vevent, 'rrule')
  522. delattr(vevent, 'exdate')
  523. delattr(vevent, 'exrule')
  524. delattr(vevent, 'rdate')
  525. except AttributeError:
  526. pass
  527. def _strip_component(vevent: vobject.base.Component) -> None:
  528. timezones_to_remove = []
  529. for component in vevent.components():
  530. if component.name == 'VTIMEZONE':
  531. timezones_to_remove.append(component)
  532. for timezone in timezones_to_remove:
  533. vevent.remove(timezone)
  534. def _split_overridden_vevents(
  535. component: vobject.base.Component,
  536. ) -> Tuple[
  537. vobject.icalendar.RecurringComponent,
  538. List[vobject.icalendar.RecurringComponent]
  539. ]:
  540. vevent_recurrence = None
  541. vevents_overridden = []
  542. for vevent in component.vevent_list:
  543. if hasattr(vevent, 'recurrence_id'):
  544. vevents_overridden += [vevent]
  545. elif vevent_recurrence:
  546. raise ValueError(
  547. f"component with UID {vevent.uid} "
  548. f"has more than one vevent with recurrence information"
  549. )
  550. else:
  551. vevent_recurrence = vevent
  552. if vevent_recurrence:
  553. return (
  554. vevent_recurrence, sorted(
  555. vevents_overridden,
  556. key=lambda vevent: vevent.recurrence_id.value
  557. )
  558. )
  559. else:
  560. raise ValueError(
  561. f"component with UID {vevent.uid} "
  562. f"does not have a vevent without a recurrence_id"
  563. )
  564. def _find_overridden(
  565. start: int,
  566. vevents: List[vobject.icalendar.RecurringComponent],
  567. dt: datetime.datetime,
  568. dt_format: str
  569. ) -> Tuple[int, Optional[vobject.icalendar.RecurringComponent]]:
  570. for i in range(start, len(vevents)):
  571. dt_event = datetime.datetime.strptime(
  572. vevents[i].recurrence_id.value,
  573. dt_format
  574. ).replace(tzinfo=datetime.timezone.utc)
  575. if dt_event == dt:
  576. return (i + 1, vevents[i])
  577. return (start, None)
  578. def xml_item_response(base_prefix: str, href: str,
  579. found_props: Sequence[ET.Element] = (),
  580. not_found_props: Sequence[ET.Element] = (),
  581. found_item: bool = True) -> ET.Element:
  582. response = ET.Element(xmlutils.make_clark("D:response"))
  583. href_element = ET.Element(xmlutils.make_clark("D:href"))
  584. href_element.text = xmlutils.make_href(base_prefix, href)
  585. response.append(href_element)
  586. if found_item:
  587. for code, props in ((200, found_props), (404, not_found_props)):
  588. if props:
  589. propstat = ET.Element(xmlutils.make_clark("D:propstat"))
  590. status = ET.Element(xmlutils.make_clark("D:status"))
  591. status.text = xmlutils.make_response(code)
  592. prop_element = ET.Element(xmlutils.make_clark("D:prop"))
  593. for prop in props:
  594. prop_element.append(prop)
  595. propstat.append(prop_element)
  596. propstat.append(status)
  597. response.append(propstat)
  598. else:
  599. status = ET.Element(xmlutils.make_clark("D:status"))
  600. status.text = xmlutils.make_response(404)
  601. response.append(status)
  602. return response
  603. def retrieve_items(
  604. base_prefix: str, path: str, collection: storage.BaseCollection,
  605. hreferences: Iterable[str], filters: Sequence[ET.Element],
  606. multistatus: ET.Element) -> Iterator[Tuple[radicale_item.Item, bool]]:
  607. """Retrieves all items that are referenced in ``hreferences`` from
  608. ``collection`` and adds 404 responses for missing and invalid items
  609. to ``multistatus``."""
  610. collection_requested = False
  611. def get_names() -> Iterator[str]:
  612. """Extracts all names from references in ``hreferences`` and adds
  613. 404 responses for invalid references to ``multistatus``.
  614. If the whole collections is referenced ``collection_requested``
  615. gets set to ``True``."""
  616. nonlocal collection_requested
  617. for hreference in hreferences:
  618. try:
  619. name = pathutils.name_from_path(hreference, collection)
  620. except ValueError as e:
  621. logger.warning("Skipping invalid path %r in REPORT request on "
  622. "%r: %s", hreference, path, e)
  623. response = xml_item_response(base_prefix, hreference,
  624. found_item=False)
  625. multistatus.append(response)
  626. continue
  627. if name:
  628. # Reference is an item
  629. yield name
  630. else:
  631. # Reference is a collection
  632. collection_requested = True
  633. for name, item in collection.get_multi(get_names()):
  634. if not item:
  635. uri = pathutils.unstrip_path(posixpath.join(collection.path, name))
  636. response = xml_item_response(base_prefix, uri, found_item=False)
  637. multistatus.append(response)
  638. else:
  639. yield item, False
  640. if collection_requested:
  641. logger.debug("TRACE/REPORT/retrieve_items: get_filtered")
  642. yield from collection.get_filtered(filters)
  643. def test_filter(collection_tag: str, item: radicale_item.Item,
  644. filter_: ET.Element) -> bool:
  645. """Match an item against a filter."""
  646. if (collection_tag == "VCALENDAR" and
  647. filter_.tag != xmlutils.make_clark("C:%s" % filter_)):
  648. if len(filter_) == 0:
  649. return True
  650. if len(filter_) > 1:
  651. raise ValueError("Filter with %d children" % len(filter_))
  652. if filter_[0].tag != xmlutils.make_clark("C:comp-filter"):
  653. raise ValueError("Unexpected %r in filter" % filter_[0].tag)
  654. return radicale_filter.comp_match(item, filter_[0])
  655. if (collection_tag == "VADDRESSBOOK" and
  656. filter_.tag != xmlutils.make_clark("CR:%s" % filter_)):
  657. for child in filter_:
  658. if child.tag != xmlutils.make_clark("CR:prop-filter"):
  659. raise ValueError("Unexpected %r in filter" % child.tag)
  660. test = filter_.get("test", "anyof")
  661. if test == "anyof":
  662. return any(radicale_filter.prop_match(item.vobject_item, f, "CR")
  663. for f in filter_)
  664. if test == "allof":
  665. return all(radicale_filter.prop_match(item.vobject_item, f, "CR")
  666. for f in filter_)
  667. raise ValueError("Unsupported filter test: %r" % test)
  668. raise ValueError("Unsupported filter %r for %r" %
  669. (filter_.tag, collection_tag))
  670. class ApplicationPartReport(ApplicationBase):
  671. def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
  672. path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
  673. """Manage REPORT request."""
  674. access = Access(self._rights, user, path)
  675. if not access.check("r"):
  676. return httputils.NOT_ALLOWED
  677. try:
  678. xml_content = self._read_xml_request_body(environ)
  679. except RuntimeError as e:
  680. logger.warning("Bad REPORT request on %r: %s", path, e,
  681. exc_info=True)
  682. return httputils.BAD_REQUEST
  683. except socket.timeout:
  684. logger.debug("Client timed out", exc_info=True)
  685. return httputils.REQUEST_TIMEOUT
  686. with contextlib.ExitStack() as lock_stack:
  687. lock_stack.enter_context(self._storage.acquire_lock("r", user))
  688. item = next(iter(self._storage.discover(path)), None)
  689. if not item:
  690. return httputils.NOT_FOUND
  691. if not access.check("r", item):
  692. return httputils.NOT_ALLOWED
  693. if isinstance(item, storage.BaseCollection):
  694. collection = item
  695. else:
  696. assert item.collection is not None
  697. collection = item.collection
  698. max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence")
  699. if xml_content is not None and \
  700. xml_content.tag == xmlutils.make_clark("C:free-busy-query"):
  701. try:
  702. status, body = free_busy_report(
  703. base_prefix, path, xml_content, collection, self._encoding,
  704. lock_stack.close, max_occurrence)
  705. except ValueError as e:
  706. logger.warning(
  707. "Bad REPORT request on %r: %s", path, e, exc_info=True)
  708. return httputils.BAD_REQUEST
  709. headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
  710. return status, headers, str(body), xmlutils.pretty_xml(xml_content)
  711. else:
  712. try:
  713. status, xml_answer = xml_report(
  714. base_prefix, path, xml_content, collection, self._encoding,
  715. lock_stack.close, max_occurrence, user, remote_host, remote_useragent)
  716. except ValueError as e:
  717. logger.warning(
  718. "Bad REPORT request on %r: %s", path, e, exc_info=True)
  719. return httputils.BAD_REQUEST
  720. headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
  721. return status, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)