Skip to content

Commit 2b3b7ab

Browse files
committed
Media repository callbacks for the module API to control file upload size
Adds new callbacks for media related functionality: - get_media_config_for_user - is_user_allowed_to_upload_media_of_size
1 parent 2436512 commit 2b3b7ab

File tree

11 files changed

+282
-9
lines changed

11 files changed

+282
-9
lines changed

changelog.d/18457.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add new module API callbacks that allows overriding of media repository maximum upload size.

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
- [Background update controller callbacks](modules/background_update_controller_callbacks.md)
5050
- [Account data callbacks](modules/account_data_callbacks.md)
5151
- [Add extra fields to client events unsigned section callbacks](modules/add_extra_fields_to_client_events_unsigned.md)
52+
- [Media repository](modules/media_repository_callbacks.md)
5253
- [Porting a legacy module to the new interface](modules/porting_legacy_module.md)
5354
- [Workers](workers.md)
5455
- [Using `synctl` with Workers](synctl_workers.md)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Media repository callbacks
2+
3+
Media repository callbacks allow module developers to customise the behaviour of the
4+
media repository on a per user basis. Media repository callbacks can be registered
5+
using the module API's `register_media_repository_callbacks` method.
6+
7+
The available media repository callbacks are:
8+
9+
### `get_media_config_for_user`
10+
11+
_First introduced in Synapse v1.X.X_
12+
13+
```python
14+
async def get_media_config_for_user(user: str) -> Optional[JsonDict]
15+
```
16+
17+
Called when processing a request from a client for the configuration of the content
18+
repository. The module can return a JSON dictionary that should be returned for the use
19+
or `None` if the module is happy for the default dictionary to be used. The user is
20+
represented by their Matrix user ID (e.g. `@alice:example.com`).
21+
22+
If multiple modules implement this callback, they will be considered in order. If a
23+
callback returns `None`, Synapse falls through to the next one. The value of the first
24+
callback that does not return `None` will be used. If this happens, Synapse will not call
25+
any of the subsequent implementations of this callback.
26+
27+
If no module returns a non-`None` value then the default configuration will be returned.
28+
29+
### `is_user_allowed_to_upload_media_of_size`
30+
31+
_First introduced in Synapse v1.X.X_
32+
33+
```python
34+
async def is_user_allowed_to_upload_media_of_size(user: str, size: int) -> bool
35+
```
36+
37+
Called before media is accepted for upload from a user, in case the module needs to
38+
enforce a different limit for the particular user. The user is represented by their Matrix
39+
user ID. The size is in bytes.
40+
41+
If the module returns `False`, the current request will be denied with the error code
42+
`M_TOO_LARGE` and the HTTP status code 413.
43+
44+
If multiple modules implement this callback, they will be considered in order. If a callback
45+
returns `True`, Synapse falls through to the next one. The value of the first callback that
46+
returns `False` will be used. If this happens, Synapse will not call any of the subsequent
47+
implementations of this callback.

synapse/module_api/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@
9090
ON_USER_LOGIN_CALLBACK,
9191
ON_USER_REGISTRATION_CALLBACK,
9292
)
93+
from synapse.module_api.callbacks.media_repository_callbacks import (
94+
GET_MEDIA_CONFIG_FOR_USER_CALLBACK,
95+
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK,
96+
)
9397
from synapse.module_api.callbacks.spamchecker_callbacks import (
9498
CHECK_EVENT_FOR_SPAM_CALLBACK,
9599
CHECK_LOGIN_FOR_SPAM_CALLBACK,
@@ -360,6 +364,22 @@ def register_account_validity_callbacks(
360364
on_legacy_admin_request=on_legacy_admin_request,
361365
)
362366

367+
def register_media_repository_callbacks(
368+
self,
369+
*,
370+
get_media_config_for_user: Optional[GET_MEDIA_CONFIG_FOR_USER_CALLBACK] = None,
371+
is_user_allowed_to_upload_media_of_size: Optional[
372+
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
373+
] = None,
374+
) -> None:
375+
"""Registers callbacks for media repository capabilities.
376+
Added in Synapse v1.x.x.
377+
"""
378+
return self._callbacks.media_repository.register_callbacks(
379+
get_media_config_for_user=get_media_config_for_user,
380+
is_user_allowed_to_upload_media_of_size=is_user_allowed_to_upload_media_of_size,
381+
)
382+
363383
def register_third_party_rules_callbacks(
364384
self,
365385
*,

synapse/module_api/callbacks/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
from synapse.module_api.callbacks.account_validity_callbacks import (
2828
AccountValidityModuleApiCallbacks,
2929
)
30+
from synapse.module_api.callbacks.media_repository_callbacks import (
31+
MediaRepositoryModuleApiCallbacks,
32+
)
3033
from synapse.module_api.callbacks.spamchecker_callbacks import (
3134
SpamCheckerModuleApiCallbacks,
3235
)
@@ -38,5 +41,6 @@
3841
class ModuleApiCallbacks:
3942
def __init__(self, hs: "HomeServer") -> None:
4043
self.account_validity = AccountValidityModuleApiCallbacks()
44+
self.media_repository = MediaRepositoryModuleApiCallbacks(hs)
4145
self.spam_checker = SpamCheckerModuleApiCallbacks(hs)
4246
self.third_party_event_rules = ThirdPartyEventRulesModuleApiCallbacks(hs)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2025 New Vector, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
13+
#
14+
15+
import logging
16+
from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional
17+
18+
from synapse.types import JsonDict
19+
from synapse.util.async_helpers import delay_cancellation
20+
from synapse.util.metrics import Measure
21+
22+
if TYPE_CHECKING:
23+
from synapse.server import HomeServer
24+
25+
logger = logging.getLogger(__name__)
26+
27+
GET_MEDIA_CONFIG_FOR_USER_CALLBACK = Callable[[str], Awaitable[Optional[JsonDict]]]
28+
29+
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK = Callable[[str, int], Awaitable[bool]]
30+
31+
32+
class MediaRepositoryModuleApiCallbacks:
33+
def __init__(self, hs: "HomeServer") -> None:
34+
self.clock = hs.get_clock()
35+
self._get_media_config_for_user_callbacks: List[
36+
GET_MEDIA_CONFIG_FOR_USER_CALLBACK
37+
] = []
38+
self._is_user_allowed_to_upload_media_of_size_callbacks: List[
39+
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
40+
] = []
41+
42+
def register_callbacks(
43+
self,
44+
get_media_config_for_user: Optional[GET_MEDIA_CONFIG_FOR_USER_CALLBACK] = None,
45+
is_user_allowed_to_upload_media_of_size: Optional[
46+
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
47+
] = None,
48+
) -> None:
49+
"""Register callbacks from module for each hook."""
50+
if get_media_config_for_user is not None:
51+
self._get_media_config_for_user_callbacks.append(get_media_config_for_user)
52+
53+
if is_user_allowed_to_upload_media_of_size is not None:
54+
self._is_user_allowed_to_upload_media_of_size_callbacks.append(
55+
is_user_allowed_to_upload_media_of_size
56+
)
57+
58+
async def get_media_config_for_user(self, user_id: str) -> Optional[JsonDict]:
59+
for callback in self._get_media_config_for_user_callbacks:
60+
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
61+
res: Optional[JsonDict] = await delay_cancellation(callback(user_id))
62+
if res:
63+
return res
64+
65+
return None
66+
67+
async def is_user_allowed_to_upload_media_of_size(
68+
self, user_id: str, size: int
69+
) -> bool:
70+
for callback in self._is_user_allowed_to_upload_media_of_size_callbacks:
71+
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
72+
res: bool = await delay_cancellation(callback(user_id, size))
73+
if not res:
74+
return res
75+
76+
return True

synapse/rest/client/media.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,17 @@ def __init__(self, hs: "HomeServer"):
102102
self.clock = hs.get_clock()
103103
self.auth = hs.get_auth()
104104
self.limits_dict = {"m.upload.size": config.media.max_upload_size}
105+
self.media_repository_callbacks = hs.get_module_api_callbacks().media_repository
105106

106107
async def on_GET(self, request: SynapseRequest) -> None:
107-
await self.auth.get_user_by_req(request)
108-
respond_with_json(request, 200, self.limits_dict, send_cors=True)
108+
requester = await self.auth.get_user_by_req(request)
109+
user_specific_config = (
110+
await self.media_repository_callbacks.get_media_config_for_user(
111+
requester.user.to_string(),
112+
)
113+
)
114+
response = user_specific_config if user_specific_config else self.limits_dict
115+
respond_with_json(request, 200, response, send_cors=True)
109116

110117

111118
class ThumbnailResource(RestServlet):

synapse/rest/media/config_resource.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,14 @@ def __init__(self, hs: "HomeServer"):
4040
self.clock = hs.get_clock()
4141
self.auth = hs.get_auth()
4242
self.limits_dict = {"m.upload.size": config.media.max_upload_size}
43+
self.media_repository_callbacks = hs.get_module_api_callbacks().media_repository
4344

4445
async def on_GET(self, request: SynapseRequest) -> None:
45-
await self.auth.get_user_by_req(request)
46-
respond_with_json(request, 200, self.limits_dict, send_cors=True)
46+
requester = await self.auth.get_user_by_req(request)
47+
user_specific_config = (
48+
await self.media_repository_callbacks.get_media_config_for_user(
49+
requester.user.to_string()
50+
)
51+
)
52+
response = user_specific_config if user_specific_config else self.limits_dict
53+
respond_with_json(request, 200, response, send_cors=True)

synapse/rest/media/upload_resource.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@ def __init__(self, hs: "HomeServer", media_repo: "MediaRepository"):
5050
self.server_name = hs.hostname
5151
self.auth = hs.get_auth()
5252
self.max_upload_size = hs.config.media.max_upload_size
53+
self._media_repository_callbacks = (
54+
hs.get_module_api_callbacks().media_repository
55+
)
5356

54-
def _get_file_metadata(
55-
self, request: SynapseRequest
57+
async def _get_file_metadata(
58+
self, request: SynapseRequest, user_id: str
5659
) -> Tuple[int, Optional[str], str]:
5760
raw_content_length = request.getHeader("Content-Length")
5861
if raw_content_length is None:
@@ -67,7 +70,14 @@ def _get_file_metadata(
6770
code=413,
6871
errcode=Codes.TOO_LARGE,
6972
)
70-
73+
if not await self._media_repository_callbacks.is_user_allowed_to_upload_media_of_size(
74+
user_id, content_length
75+
):
76+
raise SynapseError(
77+
msg="Upload request body is too large",
78+
code=413,
79+
errcode=Codes.TOO_LARGE,
80+
)
7181
args: Dict[bytes, List[bytes]] = request.args # type: ignore
7282
upload_name_bytes = parse_bytes_from_args(args, "filename")
7383
if upload_name_bytes:
@@ -104,7 +114,9 @@ class UploadServlet(BaseUploadServlet):
104114

105115
async def on_POST(self, request: SynapseRequest) -> None:
106116
requester = await self.auth.get_user_by_req(request)
107-
content_length, upload_name, media_type = self._get_file_metadata(request)
117+
content_length, upload_name, media_type = await self._get_file_metadata(
118+
request, requester.user.to_string()
119+
)
108120

109121
try:
110122
content: IO = request.content # type: ignore
@@ -152,7 +164,9 @@ async def on_PUT(
152164

153165
async with lock:
154166
await self.media_repo.verify_can_upload(media_id, requester.user)
155-
content_length, upload_name, media_type = self._get_file_metadata(request)
167+
content_length, upload_name, media_type = await self._get_file_metadata(
168+
request, requester.user.to_string()
169+
)
156170

157171
try:
158172
content: IO = request.content # type: ignore

tests/media/test_media_storage.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,3 +1360,42 @@ async def _send_request(*args: Any, **kwargs: Any) -> IResponse:
13601360
store_media.sha256,
13611361
SMALL_PNG_SHA256,
13621362
)
1363+
1364+
1365+
class MediaRepoSizeModuleCallbackTestCase(unittest.HomeserverTestCase):
1366+
servlets = [
1367+
login.register_servlets,
1368+
admin.register_servlets,
1369+
]
1370+
1371+
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
1372+
self.user = self.register_user("user", "pass")
1373+
self.tok = self.login("user", "pass")
1374+
self.mock_result = True # Allow all uploads by default
1375+
1376+
hs.get_module_api().register_media_repository_callbacks(
1377+
is_user_allowed_to_upload_media_of_size=self.is_user_allowed_to_upload_media_of_size,
1378+
)
1379+
1380+
def create_resource_dict(self) -> Dict[str, Resource]:
1381+
resources = super().create_resource_dict()
1382+
resources["/_matrix/media"] = self.hs.get_media_repository_resource()
1383+
return resources
1384+
1385+
async def is_user_allowed_to_upload_media_of_size(
1386+
self, user_id: str, size: int
1387+
) -> bool:
1388+
self.last_user_id = user_id
1389+
self.last_size = size
1390+
return self.mock_result
1391+
1392+
def test_upload_allowed(self) -> None:
1393+
self.helper.upload_media(SMALL_PNG, tok=self.tok, expect_code=200)
1394+
assert self.last_user_id == self.user
1395+
assert self.last_size == len(SMALL_PNG)
1396+
1397+
def test_upload_not_allowed(self) -> None:
1398+
self.mock_result = False
1399+
self.helper.upload_media(SMALL_PNG, tok=self.tok, expect_code=413)
1400+
assert self.last_user_id == self.user
1401+
assert self.last_size == len(SMALL_PNG)

0 commit comments

Comments
 (0)