diff --git a/django/http/response.py b/django/http/response.py index bb94e81263..3c281f3dd0 100644 --- a/django/http/response.py +++ b/django/http/response.py @@ -8,7 +8,7 @@ import sys import time from email.header import Header from http.client import responses -from urllib.parse import quote, urlparse +from urllib.parse import urlparse from django.conf import settings from django.core import signals, signing @@ -18,7 +18,7 @@ from django.http.cookie import SimpleCookie from django.utils import timezone from django.utils.datastructures import CaseInsensitiveMapping from django.utils.encoding import iri_to_uri -from django.utils.http import http_date +from django.utils.http import content_disposition_header, http_date from django.utils.regex_helper import _lazy_re_compile _charset_from_content_type_re = _lazy_re_compile( @@ -569,20 +569,10 @@ class FileResponse(StreamingHttpResponse): else: self.headers["Content-Type"] = "application/octet-stream" - if filename: - disposition = "attachment" if self.as_attachment else "inline" - try: - filename.encode("ascii") - file_expr = 'filename="{}"'.format( - filename.replace("\\", "\\\\").replace('"', r"\"") - ) - 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" + if content_disposition := content_disposition_header( + self.as_attachment, filename + ): + self.headers["Content-Disposition"] = content_disposition class HttpResponseRedirectBase(HttpResponse): diff --git a/django/utils/http.py b/django/utils/http.py index db4dee2f27..3e7acb5835 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -10,6 +10,7 @@ from urllib.parse import ( _coerce_args, _splitnetloc, _splitparams, + quote, scheme_chars, unquote, ) @@ -425,3 +426,24 @@ def parse_header_parameters(line): value = unquote(value, encoding=encoding) pdict[name] = value return key, pdict + + +def content_disposition_header(as_attachment, filename): + """ + Construct a Content-Disposition HTTP header value from the given filename + as specified by RFC 6266. + """ + if filename: + disposition = "attachment" if as_attachment else "inline" + try: + filename.encode("ascii") + file_expr = 'filename="{}"'.format( + filename.replace("\\", "\\\\").replace('"', r"\"") + ) + except UnicodeEncodeError: + file_expr = "filename*=utf-8''{}".format(quote(filename)) + return f"{disposition}; {file_expr}" + elif as_attachment: + return "attachment" + else: + return None diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 1c9141a986..b1a08dc0e1 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -729,6 +729,15 @@ escaping HTML. Outputs a string in the format ``Wdy, DD Mon YYYY HH:MM:SS GMT``. +.. function:: content_disposition_header(as_attachment, filename) + + .. versionadded:: 4.2 + + Constructs a ``Content-Disposition`` HTTP header value from the given + ``filename`` as specified by :rfc:`6266`. Returns ``None`` if + ``as_attachment`` is ``False`` and ``filename`` is ``None``, otherwise + returns a string suitable for the ``Content-Disposition`` HTTP header. + .. function:: base36_to_int(s) Converts a base 36 string to an integer. diff --git a/docs/releases/4.2.txt b/docs/releases/4.2.txt index 054f8d6621..7e2aa6e5d6 100644 --- a/docs/releases/4.2.txt +++ b/docs/releases/4.2.txt @@ -321,6 +321,9 @@ Utilities documented functions for handling URL redirects. The Django functions were not affected. +* The new :func:`django.utils.http.content_disposition_header` function returns + a ``Content-Disposition`` HTTP header value as specified by :rfc:`6266`. + Validators ~~~~~~~~~~ diff --git a/tests/utils_tests/test_http.py b/tests/utils_tests/test_http.py index add9625685..2290fe85fb 100644 --- a/tests/utils_tests/test_http.py +++ b/tests/utils_tests/test_http.py @@ -7,6 +7,7 @@ from django.test import SimpleTestCase from django.utils.datastructures import MultiValueDict from django.utils.http import ( base36_to_int, + content_disposition_header, escape_leading_slashes, http_date, int_to_base36, @@ -511,3 +512,28 @@ class ParseHeaderParameterTests(unittest.TestCase): for raw_line, expected_title in test_data: parsed = parse_header_parameters(raw_line) self.assertEqual(parsed[1]["title"], expected_title) + + +class ContentDispositionHeaderTests(unittest.TestCase): + def test_basic(self): + tests = ( + ((False, None), None), + ((False, "example"), 'inline; filename="example"'), + ((True, None), "attachment"), + ((True, "example"), 'attachment; filename="example"'), + ( + (True, '"example" file\\name'), + 'attachment; filename="\\"example\\" file\\\\name"', + ), + ((True, "espécimen"), "attachment; filename*=utf-8''esp%C3%A9cimen"), + ( + (True, '"espécimen" filename'), + "attachment; filename*=utf-8''%22esp%C3%A9cimen%22%20filename", + ), + ) + + for (is_attachment, filename), expected in tests: + with self.subTest(is_attachment=is_attachment, filename=filename): + self.assertEqual( + content_disposition_header(is_attachment, filename), expected + )