From a177f854c34718e473bcd0a2dc6c4fd935c8e327 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Tue, 15 May 2018 18:12:11 +0200 Subject: [PATCH] Fixed #16470 -- Allowed FileResponse to auto-set some Content headers. Thanks Simon Charette, Jon Dufresne, and Tim Graham for the reviews. --- django/http/response.py | 61 +++++++++++++++--- django/views/static.py | 3 - docs/howto/outputting-pdf.txt | 94 +++++++++------------------- docs/ref/request-response.txt | 38 +++++++++-- docs/releases/2.1.txt | 5 ++ tests/responses/test_fileresponse.py | 73 +++++++++++++++++++++ 6 files changed, 192 insertions(+), 82 deletions(-) create mode 100644 tests/responses/test_fileresponse.py diff --git a/django/http/response.py b/django/http/response.py index 96c0cae597..266c6efb73 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -1,11 +1,13 @@ import datetime import json +import mimetypes +import os import re import sys import time from email.header import Header from http.client import responses -from urllib.parse import urlparse +from urllib.parse import quote, urlparse from django.conf import settings from django.core import signals, signing @@ -391,17 +393,60 @@ class FileResponse(StreamingHttpResponse): """ 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 hasattr(value, 'read'): - self.file_to_stream = value - filelike = value - if hasattr(filelike, 'close'): - self._closable_objects.append(filelike) - value = iter(lambda: filelike.read(self.block_size), b'') - else: + 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._closable_objects.append(filelike) + 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 + if os.path.isabs(filename): + self['Content-Length'] = os.path.getsize(filelike.name) + elif hasattr(filelike, 'getbuffer'): + self['Content-Length'] = filelike.getbuffer().nbytes + + if self.get('Content-Type', '').startswith(settings.DEFAULT_CONTENT_TYPE): + 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['Content-Type'] = content_type or 'application/octet-stream' + else: + self['Content-Type'] = 'application/octet-stream' + + if self.as_attachment: + filename = self.filename or os.path.basename(filename) + if filename: + try: + filename.encode('ascii') + file_expr = 'filename="{}"'.format(filename) + except UnicodeEncodeError: + file_expr = "filename*=utf-8''{}".format(quote(filename)) + self['Content-Disposition'] = 'attachment; {}'.format(file_expr) + class HttpResponseRedirectBase(HttpResponse): allowed_schemes = ['http', 'https', 'ftp'] diff --git a/django/views/static.py b/django/views/static.py index b691e57e6c..90bad8db7e 100644 --- a/django/views/static.py +++ b/django/views/static.py @@ -5,7 +5,6 @@ during development, and SHOULD NOT be used in a production setting. import mimetypes import posixpath import re -import stat from pathlib import Path from django.http import ( @@ -50,8 +49,6 @@ def serve(request, path, document_root=None, show_indexes=False): content_type = content_type or 'application/octet-stream' response = FileResponse(fullpath.open('rb'), content_type=content_type) response["Last-Modified"] = http_date(statobj.st_mtime) - if stat.S_ISREG(statobj.st_mode): - response["Content-Length"] = statobj.st_size if encoding: response["Content-Encoding"] = encoding return response diff --git a/docs/howto/outputting-pdf.txt b/docs/howto/outputting-pdf.txt index fa911df169..9950c4316c 100644 --- a/docs/howto/outputting-pdf.txt +++ b/docs/howto/outputting-pdf.txt @@ -41,21 +41,21 @@ Write your view =============== The key to generating PDFs dynamically with Django is that the ReportLab API -acts on file-like objects, and Django's :class:`~django.http.HttpResponse` -objects are file-like objects. +acts on file-like objects, and Django's :class:`~django.http.FileResponse` +objects accept file-like objects. Here's a "Hello World" example:: - from django.http import HttpResponse + import io + from django.http import FileResponse from reportlab.pdfgen import canvas def some_view(request): - # Create the HttpResponse object with the appropriate PDF headers. - response = HttpResponse(content_type='application/pdf') - response['Content-Disposition'] = 'attachment; filename="somefilename.pdf"' + # Create a file-like buffer to receive PDF data. + buffer = io.BytesIO() - # Create the PDF object, using the response object as its "file." - p = canvas.Canvas(response) + # Create the PDF object, using the buffer as its "file." + p = canvas.Canvas(buffer) # Draw things on the PDF. Here's where the PDF generation happens. # See the ReportLab documentation for the full list of functionality. @@ -64,37 +64,35 @@ Here's a "Hello World" example:: # Close the PDF object cleanly, and we're done. p.showPage() p.save() - return response + + # FileResponse sets the Content-Disposition header so that browsers + # present the option to save the file. + return FileResponse(buffer, as_attachment=True, filename='hello.pdf') The code and comments should be self-explanatory, but a few things deserve a mention: -* The response gets a special MIME type, :mimetype:`application/pdf`. This - tells browsers that the document is a PDF file, rather than an HTML file. - If you leave this off, browsers will probably interpret the output as - HTML, which would result in ugly, scary gobbledygook in the browser - window. +* The response will automatically set the MIME type :mimetype:`application/pdf` + based on the filename extension. This tells browsers that the document is a + PDF file, rather than an HTML file or a generic `application/octet-stream` + binary content. -* The response gets an additional ``Content-Disposition`` header, which - contains the name of the PDF file. This filename is arbitrary: Call it - whatever you want. It'll be used by browsers in the "Save as..." dialog, etc. +* When ``as_attachment=True`` is passed to ``FileResponse``, it sets the + appropriate ``Content-Disposition`` header and that tells Web browsers to + pop-up a dialog box prompting/confirming how to handle the document even if a + default is set on the machine. If the ``as_attachment`` parameter is omitted, + browsers will handle the PDF using whatever program/plugin they've been + configured to use for PDFs. -* The ``Content-Disposition`` header starts with ``'attachment; '`` in this - example. This forces Web browsers to pop-up a dialog box - prompting/confirming how to handle the document even if a default is set - on the machine. If you leave off ``'attachment;'``, browsers will handle - the PDF using whatever program/plugin they've been configured to use for - PDFs. Here's what that code would look like:: +* You can provide an arbitrary ``filename`` parameter. It'll be used by browsers + in the "Save as..." dialog. - response['Content-Disposition'] = 'filename="somefilename.pdf"' - -* Hooking into the ReportLab API is easy: Just pass ``response`` as the - first argument to ``canvas.Canvas``. The ``Canvas`` class expects a - file-like object, and :class:`~django.http.HttpResponse` objects fit the - bill. +* Hooking into the ReportLab API is easy: The same buffer passed as the first + argument to ``canvas.Canvas`` can be fed to the + :class:`~django.http.FileResponse` class. * Note that all subsequent PDF-generation methods are called on the PDF - object (in this case, ``p``) -- not on ``response``. + object (in this case, ``p``) -- not on ``buffer``. * Finally, it's important to call ``showPage()`` and ``save()`` on the PDF file. @@ -105,42 +103,6 @@ mention: with building PDF-generating Django views that are accessed by many people at the same time. -Complex PDFs -============ - -If you're creating a complex PDF document with ReportLab, consider using the -:mod:`io` library as a temporary holding place for your PDF file. This -library provides a file-like object interface that is particularly efficient. -Here's the above "Hello World" example rewritten to use :mod:`io`:: - - from io import BytesIO - from reportlab.pdfgen import canvas - from django.http import HttpResponse - - def some_view(request): - # Create the HttpResponse object with the appropriate PDF headers. - response = HttpResponse(content_type='application/pdf') - response['Content-Disposition'] = 'attachment; filename="somefilename.pdf"' - - buffer = BytesIO() - - # Create the PDF object, using the BytesIO object as its "file." - p = canvas.Canvas(buffer) - - # Draw things on the PDF. Here's where the PDF generation happens. - # See the ReportLab documentation for the full list of functionality. - p.drawString(100, 100, "Hello world.") - - # Close the PDF object cleanly. - p.showPage() - p.save() - - # Get the value of the BytesIO buffer and write it to the response. - pdf = buffer.getvalue() - buffer.close() - response.write(pdf) - return response - Other formats ============= diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 0caf37bc99..05cb24f3b1 100644 --- a/docs/ref/request-response.txt +++ b/docs/ref/request-response.txt @@ -1054,17 +1054,45 @@ Attributes ``FileResponse`` objects ======================== -.. class:: FileResponse +.. class:: FileResponse(open_file, as_attachment=False, filename='', **kwargs) -:class:`FileResponse` is a subclass of :class:`StreamingHttpResponse` optimized -for binary files. It uses `wsgi.file_wrapper`_ if provided by the wsgi server, -otherwise it streams the file out in small chunks. + :class:`FileResponse` is a subclass of :class:`StreamingHttpResponse` + optimized for binary files. It uses `wsgi.file_wrapper`_ if provided by the + wsgi server, otherwise it streams the file out in small chunks. + + If ``as_attachment=True``, the ``Content-Disposition`` header is set, which + asks the browser to offer the file to the user as a download. + + If ``open_file`` doesn't have a name or if the name of ``open_file`` isn't + appropriate, provide a custom file name using the ``filename`` parameter. + + The ``Content-Length``, ``Content-Type``, and ``Content-Disposition`` + headers are automatically set when they can be guessed from contents of + ``open_file``. + + .. versionadded:: 2.1 + + The ``as_attachment`` and ``filename`` keywords argument were added. + Also, ``FileResponse`` sets the ``Content`` headers if it can guess + them. .. _wsgi.file_wrapper: https://www.python.org/dev/peps/pep-3333/#optional-platform-specific-file-handling -``FileResponse`` expects a file open in binary mode like so:: +``FileResponse`` accepts any file-like object with binary content, for example +a file open in binary mode like so:: >>> from django.http import FileResponse >>> response = FileResponse(open('myfile.png', 'rb')) The file will be closed automatically, so don't open it with a context manager. + +Methods +------- + +.. method:: FileResponse.set_headers(open_file) + + .. versionadded:: 2.1 + + This method is automatically called during the response initialization and + set various headers (``Content-Length``, ``Content-Type``, and + ``Content-Disposition``) depending on ``open_file``. diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index bc565d51b1..083488491c 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -255,6 +255,11 @@ Requests and Responses * Added the ``samesite`` argument to :meth:`.HttpResponse.set_cookie` to allow setting the ``SameSite`` cookie flag. +* The new ``as_attachment`` argument for :class:`~django.http.FileResponse` + sets the ``Content-Disposition`` header to make the browser ask if the user + wants to download the file. ``FileResponse`` also tries to set the + ``Content-Type`` and ``Content-Length`` headers where appropriate. + Serialization ~~~~~~~~~~~~~ diff --git a/tests/responses/test_fileresponse.py b/tests/responses/test_fileresponse.py new file mode 100644 index 0000000000..d6e6535b92 --- /dev/null +++ b/tests/responses/test_fileresponse.py @@ -0,0 +1,73 @@ +import io +import os +import sys +import tempfile +from unittest import skipIf + +from django.core.files.base import ContentFile +from django.http import FileResponse +from django.test import SimpleTestCase + + +class FileResponseTests(SimpleTestCase): + def test_file_from_disk_response(self): + response = FileResponse(open(__file__, 'rb')) + self.assertEqual(response['Content-Length'], str(os.path.getsize(__file__))) + self.assertIn(response['Content-Type'], ['text/x-python', 'text/plain']) + response.close() + + def test_file_from_buffer_response(self): + response = FileResponse(io.BytesIO(b'binary content')) + self.assertEqual(response['Content-Length'], '14') + self.assertEqual(response['Content-Type'], 'application/octet-stream') + self.assertEqual(list(response), [b'binary content']) + + @skipIf(sys.platform == 'win32', "Named pipes are Unix-only.") + def test_file_from_named_pipe_response(self): + with tempfile.TemporaryDirectory() as temp_dir: + pipe_file = os.path.join(temp_dir, 'named_pipe') + os.mkfifo(pipe_file) + pipe_for_read = os.open(pipe_file, os.O_RDONLY | os.O_NONBLOCK) + with open(pipe_file, 'wb') as pipe_for_write: + pipe_for_write.write(b'binary content') + + response = FileResponse(os.fdopen(pipe_for_read, mode='rb')) + self.assertEqual(list(response), [b'binary content']) + response.close() + self.assertFalse(response.has_header('Ĉontent-Length')) + + def test_file_from_disk_as_attachment(self): + response = FileResponse(open(__file__, 'rb'), as_attachment=True) + self.assertEqual(response['Content-Length'], str(os.path.getsize(__file__))) + self.assertIn(response['Content-Type'], ['text/x-python', 'text/plain']) + self.assertEqual(response['Content-Disposition'], 'attachment; filename="test_fileresponse.py"') + response.close() + + def test_compressed_response(self): + """ + If compressed responses are served with the uncompressed Content-Type + and a compression Content-Encoding, browsers might automatically + uncompress the file, which is most probably not wanted. + """ + test_tuples = ( + ('.tar.gz', 'application/gzip'), + ('.tar.bz2', 'application/x-bzip'), + ('.tar.xz', 'application/x-xz'), + ) + for extension, mimetype in test_tuples: + with self.subTest(ext=extension): + with tempfile.NamedTemporaryFile(suffix=extension) as tmp: + response = FileResponse(tmp) + self.assertEqual(response['Content-Type'], mimetype) + self.assertFalse(response.has_header('Content-Encoding')) + + def test_unicode_attachment(self): + response = FileResponse( + ContentFile(b'binary content', name="祝您平安.odt"), as_attachment=True, + content_type='application/vnd.oasis.opendocument.text', + ) + self.assertEqual(response['Content-Type'], 'application/vnd.oasis.opendocument.text') + self.assertEqual( + response['Content-Disposition'], + "attachment; filename*=utf-8''%E7%A5%9D%E6%82%A8%E5%B9%B3%E5%AE%89.odt" + )