report.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. # This file is part of Radicale Server - Calendar 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. """
  20. Radicale WSGI application.
  21. Can be used with an external WSGI server or the built-in server.
  22. """
  23. import contextlib
  24. import posixpath
  25. import socket
  26. from http import client
  27. from urllib.parse import unquote, urlparse
  28. from xml.etree import ElementTree as ET
  29. from radicale import httputils, pathutils, storage, xmlutils
  30. from radicale.item import filter as radicale_filter
  31. from radicale.log import logger
  32. def xml_report(base_prefix, path, xml_request, collection, unlock_storage_fn):
  33. """Read and answer REPORT requests.
  34. Read rfc3253-3.6 for info.
  35. """
  36. multistatus = ET.Element(xmlutils.make_tag("D", "multistatus"))
  37. if xml_request is None:
  38. return client.MULTI_STATUS, multistatus
  39. root = xml_request
  40. if root.tag in (
  41. xmlutils.make_tag("D", "principal-search-property-set"),
  42. xmlutils.make_tag("D", "principal-property-search"),
  43. xmlutils.make_tag("D", "expand-property")):
  44. # We don't support searching for principals or indirect retrieving of
  45. # properties, just return an empty result.
  46. # InfCloud asks for expand-property reports (even if we don't announce
  47. # support for them) and stops working if an error code is returned.
  48. logger.warning("Unsupported REPORT method %r on %r requested",
  49. xmlutils.tag_from_clark(root.tag), path)
  50. return client.MULTI_STATUS, multistatus
  51. if (root.tag == xmlutils.make_tag("C", "calendar-multiget") and
  52. collection.get_meta("tag") != "VCALENDAR" or
  53. root.tag == xmlutils.make_tag("CR", "addressbook-multiget") and
  54. collection.get_meta("tag") != "VADDRESSBOOK" or
  55. root.tag == xmlutils.make_tag("D", "sync-collection") and
  56. collection.get_meta("tag") not in ("VADDRESSBOOK", "VCALENDAR")):
  57. logger.warning("Invalid REPORT method %r on %r requested",
  58. xmlutils.tag_from_clark(root.tag), path)
  59. return (client.CONFLICT,
  60. xmlutils.webdav_error("D", "supported-report"))
  61. prop_element = root.find(xmlutils.make_tag("D", "prop"))
  62. props = (
  63. [prop.tag for prop in prop_element]
  64. if prop_element is not None else [])
  65. if root.tag in (
  66. xmlutils.make_tag("C", "calendar-multiget"),
  67. xmlutils.make_tag("CR", "addressbook-multiget")):
  68. # Read rfc4791-7.9 for info
  69. hreferences = set()
  70. for href_element in root.findall(xmlutils.make_tag("D", "href")):
  71. href_path = pathutils.sanitize_path(
  72. unquote(urlparse(href_element.text).path))
  73. if (href_path + "/").startswith(base_prefix + "/"):
  74. hreferences.add(href_path[len(base_prefix):])
  75. else:
  76. logger.warning("Skipping invalid path %r in REPORT request on "
  77. "%r", href_path, path)
  78. elif root.tag == xmlutils.make_tag("D", "sync-collection"):
  79. old_sync_token_element = root.find(
  80. xmlutils.make_tag("D", "sync-token"))
  81. old_sync_token = ""
  82. if old_sync_token_element is not None and old_sync_token_element.text:
  83. old_sync_token = old_sync_token_element.text.strip()
  84. logger.debug("Client provided sync token: %r", old_sync_token)
  85. try:
  86. sync_token, names = collection.sync(old_sync_token)
  87. except ValueError as e:
  88. # Invalid sync token
  89. logger.warning("Client provided invalid sync token %r: %s",
  90. old_sync_token, e, exc_info=True)
  91. return (client.CONFLICT,
  92. xmlutils.webdav_error("D", "valid-sync-token"))
  93. hreferences = ("/" + posixpath.join(collection.path, n) for n in names)
  94. # Append current sync token to response
  95. sync_token_element = ET.Element(xmlutils.make_tag("D", "sync-token"))
  96. sync_token_element.text = sync_token
  97. multistatus.append(sync_token_element)
  98. else:
  99. hreferences = (path,)
  100. filters = (
  101. root.findall("./%s" % xmlutils.make_tag("C", "filter")) +
  102. root.findall("./%s" % xmlutils.make_tag("CR", "filter")))
  103. def retrieve_items(collection, hreferences, multistatus):
  104. """Retrieves all items that are referenced in ``hreferences`` from
  105. ``collection`` and adds 404 responses for missing and invalid items
  106. to ``multistatus``."""
  107. collection_requested = False
  108. def get_names():
  109. """Extracts all names from references in ``hreferences`` and adds
  110. 404 responses for invalid references to ``multistatus``.
  111. If the whole collections is referenced ``collection_requested``
  112. gets set to ``True``."""
  113. nonlocal collection_requested
  114. for hreference in hreferences:
  115. try:
  116. name = pathutils.name_from_path(hreference, collection)
  117. except ValueError as e:
  118. logger.warning("Skipping invalid path %r in REPORT request"
  119. " on %r: %s", hreference, path, e)
  120. response = xml_item_response(base_prefix, hreference,
  121. found_item=False)
  122. multistatus.append(response)
  123. continue
  124. if name:
  125. # Reference is an item
  126. yield name
  127. else:
  128. # Reference is a collection
  129. collection_requested = True
  130. for name, item in collection.get_multi(get_names()):
  131. if not item:
  132. uri = "/" + posixpath.join(collection.path, name)
  133. response = xml_item_response(base_prefix, uri,
  134. found_item=False)
  135. multistatus.append(response)
  136. else:
  137. yield item, False
  138. if collection_requested:
  139. yield from collection.get_all_filtered(filters)
  140. # Retrieve everything required for finishing the request.
  141. retrieved_items = list(retrieve_items(collection, hreferences,
  142. multistatus))
  143. collection_tag = collection.get_meta("tag")
  144. # Don't access storage after this!
  145. unlock_storage_fn()
  146. def match(item, filter_):
  147. tag = collection_tag
  148. if (tag == "VCALENDAR" and
  149. filter_.tag != xmlutils.make_tag("C", filter_)):
  150. if len(filter_) == 0:
  151. return True
  152. if len(filter_) > 1:
  153. raise ValueError("Filter with %d children" % len(filter_))
  154. if filter_[0].tag != xmlutils.make_tag("C", "comp-filter"):
  155. raise ValueError("Unexpected %r in filter" % filter_[0].tag)
  156. return radicale_filter.comp_match(item, filter_[0])
  157. if (tag == "VADDRESSBOOK" and
  158. filter_.tag != xmlutils.make_tag("CR", filter_)):
  159. for child in filter_:
  160. if child.tag != xmlutils.make_tag("CR", "prop-filter"):
  161. raise ValueError("Unexpected %r in filter" % child.tag)
  162. test = filter_.get("test", "anyof")
  163. if test == "anyof":
  164. return any(
  165. radicale_filter.prop_match(item.vobject_item, f, "CR")
  166. for f in filter_)
  167. if test == "allof":
  168. return all(
  169. radicale_filter.prop_match(item.vobject_item, f, "CR")
  170. for f in filter_)
  171. raise ValueError("Unsupported filter test: %r" % test)
  172. return all(radicale_filter.prop_match(item.vobject_item, f, "CR")
  173. for f in filter_)
  174. raise ValueError("unsupported filter %r for %r" % (filter_.tag, tag))
  175. while retrieved_items:
  176. # ``item.vobject_item`` might be accessed during filtering.
  177. # Don't keep reference to ``item``, because VObject requires a lot of
  178. # memory.
  179. item, filters_matched = retrieved_items.pop(0)
  180. if filters and not filters_matched:
  181. try:
  182. if not all(match(item, filter_) for filter_ in filters):
  183. continue
  184. except ValueError as e:
  185. raise ValueError("Failed to filter item %r from %r: %s" %
  186. (item.href, collection.path, e)) from e
  187. except Exception as e:
  188. raise RuntimeError("Failed to filter item %r from %r: %s" %
  189. (item.href, collection.path, e)) from e
  190. found_props = []
  191. not_found_props = []
  192. for tag in props:
  193. element = ET.Element(tag)
  194. if tag == xmlutils.make_tag("D", "getetag"):
  195. element.text = item.etag
  196. found_props.append(element)
  197. elif tag == xmlutils.make_tag("D", "getcontenttype"):
  198. element.text = xmlutils.get_content_type(item)
  199. found_props.append(element)
  200. elif tag in (
  201. xmlutils.make_tag("C", "calendar-data"),
  202. xmlutils.make_tag("CR", "address-data")):
  203. element.text = item.serialize()
  204. found_props.append(element)
  205. else:
  206. not_found_props.append(element)
  207. uri = "/" + posixpath.join(collection.path, item.href)
  208. multistatus.append(xml_item_response(
  209. base_prefix, uri, found_props=found_props,
  210. not_found_props=not_found_props, found_item=True))
  211. return client.MULTI_STATUS, multistatus
  212. def xml_item_response(base_prefix, href, found_props=(), not_found_props=(),
  213. found_item=True):
  214. response = ET.Element(xmlutils.make_tag("D", "response"))
  215. href_tag = ET.Element(xmlutils.make_tag("D", "href"))
  216. href_tag.text = xmlutils.make_href(base_prefix, href)
  217. response.append(href_tag)
  218. if found_item:
  219. for code, props in ((200, found_props), (404, not_found_props)):
  220. if props:
  221. propstat = ET.Element(xmlutils.make_tag("D", "propstat"))
  222. status = ET.Element(xmlutils.make_tag("D", "status"))
  223. status.text = xmlutils.make_response(code)
  224. prop_tag = ET.Element(xmlutils.make_tag("D", "prop"))
  225. for prop in props:
  226. prop_tag.append(prop)
  227. propstat.append(prop_tag)
  228. propstat.append(status)
  229. response.append(propstat)
  230. else:
  231. status = ET.Element(xmlutils.make_tag("D", "status"))
  232. status.text = xmlutils.make_response(404)
  233. response.append(status)
  234. return response
  235. class ApplicationReportMixin:
  236. def do_REPORT(self, environ, base_prefix, path, user):
  237. """Manage REPORT request."""
  238. if not self.access(user, path, "r"):
  239. return httputils.NOT_ALLOWED
  240. try:
  241. xml_content = self.read_xml_content(environ)
  242. except RuntimeError as e:
  243. logger.warning(
  244. "Bad REPORT request on %r: %s", path, e, exc_info=True)
  245. return httputils.BAD_REQUEST
  246. except socket.timeout as e:
  247. logger.debug("client timed out", exc_info=True)
  248. return httputils.REQUEST_TIMEOUT
  249. with contextlib.ExitStack() as lock_stack:
  250. lock_stack.enter_context(self.Collection.acquire_lock("r", user))
  251. item = next(self.Collection.discover(path), None)
  252. if not item:
  253. return httputils.NOT_FOUND
  254. if not self.access(user, path, "r", item):
  255. return httputils.NOT_ALLOWED
  256. if isinstance(item, storage.BaseCollection):
  257. collection = item
  258. else:
  259. collection = item.collection
  260. headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
  261. try:
  262. status, xml_answer = xml_report(
  263. base_prefix, path, xml_content, collection,
  264. lock_stack.close)
  265. except ValueError as e:
  266. logger.warning(
  267. "Bad REPORT request on %r: %s", path, e, exc_info=True)
  268. return httputils.BAD_REQUEST
  269. return (status, headers, self.write_xml_content(xml_answer))