__init__.py 23 KB

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