Support rendering some media downloads as inline (#15988)
Use an `inline` Content-Disposition header when the media is "safe" to display inline (some known text, image, video, audio formats).
This commit is contained in:
parent
fb664cf159
commit
79eb6c0cdc
|
@ -0,0 +1 @@
|
||||||
|
Render plain, CSS, CSV, JSON and common image formats media content in the browser (inline) when requested through the /download endpoint.
|
|
@ -50,6 +50,39 @@ TEXT_CONTENT_TYPES = [
|
||||||
"text/xml",
|
"text/xml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# A list of all content types that are "safe" to be rendered inline in a browser.
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def parse_media_id(request: Request) -> Tuple[str, str, Optional[str]]:
|
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
|
"""Parses the server name, media ID and optional file name from the request URI
|
||||||
|
@ -153,8 +186,13 @@ def add_file_headers(
|
||||||
|
|
||||||
request.setHeader(b"Content-Type", content_type.encode("UTF-8"))
|
request.setHeader(b"Content-Type", content_type.encode("UTF-8"))
|
||||||
|
|
||||||
# Use a Content-Disposition of attachment to force download of media.
|
# 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"
|
disposition = "attachment"
|
||||||
|
|
||||||
if upload_name:
|
if upload_name:
|
||||||
# RFC6266 section 4.1 [1] defines both `filename` and `filename*`.
|
# RFC6266 section 4.1 [1] defines both `filename` and `filename*`.
|
||||||
#
|
#
|
||||||
|
|
|
@ -12,7 +12,9 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# 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
|
from tests import unittest
|
||||||
|
|
||||||
|
@ -36,3 +38,28 @@ class GetFileNameFromHeadersTests(unittest.TestCase):
|
||||||
expected,
|
expected,
|
||||||
f"expected output for {hdr!r} to be {expected} but was {res}",
|
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")
|
||||||
|
|
|
@ -129,6 +129,8 @@ class _TestImage:
|
||||||
a 404/400 is expected.
|
a 404/400 is expected.
|
||||||
unable_to_thumbnail: True if we expect the thumbnailing to fail (400), or
|
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.
|
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
|
data: bytes
|
||||||
|
@ -138,6 +140,7 @@ class _TestImage:
|
||||||
expected_scaled: Optional[bytes] = None
|
expected_scaled: Optional[bytes] = None
|
||||||
expected_found: bool = True
|
expected_found: bool = True
|
||||||
unable_to_thumbnail: bool = False
|
unable_to_thumbnail: bool = False
|
||||||
|
is_inline: bool = True
|
||||||
|
|
||||||
|
|
||||||
@parameterized_class(
|
@parameterized_class(
|
||||||
|
@ -198,6 +201,25 @@ class _TestImage:
|
||||||
unable_to_thumbnail=True,
|
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):
|
class MediaRepoTests(unittest.HomeserverTestCase):
|
||||||
|
@ -339,7 +361,11 @@ class MediaRepoTests(unittest.HomeserverTestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
headers.getRawHeaders(b"Content-Disposition"),
|
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:
|
def test_disposition_filenamestar_utf8escaped(self) -> None:
|
||||||
|
@ -359,7 +385,12 @@ class MediaRepoTests(unittest.HomeserverTestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
headers.getRawHeaders(b"Content-Disposition"),
|
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:
|
def test_disposition_none(self) -> None:
|
||||||
|
@ -373,7 +404,10 @@ class MediaRepoTests(unittest.HomeserverTestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
headers.getRawHeaders(b"Content-Type"), [self.test_image.content_type]
|
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:
|
def test_thumbnail_crop(self) -> None:
|
||||||
"""Test that a cropped remote thumbnail is available."""
|
"""Test that a cropped remote thumbnail is available."""
|
||||||
|
|
Loading…
Reference in New Issue