put.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  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-2020 Unrud <unrud@outlook.com>
  6. # Copyright © 2020-2023 Tuna Celik <tuna@jakpark.com>
  7. # Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
  8. #
  9. # This library is free software: you can redistribute it and/or modify
  10. # it under the terms of the GNU General Public License as published by
  11. # the Free Software Foundation, either version 3 of the License, or
  12. # (at your option) any later version.
  13. #
  14. # This library is distributed in the hope that it will be useful,
  15. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. # GNU General Public License for more details.
  18. #
  19. # You should have received a copy of the GNU General Public License
  20. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  21. import errno
  22. import itertools
  23. import logging
  24. import posixpath
  25. import re
  26. import socket
  27. import sys
  28. from http import client
  29. from types import TracebackType
  30. from typing import Iterator, List, Mapping, MutableMapping, Optional, Tuple
  31. import vobject
  32. import radicale.item as radicale_item
  33. from radicale import (httputils, pathutils, rights, storage, types, utils,
  34. xmlutils)
  35. from radicale.app.base import Access, ApplicationBase
  36. from radicale.hook import HookNotificationItem, HookNotificationItemTypes
  37. from radicale.log import logger
  38. MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in
  39. xmlutils.MIMETYPES.items()}
  40. PRODID = u"-//Radicale//NONSGML Version " + utils.package_version("radicale") + "//EN"
  41. def prepare(vobject_items: List[vobject.base.Component], path: str,
  42. content_type: str, permission: bool, parent_permission: bool, max_resource_size: int,
  43. tag: Optional[str] = None,
  44. write_whole_collection: Optional[bool] = None) -> Tuple[
  45. Iterator[radicale_item.Item], # items
  46. Optional[str], # tag
  47. Optional[bool], # write_whole_collection
  48. Optional[MutableMapping[str, str]], # props
  49. Optional[Tuple[type, BaseException, Optional[TracebackType]]]]:
  50. if (write_whole_collection or permission and not parent_permission):
  51. write_whole_collection = True
  52. tag = radicale_item.predict_tag_of_whole_collection(
  53. vobject_items, MIMETYPE_TAGS.get(content_type))
  54. if not tag:
  55. raise ValueError("Can't determine collection tag")
  56. collection_path = pathutils.strip_path(path)
  57. elif (write_whole_collection is not None and not write_whole_collection or
  58. not permission and parent_permission):
  59. write_whole_collection = False
  60. if tag is None:
  61. tag = radicale_item.predict_tag_of_parent_collection(vobject_items)
  62. collection_path = posixpath.dirname(pathutils.strip_path(path))
  63. props: Optional[MutableMapping[str, str]] = None
  64. stored_exc_info = None
  65. items = []
  66. try:
  67. if tag and write_whole_collection is not None:
  68. radicale_item.check_and_sanitize_items(
  69. vobject_items, is_collection=write_whole_collection, tag=tag)
  70. if write_whole_collection and tag == "VCALENDAR":
  71. vobject_components: List[vobject.base.Component] = []
  72. vobject_item, = vobject_items
  73. for content in ("vevent", "vtodo", "vjournal"):
  74. vobject_components.extend(
  75. getattr(vobject_item, "%s_list" % content, []))
  76. vobject_components_by_uid = itertools.groupby(
  77. sorted(vobject_components, key=radicale_item.get_uid),
  78. radicale_item.get_uid)
  79. for _, components in vobject_components_by_uid:
  80. vobject_collection = vobject.iCalendar()
  81. for component in components:
  82. vobject_collection.add(component)
  83. vobject_collection.add(vobject.base.ContentLine("PRODID", [], PRODID))
  84. item = radicale_item.Item(collection_path=collection_path,
  85. vobject_item=vobject_collection)
  86. logger.debug("Prepare item with UID '%s'", item.uid)
  87. try:
  88. item.prepare()
  89. except (RuntimeError, ValueError, AttributeError) as e:
  90. if logger.isEnabledFor(logging.DEBUG):
  91. if item._text is None:
  92. content = vobject_item
  93. else:
  94. content = item._text
  95. logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content))
  96. else:
  97. logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
  98. raise
  99. size = len(item.serialize())
  100. if (size > max_resource_size):
  101. logger.warning("PUT request contains item with UID %r size %d > limit %d: %r", item.uid, size, max_resource_size, path)
  102. # Use OverflowError as flag for max_resource_size
  103. raise OverflowError
  104. else:
  105. logger.debug("PUT request contains item with UID %r size %d <= limit %d: %r", item.uid, size, max_resource_size, path)
  106. items.append(item)
  107. elif write_whole_collection and tag == "VADDRESSBOOK":
  108. for vobject_item in vobject_items:
  109. item = radicale_item.Item(collection_path=collection_path,
  110. vobject_item=vobject_item)
  111. logger.debug("Prepare item with UID '%s'", item.uid)
  112. try:
  113. item.prepare()
  114. except (RuntimeError, ValueError, AttributeError) as e:
  115. if logger.isEnabledFor(logging.DEBUG):
  116. if item._text is None:
  117. content = vobject_item
  118. else:
  119. content = item._text
  120. logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content))
  121. else:
  122. logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
  123. raise
  124. size = len(item.serialize())
  125. if (size > max_resource_size):
  126. logger.warning("PUT request contains item with UID %r size %d > limit %d: %r", item.uid, size, max_resource_size, path)
  127. # Use OverflowError as flag for max_resource_size
  128. raise OverflowError
  129. else:
  130. logger.debug("PUT request contains item with UID %r size %d <= limit %d: %r", item.uid, size, max_resource_size, path)
  131. items.append(item)
  132. elif not write_whole_collection:
  133. vobject_item, = vobject_items
  134. item = radicale_item.Item(collection_path=collection_path,
  135. vobject_item=vobject_item)
  136. item.prepare()
  137. size = len(item.serialize())
  138. if (size > max_resource_size):
  139. logger.warning("PUT request contains item with UID %r size %d above limit %d: %r", item.uid, size, max_resource_size, path)
  140. # Use OverflowError as flag for max_resource_size
  141. raise OverflowError
  142. else:
  143. logger.debug("PUT request contains item with UID %r size %d below limit %d: %r", item.uid, size, max_resource_size, path)
  144. items.append(item)
  145. if write_whole_collection:
  146. props = {}
  147. if tag:
  148. props["tag"] = tag
  149. if tag == "VCALENDAR" and vobject_items:
  150. if hasattr(vobject_items[0], "x_wr_calname"):
  151. calname = vobject_items[0].x_wr_calname.value
  152. if calname:
  153. props["D:displayname"] = calname
  154. if hasattr(vobject_items[0], "x_wr_caldesc"):
  155. caldesc = vobject_items[0].x_wr_caldesc.value
  156. if caldesc:
  157. props["C:calendar-description"] = caldesc
  158. props = radicale_item.check_and_sanitize_props(props)
  159. except Exception:
  160. exc_info_or_none_tuple = sys.exc_info()
  161. assert exc_info_or_none_tuple[0] is not None
  162. stored_exc_info = exc_info_or_none_tuple
  163. # Use iterator for items and delete references to free memory early
  164. def items_iter() -> Iterator[radicale_item.Item]:
  165. while items:
  166. yield items.pop(0)
  167. return items_iter(), tag, write_whole_collection, props, stored_exc_info
  168. class ApplicationPartPut(ApplicationBase):
  169. def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str,
  170. path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
  171. """Manage PUT request."""
  172. access = Access(self._rights, user, path)
  173. if not access.check("w"):
  174. return httputils.NOT_ALLOWED
  175. try:
  176. content = httputils.read_request_body(self.configuration, environ)
  177. except RuntimeError as e:
  178. logger.warning("Bad PUT request on %r (read_request_body): %s", path, e, exc_info=True)
  179. return httputils.BAD_REQUEST
  180. except socket.timeout:
  181. logger.debug("Client timed out", exc_info=True)
  182. return httputils.REQUEST_TIMEOUT
  183. # Prepare before locking
  184. content_type = environ.get("CONTENT_TYPE", "").split(";",
  185. maxsplit=1)[0]
  186. try:
  187. vobject_items = radicale_item.read_components(content or "")
  188. except Exception as e:
  189. logger.warning(
  190. "Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
  191. if self._log_bad_put_request_content:
  192. logger.warning("Bad PUT request content of %r:\n%s", path, utils.textwrap_str(content))
  193. if logger.isEnabledFor(logging.DEBUG):
  194. logger.debug("Request content (sha256sum): %s", utils.sha256_str(content))
  195. logger.debug("Request content (hexdump/lines):\n%s", utils.hexdump_lines(content))
  196. else:
  197. logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
  198. return httputils.BAD_REQUEST
  199. (prepared_items, prepared_tag, prepared_write_whole_collection,
  200. prepared_props, prepared_exc_info) = prepare(
  201. vobject_items, path, content_type,
  202. bool(rights.intersect(access.permissions, "Ww")),
  203. bool(rights.intersect(access.parent_permissions, "w")),
  204. self._max_resource_size)
  205. with self._storage.acquire_lock("w", user, path=path, request="PUT"):
  206. item = next(iter(self._storage.discover(path)), None)
  207. parent_item = next(iter(
  208. self._storage.discover(access.parent_path)), None)
  209. if not isinstance(parent_item, storage.BaseCollection):
  210. return httputils.CONFLICT
  211. write_whole_collection = (
  212. isinstance(item, storage.BaseCollection) or
  213. not parent_item.tag)
  214. if write_whole_collection:
  215. tag = prepared_tag
  216. else:
  217. tag = parent_item.tag
  218. if write_whole_collection:
  219. if ("w" if tag else "W") not in access.permissions:
  220. if not parent_item.tag:
  221. logger.warning("Not a collection (check .Radicale.props): %r", parent_item.path)
  222. return httputils.NOT_ALLOWED
  223. if not self._permit_overwrite_collection:
  224. if ("O") not in access.permissions:
  225. logger.info("overwrite of collection is prevented by config/option [rights] permit_overwrite_collection and not explicit allowed by permssion 'O': %r", path)
  226. return httputils.NOT_ALLOWED
  227. else:
  228. if ("o") in access.permissions:
  229. logger.info("overwrite of collection is allowed by config/option [rights] permit_overwrite_collection but explicit forbidden by permission 'o': %r", path)
  230. return httputils.NOT_ALLOWED
  231. elif "w" not in access.parent_permissions:
  232. return httputils.NOT_ALLOWED
  233. etag = environ.get("HTTP_IF_MATCH", "")
  234. if item and not etag and self._strict_preconditions:
  235. logger.warning("Precondition failed for %r: existing item, no If-Match header, strict mode enabled", path)
  236. return httputils.PRECONDITION_FAILED
  237. if not item and etag:
  238. # Etag asked but no item found: item has been removed
  239. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item not existing)", path, etag)
  240. return httputils.PRECONDITION_FAILED
  241. if item and etag and item.etag != etag:
  242. # Etag asked but item not matching: item has changed
  243. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item has different etag: %s)", path, etag, item.etag)
  244. return httputils.PRECONDITION_FAILED
  245. if item and etag:
  246. logger.debug("Precondition passed on PUT request for %r (HTTP_IF_MATCH: %s, item has etag: %s)", path, etag, item.etag)
  247. match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
  248. if item and match:
  249. # Creation asked but item found: item can't be replaced
  250. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_NONE_MATCH: *, creation requested but item found with etag: %s)", path, item.etag)
  251. return httputils.PRECONDITION_FAILED
  252. if match:
  253. logger.debug("Precondition passed on PUT request for %r (HTTP_IF_NONE_MATCH: *)", path)
  254. if (tag != prepared_tag or
  255. prepared_write_whole_collection != write_whole_collection):
  256. (prepared_items, prepared_tag, prepared_write_whole_collection,
  257. prepared_props, prepared_exc_info) = prepare(
  258. vobject_items, path, content_type,
  259. bool(rights.intersect(access.permissions, "Ww")),
  260. bool(rights.intersect(access.parent_permissions, "w")),
  261. self._max_resource_size,
  262. tag, write_whole_collection)
  263. props = prepared_props
  264. if prepared_exc_info:
  265. # Use OverflowError as flag for max_resource_size
  266. if prepared_exc_info[0] == OverflowError:
  267. return httputils.PRECONDITION_FAILED
  268. else:
  269. logger.warning(
  270. "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1],
  271. exc_info=prepared_exc_info)
  272. return httputils.BAD_REQUEST
  273. if write_whole_collection:
  274. try:
  275. col, replaced_items, new_item_hrefs = self._storage.create_collection(
  276. href=path,
  277. items=prepared_items,
  278. props=props)
  279. for item in prepared_items:
  280. # Try to grab the previously-existing item by href
  281. existing_item = replaced_items.get(item.href, None) # type: ignore
  282. if existing_item:
  283. hook_notification_item = HookNotificationItem(
  284. notification_item_type=HookNotificationItemTypes.UPSERT,
  285. path=access.path,
  286. content=existing_item.serialize(),
  287. uid=None,
  288. old_content=existing_item.serialize(),
  289. new_content=item.serialize()
  290. )
  291. else: # We assume the item is new because it was not in the replaced_items
  292. hook_notification_item = HookNotificationItem(
  293. notification_item_type=HookNotificationItemTypes.UPSERT,
  294. path=access.path,
  295. content=item.serialize(),
  296. uid=None,
  297. old_content=None,
  298. new_content=item.serialize()
  299. )
  300. self._hook.notify(hook_notification_item)
  301. except ValueError as e:
  302. logger.warning(
  303. "Bad PUT request on %r (create_collection): %s", path, e, exc_info=True)
  304. return httputils.BAD_REQUEST
  305. else:
  306. assert not isinstance(item, storage.BaseCollection)
  307. prepared_item, = prepared_items
  308. if (item and item.uid != prepared_item.uid or
  309. not item and parent_item.has_uid(prepared_item.uid)):
  310. return self._webdav_error_response(
  311. client.CONFLICT, "%s:no-uid-conflict" % (
  312. "C" if tag == "VCALENDAR" else "CR"))
  313. href = posixpath.basename(pathutils.strip_path(path))
  314. try:
  315. uploaded_item, replaced_item = parent_item.upload(href, prepared_item)
  316. etag = uploaded_item.etag
  317. hook_notification_item = HookNotificationItem(
  318. notification_item_type=HookNotificationItemTypes.UPSERT,
  319. path=access.path,
  320. content=prepared_item.serialize(),
  321. uid=None,
  322. old_content=replaced_item.serialize() if replaced_item else None,
  323. new_content=prepared_item.serialize()
  324. )
  325. self._hook.notify(hook_notification_item)
  326. except ValueError as e:
  327. # return better matching HTTP result in case errno is provided and catched
  328. errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
  329. if errno_match:
  330. logger.error(
  331. "Failed PUT request on %r (upload): %s", path, e, exc_info=True)
  332. errno_e = int(errno_match.group(1))
  333. if errno_e == errno.ENOSPC:
  334. return httputils.INSUFFICIENT_STORAGE
  335. elif errno_e in [errno.EPERM, errno.EACCES]:
  336. return httputils.FORBIDDEN
  337. else:
  338. return httputils.INTERNAL_SERVER_ERROR
  339. else:
  340. logger.warning(
  341. "Bad PUT request on %r (upload): %s", path, e, exc_info=True)
  342. return httputils.BAD_REQUEST
  343. if (item and item.uid == prepared_item.uid):
  344. logger.debug("PUT request updated existing item %r", path)
  345. headers = {"ETag": etag}
  346. return client.NO_CONTENT, headers, None, None
  347. headers = {"ETag": etag}
  348. return client.CREATED, headers, None, None