Repository: falconry/falcon

Issue:
Make it easier to format inline Content-Disposition header
(As requested by @Pachada on Gitter.)

While it's very easy to mark the response content as a downloadable file using [`resp.downloadable_as`](https://falcon.readthedocs.io/en/stable/api/request_and_response_wsgi.html#falcon.Response.downloadable_as), this sets the `Content-Disposition` header assuming `attachment` disposition.

In some cases, such as a PDF document meant to be viewed `inline`, this is not optimal. The user could format the header on their own, but then they would have to reimplement [`resp.downloadable_as`'s](https://falcon.readthedocs.io/en/stable/api/request_and_response_wsgi.html#falcon.Response.downloadable_as) machinery for encoding non-ASCII filenames as per [RFC 6266](https://datatracker.ietf.org/doc/html/rfc6266#appendix-D) recommendations.


Discussion:

Resolving patch:
diff --git a/falcon/response.py b/falcon/response.py
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -14,6 +14,7 @@
 
 """Response class."""
 
+import functools
 import mimetypes
 
 from falcon.constants import _UNSET
@@ -1011,7 +1012,25 @@ def append_link(
         ``filename*`` directive, whereas ``filename`` will contain the US
         ASCII fallback.
         """,
-        format_content_disposition,
+        functools.partial(format_content_disposition, disposition_type='attachment'),
+    )
+
+    viewable_as = header_property(
+        'Content-Disposition',
+        """Set an inline Content-Disposition header using the given filename.
+
+        The value will be used for the ``filename`` directive. For example,
+        given ``'report.pdf'``, the Content-Disposition header would be set
+        to: ``'inline; filename="report.pdf"'``.
+
+        As per `RFC 6266 <https://tools.ietf.org/html/rfc6266#appendix-D>`_
+        recommendations, non-ASCII filenames will be encoded using the
+        ``filename*`` directive, whereas ``filename`` will contain the US
+        ASCII fallback.
+
+        .. versionadded:: 3.1
+        """,
+        functools.partial(format_content_disposition, disposition_type='inline'),
     )
 
     etag = header_property(
diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py
--- a/falcon/response_helpers.py
+++ b/falcon/response_helpers.py
@@ -85,14 +85,14 @@ def format_range(value):
     return result
 
 
-def format_content_disposition(value):
+def format_content_disposition(value, disposition_type='attachment'):
     """Format a Content-Disposition header given a filename."""
 
     # NOTE(vytas): RFC 6266, Appendix D.
     #   Include a "filename" parameter when US-ASCII ([US-ASCII]) is
     #   sufficiently expressive.
     if isascii(value):
-        return 'attachment; filename="' + value + '"'
+        return '%s; filename="%s"' % (disposition_type, value)
 
     # NOTE(vytas): RFC 6266, Appendix D.
     #   * Include a "filename*" parameter where the desired filename cannot be
@@ -105,7 +105,8 @@ def format_content_disposition(value):
     #   * When a "filename" parameter is included as a fallback (as per above),
     #     "filename" should occur first, due to parsing problems in some
     #     existing implementations.
-    return "attachment; filename=%s; filename*=UTF-8''%s" % (
+    return "%s; filename=%s; filename*=UTF-8''%s" % (
+        disposition_type,
         secure_filename(value),
         uri.encode_value(value),
     )


Test patch:
diff --git a/tests/test_headers.py b/tests/test_headers.py
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -247,6 +247,16 @@ def on_get(self, req, resp):
         resp.downloadable_as = self.filename
 
 
+class ViewableResource:
+    def __init__(self, filename):
+        self.filename = filename
+
+    def on_get(self, req, resp):
+        resp.text = 'Hello, World!\n'
+        resp.content_type = falcon.MEDIA_TEXT
+        resp.viewable_as = self.filename
+
+
 class ContentLengthHeaderResource:
     def __init__(self, content_length, body=None, data=None):
         self._content_length = content_length
@@ -566,7 +576,7 @@ def test_response_header_helpers_on_get(self, client):
             ),
         ],
     )
-    def test_content_disposition_header(self, client, filename, expected):
+    def test_content_disposition_attachment_header(self, client, filename, expected):
         resource = DownloadableResource(filename)
         client.app.add_route('/', resource)
         resp = client.simulate_get()
@@ -574,6 +584,26 @@ def test_content_disposition_header(self, client, filename, expected):
         assert resp.status_code == 200
         assert resp.headers['Content-Disposition'] == expected
 
+    @pytest.mark.parametrize(
+        'filename,expected',
+        [
+            ('report.csv', 'inline; filename="report.csv"'),
+            ('Hello World.txt', 'inline; filename="Hello World.txt"'),
+            (
+                'Bold Digit 𝟏.txt',
+                'inline; filename=Bold_Digit_1.txt; '
+                "filename*=UTF-8''Bold%20Digit%20%F0%9D%9F%8F.txt",
+            ),
+        ],
+    )
+    def test_content_disposition_inline_header(self, client, filename, expected):
+        resource = ViewableResource(filename)
+        client.app.add_route('/', resource)
+        resp = client.simulate_get()
+
+        assert resp.status_code == 200
+        assert resp.headers['Content-Disposition'] == expected
+
     def test_request_latin1_headers(self, client):
         client.app.add_route('/headers', HeadersDebugResource())
         client.app.add_route(