Skip to content

Commit e1036ff

Browse files
Add get_media_upload_limits_for_user and on_media_upload_limit_exceeded callbacks to module API (#18848)
Co-authored-by: Andrew Morgan <[email protected]>
1 parent 8c98cf7 commit e1036ff

File tree

9 files changed

+389
-8
lines changed

9 files changed

+389
-8
lines changed

changelog.d/18848.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `get_media_upload_limits_for_user` and `on_media_upload_limit_exceeded` module API callbacks for media repository.

docs/modules/media_repository_callbacks.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,68 @@ If multiple modules implement this callback, they will be considered in order. I
6464
returns `True`, Synapse falls through to the next one. The value of the first callback that
6565
returns `False` will be used. If this happens, Synapse will not call any of the subsequent
6666
implementations of this callback.
67+
68+
### `get_media_upload_limits_for_user`
69+
70+
_First introduced in Synapse v1.139.0_
71+
72+
```python
73+
async def get_media_upload_limits_for_user(user_id: str, size: int) -> Optional[List[synapse.module_api.MediaUploadLimit]]
74+
```
75+
76+
**<span style="color:red">
77+
Caution: This callback is currently experimental. The method signature or behaviour
78+
may change without notice.
79+
</span>**
80+
81+
Called when processing a request to store content in the media repository. This can be used to dynamically override
82+
the [media upload limits configuration](../usage/configuration/config_documentation.html#media_upload_limits).
83+
84+
The arguments passed to this callback are:
85+
86+
* `user_id`: The Matrix user ID of the user (e.g. `@alice:example.com`) making the request.
87+
88+
If the callback returns a list then it will be used as the limits instead of those in the configuration (if any).
89+
90+
If an empty list is returned then no limits are applied (**warning:** users will be able
91+
to upload as much data as they desire).
92+
93+
If multiple modules implement this callback, they will be considered in order. If a
94+
callback returns `None`, Synapse falls through to the next one. The value of the first
95+
callback that does not return `None` will be used. If this happens, Synapse will not call
96+
any of the subsequent implementations of this callback.
97+
98+
If there are no registered modules, or if all modules return `None`, then
99+
the default
100+
[media upload limits configuration](../usage/configuration/config_documentation.html#media_upload_limits)
101+
will be used.
102+
103+
### `on_media_upload_limit_exceeded`
104+
105+
_First introduced in Synapse v1.139.0_
106+
107+
```python
108+
async def on_media_upload_limit_exceeded(user_id: str, limit: synapse.module_api.MediaUploadLimit, sent_bytes: int, attempted_bytes: int) -> None
109+
```
110+
111+
**<span style="color:red">
112+
Caution: This callback is currently experimental. The method signature or behaviour
113+
may change without notice.
114+
</span>**
115+
116+
Called when a user attempts to upload media that would exceed a
117+
[configured media upload limit](../usage/configuration/config_documentation.html#media_upload_limits).
118+
119+
This callback will only be called on workers which handle
120+
[POST /_matrix/media/v3/upload](https://spec.matrix.org/v1.15/client-server-api/#post_matrixmediav3upload)
121+
requests.
122+
123+
This could be used to inform the user that they have reached a media upload limit through
124+
some external method.
125+
126+
The arguments passed to this callback are:
127+
128+
* `user_id`: The Matrix user ID of the user (e.g. `@alice:example.com`) making the request.
129+
* `limit`: The `synapse.module_api.MediaUploadLimit` representing the limit that was reached.
130+
* `sent_bytes`: The number of bytes already sent during the period of the limit.
131+
* `attempted_bytes`: The number of bytes that the user attempted to send.

docs/usage/configuration/config_documentation.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2168,9 +2168,12 @@ max_upload_size: 60M
21682168
### `media_upload_limits`
21692169

21702170
*(array)* A list of media upload limits defining how much data a given user can upload in a given time period.
2171+
These limits are applied in addition to the `max_upload_size` limit above (which applies to individual uploads).
21712172

21722173
An empty list means no limits are applied.
21732174

2175+
These settings can be overridden using the `get_media_upload_limits_for_user` module API [callback](../../modules/media_repository_callbacks.md#get_media_upload_limits_for_user).
2176+
21742177
Defaults to `[]`.
21752178

21762179
Example configuration:

schema/synapse-config.schema.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2415,8 +2415,15 @@ properties:
24152415
A list of media upload limits defining how much data a given user can
24162416
upload in a given time period.
24172417
2418+
These limits are applied in addition to the `max_upload_size` limit above
2419+
(which applies to individual uploads).
2420+
24182421
24192422
An empty list means no limits are applied.
2423+
2424+
2425+
These settings can be overridden using the `get_media_upload_limits_for_user`
2426+
module API [callback](../../modules/media_repository_callbacks.md#get_media_upload_limits_for_user).
24202427
default: []
24212428
items:
24222429
time_period:

synapse/config/repository.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,19 @@ def parse_thumbnail_requirements(
120120

121121
@attr.s(auto_attribs=True, slots=True, frozen=True)
122122
class MediaUploadLimit:
123-
"""A limit on the amount of data a user can upload in a given time
124-
period."""
123+
"""
124+
Represents a limit on the amount of data a user can upload in a given time
125+
period.
126+
127+
These can be configured through the `media_upload_limits` [config option](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#media_upload_limits)
128+
or via the `get_media_upload_limits_for_user` module API [callback](https://element-hq.github.io/synapse/latest/modules/media_repository_callbacks.html#get_media_upload_limits_for_user).
129+
"""
125130

126131
max_bytes: int
132+
"""The maximum number of bytes that can be uploaded in the given time period."""
133+
127134
time_period_ms: int
135+
"""The time period in milliseconds."""
128136

129137

130138
class ContentRepositoryConfig(Config):

synapse/media/media_repository.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,13 @@ def __init__(self, hs: "HomeServer"):
179179

180180
# We get the media upload limits and sort them in descending order of
181181
# time period, so that we can apply some optimizations.
182-
self.media_upload_limits = hs.config.media.media_upload_limits
183-
self.media_upload_limits.sort(
182+
self.default_media_upload_limits = hs.config.media.media_upload_limits
183+
self.default_media_upload_limits.sort(
184184
key=lambda limit: limit.time_period_ms, reverse=True
185185
)
186186

187+
self.media_repository_callbacks = hs.get_module_api_callbacks().media_repository
188+
187189
def _start_update_recently_accessed(self) -> Deferred:
188190
return run_as_background_process(
189191
"update_recently_accessed_media",
@@ -340,16 +342,27 @@ async def create_or_update_content(
340342

341343
# Check that the user has not exceeded any of the media upload limits.
342344

345+
# Use limits from module API if provided
346+
media_upload_limits = (
347+
await self.media_repository_callbacks.get_media_upload_limits_for_user(
348+
auth_user.to_string()
349+
)
350+
)
351+
352+
# Otherwise use the default limits from config
353+
if media_upload_limits is None:
354+
# Note: the media upload limits are sorted so larger time periods are
355+
# first.
356+
media_upload_limits = self.default_media_upload_limits
357+
343358
# This is the total size of media uploaded by the user in the last
344359
# `time_period_ms` milliseconds, or None if we haven't checked yet.
345360
uploaded_media_size: Optional[int] = None
346361

347-
# Note: the media upload limits are sorted so larger time periods are
348-
# first.
349-
for limit in self.media_upload_limits:
362+
for limit in media_upload_limits:
350363
# We only need to check the amount of media uploaded by the user in
351364
# this latest (smaller) time period if the amount of media uploaded
352-
# in a previous (larger) time period is above the limit.
365+
# in a previous (larger) time period is below the limit.
353366
#
354367
# This optimization means that in the common case where the user
355368
# hasn't uploaded much media, we only need to query the database
@@ -363,6 +376,12 @@ async def create_or_update_content(
363376
)
364377

365378
if uploaded_media_size + content_length > limit.max_bytes:
379+
await self.media_repository_callbacks.on_media_upload_limit_exceeded(
380+
user_id=auth_user.to_string(),
381+
limit=limit,
382+
sent_bytes=uploaded_media_size,
383+
attempted_bytes=content_length,
384+
)
366385
raise SynapseError(
367386
400, "Media upload limit exceeded", Codes.RESOURCE_LIMIT_EXCEEDED
368387
)

synapse/module_api/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from synapse.api.errors import SynapseError
5151
from synapse.api.presence import UserPresenceState
5252
from synapse.config import ConfigError
53+
from synapse.config.repository import MediaUploadLimit
5354
from synapse.events import EventBase
5455
from synapse.events.presence_router import (
5556
GET_INTERESTED_USERS_CALLBACK,
@@ -94,7 +95,9 @@
9495
)
9596
from synapse.module_api.callbacks.media_repository_callbacks import (
9697
GET_MEDIA_CONFIG_FOR_USER_CALLBACK,
98+
GET_MEDIA_UPLOAD_LIMITS_FOR_USER_CALLBACK,
9799
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK,
100+
ON_MEDIA_UPLOAD_LIMIT_EXCEEDED_CALLBACK,
98101
)
99102
from synapse.module_api.callbacks.ratelimit_callbacks import (
100103
GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK,
@@ -205,6 +208,7 @@
205208
"RoomAlias",
206209
"UserProfile",
207210
"RatelimitOverride",
211+
"MediaUploadLimit",
208212
]
209213

210214
logger = logging.getLogger(__name__)
@@ -462,13 +466,21 @@ def register_media_repository_callbacks(
462466
is_user_allowed_to_upload_media_of_size: Optional[
463467
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
464468
] = None,
469+
get_media_upload_limits_for_user: Optional[
470+
GET_MEDIA_UPLOAD_LIMITS_FOR_USER_CALLBACK
471+
] = None,
472+
on_media_upload_limit_exceeded: Optional[
473+
ON_MEDIA_UPLOAD_LIMIT_EXCEEDED_CALLBACK
474+
] = None,
465475
) -> None:
466476
"""Registers callbacks for media repository capabilities.
467477
Added in Synapse v1.132.0.
468478
"""
469479
return self._callbacks.media_repository.register_callbacks(
470480
get_media_config_for_user=get_media_config_for_user,
471481
is_user_allowed_to_upload_media_of_size=is_user_allowed_to_upload_media_of_size,
482+
get_media_upload_limits_for_user=get_media_upload_limits_for_user,
483+
on_media_upload_limit_exceeded=on_media_upload_limit_exceeded,
472484
)
473485

474486
def register_third_party_rules_callbacks(

synapse/module_api/callbacks/media_repository_callbacks.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import logging
1616
from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional
1717

18+
from synapse.config.repository import MediaUploadLimit
1819
from synapse.types import JsonDict
1920
from synapse.util.async_helpers import delay_cancellation
2021
from synapse.util.metrics import Measure
@@ -28,6 +29,14 @@
2829

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

32+
GET_MEDIA_UPLOAD_LIMITS_FOR_USER_CALLBACK = Callable[
33+
[str], Awaitable[Optional[List[MediaUploadLimit]]]
34+
]
35+
36+
ON_MEDIA_UPLOAD_LIMIT_EXCEEDED_CALLBACK = Callable[
37+
[str, MediaUploadLimit, int, int], Awaitable[None]
38+
]
39+
3140

3241
class MediaRepositoryModuleApiCallbacks:
3342
def __init__(self, hs: "HomeServer") -> None:
@@ -39,13 +48,25 @@ def __init__(self, hs: "HomeServer") -> None:
3948
self._is_user_allowed_to_upload_media_of_size_callbacks: List[
4049
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
4150
] = []
51+
self._get_media_upload_limits_for_user_callbacks: List[
52+
GET_MEDIA_UPLOAD_LIMITS_FOR_USER_CALLBACK
53+
] = []
54+
self._on_media_upload_limit_exceeded_callbacks: List[
55+
ON_MEDIA_UPLOAD_LIMIT_EXCEEDED_CALLBACK
56+
] = []
4257

4358
def register_callbacks(
4459
self,
4560
get_media_config_for_user: Optional[GET_MEDIA_CONFIG_FOR_USER_CALLBACK] = None,
4661
is_user_allowed_to_upload_media_of_size: Optional[
4762
IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK
4863
] = None,
64+
get_media_upload_limits_for_user: Optional[
65+
GET_MEDIA_UPLOAD_LIMITS_FOR_USER_CALLBACK
66+
] = None,
67+
on_media_upload_limit_exceeded: Optional[
68+
ON_MEDIA_UPLOAD_LIMIT_EXCEEDED_CALLBACK
69+
] = None,
4970
) -> None:
5071
"""Register callbacks from module for each hook."""
5172
if get_media_config_for_user is not None:
@@ -56,6 +77,16 @@ def register_callbacks(
5677
is_user_allowed_to_upload_media_of_size
5778
)
5879

80+
if get_media_upload_limits_for_user is not None:
81+
self._get_media_upload_limits_for_user_callbacks.append(
82+
get_media_upload_limits_for_user
83+
)
84+
85+
if on_media_upload_limit_exceeded is not None:
86+
self._on_media_upload_limit_exceeded_callbacks.append(
87+
on_media_upload_limit_exceeded
88+
)
89+
5990
async def get_media_config_for_user(self, user_id: str) -> Optional[JsonDict]:
6091
for callback in self._get_media_config_for_user_callbacks:
6192
with Measure(
@@ -83,3 +114,47 @@ async def is_user_allowed_to_upload_media_of_size(
83114
return res
84115

85116
return True
117+
118+
async def get_media_upload_limits_for_user(
119+
self, user_id: str
120+
) -> Optional[List[MediaUploadLimit]]:
121+
"""
122+
Get the first non-None list of MediaUploadLimits for the user from the registered callbacks.
123+
If a list is returned it will be sorted in descending order of duration.
124+
"""
125+
for callback in self._get_media_upload_limits_for_user_callbacks:
126+
with Measure(
127+
self.clock,
128+
name=f"{callback.__module__}.{callback.__qualname__}",
129+
server_name=self.server_name,
130+
):
131+
res: Optional[List[MediaUploadLimit]] = await delay_cancellation(
132+
callback(user_id)
133+
)
134+
if res is not None: # to allow [] to be returned meaning no limit
135+
# We sort them in descending order of time period
136+
res.sort(key=lambda limit: limit.time_period_ms, reverse=True)
137+
return res
138+
139+
return None
140+
141+
async def on_media_upload_limit_exceeded(
142+
self,
143+
user_id: str,
144+
limit: MediaUploadLimit,
145+
sent_bytes: int,
146+
attempted_bytes: int,
147+
) -> None:
148+
for callback in self._on_media_upload_limit_exceeded_callbacks:
149+
with Measure(
150+
self.clock,
151+
name=f"{callback.__module__}.{callback.__qualname__}",
152+
server_name=self.server_name,
153+
):
154+
# Use a copy of the data in case the module modifies it
155+
limit_copy = MediaUploadLimit(
156+
max_bytes=limit.max_bytes, time_period_ms=limit.time_period_ms
157+
)
158+
await delay_cancellation(
159+
callback(user_id, limit_copy, sent_bytes, attempted_bytes)
160+
)

0 commit comments

Comments
 (0)