__init__.py 20 KB

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