__init__.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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 access, 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. def address_string(self):
  87. """Client address, formatted for logging."""
  88. if config.getboolean("server", "dns_lookup"):
  89. return \
  90. wsgiref.simple_server.WSGIRequestHandler.address_string(self)
  91. else:
  92. return self.client_address[0]
  93. class Application(object):
  94. """WSGI application managing collections."""
  95. def __init__(self):
  96. """Initialize application."""
  97. super(Application, self).__init__()
  98. access.load()
  99. storage.load()
  100. self.encoding = config.get("encoding", "request")
  101. if config.getboolean("logging", "full_environment"):
  102. self.headers_log = lambda environ: environ
  103. # This method is overriden in __init__ if full_environment is set
  104. # pylint: disable=E0202
  105. @staticmethod
  106. def headers_log(environ):
  107. """Remove environment variables from the headers for logging."""
  108. request_environ = dict(environ)
  109. for shell_variable in os.environ:
  110. if shell_variable in request_environ:
  111. del request_environ[shell_variable]
  112. return request_environ
  113. # pylint: enable=E0202
  114. def decode(self, text, environ):
  115. """Try to magically decode ``text`` according to given ``environ``."""
  116. # List of charsets to try
  117. charsets = []
  118. # First append content charset given in the request
  119. content_type = environ.get("CONTENT_TYPE")
  120. if content_type and "charset=" in content_type:
  121. charsets.append(content_type.split("charset=")[1].strip())
  122. # Then append default Radicale charset
  123. charsets.append(self.encoding)
  124. # Then append various fallbacks
  125. charsets.append("utf-8")
  126. charsets.append("iso8859-1")
  127. # Try to decode
  128. for charset in charsets:
  129. try:
  130. return text.decode(charset)
  131. except UnicodeDecodeError:
  132. pass
  133. raise UnicodeDecodeError
  134. @staticmethod
  135. def sanitize_uri(uri):
  136. """Unquote and remove /../ to prevent access to other data."""
  137. uri = unquote(uri)
  138. trailing_slash = "/" if uri.endswith("/") else ""
  139. uri = posixpath.normpath(uri)
  140. trailing_slash = "" if uri == "/" else trailing_slash
  141. return uri + trailing_slash
  142. def __call__(self, environ, start_response):
  143. """Manage a request."""
  144. log.LOGGER.info("%s request at %s received" % (
  145. environ["REQUEST_METHOD"], environ["PATH_INFO"]))
  146. headers = pprint.pformat(self.headers_log(environ))
  147. log.LOGGER.debug("Request headers:\n%s" % headers)
  148. # Sanitize request URI
  149. environ["PATH_INFO"] = self.sanitize_uri(environ["PATH_INFO"])
  150. log.LOGGER.debug("Sanitized path: %s", environ["PATH_INFO"])
  151. # Get content
  152. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  153. if content_length:
  154. content = self.decode(
  155. environ["wsgi.input"].read(content_length), environ)
  156. log.LOGGER.debug("Request content:\n%s" % content)
  157. else:
  158. content = None
  159. path = environ["PATH_INFO"]
  160. # Find collection(s)
  161. items = ical.Collection.from_path(
  162. path, environ.get("HTTP_DEPTH", "0"))
  163. # Get function corresponding to method
  164. function = getattr(self, environ["REQUEST_METHOD"].lower())
  165. # Check rights
  166. if items and function != self.options:
  167. # Ask authentication backend to check rights
  168. authorization = environ.get("HTTP_AUTHORIZATION", None)
  169. if authorization:
  170. auth = authorization.lstrip("Basic").strip().encode("ascii")
  171. user, password = self.decode(
  172. base64.b64decode(auth), environ).split(":")
  173. else:
  174. user = password = None
  175. if access.is_authenticated(user, password):
  176. last_collection_allowed = None
  177. allowed_items = []
  178. for item in items:
  179. if isinstance(item, ical.Collection):
  180. if access.read_authorized(user, item) or \
  181. access.write_authorized(user, item):
  182. log.LOGGER.info("%s has access to collection %s" % (
  183. user, item.name or "/"))
  184. last_collection_allowed = True
  185. allowed_items.append(item)
  186. else:
  187. log.LOGGER.info("%s has NO access to collection %s" % (
  188. user, item.name or "/"))
  189. last_collection_allowed = False
  190. else:
  191. # item is not a colleciton, it's the child of the last
  192. # collection we've met in the loop. Only add this item
  193. # if this last collection was allowed.
  194. if last_collection_allowed:
  195. log.LOGGER.info("%s has access to item %s" % (
  196. user, item.name or "/"))
  197. allowed_items.append(item)
  198. else:
  199. log.LOGGER.info("%s has NO access to item %s" % (
  200. user, item.name or "/"))
  201. if allowed_items:
  202. # Collections found
  203. status, headers, answer = function(
  204. environ, allowed_items, content, user)
  205. else:
  206. # Good user and no collections found, redirect user to home
  207. location = "/%s/" % str(quote(user))
  208. if path == location:
  209. # Send answer anyway since else we're getting into a
  210. # redirect loop
  211. status, headers, answer = function(
  212. environ, allowed_items, content, user)
  213. else:
  214. log.LOGGER.info("redirecting to %s" % location)
  215. status = client.FOUND
  216. headers = {"Location": location}
  217. answer = "Redirecting to %s" % location
  218. else:
  219. # Unknown or unauthorized user
  220. log.LOGGER.info(
  221. "%s refused" % (user or "Anonymous user"))
  222. status = client.UNAUTHORIZED
  223. headers = {
  224. "WWW-Authenticate":
  225. "Basic realm=\"Radicale Server - Password Required\""}
  226. answer = None
  227. # Set content length
  228. if answer:
  229. log.LOGGER.debug(
  230. "Response content:\n%s" % self.decode(answer, environ))
  231. headers["Content-Length"] = str(len(answer))
  232. # Start response
  233. status = "%i %s" % (status, client.responses.get(status, "Unknown"))
  234. log.LOGGER.debug("Answer status: %s" % status)
  235. start_response(status, list(headers.items()))
  236. # Return response content
  237. return [answer] if answer else []
  238. def response_not_allowed(self):
  239. """Return a standard "not allowed" response."""
  240. headers = {
  241. "WWW-Authenticate":
  242. "Basic realm=\"Radicale Server - Password Required\""}
  243. return client.FORBIDDEN, headers, None
  244. # All these functions must have the same parameters, some are useless
  245. # pylint: disable=W0612,W0613,R0201
  246. def delete(self, environ, collections, content, user):
  247. """Manage DELETE request."""
  248. collection = collections[0]
  249. if collection.path == environ["PATH_INFO"].strip("/"):
  250. # Path matching the collection, the collection must be deleted
  251. item = collection
  252. else:
  253. # Try to get an item matching the path
  254. item = collection.get_item(
  255. xmlutils.name_from_path(environ["PATH_INFO"], collection))
  256. if item:
  257. # Evolution bug workaround
  258. etag = environ.get("HTTP_IF_MATCH", item.etag).replace("\\", "")
  259. if etag == item.etag:
  260. # No ETag precondition or precondition verified, delete item
  261. if access.write_authorized(user, collection):
  262. answer = xmlutils.delete(environ["PATH_INFO"], collection)
  263. return client.OK, {}, answer
  264. else:
  265. return self.response_not_allowed()
  266. # No item or ETag precondition not verified, do not delete item
  267. return client.PRECONDITION_FAILED, {}, None
  268. def get(self, environ, collections, content, user):
  269. """Manage GET request.
  270. In Radicale, GET requests create collections when the URL is not
  271. available. This is useful for clients with no MKCOL or MKCALENDAR
  272. support.
  273. """
  274. # Display a "Radicale works!" message if the root URL is requested
  275. if environ["PATH_INFO"] == "/":
  276. headers = {"Content-type": "text/html"}
  277. answer = b"<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
  278. return client.OK, headers, answer
  279. collection = collections[0]
  280. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  281. if item_name:
  282. # Get collection item
  283. item = collection.get_item(item_name)
  284. if item:
  285. if access.read_authorized(user, collection):
  286. items = collection.timezones
  287. items.append(item)
  288. answer_text = ical.serialize(
  289. collection.tag, collection.headers, items)
  290. etag = item.etag
  291. else:
  292. return self.response_not_allowed()
  293. else:
  294. return client.GONE, {}, None
  295. else:
  296. # Create the collection if it does not exist
  297. if not collection.exists and \
  298. access.write_authorized(user, collection):
  299. log.LOGGER.debug("Creating collection %s" % collection.name)
  300. collection.write()
  301. if access.read_authorized(user, collection):
  302. # Get whole collection
  303. answer_text = collection.text
  304. etag = collection.etag
  305. else:
  306. return self.response_not_allowed()
  307. headers = {
  308. "Content-Type": collection.mimetype,
  309. "Last-Modified": collection.last_modified,
  310. "ETag": etag}
  311. answer = answer_text.encode(self.encoding)
  312. return client.OK, headers, answer
  313. def head(self, environ, collections, content, user):
  314. """Manage HEAD request."""
  315. status, headers, answer = self.get(environ, collections, content, user)
  316. return status, headers, None
  317. def mkcalendar(self, environ, collections, content, user):
  318. """Manage MKCALENDAR request."""
  319. collection = collections[0]
  320. props = xmlutils.props_from_request(content)
  321. timezone = props.get("C:calendar-timezone")
  322. if timezone:
  323. collection.replace("", timezone)
  324. del props["C:calendar-timezone"]
  325. with collection.props as collection_props:
  326. for key, value in props.items():
  327. collection_props[key] = value
  328. if access.write_authorized(user, collection):
  329. collection.write()
  330. else:
  331. return self.response_not_allowed()
  332. return client.CREATED, {}, None
  333. def mkcol(self, environ, collections, content, user):
  334. """Manage MKCOL request."""
  335. collection = collections[0]
  336. props = xmlutils.props_from_request(content)
  337. with collection.props as collection_props:
  338. for key, value in props.items():
  339. collection_props[key] = value
  340. if access.write_authorized(user, collection):
  341. collection.write()
  342. else:
  343. return self.response_not_allowed()
  344. return client.CREATED, {}, None
  345. def move(self, environ, collections, content, user):
  346. """Manage MOVE request."""
  347. from_collection = collections[0]
  348. from_name = xmlutils.name_from_path(
  349. environ["PATH_INFO"], from_collection)
  350. if from_name:
  351. item = from_collection.get_item(from_name)
  352. if item:
  353. # Move the item
  354. to_url_parts = urlparse(environ["HTTP_DESTINATION"])
  355. if to_url_parts.netloc == environ["HTTP_HOST"]:
  356. to_url = to_url_parts.path
  357. to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
  358. to_collection = ical.Collection.from_path(
  359. to_path, depth="0")[0]
  360. if access.write_authorized(user, to_collection) and \
  361. access.write_authorized(user.from_collection):
  362. to_collection.append(to_name, item.text)
  363. from_collection.remove(from_name)
  364. return client.CREATED, {}, None
  365. else:
  366. return self.response_not_allowed()
  367. else:
  368. # Remote destination server, not supported
  369. return client.BAD_GATEWAY, {}, None
  370. else:
  371. # No item found
  372. return client.GONE, {}, None
  373. else:
  374. # Moving collections, not supported
  375. return client.FORBIDDEN, {}, None
  376. def options(self, environ, collections, content, user):
  377. """Manage OPTIONS request."""
  378. headers = {
  379. "Allow": ("DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, "
  380. "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT"),
  381. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
  382. return client.OK, headers, None
  383. def propfind(self, environ, collections, content, user):
  384. """Manage PROPFIND request."""
  385. headers = {
  386. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  387. "Content-Type": "text/xml"}
  388. answer = xmlutils.propfind(
  389. environ["PATH_INFO"], content, collections, user)
  390. return client.MULTI_STATUS, headers, answer
  391. def proppatch(self, environ, collections, content, user):
  392. """Manage PROPPATCH request."""
  393. collection = collections[0]
  394. answer = xmlutils.proppatch(environ["PATH_INFO"], content, collection)
  395. headers = {
  396. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  397. "Content-Type": "text/xml"}
  398. return client.MULTI_STATUS, headers, answer
  399. def put(self, environ, collections, content, user):
  400. """Manage PUT request."""
  401. collection = collections[0]
  402. collection.set_mimetype(environ.get("CONTENT_TYPE"))
  403. headers = {}
  404. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  405. item = collection.get_item(item_name)
  406. # Evolution bug workaround
  407. etag = environ.get("HTTP_IF_MATCH", "").replace("\\", "")
  408. if (not item and not etag) or (
  409. item and ((etag or item.etag) == item.etag)):
  410. # PUT allowed in 3 cases
  411. # Case 1: No item and no ETag precondition: Add new item
  412. # Case 2: Item and ETag precondition verified: Modify item
  413. # Case 3: Item and no Etag precondition: Force modifying item
  414. if access.write_authorized(user, collection):
  415. xmlutils.put(environ["PATH_INFO"], content, collection)
  416. status = client.CREATED
  417. # Try to return the etag in the header.
  418. # If the added item does't have the same name as the one given
  419. # by the client, then there's no obvious way to generate an
  420. # etag, we can safely ignore it.
  421. new_item = collection.get_item(item_name)
  422. if new_item:
  423. headers["ETag"] = new_item.etag
  424. else:
  425. return self.response_not_allowed()
  426. else:
  427. # PUT rejected in all other cases
  428. status = client.PRECONDITION_FAILED
  429. return status, headers, None
  430. def report(self, environ, collections, content, user):
  431. """Manage REPORT request."""
  432. collection = collections[0]
  433. headers = {"Content-Type": "text/xml"}
  434. if access.read_authorized(user, collection):
  435. answer = xmlutils.report(environ["PATH_INFO"], content, collection)
  436. return client.MULTI_STATUS, headers, answer
  437. else:
  438. return self.response_not_allowed()
  439. # pylint: enable=W0612,W0613,R0201