Skip to content

Commit 66daf0b

Browse files
authored
Add ability to limit amount uploaded by a user (#18527)
You can now configure how much media can be uploaded by a user in a given time period. Note the first commit here is a refactor of create/upload content function
1 parent b9b8775 commit 66daf0b

File tree

11 files changed

+292
-73
lines changed

11 files changed

+292
-73
lines changed

changelog.d/18527.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add ability to limit amount uploaded by a user in a given time period.

docs/usage/configuration/config_documentation.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2086,6 +2086,23 @@ Example configuration:
20862086
max_upload_size: 60M
20872087
```
20882088
---
2089+
### `media_upload_limits`
2090+
2091+
*(array)* A list of media upload limits defining how much data a given user can upload in a given time period.
2092+
2093+
An empty list means no limits are applied.
2094+
2095+
Defaults to `[]`.
2096+
2097+
Example configuration:
2098+
```yaml
2099+
media_upload_limits:
2100+
- time_period: 1h
2101+
max_size: 100M
2102+
- time_period: 1w
2103+
max_size: 500M
2104+
```
2105+
---
20892106
### `max_image_pixels`
20902107

20912108
*(byte size)* Maximum number of pixels that will be thumbnailed. Defaults to `"32M"`.

schema/synapse-config.schema.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2335,6 +2335,30 @@ properties:
23352335
default: 50M
23362336
examples:
23372337
- 60M
2338+
media_upload_limits:
2339+
type: array
2340+
description: >-
2341+
A list of media upload limits defining how much data a given user can
2342+
upload in a given time period.
2343+
2344+
2345+
An empty list means no limits are applied.
2346+
default: []
2347+
items:
2348+
time_period:
2349+
type: "#/$defs/duration"
2350+
description: >-
2351+
The time period over which the limit applies. Required.
2352+
max_size:
2353+
type: "#/$defs/bytes"
2354+
description: >-
2355+
Amount of data that can be uploaded in the time period by the user.
2356+
Required.
2357+
examples:
2358+
- - time_period: 1h
2359+
max_size: 100M
2360+
- time_period: 1w
2361+
max_size: 500M
23382362
max_image_pixels:
23392363
$ref: "#/$defs/bytes"
23402364
description: Maximum number of pixels that will be thumbnailed.

synapse/config/repository.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ def parse_thumbnail_requirements(
119119
}
120120

121121

122+
@attr.s(auto_attribs=True, slots=True, frozen=True)
123+
class MediaUploadLimit:
124+
"""A limit on the amount of data a user can upload in a given time
125+
period."""
126+
127+
max_bytes: int
128+
time_period_ms: int
129+
130+
122131
class ContentRepositoryConfig(Config):
123132
section = "media"
124133

@@ -274,6 +283,13 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
274283

275284
self.enable_authenticated_media = config.get("enable_authenticated_media", True)
276285

286+
self.media_upload_limits: List[MediaUploadLimit] = []
287+
for limit_config in config.get("media_upload_limits", []):
288+
time_period_ms = self.parse_duration(limit_config["time_period"])
289+
max_bytes = self.parse_size(limit_config["max_size"])
290+
291+
self.media_upload_limits.append(MediaUploadLimit(max_bytes, time_period_ms))
292+
277293
def generate_config_section(self, data_dir_path: str, **kwargs: Any) -> str:
278294
assert data_dir_path is not None
279295
media_store = os.path.join(data_dir_path, "media_store")

synapse/handlers/sso.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -824,7 +824,7 @@ def is_allowed_mime_type(content_type: str) -> bool:
824824
return True
825825

826826
# store it in media repository
827-
avatar_mxc_url = await self._media_repo.create_content(
827+
avatar_mxc_url = await self._media_repo.create_or_update_content(
828828
media_type=headers[b"Content-Type"][0].decode("utf-8"),
829829
upload_name=upload_name,
830830
content=picture,

synapse/media/media_repository.py

Lines changed: 66 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ def __init__(self, hs: "HomeServer"):
177177
else:
178178
self.url_previewer = None
179179

180+
# We get the media upload limits and sort them in descending order of
181+
# 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(
184+
key=lambda limit: limit.time_period_ms, reverse=True
185+
)
186+
180187
def _start_update_recently_accessed(self) -> Deferred:
181188
return run_as_background_process(
182189
"update_recently_accessed_media", self._update_recently_accessed
@@ -285,80 +292,37 @@ async def verify_can_upload(self, media_id: str, auth_user: UserID) -> None:
285292
raise NotFoundError("Media ID has expired")
286293

287294
@trace
288-
async def update_content(
289-
self,
290-
media_id: str,
291-
media_type: str,
292-
upload_name: Optional[str],
293-
content: IO,
294-
content_length: int,
295-
auth_user: UserID,
296-
) -> None:
297-
"""Update the content of the given media ID.
298-
299-
Args:
300-
media_id: The media ID to replace.
301-
media_type: The content type of the file.
302-
upload_name: The name of the file, if provided.
303-
content: A file like object that is the content to store
304-
content_length: The length of the content
305-
auth_user: The user_id of the uploader
306-
"""
307-
file_info = FileInfo(server_name=None, file_id=media_id)
308-
sha256reader = SHA256TransparentIOReader(content)
309-
# This implements all of IO as it has a passthrough
310-
fname = await self.media_storage.store_file(sha256reader.wrap(), file_info)
311-
sha256 = sha256reader.hexdigest()
312-
should_quarantine = await self.store.get_is_hash_quarantined(sha256)
313-
logger.info("Stored local media in file %r", fname)
314-
315-
if should_quarantine:
316-
logger.warning(
317-
"Media has been automatically quarantined as it matched existing quarantined media"
318-
)
319-
320-
await self.store.update_local_media(
321-
media_id=media_id,
322-
media_type=media_type,
323-
upload_name=upload_name,
324-
media_length=content_length,
325-
user_id=auth_user,
326-
sha256=sha256,
327-
quarantined_by="system" if should_quarantine else None,
328-
)
329-
330-
try:
331-
await self._generate_thumbnails(None, media_id, media_id, media_type)
332-
except Exception as e:
333-
logger.info("Failed to generate thumbnails: %s", e)
334-
335-
@trace
336-
async def create_content(
295+
async def create_or_update_content(
337296
self,
338297
media_type: str,
339298
upload_name: Optional[str],
340299
content: IO,
341300
content_length: int,
342301
auth_user: UserID,
302+
media_id: Optional[str] = None,
343303
) -> MXCUri:
344-
"""Store uploaded content for a local user and return the mxc URL
304+
"""Create or update the content of the given media ID.
345305
346306
Args:
347307
media_type: The content type of the file.
348308
upload_name: The name of the file, if provided.
349309
content: A file like object that is the content to store
350310
content_length: The length of the content
351311
auth_user: The user_id of the uploader
312+
media_id: The media ID to update if provided, otherwise creates
313+
new media ID.
352314
353315
Returns:
354316
The mxc url of the stored content
355317
"""
356318

357-
media_id = random_string(24)
319+
is_new_media = media_id is None
320+
if media_id is None:
321+
media_id = random_string(24)
358322

359323
file_info = FileInfo(server_name=None, file_id=media_id)
360-
# This implements all of IO as it has a passthrough
361324
sha256reader = SHA256TransparentIOReader(content)
325+
# This implements all of IO as it has a passthrough
362326
fname = await self.media_storage.store_file(sha256reader.wrap(), file_info)
363327
sha256 = sha256reader.hexdigest()
364328
should_quarantine = await self.store.get_is_hash_quarantined(sha256)
@@ -370,16 +334,56 @@ async def create_content(
370334
"Media has been automatically quarantined as it matched existing quarantined media"
371335
)
372336

373-
await self.store.store_local_media(
374-
media_id=media_id,
375-
media_type=media_type,
376-
time_now_ms=self.clock.time_msec(),
377-
upload_name=upload_name,
378-
media_length=content_length,
379-
user_id=auth_user,
380-
sha256=sha256,
381-
quarantined_by="system" if should_quarantine else None,
382-
)
337+
# Check that the user has not exceeded any of the media upload limits.
338+
339+
# This is the total size of media uploaded by the user in the last
340+
# `time_period_ms` milliseconds, or None if we haven't checked yet.
341+
uploaded_media_size: Optional[int] = None
342+
343+
# Note: the media upload limits are sorted so larger time periods are
344+
# first.
345+
for limit in self.media_upload_limits:
346+
# We only need to check the amount of media uploaded by the user in
347+
# this latest (smaller) time period if the amount of media uploaded
348+
# in a previous (larger) time period is above the limit.
349+
#
350+
# This optimization means that in the common case where the user
351+
# hasn't uploaded much media, we only need to query the database
352+
# once.
353+
if (
354+
uploaded_media_size is None
355+
or uploaded_media_size + content_length > limit.max_bytes
356+
):
357+
uploaded_media_size = await self.store.get_media_uploaded_size_for_user(
358+
user_id=auth_user.to_string(), time_period_ms=limit.time_period_ms
359+
)
360+
361+
if uploaded_media_size + content_length > limit.max_bytes:
362+
raise SynapseError(
363+
400, "Media upload limit exceeded", Codes.RESOURCE_LIMIT_EXCEEDED
364+
)
365+
366+
if is_new_media:
367+
await self.store.store_local_media(
368+
media_id=media_id,
369+
media_type=media_type,
370+
time_now_ms=self.clock.time_msec(),
371+
upload_name=upload_name,
372+
media_length=content_length,
373+
user_id=auth_user,
374+
sha256=sha256,
375+
quarantined_by="system" if should_quarantine else None,
376+
)
377+
else:
378+
await self.store.update_local_media(
379+
media_id=media_id,
380+
media_type=media_type,
381+
upload_name=upload_name,
382+
media_length=content_length,
383+
user_id=auth_user,
384+
sha256=sha256,
385+
quarantined_by="system" if should_quarantine else None,
386+
)
383387

384388
try:
385389
await self._generate_thumbnails(None, media_id, media_id, media_type)

synapse/rest/media/upload_resource.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ async def on_POST(self, request: SynapseRequest) -> None:
120120

121121
try:
122122
content: IO = request.content # type: ignore
123-
content_uri = await self.media_repo.create_content(
123+
content_uri = await self.media_repo.create_or_update_content(
124124
media_type, upload_name, content, content_length, requester.user
125125
)
126126
except SpamMediaException:
@@ -170,13 +170,13 @@ async def on_PUT(
170170

171171
try:
172172
content: IO = request.content # type: ignore
173-
await self.media_repo.update_content(
174-
media_id,
173+
await self.media_repo.create_or_update_content(
175174
media_type,
176175
upload_name,
177176
content,
178177
content_length,
179178
requester.user,
179+
media_id=media_id,
180180
)
181181
except SpamMediaException:
182182
# For uploading of media we want to respond with a 400, instead of

synapse/storage/databases/main/media_repository.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,3 +1034,39 @@ def get_matching_media_txn(
10341034
"local_media_repository",
10351035
sha256,
10361036
)
1037+
1038+
async def get_media_uploaded_size_for_user(
1039+
self, user_id: str, time_period_ms: int
1040+
) -> int:
1041+
"""Get the total size of media uploaded by a user in the last
1042+
time_period_ms milliseconds.
1043+
1044+
Args:
1045+
user_id: The user ID to check.
1046+
time_period_ms: The time period in milliseconds to consider.
1047+
1048+
Returns:
1049+
The total size of media uploaded by the user in bytes.
1050+
"""
1051+
1052+
sql = """
1053+
SELECT COALESCE(SUM(media_length), 0)
1054+
FROM local_media_repository
1055+
WHERE user_id = ? AND created_ts > ?
1056+
"""
1057+
1058+
def _get_media_uploaded_size_for_user_txn(
1059+
txn: LoggingTransaction,
1060+
) -> int:
1061+
# Calculate the timestamp for the start of the time period
1062+
start_ts = self._clock.time_msec() - time_period_ms
1063+
txn.execute(sql, (user_id, start_ts))
1064+
row = txn.fetchone()
1065+
if row is None:
1066+
return 0
1067+
return row[0]
1068+
1069+
return await self.db_pool.runInteraction(
1070+
"get_media_uploaded_size_for_user",
1071+
_get_media_uploaded_size_for_user_txn,
1072+
)

tests/federation/test_federation_media.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
6767
def test_file_download(self) -> None:
6868
content = io.BytesIO(b"file_to_stream")
6969
content_uri = self.get_success(
70-
self.media_repo.create_content(
70+
self.media_repo.create_or_update_content(
7171
"text/plain",
7272
"test_upload",
7373
content,
@@ -110,7 +110,7 @@ def test_file_download(self) -> None:
110110

111111
content = io.BytesIO(SMALL_PNG)
112112
content_uri = self.get_success(
113-
self.media_repo.create_content(
113+
self.media_repo.create_or_update_content(
114114
"image/png",
115115
"test_png_upload",
116116
content,
@@ -152,7 +152,7 @@ def test_federation_etag(self) -> None:
152152

153153
content = io.BytesIO(b"file_to_stream")
154154
content_uri = self.get_success(
155-
self.media_repo.create_content(
155+
self.media_repo.create_or_update_content(
156156
"text/plain",
157157
"test_upload",
158158
content,
@@ -215,7 +215,7 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
215215
def test_thumbnail_download_scaled(self) -> None:
216216
content = io.BytesIO(small_png.data)
217217
content_uri = self.get_success(
218-
self.media_repo.create_content(
218+
self.media_repo.create_or_update_content(
219219
"image/png",
220220
"test_png_thumbnail",
221221
content,
@@ -255,7 +255,7 @@ def test_thumbnail_download_scaled(self) -> None:
255255
def test_thumbnail_download_cropped(self) -> None:
256256
content = io.BytesIO(small_png.data)
257257
content_uri = self.get_success(
258-
self.media_repo.create_content(
258+
self.media_repo.create_or_update_content(
259259
"image/png",
260260
"test_png_thumbnail",
261261
content,

tests/media/test_media_retention.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def _create_media_and_set_attributes(
7878
# If the meda
7979
random_content = bytes(random_string(24), "utf-8")
8080
mxc_uri: MXCUri = self.get_success(
81-
media_repository.create_content(
81+
media_repository.create_or_update_content(
8282
media_type="text/plain",
8383
upload_name=None,
8484
content=io.BytesIO(random_content),

0 commit comments

Comments
 (0)