Browse Source

Fixed #16470 -- Allowed FileResponse to auto-set some Content headers.

Thanks Simon Charette, Jon Dufresne, and Tim Graham for the reviews.
Claude Paroz 6 years ago
parent
commit
a177f854c3

+ 53 - 8
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']

+ 0 - 3
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

+ 28 - 66
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 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.
+* 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 ``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::
+* 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.
 
-      response['Content-Disposition'] = 'filename="somefilename.pdf"'
+* You can provide an arbitrary ``filename`` parameter. It'll be used by browsers
+  in the "Save as..." dialog.
 
-* 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
 =============
 

+ 33 - 5
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``.

+ 5 - 0
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
 ~~~~~~~~~~~~~
 

+ 73 - 0
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"
+        )