__init__.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. # -*- coding: utf-8 -*-
  2. #
  3. # This file is part of Radicale Server - Calendar Server
  4. # Copyright © 2008-2012 Guillaume Ayoub
  5. # Copyright © 2008 Nicolas Kandel
  6. # Copyright © 2008 Pascal Halter
  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 quote, unquote, urlparse
  38. except ImportError:
  39. import httplib as client
  40. from urllib import quote, unquote
  41. from urlparse import urlparse
  42. # pylint: enable=F0401,E0611
  43. from radicale import acl, config, ical, log, storage, xmlutils
  44. VERSION = "git"
  45. class HTTPServer(wsgiref.simple_server.WSGIServer, object):
  46. """HTTP server."""
  47. def __init__(self, address, handler, bind_and_activate=True):
  48. """Create server."""
  49. ipv6 = ":" in address[0]
  50. if ipv6:
  51. self.address_family = socket.AF_INET6
  52. # Do not bind and activate, as we might change socket options
  53. super(HTTPServer, self).__init__(address, handler, False)
  54. if ipv6:
  55. # Only allow IPv6 connections to the IPv6 socket
  56. self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
  57. if bind_and_activate:
  58. self.server_bind()
  59. self.server_activate()
  60. class HTTPSServer(HTTPServer):
  61. """HTTPS server."""
  62. def __init__(self, address, handler):
  63. """Create server by wrapping HTTP socket in an SSL socket."""
  64. super(HTTPSServer, self).__init__(address, handler, False)
  65. # Test if the SSL files can be read
  66. for name in ("certificate", "key"):
  67. filename = config.get("server", name)
  68. try:
  69. open(filename, "r").close()
  70. except IOError as exception:
  71. log.LOGGER.warn(
  72. "Error while reading SSL %s %r: %s" % (
  73. name, filename, exception))
  74. self.socket = ssl.wrap_socket(
  75. self.socket,
  76. server_side=True,
  77. certfile=config.get("server", "certificate"),
  78. keyfile=config.get("server", "key"),
  79. ssl_version=ssl.PROTOCOL_SSLv23)
  80. self.server_bind()
  81. self.server_activate()
  82. class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
  83. """HTTP requests handler."""
  84. def log_message(self, *args, **kwargs):
  85. """Disable inner logging management."""
  86. class Application(object):
  87. """WSGI application managing collections."""
  88. def __init__(self):
  89. """Initialize application."""
  90. super(Application, self).__init__()
  91. self.acl = acl.load()
  92. storage.load()
  93. self.encoding = config.get("encoding", "request")
  94. if config.getboolean('logging', 'full_environment'):
  95. self.headers_log = lambda environ: environ
  96. # This method is overriden in __init__ if full_environment is set
  97. # pylint: disable=E0202
  98. @staticmethod
  99. def headers_log(environ):
  100. """Remove environment variables from the headers for logging."""
  101. request_environ = dict(environ)
  102. for shell_variable in os.environ:
  103. if shell_variable in request_environ:
  104. del request_environ[shell_variable]
  105. return request_environ
  106. # pylint: enable=E0202
  107. def decode(self, text, environ):
  108. """Try to magically decode ``text`` according to given ``environ``."""
  109. # List of charsets to try
  110. charsets = []
  111. # First append content charset given in the request
  112. content_type = environ.get("CONTENT_TYPE")
  113. if content_type and "charset=" in content_type:
  114. charsets.append(content_type.split("charset=")[1].strip())
  115. # Then append default Radicale charset
  116. charsets.append(self.encoding)
  117. # Then append various fallbacks
  118. charsets.append("utf-8")
  119. charsets.append("iso8859-1")
  120. # Try to decode
  121. for charset in charsets:
  122. try:
  123. return text.decode(charset)
  124. except UnicodeDecodeError:
  125. pass
  126. raise UnicodeDecodeError
  127. @staticmethod
  128. def sanitize_uri(uri):
  129. """Unquote and remove /../ to prevent access to other data."""
  130. uri = unquote(uri)
  131. trailing_slash = "/" if uri.endswith("/") else ""
  132. uri = posixpath.normpath(uri)
  133. trailing_slash = "" if uri == "/" else trailing_slash
  134. return uri + trailing_slash
  135. def __call__(self, environ, start_response):
  136. """Manage a request."""
  137. log.LOGGER.info("%s request at %s received" % (
  138. environ["REQUEST_METHOD"], environ["PATH_INFO"]))
  139. headers = pprint.pformat(self.headers_log(environ))
  140. log.LOGGER.debug("Request headers:\n%s" % headers)
  141. # Sanitize request URI
  142. environ["PATH_INFO"] = self.sanitize_uri(environ["PATH_INFO"])
  143. log.LOGGER.debug("Sanitized path: %s", environ["PATH_INFO"])
  144. # Get content
  145. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  146. if content_length:
  147. content = self.decode(
  148. environ["wsgi.input"].read(content_length), environ)
  149. log.LOGGER.debug("Request content:\n%s" % content)
  150. else:
  151. content = None
  152. # Find collection(s)
  153. items = ical.Collection.from_path(
  154. environ["PATH_INFO"], environ.get("HTTP_DEPTH", "0"))
  155. # Get function corresponding to method
  156. function = getattr(self, environ["REQUEST_METHOD"].lower())
  157. # Check rights
  158. if not items or not self.acl:
  159. # No collection or no acl, don't check rights
  160. status, headers, answer = function(environ, items, content, None)
  161. else:
  162. # Ask authentication backend to check rights
  163. authorization = environ.get("HTTP_AUTHORIZATION", None)
  164. if authorization:
  165. auth = authorization.lstrip("Basic").strip().encode("ascii")
  166. user, password = self.decode(
  167. base64.b64decode(auth), environ).split(":")
  168. else:
  169. user = password = None
  170. last_allowed = None
  171. collections = []
  172. for collection in items:
  173. if not isinstance(collection, ical.Collection):
  174. if last_allowed:
  175. collections.append(collection)
  176. continue
  177. if collection.owner in acl.PUBLIC_USERS:
  178. log.LOGGER.info("Public collection")
  179. collections.append(collection)
  180. last_allowed = True
  181. else:
  182. log.LOGGER.info(
  183. "Checking rights for collection owned by %s" % (
  184. collection.owner or "nobody"))
  185. if self.acl.has_right(collection.owner, user, password):
  186. log.LOGGER.info(
  187. "%s allowed" % (user or "Anonymous user"))
  188. collections.append(collection)
  189. last_allowed = True
  190. else:
  191. log.LOGGER.info(
  192. "%s refused" % (user or "Anonymous user"))
  193. last_allowed = False
  194. if collections:
  195. # Collections found
  196. status, headers, answer = function(
  197. environ, collections, content, user)
  198. elif user and last_allowed is None:
  199. # Good user and no collections found, redirect user to home
  200. location = "/%s/" % str(quote(user))
  201. log.LOGGER.info("redirecting to %s" % location)
  202. status = client.FOUND
  203. headers = {"Location": location}
  204. answer = "Redirecting to %s" % location
  205. else:
  206. # Unknown or unauthorized user
  207. status = client.UNAUTHORIZED
  208. headers = {
  209. "WWW-Authenticate":
  210. "Basic realm=\"Radicale Server - Password Required\""}
  211. answer = None
  212. # Set content length
  213. if answer:
  214. log.LOGGER.debug(
  215. "Response content:\n%s" % self.decode(answer, environ))
  216. headers["Content-Length"] = str(len(answer))
  217. # Start response
  218. status = "%i %s" % (status, client.responses.get(status, "Unknown"))
  219. log.LOGGER.debug("Answer status: %s" % status)
  220. start_response(status, list(headers.items()))
  221. # Return response content
  222. return [answer] if answer else []
  223. # All these functions must have the same parameters, some are useless
  224. # pylint: disable=W0612,W0613,R0201
  225. def delete(self, environ, collections, content, user):
  226. """Manage DELETE request."""
  227. collection = collections[0]
  228. if collection.path == environ["PATH_INFO"].strip("/"):
  229. # Path matching the collection, the collection must be deleted
  230. item = collection
  231. else:
  232. # Try to get an item matching the path
  233. item = collection.get_item(
  234. xmlutils.name_from_path(environ["PATH_INFO"], collection))
  235. # Evolution bug workaround
  236. etag = environ.get("HTTP_IF_MATCH", item.etag).replace("\\", "")
  237. if item and etag == item.etag:
  238. # No ETag precondition or precondition verified, delete item
  239. answer = xmlutils.delete(environ["PATH_INFO"], collection)
  240. status = client.NO_CONTENT
  241. else:
  242. # No item or ETag precondition not verified, do not delete item
  243. answer = None
  244. status = client.PRECONDITION_FAILED
  245. return status, {}, answer
  246. def get(self, environ, collections, content, user):
  247. """Manage GET request."""
  248. # Display a "Radicale works!" message if the root URL is requested
  249. if environ["PATH_INFO"] == "/":
  250. headers = {"Content-type": "text/html"}
  251. answer = "<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
  252. return client.OK, headers, answer
  253. collection = collections[0]
  254. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  255. if item_name:
  256. # Get collection item
  257. item = collection.get_item(item_name)
  258. if item:
  259. items = collection.timezones
  260. items.append(item)
  261. answer_text = ical.serialize(
  262. collection.tag, collection.headers, items)
  263. etag = item.etag
  264. else:
  265. return client.GONE, {}, None
  266. else:
  267. # Get whole collection
  268. answer_text = collection.text
  269. etag = collection.etag
  270. headers = {
  271. "Content-Type": collection.mimetype,
  272. "Last-Modified": collection.last_modified,
  273. "ETag": etag}
  274. answer = answer_text.encode(self.encoding)
  275. return client.OK, headers, answer
  276. def head(self, environ, collections, content, user):
  277. """Manage HEAD request."""
  278. status, headers, answer = self.get(environ, collections, content, user)
  279. return status, headers, None
  280. def mkcalendar(self, environ, collections, content, user):
  281. """Manage MKCALENDAR request."""
  282. collection = collections[0]
  283. props = xmlutils.props_from_request(content)
  284. timezone = props.get('C:calendar-timezone')
  285. if timezone:
  286. collection.replace('', timezone)
  287. del props['C:calendar-timezone']
  288. with collection.props as collection_props:
  289. for key, value in props.items():
  290. collection_props[key] = value
  291. collection.write()
  292. return client.CREATED, {}, None
  293. def mkcol(self, environ, collections, content, user):
  294. """Manage MKCOL request."""
  295. collection = collections[0]
  296. props = xmlutils.props_from_request(content)
  297. with collection.props as collection_props:
  298. for key, value in props.items():
  299. collection_props[key] = value
  300. collection.write()
  301. return client.CREATED, {}, None
  302. def move(self, environ, collections, content, user):
  303. """Manage MOVE request."""
  304. from_collection = collections[0]
  305. from_name = xmlutils.name_from_path(
  306. environ["PATH_INFO"], from_collection)
  307. if from_name:
  308. item = from_collection.get_item(from_name)
  309. if item:
  310. # Move the item
  311. to_url_parts = urlparse(environ["HTTP_DESTINATION"])
  312. if to_url_parts.netloc == environ["HTTP_HOST"]:
  313. to_url = to_url_parts.path
  314. to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
  315. to_collection = ical.Collection.from_path(
  316. to_path, depth="0")[0]
  317. to_collection.append(to_name, item.text)
  318. from_collection.remove(from_name)
  319. return client.CREATED, {}, None
  320. else:
  321. # Remote destination server, not supported
  322. return client.BAD_GATEWAY, {}, None
  323. else:
  324. # No item found
  325. return client.GONE, {}, None
  326. else:
  327. # Moving collections, not supported
  328. return client.FORBIDDEN, {}, None
  329. def options(self, environ, collections, content, user):
  330. """Manage OPTIONS request."""
  331. headers = {
  332. "Allow": "DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, " \
  333. "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT",
  334. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
  335. return client.OK, headers, None
  336. def propfind(self, environ, collections, content, user):
  337. """Manage PROPFIND request."""
  338. headers = {
  339. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  340. "Content-Type": "text/xml"}
  341. answer = xmlutils.propfind(
  342. environ["PATH_INFO"], content, collections, user)
  343. return client.MULTI_STATUS, headers, answer
  344. def proppatch(self, environ, collections, content, user):
  345. """Manage PROPPATCH request."""
  346. collection = collections[0]
  347. answer = xmlutils.proppatch(environ["PATH_INFO"], content, collection)
  348. headers = {
  349. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  350. "Content-Type": "text/xml"}
  351. return client.MULTI_STATUS, headers, answer
  352. def put(self, environ, collections, content, user):
  353. """Manage PUT request."""
  354. collection = collections[0]
  355. collection.set_mimetype(environ.get("CONTENT_TYPE"))
  356. headers = {}
  357. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  358. item = collection.get_item(item_name)
  359. # Evolution bug workaround
  360. etag = environ.get("HTTP_IF_MATCH", "").replace("\\", "")
  361. if (not item and not etag) or (
  362. item and ((etag or item.etag) == item.etag)):
  363. # PUT allowed in 3 cases
  364. # Case 1: No item and no ETag precondition: Add new item
  365. # Case 2: Item and ETag precondition verified: Modify item
  366. # Case 3: Item and no Etag precondition: Force modifying item
  367. xmlutils.put(environ["PATH_INFO"], content, collection)
  368. status = client.CREATED
  369. headers["ETag"] = collection.get_item(item_name).etag
  370. else:
  371. # PUT rejected in all other cases
  372. status = client.PRECONDITION_FAILED
  373. return status, headers, None
  374. def report(self, environ, collections, content, user):
  375. """Manage REPORT request."""
  376. collection = collections[0]
  377. headers = {'Content-Type': 'text/xml'}
  378. answer = xmlutils.report(environ["PATH_INFO"], content, collection)
  379. return client.MULTI_STATUS, headers, answer
  380. # pylint: enable=W0612,W0613,R0201