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

Thanks Simon Charette, Jon Dufresne, and Tim Graham for the reviews.
This commit is contained in:
Claude Paroz 2018-05-15 18:12:11 +02:00 committed by GitHub
parent 2dcc5d629a
commit a177f854c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 192 additions and 82 deletions

View File

@ -1,11 +1,13 @@
import datetime import datetime
import json import json
import mimetypes
import os
import re import re
import sys import sys
import time import time
from email.header import Header from email.header import Header
from http.client import responses from http.client import responses
from urllib.parse import urlparse from urllib.parse import quote, urlparse
from django.conf import settings from django.conf import settings
from django.core import signals, signing from django.core import signals, signing
@ -391,17 +393,60 @@ class FileResponse(StreamingHttpResponse):
""" """
block_size = 4096 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): def _set_streaming_content(self, value):
if hasattr(value, 'read'): if not 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:
self.file_to_stream = None 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) 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): class HttpResponseRedirectBase(HttpResponse):
allowed_schemes = ['http', 'https', 'ftp'] allowed_schemes = ['http', 'https', 'ftp']

View File

@ -5,7 +5,6 @@ during development, and SHOULD NOT be used in a production setting.
import mimetypes import mimetypes
import posixpath import posixpath
import re import re
import stat
from pathlib import Path from pathlib import Path
from django.http import ( 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' content_type = content_type or 'application/octet-stream'
response = FileResponse(fullpath.open('rb'), content_type=content_type) response = FileResponse(fullpath.open('rb'), content_type=content_type)
response["Last-Modified"] = http_date(statobj.st_mtime) response["Last-Modified"] = http_date(statobj.st_mtime)
if stat.S_ISREG(statobj.st_mode):
response["Content-Length"] = statobj.st_size
if encoding: if encoding:
response["Content-Encoding"] = encoding response["Content-Encoding"] = encoding
return response return response

View File

@ -41,21 +41,21 @@ Write your view
=============== ===============
The key to generating PDFs dynamically with Django is that the ReportLab API 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` acts on file-like objects, and Django's :class:`~django.http.FileResponse`
objects are file-like objects. objects accept file-like objects.
Here's a "Hello World" example:: Here's a "Hello World" example::
from django.http import HttpResponse import io
from django.http import FileResponse
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
def some_view(request): def some_view(request):
# Create the HttpResponse object with the appropriate PDF headers. # Create a file-like buffer to receive PDF data.
response = HttpResponse(content_type='application/pdf') buffer = io.BytesIO()
response['Content-Disposition'] = 'attachment; filename="somefilename.pdf"'
# Create the PDF object, using the response object as its "file." # Create the PDF object, using the buffer as its "file."
p = canvas.Canvas(response) p = canvas.Canvas(buffer)
# Draw things on the PDF. Here's where the PDF generation happens. # Draw things on the PDF. Here's where the PDF generation happens.
# See the ReportLab documentation for the full list of functionality. # 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. # Close the PDF object cleanly, and we're done.
p.showPage() p.showPage()
p.save() 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 The code and comments should be self-explanatory, but a few things deserve a
mention: mention:
* The response gets a special MIME type, :mimetype:`application/pdf`. This * The response will automatically set the MIME type :mimetype:`application/pdf`
tells browsers that the document is a PDF file, rather than an HTML file. based on the filename extension. This tells browsers that the document is a
If you leave this off, browsers will probably interpret the output as PDF file, rather than an HTML file or a generic `application/octet-stream`
HTML, which would result in ugly, scary gobbledygook in the browser binary content.
window.
* The response gets an additional ``Content-Disposition`` header, which * When ``as_attachment=True`` is passed to ``FileResponse``, it sets the
contains the name of the PDF file. This filename is arbitrary: Call it appropriate ``Content-Disposition`` header and that tells Web browsers to
whatever you want. It'll be used by browsers in the "Save as..." dialog, etc. 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 * You can provide an arbitrary ``filename`` parameter. It'll be used by browsers
example. This forces Web browsers to pop-up a dialog box in the "Save as..." dialog.
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::
response['Content-Disposition'] = 'filename="somefilename.pdf"' * Hooking into the ReportLab API is easy: The same buffer passed as the first
argument to ``canvas.Canvas`` can be fed to the
* Hooking into the ReportLab API is easy: Just pass ``response`` as the :class:`~django.http.FileResponse` class.
first argument to ``canvas.Canvas``. The ``Canvas`` class expects a
file-like object, and :class:`~django.http.HttpResponse` objects fit the
bill.
* Note that all subsequent PDF-generation methods are called on the PDF * 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 * Finally, it's important to call ``showPage()`` and ``save()`` on the PDF
file. file.
@ -105,42 +103,6 @@ mention:
with building PDF-generating Django views that are accessed by many people with building PDF-generating Django views that are accessed by many people
at the same time. 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 Other formats
============= =============

View File

@ -1054,17 +1054,45 @@ Attributes
``FileResponse`` objects ``FileResponse`` objects
======================== ========================
.. class:: FileResponse .. class:: FileResponse(open_file, as_attachment=False, filename='', **kwargs)
:class:`FileResponse` is a subclass of :class:`StreamingHttpResponse` optimized :class:`FileResponse` is a subclass of :class:`StreamingHttpResponse`
for binary files. It uses `wsgi.file_wrapper`_ if provided by the wsgi server, optimized for binary files. It uses `wsgi.file_wrapper`_ if provided by the
otherwise it streams the file out in small chunks. 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 .. _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 >>> from django.http import FileResponse
>>> response = FileResponse(open('myfile.png', 'rb')) >>> response = FileResponse(open('myfile.png', 'rb'))
The file will be closed automatically, so don't open it with a context manager. 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``.

View File

@ -255,6 +255,11 @@ Requests and Responses
* Added the ``samesite`` argument to :meth:`.HttpResponse.set_cookie` to allow * Added the ``samesite`` argument to :meth:`.HttpResponse.set_cookie` to allow
setting the ``SameSite`` cookie flag. 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 Serialization
~~~~~~~~~~~~~ ~~~~~~~~~~~~~

View File

@ -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"
)