web_fileresponse.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import asyncio
  2. import io
  3. import os
  4. import pathlib
  5. import sys
  6. from contextlib import suppress
  7. from enum import Enum, auto
  8. from mimetypes import MimeTypes
  9. from stat import S_ISREG
  10. from types import MappingProxyType
  11. from typing import ( # noqa
  12. IO,
  13. TYPE_CHECKING,
  14. Any,
  15. Awaitable,
  16. Callable,
  17. Final,
  18. Iterator,
  19. List,
  20. Optional,
  21. Set,
  22. Tuple,
  23. Union,
  24. cast,
  25. )
  26. from . import hdrs
  27. from .abc import AbstractStreamWriter
  28. from .helpers import ETAG_ANY, ETag, must_be_empty_body
  29. from .typedefs import LooseHeaders, PathLike
  30. from .web_exceptions import (
  31. HTTPForbidden,
  32. HTTPNotFound,
  33. HTTPNotModified,
  34. HTTPPartialContent,
  35. HTTPPreconditionFailed,
  36. HTTPRequestRangeNotSatisfiable,
  37. )
  38. from .web_response import StreamResponse
  39. __all__ = ("FileResponse",)
  40. if TYPE_CHECKING:
  41. from .web_request import BaseRequest
  42. _T_OnChunkSent = Optional[Callable[[bytes], Awaitable[None]]]
  43. NOSENDFILE: Final[bool] = bool(os.environ.get("AIOHTTP_NOSENDFILE"))
  44. CONTENT_TYPES: Final[MimeTypes] = MimeTypes()
  45. # File extension to IANA encodings map that will be checked in the order defined.
  46. ENCODING_EXTENSIONS = MappingProxyType(
  47. {ext: CONTENT_TYPES.encodings_map[ext] for ext in (".br", ".gz")}
  48. )
  49. FALLBACK_CONTENT_TYPE = "application/octet-stream"
  50. # Provide additional MIME type/extension pairs to be recognized.
  51. # https://en.wikipedia.org/wiki/List_of_archive_formats#Compression_only
  52. ADDITIONAL_CONTENT_TYPES = MappingProxyType(
  53. {
  54. "application/gzip": ".gz",
  55. "application/x-brotli": ".br",
  56. "application/x-bzip2": ".bz2",
  57. "application/x-compress": ".Z",
  58. "application/x-xz": ".xz",
  59. }
  60. )
  61. class _FileResponseResult(Enum):
  62. """The result of the file response."""
  63. SEND_FILE = auto() # Ie a regular file to send
  64. NOT_ACCEPTABLE = auto() # Ie a socket, or non-regular file
  65. PRE_CONDITION_FAILED = auto() # Ie If-Match or If-None-Match failed
  66. NOT_MODIFIED = auto() # 304 Not Modified
  67. # Add custom pairs and clear the encodings map so guess_type ignores them.
  68. CONTENT_TYPES.encodings_map.clear()
  69. for content_type, extension in ADDITIONAL_CONTENT_TYPES.items():
  70. CONTENT_TYPES.add_type(content_type, extension) # type: ignore[attr-defined]
  71. _CLOSE_FUTURES: Set[asyncio.Future[None]] = set()
  72. class FileResponse(StreamResponse):
  73. """A response object can be used to send files."""
  74. def __init__(
  75. self,
  76. path: PathLike,
  77. chunk_size: int = 256 * 1024,
  78. status: int = 200,
  79. reason: Optional[str] = None,
  80. headers: Optional[LooseHeaders] = None,
  81. ) -> None:
  82. super().__init__(status=status, reason=reason, headers=headers)
  83. self._path = pathlib.Path(path)
  84. self._chunk_size = chunk_size
  85. def _seek_and_read(self, fobj: IO[Any], offset: int, chunk_size: int) -> bytes:
  86. fobj.seek(offset)
  87. return fobj.read(chunk_size) # type: ignore[no-any-return]
  88. async def _sendfile_fallback(
  89. self, writer: AbstractStreamWriter, fobj: IO[Any], offset: int, count: int
  90. ) -> AbstractStreamWriter:
  91. # To keep memory usage low,fobj is transferred in chunks
  92. # controlled by the constructor's chunk_size argument.
  93. chunk_size = self._chunk_size
  94. loop = asyncio.get_event_loop()
  95. chunk = await loop.run_in_executor(
  96. None, self._seek_and_read, fobj, offset, chunk_size
  97. )
  98. while chunk:
  99. await writer.write(chunk)
  100. count = count - chunk_size
  101. if count <= 0:
  102. break
  103. chunk = await loop.run_in_executor(None, fobj.read, min(chunk_size, count))
  104. await writer.drain()
  105. return writer
  106. async def _sendfile(
  107. self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int
  108. ) -> AbstractStreamWriter:
  109. writer = await super().prepare(request)
  110. assert writer is not None
  111. if NOSENDFILE or self.compression:
  112. return await self._sendfile_fallback(writer, fobj, offset, count)
  113. loop = request._loop
  114. transport = request.transport
  115. assert transport is not None
  116. try:
  117. await loop.sendfile(transport, fobj, offset, count)
  118. except NotImplementedError:
  119. return await self._sendfile_fallback(writer, fobj, offset, count)
  120. await super().write_eof()
  121. return writer
  122. @staticmethod
  123. def _etag_match(etag_value: str, etags: Tuple[ETag, ...], *, weak: bool) -> bool:
  124. if len(etags) == 1 and etags[0].value == ETAG_ANY:
  125. return True
  126. return any(
  127. etag.value == etag_value for etag in etags if weak or not etag.is_weak
  128. )
  129. async def _not_modified(
  130. self, request: "BaseRequest", etag_value: str, last_modified: float
  131. ) -> Optional[AbstractStreamWriter]:
  132. self.set_status(HTTPNotModified.status_code)
  133. self._length_check = False
  134. self.etag = etag_value # type: ignore[assignment]
  135. self.last_modified = last_modified # type: ignore[assignment]
  136. # Delete any Content-Length headers provided by user. HTTP 304
  137. # should always have empty response body
  138. return await super().prepare(request)
  139. async def _precondition_failed(
  140. self, request: "BaseRequest"
  141. ) -> Optional[AbstractStreamWriter]:
  142. self.set_status(HTTPPreconditionFailed.status_code)
  143. self.content_length = 0
  144. return await super().prepare(request)
  145. def _make_response(
  146. self, request: "BaseRequest", accept_encoding: str
  147. ) -> Tuple[
  148. _FileResponseResult, Optional[io.BufferedReader], os.stat_result, Optional[str]
  149. ]:
  150. """Return the response result, io object, stat result, and encoding.
  151. If an uncompressed file is returned, the encoding is set to
  152. :py:data:`None`.
  153. This method should be called from a thread executor
  154. since it calls os.stat which may block.
  155. """
  156. file_path, st, file_encoding = self._get_file_path_stat_encoding(
  157. accept_encoding
  158. )
  159. if not file_path:
  160. return _FileResponseResult.NOT_ACCEPTABLE, None, st, None
  161. etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
  162. # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1-2
  163. if (ifmatch := request.if_match) is not None and not self._etag_match(
  164. etag_value, ifmatch, weak=False
  165. ):
  166. return _FileResponseResult.PRE_CONDITION_FAILED, None, st, file_encoding
  167. if (
  168. (unmodsince := request.if_unmodified_since) is not None
  169. and ifmatch is None
  170. and st.st_mtime > unmodsince.timestamp()
  171. ):
  172. return _FileResponseResult.PRE_CONDITION_FAILED, None, st, file_encoding
  173. # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2-2
  174. if (ifnonematch := request.if_none_match) is not None and self._etag_match(
  175. etag_value, ifnonematch, weak=True
  176. ):
  177. return _FileResponseResult.NOT_MODIFIED, None, st, file_encoding
  178. if (
  179. (modsince := request.if_modified_since) is not None
  180. and ifnonematch is None
  181. and st.st_mtime <= modsince.timestamp()
  182. ):
  183. return _FileResponseResult.NOT_MODIFIED, None, st, file_encoding
  184. fobj = file_path.open("rb")
  185. with suppress(OSError):
  186. # fstat() may not be available on all platforms
  187. # Once we open the file, we want the fstat() to ensure
  188. # the file has not changed between the first stat()
  189. # and the open().
  190. st = os.stat(fobj.fileno())
  191. return _FileResponseResult.SEND_FILE, fobj, st, file_encoding
  192. def _get_file_path_stat_encoding(
  193. self, accept_encoding: str
  194. ) -> Tuple[Optional[pathlib.Path], os.stat_result, Optional[str]]:
  195. file_path = self._path
  196. for file_extension, file_encoding in ENCODING_EXTENSIONS.items():
  197. if file_encoding not in accept_encoding:
  198. continue
  199. compressed_path = file_path.with_suffix(file_path.suffix + file_extension)
  200. with suppress(OSError):
  201. # Do not follow symlinks and ignore any non-regular files.
  202. st = compressed_path.lstat()
  203. if S_ISREG(st.st_mode):
  204. return compressed_path, st, file_encoding
  205. # Fallback to the uncompressed file
  206. st = file_path.stat()
  207. return file_path if S_ISREG(st.st_mode) else None, st, None
  208. async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
  209. loop = asyncio.get_running_loop()
  210. # Encoding comparisons should be case-insensitive
  211. # https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
  212. accept_encoding = request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
  213. try:
  214. response_result, fobj, st, file_encoding = await loop.run_in_executor(
  215. None, self._make_response, request, accept_encoding
  216. )
  217. except PermissionError:
  218. self.set_status(HTTPForbidden.status_code)
  219. return await super().prepare(request)
  220. except OSError:
  221. # Most likely to be FileNotFoundError or OSError for circular
  222. # symlinks in python >= 3.13, so respond with 404.
  223. self.set_status(HTTPNotFound.status_code)
  224. return await super().prepare(request)
  225. # Forbid special files like sockets, pipes, devices, etc.
  226. if response_result is _FileResponseResult.NOT_ACCEPTABLE:
  227. self.set_status(HTTPForbidden.status_code)
  228. return await super().prepare(request)
  229. if response_result is _FileResponseResult.PRE_CONDITION_FAILED:
  230. return await self._precondition_failed(request)
  231. if response_result is _FileResponseResult.NOT_MODIFIED:
  232. etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
  233. last_modified = st.st_mtime
  234. return await self._not_modified(request, etag_value, last_modified)
  235. assert fobj is not None
  236. try:
  237. return await self._prepare_open_file(request, fobj, st, file_encoding)
  238. finally:
  239. # We do not await here because we do not want to wait
  240. # for the executor to finish before returning the response
  241. # so the connection can begin servicing another request
  242. # as soon as possible.
  243. close_future = loop.run_in_executor(None, fobj.close)
  244. # Hold a strong reference to the future to prevent it from being
  245. # garbage collected before it completes.
  246. _CLOSE_FUTURES.add(close_future)
  247. close_future.add_done_callback(_CLOSE_FUTURES.remove)
  248. async def _prepare_open_file(
  249. self,
  250. request: "BaseRequest",
  251. fobj: io.BufferedReader,
  252. st: os.stat_result,
  253. file_encoding: Optional[str],
  254. ) -> Optional[AbstractStreamWriter]:
  255. status = self._status
  256. file_size: int = st.st_size
  257. file_mtime: float = st.st_mtime
  258. count: int = file_size
  259. start: Optional[int] = None
  260. if (ifrange := request.if_range) is None or file_mtime <= ifrange.timestamp():
  261. # If-Range header check:
  262. # condition = cached date >= last modification date
  263. # return 206 if True else 200.
  264. # if False:
  265. # Range header would not be processed, return 200
  266. # if True but Range header missing
  267. # return 200
  268. try:
  269. rng = request.http_range
  270. start = rng.start
  271. end: Optional[int] = rng.stop
  272. except ValueError:
  273. # https://tools.ietf.org/html/rfc7233:
  274. # A server generating a 416 (Range Not Satisfiable) response to
  275. # a byte-range request SHOULD send a Content-Range header field
  276. # with an unsatisfied-range value.
  277. # The complete-length in a 416 response indicates the current
  278. # length of the selected representation.
  279. #
  280. # Will do the same below. Many servers ignore this and do not
  281. # send a Content-Range header with HTTP 416
  282. self._headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
  283. self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
  284. return await super().prepare(request)
  285. # If a range request has been made, convert start, end slice
  286. # notation into file pointer offset and count
  287. if start is not None:
  288. if start < 0 and end is None: # return tail of file
  289. start += file_size
  290. if start < 0:
  291. # if Range:bytes=-1000 in request header but file size
  292. # is only 200, there would be trouble without this
  293. start = 0
  294. count = file_size - start
  295. else:
  296. # rfc7233:If the last-byte-pos value is
  297. # absent, or if the value is greater than or equal to
  298. # the current length of the representation data,
  299. # the byte range is interpreted as the remainder
  300. # of the representation (i.e., the server replaces the
  301. # value of last-byte-pos with a value that is one less than
  302. # the current length of the selected representation).
  303. count = (
  304. min(end if end is not None else file_size, file_size) - start
  305. )
  306. if start >= file_size:
  307. # HTTP 416 should be returned in this case.
  308. #
  309. # According to https://tools.ietf.org/html/rfc7233:
  310. # If a valid byte-range-set includes at least one
  311. # byte-range-spec with a first-byte-pos that is less than
  312. # the current length of the representation, or at least one
  313. # suffix-byte-range-spec with a non-zero suffix-length,
  314. # then the byte-range-set is satisfiable. Otherwise, the
  315. # byte-range-set is unsatisfiable.
  316. self._headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
  317. self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
  318. return await super().prepare(request)
  319. status = HTTPPartialContent.status_code
  320. # Even though you are sending the whole file, you should still
  321. # return a HTTP 206 for a Range request.
  322. self.set_status(status)
  323. # If the Content-Type header is not already set, guess it based on the
  324. # extension of the request path. The encoding returned by guess_type
  325. # can be ignored since the map was cleared above.
  326. if hdrs.CONTENT_TYPE not in self._headers:
  327. if sys.version_info >= (3, 13):
  328. guesser = CONTENT_TYPES.guess_file_type
  329. else:
  330. guesser = CONTENT_TYPES.guess_type
  331. self.content_type = guesser(self._path)[0] or FALLBACK_CONTENT_TYPE
  332. if file_encoding:
  333. self._headers[hdrs.CONTENT_ENCODING] = file_encoding
  334. self._headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
  335. # Disable compression if we are already sending
  336. # a compressed file since we don't want to double
  337. # compress.
  338. self._compression = False
  339. self.etag = f"{st.st_mtime_ns:x}-{st.st_size:x}" # type: ignore[assignment]
  340. self.last_modified = file_mtime # type: ignore[assignment]
  341. self.content_length = count
  342. self._headers[hdrs.ACCEPT_RANGES] = "bytes"
  343. if status == HTTPPartialContent.status_code:
  344. real_start = start
  345. assert real_start is not None
  346. self._headers[hdrs.CONTENT_RANGE] = "bytes {}-{}/{}".format(
  347. real_start, real_start + count - 1, file_size
  348. )
  349. # If we are sending 0 bytes calling sendfile() will throw a ValueError
  350. if count == 0 or must_be_empty_body(request.method, status):
  351. return await super().prepare(request)
  352. # be aware that start could be None or int=0 here.
  353. offset = start or 0
  354. return await self._sendfile(request, fobj, offset, count)