__init__.py 23 KB

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