propfind.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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 © 2025-2025 Peter Bieringer <pb@bieringer.de>
  7. #
  8. # This library is free software: you can redistribute it and/or modify
  9. # it under the terms of the GNU General Public License as published by
  10. # the Free Software Foundation, either version 3 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This library is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License
  19. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  20. import collections
  21. import itertools
  22. import posixpath
  23. import socket
  24. import xml.etree.ElementTree as ET
  25. from http import client
  26. from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
  27. from radicale import (httputils, pathutils, rights, storage, types, utils,
  28. xmlutils)
  29. from radicale.app.base import Access, ApplicationBase
  30. from radicale.log import logger
  31. def xml_propfind(base_prefix: str, path: str,
  32. xml_request: Optional[ET.Element],
  33. allowed_items: Iterable[Tuple[types.CollectionOrItem, str]],
  34. user: str, encoding: str, max_resource_size: int) -> Optional[ET.Element]:
  35. """Read and answer PROPFIND requests.
  36. Read rfc4918-9.1 for info.
  37. The collections parameter is a list of collections that are to be included
  38. in the output.
  39. """
  40. # A client may choose not to submit a request body. An empty PROPFIND
  41. # request body MUST be treated as if it were an 'allprop' request.
  42. top_element = (xml_request[0] if xml_request is not None else
  43. ET.Element(xmlutils.make_clark("D:allprop")))
  44. props: List[str] = []
  45. allprop = False
  46. propname = False
  47. if top_element.tag == xmlutils.make_clark("D:allprop"):
  48. allprop = True
  49. elif top_element.tag == xmlutils.make_clark("D:propname"):
  50. propname = True
  51. elif top_element.tag == xmlutils.make_clark("D:prop"):
  52. props.extend(prop.tag for prop in top_element)
  53. if xmlutils.make_clark("D:current-user-principal") in props and not user:
  54. # Ask for authentication
  55. # Returning the DAV:unauthenticated pseudo-principal as specified in
  56. # RFC 5397 doesn't seem to work with DAVx5.
  57. return None
  58. # Writing answer
  59. multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
  60. for item, permission in allowed_items:
  61. write = permission == "w"
  62. multistatus.append(xml_propfind_response(
  63. base_prefix, path, item, props, user, encoding, write=write,
  64. allprop=allprop, propname=propname, max_resource_size=max_resource_size))
  65. return multistatus
  66. def xml_propfind_response(
  67. base_prefix: str, path: str, item: types.CollectionOrItem,
  68. props: Sequence[str], user: str, encoding: str, max_resource_size: int, write: bool = False,
  69. propname: bool = False, allprop: bool = False) -> ET.Element:
  70. """Build and return a PROPFIND response."""
  71. if propname and allprop or (props and (propname or allprop)):
  72. raise ValueError("Only use one of props, propname and allprops")
  73. if isinstance(item, storage.BaseCollection):
  74. is_collection = True
  75. is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR", "VSUBSCRIBED")
  76. collection = item
  77. # Some clients expect collections to end with `/`
  78. uri = pathutils.unstrip_path(item.path, True)
  79. else:
  80. is_collection = is_leaf = False
  81. assert item.collection is not None
  82. assert item.href
  83. collection = item.collection
  84. uri = pathutils.unstrip_path(posixpath.join(
  85. collection.path, item.href))
  86. response = ET.Element(xmlutils.make_clark("D:response"))
  87. href = ET.Element(xmlutils.make_clark("D:href"))
  88. href.text = xmlutils.make_href(base_prefix, uri)
  89. response.append(href)
  90. if propname or allprop:
  91. props = []
  92. # Should list all properties that can be retrieved by the code below
  93. props.append(xmlutils.make_clark("D:principal-collection-set"))
  94. props.append(xmlutils.make_clark("D:current-user-principal"))
  95. props.append(xmlutils.make_clark("D:current-user-privilege-set"))
  96. props.append(xmlutils.make_clark("D:supported-report-set"))
  97. props.append(xmlutils.make_clark("D:resourcetype"))
  98. props.append(xmlutils.make_clark("D:owner"))
  99. if not allprop:
  100. # RFC4791#5.2.5: SHOULD NOT be returned by a PROPFIND DAV:allprop request
  101. props.append(xmlutils.make_clark("C:max-resource-size"))
  102. if is_collection and collection.is_principal:
  103. props.append(xmlutils.make_clark("C:calendar-user-address-set"))
  104. props.append(xmlutils.make_clark("D:principal-URL"))
  105. props.append(xmlutils.make_clark("CR:addressbook-home-set"))
  106. props.append(xmlutils.make_clark("C:calendar-home-set"))
  107. if not is_collection or is_leaf:
  108. props.append(xmlutils.make_clark("D:getetag"))
  109. props.append(xmlutils.make_clark("D:getlastmodified"))
  110. props.append(xmlutils.make_clark("D:getcontenttype"))
  111. props.append(xmlutils.make_clark("D:getcontentlength"))
  112. if is_collection:
  113. if is_leaf:
  114. props.append(xmlutils.make_clark("D:displayname"))
  115. props.append(xmlutils.make_clark("D:sync-token"))
  116. if collection.tag == "VCALENDAR":
  117. props.append(xmlutils.make_clark("CS:getctag"))
  118. props.append(
  119. xmlutils.make_clark("C:supported-calendar-component-set"))
  120. if collection.tag == "VADDRESSBOOK":
  121. props.append(xmlutils.make_clark("CS:getctag"))
  122. props.append(
  123. xmlutils.make_clark("CR:supported-address-data"))
  124. meta = collection.get_meta()
  125. for tag in meta:
  126. if tag == "tag":
  127. continue
  128. clark_tag = xmlutils.make_clark(tag)
  129. if clark_tag not in props:
  130. props.append(clark_tag)
  131. responses: Dict[int, List[ET.Element]] = collections.defaultdict(list)
  132. if propname:
  133. for tag in props:
  134. responses[200].append(ET.Element(tag))
  135. props = []
  136. for tag in props:
  137. element = ET.Element(tag)
  138. is404 = False
  139. if tag == xmlutils.make_clark("D:getetag"):
  140. if not is_collection or is_leaf:
  141. element.text = item.etag
  142. else:
  143. is404 = True
  144. elif tag == xmlutils.make_clark("D:getlastmodified"):
  145. if not is_collection or is_leaf:
  146. element.text = item.last_modified
  147. else:
  148. is404 = True
  149. elif tag == xmlutils.make_clark("D:principal-collection-set"):
  150. child_element = ET.Element(xmlutils.make_clark("D:href"))
  151. child_element.text = xmlutils.make_href(base_prefix, "/")
  152. element.append(child_element)
  153. elif (tag in (xmlutils.make_clark("C:calendar-user-address-set"),
  154. xmlutils.make_clark("D:principal-URL"),
  155. xmlutils.make_clark("CR:addressbook-home-set"),
  156. xmlutils.make_clark("C:calendar-home-set")) and
  157. is_collection and collection.is_principal):
  158. child_element = ET.Element(xmlutils.make_clark("D:href"))
  159. child_element.text = xmlutils.make_href(base_prefix, path)
  160. element.append(child_element)
  161. elif tag == xmlutils.make_clark("C:supported-calendar-component-set"):
  162. human_tag = xmlutils.make_human_tag(tag)
  163. if is_collection and is_leaf:
  164. components_text = collection.get_meta(human_tag)
  165. if components_text:
  166. components = components_text.split(",")
  167. else:
  168. components = ["VTODO", "VEVENT", "VJOURNAL"]
  169. for component in components:
  170. comp = ET.Element(xmlutils.make_clark("C:comp"))
  171. comp.set("name", component)
  172. element.append(comp)
  173. else:
  174. is404 = True
  175. elif tag == xmlutils.make_clark("CR:supported-address-data"):
  176. if is_collection and is_leaf and collection.tag == "VADDRESSBOOK":
  177. # Advertise supported vCard versions per RFC 6352 section 6.2.2
  178. # vCard 4.0 requires vobject >= 1.0.0
  179. versions: Sequence[str] = (("4.0", "3.0")
  180. if utils.vobject_supports_vcard4()
  181. else ("3.0",))
  182. for version in versions:
  183. address_data_type = ET.Element(
  184. xmlutils.make_clark("CR:address-data-type"))
  185. address_data_type.set("content-type", "text/vcard")
  186. address_data_type.set("version", version)
  187. element.append(address_data_type)
  188. else:
  189. is404 = True
  190. elif tag == xmlutils.make_clark("D:current-user-principal"):
  191. if user:
  192. child_element = ET.Element(xmlutils.make_clark("D:href"))
  193. child_element.text = xmlutils.make_href(
  194. base_prefix, "/%s/" % user)
  195. element.append(child_element)
  196. else:
  197. element.append(ET.Element(
  198. xmlutils.make_clark("D:unauthenticated")))
  199. elif tag == xmlutils.make_clark("D:current-user-privilege-set"):
  200. privileges = ["D:read"]
  201. if write:
  202. privileges.append("D:all")
  203. privileges.append("D:write")
  204. privileges.append("D:write-properties")
  205. privileges.append("D:write-content")
  206. for human_tag in privileges:
  207. privilege = ET.Element(xmlutils.make_clark("D:privilege"))
  208. privilege.append(ET.Element(
  209. xmlutils.make_clark(human_tag)))
  210. element.append(privilege)
  211. elif tag == xmlutils.make_clark("D:supported-report-set"):
  212. # These 3 reports are not implemented
  213. reports = ["D:expand-property",
  214. "D:principal-search-property-set",
  215. "D:principal-property-search"]
  216. if is_collection and is_leaf:
  217. reports.append("D:sync-collection")
  218. if collection.tag == "VADDRESSBOOK":
  219. reports.append("CR:addressbook-multiget")
  220. reports.append("CR:addressbook-query")
  221. elif collection.tag == "VCALENDAR":
  222. reports.append("C:calendar-multiget")
  223. reports.append("C:calendar-query")
  224. for human_tag in reports:
  225. supported_report = ET.Element(
  226. xmlutils.make_clark("D:supported-report"))
  227. report_element = ET.Element(xmlutils.make_clark("D:report"))
  228. report_element.append(
  229. ET.Element(xmlutils.make_clark(human_tag)))
  230. supported_report.append(report_element)
  231. element.append(supported_report)
  232. elif tag == xmlutils.make_clark("D:getcontentlength"):
  233. if not is_collection or is_leaf:
  234. element.text = str(len(item.serialize().encode(encoding)))
  235. else:
  236. is404 = True
  237. elif tag == xmlutils.make_clark("D:owner"):
  238. # return empty elment, if no owner available (rfc3744-5.1)
  239. if collection.owner:
  240. child_element = ET.Element(xmlutils.make_clark("D:href"))
  241. child_element.text = xmlutils.make_href(
  242. base_prefix, "/%s/" % collection.owner)
  243. element.append(child_element)
  244. elif tag == xmlutils.make_clark("C:max-resource-size"):
  245. # RFC4791#5.2.5
  246. element.text = str(max_resource_size)
  247. elif is_collection:
  248. if tag == xmlutils.make_clark("D:getcontenttype"):
  249. if is_leaf:
  250. element.text = xmlutils.MIMETYPES[
  251. collection.tag]
  252. else:
  253. is404 = True
  254. elif tag == xmlutils.make_clark("D:resourcetype"):
  255. if collection.is_principal:
  256. child_element = ET.Element(
  257. xmlutils.make_clark("D:principal"))
  258. element.append(child_element)
  259. if is_leaf:
  260. if collection.tag == "VADDRESSBOOK":
  261. child_element = ET.Element(
  262. xmlutils.make_clark("CR:addressbook"))
  263. element.append(child_element)
  264. elif collection.tag == "VCALENDAR":
  265. child_element = ET.Element(
  266. xmlutils.make_clark("C:calendar"))
  267. element.append(child_element)
  268. elif collection.tag == "VSUBSCRIBED":
  269. child_element = ET.Element(
  270. xmlutils.make_clark("CS:subscribed"))
  271. element.append(child_element)
  272. child_element = ET.Element(xmlutils.make_clark("D:collection"))
  273. element.append(child_element)
  274. elif tag == xmlutils.make_clark("RADICALE:displayname"):
  275. # Only for internal use by the web interface
  276. displayname = collection.get_meta("D:displayname")
  277. if displayname is not None:
  278. element.text = displayname
  279. else:
  280. is404 = True
  281. elif tag == xmlutils.make_clark("RADICALE:getcontentcount"):
  282. # Only for internal use by the web interface
  283. if isinstance(item, storage.BaseCollection) and not collection.is_principal:
  284. element.text = str(sum(1 for x in item.get_all()))
  285. else:
  286. is404 = True
  287. elif tag == xmlutils.make_clark("D:displayname"):
  288. displayname = collection.get_meta("D:displayname")
  289. if not displayname and is_leaf:
  290. displayname = collection.path
  291. if displayname is not None:
  292. element.text = displayname
  293. else:
  294. is404 = True
  295. elif tag == xmlutils.make_clark("CS:getctag"):
  296. if is_leaf:
  297. element.text = collection.etag
  298. else:
  299. is404 = True
  300. elif tag == xmlutils.make_clark("D:sync-token"):
  301. if is_leaf:
  302. element.text, _ = collection.sync()
  303. else:
  304. is404 = True
  305. elif tag == xmlutils.make_clark("CS:source"):
  306. if is_leaf:
  307. child_element = ET.Element(xmlutils.make_clark("D:href"))
  308. child_element.text = collection.get_meta('CS:source')
  309. element.append(child_element)
  310. else:
  311. is404 = True
  312. else:
  313. human_tag = xmlutils.make_human_tag(tag)
  314. tag_text = collection.get_meta(human_tag)
  315. if tag_text is not None:
  316. element.text = tag_text
  317. else:
  318. is404 = True
  319. # Not for collections
  320. elif tag == xmlutils.make_clark("D:getcontenttype"):
  321. assert not isinstance(item, storage.BaseCollection)
  322. element.text = xmlutils.get_content_type(item, encoding)
  323. elif tag == xmlutils.make_clark("D:resourcetype"):
  324. # resourcetype must be returned empty for non-collection elements
  325. pass
  326. else:
  327. is404 = True
  328. responses[404 if is404 else 200].append(element)
  329. for status_code, children in responses.items():
  330. if not children:
  331. continue
  332. propstat = ET.Element(xmlutils.make_clark("D:propstat"))
  333. response.append(propstat)
  334. prop = ET.Element(xmlutils.make_clark("D:prop"))
  335. prop.extend(children)
  336. propstat.append(prop)
  337. status = ET.Element(xmlutils.make_clark("D:status"))
  338. status.text = xmlutils.make_response(status_code)
  339. propstat.append(status)
  340. return response
  341. class ApplicationPartPropfind(ApplicationBase):
  342. def _collect_allowed_items(
  343. self, items: Iterable[types.CollectionOrItem], user: str
  344. ) -> Iterator[Tuple[types.CollectionOrItem, str]]:
  345. """Get items from request that user is allowed to access."""
  346. for item in items:
  347. if isinstance(item, storage.BaseCollection):
  348. path = pathutils.unstrip_path(item.path, True)
  349. if item.tag:
  350. permissions = rights.intersect(
  351. self._rights.authorization(user, path), "rw")
  352. target = "collection with tag %r" % item.path
  353. else:
  354. permissions = rights.intersect(
  355. self._rights.authorization(user, path), "RW")
  356. target = "collection %r" % item.path
  357. else:
  358. assert item.collection is not None
  359. path = pathutils.unstrip_path(item.collection.path, True)
  360. permissions = rights.intersect(
  361. self._rights.authorization(user, path), "rw")
  362. target = "item %r from %r" % (item.href, item.collection.path)
  363. if rights.intersect(permissions, "Ww"):
  364. permission = "w"
  365. status = "write"
  366. elif rights.intersect(permissions, "Rr"):
  367. permission = "r"
  368. status = "read"
  369. else:
  370. permission = ""
  371. status = "NO"
  372. logger.debug(
  373. "%s has %s access to %s",
  374. repr(user) if user else "anonymous user", status, target)
  375. if permission:
  376. yield item, permission
  377. def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str,
  378. path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
  379. """Manage PROPFIND request."""
  380. access = Access(self._rights, user, path)
  381. if not access.check("r"):
  382. return httputils.NOT_ALLOWED
  383. try:
  384. xml_content = self._read_xml_request_body(environ)
  385. except RuntimeError as e:
  386. logger.warning(
  387. "Bad PROPFIND request on %r: %s", path, e, exc_info=True)
  388. return httputils.BAD_REQUEST
  389. except socket.timeout:
  390. logger.debug("Client timed out", exc_info=True)
  391. return httputils.REQUEST_TIMEOUT
  392. with self._storage.acquire_lock("r", user):
  393. items_iter = iter(self._storage.discover(
  394. path, environ.get("HTTP_DEPTH", "0"),
  395. None, self._rights._user_groups))
  396. # take root item for rights checking
  397. item = next(items_iter, None)
  398. if not item:
  399. return httputils.NOT_FOUND
  400. if not access.check("r", item):
  401. return httputils.NOT_ALLOWED
  402. # put item back
  403. items_iter = itertools.chain([item], items_iter)
  404. allowed_items = self._collect_allowed_items(items_iter, user)
  405. headers = {"DAV": httputils.DAV_HEADERS,
  406. "Content-Type": "text/xml; charset=%s" % self._encoding}
  407. xml_answer = xml_propfind(base_prefix, path, xml_content,
  408. allowed_items, user, self._encoding, max_resource_size=self._max_resource_size)
  409. if xml_answer is None:
  410. return httputils.NOT_ALLOWED
  411. return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)