web_fileresponse.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import asyncio
  2. import mimetypes
  3. import os
  4. import pathlib
  5. import sys
  6. from typing import ( # noqa
  7. IO,
  8. TYPE_CHECKING,
  9. Any,
  10. Awaitable,
  11. Callable,
  12. List,
  13. Optional,
  14. Union,
  15. cast,
  16. )
  17. from . import hdrs
  18. from .abc import AbstractStreamWriter
  19. from .typedefs import LooseHeaders
  20. from .web_exceptions import (
  21. HTTPNotModified,
  22. HTTPPartialContent,
  23. HTTPPreconditionFailed,
  24. HTTPRequestRangeNotSatisfiable,
  25. )
  26. from .web_response import StreamResponse
  27. __all__ = ("FileResponse",)
  28. if TYPE_CHECKING: # pragma: no cover
  29. from .web_request import BaseRequest
  30. _T_OnChunkSent = Optional[Callable[[bytes], Awaitable[None]]]
  31. NOSENDFILE = bool(os.environ.get("AIOHTTP_NOSENDFILE"))
  32. class FileResponse(StreamResponse):
  33. """A response object can be used to send files."""
  34. def __init__(
  35. self,
  36. path: Union[str, pathlib.Path],
  37. chunk_size: int = 256 * 1024,
  38. status: int = 200,
  39. reason: Optional[str] = None,
  40. headers: Optional[LooseHeaders] = None,
  41. ) -> None:
  42. super().__init__(status=status, reason=reason, headers=headers)
  43. if isinstance(path, str):
  44. path = pathlib.Path(path)
  45. self._path = path
  46. self._chunk_size = chunk_size
  47. async def _sendfile_fallback(
  48. self, writer: AbstractStreamWriter, fobj: IO[Any], offset: int, count: int
  49. ) -> AbstractStreamWriter:
  50. # To keep memory usage low,fobj is transferred in chunks
  51. # controlled by the constructor's chunk_size argument.
  52. chunk_size = self._chunk_size
  53. loop = asyncio.get_event_loop()
  54. await loop.run_in_executor(None, fobj.seek, offset)
  55. chunk = await loop.run_in_executor(None, fobj.read, chunk_size)
  56. while chunk:
  57. await writer.write(chunk)
  58. count = count - chunk_size
  59. if count <= 0:
  60. break
  61. chunk = await loop.run_in_executor(None, fobj.read, min(chunk_size, count))
  62. await writer.drain()
  63. return writer
  64. async def _sendfile(
  65. self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int
  66. ) -> AbstractStreamWriter:
  67. writer = await super().prepare(request)
  68. assert writer is not None
  69. if NOSENDFILE or sys.version_info < (3, 7) or self.compression:
  70. return await self._sendfile_fallback(writer, fobj, offset, count)
  71. loop = request._loop
  72. transport = request.transport
  73. assert transport is not None
  74. try:
  75. await loop.sendfile(transport, fobj, offset, count)
  76. except NotImplementedError:
  77. return await self._sendfile_fallback(writer, fobj, offset, count)
  78. await super().write_eof()
  79. return writer
  80. async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
  81. filepath = self._path
  82. gzip = False
  83. if "gzip" in request.headers.get(hdrs.ACCEPT_ENCODING, ""):
  84. gzip_path = filepath.with_name(filepath.name + ".gz")
  85. if gzip_path.is_file():
  86. filepath = gzip_path
  87. gzip = True
  88. loop = asyncio.get_event_loop()
  89. st = await loop.run_in_executor(None, filepath.stat)
  90. modsince = request.if_modified_since
  91. if modsince is not None and st.st_mtime <= modsince.timestamp():
  92. self.set_status(HTTPNotModified.status_code)
  93. self._length_check = False
  94. # Delete any Content-Length headers provided by user. HTTP 304
  95. # should always have empty response body
  96. return await super().prepare(request)
  97. unmodsince = request.if_unmodified_since
  98. if unmodsince is not None and st.st_mtime > unmodsince.timestamp():
  99. self.set_status(HTTPPreconditionFailed.status_code)
  100. return await super().prepare(request)
  101. if hdrs.CONTENT_TYPE not in self.headers:
  102. ct, encoding = mimetypes.guess_type(str(filepath))
  103. if not ct:
  104. ct = "application/octet-stream"
  105. should_set_ct = True
  106. else:
  107. encoding = "gzip" if gzip else None
  108. should_set_ct = False
  109. status = self._status
  110. file_size = st.st_size
  111. count = file_size
  112. start = None
  113. ifrange = request.if_range
  114. if ifrange is None or st.st_mtime <= ifrange.timestamp():
  115. # If-Range header check:
  116. # condition = cached date >= last modification date
  117. # return 206 if True else 200.
  118. # if False:
  119. # Range header would not be processed, return 200
  120. # if True but Range header missing
  121. # return 200
  122. try:
  123. rng = request.http_range
  124. start = rng.start
  125. end = rng.stop
  126. except ValueError:
  127. # https://tools.ietf.org/html/rfc7233:
  128. # A server generating a 416 (Range Not Satisfiable) response to
  129. # a byte-range request SHOULD send a Content-Range header field
  130. # with an unsatisfied-range value.
  131. # The complete-length in a 416 response indicates the current
  132. # length of the selected representation.
  133. #
  134. # Will do the same below. Many servers ignore this and do not
  135. # send a Content-Range header with HTTP 416
  136. self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
  137. self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
  138. return await super().prepare(request)
  139. # If a range request has been made, convert start, end slice
  140. # notation into file pointer offset and count
  141. if start is not None or end is not None:
  142. if start < 0 and end is None: # return tail of file
  143. start += file_size
  144. if start < 0:
  145. # if Range:bytes=-1000 in request header but file size
  146. # is only 200, there would be trouble without this
  147. start = 0
  148. count = file_size - start
  149. else:
  150. # rfc7233:If the last-byte-pos value is
  151. # absent, or if the value is greater than or equal to
  152. # the current length of the representation data,
  153. # the byte range is interpreted as the remainder
  154. # of the representation (i.e., the server replaces the
  155. # value of last-byte-pos with a value that is one less than
  156. # the current length of the selected representation).
  157. count = (
  158. min(end if end is not None else file_size, file_size) - start
  159. )
  160. if start >= file_size:
  161. # HTTP 416 should be returned in this case.
  162. #
  163. # According to https://tools.ietf.org/html/rfc7233:
  164. # If a valid byte-range-set includes at least one
  165. # byte-range-spec with a first-byte-pos that is less than
  166. # the current length of the representation, or at least one
  167. # suffix-byte-range-spec with a non-zero suffix-length,
  168. # then the byte-range-set is satisfiable. Otherwise, the
  169. # byte-range-set is unsatisfiable.
  170. self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
  171. self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
  172. return await super().prepare(request)
  173. status = HTTPPartialContent.status_code
  174. # Even though you are sending the whole file, you should still
  175. # return a HTTP 206 for a Range request.
  176. self.set_status(status)
  177. if should_set_ct:
  178. self.content_type = ct # type: ignore
  179. if encoding:
  180. self.headers[hdrs.CONTENT_ENCODING] = encoding
  181. if gzip:
  182. self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
  183. self.last_modified = st.st_mtime # type: ignore
  184. self.content_length = count
  185. self.headers[hdrs.ACCEPT_RANGES] = "bytes"
  186. real_start = cast(int, start)
  187. if status == HTTPPartialContent.status_code:
  188. self.headers[hdrs.CONTENT_RANGE] = "bytes {}-{}/{}".format(
  189. real_start, real_start + count - 1, file_size
  190. )
  191. if request.method == hdrs.METH_HEAD or self.status in [204, 304]:
  192. return await super().prepare(request)
  193. fobj = await loop.run_in_executor(None, filepath.open, "rb")
  194. if start: # be aware that start could be None or int=0 here.
  195. offset = start
  196. else:
  197. offset = 0
  198. try:
  199. return await self._sendfile(request, fobj, offset, count)
  200. finally:
  201. await loop.run_in_executor(None, fobj.close)