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:
parent
2dcc5d629a
commit
a177f854c3
|
@ -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 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'')
|
||||
else:
|
||||
self.file_to_stream = None
|
||||
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']
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
=============
|
||||
|
||||
|
|
|
@ -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``.
|
||||
|
|
|
@ -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
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
|
|
|
@ -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"
|
||||
)
|
Loading…
Reference in New Issue