Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Support rendering some media downloads as inline #15988

Merged
merged 18 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/15988.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Render plain, CSS, CSV, JSON and common image formats media content in the browser (inline) when requested through the /download endpoint.
42 changes: 40 additions & 2 deletions synapse/media/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,39 @@
"text/xml",
]

# A list of all content types that are "safe" to be rendered inline in a browser.
Half-Shot marked this conversation as resolved.
Show resolved Hide resolved
INLINE_CONTENT_TYPES = [
"text/css",
"text/plain",
"text/csv",
"application/json",
"application/ld+json",
# We allow some media files deemed as safe, which comes from the matrix-react-sdk.
# https://github.com/matrix-org/matrix-react-sdk/blob/a70fcfd0bcf7f8c85986da18001ea11597989a7c/src/utils/blobs.ts#L51
# SVGs are *intentionally* omitted.
"image/jpeg",
"image/gif",
"image/png",
"image/apng",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"audio/mp4",
"audio/webm",
"audio/aac",
"audio/mpeg",
"audio/ogg",
"audio/wave",
"audio/wav",
"audio/x-wav",
"audio/x-pn-wav",
"audio/flac",
"audio/x-flac",
]
Half-Shot marked this conversation as resolved.
Show resolved Hide resolved


def parse_media_id(request: Request) -> Tuple[str, str, Optional[str]]:
"""Parses the server name, media ID and optional file name from the request URI
Expand Down Expand Up @@ -153,8 +186,13 @@ def _quote(x: str) -> str:

request.setHeader(b"Content-Type", content_type.encode("UTF-8"))

# Use a Content-Disposition of attachment to force download of media.
disposition = "attachment"
# A strict subset of content types is allowed to be inlined so that they may
# be viewed directly in a browser. Other file types are forced to be downloads.
if media_type.lower() in INLINE_CONTENT_TYPES:
disposition = "inline"
else:
disposition = "attachment"

if upload_name:
# RFC6266 section 4.1 [1] defines both `filename` and `filename*`.
#
Expand Down
29 changes: 28 additions & 1 deletion tests/media/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from synapse.media._base import get_filename_from_headers
from unittest.mock import Mock

from synapse.media._base import add_file_headers, get_filename_from_headers

from tests import unittest

Expand All @@ -36,3 +38,28 @@ def tests(self) -> None:
expected,
f"expected output for {hdr!r} to be {expected} but was {res}",
)


class AddFileHeadersTests(unittest.TestCase):
TEST_CASES = {
"text/plain": b"inline; filename=file.name",
"text/csv": b"inline; filename=file.name",
"image/png": b"inline; filename=file.name",
"text/html": b"attachment; filename=file.name",
"any/thing": b"attachment; filename=file.name",
}

def test_content_disposition(self) -> None:
for media_type, expected in self.TEST_CASES.items():
request = Mock()
add_file_headers(request, media_type, 0, "file.name")
request.setHeader.assert_any_call(b"Content-Disposition", expected)

def test_no_filename(self) -> None:
request = Mock()
add_file_headers(request, "text/plain", 0, None)
request.setHeader.assert_any_call(b"Content-Disposition", b"inline")

request.reset_mock()
add_file_headers(request, "text/html", 0, None)
request.setHeader.assert_any_call(b"Content-Disposition", b"attachment")
40 changes: 37 additions & 3 deletions tests/media/test_media_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ class _TestImage:
a 404/400 is expected.
unable_to_thumbnail: True if we expect the thumbnailing to fail (400), or
False if the thumbnailing should succeed or a normal 404 is expected.
is_inline: True if we expect the file to be served using an inline
Content-Disposition or False if we expect an attachment.
"""

data: bytes
Expand All @@ -138,6 +140,7 @@ class _TestImage:
expected_scaled: Optional[bytes] = None
expected_found: bool = True
unable_to_thumbnail: bool = False
is_inline: bool = True


@parameterized_class(
Expand Down Expand Up @@ -198,6 +201,25 @@ class _TestImage:
unable_to_thumbnail=True,
),
),
# An SVG.
(
_TestImage(
b"""<?xml version="1.0"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">

<svg xmlns="http://www.w3.org/2000/svg"
width="400" height="400">
<circle cx="100" cy="100" r="50" stroke="black"
stroke-width="5" fill="red" />
</svg>""",
b"image/svg",
b".svg",
expected_found=False,
unable_to_thumbnail=True,
is_inline=False,
),
),
],
)
class MediaRepoTests(unittest.HomeserverTestCase):
Expand Down Expand Up @@ -339,7 +361,11 @@ def test_disposition_filename_ascii(self) -> None:
)
self.assertEqual(
headers.getRawHeaders(b"Content-Disposition"),
[b"attachment; filename=out" + self.test_image.extension],
[
(b"inline" if self.test_image.is_inline else b"attachment")
+ b"; filename=out"
+ self.test_image.extension
],
)

def test_disposition_filenamestar_utf8escaped(self) -> None:
Expand All @@ -359,7 +385,12 @@ def test_disposition_filenamestar_utf8escaped(self) -> None:
)
self.assertEqual(
headers.getRawHeaders(b"Content-Disposition"),
[b"attachment; filename*=utf-8''" + filename + self.test_image.extension],
[
(b"inline" if self.test_image.is_inline else b"attachment")
+ b"; filename*=utf-8''"
+ filename
+ self.test_image.extension
],
)

def test_disposition_none(self) -> None:
Expand All @@ -373,7 +404,10 @@ def test_disposition_none(self) -> None:
self.assertEqual(
headers.getRawHeaders(b"Content-Type"), [self.test_image.content_type]
)
self.assertEqual(headers.getRawHeaders(b"Content-Disposition"), [b"attachment"])
self.assertEqual(
headers.getRawHeaders(b"Content-Disposition"),
[b"inline" if self.test_image.is_inline else b"attachment"],
)

def test_thumbnail_crop(self) -> None:
"""Test that a cropped remote thumbnail is available."""
Expand Down
Loading