Provide more info why we don't have any thumbnails to serve (#13038)

Fix https://github.com/matrix-org/synapse/issues/13016

## New error code and status

### Before

Previously, we returned a `404` for `/thumbnail` which isn't even in the spec.

```json
{
  "errcode": "M_NOT_FOUND",
  "error": "Not found [b'hs1', b'tefQeZhmVxoiBfuFQUKRzJxc']"
}
```

### After

What does the spec say?

> 400: The request does not make sense to the server, or the server cannot thumbnail the content. For example, the client requested non-integer dimensions or asked for negatively-sized images.
>
> *-- https://spec.matrix.org/v1.1/client-server-api/#get_matrixmediav3thumbnailservernamemediaid*

Now with this PR, we respond with a `400` when we don't have thumbnails to serve and we explain why we might not have any thumbnails.

```json
{
    "errcode": "M_UNKNOWN",
    "error": "Cannot find any thumbnails for the requested media ([b'example.com', b'12345']). This might mean the media is not a supported_media_format=(image/jpeg, image/jpg, image/webp, image/gif, image/png) or that thumbnailing failed for some other reason. (Dynamic thumbnails are disabled on this server.)",
}
```

> Cannot find any thumbnails for the requested media ([b'example.com', b'12345']). This might mean the media is not a supported_media_format=(image/jpeg, image/jpg, image/webp, image/gif, image/png) or that thumbnailing failed for some other reason. (Dynamic thumbnails are disabled on this server.)


---

We still respond with a 404 in many other places. But we can iterate on those later and maybe keep some in some specific places after spec updates/clarification: https://github.com/matrix-org/matrix-spec/issues/1122

We can also iterate on the bugs where Synapse doesn't thumbnail when it should in other issues/PRs.
This commit is contained in:
Eric Eastwood 2022-07-15 11:42:21 -05:00 committed by GitHub
parent e9ce4d089b
commit 7b67e93d49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 129 additions and 17 deletions

View File

@ -0,0 +1 @@
Provide more info why we don't have any thumbnails to serve.

View File

@ -42,6 +42,18 @@ THUMBNAIL_SIZE_YAML = """\
# method: %(method)s # method: %(method)s
""" """
# A map from the given media type to the type of thumbnail we should generate
# for it.
THUMBNAIL_SUPPORTED_MEDIA_FORMAT_MAP = {
"image/jpeg": "jpeg",
"image/jpg": "jpeg",
"image/webp": "jpeg",
# Thumbnails can only be jpeg or png. We choose png thumbnails for gif
# because it can have transparency.
"image/gif": "png",
"image/png": "png",
}
HTTP_PROXY_SET_WARNING = """\ HTTP_PROXY_SET_WARNING = """\
The Synapse config url_preview_ip_range_blacklist will be ignored as an HTTP(s) proxy is configured.""" The Synapse config url_preview_ip_range_blacklist will be ignored as an HTTP(s) proxy is configured."""
@ -79,13 +91,22 @@ def parse_thumbnail_requirements(
width = size["width"] width = size["width"]
height = size["height"] height = size["height"]
method = size["method"] method = size["method"]
jpeg_thumbnail = ThumbnailRequirement(width, height, method, "image/jpeg")
png_thumbnail = ThumbnailRequirement(width, height, method, "image/png") for format, thumbnail_format in THUMBNAIL_SUPPORTED_MEDIA_FORMAT_MAP.items():
requirements.setdefault("image/jpeg", []).append(jpeg_thumbnail) requirement = requirements.setdefault(format, [])
requirements.setdefault("image/jpg", []).append(jpeg_thumbnail) if thumbnail_format == "jpeg":
requirements.setdefault("image/webp", []).append(jpeg_thumbnail) requirement.append(
requirements.setdefault("image/gif", []).append(png_thumbnail) ThumbnailRequirement(width, height, method, "image/jpeg")
requirements.setdefault("image/png", []).append(png_thumbnail) )
elif thumbnail_format == "png":
requirement.append(
ThumbnailRequirement(width, height, method, "image/png")
)
else:
raise Exception(
"Unknown thumbnail mapping from %s to %s. This is a Synapse problem, please report!"
% (format, thumbnail_format)
)
return { return {
media_type: tuple(thumbnails) for media_type, thumbnails in requirements.items() media_type: tuple(thumbnails) for media_type, thumbnails in requirements.items()
} }

View File

@ -17,9 +17,11 @@
import logging import logging
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from synapse.api.errors import SynapseError from synapse.api.errors import Codes, SynapseError, cs_error
from synapse.config.repository import THUMBNAIL_SUPPORTED_MEDIA_FORMAT_MAP
from synapse.http.server import ( from synapse.http.server import (
DirectServeJsonResource, DirectServeJsonResource,
respond_with_json,
set_corp_headers, set_corp_headers,
set_cors_headers, set_cors_headers,
) )
@ -309,6 +311,19 @@ class ThumbnailResource(DirectServeJsonResource):
url_cache: True if this is from a URL cache. url_cache: True if this is from a URL cache.
server_name: The server name, if this is a remote thumbnail. server_name: The server name, if this is a remote thumbnail.
""" """
logger.debug(
"_select_and_respond_with_thumbnail: media_id=%s desired=%sx%s (%s) thumbnail_infos=%s",
media_id,
desired_width,
desired_height,
desired_method,
thumbnail_infos,
)
# If `dynamic_thumbnails` is enabled, we expect Synapse to go down a
# different code path to handle it.
assert not self.dynamic_thumbnails
if thumbnail_infos: if thumbnail_infos:
file_info = self._select_thumbnail( file_info = self._select_thumbnail(
desired_width, desired_width,
@ -384,8 +399,29 @@ class ThumbnailResource(DirectServeJsonResource):
file_info.thumbnail.length, file_info.thumbnail.length,
) )
else: else:
# This might be because:
# 1. We can't create thumbnails for the given media (corrupted or
# unsupported file type), or
# 2. The thumbnailing process never ran or errored out initially
# when the media was first uploaded (these bugs should be
# reported and fixed).
# Note that we don't attempt to generate a thumbnail now because
# `dynamic_thumbnails` is disabled.
logger.info("Failed to find any generated thumbnails") logger.info("Failed to find any generated thumbnails")
respond_404(request)
respond_with_json(
request,
400,
cs_error(
"Cannot find any thumbnails for the requested media (%r). This might mean the media is not a supported_media_format=(%s) or that thumbnailing failed for some other reason. (Dynamic thumbnails are disabled on this server.)"
% (
request.postpath,
", ".join(THUMBNAIL_SUPPORTED_MEDIA_FORMAT_MAP.keys()),
),
code=Codes.UNKNOWN,
),
send_cors=True,
)
def _select_thumbnail( def _select_thumbnail(
self, self,

View File

@ -126,7 +126,9 @@ class _TestImage:
expected_scaled: The expected bytes from scaled thumbnailing, or None if expected_scaled: The expected bytes from scaled thumbnailing, or None if
test should just check for a valid image returned. test should just check for a valid image returned.
expected_found: True if the file should exist on the server, or False if expected_found: True if the file should exist on the server, or False if
a 404 is expected. 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.
""" """
data: bytes data: bytes
@ -135,6 +137,7 @@ class _TestImage:
expected_cropped: Optional[bytes] = None expected_cropped: Optional[bytes] = None
expected_scaled: Optional[bytes] = None expected_scaled: Optional[bytes] = None
expected_found: bool = True expected_found: bool = True
unable_to_thumbnail: bool = False
@parameterized_class( @parameterized_class(
@ -192,6 +195,7 @@ class _TestImage:
b"image/gif", b"image/gif",
b".gif", b".gif",
expected_found=False, expected_found=False,
unable_to_thumbnail=True,
), ),
), ),
], ],
@ -366,18 +370,29 @@ class MediaRepoTests(unittest.HomeserverTestCase):
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."""
self._test_thumbnail( self._test_thumbnail(
"crop", self.test_image.expected_cropped, self.test_image.expected_found "crop",
self.test_image.expected_cropped,
expected_found=self.test_image.expected_found,
unable_to_thumbnail=self.test_image.unable_to_thumbnail,
) )
def test_thumbnail_scale(self) -> None: def test_thumbnail_scale(self) -> None:
"""Test that a scaled remote thumbnail is available.""" """Test that a scaled remote thumbnail is available."""
self._test_thumbnail( self._test_thumbnail(
"scale", self.test_image.expected_scaled, self.test_image.expected_found "scale",
self.test_image.expected_scaled,
expected_found=self.test_image.expected_found,
unable_to_thumbnail=self.test_image.unable_to_thumbnail,
) )
def test_invalid_type(self) -> None: def test_invalid_type(self) -> None:
"""An invalid thumbnail type is never available.""" """An invalid thumbnail type is never available."""
self._test_thumbnail("invalid", None, False) self._test_thumbnail(
"invalid",
None,
expected_found=False,
unable_to_thumbnail=self.test_image.unable_to_thumbnail,
)
@unittest.override_config( @unittest.override_config(
{"thumbnail_sizes": [{"width": 32, "height": 32, "method": "scale"}]} {"thumbnail_sizes": [{"width": 32, "height": 32, "method": "scale"}]}
@ -386,7 +401,12 @@ class MediaRepoTests(unittest.HomeserverTestCase):
""" """
Override the config to generate only scaled thumbnails, but request a cropped one. Override the config to generate only scaled thumbnails, but request a cropped one.
""" """
self._test_thumbnail("crop", None, False) self._test_thumbnail(
"crop",
None,
expected_found=False,
unable_to_thumbnail=self.test_image.unable_to_thumbnail,
)
@unittest.override_config( @unittest.override_config(
{"thumbnail_sizes": [{"width": 32, "height": 32, "method": "crop"}]} {"thumbnail_sizes": [{"width": 32, "height": 32, "method": "crop"}]}
@ -395,14 +415,22 @@ class MediaRepoTests(unittest.HomeserverTestCase):
""" """
Override the config to generate only cropped thumbnails, but request a scaled one. Override the config to generate only cropped thumbnails, but request a scaled one.
""" """
self._test_thumbnail("scale", None, False) self._test_thumbnail(
"scale",
None,
expected_found=False,
unable_to_thumbnail=self.test_image.unable_to_thumbnail,
)
def test_thumbnail_repeated_thumbnail(self) -> None: def test_thumbnail_repeated_thumbnail(self) -> None:
"""Test that fetching the same thumbnail works, and deleting the on disk """Test that fetching the same thumbnail works, and deleting the on disk
thumbnail regenerates it. thumbnail regenerates it.
""" """
self._test_thumbnail( self._test_thumbnail(
"scale", self.test_image.expected_scaled, self.test_image.expected_found "scale",
self.test_image.expected_scaled,
expected_found=self.test_image.expected_found,
unable_to_thumbnail=self.test_image.unable_to_thumbnail,
) )
if not self.test_image.expected_found: if not self.test_image.expected_found:
@ -459,8 +487,24 @@ class MediaRepoTests(unittest.HomeserverTestCase):
) )
def _test_thumbnail( def _test_thumbnail(
self, method: str, expected_body: Optional[bytes], expected_found: bool self,
method: str,
expected_body: Optional[bytes],
expected_found: bool,
unable_to_thumbnail: bool = False,
) -> None: ) -> None:
"""Test the given thumbnailing method works as expected.
Args:
method: The thumbnailing method to use (crop, scale).
expected_body: The expected bytes from thumbnailing, or None if
test should just check for a valid image.
expected_found: True if the file should exist on the server, or False if
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.
"""
params = "?width=32&height=32&method=" + method params = "?width=32&height=32&method=" + method
channel = make_request( channel = make_request(
self.reactor, self.reactor,
@ -496,6 +540,16 @@ class MediaRepoTests(unittest.HomeserverTestCase):
else: else:
# ensure that the result is at least some valid image # ensure that the result is at least some valid image
Image.open(BytesIO(channel.result["body"])) Image.open(BytesIO(channel.result["body"]))
elif unable_to_thumbnail:
# A 400 with a JSON body.
self.assertEqual(channel.code, 400)
self.assertEqual(
channel.json_body,
{
"errcode": "M_UNKNOWN",
"error": "Cannot find any thumbnails for the requested media ([b'example.com', b'12345']). This might mean the media is not a supported_media_format=(image/jpeg, image/jpg, image/webp, image/gif, image/png) or that thumbnailing failed for some other reason. (Dynamic thumbnails are disabled on this server.)",
},
)
else: else:
# A 404 with a JSON body. # A 404 with a JSON body.
self.assertEqual(channel.code, 404) self.assertEqual(channel.code, 404)