staticfiles.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import importlib.util
  2. import os
  3. import stat
  4. import typing
  5. from email.utils import parsedate
  6. import anyio
  7. import anyio.to_thread
  8. from starlette._utils import get_route_path
  9. from starlette.datastructures import URL, Headers
  10. from starlette.exceptions import HTTPException
  11. from starlette.responses import FileResponse, RedirectResponse, Response
  12. from starlette.types import Receive, Scope, Send
  13. PathLike = typing.Union[str, "os.PathLike[str]"]
  14. class NotModifiedResponse(Response):
  15. NOT_MODIFIED_HEADERS = (
  16. "cache-control",
  17. "content-location",
  18. "date",
  19. "etag",
  20. "expires",
  21. "vary",
  22. )
  23. def __init__(self, headers: Headers):
  24. super().__init__(
  25. status_code=304,
  26. headers={
  27. name: value
  28. for name, value in headers.items()
  29. if name in self.NOT_MODIFIED_HEADERS
  30. },
  31. )
  32. class StaticFiles:
  33. def __init__(
  34. self,
  35. *,
  36. directory: typing.Optional[PathLike] = None,
  37. packages: typing.Optional[
  38. typing.List[typing.Union[str, typing.Tuple[str, str]]]
  39. ] = None,
  40. html: bool = False,
  41. check_dir: bool = True,
  42. follow_symlink: bool = False,
  43. ) -> None:
  44. self.directory = directory
  45. self.packages = packages
  46. self.all_directories = self.get_directories(directory, packages)
  47. self.html = html
  48. self.config_checked = False
  49. self.follow_symlink = follow_symlink
  50. if check_dir and directory is not None and not os.path.isdir(directory):
  51. raise RuntimeError(f"Directory '{directory}' does not exist")
  52. def get_directories(
  53. self,
  54. directory: typing.Optional[PathLike] = None,
  55. packages: typing.Optional[
  56. typing.List[typing.Union[str, typing.Tuple[str, str]]]
  57. ] = None,
  58. ) -> typing.List[PathLike]:
  59. """
  60. Given `directory` and `packages` arguments, return a list of all the
  61. directories that should be used for serving static files from.
  62. """
  63. directories = []
  64. if directory is not None:
  65. directories.append(directory)
  66. for package in packages or []:
  67. if isinstance(package, tuple):
  68. package, statics_dir = package
  69. else:
  70. statics_dir = "statics"
  71. spec = importlib.util.find_spec(package)
  72. assert spec is not None, f"Package {package!r} could not be found."
  73. assert spec.origin is not None, f"Package {package!r} could not be found."
  74. package_directory = os.path.normpath(
  75. os.path.join(spec.origin, "..", statics_dir)
  76. )
  77. assert os.path.isdir(
  78. package_directory
  79. ), f"Directory '{statics_dir!r}' in package {package!r} could not be found."
  80. directories.append(package_directory)
  81. return directories
  82. async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
  83. """
  84. The ASGI entry point.
  85. """
  86. assert scope["type"] == "http"
  87. if not self.config_checked:
  88. await self.check_config()
  89. self.config_checked = True
  90. path = self.get_path(scope)
  91. response = await self.get_response(path, scope)
  92. await response(scope, receive, send)
  93. def get_path(self, scope: Scope) -> str:
  94. """
  95. Given the ASGI scope, return the `path` string to serve up,
  96. with OS specific path separators, and any '..', '.' components removed.
  97. """
  98. route_path = get_route_path(scope)
  99. return os.path.normpath(os.path.join(*route_path.split("/"))) # noqa: E501
  100. async def get_response(self, path: str, scope: Scope) -> Response:
  101. """
  102. Returns an HTTP response, given the incoming path, method and request headers.
  103. """
  104. if scope["method"] not in ("GET", "HEAD"):
  105. raise HTTPException(status_code=405)
  106. try:
  107. full_path, stat_result = await anyio.to_thread.run_sync(
  108. self.lookup_path, path
  109. )
  110. except PermissionError:
  111. raise HTTPException(status_code=401)
  112. except OSError:
  113. raise
  114. if stat_result and stat.S_ISREG(stat_result.st_mode):
  115. # We have a static file to serve.
  116. return self.file_response(full_path, stat_result, scope)
  117. elif stat_result and stat.S_ISDIR(stat_result.st_mode) and self.html:
  118. # We're in HTML mode, and have got a directory URL.
  119. # Check if we have 'index.html' file to serve.
  120. index_path = os.path.join(path, "index.html")
  121. full_path, stat_result = await anyio.to_thread.run_sync(
  122. self.lookup_path, index_path
  123. )
  124. if stat_result is not None and stat.S_ISREG(stat_result.st_mode):
  125. if not scope["path"].endswith("/"):
  126. # Directory URLs should redirect to always end in "/".
  127. url = URL(scope=scope)
  128. url = url.replace(path=url.path + "/")
  129. return RedirectResponse(url=url)
  130. return self.file_response(full_path, stat_result, scope)
  131. if self.html:
  132. # Check for '404.html' if we're in HTML mode.
  133. full_path, stat_result = await anyio.to_thread.run_sync(
  134. self.lookup_path, "404.html"
  135. )
  136. if stat_result and stat.S_ISREG(stat_result.st_mode):
  137. return FileResponse(full_path, stat_result=stat_result, status_code=404)
  138. raise HTTPException(status_code=404)
  139. def lookup_path(
  140. self, path: str
  141. ) -> typing.Tuple[str, typing.Optional[os.stat_result]]:
  142. for directory in self.all_directories:
  143. joined_path = os.path.join(directory, path)
  144. if self.follow_symlink:
  145. full_path = os.path.abspath(joined_path)
  146. else:
  147. full_path = os.path.realpath(joined_path)
  148. directory = os.path.realpath(directory)
  149. if os.path.commonpath([full_path, directory]) != directory:
  150. # Don't allow misbehaving clients to break out of the static files
  151. # directory.
  152. continue
  153. try:
  154. return full_path, os.stat(full_path)
  155. except (FileNotFoundError, NotADirectoryError):
  156. continue
  157. return "", None
  158. def file_response(
  159. self,
  160. full_path: PathLike,
  161. stat_result: os.stat_result,
  162. scope: Scope,
  163. status_code: int = 200,
  164. ) -> Response:
  165. request_headers = Headers(scope=scope)
  166. response = FileResponse(
  167. full_path, status_code=status_code, stat_result=stat_result
  168. )
  169. if self.is_not_modified(response.headers, request_headers):
  170. return NotModifiedResponse(response.headers)
  171. return response
  172. async def check_config(self) -> None:
  173. """
  174. Perform a one-off configuration check that StaticFiles is actually
  175. pointed at a directory, so that we can raise loud errors rather than
  176. just returning 404 responses.
  177. """
  178. if self.directory is None:
  179. return
  180. try:
  181. stat_result = await anyio.to_thread.run_sync(os.stat, self.directory)
  182. except FileNotFoundError:
  183. raise RuntimeError(
  184. f"StaticFiles directory '{self.directory}' does not exist."
  185. )
  186. if not (stat.S_ISDIR(stat_result.st_mode) or stat.S_ISLNK(stat_result.st_mode)):
  187. raise RuntimeError(
  188. f"StaticFiles path '{self.directory}' is not a directory."
  189. )
  190. def is_not_modified(
  191. self, response_headers: Headers, request_headers: Headers
  192. ) -> bool:
  193. """
  194. Given the request and response headers, return `True` if an HTTP
  195. "Not Modified" response could be returned instead.
  196. """
  197. try:
  198. if_none_match = request_headers["if-none-match"]
  199. etag = response_headers["etag"]
  200. if etag in [tag.strip(" W/") for tag in if_none_match.split(",")]:
  201. return True
  202. except KeyError:
  203. pass
  204. try:
  205. if_modified_since = parsedate(request_headers["if-modified-since"])
  206. last_modified = parsedate(response_headers["last-modified"])
  207. if (
  208. if_modified_since is not None
  209. and last_modified is not None
  210. and if_modified_since >= last_modified
  211. ):
  212. return True
  213. except KeyError:
  214. pass
  215. return False