__init__.py 23 KB

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