__init__.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  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 socket
  28. import ssl
  29. import wsgiref.simple_server
  30. import re
  31. from http import client
  32. from urllib.parse import unquote, urlparse
  33. from . import auth, config, ical, log, pathutils, rights, storage, xmlutils
  34. VERSION = "2.0.0-pre"
  35. # Standard "not allowed" response that is returned when an authenticated user
  36. # tries to access information they don't have rights to
  37. NOT_ALLOWED = (client.FORBIDDEN, {}, None)
  38. WELL_KNOWN_RE = re.compile(r"/\.well-known/(carddav|caldav)/?$")
  39. class HTTPServer(wsgiref.simple_server.WSGIServer, object):
  40. """HTTP server."""
  41. def __init__(self, address, handler, bind_and_activate=True):
  42. """Create server."""
  43. ipv6 = ":" in address[0]
  44. if ipv6:
  45. self.address_family = socket.AF_INET6
  46. # Do not bind and activate, as we might change socket options
  47. super().__init__(address, handler, False)
  48. if ipv6:
  49. # Only allow IPv6 connections to the IPv6 socket
  50. self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
  51. if bind_and_activate:
  52. self.server_bind()
  53. self.server_activate()
  54. class HTTPSServer(HTTPServer):
  55. """HTTPS server."""
  56. def __init__(self, address, handler):
  57. """Create server by wrapping HTTP socket in an SSL socket."""
  58. super().__init__(address, handler, False)
  59. # Test if the SSL files can be read
  60. for name in ("certificate", "key"):
  61. filename = config.get("server", name)
  62. try:
  63. open(filename, "r").close()
  64. except IOError as exception:
  65. log.LOGGER.warning(
  66. "Error while reading SSL %s %r: %s" % (
  67. name, filename, exception))
  68. ssl_kwargs = dict(
  69. server_side=True,
  70. certfile=config.get("server", "certificate"),
  71. keyfile=config.get("server", "key"),
  72. ssl_version=getattr(
  73. ssl, config.get("server", "protocol"), ssl.PROTOCOL_SSLv23))
  74. ssl_kwargs["ciphers"] = config.get("server", "ciphers") or None
  75. self.socket = ssl.wrap_socket(self.socket, **ssl_kwargs)
  76. self.server_bind()
  77. self.server_activate()
  78. class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
  79. """HTTP requests handler."""
  80. def log_message(self, *args, **kwargs):
  81. """Disable inner logging management."""
  82. def address_string(self):
  83. """Client address, formatted for logging."""
  84. if config.getboolean("server", "dns_lookup"):
  85. return (
  86. wsgiref.simple_server.WSGIRequestHandler.address_string(self))
  87. else:
  88. return self.client_address[0]
  89. class Application(object):
  90. """WSGI application managing collections."""
  91. def __init__(self):
  92. """Initialize application."""
  93. super().__init__()
  94. auth.load()
  95. storage.load()
  96. rights.load()
  97. self.encoding = config.get("encoding", "request")
  98. if config.getboolean("logging", "full_environment"):
  99. self.headers_log = lambda environ: environ
  100. # This method is overriden in __init__ if full_environment is set
  101. # pylint: disable=E0202
  102. @staticmethod
  103. def headers_log(environ):
  104. """Remove environment variables from the headers for logging."""
  105. request_environ = dict(environ)
  106. for shell_variable in os.environ:
  107. if shell_variable in request_environ:
  108. del request_environ[shell_variable]
  109. return request_environ
  110. # pylint: enable=E0202
  111. def decode(self, text, environ):
  112. """Try to magically decode ``text`` according to given ``environ``."""
  113. # List of charsets to try
  114. charsets = []
  115. # First append content charset given in the request
  116. content_type = environ.get("CONTENT_TYPE")
  117. if content_type and "charset=" in content_type:
  118. charsets.append(
  119. content_type.split("charset=")[1].split(";")[0].strip())
  120. # Then append default Radicale charset
  121. charsets.append(self.encoding)
  122. # Then append various fallbacks
  123. charsets.append("utf-8")
  124. charsets.append("iso8859-1")
  125. # Try to decode
  126. for charset in charsets:
  127. try:
  128. return text.decode(charset)
  129. except UnicodeDecodeError:
  130. pass
  131. raise UnicodeDecodeError
  132. @staticmethod
  133. def sanitize_uri(uri):
  134. """Unquote and make absolute to prevent access to other data."""
  135. uri = unquote(uri)
  136. return pathutils.sanitize_path(uri)
  137. def collect_allowed_items(self, items, user):
  138. """Get items from request that user is allowed to access."""
  139. read_last_collection_allowed = None
  140. write_last_collection_allowed = None
  141. read_allowed_items = []
  142. write_allowed_items = []
  143. for item in items:
  144. if isinstance(item, ical.Collection):
  145. if rights.authorized(user, item, "r"):
  146. log.LOGGER.debug(
  147. "%s has read access to collection %s" %
  148. (user or "Anonymous", item.url or "/"))
  149. read_last_collection_allowed = True
  150. read_allowed_items.append(item)
  151. else:
  152. log.LOGGER.debug(
  153. "%s has NO read access to collection %s" %
  154. (user or "Anonymous", item.url or "/"))
  155. read_last_collection_allowed = False
  156. if rights.authorized(user, item, "w"):
  157. log.LOGGER.debug(
  158. "%s has write access to collection %s" %
  159. (user or "Anonymous", item.url or "/"))
  160. write_last_collection_allowed = True
  161. write_allowed_items.append(item)
  162. else:
  163. log.LOGGER.debug(
  164. "%s has NO write access to collection %s" %
  165. (user or "Anonymous", item.url or "/"))
  166. write_last_collection_allowed = False
  167. else:
  168. # item is not a collection, it's the child of the last
  169. # collection we've met in the loop. Only add this item
  170. # if this last collection was allowed.
  171. if read_last_collection_allowed:
  172. log.LOGGER.debug(
  173. "%s has read access to item %s" %
  174. (user or "Anonymous", item.name))
  175. read_allowed_items.append(item)
  176. else:
  177. log.LOGGER.debug(
  178. "%s has NO read access to item %s" %
  179. (user or "Anonymous", item.name))
  180. if write_last_collection_allowed:
  181. log.LOGGER.debug(
  182. "%s has write access to item %s" %
  183. (user or "Anonymous", item.name))
  184. write_allowed_items.append(item)
  185. else:
  186. log.LOGGER.debug(
  187. "%s has NO write access to item %s" %
  188. (user or "Anonymous", item.name))
  189. return read_allowed_items, write_allowed_items
  190. def __call__(self, environ, start_response):
  191. """Manage a request."""
  192. log.LOGGER.info("%s request at %s received" % (
  193. environ["REQUEST_METHOD"], environ["PATH_INFO"]))
  194. headers = pprint.pformat(self.headers_log(environ))
  195. log.LOGGER.debug("Request headers:\n%s" % headers)
  196. # Strip base_prefix from request URI
  197. base_prefix = config.get("server", "base_prefix")
  198. if environ["PATH_INFO"].startswith(base_prefix):
  199. environ["PATH_INFO"] = environ["PATH_INFO"][len(base_prefix):]
  200. elif config.get("server", "can_skip_base_prefix"):
  201. log.LOGGER.debug(
  202. "Prefix already stripped from path: %s", environ["PATH_INFO"])
  203. else:
  204. # Request path not starting with base_prefix, not allowed
  205. log.LOGGER.debug(
  206. "Path not starting with prefix: %s", environ["PATH_INFO"])
  207. status, headers, _ = NOT_ALLOWED
  208. start_response(status, list(headers.items()))
  209. return []
  210. # Sanitize request URI
  211. environ["PATH_INFO"] = self.sanitize_uri(environ["PATH_INFO"])
  212. log.LOGGER.debug("Sanitized path: %s", environ["PATH_INFO"])
  213. path = environ["PATH_INFO"]
  214. # Get function corresponding to method
  215. function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())
  216. # Ask authentication backend to check rights
  217. authorization = environ.get("HTTP_AUTHORIZATION", None)
  218. if authorization:
  219. authorization = authorization.lstrip("Basic").strip()
  220. user, password = self.decode(base64.b64decode(
  221. authorization.encode("ascii")), environ).split(":", 1)
  222. else:
  223. user = environ.get("REMOTE_USER")
  224. password = None
  225. well_known = WELL_KNOWN_RE.match(path)
  226. if well_known:
  227. redirect = config.get("well-known", well_known.group(1))
  228. try:
  229. redirect = redirect % ({"user": user} if user else {})
  230. except KeyError:
  231. status = client.UNAUTHORIZED
  232. headers = {
  233. "WWW-Authenticate":
  234. "Basic realm=\"%s\"" % config.get("server", "realm")}
  235. log.LOGGER.info(
  236. "Refused /.well-known/ redirection to anonymous user")
  237. else:
  238. status = client.SEE_OTHER
  239. log.LOGGER.info("/.well-known/ redirection to: %s" % redirect)
  240. headers = {"Location": redirect}
  241. status = "%i %s" % (
  242. status, client.responses.get(status, "Unknown"))
  243. start_response(status, list(headers.items()))
  244. return []
  245. is_authenticated = auth.is_authenticated(user, password)
  246. is_valid_user = is_authenticated or not user
  247. if is_valid_user:
  248. items = ical.Collection.from_path(
  249. path, environ.get("HTTP_DEPTH", "0"))
  250. read_allowed_items, write_allowed_items = \
  251. self.collect_allowed_items(items, user)
  252. else:
  253. read_allowed_items, write_allowed_items = None, None
  254. # Get content
  255. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  256. if content_length:
  257. content = self.decode(
  258. environ["wsgi.input"].read(content_length), environ)
  259. log.LOGGER.debug("Request content:\n%s" % content)
  260. else:
  261. content = None
  262. if is_valid_user and (
  263. (read_allowed_items or write_allowed_items) or
  264. (is_authenticated and function == self.do_PROPFIND) or
  265. function == self.do_OPTIONS):
  266. status, headers, answer = function(
  267. environ, read_allowed_items, write_allowed_items, content,
  268. user)
  269. else:
  270. status, headers, answer = NOT_ALLOWED
  271. if ((status, headers, answer) == NOT_ALLOWED and
  272. not auth.is_authenticated(user, password) and
  273. config.get("auth", "type") != "None"):
  274. # Unknown or unauthorized user
  275. log.LOGGER.info("%s refused" % (user or "Anonymous user"))
  276. status = client.UNAUTHORIZED
  277. headers = {
  278. "WWW-Authenticate":
  279. "Basic realm=\"%s\"" % config.get("server", "realm")}
  280. answer = None
  281. # Set content length
  282. if answer:
  283. log.LOGGER.debug(
  284. "Response content:\n%s" % self.decode(answer, environ))
  285. headers["Content-Length"] = str(len(answer))
  286. if config.has_section("headers"):
  287. for key in config.options("headers"):
  288. headers[key] = config.get("headers", key)
  289. # Start response
  290. status = "%i %s" % (status, client.responses.get(status, "Unknown"))
  291. log.LOGGER.debug("Answer status: %s" % status)
  292. start_response(status, list(headers.items()))
  293. # Return response content
  294. return [answer] if answer else []
  295. # All these functions must have the same parameters, some are useless
  296. # pylint: disable=W0612,W0613,R0201
  297. def do_DELETE(self, environ, read_collections, write_collections, content,
  298. user):
  299. """Manage DELETE request."""
  300. if not len(write_collections):
  301. return NOT_ALLOWED
  302. collection = write_collections[0]
  303. if collection.path == environ["PATH_INFO"].strip("/"):
  304. # Path matching the collection, the collection must be deleted
  305. item = collection
  306. else:
  307. # Try to get an item matching the path
  308. name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  309. item = collection.items.get(name)
  310. if item:
  311. # Evolution bug workaround
  312. if_match = environ.get("HTTP_IF_MATCH", "*").replace("\\", "")
  313. if if_match in ("*", item.etag):
  314. # No ETag precondition or precondition verified, delete item
  315. answer = xmlutils.delete(environ["PATH_INFO"], collection)
  316. return client.OK, {}, answer
  317. # No item or ETag precondition not verified, do not delete item
  318. return client.PRECONDITION_FAILED, {}, None
  319. def do_GET(self, environ, read_collections, write_collections, content,
  320. user):
  321. """Manage GET request.
  322. In Radicale, GET requests create collections when the URL is not
  323. available. This is useful for clients with no MKCOL or MKCALENDAR
  324. support.
  325. """
  326. # Display a "Radicale works!" message if the root URL is requested
  327. if environ["PATH_INFO"] == "/":
  328. headers = {"Content-type": "text/html"}
  329. answer = b"<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
  330. return client.OK, headers, answer
  331. if not len(read_collections):
  332. return NOT_ALLOWED
  333. collection = read_collections[0]
  334. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  335. if item_name:
  336. # Get collection item
  337. item = collection.items.get(item_name)
  338. if item:
  339. items = [item]
  340. if collection.resource_type == "calendar":
  341. items.extend(collection.timezones)
  342. answer_text = ical.serialize(
  343. collection.tag, collection.headers, items)
  344. etag = item.etag
  345. else:
  346. return client.NOT_FOUND, {}, None
  347. else:
  348. # Create the collection if it does not exist
  349. if not collection.exists:
  350. if collection in write_collections:
  351. log.LOGGER.debug(
  352. "Creating collection %s" % collection.name)
  353. collection.write()
  354. else:
  355. log.LOGGER.debug(
  356. "Collection %s not available and could not be created "
  357. "due to missing write rights" % collection.name)
  358. return NOT_ALLOWED
  359. # Get whole collection
  360. answer_text = collection.text
  361. etag = collection.etag
  362. headers = {
  363. "Content-Type": collection.mimetype,
  364. "Last-Modified": collection.last_modified,
  365. "ETag": etag}
  366. answer = answer_text.encode(self.encoding)
  367. return client.OK, headers, answer
  368. def do_HEAD(self, environ, read_collections, write_collections, content,
  369. user):
  370. """Manage HEAD request."""
  371. status, headers, answer = self.do_GET(
  372. environ, read_collections, write_collections, content, user)
  373. return status, headers, None
  374. def do_MKCALENDAR(self, environ, read_collections, write_collections,
  375. content, user):
  376. """Manage MKCALENDAR request."""
  377. if not len(write_collections):
  378. return NOT_ALLOWED
  379. collection = write_collections[0]
  380. props = xmlutils.props_from_request(content)
  381. timezone = props.get("C:calendar-timezone")
  382. if timezone:
  383. collection.replace("", timezone)
  384. del props["C:calendar-timezone"]
  385. with collection.props as collection_props:
  386. for key, value in props.items():
  387. collection_props[key] = value
  388. collection.write()
  389. return client.CREATED, {}, None
  390. def do_MKCOL(self, environ, read_collections, write_collections, content,
  391. user):
  392. """Manage MKCOL request."""
  393. if not len(write_collections):
  394. return NOT_ALLOWED
  395. collection = write_collections[0]
  396. props = xmlutils.props_from_request(content)
  397. with collection.props as collection_props:
  398. for key, value in props.items():
  399. collection_props[key] = value
  400. collection.write()
  401. return client.CREATED, {}, None
  402. def do_MOVE(self, environ, read_collections, write_collections, content,
  403. user):
  404. """Manage MOVE request."""
  405. if not len(write_collections):
  406. return NOT_ALLOWED
  407. from_collection = write_collections[0]
  408. from_name = xmlutils.name_from_path(
  409. environ["PATH_INFO"], from_collection)
  410. if from_name:
  411. item = from_collection.items.get(from_name)
  412. if item:
  413. # Move the item
  414. to_url_parts = urlparse(environ["HTTP_DESTINATION"])
  415. if to_url_parts.netloc == environ["HTTP_HOST"]:
  416. to_url = to_url_parts.path
  417. to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
  418. to_collection = ical.Collection.from_path(
  419. to_path, depth="0")[0]
  420. if to_collection in write_collections:
  421. to_collection.append(to_name, item.text)
  422. from_collection.remove(from_name)
  423. return client.CREATED, {}, None
  424. else:
  425. return NOT_ALLOWED
  426. else:
  427. # Remote destination server, not supported
  428. return client.BAD_GATEWAY, {}, None
  429. else:
  430. # No item found
  431. return client.GONE, {}, None
  432. else:
  433. # Moving collections, not supported
  434. return client.FORBIDDEN, {}, None
  435. def do_OPTIONS(self, environ, read_collections, write_collections,
  436. content, user):
  437. """Manage OPTIONS request."""
  438. headers = {
  439. "Allow": ("DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, "
  440. "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT"),
  441. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
  442. return client.OK, headers, None
  443. def do_PROPFIND(self, environ, read_collections, write_collections,
  444. content, user):
  445. """Manage PROPFIND request."""
  446. # Rights is handled by collection in xmlutils.propfind
  447. headers = {
  448. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  449. "Content-Type": "text/xml"}
  450. collections = set(read_collections + write_collections)
  451. answer = xmlutils.propfind(
  452. environ["PATH_INFO"], content, collections, user)
  453. return client.MULTI_STATUS, headers, answer
  454. def do_PROPPATCH(self, environ, read_collections, write_collections,
  455. content, user):
  456. """Manage PROPPATCH request."""
  457. if not len(write_collections):
  458. return NOT_ALLOWED
  459. collection = write_collections[0]
  460. answer = xmlutils.proppatch(
  461. environ["PATH_INFO"], content, collection)
  462. headers = {
  463. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  464. "Content-Type": "text/xml"}
  465. return client.MULTI_STATUS, headers, answer
  466. def do_PUT(self, environ, read_collections, write_collections, content,
  467. user):
  468. """Manage PUT request."""
  469. if not len(write_collections):
  470. return NOT_ALLOWED
  471. collection = write_collections[0]
  472. collection.set_mimetype(environ.get("CONTENT_TYPE"))
  473. headers = {}
  474. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  475. item = collection.items.get(item_name)
  476. # Evolution bug workaround
  477. etag = environ.get("HTTP_IF_MATCH", "").replace("\\", "")
  478. match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
  479. if (not item and not etag) or (
  480. item and ((etag or item.etag) == item.etag) and not match):
  481. # PUT allowed in 3 cases
  482. # Case 1: No item and no ETag precondition: Add new item
  483. # Case 2: Item and ETag precondition verified: Modify item
  484. # Case 3: Item and no Etag precondition: Force modifying item
  485. xmlutils.put(environ["PATH_INFO"], content, collection)
  486. status = client.CREATED
  487. # Try to return the etag in the header.
  488. # If the added item doesn't have the same name as the one given
  489. # by the client, then there's no obvious way to generate an
  490. # etag, we can safely ignore it.
  491. new_item = collection.items.get(item_name)
  492. if new_item:
  493. headers["ETag"] = new_item.etag
  494. else:
  495. # PUT rejected in all other cases
  496. status = client.PRECONDITION_FAILED
  497. return status, headers, None
  498. def do_REPORT(self, environ, read_collections, write_collections, content,
  499. user):
  500. """Manage REPORT request."""
  501. if not len(read_collections):
  502. return NOT_ALLOWED
  503. collection = read_collections[0]
  504. headers = {"Content-Type": "text/xml"}
  505. answer = xmlutils.report(environ["PATH_INFO"], content, collection)
  506. return client.MULTI_STATUS, headers, answer
  507. # pylint: enable=W0612,W0613,R0201