__init__.py 15 KB

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