put.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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,
  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 ValueError as e:
  90. if logger.isEnabledFor(logging.DEBUG):
  91. logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, item._text)
  92. else:
  93. logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
  94. raise
  95. items.append(item)
  96. elif write_whole_collection and tag == "VADDRESSBOOK":
  97. for vobject_item in vobject_items:
  98. item = radicale_item.Item(collection_path=collection_path,
  99. vobject_item=vobject_item)
  100. item.prepare()
  101. items.append(item)
  102. elif not write_whole_collection:
  103. vobject_item, = vobject_items
  104. item = radicale_item.Item(collection_path=collection_path,
  105. vobject_item=vobject_item)
  106. item.prepare()
  107. items.append(item)
  108. if write_whole_collection:
  109. props = {}
  110. if tag:
  111. props["tag"] = tag
  112. if tag == "VCALENDAR" and vobject_items:
  113. if hasattr(vobject_items[0], "x_wr_calname"):
  114. calname = vobject_items[0].x_wr_calname.value
  115. if calname:
  116. props["D:displayname"] = calname
  117. if hasattr(vobject_items[0], "x_wr_caldesc"):
  118. caldesc = vobject_items[0].x_wr_caldesc.value
  119. if caldesc:
  120. props["C:calendar-description"] = caldesc
  121. props = radicale_item.check_and_sanitize_props(props)
  122. except Exception:
  123. exc_info_or_none_tuple = sys.exc_info()
  124. assert exc_info_or_none_tuple[0] is not None
  125. stored_exc_info = exc_info_or_none_tuple
  126. # Use iterator for items and delete references to free memory early
  127. def items_iter() -> Iterator[radicale_item.Item]:
  128. while items:
  129. yield items.pop(0)
  130. return items_iter(), tag, write_whole_collection, props, stored_exc_info
  131. class ApplicationPartPut(ApplicationBase):
  132. def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str,
  133. path: str, user: str) -> types.WSGIResponse:
  134. """Manage PUT request."""
  135. access = Access(self._rights, user, path)
  136. if not access.check("w"):
  137. return httputils.NOT_ALLOWED
  138. try:
  139. content = httputils.read_request_body(self.configuration, environ)
  140. except RuntimeError as e:
  141. logger.warning("Bad PUT request on %r (read_request_body): %s", path, e, exc_info=True)
  142. return httputils.BAD_REQUEST
  143. except socket.timeout:
  144. logger.debug("Client timed out", exc_info=True)
  145. return httputils.REQUEST_TIMEOUT
  146. # Prepare before locking
  147. content_type = environ.get("CONTENT_TYPE", "").split(";",
  148. maxsplit=1)[0]
  149. try:
  150. vobject_items = radicale_item.read_components(content or "")
  151. except Exception as e:
  152. logger.warning(
  153. "Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
  154. if self._log_bad_put_request_content:
  155. logger.warning("Bad PUT request content of %r:\n%s", path, content)
  156. else:
  157. logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
  158. return httputils.BAD_REQUEST
  159. (prepared_items, prepared_tag, prepared_write_whole_collection,
  160. prepared_props, prepared_exc_info) = prepare(
  161. vobject_items, path, content_type,
  162. bool(rights.intersect(access.permissions, "Ww")),
  163. bool(rights.intersect(access.parent_permissions, "w")))
  164. with self._storage.acquire_lock("w", user, path=path, request="PUT"):
  165. item = next(iter(self._storage.discover(path)), None)
  166. parent_item = next(iter(
  167. self._storage.discover(access.parent_path)), None)
  168. if not isinstance(parent_item, storage.BaseCollection):
  169. return httputils.CONFLICT
  170. write_whole_collection = (
  171. isinstance(item, storage.BaseCollection) or
  172. not parent_item.tag)
  173. if write_whole_collection:
  174. tag = prepared_tag
  175. else:
  176. tag = parent_item.tag
  177. if write_whole_collection:
  178. if ("w" if tag else "W") not in access.permissions:
  179. if not parent_item.tag:
  180. logger.warning("Not a collection (check .Radicale.props): %r", parent_item.path)
  181. return httputils.NOT_ALLOWED
  182. if not self._permit_overwrite_collection:
  183. if ("O") not in access.permissions:
  184. logger.info("overwrite of collection is prevented by config/option [rights] permit_overwrite_collection and not explicit allowed by permssion 'O': %r", path)
  185. return httputils.NOT_ALLOWED
  186. else:
  187. if ("o") in access.permissions:
  188. logger.info("overwrite of collection is allowed by config/option [rights] permit_overwrite_collection but explicit forbidden by permission 'o': %r", path)
  189. return httputils.NOT_ALLOWED
  190. elif "w" not in access.parent_permissions:
  191. return httputils.NOT_ALLOWED
  192. etag = environ.get("HTTP_IF_MATCH", "")
  193. if item and not etag and self._strict_preconditions:
  194. logger.warning("Precondition failed for %r: existing item, no If-Match header, strict mode enabled", path)
  195. return httputils.PRECONDITION_FAILED
  196. if not item and etag:
  197. # Etag asked but no item found: item has been removed
  198. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item not existing)", path, etag)
  199. return httputils.PRECONDITION_FAILED
  200. if item and etag and item.etag != etag:
  201. # Etag asked but item not matching: item has changed
  202. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item has different etag: %s)", path, etag, item.etag)
  203. return httputils.PRECONDITION_FAILED
  204. if item and etag:
  205. logger.debug("Precondition passed on PUT request for %r (HTTP_IF_MATCH: %s, item has etag: %s)", path, etag, item.etag)
  206. match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
  207. if item and match:
  208. # Creation asked but item found: item can't be replaced
  209. logger.warning("Precondition failed on PUT request for %r (HTTP_IF_NONE_MATCH: *, creation requested but item found with etag: %s)", path, item.etag)
  210. return httputils.PRECONDITION_FAILED
  211. if match:
  212. logger.debug("Precondition passed on PUT request for %r (HTTP_IF_NONE_MATCH: *)", path)
  213. if (tag != prepared_tag or
  214. prepared_write_whole_collection != write_whole_collection):
  215. (prepared_items, prepared_tag, prepared_write_whole_collection,
  216. prepared_props, prepared_exc_info) = prepare(
  217. vobject_items, path, content_type,
  218. bool(rights.intersect(access.permissions, "Ww")),
  219. bool(rights.intersect(access.parent_permissions, "w")),
  220. tag, write_whole_collection)
  221. props = prepared_props
  222. if prepared_exc_info:
  223. logger.warning(
  224. "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1],
  225. exc_info=prepared_exc_info)
  226. return httputils.BAD_REQUEST
  227. if write_whole_collection:
  228. try:
  229. col, replaced_items, new_item_hrefs = self._storage.create_collection(
  230. href=path,
  231. items=prepared_items,
  232. props=props)
  233. for item in prepared_items:
  234. # Try to grab the previously-existing item by href
  235. existing_item = replaced_items.get(item.href, None) # type: ignore
  236. if existing_item:
  237. hook_notification_item = HookNotificationItem(
  238. notification_item_type=HookNotificationItemTypes.UPSERT,
  239. path=access.path,
  240. content=existing_item.serialize(),
  241. uid=None,
  242. old_content=existing_item.serialize(),
  243. new_content=item.serialize()
  244. )
  245. else: # We assume the item is new because it was not in the replaced_items
  246. hook_notification_item = HookNotificationItem(
  247. notification_item_type=HookNotificationItemTypes.UPSERT,
  248. path=access.path,
  249. content=item.serialize(),
  250. uid=None,
  251. old_content=None,
  252. new_content=item.serialize()
  253. )
  254. self._hook.notify(hook_notification_item)
  255. except ValueError as e:
  256. logger.warning(
  257. "Bad PUT request on %r (create_collection): %s", path, e, exc_info=True)
  258. return httputils.BAD_REQUEST
  259. else:
  260. assert not isinstance(item, storage.BaseCollection)
  261. prepared_item, = prepared_items
  262. if (item and item.uid != prepared_item.uid or
  263. not item and parent_item.has_uid(prepared_item.uid)):
  264. return self._webdav_error_response(
  265. client.CONFLICT, "%s:no-uid-conflict" % (
  266. "C" if tag == "VCALENDAR" else "CR"))
  267. href = posixpath.basename(pathutils.strip_path(path))
  268. try:
  269. uploaded_item, replaced_item = parent_item.upload(href, prepared_item)
  270. etag = uploaded_item.etag
  271. hook_notification_item = HookNotificationItem(
  272. notification_item_type=HookNotificationItemTypes.UPSERT,
  273. path=access.path,
  274. content=prepared_item.serialize(),
  275. uid=None,
  276. old_content=replaced_item.serialize() if replaced_item else None,
  277. new_content=prepared_item.serialize()
  278. )
  279. self._hook.notify(hook_notification_item)
  280. except ValueError as e:
  281. # return better matching HTTP result in case errno is provided and catched
  282. errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
  283. if errno_match:
  284. logger.error(
  285. "Failed PUT request on %r (upload): %s", path, e, exc_info=True)
  286. errno_e = int(errno_match.group(1))
  287. if errno_e == errno.ENOSPC:
  288. return httputils.INSUFFICIENT_STORAGE
  289. elif errno_e in [errno.EPERM, errno.EACCES]:
  290. return httputils.FORBIDDEN
  291. else:
  292. return httputils.INTERNAL_SERVER_ERROR
  293. else:
  294. logger.warning(
  295. "Bad PUT request on %r (upload): %s", path, e, exc_info=True)
  296. return httputils.BAD_REQUEST
  297. if (item and item.uid == prepared_item.uid):
  298. logger.debug("PUT request updated existing item %r", path)
  299. headers = {"ETag": etag}
  300. return client.NO_CONTENT, headers, None
  301. headers = {"ETag": etag}
  302. return client.CREATED, headers, None