report.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  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-2018 Unrud <unrud@outlook.com>
  6. #
  7. # This library is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This library is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  19. import contextlib
  20. import datetime
  21. import posixpath
  22. import socket
  23. import copy
  24. import xml.etree.ElementTree as ET
  25. from http import client
  26. from typing import (
  27. Callable, Iterable, Iterator,
  28. Optional, Sequence, Tuple, List,
  29. )
  30. from urllib.parse import unquote, urlparse
  31. import radicale.item as radicale_item
  32. from radicale import httputils, pathutils, storage, types, xmlutils
  33. from radicale.app.base import Access, ApplicationBase
  34. from radicale.item import filter as radicale_filter
  35. from radicale.log import logger
  36. def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
  37. collection: storage.BaseCollection, encoding: str,
  38. unlock_storage_fn: Callable[[], None]
  39. ) -> Tuple[int, ET.Element]:
  40. """Read and answer REPORT requests.
  41. Read rfc3253-3.6 for info.
  42. """
  43. multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
  44. if xml_request is None:
  45. return client.MULTI_STATUS, multistatus
  46. root = xml_request
  47. if root.tag in (xmlutils.make_clark("D:principal-search-property-set"),
  48. xmlutils.make_clark("D:principal-property-search"),
  49. xmlutils.make_clark("D:expand-property")):
  50. # We don't support searching for principals or indirect retrieving of
  51. # properties, just return an empty result.
  52. # InfCloud asks for expand-property reports (even if we don't announce
  53. # support for them) and stops working if an error code is returned.
  54. logger.warning("Unsupported REPORT method %r on %r requested",
  55. xmlutils.make_human_tag(root.tag), path)
  56. return client.MULTI_STATUS, multistatus
  57. if (root.tag == xmlutils.make_clark("C:calendar-multiget") and
  58. collection.tag != "VCALENDAR" or
  59. root.tag == xmlutils.make_clark("CR:addressbook-multiget") and
  60. collection.tag != "VADDRESSBOOK" or
  61. root.tag == xmlutils.make_clark("D:sync-collection") and
  62. collection.tag not in ("VADDRESSBOOK", "VCALENDAR")):
  63. logger.warning("Invalid REPORT method %r on %r requested",
  64. xmlutils.make_human_tag(root.tag), path)
  65. return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
  66. props = root.find(xmlutils.make_clark("D:prop")) or []
  67. hreferences: Iterable[str]
  68. if root.tag in (
  69. xmlutils.make_clark("C:calendar-multiget"),
  70. xmlutils.make_clark("CR:addressbook-multiget")):
  71. # Read rfc4791-7.9 for info
  72. hreferences = set()
  73. for href_element in root.findall(xmlutils.make_clark("D:href")):
  74. temp_url_path = urlparse(href_element.text).path
  75. assert isinstance(temp_url_path, str)
  76. href_path = pathutils.sanitize_path(unquote(temp_url_path))
  77. if (href_path + "/").startswith(base_prefix + "/"):
  78. hreferences.add(href_path[len(base_prefix):])
  79. else:
  80. logger.warning("Skipping invalid path %r in REPORT request on "
  81. "%r", href_path, path)
  82. elif root.tag == xmlutils.make_clark("D:sync-collection"):
  83. old_sync_token_element = root.find(
  84. xmlutils.make_clark("D:sync-token"))
  85. old_sync_token = ""
  86. if old_sync_token_element is not None and old_sync_token_element.text:
  87. old_sync_token = old_sync_token_element.text.strip()
  88. logger.debug("Client provided sync token: %r", old_sync_token)
  89. try:
  90. sync_token, names = collection.sync(old_sync_token)
  91. except ValueError as e:
  92. # Invalid sync token
  93. logger.warning("Client provided invalid sync token %r: %s",
  94. old_sync_token, e, exc_info=True)
  95. # client.CONFLICT doesn't work with some clients (e.g. InfCloud)
  96. return (client.FORBIDDEN,
  97. xmlutils.webdav_error("D:valid-sync-token"))
  98. hreferences = (pathutils.unstrip_path(
  99. posixpath.join(collection.path, n)) for n in names)
  100. # Append current sync token to response
  101. sync_token_element = ET.Element(xmlutils.make_clark("D:sync-token"))
  102. sync_token_element.text = sync_token
  103. multistatus.append(sync_token_element)
  104. else:
  105. hreferences = (path,)
  106. filters = (
  107. root.findall(xmlutils.make_clark("C:filter")) +
  108. root.findall(xmlutils.make_clark("CR:filter")))
  109. # Retrieve everything required for finishing the request.
  110. retrieved_items = list(retrieve_items(
  111. base_prefix, path, collection, hreferences, filters, multistatus))
  112. collection_tag = collection.tag
  113. # !!! Don't access storage after this !!!
  114. unlock_storage_fn()
  115. while retrieved_items:
  116. # ``item.vobject_item`` might be accessed during filtering.
  117. # Don't keep reference to ``item``, because VObject requires a lot of
  118. # memory.
  119. item, filters_matched = retrieved_items.pop(0)
  120. if filters and not filters_matched:
  121. try:
  122. if not all(test_filter(collection_tag, item, filter_)
  123. for filter_ in filters):
  124. continue
  125. except ValueError as e:
  126. raise ValueError("Failed to filter item %r from %r: %s" %
  127. (item.href, collection.path, e)) from e
  128. except Exception as e:
  129. raise RuntimeError("Failed to filter item %r from %r: %s" %
  130. (item.href, collection.path, e)) from e
  131. found_props = []
  132. not_found_props = []
  133. for prop in props:
  134. element = ET.Element(prop.tag)
  135. if prop.tag == xmlutils.make_clark("D:getetag"):
  136. element.text = item.etag
  137. found_props.append(element)
  138. elif prop.tag == xmlutils.make_clark("D:getcontenttype"):
  139. element.text = xmlutils.get_content_type(item, encoding)
  140. found_props.append(element)
  141. elif prop.tag in (
  142. xmlutils.make_clark("C:calendar-data"),
  143. xmlutils.make_clark("CR:address-data")):
  144. element.text = item.serialize()
  145. expand = prop.find(xmlutils.make_clark("C:expand"))
  146. if expand is not None:
  147. start = expand.get('start')
  148. end = expand.get('end')
  149. if (start is None) or (end is None):
  150. return client.FORBIDDEN, \
  151. xmlutils.webdav_error("C:expand")
  152. start = datetime.datetime.strptime(
  153. start, '%Y%m%dT%H%M%SZ'
  154. ).replace(tzinfo=datetime.timezone.utc)
  155. end = datetime.datetime.strptime(
  156. end, '%Y%m%dT%H%M%SZ'
  157. ).replace(tzinfo=datetime.timezone.utc)
  158. expanded_elements = _expand(
  159. element, copy.copy(item), start, end)
  160. found_props.extend(expanded_elements)
  161. else:
  162. found_props.append(element)
  163. else:
  164. not_found_props.append(element)
  165. assert item.href
  166. uri = pathutils.unstrip_path(
  167. posixpath.join(collection.path, item.href))
  168. multistatus.append(xml_item_response(
  169. base_prefix, uri, found_props=found_props,
  170. not_found_props=not_found_props, found_item=True))
  171. return client.MULTI_STATUS, multistatus
  172. def _expand(
  173. element: ET.Element,
  174. item: radicale_item.Item,
  175. start: datetime.datetime,
  176. end: datetime.datetime,
  177. ) -> List[ET.Element]:
  178. expanded = [element]
  179. for component in item.vobject_item.components():
  180. if component.name != 'VEVENT':
  181. continue
  182. if hasattr(component, "rrule"):
  183. rulleset = component.getrruleset()
  184. recurrences = rulleset.between(start, end)
  185. expanded = []
  186. for recurrence_dt in recurrences:
  187. try:
  188. delattr(item.vobject_item.vevent, 'recurrence-id')
  189. except AttributeError:
  190. pass
  191. item.vobject_item.vevent.add('RECURRENCE-ID').value = recurrence_dt
  192. element.text = item.vobject_item.serialize()
  193. expanded.append(element)
  194. return expanded
  195. def xml_item_response(base_prefix: str, href: str,
  196. found_props: Sequence[ET.Element] = (),
  197. not_found_props: Sequence[ET.Element] = (),
  198. found_item: bool = True) -> ET.Element:
  199. response = ET.Element(xmlutils.make_clark("D:response"))
  200. href_element = ET.Element(xmlutils.make_clark("D:href"))
  201. href_element.text = xmlutils.make_href(base_prefix, href)
  202. response.append(href_element)
  203. if found_item:
  204. for code, props in ((200, found_props), (404, not_found_props)):
  205. if props:
  206. propstat = ET.Element(xmlutils.make_clark("D:propstat"))
  207. status = ET.Element(xmlutils.make_clark("D:status"))
  208. status.text = xmlutils.make_response(code)
  209. prop_element = ET.Element(xmlutils.make_clark("D:prop"))
  210. for prop in props:
  211. prop_element.append(prop)
  212. propstat.append(prop_element)
  213. propstat.append(status)
  214. response.append(propstat)
  215. else:
  216. status = ET.Element(xmlutils.make_clark("D:status"))
  217. status.text = xmlutils.make_response(404)
  218. response.append(status)
  219. return response
  220. def retrieve_items(
  221. base_prefix: str, path: str, collection: storage.BaseCollection,
  222. hreferences: Iterable[str], filters: Sequence[ET.Element],
  223. multistatus: ET.Element) -> Iterator[Tuple[radicale_item.Item, bool]]:
  224. """Retrieves all items that are referenced in ``hreferences`` from
  225. ``collection`` and adds 404 responses for missing and invalid items
  226. to ``multistatus``."""
  227. collection_requested = False
  228. def get_names() -> Iterator[str]:
  229. """Extracts all names from references in ``hreferences`` and adds
  230. 404 responses for invalid references to ``multistatus``.
  231. If the whole collections is referenced ``collection_requested``
  232. gets set to ``True``."""
  233. nonlocal collection_requested
  234. for hreference in hreferences:
  235. try:
  236. name = pathutils.name_from_path(hreference, collection)
  237. except ValueError as e:
  238. logger.warning("Skipping invalid path %r in REPORT request on "
  239. "%r: %s", hreference, path, e)
  240. response = xml_item_response(base_prefix, hreference,
  241. found_item=False)
  242. multistatus.append(response)
  243. continue
  244. if name:
  245. # Reference is an item
  246. yield name
  247. else:
  248. # Reference is a collection
  249. collection_requested = True
  250. for name, item in collection.get_multi(get_names()):
  251. if not item:
  252. uri = pathutils.unstrip_path(posixpath.join(collection.path, name))
  253. response = xml_item_response(base_prefix, uri, found_item=False)
  254. multistatus.append(response)
  255. else:
  256. yield item, False
  257. if collection_requested:
  258. yield from collection.get_filtered(filters)
  259. def test_filter(collection_tag: str, item: radicale_item.Item,
  260. filter_: ET.Element) -> bool:
  261. """Match an item against a filter."""
  262. if (collection_tag == "VCALENDAR" and
  263. filter_.tag != xmlutils.make_clark("C:%s" % filter_)):
  264. if len(filter_) == 0:
  265. return True
  266. if len(filter_) > 1:
  267. raise ValueError("Filter with %d children" % len(filter_))
  268. if filter_[0].tag != xmlutils.make_clark("C:comp-filter"):
  269. raise ValueError("Unexpected %r in filter" % filter_[0].tag)
  270. return radicale_filter.comp_match(item, filter_[0])
  271. if (collection_tag == "VADDRESSBOOK" and
  272. filter_.tag != xmlutils.make_clark("CR:%s" % filter_)):
  273. for child in filter_:
  274. if child.tag != xmlutils.make_clark("CR:prop-filter"):
  275. raise ValueError("Unexpected %r in filter" % child.tag)
  276. test = filter_.get("test", "anyof")
  277. if test == "anyof":
  278. return any(radicale_filter.prop_match(item.vobject_item, f, "CR")
  279. for f in filter_)
  280. if test == "allof":
  281. return all(radicale_filter.prop_match(item.vobject_item, f, "CR")
  282. for f in filter_)
  283. raise ValueError("Unsupported filter test: %r" % test)
  284. raise ValueError("Unsupported filter %r for %r" %
  285. (filter_.tag, collection_tag))
  286. class ApplicationPartReport(ApplicationBase):
  287. def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
  288. path: str, user: str) -> types.WSGIResponse:
  289. """Manage REPORT request."""
  290. access = Access(self._rights, user, path)
  291. if not access.check("r"):
  292. return httputils.NOT_ALLOWED
  293. try:
  294. xml_content = self._read_xml_request_body(environ)
  295. except RuntimeError as e:
  296. logger.warning("Bad REPORT request on %r: %s", path, e,
  297. exc_info=True)
  298. return httputils.BAD_REQUEST
  299. except socket.timeout:
  300. logger.debug("Client timed out", exc_info=True)
  301. return httputils.REQUEST_TIMEOUT
  302. with contextlib.ExitStack() as lock_stack:
  303. lock_stack.enter_context(self._storage.acquire_lock("r", user))
  304. item = next(iter(self._storage.discover(path)), None)
  305. if not item:
  306. return httputils.NOT_FOUND
  307. if not access.check("r", item):
  308. return httputils.NOT_ALLOWED
  309. if isinstance(item, storage.BaseCollection):
  310. collection = item
  311. else:
  312. assert item.collection is not None
  313. collection = item.collection
  314. try:
  315. status, xml_answer = xml_report(
  316. base_prefix, path, xml_content, collection, self._encoding,
  317. lock_stack.close)
  318. except ValueError as e:
  319. logger.warning(
  320. "Bad REPORT request on %r: %s", path, e, exc_info=True)
  321. return httputils.BAD_REQUEST
  322. headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
  323. return status, headers, self._xml_response(xml_answer)