httputils.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. # This file is part of Radicale - CalDAV and CardDAV server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2017 Guillaume Ayoub
  5. # Copyright © 2017-2022 Unrud <unrud@outlook.com>
  6. # Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
  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. Helper functions for HTTP.
  22. """
  23. import contextlib
  24. import os
  25. import pathlib
  26. import sys
  27. import time
  28. from http import client
  29. from typing import List, Mapping, Union, cast
  30. from radicale import config, pathutils, types
  31. from radicale.log import logger
  32. if sys.version_info < (3, 9):
  33. import pkg_resources
  34. _TRAVERSABLE_LIKE_TYPE = pathlib.Path
  35. else:
  36. import importlib.abc
  37. from importlib import resources
  38. if sys.version_info < (3, 13):
  39. _TRAVERSABLE_LIKE_TYPE = Union[importlib.abc.Traversable, pathlib.Path]
  40. else:
  41. _TRAVERSABLE_LIKE_TYPE = Union[importlib.resources.abc.Traversable, pathlib.Path]
  42. NOT_ALLOWED: types.WSGIResponse = (
  43. client.FORBIDDEN, (("Content-Type", "text/plain"),),
  44. "Access to the requested resource forbidden.")
  45. FORBIDDEN: types.WSGIResponse = (
  46. client.FORBIDDEN, (("Content-Type", "text/plain"),),
  47. "Action on the requested resource refused.")
  48. BAD_REQUEST: types.WSGIResponse = (
  49. client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request")
  50. NOT_FOUND: types.WSGIResponse = (
  51. client.NOT_FOUND, (("Content-Type", "text/plain"),),
  52. "The requested resource could not be found.")
  53. CONFLICT: types.WSGIResponse = (
  54. client.CONFLICT, (("Content-Type", "text/plain"),),
  55. "Conflict in the request.")
  56. METHOD_NOT_ALLOWED: types.WSGIResponse = (
  57. client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),),
  58. "The method is not allowed on the requested resource.")
  59. PRECONDITION_FAILED: types.WSGIResponse = (
  60. client.PRECONDITION_FAILED,
  61. (("Content-Type", "text/plain"),), "Precondition failed.")
  62. REQUEST_TIMEOUT: types.WSGIResponse = (
  63. client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
  64. "Connection timed out.")
  65. REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = (
  66. client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
  67. "Request body too large.")
  68. REMOTE_DESTINATION: types.WSGIResponse = (
  69. client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
  70. "Remote destination not supported.")
  71. DIRECTORY_LISTING: types.WSGIResponse = (
  72. client.FORBIDDEN, (("Content-Type", "text/plain"),),
  73. "Directory listings are not supported.")
  74. INSUFFICIENT_STORAGE: types.WSGIResponse = (
  75. client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),),
  76. "Insufficient Storage. Please contact the administrator.")
  77. INTERNAL_SERVER_ERROR: types.WSGIResponse = (
  78. client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
  79. "A server error occurred. Please contact the administrator.")
  80. DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
  81. MIMETYPES: Mapping[str, str] = {
  82. ".css": "text/css",
  83. ".eot": "application/vnd.ms-fontobject",
  84. ".gif": "image/gif",
  85. ".html": "text/html",
  86. ".js": "application/javascript",
  87. ".manifest": "text/cache-manifest",
  88. ".png": "image/png",
  89. ".svg": "image/svg+xml",
  90. ".ttf": "application/font-sfnt",
  91. ".txt": "text/plain",
  92. ".woff": "application/font-woff",
  93. ".woff2": "font/woff2",
  94. ".xml": "text/xml"}
  95. FALLBACK_MIMETYPE: str = "application/octet-stream"
  96. def decode_request(configuration: "config.Configuration",
  97. environ: types.WSGIEnviron, text: bytes) -> str:
  98. """Try to magically decode ``text`` according to given ``environ``."""
  99. # List of charsets to try
  100. charsets: List[str] = []
  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(
  105. content_type.split("charset=")[1].split(";")[0].strip())
  106. # Then append default Radicale charset
  107. charsets.append(cast(str, configuration.get("encoding", "request")))
  108. # Then append various fallbacks
  109. charsets.append("utf-8")
  110. charsets.append("iso8859-1")
  111. # Remove duplicates
  112. for i, s in reversed(list(enumerate(charsets))):
  113. if s in charsets[:i]:
  114. del charsets[i]
  115. # Try to decode
  116. for charset in charsets:
  117. with contextlib.suppress(UnicodeDecodeError):
  118. return text.decode(charset)
  119. raise UnicodeDecodeError("decode_request", text, 0, len(text),
  120. "all codecs failed [%s]" % ", ".join(charsets))
  121. def read_raw_request_body(configuration: "config.Configuration",
  122. environ: types.WSGIEnviron) -> bytes:
  123. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  124. if not content_length:
  125. return b""
  126. content = environ["wsgi.input"].read(content_length)
  127. if len(content) < content_length:
  128. raise RuntimeError("Request body too short: %d" % len(content))
  129. return content
  130. def read_request_body(configuration: "config.Configuration",
  131. environ: types.WSGIEnviron) -> str:
  132. content = decode_request(configuration, environ,
  133. read_raw_request_body(configuration, environ))
  134. if configuration.get("logging", "request_content_on_debug"):
  135. logger.debug("Request content:\n%s", content)
  136. else:
  137. logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
  138. return content
  139. def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
  140. return (status,
  141. {"Location": location, "Content-Type": "text/plain"},
  142. "Redirected to %s" % location)
  143. def _serve_traversable(
  144. traversable: _TRAVERSABLE_LIKE_TYPE, base_prefix: str, path: str,
  145. path_prefix: str, index_file: str, mimetypes: Mapping[str, str],
  146. fallback_mimetype: str) -> types.WSGIResponse:
  147. if path != path_prefix and not path.startswith(path_prefix):
  148. raise ValueError("path must start with path_prefix: %r --> %r" %
  149. (path_prefix, path))
  150. assert pathutils.sanitize_path(path) == path
  151. parts_path = path[len(path_prefix):].strip('/')
  152. parts = parts_path.split("/") if parts_path else []
  153. for part in parts:
  154. if not pathutils.is_safe_filesystem_path_component(part):
  155. logger.debug("Web content with unsafe path %r requested", path)
  156. return NOT_FOUND
  157. if (not traversable.is_dir() or
  158. all(part != entry.name for entry in traversable.iterdir())):
  159. return NOT_FOUND
  160. traversable = traversable.joinpath(part)
  161. if traversable.is_dir():
  162. if not path.endswith("/"):
  163. return redirect(base_prefix + path + "/")
  164. if not index_file:
  165. return NOT_FOUND
  166. traversable = traversable.joinpath(index_file)
  167. if not traversable.is_file():
  168. return NOT_FOUND
  169. content_type = MIMETYPES.get(
  170. os.path.splitext(traversable.name)[1].lower(), FALLBACK_MIMETYPE)
  171. headers = {"Content-Type": content_type}
  172. if isinstance(traversable, pathlib.Path):
  173. headers["Last-Modified"] = time.strftime(
  174. "%a, %d %b %Y %H:%M:%S GMT",
  175. time.gmtime(traversable.stat().st_mtime))
  176. answer = traversable.read_bytes()
  177. return client.OK, headers, answer
  178. def serve_resource(
  179. package: str, resource: str, base_prefix: str, path: str,
  180. path_prefix: str = "/.web", index_file: str = "index.html",
  181. mimetypes: Mapping[str, str] = MIMETYPES,
  182. fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse:
  183. if sys.version_info < (3, 9):
  184. traversable = pathlib.Path(
  185. pkg_resources.resource_filename(package, resource))
  186. else:
  187. traversable = resources.files(package).joinpath(resource)
  188. return _serve_traversable(traversable, base_prefix, path, path_prefix,
  189. index_file, mimetypes, fallback_mimetype)
  190. def serve_folder(
  191. folder: str, base_prefix: str, path: str,
  192. path_prefix: str = "/.web", index_file: str = "index.html",
  193. mimetypes: Mapping[str, str] = MIMETYPES,
  194. fallback_mimetype: str = FALLBACK_MIMETYPE) -> types.WSGIResponse:
  195. # deprecated: use `serve_resource` instead
  196. traversable = pathlib.Path(folder)
  197. return _serve_traversable(traversable, base_prefix, path, path_prefix,
  198. index_file, mimetypes, fallback_mimetype)