Skip to content

Add media repository callbacks to module API to control media upload size #18457

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
- [Background update controller callbacks](modules/background_update_controller_callbacks.md)
- [Account data callbacks](modules/account_data_callbacks.md)
- [Add extra fields to client events unsigned section callbacks](modules/add_extra_fields_to_client_events_unsigned.md)
- [Media repository](modules/media_repository_callbacks.md)
- [Porting a legacy module to the new interface](modules/porting_legacy_module.md)
- [Workers](workers.md)
- [Using `synctl` with Workers](synctl_workers.md)
Expand Down
47 changes: 47 additions & 0 deletions docs/modules/media_repository_callbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Media repository callbacks

Media repository callbacks allow module developers to customise the behaviour of the
media repository on a per user basis. Media repository callbacks can be registered
using the module API's `register_media_repository_callbacks` method.

The available media repository callbacks are:

### `get_media_config_for_user`

_First introduced in Synapse v1.X.X_

```python
async def get_media_config_for_user(user: str) -> Optional[JsonDict]
```

Called when processing a request from a client for the configuration of the content
repository. The module can return a JSON dictionary that should be returned for the use
or `None` if the module is happy for the default dictionary to be used. The user is
represented by their Matrix user ID (e.g. `@alice:example.com`).

If multiple modules implement this callback, they will be considered in order. If a
callback returns `None`, Synapse falls through to the next one. The value of the first
callback that does not return `None` will be used. If this happens, Synapse will not call
any of the subsequent implementations of this callback.

If no module returns a non-`None` value then the default configuration will be returned.

### `is_user_allowed_to_upload_media_of_size`

_First introduced in Synapse v1.X.X_

```python
async def is_user_allowed_to_upload_media_of_size(user: str, size: int) -> bool
```

Called before media is accepted for upload from a user, in case the module needs to
enforce a different limit for the particular user. The user is represented by their Matrix
user ID. The size is in bytes.

If the module returns `False`, the current request will be denied with the error code
`M_TOO_LARGE` and the HTTP status code 413.

If multiple modules implement this callback, they will be considered in order. If a callback
returns `True`, Synapse falls through to the next one. The value of the first callback that
returns `False` will be used. If this happens, Synapse will not call any of the subsequent
implementations of this callback.
20 changes: 20 additions & 0 deletions synapse/module_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@
ON_USER_LOGIN_CALLBACK,
ON_USER_REGISTRATION_CALLBACK,
)
from synapse.module_api.callbacks.media_repository_callbacks import (
GET_MEDIA_CONFIG_FOR_USER_CALLBACK,
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK,
)
from synapse.module_api.callbacks.spamchecker_callbacks import (
CHECK_EVENT_FOR_SPAM_CALLBACK,
CHECK_LOGIN_FOR_SPAM_CALLBACK,
Expand Down Expand Up @@ -360,6 +364,22 @@ def register_account_validity_callbacks(
on_legacy_admin_request=on_legacy_admin_request,
)

def register_media_repository_callbacks(
self,
*,
get_media_config_for_user: Optional[GET_MEDIA_CONFIG_FOR_USER_CALLBACK] = None,
is_user_allowed_to_upload_media_of_size: Optional[
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
] = None,
) -> None:
"""Registers callbacks for media repository capabilities.
Added in Synapse v1.x.x.
"""
return self._callbacks.media_repository.register_callbacks(
get_media_config_for_user=get_media_config_for_user,
is_user_allowed_to_upload_media_of_size=is_user_allowed_to_upload_media_of_size,
)

def register_third_party_rules_callbacks(
self,
*,
Expand Down
4 changes: 4 additions & 0 deletions synapse/module_api/callbacks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
from synapse.module_api.callbacks.account_validity_callbacks import (
AccountValidityModuleApiCallbacks,
)
from synapse.module_api.callbacks.media_repository_callbacks import (
MediaRepositoryModuleApiCallbacks,
)
from synapse.module_api.callbacks.spamchecker_callbacks import (
SpamCheckerModuleApiCallbacks,
)
Expand All @@ -38,5 +41,6 @@
class ModuleApiCallbacks:
def __init__(self, hs: "HomeServer") -> None:
self.account_validity = AccountValidityModuleApiCallbacks()
self.media_repository = MediaRepositoryModuleApiCallbacks(hs)
self.spam_checker = SpamCheckerModuleApiCallbacks(hs)
self.third_party_event_rules = ThirdPartyEventRulesModuleApiCallbacks(hs)
76 changes: 76 additions & 0 deletions synapse/module_api/callbacks/media_repository_callbacks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#

import logging
from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional

from synapse.types import JsonDict
from synapse.util.async_helpers import delay_cancellation
from synapse.util.metrics import Measure

if TYPE_CHECKING:
from synapse.server import HomeServer

logger = logging.getLogger(__name__)

GET_MEDIA_CONFIG_FOR_USER_CALLBACK = Callable[[str], Awaitable[Optional[JsonDict]]]

IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK = Callable[[str, int], Awaitable[bool]]


class MediaRepositoryModuleApiCallbacks:
def __init__(self, hs: "HomeServer") -> None:
self.clock = hs.get_clock()
self._get_media_config_for_user_callbacks: List[
GET_MEDIA_CONFIG_FOR_USER_CALLBACK
] = []
self._is_user_allowed_to_upload_media_of_size_callbacks: List[
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
] = []

def register_callbacks(
self,
get_media_config_for_user: Optional[GET_MEDIA_CONFIG_FOR_USER_CALLBACK] = None,
is_user_allowed_to_upload_media_of_size: Optional[
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
] = None,
) -> None:
"""Register callbacks from module for each hook."""
if get_media_config_for_user is not None:
self._get_media_config_for_user_callbacks.append(get_media_config_for_user)

if is_user_allowed_to_upload_media_of_size is not None:
self._is_user_allowed_to_upload_media_of_size_callbacks.append(
is_user_allowed_to_upload_media_of_size
)

async def get_media_config_for_user(self, user_id: str) -> Optional[JsonDict]:
for callback in self._get_media_config_for_user_callbacks:
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
res: Optional[JsonDict] = await delay_cancellation(callback(user_id))
if res:
return res

return None

async def is_user_allowed_to_upload_media_of_size(
self, user_id: str, size: int
) -> bool:
for callback in self._is_user_allowed_to_upload_media_of_size_callbacks:
with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"):
res: bool = await delay_cancellation(callback(user_id, size))
if not res:
return res

return True
11 changes: 9 additions & 2 deletions synapse/rest/client/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,17 @@ def __init__(self, hs: "HomeServer"):
self.clock = hs.get_clock()
self.auth = hs.get_auth()
self.limits_dict = {"m.upload.size": config.media.max_upload_size}
self.media_repository_callbacks = hs.get_module_api_callbacks().media_repository

async def on_GET(self, request: SynapseRequest) -> None:
await self.auth.get_user_by_req(request)
respond_with_json(request, 200, self.limits_dict, send_cors=True)
requester = await self.auth.get_user_by_req(request)
user_specific_config = (
await self.media_repository_callbacks.get_media_config_for_user(
requester.user.to_string(),
)
)
response = user_specific_config if user_specific_config else self.limits_dict
respond_with_json(request, 200, response, send_cors=True)


class ThumbnailResource(RestServlet):
Expand Down
11 changes: 9 additions & 2 deletions synapse/rest/media/config_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ def __init__(self, hs: "HomeServer"):
self.clock = hs.get_clock()
self.auth = hs.get_auth()
self.limits_dict = {"m.upload.size": config.media.max_upload_size}
self.media_repository_callbacks = hs.get_module_api_callbacks().media_repository

async def on_GET(self, request: SynapseRequest) -> None:
await self.auth.get_user_by_req(request)
respond_with_json(request, 200, self.limits_dict, send_cors=True)
requester = await self.auth.get_user_by_req(request)
user_specific_config = (
await self.media_repository_callbacks.get_media_config_for_user(
requester.user.to_string()
)
)
response = user_specific_config if user_specific_config else self.limits_dict
respond_with_json(request, 200, response, send_cors=True)
24 changes: 19 additions & 5 deletions synapse/rest/media/upload_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,12 @@ def __init__(self, hs: "HomeServer", media_repo: "MediaRepository"):
self.server_name = hs.hostname
self.auth = hs.get_auth()
self.max_upload_size = hs.config.media.max_upload_size
self._media_repository_callbacks = (
hs.get_module_api_callbacks().media_repository
)

def _get_file_metadata(
self, request: SynapseRequest
async def _get_file_metadata(
self, request: SynapseRequest, user_id: str
) -> Tuple[int, Optional[str], str]:
raw_content_length = request.getHeader("Content-Length")
if raw_content_length is None:
Expand All @@ -67,7 +70,14 @@ def _get_file_metadata(
code=413,
errcode=Codes.TOO_LARGE,
)

if not await self._media_repository_callbacks.is_user_allowed_to_upload_media_of_size(
user_id, content_length
):
raise SynapseError(
msg="Upload request body is too large",
code=413,
errcode=Codes.TOO_LARGE,
)
args: Dict[bytes, List[bytes]] = request.args # type: ignore
upload_name_bytes = parse_bytes_from_args(args, "filename")
if upload_name_bytes:
Expand Down Expand Up @@ -104,7 +114,9 @@ class UploadServlet(BaseUploadServlet):

async def on_POST(self, request: SynapseRequest) -> None:
requester = await self.auth.get_user_by_req(request)
content_length, upload_name, media_type = self._get_file_metadata(request)
content_length, upload_name, media_type = await self._get_file_metadata(
request, requester.user.to_string()
)

try:
content: IO = request.content # type: ignore
Expand Down Expand Up @@ -152,7 +164,9 @@ async def on_PUT(

async with lock:
await self.media_repo.verify_can_upload(media_id, requester.user)
content_length, upload_name, media_type = self._get_file_metadata(request)
content_length, upload_name, media_type = await self._get_file_metadata(
request, requester.user.to_string()
)

try:
content: IO = request.content # type: ignore
Expand Down
Loading