Skip to content

Commit a023538

Browse files
authored
Re-introduce federation /download endpoint (#17350)
1 parent f79dbd0 commit a023538

File tree

8 files changed

+588
-11
lines changed

8 files changed

+588
-11
lines changed

changelog.d/17350.feature

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
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 a federation /download endpoint.

synapse/federation/transport/server/__init__.py

+8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
FEDERATION_SERVLET_CLASSES,
3434
FederationAccountStatusServlet,
3535
FederationUnstableClientKeysClaimServlet,
36+
FederationUnstableMediaDownloadServlet,
3637
)
3738
from synapse.http.server import HttpServer, JsonResource
3839
from synapse.http.servlet import (
@@ -315,6 +316,13 @@ def register_servlets(
315316
):
316317
continue
317318

319+
if servletclass == FederationUnstableMediaDownloadServlet:
320+
if (
321+
not hs.config.server.enable_media_repo
322+
or not hs.config.experimental.msc3916_authenticated_media_enabled
323+
):
324+
continue
325+
318326
servletclass(
319327
hs=hs,
320328
authenticator=authenticator,

synapse/federation/transport/server/_base.py

+20-4
Original file line numberDiff line numberDiff line change
@@ -360,13 +360,29 @@ async def new_func(
360360
"request"
361361
)
362362
return None
363+
if (
364+
func.__self__.__class__.__name__ # type: ignore
365+
== "FederationUnstableMediaDownloadServlet"
366+
):
367+
response = await func(
368+
origin, content, request, *args, **kwargs
369+
)
370+
else:
371+
response = await func(
372+
origin, content, request.args, *args, **kwargs
373+
)
374+
else:
375+
if (
376+
func.__self__.__class__.__name__ # type: ignore
377+
== "FederationUnstableMediaDownloadServlet"
378+
):
379+
response = await func(
380+
origin, content, request, *args, **kwargs
381+
)
382+
else:
363383
response = await func(
364384
origin, content, request.args, *args, **kwargs
365385
)
366-
else:
367-
response = await func(
368-
origin, content, request.args, *args, **kwargs
369-
)
370386
finally:
371387
# if we used the origin's context as the parent, add a new span using
372388
# the servlet span as a parent, so that we have a link

synapse/federation/transport/server/federation.py

+41
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,13 @@
4444
)
4545
from synapse.http.servlet import (
4646
parse_boolean_from_args,
47+
parse_integer,
4748
parse_integer_from_args,
4849
parse_string_from_args,
4950
parse_strings_from_args,
5051
)
52+
from synapse.http.site import SynapseRequest
53+
from synapse.media._base import DEFAULT_MAX_TIMEOUT_MS, MAXIMUM_ALLOWED_MAX_TIMEOUT_MS
5154
from synapse.types import JsonDict
5255
from synapse.util import SYNAPSE_VERSION
5356
from synapse.util.ratelimitutils import FederationRateLimiter
@@ -787,6 +790,43 @@ async def on_POST(
787790
return 200, {"account_statuses": statuses, "failures": failures}
788791

789792

793+
class FederationUnstableMediaDownloadServlet(BaseFederationServerServlet):
794+
"""
795+
Implementation of new federation media `/download` endpoint outlined in MSC3916. Returns
796+
a multipart/mixed response consisting of a JSON object and the requested media
797+
item. This endpoint only returns local media.
798+
"""
799+
800+
PATH = "/media/download/(?P<media_id>[^/]*)"
801+
PREFIX = FEDERATION_UNSTABLE_PREFIX + "/org.matrix.msc3916"
802+
RATELIMIT = True
803+
804+
def __init__(
805+
self,
806+
hs: "HomeServer",
807+
ratelimiter: FederationRateLimiter,
808+
authenticator: Authenticator,
809+
server_name: str,
810+
):
811+
super().__init__(hs, authenticator, ratelimiter, server_name)
812+
self.media_repo = self.hs.get_media_repository()
813+
814+
async def on_GET(
815+
self,
816+
origin: Optional[str],
817+
content: Literal[None],
818+
request: SynapseRequest,
819+
media_id: str,
820+
) -> None:
821+
max_timeout_ms = parse_integer(
822+
request, "timeout_ms", default=DEFAULT_MAX_TIMEOUT_MS
823+
)
824+
max_timeout_ms = min(max_timeout_ms, MAXIMUM_ALLOWED_MAX_TIMEOUT_MS)
825+
await self.media_repo.get_local_media(
826+
request, media_id, None, max_timeout_ms, federation=True
827+
)
828+
829+
790830
FEDERATION_SERVLET_CLASSES: Tuple[Type[BaseFederationServlet], ...] = (
791831
FederationSendServlet,
792832
FederationEventServlet,
@@ -818,4 +858,5 @@ async def on_POST(
818858
FederationV1SendKnockServlet,
819859
FederationMakeKnockServlet,
820860
FederationAccountStatusServlet,
861+
FederationUnstableMediaDownloadServlet,
821862
)

synapse/media/_base.py

+77-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@
2525
import urllib
2626
from abc import ABC, abstractmethod
2727
from types import TracebackType
28-
from typing import Awaitable, Dict, Generator, List, Optional, Tuple, Type
28+
from typing import (
29+
TYPE_CHECKING,
30+
Awaitable,
31+
Dict,
32+
Generator,
33+
List,
34+
Optional,
35+
Tuple,
36+
Type,
37+
)
2938

3039
import attr
3140

@@ -37,8 +46,13 @@
3746
from synapse.http.server import finish_request, respond_with_json
3847
from synapse.http.site import SynapseRequest
3948
from synapse.logging.context import make_deferred_yieldable
49+
from synapse.util import Clock
4050
from synapse.util.stringutils import is_ascii
4151

52+
if TYPE_CHECKING:
53+
from synapse.storage.databases.main.media_repository import LocalMedia
54+
55+
4256
logger = logging.getLogger(__name__)
4357

4458
# list all text content types that will have the charset default to UTF-8 when
@@ -260,6 +274,68 @@ def _can_encode_filename_as_token(x: str) -> bool:
260274
return True
261275

262276

277+
async def respond_with_multipart_responder(
278+
clock: Clock,
279+
request: SynapseRequest,
280+
responder: "Optional[Responder]",
281+
media_info: "LocalMedia",
282+
) -> None:
283+
"""
284+
Responds to requests originating from the federation media `/download` endpoint by
285+
streaming a multipart/mixed response
286+
287+
Args:
288+
clock:
289+
request: the federation request to respond to
290+
responder: the responder which will send the response
291+
media_info: metadata about the media item
292+
"""
293+
if not responder:
294+
respond_404(request)
295+
return
296+
297+
# If we have a responder we *must* use it as a context manager.
298+
with responder:
299+
if request._disconnected:
300+
logger.warning(
301+
"Not sending response to request %s, already disconnected.", request
302+
)
303+
return
304+
305+
from synapse.media.media_storage import MultipartFileConsumer
306+
307+
# note that currently the json_object is just {}, this will change when linked media
308+
# is implemented
309+
multipart_consumer = MultipartFileConsumer(
310+
clock, request, media_info.media_type, {}, media_info.media_length
311+
)
312+
313+
logger.debug("Responding to media request with responder %s", responder)
314+
if media_info.media_length is not None:
315+
content_length = multipart_consumer.content_length()
316+
assert content_length is not None
317+
request.setHeader(b"Content-Length", b"%d" % (content_length,))
318+
319+
request.setHeader(
320+
b"Content-Type",
321+
b"multipart/mixed; boundary=%s" % multipart_consumer.boundary,
322+
)
323+
324+
try:
325+
await responder.write_to_consumer(multipart_consumer)
326+
except Exception as e:
327+
# The majority of the time this will be due to the client having gone
328+
# away. Unfortunately, Twisted simply throws a generic exception at us
329+
# in that case.
330+
logger.warning("Failed to write to consumer: %s %s", type(e), e)
331+
332+
# Unregister the producer, if it has one, so Twisted doesn't complain
333+
if request.producer:
334+
request.unregisterProducer()
335+
336+
finish_request(request)
337+
338+
263339
async def respond_with_responder(
264340
request: SynapseRequest,
265341
responder: "Optional[Responder]",

synapse/media/media_repository.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
ThumbnailInfo,
5555
get_filename_from_headers,
5656
respond_404,
57+
respond_with_multipart_responder,
5758
respond_with_responder,
5859
)
5960
from synapse.media.filepath import MediaFilePaths
@@ -429,6 +430,7 @@ async def get_local_media(
429430
media_id: str,
430431
name: Optional[str],
431432
max_timeout_ms: int,
433+
federation: bool = False,
432434
) -> None:
433435
"""Responds to requests for local media, if exists, or returns 404.
434436
@@ -440,6 +442,7 @@ async def get_local_media(
440442
the filename in the Content-Disposition header of the response.
441443
max_timeout_ms: the maximum number of milliseconds to wait for the
442444
media to be uploaded.
445+
federation: whether the local media being fetched is for a federation request
443446
444447
Returns:
445448
Resolves once a response has successfully been written to request
@@ -460,9 +463,14 @@ async def get_local_media(
460463
file_info = FileInfo(None, media_id, url_cache=bool(url_cache))
461464

462465
responder = await self.media_storage.fetch_media(file_info)
463-
await respond_with_responder(
464-
request, responder, media_type, media_length, upload_name
465-
)
466+
if federation:
467+
await respond_with_multipart_responder(
468+
self.clock, request, responder, media_info
469+
)
470+
else:
471+
await respond_with_responder(
472+
request, responder, media_type, media_length, upload_name
473+
)
466474

467475
async def get_remote_media(
468476
self,

0 commit comments

Comments
 (0)