__init__.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2016 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 Server module.
  20. This module offers a WSGI application class.
  21. To use this module, you should take a look at the file ``radicale.py`` that
  22. should have been included in this package.
  23. """
  24. import os
  25. import pprint
  26. import base64
  27. import contextlib
  28. import shlex
  29. import socket
  30. import socketserver
  31. import ssl
  32. import threading
  33. import subprocess
  34. import wsgiref.simple_server
  35. import re
  36. import zlib
  37. from http import client
  38. from urllib.parse import unquote, urlparse
  39. import vobject
  40. from . import auth, rights, storage, xmlutils
  41. VERSION = "2.0.0rc0"
  42. # Standard "not allowed" response that is returned when an authenticated user
  43. # tries to access information they don't have rights to
  44. NOT_ALLOWED = (client.FORBIDDEN, {}, None)
  45. WELL_KNOWN_RE = re.compile(r"/\.well-known/(carddav|caldav)/?$")
  46. class HTTPServer(wsgiref.simple_server.WSGIServer):
  47. """HTTP server."""
  48. # These class attributes must be set before creating instance
  49. client_timeout = None
  50. max_connections = None
  51. def __init__(self, address, handler, bind_and_activate=True):
  52. """Create server."""
  53. ipv6 = ":" in address[0]
  54. if ipv6:
  55. self.address_family = socket.AF_INET6
  56. # Do not bind and activate, as we might change socket options
  57. super().__init__(address, handler, False)
  58. if ipv6:
  59. # Only allow IPv6 connections to the IPv6 socket
  60. self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
  61. if bind_and_activate:
  62. self.server_bind()
  63. self.server_activate()
  64. if self.max_connections:
  65. self.connections_guard = threading.BoundedSemaphore(
  66. self.max_connections)
  67. else:
  68. # use dummy context manager
  69. self.connections_guard = contextlib.suppress()
  70. def get_request(self):
  71. # Set timeout for client
  72. _socket, address = super().get_request()
  73. if self.client_timeout:
  74. _socket.settimeout(self.client_timeout)
  75. return _socket, address
  76. class HTTPSServer(HTTPServer):
  77. """HTTPS server."""
  78. # These class attributes must be set before creating instance
  79. certificate = None
  80. key = None
  81. protocol = None
  82. cyphers = None
  83. def __init__(self, address, handler):
  84. """Create server by wrapping HTTP socket in an SSL socket."""
  85. super().__init__(address, handler, bind_and_activate=False)
  86. self.socket = ssl.wrap_socket(
  87. self.socket, self.key, self.certificate, server_side=True,
  88. ssl_version=self.protocol, cyphers=self.cyphers)
  89. self.server_bind()
  90. self.server_activate()
  91. class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
  92. def process_request_thread(self, request, client_address):
  93. with self.connections_guard:
  94. return super().process_request_thread(request, client_address)
  95. class ThreadedHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer):
  96. def process_request_thread(self, request, client_address):
  97. with self.connections_guard:
  98. return super().process_request_thread(request, client_address)
  99. class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
  100. """HTTP requests handler."""
  101. def log_message(self, *args, **kwargs):
  102. """Disable inner logging management."""
  103. class Application:
  104. """WSGI application managing collections."""
  105. def __init__(self, configuration, logger):
  106. """Initialize application."""
  107. super().__init__()
  108. self.configuration = configuration
  109. self.logger = logger
  110. self.is_authenticated = auth.load(configuration, logger)
  111. self.Collection = storage.load(configuration, logger)
  112. self.authorized = rights.load(configuration, logger)
  113. self.encoding = configuration.get("encoding", "request")
  114. def headers_log(self, environ):
  115. """Sanitize headers for logging."""
  116. request_environ = dict(environ)
  117. # Remove environment variables
  118. if not self.configuration.getboolean("logging", "full_environment"):
  119. for shell_variable in os.environ:
  120. request_environ.pop(shell_variable, None)
  121. # Mask credentials
  122. if (self.configuration.getboolean("logging", "mask_passwords") and
  123. request_environ.get("HTTP_AUTHORIZATION",
  124. "").startswith("Basic")):
  125. request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
  126. return request_environ
  127. def decode(self, text, environ):
  128. """Try to magically decode ``text`` according to given ``environ``."""
  129. # List of charsets to try
  130. charsets = []
  131. # First append content charset given in the request
  132. content_type = environ.get("CONTENT_TYPE")
  133. if content_type and "charset=" in content_type:
  134. charsets.append(
  135. content_type.split("charset=")[1].split(";")[0].strip())
  136. # Then append default Radicale charset
  137. charsets.append(self.encoding)
  138. # Then append various fallbacks
  139. charsets.append("utf-8")
  140. charsets.append("iso8859-1")
  141. # Try to decode
  142. for charset in charsets:
  143. try:
  144. return text.decode(charset)
  145. except UnicodeDecodeError:
  146. pass
  147. raise UnicodeDecodeError
  148. def collect_allowed_items(self, items, user):
  149. """Get items from request that user is allowed to access."""
  150. read_last_collection_allowed = None
  151. write_last_collection_allowed = None
  152. read_allowed_items = []
  153. write_allowed_items = []
  154. for item in items:
  155. if isinstance(item, self.Collection):
  156. if self.authorized(user, item, "r"):
  157. self.logger.debug(
  158. "%s has read access to collection %s" %
  159. (user or "Anonymous", item.path or "/"))
  160. read_last_collection_allowed = True
  161. read_allowed_items.append(item)
  162. else:
  163. self.logger.debug(
  164. "%s has NO read access to collection %s" %
  165. (user or "Anonymous", item.path or "/"))
  166. read_last_collection_allowed = False
  167. if self.authorized(user, item, "w"):
  168. self.logger.debug(
  169. "%s has write access to collection %s" %
  170. (user or "Anonymous", item.path or "/"))
  171. write_last_collection_allowed = True
  172. write_allowed_items.append(item)
  173. else:
  174. self.logger.debug(
  175. "%s has NO write access to collection %s" %
  176. (user or "Anonymous", item.path or "/"))
  177. write_last_collection_allowed = False
  178. else:
  179. # item is not a collection, it's the child of the last
  180. # collection we've met in the loop. Only add this item
  181. # if this last collection was allowed.
  182. if read_last_collection_allowed:
  183. self.logger.debug(
  184. "%s has read access to item %s" %
  185. (user or "Anonymous", item.href))
  186. read_allowed_items.append(item)
  187. else:
  188. self.logger.debug(
  189. "%s has NO read access to item %s" %
  190. (user or "Anonymous", item.href))
  191. if write_last_collection_allowed:
  192. self.logger.debug(
  193. "%s has write access to item %s" %
  194. (user or "Anonymous", item.href))
  195. write_allowed_items.append(item)
  196. else:
  197. self.logger.debug(
  198. "%s has NO write access to item %s" %
  199. (user or "Anonymous", item.href))
  200. return read_allowed_items, write_allowed_items
  201. def __call__(self, environ, start_response):
  202. """Manage a request."""
  203. def response(status, headers={}, answer=None):
  204. # Start response
  205. status = "%i %s" % (status,
  206. client.responses.get(status, "Unknown"))
  207. self.logger.debug("Answer status: %s" % status)
  208. start_response(status, list(headers.items()))
  209. # Return response content
  210. return [answer] if answer else []
  211. self.logger.info("%s request at %s received" % (
  212. environ["REQUEST_METHOD"], environ["PATH_INFO"]))
  213. headers = pprint.pformat(self.headers_log(environ))
  214. self.logger.debug("Request headers:\n%s" % headers)
  215. # Strip base_prefix from request URI
  216. base_prefix = self.configuration.get("server", "base_prefix")
  217. if environ["PATH_INFO"].startswith(base_prefix):
  218. environ["PATH_INFO"] = environ["PATH_INFO"][len(base_prefix):]
  219. elif self.configuration.get("server", "can_skip_base_prefix"):
  220. self.logger.debug(
  221. "Prefix already stripped from path: %s", environ["PATH_INFO"])
  222. else:
  223. # Request path not starting with base_prefix, not allowed
  224. self.logger.debug(
  225. "Path not starting with prefix: %s", environ["PATH_INFO"])
  226. return response(*NOT_ALLOWED)
  227. # Sanitize request URI
  228. environ["PATH_INFO"] = storage.sanitize_path(
  229. unquote(environ["PATH_INFO"]))
  230. self.logger.debug("Sanitized path: %s", environ["PATH_INFO"])
  231. path = environ["PATH_INFO"]
  232. # Get function corresponding to method
  233. function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())
  234. # Ask authentication backend to check rights
  235. authorization = environ.get("HTTP_AUTHORIZATION", None)
  236. if authorization and authorization.startswith("Basic"):
  237. authorization = authorization[len("Basic"):].strip()
  238. user, password = self.decode(base64.b64decode(
  239. authorization.encode("ascii")), environ).split(":", 1)
  240. else:
  241. user = environ.get("REMOTE_USER")
  242. password = None
  243. well_known = WELL_KNOWN_RE.match(path)
  244. if well_known:
  245. redirect = self.configuration.get(
  246. "well-known", well_known.group(1))
  247. try:
  248. redirect = redirect % ({"user": user} if user else {})
  249. except KeyError:
  250. status = client.UNAUTHORIZED
  251. realm = self.configuration.get("server", "realm")
  252. headers = {"WWW-Authenticate": "Basic realm=\"%s\"" % realm}
  253. self.logger.info(
  254. "Refused /.well-known/ redirection to anonymous user")
  255. else:
  256. status = client.SEE_OTHER
  257. self.logger.info("/.well-known/ redirection to: %s" % redirect)
  258. headers = {"Location": redirect}
  259. return response(status, headers)
  260. if user and not storage.is_safe_path_component(user):
  261. # Prevent usernames like "user/calendar.ics"
  262. self.logger.info("Refused unsafe username: %s", user)
  263. is_authenticated = False
  264. else:
  265. is_authenticated = self.is_authenticated(user, password)
  266. is_valid_user = is_authenticated or not user
  267. # Get content
  268. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  269. if content_length:
  270. max_content_length = self.configuration.getint(
  271. "server", "max_content_length")
  272. if max_content_length and content_length > max_content_length:
  273. self.logger.debug(
  274. "Request body too large: %d", content_length)
  275. return response(client.REQUEST_ENTITY_TOO_LARGE)
  276. try:
  277. content = self.decode(
  278. environ["wsgi.input"].read(content_length), environ)
  279. except socket.timeout:
  280. return response(client.REQUEST_TIMEOUT)
  281. self.logger.debug("Request content:\n%s" % content)
  282. else:
  283. content = None
  284. if is_valid_user:
  285. if function in (self.do_GET, self.do_HEAD,
  286. self.do_OPTIONS, self.do_PROPFIND,
  287. self.do_REPORT):
  288. lock_mode = "r"
  289. else:
  290. lock_mode = "w"
  291. with self.Collection.acquire_lock(lock_mode):
  292. items = self.Collection.discover(
  293. path, environ.get("HTTP_DEPTH", "0"))
  294. read_allowed_items, write_allowed_items = (
  295. self.collect_allowed_items(items, user))
  296. if (read_allowed_items or write_allowed_items or
  297. is_authenticated and function == self.do_PROPFIND or
  298. function == self.do_OPTIONS):
  299. status, headers, answer = function(
  300. environ, read_allowed_items, write_allowed_items,
  301. content, user)
  302. hook = self.configuration.get("storage", "hook")
  303. if lock_mode == "w" and hook:
  304. self.logger.debug("Running hook")
  305. folder = os.path.expanduser(
  306. self.configuration.get("storage",
  307. "filesystem_folder"))
  308. subprocess.check_call(
  309. hook % {"user": shlex.quote(user or "Anonymous")},
  310. shell=True, cwd=folder)
  311. else:
  312. status, headers, answer = NOT_ALLOWED
  313. else:
  314. status, headers, answer = NOT_ALLOWED
  315. if (status, headers, answer) == NOT_ALLOWED and not is_authenticated:
  316. # Unknown or unauthorized user
  317. self.logger.info("%s refused" % (user or "Anonymous user"))
  318. status = client.UNAUTHORIZED
  319. realm = self.configuration.get("server", "realm")
  320. headers = {
  321. "WWW-Authenticate":
  322. "Basic realm=\"%s\"" % realm}
  323. answer = None
  324. # Set content length
  325. if answer:
  326. self.logger.debug("Response content:\n%s" % answer, environ)
  327. answer = answer.encode(self.encoding)
  328. accept_encoding = [
  329. encoding.strip() for encoding in
  330. environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
  331. if encoding.strip()]
  332. if "gzip" in accept_encoding:
  333. zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
  334. answer = zcomp.compress(answer) + zcomp.flush()
  335. headers["Content-Encoding"] = "gzip"
  336. headers["Content-Length"] = str(len(answer))
  337. if self.configuration.has_section("headers"):
  338. for key in self.configuration.options("headers"):
  339. headers[key] = self.configuration.get("headers", key)
  340. return response(status, headers, answer)
  341. # All these functions must have the same parameters, some are useless
  342. # pylint: disable=W0612,W0613,R0201
  343. def do_DELETE(self, environ, read_collections, write_collections, content,
  344. user):
  345. """Manage DELETE request."""
  346. if not write_collections:
  347. return NOT_ALLOWED
  348. collection = write_collections[0]
  349. if collection.path == environ["PATH_INFO"].strip("/"):
  350. # Path matching the collection, the collection must be deleted
  351. item = collection
  352. else:
  353. # Try to get an item matching the path
  354. name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  355. item = collection.get(name)
  356. if item:
  357. if_match = environ.get("HTTP_IF_MATCH", "*")
  358. if if_match in ("*", item.etag):
  359. # No ETag precondition or precondition verified, delete item
  360. answer = xmlutils.delete(environ["PATH_INFO"], collection)
  361. return client.OK, {}, answer
  362. # No item or ETag precondition not verified, do not delete item
  363. return client.PRECONDITION_FAILED, {}, None
  364. def do_GET(self, environ, read_collections, write_collections, content,
  365. user):
  366. """Manage GET request."""
  367. # Display a "Radicale works!" message if the root URL is requested
  368. if environ["PATH_INFO"] == "/":
  369. headers = {"Content-type": "text/html"}
  370. answer = "<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
  371. return client.OK, headers, answer
  372. if not read_collections:
  373. return NOT_ALLOWED
  374. collection = read_collections[0]
  375. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  376. if item_name:
  377. # Get collection item
  378. item = collection.get(item_name)
  379. if item:
  380. answer = item.serialize()
  381. etag = item.etag
  382. else:
  383. return client.NOT_FOUND, {}, None
  384. else:
  385. # Get whole collection
  386. answer = collection.serialize()
  387. if answer is None:
  388. return client.NOT_FOUND, {}, None
  389. else:
  390. etag = collection.etag
  391. if answer:
  392. headers = {
  393. "Content-Type": storage.MIMETYPES[collection.get_meta("tag")],
  394. "Last-Modified": collection.last_modified,
  395. "ETag": etag}
  396. else:
  397. headers = {}
  398. return client.OK, headers, answer
  399. def do_HEAD(self, environ, read_collections, write_collections, content,
  400. user):
  401. """Manage HEAD request."""
  402. status, headers, answer = self.do_GET(
  403. environ, read_collections, write_collections, content, user)
  404. return status, headers, None
  405. def do_MKCALENDAR(self, environ, read_collections, write_collections,
  406. content, user):
  407. """Manage MKCALENDAR request."""
  408. if not write_collections:
  409. return NOT_ALLOWED
  410. collection = write_collections[0]
  411. props = xmlutils.props_from_request(content)
  412. # TODO: use this?
  413. # timezone = props.get("C:calendar-timezone")
  414. collection = self.Collection.create_collection(
  415. environ["PATH_INFO"], tag="VCALENDAR")
  416. for key, value in props.items():
  417. collection.set_meta(key, value)
  418. return client.CREATED, {}, None
  419. def do_MKCOL(self, environ, read_collections, write_collections, content,
  420. user):
  421. """Manage MKCOL request."""
  422. if not write_collections:
  423. return NOT_ALLOWED
  424. collection = write_collections[0]
  425. props = xmlutils.props_from_request(content)
  426. collection = self.Collection.create_collection(environ["PATH_INFO"])
  427. for key, value in props.items():
  428. collection.set_meta(key, value)
  429. return client.CREATED, {}, None
  430. def do_MOVE(self, environ, read_collections, write_collections, content,
  431. user):
  432. """Manage MOVE request."""
  433. if not write_collections:
  434. return NOT_ALLOWED
  435. from_collection = write_collections[0]
  436. from_name = xmlutils.name_from_path(
  437. environ["PATH_INFO"], from_collection)
  438. item = from_collection.get(from_name)
  439. if item:
  440. # Move the item
  441. to_url_parts = urlparse(environ["HTTP_DESTINATION"])
  442. if to_url_parts.netloc == environ["HTTP_HOST"]:
  443. to_url = to_url_parts.path
  444. to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
  445. for to_collection in self.Collection.discover(
  446. to_path, depth="0"):
  447. if to_collection in write_collections:
  448. to_collection.upload(to_name, item)
  449. from_collection.delete(from_name)
  450. return client.CREATED, {}, None
  451. else:
  452. return NOT_ALLOWED
  453. else:
  454. # Remote destination server, not supported
  455. return client.BAD_GATEWAY, {}, None
  456. else:
  457. # No item found
  458. return client.GONE, {}, None
  459. def do_OPTIONS(self, environ, read_collections, write_collections,
  460. content, user):
  461. """Manage OPTIONS request."""
  462. headers = {
  463. "Allow": ("DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, "
  464. "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT"),
  465. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
  466. return client.OK, headers, None
  467. def do_PROPFIND(self, environ, read_collections, write_collections,
  468. content, user):
  469. """Manage PROPFIND request."""
  470. if not read_collections:
  471. return client.NOT_FOUND, {}, None
  472. headers = {
  473. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  474. "Content-Type": "text/xml"}
  475. answer = xmlutils.propfind(
  476. environ["PATH_INFO"], content, read_collections, write_collections,
  477. user)
  478. return client.MULTI_STATUS, headers, answer
  479. def do_PROPPATCH(self, environ, read_collections, write_collections,
  480. content, user):
  481. """Manage PROPPATCH request."""
  482. if not write_collections:
  483. return NOT_ALLOWED
  484. collection = write_collections[0]
  485. answer = xmlutils.proppatch(environ["PATH_INFO"], content, collection)
  486. headers = {
  487. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  488. "Content-Type": "text/xml"}
  489. return client.MULTI_STATUS, headers, answer
  490. def do_PUT(self, environ, read_collections, write_collections, content,
  491. user):
  492. """Manage PUT request."""
  493. if not write_collections:
  494. return NOT_ALLOWED
  495. collection = write_collections[0]
  496. content_type = environ.get("CONTENT_TYPE")
  497. if content_type:
  498. tags = {value: key for key, value in storage.MIMETYPES.items()}
  499. tag = tags.get(content_type.split(";")[0])
  500. if tag:
  501. collection.set_meta("tag", tag)
  502. headers = {}
  503. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  504. item = collection.get(item_name)
  505. etag = environ.get("HTTP_IF_MATCH", "")
  506. match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
  507. if (not item and not etag) or (
  508. item and ((etag or item.etag) == item.etag) and not match):
  509. # PUT allowed in 3 cases
  510. # Case 1: No item and no ETag precondition: Add new item
  511. # Case 2: Item and ETag precondition verified: Modify item
  512. # Case 3: Item and no Etag precondition: Force modifying item
  513. items = list(vobject.readComponents(content or ""))
  514. if item:
  515. # PUT is modifying an existing item
  516. if items:
  517. new_item = collection.update(item_name, items[0])
  518. else:
  519. new_item = None
  520. elif item_name:
  521. # PUT is adding a new item
  522. if items:
  523. new_item = collection.upload(item_name, items[0])
  524. else:
  525. new_item = None
  526. else:
  527. # PUT is replacing the whole collection
  528. collection.delete()
  529. new_item = self.Collection.create_collection(
  530. environ["PATH_INFO"], items)
  531. if new_item:
  532. headers["ETag"] = new_item.etag
  533. status = client.CREATED
  534. else:
  535. # PUT rejected in all other cases
  536. status = client.PRECONDITION_FAILED
  537. return status, headers, None
  538. def do_REPORT(self, environ, read_collections, write_collections, content,
  539. user):
  540. """Manage REPORT request."""
  541. if not read_collections:
  542. return NOT_ALLOWED
  543. collection = read_collections[0]
  544. headers = {"Content-Type": "text/xml"}
  545. answer = xmlutils.report(environ["PATH_INFO"], content, collection)
  546. return client.MULTI_STATUS, headers, answer
  547. # pylint: enable=W0612,W0613,R0201