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