put.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  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. else:
  194. logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
  195. return httputils.BAD_REQUEST
  196. (prepared_items, prepared_tag, prepared_write_whole_collection,
  197. prepared_props, prepared_exc_info) = prepare(
  198. vobject_items, path, content_type,
  199. bool(rights.intersect(access.permissions, "Ww")),
  200. bool(rights.intersect(access.parent_permissions, "w")),
  201. self._max_resource_size)
  202. with self._storage.acquire_lock("w", user, path=path, request="PUT"):
  203. item = next(iter(self._storage.discover(path)), None)
  204. parent_item = next(iter(
  205. self._storage.discover(access.parent_path)), None)
  206. if not isinstance(parent_item, storage.BaseCollection):
  207. return httputils.CONFLICT
  208. write_whole_collection = (
  209. isinstance(item, storage.BaseCollection) or
  210. not parent_item.tag)
  211. if write_whole_collection:
  212. tag = prepared_tag
  213. else:
  214. tag = parent_item.tag
  215. if write_whole_collection:
  216. if ("w" if tag else "W") not in access.permissions:
  217. if not parent_item.tag:
  218. logger.warning("Not a collection (check .Radicale.props): %r", parent_item.path)
  219. return httputils.NOT_ALLOWED
  220. if not self._permit_overwrite_collection:
  221. if ("O") not in access.permissions:
  222. logger.info("overwrite of collection is prevented by config/option [rights] permit_overwrite_collection and not explicit allowed by permssion 'O': %r", path)
  223. return httputils.NOT_ALLOWED
  224. else:
  225. if ("o") in access.permissions:
  226. logger.info("overwrite of collection is allowed by config/option [rights] permit_overwrite_collection but explicit forbidden by permission 'o': %r", path)
  227. return httputils.NOT_ALLOWED
  228. elif "w" not in access.parent_permissions:
  229. return httputils.NOT_ALLOWED
  230. etag = environ.get("HTTP_IF_MATCH", "")
  231. if item and not etag and self._strict_preconditions:
  232. logger.warning("Precondition failed for %r: existing item, no If-Match header, strict mode enabled", path)
  233. return httputils.PRECONDITION_FAILED
  234. if not item and etag:
  235. # Etag asked but no item found: item has been removed
  236. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item not existing)", path, etag)
  237. return httputils.PRECONDITION_FAILED
  238. if item and etag and item.etag != etag:
  239. # Etag asked but item not matching: item has changed
  240. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item has different etag: %s)", path, etag, item.etag)
  241. return httputils.PRECONDITION_FAILED
  242. if item and etag:
  243. logger.debug("Precondition passed on PUT request for %r (HTTP_IF_MATCH: %s, item has etag: %s)", path, etag, item.etag)
  244. match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
  245. if item and match:
  246. # Creation asked but item found: item can't be replaced
  247. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_NONE_MATCH: *, creation requested but item found with etag: %s)", path, item.etag)
  248. return httputils.PRECONDITION_FAILED
  249. if match:
  250. logger.debug("Precondition passed on PUT request for %r (HTTP_IF_NONE_MATCH: *)", path)
  251. if (tag != prepared_tag or
  252. prepared_write_whole_collection != write_whole_collection):
  253. (prepared_items, prepared_tag, prepared_write_whole_collection,
  254. prepared_props, prepared_exc_info) = prepare(
  255. vobject_items, path, content_type,
  256. bool(rights.intersect(access.permissions, "Ww")),
  257. bool(rights.intersect(access.parent_permissions, "w")),
  258. self._max_resource_size,
  259. tag, write_whole_collection)
  260. props = prepared_props
  261. if prepared_exc_info:
  262. # Use OverflowError as flag for max_resource_size
  263. if prepared_exc_info[0] == OverflowError:
  264. return httputils.PRECONDITION_FAILED
  265. else:
  266. logger.warning(
  267. "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1],
  268. exc_info=prepared_exc_info)
  269. return httputils.BAD_REQUEST
  270. if write_whole_collection:
  271. try:
  272. col, replaced_items, new_item_hrefs = self._storage.create_collection(
  273. href=path,
  274. items=prepared_items,
  275. props=props)
  276. for item in prepared_items:
  277. # Try to grab the previously-existing item by href
  278. existing_item = replaced_items.get(item.href, None) # type: ignore
  279. if existing_item:
  280. hook_notification_item = HookNotificationItem(
  281. notification_item_type=HookNotificationItemTypes.UPSERT,
  282. path=access.path,
  283. content=existing_item.serialize(),
  284. uid=None,
  285. old_content=existing_item.serialize(),
  286. new_content=item.serialize()
  287. )
  288. else: # We assume the item is new because it was not in the replaced_items
  289. hook_notification_item = HookNotificationItem(
  290. notification_item_type=HookNotificationItemTypes.UPSERT,
  291. path=access.path,
  292. content=item.serialize(),
  293. uid=None,
  294. old_content=None,
  295. new_content=item.serialize()
  296. )
  297. self._hook.notify(hook_notification_item)
  298. except ValueError as e:
  299. logger.warning(
  300. "Bad PUT request on %r (create_collection): %s", path, e, exc_info=True)
  301. return httputils.BAD_REQUEST
  302. else:
  303. assert not isinstance(item, storage.BaseCollection)
  304. prepared_item, = prepared_items
  305. if (item and item.uid != prepared_item.uid or
  306. not item and parent_item.has_uid(prepared_item.uid)):
  307. return self._webdav_error_response(
  308. client.CONFLICT, "%s:no-uid-conflict" % (
  309. "C" if tag == "VCALENDAR" else "CR"))
  310. href = posixpath.basename(pathutils.strip_path(path))
  311. try:
  312. uploaded_item, replaced_item = parent_item.upload(href, prepared_item)
  313. etag = uploaded_item.etag
  314. hook_notification_item = HookNotificationItem(
  315. notification_item_type=HookNotificationItemTypes.UPSERT,
  316. path=access.path,
  317. content=prepared_item.serialize(),
  318. uid=None,
  319. old_content=replaced_item.serialize() if replaced_item else None,
  320. new_content=prepared_item.serialize()
  321. )
  322. self._hook.notify(hook_notification_item)
  323. except ValueError as e:
  324. # return better matching HTTP result in case errno is provided and catched
  325. errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
  326. if errno_match:
  327. logger.error(
  328. "Failed PUT request on %r (upload): %s", path, e, exc_info=True)
  329. errno_e = int(errno_match.group(1))
  330. if errno_e == errno.ENOSPC:
  331. return httputils.INSUFFICIENT_STORAGE
  332. elif errno_e in [errno.EPERM, errno.EACCES]:
  333. return httputils.FORBIDDEN
  334. else:
  335. return httputils.INTERNAL_SERVER_ERROR
  336. else:
  337. logger.warning(
  338. "Bad PUT request on %r (upload): %s", path, e, exc_info=True)
  339. return httputils.BAD_REQUEST
  340. if (item and item.uid == prepared_item.uid):
  341. logger.debug("PUT request updated existing item %r", path)
  342. headers = {"ETag": etag}
  343. return client.NO_CONTENT, headers, None, None
  344. headers = {"ETag": etag}
  345. return client.CREATED, headers, None, None