__init__.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
  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. #
  6. # This library is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. Radicale WSGI application.
  20. Can be used with an external WSGI server or the built-in server.
  21. """
  22. import base64
  23. import contextlib
  24. import datetime
  25. import io
  26. import itertools
  27. import logging
  28. import os
  29. import pkg_resources
  30. import posixpath
  31. import pprint
  32. import random
  33. import socket
  34. import sys
  35. import threading
  36. import time
  37. import zlib
  38. from http import client
  39. from urllib.parse import urlparse, quote
  40. from xml.etree import ElementTree as ET
  41. import vobject
  42. from radicale import auth, config, log, rights, storage, web, xmlutils
  43. from radicale.log import logger
  44. VERSION = pkg_resources.get_distribution("radicale").version
  45. NOT_ALLOWED = (
  46. client.FORBIDDEN, (("Content-Type", "text/plain"),),
  47. "Access to the requested resource forbidden.")
  48. FORBIDDEN = (
  49. client.FORBIDDEN, (("Content-Type", "text/plain"),),
  50. "Action on the requested resource refused.")
  51. BAD_REQUEST = (
  52. client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request")
  53. NOT_FOUND = (
  54. client.NOT_FOUND, (("Content-Type", "text/plain"),),
  55. "The requested resource could not be found.")
  56. CONFLICT = (
  57. client.CONFLICT, (("Content-Type", "text/plain"),),
  58. "Conflict in the request.")
  59. WEBDAV_PRECONDITION_FAILED = (
  60. client.CONFLICT, (("Content-Type", "text/plain"),),
  61. "WebDAV precondition failed.")
  62. METHOD_NOT_ALLOWED = (
  63. client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),),
  64. "The method is not allowed on the requested resource.")
  65. PRECONDITION_FAILED = (
  66. client.PRECONDITION_FAILED,
  67. (("Content-Type", "text/plain"),), "Precondition failed.")
  68. REQUEST_TIMEOUT = (
  69. client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
  70. "Connection timed out.")
  71. REQUEST_ENTITY_TOO_LARGE = (
  72. client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
  73. "Request body too large.")
  74. REMOTE_DESTINATION = (
  75. client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
  76. "Remote destination not supported.")
  77. DIRECTORY_LISTING = (
  78. client.FORBIDDEN, (("Content-Type", "text/plain"),),
  79. "Directory listings are not supported.")
  80. INTERNAL_SERVER_ERROR = (
  81. client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
  82. "A server error occurred. Please contact the administrator.")
  83. DAV_HEADERS = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
  84. class Application:
  85. """WSGI application managing collections."""
  86. def __init__(self, configuration):
  87. """Initialize application."""
  88. super().__init__()
  89. self.configuration = configuration
  90. self.Auth = auth.load(configuration)
  91. self.Collection = storage.load(configuration)
  92. self.Rights = rights.load(configuration)
  93. self.Web = web.load(configuration)
  94. self.encoding = configuration.get("encoding", "request")
  95. def headers_log(self, environ):
  96. """Sanitize headers for logging."""
  97. request_environ = dict(environ)
  98. # Mask passwords
  99. mask_passwords = self.configuration.getboolean(
  100. "logging", "mask_passwords")
  101. authorization = request_environ.get("HTTP_AUTHORIZATION", "")
  102. if mask_passwords and authorization.startswith("Basic"):
  103. request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
  104. if request_environ.get("HTTP_COOKIE"):
  105. request_environ["HTTP_COOKIE"] = "**masked**"
  106. return request_environ
  107. def decode(self, text, environ):
  108. """Try to magically decode ``text`` according to given ``environ``."""
  109. # List of charsets to try
  110. charsets = []
  111. # First append content charset given in the request
  112. content_type = environ.get("CONTENT_TYPE")
  113. if content_type and "charset=" in content_type:
  114. charsets.append(
  115. content_type.split("charset=")[1].split(";")[0].strip())
  116. # Then append default Radicale charset
  117. charsets.append(self.encoding)
  118. # Then append various fallbacks
  119. charsets.append("utf-8")
  120. charsets.append("iso8859-1")
  121. # Try to decode
  122. for charset in charsets:
  123. try:
  124. return text.decode(charset)
  125. except UnicodeDecodeError:
  126. pass
  127. raise UnicodeDecodeError
  128. def collect_allowed_items(self, items, user):
  129. """Get items from request that user is allowed to access."""
  130. for item in items:
  131. if isinstance(item, storage.BaseCollection):
  132. path = storage.sanitize_path("/%s/" % item.path)
  133. if item.get_meta("tag"):
  134. permissions = self.Rights.authorized(user, path, "rw")
  135. target = "collection with tag %r" % item.path
  136. else:
  137. permissions = self.Rights.authorized(user, path, "RW")
  138. target = "collection %r" % item.path
  139. else:
  140. path = storage.sanitize_path("/%s/" % item.collection.path)
  141. permissions = self.Rights.authorized(user, path, "rw")
  142. target = "item %r from %r" % (item.href, item.collection.path)
  143. if rights.intersect_permissions(permissions, "Ww"):
  144. permission = "w"
  145. status = "write"
  146. elif rights.intersect_permissions(permissions, "Rr"):
  147. permission = "r"
  148. status = "read"
  149. else:
  150. permission = ""
  151. status = "NO"
  152. logger.debug(
  153. "%s has %s access to %s",
  154. repr(user) if user else "anonymous user", status, target)
  155. if permission:
  156. yield item, permission
  157. def __call__(self, environ, start_response):
  158. with log.register_stream(environ["wsgi.errors"]):
  159. try:
  160. status, headers, answers = self._handle_request(environ)
  161. except Exception as e:
  162. try:
  163. method = str(environ["REQUEST_METHOD"])
  164. except Exception:
  165. method = "unknown"
  166. try:
  167. path = str(environ.get("PATH_INFO", ""))
  168. except Exception:
  169. path = ""
  170. logger.error("An exception occurred during %s request on %r: "
  171. "%s", method, path, e, exc_info=True)
  172. status, headers, answer = INTERNAL_SERVER_ERROR
  173. answer = answer.encode("ascii")
  174. status = "%d %s" % (
  175. status, client.responses.get(status, "Unknown"))
  176. headers = [
  177. ("Content-Length", str(len(answer)))] + list(headers)
  178. answers = [answer]
  179. start_response(status, headers)
  180. return answers
  181. def _handle_request(self, environ):
  182. """Manage a request."""
  183. def response(status, headers=(), answer=None):
  184. headers = dict(headers)
  185. # Set content length
  186. if answer:
  187. if hasattr(answer, "encode"):
  188. logger.debug("Response content:\n%s", answer)
  189. headers["Content-Type"] += "; charset=%s" % self.encoding
  190. answer = answer.encode(self.encoding)
  191. accept_encoding = [
  192. encoding.strip() for encoding in
  193. environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
  194. if encoding.strip()]
  195. if "gzip" in accept_encoding:
  196. zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
  197. answer = zcomp.compress(answer) + zcomp.flush()
  198. headers["Content-Encoding"] = "gzip"
  199. headers["Content-Length"] = str(len(answer))
  200. # Add extra headers set in configuration
  201. if self.configuration.has_section("headers"):
  202. for key in self.configuration.options("headers"):
  203. headers[key] = self.configuration.get("headers", key)
  204. # Start response
  205. time_end = datetime.datetime.now()
  206. status = "%d %s" % (
  207. status, client.responses.get(status, "Unknown"))
  208. logger.info(
  209. "%s response status for %r%s in %.3f seconds: %s",
  210. environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""),
  211. depthinfo, (time_end - time_begin).total_seconds(), status)
  212. # Return response content
  213. return status, list(headers.items()), [answer] if answer else []
  214. remote_host = "unknown"
  215. if environ.get("REMOTE_HOST"):
  216. remote_host = repr(environ["REMOTE_HOST"])
  217. elif environ.get("REMOTE_ADDR"):
  218. remote_host = environ["REMOTE_ADDR"]
  219. if environ.get("HTTP_X_FORWARDED_FOR"):
  220. remote_host = "%r (forwarded by %s)" % (
  221. environ["HTTP_X_FORWARDED_FOR"], remote_host)
  222. remote_useragent = ""
  223. if environ.get("HTTP_USER_AGENT"):
  224. remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
  225. depthinfo = ""
  226. if environ.get("HTTP_DEPTH"):
  227. depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
  228. time_begin = datetime.datetime.now()
  229. logger.info(
  230. "%s request for %r%s received from %s%s",
  231. environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo,
  232. remote_host, remote_useragent)
  233. headers = pprint.pformat(self.headers_log(environ))
  234. logger.debug("Request headers:\n%s", headers)
  235. # Let reverse proxies overwrite SCRIPT_NAME
  236. if "HTTP_X_SCRIPT_NAME" in environ:
  237. # script_name must be removed from PATH_INFO by the client.
  238. unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"]
  239. logger.debug("Script name overwritten by client: %r",
  240. unsafe_base_prefix)
  241. else:
  242. # SCRIPT_NAME is already removed from PATH_INFO, according to the
  243. # WSGI specification.
  244. unsafe_base_prefix = environ.get("SCRIPT_NAME", "")
  245. # Sanitize base prefix
  246. base_prefix = storage.sanitize_path(unsafe_base_prefix).rstrip("/")
  247. logger.debug("Sanitized script name: %r", base_prefix)
  248. # Sanitize request URI (a WSGI server indicates with an empty path,
  249. # that the URL targets the application root without a trailing slash)
  250. path = storage.sanitize_path(environ.get("PATH_INFO", ""))
  251. logger.debug("Sanitized path: %r", path)
  252. # Get function corresponding to method
  253. function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())
  254. # If "/.well-known" is not available, clients query "/"
  255. if path == "/.well-known" or path.startswith("/.well-known/"):
  256. return response(*NOT_FOUND)
  257. # Ask authentication backend to check rights
  258. login = password = ""
  259. external_login = self.Auth.get_external_login(environ)
  260. authorization = environ.get("HTTP_AUTHORIZATION", "")
  261. if external_login:
  262. login, password = external_login
  263. login, password = login or "", password or ""
  264. elif authorization.startswith("Basic"):
  265. authorization = authorization[len("Basic"):].strip()
  266. login, password = self.decode(base64.b64decode(
  267. authorization.encode("ascii")), environ).split(":", 1)
  268. user = self.Auth.login(login, password) or "" if login else ""
  269. if user and login == user:
  270. logger.info("Successful login: %r", user)
  271. elif user:
  272. logger.info("Successful login: %r -> %r", login, user)
  273. elif login:
  274. logger.info("Failed login attempt: %r", login)
  275. # Random delay to avoid timing oracles and bruteforce attacks
  276. delay = self.configuration.getfloat("auth", "delay")
  277. if delay > 0:
  278. random_delay = delay * (0.5 + random.random())
  279. logger.debug("Sleeping %.3f seconds", random_delay)
  280. time.sleep(random_delay)
  281. if user and not storage.is_safe_path_component(user):
  282. # Prevent usernames like "user/calendar.ics"
  283. logger.info("Refused unsafe username: %r", user)
  284. user = ""
  285. # Create principal collection
  286. if user:
  287. principal_path = "/%s/" % user
  288. if self.Rights.authorized(user, principal_path, "W"):
  289. with self.Collection.acquire_lock("r", user):
  290. principal = next(
  291. self.Collection.discover(principal_path, depth="1"),
  292. None)
  293. if not principal:
  294. with self.Collection.acquire_lock("w", user):
  295. try:
  296. self.Collection.create_collection(principal_path)
  297. except ValueError as e:
  298. logger.warning("Failed to create principal "
  299. "collection %r: %s", user, e)
  300. user = ""
  301. else:
  302. logger.warning("Access to principal path %r denied by "
  303. "rights backend", principal_path)
  304. if self.configuration.getboolean("internal", "internal_server"):
  305. # Verify content length
  306. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  307. if content_length:
  308. max_content_length = self.configuration.getint(
  309. "server", "max_content_length")
  310. if max_content_length and content_length > max_content_length:
  311. logger.info("Request body too large: %d", content_length)
  312. return response(*REQUEST_ENTITY_TOO_LARGE)
  313. if not login or user:
  314. status, headers, answer = function(
  315. environ, base_prefix, path, user)
  316. if (status, headers, answer) == NOT_ALLOWED:
  317. logger.info("Access to %r denied for %s", path,
  318. repr(user) if user else "anonymous user")
  319. else:
  320. status, headers, answer = NOT_ALLOWED
  321. if ((status, headers, answer) == NOT_ALLOWED and not user and
  322. not external_login):
  323. # Unknown or unauthorized user
  324. logger.debug("Asking client for authentication")
  325. status = client.UNAUTHORIZED
  326. realm = self.configuration.get("auth", "realm")
  327. headers = dict(headers)
  328. headers.update({
  329. "WWW-Authenticate":
  330. "Basic realm=\"%s\"" % realm})
  331. return response(status, headers, answer)
  332. def _access(self, user, path, permission, item=None):
  333. if permission not in "rw":
  334. raise ValueError("Invalid permission argument: %r" % permission)
  335. if not item:
  336. permissions = permission + permission.upper()
  337. parent_permissions = permission
  338. elif isinstance(item, storage.BaseCollection):
  339. if item.get_meta("tag"):
  340. permissions = permission
  341. else:
  342. permissions = permission.upper()
  343. parent_permissions = ""
  344. else:
  345. permissions = ""
  346. parent_permissions = permission
  347. if permissions and self.Rights.authorized(user, path, permissions):
  348. return True
  349. if parent_permissions:
  350. parent_path = storage.sanitize_path(
  351. "/%s/" % posixpath.dirname(path.strip("/")))
  352. if self.Rights.authorized(user, parent_path, parent_permissions):
  353. return True
  354. return False
  355. def _read_raw_content(self, environ):
  356. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  357. if not content_length:
  358. return b""
  359. content = environ["wsgi.input"].read(content_length)
  360. if len(content) < content_length:
  361. raise RuntimeError("Request body too short: %d" % len(content))
  362. return content
  363. def _read_content(self, environ):
  364. content = self.decode(self._read_raw_content(environ), environ)
  365. logger.debug("Request content:\n%s", content)
  366. return content
  367. def _read_xml_content(self, environ):
  368. content = self.decode(self._read_raw_content(environ), environ)
  369. if not content:
  370. return None
  371. try:
  372. xml_content = ET.fromstring(content)
  373. except ET.ParseError as e:
  374. logger.debug("Request content (Invalid XML):\n%s", content)
  375. raise RuntimeError("Failed to parse XML: %s" % e) from e
  376. if logger.isEnabledFor(logging.DEBUG):
  377. logger.debug("Request content:\n%s",
  378. xmlutils.pretty_xml(xml_content))
  379. return xml_content
  380. def _write_xml_content(self, xml_content):
  381. if logger.isEnabledFor(logging.DEBUG):
  382. logger.debug("Response content:\n%s",
  383. xmlutils.pretty_xml(xml_content))
  384. f = io.BytesIO()
  385. ET.ElementTree(xml_content).write(f, encoding=self.encoding,
  386. xml_declaration=True)
  387. return f.getvalue()
  388. def _webdav_error_response(self, namespace, name,
  389. status=WEBDAV_PRECONDITION_FAILED[0]):
  390. """Generate XML error response."""
  391. headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
  392. content = self._write_xml_content(
  393. xmlutils.webdav_error(namespace, name))
  394. return status, headers, content
  395. def _propose_filename(self, collection):
  396. """Propose a filename for a collection."""
  397. tag = collection.get_meta("tag")
  398. if tag == "VADDRESSBOOK":
  399. fallback_title = "Address book"
  400. suffix = ".vcf"
  401. elif tag == "VCALENDAR":
  402. fallback_title = "Calendar"
  403. suffix = ".ics"
  404. else:
  405. fallback_title = posixpath.basename(collection.path)
  406. suffix = ""
  407. title = collection.get_meta("D:displayname") or fallback_title
  408. if title and not title.lower().endswith(suffix.lower()):
  409. title += suffix
  410. return title
  411. def _content_disposition_attachement(self, filename):
  412. value = "attachement"
  413. try:
  414. encoded_filename = quote(filename, encoding=self.encoding)
  415. except UnicodeEncodeError as e:
  416. logger.warning("Failed to encode filename: %r", filename,
  417. exc_info=True)
  418. encoded_filename = ""
  419. if encoded_filename:
  420. value += "; filename*=%s''%s" % (self.encoding, encoded_filename)
  421. return value
  422. def do_DELETE(self, environ, base_prefix, path, user):
  423. """Manage DELETE request."""
  424. if not self._access(user, path, "w"):
  425. return NOT_ALLOWED
  426. with self.Collection.acquire_lock("w", user):
  427. item = next(self.Collection.discover(path), None)
  428. if not item:
  429. return NOT_FOUND
  430. if not self._access(user, path, "w", item):
  431. return NOT_ALLOWED
  432. if_match = environ.get("HTTP_IF_MATCH", "*")
  433. if if_match not in ("*", item.etag):
  434. # ETag precondition not verified, do not delete item
  435. return PRECONDITION_FAILED
  436. if isinstance(item, storage.BaseCollection):
  437. xml_answer = xmlutils.delete(base_prefix, path, item)
  438. else:
  439. xml_answer = xmlutils.delete(
  440. base_prefix, path, item.collection, item.href)
  441. headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
  442. return client.OK, headers, self._write_xml_content(xml_answer)
  443. def do_GET(self, environ, base_prefix, path, user):
  444. """Manage GET request."""
  445. # Redirect to .web if the root URL is requested
  446. if not path.strip("/"):
  447. web_path = ".web"
  448. if not environ.get("PATH_INFO"):
  449. web_path = posixpath.join(posixpath.basename(base_prefix),
  450. web_path)
  451. return (client.FOUND,
  452. {"Location": web_path, "Content-Type": "text/plain"},
  453. "Redirected to %s" % web_path)
  454. # Dispatch .web URL to web module
  455. if path == "/.web" or path.startswith("/.web/"):
  456. return self.Web.get(environ, base_prefix, path, user)
  457. if not self._access(user, path, "r"):
  458. return NOT_ALLOWED
  459. with self.Collection.acquire_lock("r", user):
  460. item = next(self.Collection.discover(path), None)
  461. if not item:
  462. return NOT_FOUND
  463. if not self._access(user, path, "r", item):
  464. return NOT_ALLOWED
  465. if isinstance(item, storage.BaseCollection):
  466. tag = item.get_meta("tag")
  467. if not tag:
  468. return DIRECTORY_LISTING
  469. content_type = xmlutils.MIMETYPES[tag]
  470. content_disposition = self._content_disposition_attachement(
  471. self._propose_filename(item))
  472. else:
  473. content_type = xmlutils.OBJECT_MIMETYPES[item.name]
  474. content_disposition = ""
  475. headers = {
  476. "Content-Type": content_type,
  477. "Last-Modified": item.last_modified,
  478. "ETag": item.etag}
  479. if content_disposition:
  480. headers["Content-Disposition"] = content_disposition
  481. answer = item.serialize()
  482. return client.OK, headers, answer
  483. def do_HEAD(self, environ, base_prefix, path, user):
  484. """Manage HEAD request."""
  485. status, headers, answer = self.do_GET(
  486. environ, base_prefix, path, user)
  487. return status, headers, None
  488. def do_MKCALENDAR(self, environ, base_prefix, path, user):
  489. """Manage MKCALENDAR request."""
  490. if not self.Rights.authorized(user, path, "w"):
  491. return NOT_ALLOWED
  492. try:
  493. xml_content = self._read_xml_content(environ)
  494. except RuntimeError as e:
  495. logger.warning(
  496. "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
  497. return BAD_REQUEST
  498. except socket.timeout as e:
  499. logger.debug("client timed out", exc_info=True)
  500. return REQUEST_TIMEOUT
  501. # Prepare before locking
  502. props = xmlutils.props_from_request(xml_content)
  503. props["tag"] = "VCALENDAR"
  504. # TODO: use this?
  505. # timezone = props.get("C:calendar-timezone")
  506. try:
  507. storage.check_and_sanitize_props(props)
  508. except ValueError as e:
  509. logger.warning(
  510. "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
  511. with self.Collection.acquire_lock("w", user):
  512. item = next(self.Collection.discover(path), None)
  513. if item:
  514. return self._webdav_error_response(
  515. "D", "resource-must-be-null")
  516. parent_path = storage.sanitize_path(
  517. "/%s/" % posixpath.dirname(path.strip("/")))
  518. parent_item = next(self.Collection.discover(parent_path), None)
  519. if not parent_item:
  520. return CONFLICT
  521. if (not isinstance(parent_item, storage.BaseCollection) or
  522. parent_item.get_meta("tag")):
  523. return FORBIDDEN
  524. try:
  525. self.Collection.create_collection(path, props=props)
  526. except ValueError as e:
  527. logger.warning(
  528. "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
  529. return BAD_REQUEST
  530. return client.CREATED, {}, None
  531. def do_MKCOL(self, environ, base_prefix, path, user):
  532. """Manage MKCOL request."""
  533. permissions = self.Rights.authorized(user, path, "Ww")
  534. if not permissions:
  535. return NOT_ALLOWED
  536. try:
  537. xml_content = self._read_xml_content(environ)
  538. except RuntimeError as e:
  539. logger.warning(
  540. "Bad MKCOL request on %r: %s", path, e, exc_info=True)
  541. return BAD_REQUEST
  542. except socket.timeout as e:
  543. logger.debug("client timed out", exc_info=True)
  544. return REQUEST_TIMEOUT
  545. # Prepare before locking
  546. props = xmlutils.props_from_request(xml_content)
  547. try:
  548. storage.check_and_sanitize_props(props)
  549. except ValueError as e:
  550. logger.warning(
  551. "Bad MKCOL request on %r: %s", path, e, exc_info=True)
  552. return BAD_REQUEST
  553. if (props.get("tag") and "w" not in permissions or
  554. not props.get("tag") and "W" not in permissions):
  555. return NOT_ALLOWED
  556. with self.Collection.acquire_lock("w", user):
  557. item = next(self.Collection.discover(path), None)
  558. if item:
  559. return METHOD_NOT_ALLOWED
  560. parent_path = storage.sanitize_path(
  561. "/%s/" % posixpath.dirname(path.strip("/")))
  562. parent_item = next(self.Collection.discover(parent_path), None)
  563. if not parent_item:
  564. return CONFLICT
  565. if (not isinstance(parent_item, storage.BaseCollection) or
  566. parent_item.get_meta("tag")):
  567. return FORBIDDEN
  568. try:
  569. self.Collection.create_collection(path, props=props)
  570. except ValueError as e:
  571. logger.warning(
  572. "Bad MKCOL request on %r: %s", path, e, exc_info=True)
  573. return BAD_REQUEST
  574. return client.CREATED, {}, None
  575. def do_MOVE(self, environ, base_prefix, path, user):
  576. """Manage MOVE request."""
  577. raw_dest = environ.get("HTTP_DESTINATION", "")
  578. to_url = urlparse(raw_dest)
  579. if to_url.netloc != environ["HTTP_HOST"]:
  580. logger.info("Unsupported destination address: %r", raw_dest)
  581. # Remote destination server, not supported
  582. return REMOTE_DESTINATION
  583. if not self._access(user, path, "w"):
  584. return NOT_ALLOWED
  585. to_path = storage.sanitize_path(to_url.path)
  586. if not (to_path + "/").startswith(base_prefix + "/"):
  587. logger.warning("Destination %r from MOVE request on %r doesn't "
  588. "start with base prefix", to_path, path)
  589. return NOT_ALLOWED
  590. to_path = to_path[len(base_prefix):]
  591. if not self._access(user, to_path, "w"):
  592. return NOT_ALLOWED
  593. with self.Collection.acquire_lock("w", user):
  594. item = next(self.Collection.discover(path), None)
  595. if not item:
  596. return NOT_FOUND
  597. if (not self._access(user, path, "w", item) or
  598. not self._access(user, to_path, "w", item)):
  599. return NOT_ALLOWED
  600. if isinstance(item, storage.BaseCollection):
  601. # TODO: support moving collections
  602. return METHOD_NOT_ALLOWED
  603. to_item = next(self.Collection.discover(to_path), None)
  604. if isinstance(to_item, storage.BaseCollection):
  605. return FORBIDDEN
  606. to_parent_path = storage.sanitize_path(
  607. "/%s/" % posixpath.dirname(to_path.strip("/")))
  608. to_collection = next(
  609. self.Collection.discover(to_parent_path), None)
  610. if not to_collection:
  611. return CONFLICT
  612. tag = item.collection.get_meta("tag")
  613. if not tag or tag != to_collection.get_meta("tag"):
  614. return FORBIDDEN
  615. if to_item and environ.get("HTTP_OVERWRITE", "F") != "T":
  616. return PRECONDITION_FAILED
  617. if (to_item and item.uid != to_item.uid or
  618. not to_item and
  619. to_collection.path != item.collection.path and
  620. to_collection.has_uid(item.uid)):
  621. return self._webdav_error_response(
  622. "C" if tag == "VCALENDAR" else "CR", "no-uid-conflict")
  623. to_href = posixpath.basename(to_path.strip("/"))
  624. try:
  625. self.Collection.move(item, to_collection, to_href)
  626. except ValueError as e:
  627. logger.warning(
  628. "Bad MOVE request on %r: %s", path, e, exc_info=True)
  629. return BAD_REQUEST
  630. return client.NO_CONTENT if to_item else client.CREATED, {}, None
  631. def do_OPTIONS(self, environ, base_prefix, path, user):
  632. """Manage OPTIONS request."""
  633. headers = {
  634. "Allow": ", ".join(
  635. name[3:] for name in dir(self) if name.startswith("do_")),
  636. "DAV": DAV_HEADERS}
  637. return client.OK, headers, None
  638. def do_PROPFIND(self, environ, base_prefix, path, user):
  639. """Manage PROPFIND request."""
  640. if not self._access(user, path, "r"):
  641. return NOT_ALLOWED
  642. try:
  643. xml_content = self._read_xml_content(environ)
  644. except RuntimeError as e:
  645. logger.warning(
  646. "Bad PROPFIND request on %r: %s", path, e, exc_info=True)
  647. return BAD_REQUEST
  648. except socket.timeout as e:
  649. logger.debug("client timed out", exc_info=True)
  650. return REQUEST_TIMEOUT
  651. with self.Collection.acquire_lock("r", user):
  652. items = self.Collection.discover(
  653. path, environ.get("HTTP_DEPTH", "0"))
  654. # take root item for rights checking
  655. item = next(items, None)
  656. if not item:
  657. return NOT_FOUND
  658. if not self._access(user, path, "r", item):
  659. return NOT_ALLOWED
  660. # put item back
  661. items = itertools.chain([item], items)
  662. allowed_items = self.collect_allowed_items(items, user)
  663. headers = {"DAV": DAV_HEADERS,
  664. "Content-Type": "text/xml; charset=%s" % self.encoding}
  665. status, xml_answer = xmlutils.propfind(
  666. base_prefix, path, xml_content, allowed_items, user)
  667. if status == client.FORBIDDEN:
  668. return NOT_ALLOWED
  669. return status, headers, self._write_xml_content(xml_answer)
  670. def do_PROPPATCH(self, environ, base_prefix, path, user):
  671. """Manage PROPPATCH request."""
  672. if not self._access(user, path, "w"):
  673. return NOT_ALLOWED
  674. try:
  675. xml_content = self._read_xml_content(environ)
  676. except RuntimeError as e:
  677. logger.warning(
  678. "Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
  679. return BAD_REQUEST
  680. except socket.timeout as e:
  681. logger.debug("client timed out", exc_info=True)
  682. return REQUEST_TIMEOUT
  683. with self.Collection.acquire_lock("w", user):
  684. item = next(self.Collection.discover(path), None)
  685. if not item:
  686. return NOT_FOUND
  687. if not self._access(user, path, "w", item):
  688. return NOT_ALLOWED
  689. if not isinstance(item, storage.BaseCollection):
  690. return FORBIDDEN
  691. headers = {"DAV": DAV_HEADERS,
  692. "Content-Type": "text/xml; charset=%s" % self.encoding}
  693. try:
  694. xml_answer = xmlutils.proppatch(base_prefix, path, xml_content,
  695. item)
  696. except ValueError as e:
  697. logger.warning(
  698. "Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
  699. return BAD_REQUEST
  700. return (client.MULTI_STATUS, headers,
  701. self._write_xml_content(xml_answer))
  702. def do_PUT(self, environ, base_prefix, path, user):
  703. """Manage PUT request."""
  704. if not self._access(user, path, "w"):
  705. return NOT_ALLOWED
  706. try:
  707. content = self._read_content(environ)
  708. except RuntimeError as e:
  709. logger.warning("Bad PUT request on %r: %s", path, e, exc_info=True)
  710. return BAD_REQUEST
  711. except socket.timeout as e:
  712. logger.debug("client timed out", exc_info=True)
  713. return REQUEST_TIMEOUT
  714. # Prepare before locking
  715. parent_path = storage.sanitize_path(
  716. "/%s/" % posixpath.dirname(path.strip("/")))
  717. permissions = self.Rights.authorized(user, path, "Ww")
  718. parent_permissions = self.Rights.authorized(user, parent_path, "w")
  719. def prepare(vobject_items, tag=None, write_whole_collection=None):
  720. if (write_whole_collection or
  721. permissions and not parent_permissions):
  722. write_whole_collection = True
  723. content_type = environ.get("CONTENT_TYPE",
  724. "").split(";")[0]
  725. tags = {value: key
  726. for key, value in xmlutils.MIMETYPES.items()}
  727. tag = storage.predict_tag_of_whole_collection(
  728. vobject_items, tags.get(content_type))
  729. if not tag:
  730. raise ValueError("Can't determine collection tag")
  731. collection_path = storage.sanitize_path(path).strip("/")
  732. elif (write_whole_collection is not None and
  733. not write_whole_collection or
  734. not permissions and parent_permissions):
  735. write_whole_collection = False
  736. if tag is None:
  737. tag = storage.predict_tag_of_parent_collection(
  738. vobject_items)
  739. collection_path = posixpath.dirname(
  740. storage.sanitize_path(path).strip("/"))
  741. props = None
  742. stored_exc_info = None
  743. items = []
  744. try:
  745. if tag:
  746. storage.check_and_sanitize_items(
  747. vobject_items, is_collection=write_whole_collection,
  748. tag=tag)
  749. if write_whole_collection and tag == "VCALENDAR":
  750. vobject_components = []
  751. vobject_item, = vobject_items
  752. for content in ("vevent", "vtodo", "vjournal"):
  753. vobject_components.extend(
  754. getattr(vobject_item, "%s_list" % content, []))
  755. vobject_components_by_uid = itertools.groupby(
  756. sorted(vobject_components, key=storage.get_uid),
  757. storage.get_uid)
  758. for uid, components in vobject_components_by_uid:
  759. vobject_collection = vobject.iCalendar()
  760. for component in components:
  761. vobject_collection.add(component)
  762. item = storage.Item(
  763. collection_path=collection_path,
  764. vobject_item=vobject_collection)
  765. item.prepare()
  766. items.append(item)
  767. elif write_whole_collection and tag == "VADDRESSBOOK":
  768. for vobject_item in vobject_items:
  769. item = storage.Item(
  770. collection_path=collection_path,
  771. vobject_item=vobject_item)
  772. item.prepare()
  773. items.append(item)
  774. elif not write_whole_collection:
  775. vobject_item, = vobject_items
  776. item = storage.Item(collection_path=collection_path,
  777. vobject_item=vobject_item)
  778. item.prepare()
  779. items.append(item)
  780. if write_whole_collection:
  781. props = {}
  782. if tag:
  783. props["tag"] = tag
  784. if tag == "VCALENDAR" and vobject_items:
  785. if hasattr(vobject_items[0], "x_wr_calname"):
  786. calname = vobject_items[0].x_wr_calname.value
  787. if calname:
  788. props["D:displayname"] = calname
  789. if hasattr(vobject_items[0], "x_wr_caldesc"):
  790. caldesc = vobject_items[0].x_wr_caldesc.value
  791. if caldesc:
  792. props["C:calendar-description"] = caldesc
  793. storage.check_and_sanitize_props(props)
  794. except Exception:
  795. stored_exc_info = sys.exc_info()
  796. # Use generator for items and delete references to free memory
  797. # early
  798. def items_generator():
  799. while items:
  800. yield items.pop(0)
  801. return (items_generator(), tag, write_whole_collection, props,
  802. stored_exc_info)
  803. try:
  804. vobject_items = tuple(vobject.readComponents(content or ""))
  805. except Exception as e:
  806. logger.warning(
  807. "Bad PUT request on %r: %s", path, e, exc_info=True)
  808. return BAD_REQUEST
  809. (prepared_items, prepared_tag, prepared_write_whole_collection,
  810. prepared_props, prepared_exc_info) = prepare(vobject_items)
  811. with self.Collection.acquire_lock("w", user):
  812. item = next(self.Collection.discover(path), None)
  813. parent_item = next(self.Collection.discover(parent_path), None)
  814. if not parent_item:
  815. return CONFLICT
  816. write_whole_collection = (
  817. isinstance(item, storage.BaseCollection) or
  818. not parent_item.get_meta("tag"))
  819. if write_whole_collection:
  820. tag = prepared_tag
  821. else:
  822. tag = parent_item.get_meta("tag")
  823. if write_whole_collection:
  824. if not self.Rights.authorized(user, path, "w" if tag else "W"):
  825. return NOT_ALLOWED
  826. elif not self.Rights.authorized(user, parent_path, "w"):
  827. return NOT_ALLOWED
  828. etag = environ.get("HTTP_IF_MATCH", "")
  829. if not item and etag:
  830. # Etag asked but no item found: item has been removed
  831. return PRECONDITION_FAILED
  832. if item and etag and item.etag != etag:
  833. # Etag asked but item not matching: item has changed
  834. return PRECONDITION_FAILED
  835. match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
  836. if item and match:
  837. # Creation asked but item found: item can't be replaced
  838. return PRECONDITION_FAILED
  839. if (tag != prepared_tag or
  840. prepared_write_whole_collection != write_whole_collection):
  841. (prepared_items, prepared_tag, prepared_write_whole_collection,
  842. prepared_props, prepared_exc_info) = prepare(
  843. vobject_items, tag, write_whole_collection)
  844. props = prepared_props
  845. if prepared_exc_info:
  846. logger.warning(
  847. "Bad PUT request on %r: %s", path, prepared_exc_info[1],
  848. exc_info=prepared_exc_info)
  849. return BAD_REQUEST
  850. if write_whole_collection:
  851. try:
  852. etag = self.Collection.create_collection(
  853. path, prepared_items, props).etag
  854. except ValueError as e:
  855. logger.warning(
  856. "Bad PUT request on %r: %s", path, e, exc_info=True)
  857. return BAD_REQUEST
  858. else:
  859. prepared_item, = prepared_items
  860. if (item and item.uid != prepared_item.uid or
  861. not item and parent_item.has_uid(prepared_item.uid)):
  862. return self._webdav_error_response(
  863. "C" if tag == "VCALENDAR" else "CR",
  864. "no-uid-conflict")
  865. href = posixpath.basename(path.strip("/"))
  866. try:
  867. etag = parent_item.upload(href, prepared_item).etag
  868. except ValueError as e:
  869. logger.warning(
  870. "Bad PUT request on %r: %s", path, e, exc_info=True)
  871. return BAD_REQUEST
  872. headers = {"ETag": etag}
  873. return client.CREATED, headers, None
  874. def do_REPORT(self, environ, base_prefix, path, user):
  875. """Manage REPORT request."""
  876. if not self._access(user, path, "r"):
  877. return NOT_ALLOWED
  878. try:
  879. xml_content = self._read_xml_content(environ)
  880. except RuntimeError as e:
  881. logger.warning(
  882. "Bad REPORT request on %r: %s", path, e, exc_info=True)
  883. return BAD_REQUEST
  884. except socket.timeout as e:
  885. logger.debug("client timed out", exc_info=True)
  886. return REQUEST_TIMEOUT
  887. with contextlib.ExitStack() as lock_stack:
  888. lock_stack.enter_context(self.Collection.acquire_lock("r", user))
  889. item = next(self.Collection.discover(path), None)
  890. if not item:
  891. return NOT_FOUND
  892. if not self._access(user, path, "r", item):
  893. return NOT_ALLOWED
  894. if isinstance(item, storage.BaseCollection):
  895. collection = item
  896. else:
  897. collection = item.collection
  898. headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
  899. try:
  900. status, xml_answer = xmlutils.report(
  901. base_prefix, path, xml_content, collection,
  902. lock_stack.close)
  903. except ValueError as e:
  904. logger.warning(
  905. "Bad REPORT request on %r: %s", path, e, exc_info=True)
  906. return BAD_REQUEST
  907. return (status, headers, self._write_xml_content(xml_answer))
  908. _application = None
  909. _application_config_path = None
  910. _application_lock = threading.Lock()
  911. def _init_application(config_path, wsgi_errors):
  912. global _application, _application_config_path
  913. with _application_lock:
  914. if _application is not None:
  915. return
  916. log.setup()
  917. with log.register_stream(wsgi_errors):
  918. _application_config_path = config_path
  919. configuration = config.load([config_path] if config_path else [],
  920. ignore_missing_paths=False)
  921. log.set_level(configuration.get("logging", "level"))
  922. _application = Application(configuration)
  923. def application(environ, start_response):
  924. config_path = environ.get("RADICALE_CONFIG",
  925. os.environ.get("RADICALE_CONFIG"))
  926. if _application is None:
  927. _init_application(config_path, environ["wsgi.errors"])
  928. if _application_config_path != config_path:
  929. raise ValueError("RADICALE_CONFIG must not change: %s != %s" %
  930. (repr(config_path), repr(_application_config_path)))
  931. return _application(environ, start_response)