__init__.py 22 KB

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