Loading rpc/fileresponse.py 0 → 100644 +222 −0 Original line number Diff line number Diff line from django.http.response import StreamingHttpResponse import os import mimetypes from urllib.parse import quote class FileResponse(StreamingHttpResponse): """ A streaming HTTP response class optimized for files. """ block_size = 4096 def __init__(self, *args, as_attachment=False, filename="", **kwargs): self.as_attachment = as_attachment self.filename = filename super().__init__(*args, **kwargs) def _set_streaming_content(self, value): if self.filename: self.headers["Content-Disposition"] = "attachment;filename={}".format( self.filename ) if not hasattr(value, "read"): self.file_to_stream = None return super()._set_streaming_content(value) self.file_to_stream = filelike = value if hasattr(filelike, "close"): self._resource_closers.append(filelike.close) value = iter(lambda: filelike.read(self.block_size), b"") self.set_headers(filelike) super()._set_streaming_content(value) def set_headers(self, filelike): """ Set some common response headers (Content-Length, Content-Type, and Content-Disposition) based on the `filelike` response content. """ encoding_map = { "bzip2": "application/x-bzip", "gzip": "application/gzip", "xz": "application/x-xz", } # filename = getattr(filelike, 'name', None) # filename = filename if (isinstance(filename, str) and filename) else self.filename # filename = self.filename if self.filename else filename filename = self.filename if os.path.isabs(filename): self.headers["Content-Length"] = os.path.getsize(filelike.name) elif hasattr(filelike, "getbuffer"): self.headers["Content-Length"] = filelike.getbuffer().nbytes if self.headers.get("Content-Type", "").startswith("text/html"): if filename: content_type, encoding = mimetypes.guess_type(filename) # Encoding isn't set to prevent browsers from automatically # uncompressing files. content_type = encoding_map.get(encoding, content_type) self.headers["Content-Type"] = ( content_type or "application/octet-stream" ) else: self.headers["Content-Type"] = "application/octet-stream" filename = self.filename or os.path.basename(filename) if filename: disposition = "attachment" if self.as_attachment else "inline" try: filename.encode("ascii") file_expr = 'filename="{}"'.format(filename) except UnicodeEncodeError: file_expr = "filename*=utf-8''{}".format(quote(filename)) self.headers["Content-Disposition"] = "{}; {}".format( disposition, file_expr ) elif self.as_attachment: self.headers["Content-Disposition"] = "attachment" class RangedFileReader(object): """ Wraps a file like object with an iterator that runs over part (or all) of the file defined by start and stop. Blocks of block_size will be returned from the starting position, up to, but not including the stop point. """ block_size = 8192 def __init__(self, file_like, start=0, stop=float("inf"), block_size=None): """ Args: file_like (File): A file-like object. start (int): Where to start reading the file. stop (Optional[int]:float): Where to end reading the file. Defaults to infinity. block_size (Optional[int]): The block_size to read with. """ self.f = file_like self.size = len(self.f.read()) self.block_size = block_size or RangedFileReader.block_size self.start = start self.stop = stop def __iter__(self): """ Reads the data in chunks. """ self.f.seek(self.start) position = self.start while position < self.stop: data = self.f.read(min(self.block_size, self.stop - position)) if not data: break yield data position += self.block_size def parse_range_header(self, header, resource_size): """ Parses a range header into a list of two-tuples (start, stop) where `start` is the starting byte of the range (inclusive) and `stop` is the ending byte position of the range (exclusive). Args: header (str): The HTTP_RANGE request header. resource_size (int): The size of the file in bytes. Returns: None if the value of the header is not syntatically valid. """ if not header or "=" not in header: return None ranges = [] units, range_ = header.split("=", 1) units = units.strip().lower() if units != "bytes": return None for val in range_.split(","): val = val.strip() if "-" not in val: return None if val.startswith("-"): # suffix-byte-range-spec: this form specifies the last N bytes # of an entity-body. start = resource_size + int(val) if start < 0: start = 0 stop = resource_size else: # byte-range-spec: first-byte-pos "-" [last-byte-pos]. start, stop = val.split("-", 1) start = int(start) # The +1 is here since we want the stopping point to be # exclusive, whereas in the HTTP spec, the last-byte-pos # is inclusive. stop = int(stop) + 1 if stop else resource_size if start >= stop: return None ranges.append((start, stop)) return ranges class RangedFileResponse(FileResponse): """ This is a modified FileResponse that returns `Content-Range` headers with the response, so browsers that request the file, can stream the response properly. """ def __init__(self, request, file, *args, **kwargs): """ RangedFileResponse constructor also requires a request, which checks whether range headers should be added to the response. Args: request(WGSIRequest): The Django request object. file (File): A file-like object. """ self.ranged_file = RangedFileReader(file) super(RangedFileResponse, self).__init__(self.ranged_file, *args, **kwargs) if "HTTP_RANGE" in request.META: self.add_range_headers(request.META["HTTP_RANGE"]) def add_range_headers(self, range_header): """ Adds several headers that are necessary for a streaming file response, in order for Safari to play audio files. Also sets the HTTP status_code to 206 (partial content). Args: range_header (str): Browser HTTP_RANGE request header. """ self["Accept-Ranges"] = "bytes" size = self.ranged_file.size try: ranges = self.ranged_file.parse_range_header(range_header, size) except ValueError: ranges = None # Only handle syntactically valid headers, that are simple (no # multipart byteranges). if ranges is not None and len(ranges) == 1: start, stop = ranges[0] if start >= size: # Requested range not satisfiable. self.status_code = 416 return if stop >= size: stop = size self.ranged_file.start = start self.ranged_file.stop = stop self["Content-Range"] = "bytes %d-%d/%d" % (start, stop - 1, size) self["Content-Length"] = stop - start self.status_code = 206 rpc/views.py +2 −2 Original line number Diff line number Diff line Loading @@ -27,7 +27,7 @@ from django.db import transaction from pathlib import Path from shutil import rmtree import base64 from ranged_fileresponse import RangedFileResponse from .fileresponse import RangedFileResponse from django.views.decorators.gzip import gzip_page import logging from functools import lru_cache Loading Loading @@ -318,7 +318,7 @@ def upload_partial(request: HttpRequest) -> HttpResponse: def force_download( request: HttpRequest, file_id: int ) -> Union[HttpResponse, FileResponse]: ) -> Union[HttpResponse, RangedFileResponse]: file = models.File.objects.filter(id=file_id).first() if not file: return HttpResponse("Not Found", 404) Loading Loading
rpc/fileresponse.py 0 → 100644 +222 −0 Original line number Diff line number Diff line from django.http.response import StreamingHttpResponse import os import mimetypes from urllib.parse import quote class FileResponse(StreamingHttpResponse): """ A streaming HTTP response class optimized for files. """ block_size = 4096 def __init__(self, *args, as_attachment=False, filename="", **kwargs): self.as_attachment = as_attachment self.filename = filename super().__init__(*args, **kwargs) def _set_streaming_content(self, value): if self.filename: self.headers["Content-Disposition"] = "attachment;filename={}".format( self.filename ) if not hasattr(value, "read"): self.file_to_stream = None return super()._set_streaming_content(value) self.file_to_stream = filelike = value if hasattr(filelike, "close"): self._resource_closers.append(filelike.close) value = iter(lambda: filelike.read(self.block_size), b"") self.set_headers(filelike) super()._set_streaming_content(value) def set_headers(self, filelike): """ Set some common response headers (Content-Length, Content-Type, and Content-Disposition) based on the `filelike` response content. """ encoding_map = { "bzip2": "application/x-bzip", "gzip": "application/gzip", "xz": "application/x-xz", } # filename = getattr(filelike, 'name', None) # filename = filename if (isinstance(filename, str) and filename) else self.filename # filename = self.filename if self.filename else filename filename = self.filename if os.path.isabs(filename): self.headers["Content-Length"] = os.path.getsize(filelike.name) elif hasattr(filelike, "getbuffer"): self.headers["Content-Length"] = filelike.getbuffer().nbytes if self.headers.get("Content-Type", "").startswith("text/html"): if filename: content_type, encoding = mimetypes.guess_type(filename) # Encoding isn't set to prevent browsers from automatically # uncompressing files. content_type = encoding_map.get(encoding, content_type) self.headers["Content-Type"] = ( content_type or "application/octet-stream" ) else: self.headers["Content-Type"] = "application/octet-stream" filename = self.filename or os.path.basename(filename) if filename: disposition = "attachment" if self.as_attachment else "inline" try: filename.encode("ascii") file_expr = 'filename="{}"'.format(filename) except UnicodeEncodeError: file_expr = "filename*=utf-8''{}".format(quote(filename)) self.headers["Content-Disposition"] = "{}; {}".format( disposition, file_expr ) elif self.as_attachment: self.headers["Content-Disposition"] = "attachment" class RangedFileReader(object): """ Wraps a file like object with an iterator that runs over part (or all) of the file defined by start and stop. Blocks of block_size will be returned from the starting position, up to, but not including the stop point. """ block_size = 8192 def __init__(self, file_like, start=0, stop=float("inf"), block_size=None): """ Args: file_like (File): A file-like object. start (int): Where to start reading the file. stop (Optional[int]:float): Where to end reading the file. Defaults to infinity. block_size (Optional[int]): The block_size to read with. """ self.f = file_like self.size = len(self.f.read()) self.block_size = block_size or RangedFileReader.block_size self.start = start self.stop = stop def __iter__(self): """ Reads the data in chunks. """ self.f.seek(self.start) position = self.start while position < self.stop: data = self.f.read(min(self.block_size, self.stop - position)) if not data: break yield data position += self.block_size def parse_range_header(self, header, resource_size): """ Parses a range header into a list of two-tuples (start, stop) where `start` is the starting byte of the range (inclusive) and `stop` is the ending byte position of the range (exclusive). Args: header (str): The HTTP_RANGE request header. resource_size (int): The size of the file in bytes. Returns: None if the value of the header is not syntatically valid. """ if not header or "=" not in header: return None ranges = [] units, range_ = header.split("=", 1) units = units.strip().lower() if units != "bytes": return None for val in range_.split(","): val = val.strip() if "-" not in val: return None if val.startswith("-"): # suffix-byte-range-spec: this form specifies the last N bytes # of an entity-body. start = resource_size + int(val) if start < 0: start = 0 stop = resource_size else: # byte-range-spec: first-byte-pos "-" [last-byte-pos]. start, stop = val.split("-", 1) start = int(start) # The +1 is here since we want the stopping point to be # exclusive, whereas in the HTTP spec, the last-byte-pos # is inclusive. stop = int(stop) + 1 if stop else resource_size if start >= stop: return None ranges.append((start, stop)) return ranges class RangedFileResponse(FileResponse): """ This is a modified FileResponse that returns `Content-Range` headers with the response, so browsers that request the file, can stream the response properly. """ def __init__(self, request, file, *args, **kwargs): """ RangedFileResponse constructor also requires a request, which checks whether range headers should be added to the response. Args: request(WGSIRequest): The Django request object. file (File): A file-like object. """ self.ranged_file = RangedFileReader(file) super(RangedFileResponse, self).__init__(self.ranged_file, *args, **kwargs) if "HTTP_RANGE" in request.META: self.add_range_headers(request.META["HTTP_RANGE"]) def add_range_headers(self, range_header): """ Adds several headers that are necessary for a streaming file response, in order for Safari to play audio files. Also sets the HTTP status_code to 206 (partial content). Args: range_header (str): Browser HTTP_RANGE request header. """ self["Accept-Ranges"] = "bytes" size = self.ranged_file.size try: ranges = self.ranged_file.parse_range_header(range_header, size) except ValueError: ranges = None # Only handle syntactically valid headers, that are simple (no # multipart byteranges). if ranges is not None and len(ranges) == 1: start, stop = ranges[0] if start >= size: # Requested range not satisfiable. self.status_code = 416 return if stop >= size: stop = size self.ranged_file.start = start self.ranged_file.stop = stop self["Content-Range"] = "bytes %d-%d/%d" % (start, stop - 1, size) self["Content-Length"] = stop - start self.status_code = 206
rpc/views.py +2 −2 Original line number Diff line number Diff line Loading @@ -27,7 +27,7 @@ from django.db import transaction from pathlib import Path from shutil import rmtree import base64 from ranged_fileresponse import RangedFileResponse from .fileresponse import RangedFileResponse from django.views.decorators.gzip import gzip_page import logging from functools import lru_cache Loading Loading @@ -318,7 +318,7 @@ def upload_partial(request: HttpRequest) -> HttpResponse: def force_download( request: HttpRequest, file_id: int ) -> Union[HttpResponse, FileResponse]: ) -> Union[HttpResponse, RangedFileResponse]: file = models.File.objects.filter(id=file_id).first() if not file: return HttpResponse("Not Found", 404) Loading