report.py 32 KB

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