Do not error when thumbnailing invalid files (#8236)

If a file cannot be thumbnailed for some reason (e.g. the file is empty), then
catch the exception and convert it to a reasonable error message for the client.
This commit is contained in:
Patrick Cloke 2020-09-09 12:59:41 -04:00 committed by GitHub
parent 2ea1c68249
commit b312769c0e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 107 additions and 23 deletions

1
changelog.d/8236.bugfix Normal file
View file

@ -0,0 +1 @@
Fix a longstanding bug where files that could not be thumbnailed would result in an Internal Server Error.

View file

@ -53,7 +53,7 @@ from .media_storage import MediaStorage
from .preview_url_resource import PreviewUrlResource from .preview_url_resource import PreviewUrlResource
from .storage_provider import StorageProviderWrapper from .storage_provider import StorageProviderWrapper
from .thumbnail_resource import ThumbnailResource from .thumbnail_resource import ThumbnailResource
from .thumbnailer import Thumbnailer from .thumbnailer import Thumbnailer, ThumbnailError
from .upload_resource import UploadResource from .upload_resource import UploadResource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -460,13 +460,30 @@ class MediaRepository:
return t_byte_source return t_byte_source
async def generate_local_exact_thumbnail( async def generate_local_exact_thumbnail(
self, media_id, t_width, t_height, t_method, t_type, url_cache self,
): media_id: str,
t_width: int,
t_height: int,
t_method: str,
t_type: str,
url_cache: str,
) -> Optional[str]:
input_path = await self.media_storage.ensure_media_is_in_local_cache( input_path = await self.media_storage.ensure_media_is_in_local_cache(
FileInfo(None, media_id, url_cache=url_cache) FileInfo(None, media_id, url_cache=url_cache)
) )
try:
thumbnailer = Thumbnailer(input_path) thumbnailer = Thumbnailer(input_path)
except ThumbnailError as e:
logger.warning(
"Unable to generate a thumbnail for local media %s using a method of %s and type of %s: %s",
media_id,
t_method,
t_type,
e,
)
return None
t_byte_source = await defer_to_thread( t_byte_source = await defer_to_thread(
self.hs.get_reactor(), self.hs.get_reactor(),
self._generate_thumbnail, self._generate_thumbnail,
@ -506,14 +523,36 @@ class MediaRepository:
return output_path return output_path
# Could not generate thumbnail.
return None
async def generate_remote_exact_thumbnail( async def generate_remote_exact_thumbnail(
self, server_name, file_id, media_id, t_width, t_height, t_method, t_type self,
): server_name: str,
file_id: str,
media_id: str,
t_width: int,
t_height: int,
t_method: str,
t_type: str,
) -> Optional[str]:
input_path = await self.media_storage.ensure_media_is_in_local_cache( input_path = await self.media_storage.ensure_media_is_in_local_cache(
FileInfo(server_name, file_id, url_cache=False) FileInfo(server_name, file_id, url_cache=False)
) )
try:
thumbnailer = Thumbnailer(input_path) thumbnailer = Thumbnailer(input_path)
except ThumbnailError as e:
logger.warning(
"Unable to generate a thumbnail for remote media %s from %s using a method of %s and type of %s: %s",
media_id,
server_name,
t_method,
t_type,
e,
)
return None
t_byte_source = await defer_to_thread( t_byte_source = await defer_to_thread(
self.hs.get_reactor(), self.hs.get_reactor(),
self._generate_thumbnail, self._generate_thumbnail,
@ -559,6 +598,9 @@ class MediaRepository:
return output_path return output_path
# Could not generate thumbnail.
return None
async def _generate_thumbnails( async def _generate_thumbnails(
self, self,
server_name: Optional[str], server_name: Optional[str],
@ -590,7 +632,18 @@ class MediaRepository:
FileInfo(server_name, file_id, url_cache=url_cache) FileInfo(server_name, file_id, url_cache=url_cache)
) )
try:
thumbnailer = Thumbnailer(input_path) thumbnailer = Thumbnailer(input_path)
except ThumbnailError as e:
logger.warning(
"Unable to generate thumbnails for remote media %s from %s using a method of %s and type of %s: %s",
media_id,
server_name,
media_type,
e,
)
return None
m_width = thumbnailer.width m_width = thumbnailer.width
m_height = thumbnailer.height m_height = thumbnailer.height

View file

@ -16,6 +16,7 @@
import logging import logging
from synapse.api.errors import SynapseError
from synapse.http.server import DirectServeJsonResource, set_cors_headers from synapse.http.server import DirectServeJsonResource, set_cors_headers
from synapse.http.servlet import parse_integer, parse_string from synapse.http.servlet import parse_integer, parse_string
@ -173,7 +174,7 @@ class ThumbnailResource(DirectServeJsonResource):
await respond_with_file(request, desired_type, file_path) await respond_with_file(request, desired_type, file_path)
else: else:
logger.warning("Failed to generate thumbnail") logger.warning("Failed to generate thumbnail")
respond_404(request) raise SynapseError(400, "Failed to generate thumbnail.")
async def _select_or_generate_remote_thumbnail( async def _select_or_generate_remote_thumbnail(
self, self,
@ -235,7 +236,7 @@ class ThumbnailResource(DirectServeJsonResource):
await respond_with_file(request, desired_type, file_path) await respond_with_file(request, desired_type, file_path)
else: else:
logger.warning("Failed to generate thumbnail") logger.warning("Failed to generate thumbnail")
respond_404(request) raise SynapseError(400, "Failed to generate thumbnail.")
async def _respond_remote_thumbnail( async def _respond_remote_thumbnail(
self, request, server_name, media_id, width, height, method, m_type self, request, server_name, media_id, width, height, method, m_type

View file

@ -15,7 +15,7 @@
import logging import logging
from io import BytesIO from io import BytesIO
from PIL import Image as Image from PIL import Image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -31,12 +31,22 @@ EXIF_TRANSPOSE_MAPPINGS = {
} }
class ThumbnailError(Exception):
"""An error occurred generating a thumbnail."""
class Thumbnailer: class Thumbnailer:
FORMATS = {"image/jpeg": "JPEG", "image/png": "PNG"} FORMATS = {"image/jpeg": "JPEG", "image/png": "PNG"}
def __init__(self, input_path): def __init__(self, input_path):
try:
self.image = Image.open(input_path) self.image = Image.open(input_path)
except OSError as e:
# If an error occurs opening the image, a thumbnail won't be able to
# be generated.
raise ThumbnailError from e
self.width, self.height = self.image.size self.width, self.height = self.image.size
self.transpose_method = None self.transpose_method = None
try: try:

View file

@ -120,12 +120,13 @@ class _TestImage:
extension = attr.ib(type=bytes) extension = attr.ib(type=bytes)
expected_cropped = attr.ib(type=Optional[bytes]) expected_cropped = attr.ib(type=Optional[bytes])
expected_scaled = attr.ib(type=Optional[bytes]) expected_scaled = attr.ib(type=Optional[bytes])
expected_found = attr.ib(default=True, type=bool)
@parameterized_class( @parameterized_class(
("test_image",), ("test_image",),
[ [
# smol png # smoll png
( (
_TestImage( _TestImage(
unhexlify( unhexlify(
@ -161,6 +162,8 @@ class _TestImage:
None, None,
), ),
), ),
# an empty file
(_TestImage(b"", b"image/gif", b".gif", None, None, False,),),
], ],
) )
class MediaRepoTests(unittest.HomeserverTestCase): class MediaRepoTests(unittest.HomeserverTestCase):
@ -303,12 +306,16 @@ class MediaRepoTests(unittest.HomeserverTestCase):
self.assertEqual(headers.getRawHeaders(b"Content-Disposition"), None) self.assertEqual(headers.getRawHeaders(b"Content-Disposition"), None)
def test_thumbnail_crop(self): def test_thumbnail_crop(self):
self._test_thumbnail("crop", self.test_image.expected_cropped) self._test_thumbnail(
"crop", self.test_image.expected_cropped, self.test_image.expected_found
)
def test_thumbnail_scale(self): def test_thumbnail_scale(self):
self._test_thumbnail("scale", self.test_image.expected_scaled) self._test_thumbnail(
"scale", self.test_image.expected_scaled, self.test_image.expected_found
)
def _test_thumbnail(self, method, expected_body): def _test_thumbnail(self, method, expected_body, expected_found):
params = "?width=32&height=32&method=" + method params = "?width=32&height=32&method=" + method
request, channel = self.make_request( request, channel = self.make_request(
"GET", self.media_id + params, shorthand=False "GET", self.media_id + params, shorthand=False
@ -325,6 +332,7 @@ class MediaRepoTests(unittest.HomeserverTestCase):
) )
self.pump() self.pump()
if expected_found:
self.assertEqual(channel.code, 200) self.assertEqual(channel.code, 200)
if expected_body is not None: if expected_body is not None:
self.assertEqual( self.assertEqual(
@ -333,3 +341,14 @@ 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"]))
else:
# A 404 with a JSON body.
self.assertEqual(channel.code, 404)
self.assertEqual(
channel.json_body,
{
"errcode": "M_NOT_FOUND",
"error": "Not found [b'example.com', b'12345?width=32&height=32&method=%s']"
% method,
},
)