Skip to content

Commit cf69f8d

Browse files
authored
Support MSC3916 by adding a federation /thumbnail endpoint and authenticated _matrix/client/v1/media/thumbnail endpoint (#17388)
[MSC3916](matrix-org/matrix-spec-proposals#3916) added the endpoints `_matrix/federation/v1/media/thumbnail` and the authenticated `_matrix/client/v1/media/thumbnail`. This PR implements those endpoints, along with stabilizing `_matrix/client/v1/media/config` and `_matrix/client/v1/media/preview_url`. Complement tests are at matrix-org/complement#728
1 parent 20de685 commit cf69f8d

File tree

12 files changed

+585
-131
lines changed

12 files changed

+585
-131
lines changed

changelog.d/17388.feature

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Support [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/blob/rav/authentication-for-media/proposals/3916-authentication-for-media.md)
2+
by adding `_matrix/client/v1/media/thumbnail`, `_matrix/federation/v1/media/thumbnail` endpoints and stabilizing the
3+
remaining `_matrix/client/v1/media` endpoints.

synapse/config/experimental.py

-4
Original file line numberDiff line numberDiff line change
@@ -437,10 +437,6 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
437437
"msc3823_account_suspension", False
438438
)
439439

440-
self.msc3916_authenticated_media_enabled = experimental.get(
441-
"msc3916_authenticated_media_enabled", False
442-
)
443-
444440
# MSC4151: Report room API (Client-Server API)
445441
self.msc4151_enabled: bool = experimental.get("msc4151_enabled", False)
446442

synapse/federation/transport/server/__init__.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
FEDERATION_SERVLET_CLASSES,
3434
FederationAccountStatusServlet,
3535
FederationMediaDownloadServlet,
36+
FederationMediaThumbnailServlet,
3637
FederationUnstableClientKeysClaimServlet,
3738
)
3839
from synapse.http.server import HttpServer, JsonResource
@@ -316,7 +317,10 @@ def register_servlets(
316317
):
317318
continue
318319

319-
if servletclass == FederationMediaDownloadServlet:
320+
if (
321+
servletclass == FederationMediaDownloadServlet
322+
or servletclass == FederationMediaThumbnailServlet
323+
):
320324
if not hs.config.server.enable_media_repo:
321325
continue
322326

synapse/federation/transport/server/_base.py

+4
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,8 @@ async def new_func(
363363
if (
364364
func.__self__.__class__.__name__ # type: ignore
365365
== "FederationMediaDownloadServlet"
366+
or func.__self__.__class__.__name__ # type: ignore
367+
== "FederationMediaThumbnailServlet"
366368
):
367369
response = await func(
368370
origin, content, request, *args, **kwargs
@@ -375,6 +377,8 @@ async def new_func(
375377
if (
376378
func.__self__.__class__.__name__ # type: ignore
377379
== "FederationMediaDownloadServlet"
380+
or func.__self__.__class__.__name__ # type: ignore
381+
== "FederationMediaThumbnailServlet"
378382
):
379383
response = await func(
380384
origin, content, request, *args, **kwargs

synapse/federation/transport/server/federation.py

+56
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,13 @@
4646
parse_boolean_from_args,
4747
parse_integer,
4848
parse_integer_from_args,
49+
parse_string,
4950
parse_string_from_args,
5051
parse_strings_from_args,
5152
)
5253
from synapse.http.site import SynapseRequest
5354
from synapse.media._base import DEFAULT_MAX_TIMEOUT_MS, MAXIMUM_ALLOWED_MAX_TIMEOUT_MS
55+
from synapse.media.thumbnailer import ThumbnailProvider
5456
from synapse.types import JsonDict
5557
from synapse.util import SYNAPSE_VERSION
5658
from synapse.util.ratelimitutils import FederationRateLimiter
@@ -826,6 +828,59 @@ async def on_GET(
826828
)
827829

828830

831+
class FederationMediaThumbnailServlet(BaseFederationServerServlet):
832+
"""
833+
Implementation of new federation media `/thumbnail` endpoint outlined in MSC3916. Returns
834+
a multipart/mixed response consisting of a JSON object and the requested media
835+
item. This endpoint only returns local media.
836+
"""
837+
838+
PATH = "/media/thumbnail/(?P<media_id>[^/]*)"
839+
RATELIMIT = True
840+
841+
def __init__(
842+
self,
843+
hs: "HomeServer",
844+
ratelimiter: FederationRateLimiter,
845+
authenticator: Authenticator,
846+
server_name: str,
847+
):
848+
super().__init__(hs, authenticator, ratelimiter, server_name)
849+
self.media_repo = self.hs.get_media_repository()
850+
self.dynamic_thumbnails = hs.config.media.dynamic_thumbnails
851+
self.thumbnail_provider = ThumbnailProvider(
852+
hs, self.media_repo, self.media_repo.media_storage
853+
)
854+
855+
async def on_GET(
856+
self,
857+
origin: Optional[str],
858+
content: Literal[None],
859+
request: SynapseRequest,
860+
media_id: str,
861+
) -> None:
862+
863+
width = parse_integer(request, "width", required=True)
864+
height = parse_integer(request, "height", required=True)
865+
method = parse_string(request, "method", "scale")
866+
# TODO Parse the Accept header to get an prioritised list of thumbnail types.
867+
m_type = "image/png"
868+
max_timeout_ms = parse_integer(
869+
request, "timeout_ms", default=DEFAULT_MAX_TIMEOUT_MS
870+
)
871+
max_timeout_ms = min(max_timeout_ms, MAXIMUM_ALLOWED_MAX_TIMEOUT_MS)
872+
873+
if self.dynamic_thumbnails:
874+
await self.thumbnail_provider.select_or_generate_local_thumbnail(
875+
request, media_id, width, height, method, m_type, max_timeout_ms, True
876+
)
877+
else:
878+
await self.thumbnail_provider.respond_local_thumbnail(
879+
request, media_id, width, height, method, m_type, max_timeout_ms, True
880+
)
881+
self.media_repo.mark_recently_accessed(None, media_id)
882+
883+
829884
FEDERATION_SERVLET_CLASSES: Tuple[Type[BaseFederationServlet], ...] = (
830885
FederationSendServlet,
831886
FederationEventServlet,
@@ -858,4 +913,5 @@ async def on_GET(
858913
FederationMakeKnockServlet,
859914
FederationAccountStatusServlet,
860915
FederationMediaDownloadServlet,
916+
FederationMediaThumbnailServlet,
861917
)

synapse/media/media_repository.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,12 @@ async def get_remote_media(
542542
respond_404(request)
543543

544544
async def get_remote_media_info(
545-
self, server_name: str, media_id: str, max_timeout_ms: int, ip_address: str
545+
self,
546+
server_name: str,
547+
media_id: str,
548+
max_timeout_ms: int,
549+
ip_address: str,
550+
use_federation: bool,
546551
) -> RemoteMedia:
547552
"""Gets the media info associated with the remote file, downloading
548553
if necessary.
@@ -553,6 +558,8 @@ async def get_remote_media_info(
553558
max_timeout_ms: the maximum number of milliseconds to wait for the
554559
media to be uploaded.
555560
ip_address: IP address of the requester
561+
use_federation: if a download is necessary, whether to request the remote file
562+
over the federation `/download` endpoint
556563
557564
Returns:
558565
The media info of the file
@@ -573,7 +580,7 @@ async def get_remote_media_info(
573580
max_timeout_ms,
574581
self.download_ratelimiter,
575582
ip_address,
576-
False,
583+
use_federation,
577584
)
578585

579586
# Ensure we actually use the responder so that it releases resources

synapse/media/thumbnailer.py

+61-21
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@
3636
ThumbnailInfo,
3737
respond_404,
3838
respond_with_file,
39+
respond_with_multipart_responder,
3940
respond_with_responder,
4041
)
41-
from synapse.media.media_storage import MediaStorage
42+
from synapse.media.media_storage import FileResponder, MediaStorage
43+
from synapse.storage.databases.main.media_repository import LocalMedia
4244

4345
if TYPE_CHECKING:
4446
from synapse.media.media_repository import MediaRepository
@@ -271,6 +273,7 @@ async def respond_local_thumbnail(
271273
method: str,
272274
m_type: str,
273275
max_timeout_ms: int,
276+
for_federation: bool,
274277
) -> None:
275278
media_info = await self.media_repo.get_local_media_info(
276279
request, media_id, max_timeout_ms
@@ -290,6 +293,8 @@ async def respond_local_thumbnail(
290293
media_id,
291294
url_cache=bool(media_info.url_cache),
292295
server_name=None,
296+
for_federation=for_federation,
297+
media_info=media_info,
293298
)
294299

295300
async def select_or_generate_local_thumbnail(
@@ -301,6 +306,7 @@ async def select_or_generate_local_thumbnail(
301306
desired_method: str,
302307
desired_type: str,
303308
max_timeout_ms: int,
309+
for_federation: bool,
304310
) -> None:
305311
media_info = await self.media_repo.get_local_media_info(
306312
request, media_id, max_timeout_ms
@@ -326,10 +332,16 @@ async def select_or_generate_local_thumbnail(
326332

327333
responder = await self.media_storage.fetch_media(file_info)
328334
if responder:
329-
await respond_with_responder(
330-
request, responder, info.type, info.length
331-
)
332-
return
335+
if for_federation:
336+
await respond_with_multipart_responder(
337+
self.hs.get_clock(), request, responder, media_info
338+
)
339+
return
340+
else:
341+
await respond_with_responder(
342+
request, responder, info.type, info.length
343+
)
344+
return
333345

334346
logger.debug("We don't have a thumbnail of that size. Generating")
335347

@@ -344,7 +356,15 @@ async def select_or_generate_local_thumbnail(
344356
)
345357

346358
if file_path:
347-
await respond_with_file(request, desired_type, file_path)
359+
if for_federation:
360+
await respond_with_multipart_responder(
361+
self.hs.get_clock(),
362+
request,
363+
FileResponder(open(file_path, "rb")),
364+
media_info,
365+
)
366+
else:
367+
await respond_with_file(request, desired_type, file_path)
348368
else:
349369
logger.warning("Failed to generate thumbnail")
350370
raise SynapseError(400, "Failed to generate thumbnail.")
@@ -360,9 +380,10 @@ async def select_or_generate_remote_thumbnail(
360380
desired_type: str,
361381
max_timeout_ms: int,
362382
ip_address: str,
383+
use_federation: bool,
363384
) -> None:
364385
media_info = await self.media_repo.get_remote_media_info(
365-
server_name, media_id, max_timeout_ms, ip_address
386+
server_name, media_id, max_timeout_ms, ip_address, use_federation
366387
)
367388
if not media_info:
368389
respond_404(request)
@@ -424,12 +445,13 @@ async def respond_remote_thumbnail(
424445
m_type: str,
425446
max_timeout_ms: int,
426447
ip_address: str,
448+
use_federation: bool,
427449
) -> None:
428450
# TODO: Don't download the whole remote file
429451
# We should proxy the thumbnail from the remote server instead of
430452
# downloading the remote file and generating our own thumbnails.
431453
media_info = await self.media_repo.get_remote_media_info(
432-
server_name, media_id, max_timeout_ms, ip_address
454+
server_name, media_id, max_timeout_ms, ip_address, use_federation
433455
)
434456
if not media_info:
435457
return
@@ -448,6 +470,7 @@ async def respond_remote_thumbnail(
448470
media_info.filesystem_id,
449471
url_cache=False,
450472
server_name=server_name,
473+
for_federation=False,
451474
)
452475

453476
async def _select_and_respond_with_thumbnail(
@@ -461,7 +484,9 @@ async def _select_and_respond_with_thumbnail(
461484
media_id: str,
462485
file_id: str,
463486
url_cache: bool,
487+
for_federation: bool,
464488
server_name: Optional[str] = None,
489+
media_info: Optional[LocalMedia] = None,
465490
) -> None:
466491
"""
467492
Respond to a request with an appropriate thumbnail from the previously generated thumbnails.
@@ -476,6 +501,8 @@ async def _select_and_respond_with_thumbnail(
476501
file_id: The ID of the media that a thumbnail is being requested for.
477502
url_cache: True if this is from a URL cache.
478503
server_name: The server name, if this is a remote thumbnail.
504+
for_federation: whether the request is from the federation /thumbnail request
505+
media_info: metadata about the media being requested.
479506
"""
480507
logger.debug(
481508
"_select_and_respond_with_thumbnail: media_id=%s desired=%sx%s (%s) thumbnail_infos=%s",
@@ -511,13 +538,20 @@ async def _select_and_respond_with_thumbnail(
511538

512539
responder = await self.media_storage.fetch_media(file_info)
513540
if responder:
514-
await respond_with_responder(
515-
request,
516-
responder,
517-
file_info.thumbnail.type,
518-
file_info.thumbnail.length,
519-
)
520-
return
541+
if for_federation:
542+
assert media_info is not None
543+
await respond_with_multipart_responder(
544+
self.hs.get_clock(), request, responder, media_info
545+
)
546+
return
547+
else:
548+
await respond_with_responder(
549+
request,
550+
responder,
551+
file_info.thumbnail.type,
552+
file_info.thumbnail.length,
553+
)
554+
return
521555

522556
# If we can't find the thumbnail we regenerate it. This can happen
523557
# if e.g. we've deleted the thumbnails but still have the original
@@ -558,12 +592,18 @@ async def _select_and_respond_with_thumbnail(
558592
)
559593

560594
responder = await self.media_storage.fetch_media(file_info)
561-
await respond_with_responder(
562-
request,
563-
responder,
564-
file_info.thumbnail.type,
565-
file_info.thumbnail.length,
566-
)
595+
if for_federation:
596+
assert media_info is not None
597+
await respond_with_multipart_responder(
598+
self.hs.get_clock(), request, responder, media_info
599+
)
600+
else:
601+
await respond_with_responder(
602+
request,
603+
responder,
604+
file_info.thumbnail.type,
605+
file_info.thumbnail.length,
606+
)
567607
else:
568608
# This might be because:
569609
# 1. We can't create thumbnails for the given media (corrupted or

0 commit comments

Comments
 (0)