__init__.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2016 Guillaume Ayoub
  5. #
  6. # This library is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. Radicale Server module.
  20. This module offers a WSGI application class.
  21. To use this module, you should take a look at the file ``radicale.py`` that
  22. should have been included in this package.
  23. """
  24. import os
  25. import pprint
  26. import base64
  27. import socket
  28. import socketserver
  29. import ssl
  30. import wsgiref.simple_server
  31. import re
  32. from http import client
  33. from urllib.parse import unquote, urlparse
  34. import vobject
  35. from . import auth, rights, storage, xmlutils
  36. VERSION = "2.0.0-pre"
  37. # Standard "not allowed" response that is returned when an authenticated user
  38. # tries to access information they don't have rights to
  39. NOT_ALLOWED = (client.FORBIDDEN, {}, None)
  40. WELL_KNOWN_RE = re.compile(r"/\.well-known/(carddav|caldav)/?$")
  41. class HTTPServer(wsgiref.simple_server.WSGIServer):
  42. """HTTP server."""
  43. def __init__(self, address, handler, bind_and_activate=True):
  44. """Create server."""
  45. ipv6 = ":" in address[0]
  46. if ipv6:
  47. self.address_family = socket.AF_INET6
  48. # Do not bind and activate, as we might change socket options
  49. super().__init__(address, handler, False)
  50. if ipv6:
  51. # Only allow IPv6 connections to the IPv6 socket
  52. self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
  53. if bind_and_activate:
  54. self.server_bind()
  55. self.server_activate()
  56. class HTTPSServer(HTTPServer):
  57. """HTTPS server."""
  58. # These class attributes must be set before creating instance
  59. certificate = None
  60. key = None
  61. protocol = None
  62. cyphers = None
  63. def __init__(self, address, handler):
  64. """Create server by wrapping HTTP socket in an SSL socket."""
  65. super().__init__(address, handler, bind_and_activate=False)
  66. self.socket = ssl.wrap_socket(
  67. self.socket, self.key, self.certificate, server_side=True,
  68. ssl_version=self.protocol, cyphers=self.cyphers)
  69. self.server_bind()
  70. self.server_activate()
  71. class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
  72. pass
  73. class ThreadedHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer):
  74. pass
  75. class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
  76. """HTTP requests handler."""
  77. def log_message(self, *args, **kwargs):
  78. """Disable inner logging management."""
  79. class Application:
  80. """WSGI application managing collections."""
  81. def __init__(self, configuration, logger):
  82. """Initialize application."""
  83. super().__init__()
  84. self.configuration = configuration
  85. self.logger = logger
  86. self.is_authenticated = auth.load(configuration, logger)
  87. self.Collection = storage.load(configuration, logger)
  88. self.authorized = rights.load(configuration, logger)
  89. self.encoding = configuration.get("encoding", "request")
  90. if configuration.getboolean("logging", "full_environment"):
  91. self.headers_log = lambda environ: environ
  92. # This method is overriden in __init__ if full_environment is set
  93. # pylint: disable=E0202
  94. @staticmethod
  95. def headers_log(environ):
  96. """Remove environment variables from the headers for logging."""
  97. request_environ = dict(environ)
  98. for shell_variable in os.environ:
  99. if shell_variable in request_environ:
  100. del request_environ[shell_variable]
  101. return request_environ
  102. # pylint: enable=E0202
  103. def decode(self, text, environ):
  104. """Try to magically decode ``text`` according to given ``environ``."""
  105. # List of charsets to try
  106. charsets = []
  107. # First append content charset given in the request
  108. content_type = environ.get("CONTENT_TYPE")
  109. if content_type and "charset=" in content_type:
  110. charsets.append(
  111. content_type.split("charset=")[1].split(";")[0].strip())
  112. # Then append default Radicale charset
  113. charsets.append(self.encoding)
  114. # Then append various fallbacks
  115. charsets.append("utf-8")
  116. charsets.append("iso8859-1")
  117. # Try to decode
  118. for charset in charsets:
  119. try:
  120. return text.decode(charset)
  121. except UnicodeDecodeError:
  122. pass
  123. raise UnicodeDecodeError
  124. def collect_allowed_items(self, items, user):
  125. """Get items from request that user is allowed to access."""
  126. read_last_collection_allowed = None
  127. write_last_collection_allowed = None
  128. read_allowed_items = []
  129. write_allowed_items = []
  130. for item in items:
  131. if isinstance(item, self.Collection):
  132. if self.authorized(user, item, "r"):
  133. self.logger.debug(
  134. "%s has read access to collection %s" %
  135. (user or "Anonymous", item.path or "/"))
  136. read_last_collection_allowed = True
  137. read_allowed_items.append(item)
  138. else:
  139. self.logger.debug(
  140. "%s has NO read access to collection %s" %
  141. (user or "Anonymous", item.path or "/"))
  142. read_last_collection_allowed = False
  143. if self.authorized(user, item, "w"):
  144. self.logger.debug(
  145. "%s has write access to collection %s" %
  146. (user or "Anonymous", item.path or "/"))
  147. write_last_collection_allowed = True
  148. write_allowed_items.append(item)
  149. else:
  150. self.logger.debug(
  151. "%s has NO write access to collection %s" %
  152. (user or "Anonymous", item.path or "/"))
  153. write_last_collection_allowed = False
  154. else:
  155. # item is not a collection, it's the child of the last
  156. # collection we've met in the loop. Only add this item
  157. # if this last collection was allowed.
  158. if read_last_collection_allowed:
  159. self.logger.debug(
  160. "%s has read access to item %s" %
  161. (user or "Anonymous", item.href))
  162. read_allowed_items.append(item)
  163. else:
  164. self.logger.debug(
  165. "%s has NO read access to item %s" %
  166. (user or "Anonymous", item.href))
  167. if write_last_collection_allowed:
  168. self.logger.debug(
  169. "%s has write access to item %s" %
  170. (user or "Anonymous", item.href))
  171. write_allowed_items.append(item)
  172. else:
  173. self.logger.debug(
  174. "%s has NO write access to item %s" %
  175. (user or "Anonymous", item.href))
  176. return read_allowed_items, write_allowed_items
  177. def __call__(self, environ, start_response):
  178. """Manage a request."""
  179. self.logger.info("%s request at %s received" % (
  180. environ["REQUEST_METHOD"], environ["PATH_INFO"]))
  181. headers = pprint.pformat(self.headers_log(environ))
  182. self.logger.debug("Request headers:\n%s" % headers)
  183. # Strip base_prefix from request URI
  184. base_prefix = self.configuration.get("server", "base_prefix")
  185. if environ["PATH_INFO"].startswith(base_prefix):
  186. environ["PATH_INFO"] = environ["PATH_INFO"][len(base_prefix):]
  187. elif self.configuration.get("server", "can_skip_base_prefix"):
  188. self.logger.debug(
  189. "Prefix already stripped from path: %s", environ["PATH_INFO"])
  190. else:
  191. # Request path not starting with base_prefix, not allowed
  192. self.logger.debug(
  193. "Path not starting with prefix: %s", environ["PATH_INFO"])
  194. status, headers, _ = NOT_ALLOWED
  195. start_response(status, list(headers.items()))
  196. return []
  197. # Sanitize request URI
  198. environ["PATH_INFO"] = storage.sanitize_path(
  199. unquote(environ["PATH_INFO"]))
  200. self.logger.debug("Sanitized path: %s", environ["PATH_INFO"])
  201. path = environ["PATH_INFO"]
  202. # Get function corresponding to method
  203. function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())
  204. # Ask authentication backend to check rights
  205. authorization = environ.get("HTTP_AUTHORIZATION", None)
  206. if authorization:
  207. authorization = authorization.lstrip("Basic").strip()
  208. user, password = self.decode(base64.b64decode(
  209. authorization.encode("ascii")), environ).split(":", 1)
  210. else:
  211. user = environ.get("REMOTE_USER")
  212. password = None
  213. well_known = WELL_KNOWN_RE.match(path)
  214. if well_known:
  215. redirect = self.configuration.get(
  216. "well-known", well_known.group(1))
  217. try:
  218. redirect = redirect % ({"user": user} if user else {})
  219. except KeyError:
  220. status = client.UNAUTHORIZED
  221. realm = self.configuration.get("server", "realm")
  222. headers = {"WWW-Authenticate": "Basic realm=\"%s\"" % realm}
  223. self.logger.info(
  224. "Refused /.well-known/ redirection to anonymous user")
  225. else:
  226. status = client.SEE_OTHER
  227. self.logger.info("/.well-known/ redirection to: %s" % redirect)
  228. headers = {"Location": redirect}
  229. status = "%i %s" % (
  230. status, client.responses.get(status, "Unknown"))
  231. start_response(status, list(headers.items()))
  232. return []
  233. is_authenticated = self.is_authenticated(user, password)
  234. is_valid_user = is_authenticated or not user
  235. lock = None
  236. try:
  237. if is_valid_user:
  238. if function in (self.do_GET, self.do_HEAD,
  239. self.do_OPTIONS, self.do_PROPFIND,
  240. self.do_REPORT):
  241. lock_mode = "r"
  242. else:
  243. lock_mode = "w"
  244. lock = self.Collection.acquire_lock(lock_mode)
  245. items = self.Collection.discover(
  246. path, environ.get("HTTP_DEPTH", "0"))
  247. read_allowed_items, write_allowed_items = (
  248. self.collect_allowed_items(items, user))
  249. else:
  250. read_allowed_items, write_allowed_items = None, None
  251. # Get content
  252. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  253. if content_length:
  254. content = self.decode(
  255. environ["wsgi.input"].read(content_length), environ)
  256. self.logger.debug("Request content:\n%s" % content)
  257. else:
  258. content = None
  259. if is_valid_user and (
  260. (read_allowed_items or write_allowed_items) or
  261. (is_authenticated and function == self.do_PROPFIND) or
  262. function == self.do_OPTIONS):
  263. status, headers, answer = function(
  264. environ, read_allowed_items, write_allowed_items, content,
  265. user)
  266. else:
  267. status, headers, answer = NOT_ALLOWED
  268. finally:
  269. if lock:
  270. lock.release()
  271. if (status, headers, answer) == NOT_ALLOWED and not is_authenticated:
  272. # Unknown or unauthorized user
  273. self.logger.info("%s refused" % (user or "Anonymous user"))
  274. status = client.UNAUTHORIZED
  275. realm = self.configuration.get("server", "realm")
  276. headers = {
  277. "WWW-Authenticate":
  278. "Basic realm=\"%s\"" % realm}
  279. answer = None
  280. # Set content length
  281. if answer:
  282. self.logger.debug("Response content:\n%s" % answer, environ)
  283. answer = answer.encode(self.encoding)
  284. headers["Content-Length"] = str(len(answer))
  285. if self.configuration.has_section("headers"):
  286. for key in self.configuration.options("headers"):
  287. headers[key] = self.configuration.get("headers", key)
  288. # Start response
  289. status = "%i %s" % (status, client.responses.get(status, "Unknown"))
  290. self.logger.debug("Answer status: %s" % status)
  291. start_response(status, list(headers.items()))
  292. # Return response content
  293. return [answer] if answer else []
  294. # All these functions must have the same parameters, some are useless
  295. # pylint: disable=W0612,W0613,R0201
  296. def do_DELETE(self, environ, read_collections, write_collections, content,
  297. user):
  298. """Manage DELETE request."""
  299. if not write_collections:
  300. return NOT_ALLOWED
  301. collection = write_collections[0]
  302. if collection.path == environ["PATH_INFO"].strip("/"):
  303. # Path matching the collection, the collection must be deleted
  304. item = collection
  305. else:
  306. # Try to get an item matching the path
  307. name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  308. item = collection.get(name)
  309. if item:
  310. if_match = environ.get("HTTP_IF_MATCH", "*")
  311. if if_match in ("*", item.etag):
  312. # No ETag precondition or precondition verified, delete item
  313. answer = xmlutils.delete(environ["PATH_INFO"], collection)
  314. return client.OK, {}, answer
  315. # No item or ETag precondition not verified, do not delete item
  316. return client.PRECONDITION_FAILED, {}, None
  317. def do_GET(self, environ, read_collections, write_collections, content,
  318. user):
  319. """Manage GET request."""
  320. # Display a "Radicale works!" message if the root URL is requested
  321. if environ["PATH_INFO"] == "/":
  322. headers = {"Content-type": "text/html"}
  323. answer = "<!DOCTYPE html>\n<title>Radicale</title>Radicale works!"
  324. return client.OK, headers, answer
  325. if not read_collections:
  326. return NOT_ALLOWED
  327. collection = read_collections[0]
  328. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  329. if item_name:
  330. # Get collection item
  331. item = collection.get(item_name)
  332. if item:
  333. answer = item.serialize()
  334. etag = item.etag
  335. else:
  336. return client.NOT_FOUND, {}, None
  337. else:
  338. # Get whole collection
  339. answer = collection.serialize()
  340. etag = collection.etag
  341. if answer:
  342. headers = {
  343. "Content-Type": storage.MIMETYPES[collection.get_meta("tag")],
  344. "Last-Modified": collection.last_modified,
  345. "ETag": etag}
  346. else:
  347. headers = {}
  348. return client.OK, headers, answer
  349. def do_HEAD(self, environ, read_collections, write_collections, content,
  350. user):
  351. """Manage HEAD request."""
  352. status, headers, answer = self.do_GET(
  353. environ, read_collections, write_collections, content, user)
  354. return status, headers, None
  355. def do_MKCALENDAR(self, environ, read_collections, write_collections,
  356. content, user):
  357. """Manage MKCALENDAR request."""
  358. if not write_collections:
  359. return NOT_ALLOWED
  360. collection = write_collections[0]
  361. props = xmlutils.props_from_request(content)
  362. # TODO: use this?
  363. # timezone = props.get("C:calendar-timezone")
  364. collection = self.Collection.create_collection(
  365. environ["PATH_INFO"], tag="VCALENDAR")
  366. for key, value in props.items():
  367. collection.set_meta(key, value)
  368. return client.CREATED, {}, None
  369. def do_MKCOL(self, environ, read_collections, write_collections, content,
  370. user):
  371. """Manage MKCOL request."""
  372. if not write_collections:
  373. return NOT_ALLOWED
  374. collection = write_collections[0]
  375. props = xmlutils.props_from_request(content)
  376. collection = self.Collection.create_collection(environ["PATH_INFO"])
  377. for key, value in props.items():
  378. collection.set_meta(key, value)
  379. return client.CREATED, {}, None
  380. def do_MOVE(self, environ, read_collections, write_collections, content,
  381. user):
  382. """Manage MOVE request."""
  383. if not write_collections:
  384. return NOT_ALLOWED
  385. from_collection = write_collections[0]
  386. from_name = xmlutils.name_from_path(
  387. environ["PATH_INFO"], from_collection)
  388. item = from_collection.get(from_name)
  389. if item:
  390. # Move the item
  391. to_url_parts = urlparse(environ["HTTP_DESTINATION"])
  392. if to_url_parts.netloc == environ["HTTP_HOST"]:
  393. to_url = to_url_parts.path
  394. to_path, to_name = to_url.rstrip("/").rsplit("/", 1)
  395. for to_collection in self.Collection.discover(
  396. to_path, depth="0"):
  397. if to_collection in write_collections:
  398. to_collection.upload(to_name, item)
  399. from_collection.delete(from_name)
  400. return client.CREATED, {}, None
  401. else:
  402. return NOT_ALLOWED
  403. else:
  404. # Remote destination server, not supported
  405. return client.BAD_GATEWAY, {}, None
  406. else:
  407. # No item found
  408. return client.GONE, {}, None
  409. def do_OPTIONS(self, environ, read_collections, write_collections,
  410. content, user):
  411. """Manage OPTIONS request."""
  412. headers = {
  413. "Allow": ("DELETE, HEAD, GET, MKCALENDAR, MKCOL, MOVE, "
  414. "OPTIONS, PROPFIND, PROPPATCH, PUT, REPORT"),
  415. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol"}
  416. return client.OK, headers, None
  417. def do_PROPFIND(self, environ, read_collections, write_collections,
  418. content, user):
  419. """Manage PROPFIND request."""
  420. if not read_collections:
  421. return client.NOT_FOUND, {}, None
  422. headers = {
  423. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  424. "Content-Type": "text/xml"}
  425. answer = xmlutils.propfind(
  426. environ["PATH_INFO"], content, read_collections, write_collections,
  427. user)
  428. return client.MULTI_STATUS, headers, answer
  429. def do_PROPPATCH(self, environ, read_collections, write_collections,
  430. content, user):
  431. """Manage PROPPATCH request."""
  432. if not write_collections:
  433. return NOT_ALLOWED
  434. collection = write_collections[0]
  435. answer = xmlutils.proppatch(environ["PATH_INFO"], content, collection)
  436. headers = {
  437. "DAV": "1, 2, 3, calendar-access, addressbook, extended-mkcol",
  438. "Content-Type": "text/xml"}
  439. return client.MULTI_STATUS, headers, answer
  440. def do_PUT(self, environ, read_collections, write_collections, content,
  441. user):
  442. """Manage PUT request."""
  443. if not write_collections:
  444. return NOT_ALLOWED
  445. collection = write_collections[0]
  446. content_type = environ.get("CONTENT_TYPE")
  447. if content_type:
  448. tags = {value: key for key, value in storage.MIMETYPES.items()}
  449. collection.set_meta("tag", tags[content_type.split(";")[0]])
  450. headers = {}
  451. item_name = xmlutils.name_from_path(environ["PATH_INFO"], collection)
  452. item = collection.get(item_name)
  453. etag = environ.get("HTTP_IF_MATCH", "")
  454. match = environ.get("HTTP_IF_NONE_MATCH", "") == "*"
  455. if (not item and not etag) or (
  456. item and ((etag or item.etag) == item.etag) and not match):
  457. # PUT allowed in 3 cases
  458. # Case 1: No item and no ETag precondition: Add new item
  459. # Case 2: Item and ETag precondition verified: Modify item
  460. # Case 3: Item and no Etag precondition: Force modifying item
  461. items = list(vobject.readComponents(content))
  462. if items:
  463. if item:
  464. # PUT is modifying an existing item
  465. new_item = collection.update(item_name, items[0])
  466. elif item_name:
  467. # PUT is adding a new item
  468. new_item = collection.upload(item_name, items[0])
  469. else:
  470. # PUT is replacing the whole collection
  471. collection.delete()
  472. new_item = self.Collection.create_collection(
  473. environ["PATH_INFO"], items)
  474. if new_item:
  475. headers["ETag"] = new_item.etag
  476. status = client.CREATED
  477. else:
  478. # PUT rejected in all other cases
  479. status = client.PRECONDITION_FAILED
  480. return status, headers, None
  481. def do_REPORT(self, environ, read_collections, write_collections, content,
  482. user):
  483. """Manage REPORT request."""
  484. if not read_collections:
  485. return NOT_ALLOWED
  486. collection = read_collections[0]
  487. headers = {"Content-Type": "text/xml"}
  488. answer = xmlutils.report(environ["PATH_INFO"], content, collection)
  489. return client.MULTI_STATUS, headers, answer
  490. # pylint: enable=W0612,W0613,R0201