From 97fb38eca66f74c4a761e1b1b394e4d19486e61b Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 24 Jul 2026 22:39:04 +0100 Subject: [PATCH] Return M_USER_LIMIT_EXCEEDED error code for media upload limits from MSC4335 (#18876) This PR uses the new `M_USER_LIMIT_EXCEEDED` common error code instead of the malformed `M_RESOURCE_LIMIT_EXCEEDED` error code (as reported by #18749) (spec: MSC4335). The error code is also changed from `400` to `403` as this matches what is in the spec for the endpoints: https://spec.matrix.org/v1.18/client-server-api/#post_matrixmediav3upload and https://spec.matrix.org/v1.18/client-server-api/#put_matrixmediav3uploadservernamemediaid (albeit the latter says that `M_FORBIDDEN` would be returned) By default a new built-in `media_upload_limit_exceeded.html` template will be served for the `info_uri`. Administrators can specify an external URI in config instead. Compatibility is retained for any existing modules making use the `MediaUploadLimit` (e.g. via the `get_media_upload_limits_for_user` callback). --- changelog.d/18876.feature | 1 + .../configuration/config_documentation.md | 13 ++ schema/synapse-config.schema.yaml | 40 +++- synapse/api/errors.py | 30 +++ synapse/config/repository.py | 75 +++++- synapse/media/media_repository.py | 17 +- .../callbacks/media_repository_callbacks.py | 5 +- .../media_upload_limit_exceeded.html | 6 + synapse/rest/synapse/client/__init__.py | 11 + .../client/media_upload_limit_exceeded.py | 42 ++++ tests/rest/client/test_media.py | 215 ++++++++++++++++-- 11 files changed, 419 insertions(+), 36 deletions(-) create mode 100644 changelog.d/18876.feature create mode 100644 synapse/res/templates/media_upload_limit_exceeded.html create mode 100644 synapse/rest/synapse/client/media_upload_limit_exceeded.py diff --git a/changelog.d/18876.feature b/changelog.d/18876.feature new file mode 100644 index 0000000000..d62540f2f6 --- /dev/null +++ b/changelog.d/18876.feature @@ -0,0 +1 @@ +Return `M_USER_LIMIT_EXCEEDED` error code for media upload limits from [MSC4335](https://github.com/matrix-org/matrix-spec-proposals/pull/4335). diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md index 7f69cf23b1..7013e29f9f 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md @@ -2228,13 +2228,26 @@ These settings can be overridden using the `get_media_upload_limits_for_user` mo Defaults to `[]`. +Options for each entry include: + +* `time_period` (duration): The time period over which the limit applies. Required. + +* `max_size` (byte size): Amount of data that can be uploaded in the time period by the user. Required. + +* `info_uri` (string): URI returned to the client for where the user can find information about the upload limit and how users can reduce their upload usage or request an upload limit increase. Optional. If not set, Synapse serves a built-in page (customisable via the `media_upload_limit_exceeded.html` template) and uses its URL instead. + +* `can_upgrade` (boolean): Value returned to the client for whether the limit can be increased. Defaults to `false`. + Example configuration: ```yaml media_upload_limits: - time_period: 1h max_size: 100M + info_uri: https://example.com/quota#hour - time_period: 1w max_size: 500M + info_uri: https://example.com/quota + can_upgrade: true ``` --- ### `max_image_pixels` diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml index 68e9e2676b..b547a4fe94 100644 --- a/schema/synapse-config.schema.yaml +++ b/schema/synapse-config.schema.yaml @@ -2505,20 +2505,42 @@ properties: module API [callback](../../modules/media_repository_callbacks.md#get_media_upload_limits_for_user). default: [] items: - time_period: - type: "#/$defs/duration" - description: >- - The time period over which the limit applies. Required. - max_size: - type: "#/$defs/bytes" - description: >- - Amount of data that can be uploaded in the time period by the user. - Required. + type: object + required: + - time_period + - max_size + properties: + time_period: + $ref: "#/$defs/duration" + description: >- + The time period over which the limit applies. Required. + max_size: + $ref: "#/$defs/bytes" + description: >- + Amount of data that can be uploaded in the time period by the user. + Required. + info_uri: + type: string + description: >- + URI returned to the client for where the user can find information + about the upload limit and how users can reduce their upload usage + or request an upload limit increase. Optional. + If not set, Synapse serves a built-in page (customisable via the + `media_upload_limit_exceeded.html` template) and uses its URL + instead. + can_upgrade: + type: boolean + description: >- + Value returned to the client for whether the limit can be increased. + default: false examples: - - time_period: 1h max_size: 100M + info_uri: https://example.com/quota#hour - time_period: 1w max_size: 500M + info_uri: https://example.com/quota + can_upgrade: true max_image_pixels: $ref: "#/$defs/bytes" description: Maximum number of pixels that will be thumbnailed. diff --git a/synapse/api/errors.py b/synapse/api/errors.py index 0c35b4a7ba..7797f5062e 100644 --- a/synapse/api/errors.py +++ b/synapse/api/errors.py @@ -152,6 +152,8 @@ class Codes(str, Enum): # Part of MSC4326 UNKNOWN_DEVICE = "ORG.MATRIX.MSC4326.M_UNKNOWN_DEVICE" + USER_LIMIT_EXCEEDED = "M_USER_LIMIT_EXCEEDED" + class CodeMessageException(RuntimeError): """An exception with integer code, a message string attributes and optional headers. @@ -513,6 +515,34 @@ class ResourceLimitError(SynapseError): ) +class UserLimitExceededError(SynapseError): + """ + Implementation of M_USER_LIMIT_EXCEEDED error + """ + + def __init__( + self, + code: int, + msg: str, + *, + info_uri: str, + can_upgrade: bool = False, + ): + additional_fields: dict[str, str | bool] = { + "info_uri": info_uri, + } + + if can_upgrade: + additional_fields["can_upgrade"] = can_upgrade + + super().__init__( + code, + msg, + Codes.USER_LIMIT_EXCEEDED, + additional_fields=additional_fields, + ) + + class EventSizeError(SynapseError): """An error raised when an event is too big.""" diff --git a/synapse/config/repository.py b/synapse/config/repository.py index 373e518ddc..6d06c910d7 100644 --- a/synapse/config/repository.py +++ b/synapse/config/repository.py @@ -3,6 +3,7 @@ # # Copyright 2014, 2015 OpenMarket Ltd # Copyright (C) 2023 New Vector, Ltd +# Copyright (C) 2026 Element Creations 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 @@ -21,14 +22,16 @@ import logging import os -from typing import Any +from typing import Annotated, Any import attr +from pydantic import AnyUrl, BeforeValidator, ValidationError from synapse.config.server import generate_ip_set, parse_proxy_config from synapse.types import JsonDict from synapse.util.check_dependencies import check_requirements from synapse.util.module_loader import load_module +from synapse.util.pydantic_models import ParseModel from ._base import Config, ConfigError @@ -134,6 +137,27 @@ class MediaUploadLimit: time_period_ms: int """The time period in milliseconds.""" + info_uri: str | None = None + """The URI to return with the M_USER_LIMIT_EXCEEDED error. + + If left unset (`None`), Synapse falls back to a static page served by itself + (see `MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH`), which explains that the limit has been + exceeded and can be customized by server administrators via a custom + template.""" + + can_upgrade: bool = False + """Whether the user can upgrade their plan to increase the limit. This is returned in the M_USER_LIMIT_EXCEEDED error.""" + + +class MediaUploadLimitConfigModel(ParseModel): + """Internal model for parsing a single media_upload_limits config entry.""" + + max_size: Annotated[int, BeforeValidator(Config.parse_size)] + time_period: Annotated[int, BeforeValidator(Config.parse_duration)] + info_uri: AnyUrl | None = None + """We accept AnyUrl as a subset of valid URIs. It could be widened in future if needed.""" + can_upgrade: bool = False + class ContentRepositoryConfig(Config): section = "media" @@ -143,6 +167,18 @@ class ContentRepositoryConfig(Config): # is not a media repo worker, as it's exposed in `/capabilities` self.url_preview_enabled = bool(config.get("url_preview_enabled", False)) + # Load the template used to render the fallback page. + # + # We set this up on all workers (not just the media repo) as the + # fallback page is served by whichever process handles + # `/_synapse/client/media_upload_limit_exceeded`, so every process must + # be able to render it. This must happen before the early return below, + # which is taken by workers that do not load the media repo. + self.media_upload_limit_exceeded_template = self.read_templates( + ["media_upload_limit_exceeded.html"], + (td for td in (self.root.server.custom_template_directory,) if td), + )[0] + # Only enable the media repo if either the media repo is enabled or the # current worker app is the media repo. if ( @@ -308,11 +344,40 @@ class ContentRepositoryConfig(Config): self.enable_authenticated_media = config.get("enable_authenticated_media", True) self.media_upload_limits: list[MediaUploadLimit] = [] - for limit_config in config.get("media_upload_limits", []): - time_period_ms = self.parse_duration(limit_config["time_period"]) - max_bytes = self.parse_size(limit_config["max_size"]) + for raw_entry in config.get("media_upload_limits", []): + try: + entry = MediaUploadLimitConfigModel.model_validate( + raw_entry, strict=True + ) + except ValidationError as e: + raise ConfigError( + "Could not validate media_upload_limits entry", + ("media_upload_limits",), + ) from e - self.media_upload_limits.append(MediaUploadLimit(max_bytes, time_period_ms)) + info_uri = str(entry.info_uri) if entry.info_uri is not None else None + self.media_upload_limits.append( + MediaUploadLimit( + max_bytes=entry.max_size, + time_period_ms=entry.time_period, + info_uri=info_uri, + can_upgrade=entry.can_upgrade, + ) + ) + + # The absolute URI of the static fallback page, used as the `info_uri` + # for any media upload limit (whether from config or a module callback) + # that doesn't specify one. Built from public_baseurl so that it is a + # usable absolute URL. We import here to avoid a circular import at + # module load time. + from synapse.rest.synapse.client.media_upload_limit_exceeded import ( + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH, + ) + + self.media_upload_limit_fallback_info_uri = ( + self.root.server.public_baseurl + + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH.lstrip("/") + ) def generate_config_section(self, data_dir_path: str, **kwargs: Any) -> str: assert data_dir_path is not None diff --git a/synapse/media/media_repository.py b/synapse/media/media_repository.py index bbd31976cb..180adc20a0 100644 --- a/synapse/media/media_repository.py +++ b/synapse/media/media_repository.py @@ -40,6 +40,7 @@ from synapse.api.errors import ( NotFoundError, RequestSendFailed, SynapseError, + UserLimitExceededError, cs_error, ) from synapse.api.ratelimiting import Ratelimiter @@ -400,8 +401,20 @@ class MediaRepository: sent_bytes=uploaded_media_size, attempted_bytes=content_length, ) - raise SynapseError( - 400, "Media upload limit exceeded", Codes.RESOURCE_LIMIT_EXCEEDED + + # Fall back to the static page served by Synapse when the limit + # doesn't specify its own `info_uri` (e.g. limits returned by a + # module callback without one). + info_uri = ( + limit.info_uri + or self.hs.config.media.media_upload_limit_fallback_info_uri + ) + + raise UserLimitExceededError( + 403, + "Media upload limit exceeded", + info_uri=info_uri, + can_upgrade=limit.can_upgrade, ) if is_new_media: diff --git a/synapse/module_api/callbacks/media_repository_callbacks.py b/synapse/module_api/callbacks/media_repository_callbacks.py index f1e6ea4c38..5b58229b77 100644 --- a/synapse/module_api/callbacks/media_repository_callbacks.py +++ b/synapse/module_api/callbacks/media_repository_callbacks.py @@ -150,7 +150,10 @@ class MediaRepositoryModuleApiCallbacks: ): # Use a copy of the data in case the module modifies it limit_copy = MediaUploadLimit( - max_bytes=limit.max_bytes, time_period_ms=limit.time_period_ms + max_bytes=limit.max_bytes, + time_period_ms=limit.time_period_ms, + info_uri=limit.info_uri, + can_upgrade=limit.can_upgrade, ) await delay_cancellation( callback(user_id, limit_copy, sent_bytes, attempted_bytes) diff --git a/synapse/res/templates/media_upload_limit_exceeded.html b/synapse/res/templates/media_upload_limit_exceeded.html new file mode 100644 index 0000000000..62be18a138 --- /dev/null +++ b/synapse/res/templates/media_upload_limit_exceeded.html @@ -0,0 +1,6 @@ +{% extends "_base.html" %} +{% block title %}Media upload limit exceeded{% endblock %} + +{% block body %} +

You have exceeded a media upload limit. Ask your server administrator for more information.

+{% endblock %} diff --git a/synapse/rest/synapse/client/__init__.py b/synapse/rest/synapse/client/__init__.py index e04b84ecac..3534d59120 100644 --- a/synapse/rest/synapse/client/__init__.py +++ b/synapse/rest/synapse/client/__init__.py @@ -3,6 +3,7 @@ # # Copyright 2021 The Matrix.org Foundation C.I.C. # Copyright (C) 2023 New Vector, Ltd +# Copyright (C) 2026 Element Creations 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 @@ -24,6 +25,10 @@ from typing import TYPE_CHECKING, Mapping from twisted.web.resource import Resource from synapse.rest.synapse.client.federation_whitelist import FederationWhitelistResource +from synapse.rest.synapse.client.media_upload_limit_exceeded import ( + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH, + MediaUploadLimitExceededResource, +) from synapse.rest.synapse.client.new_user_consent import NewUserConsentResource from synapse.rest.synapse.client.pick_idp import PickIdpResource from synapse.rest.synapse.client.pick_username import pick_username_resource @@ -54,6 +59,12 @@ def build_synapse_client_resource_tree(hs: "HomeServer") -> Mapping[str, Resourc "/_synapse/client/sso_register": SsoRegisterResource(hs), # Unsubscribe to notification emails link "/_synapse/client/unsubscribe": UnsubscribeResource(hs), + # Fallback page served as the `info_uri` for media upload limits that + # don't have an explicit `info_uri`. Mounted unconditionally: in a + # worker deployment, the media repo (which generates the errors) + # typically runs on a different worker than the one serving + # `/_synapse/client`. + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH: MediaUploadLimitExceededResource(hs), } if hs.config.mas.enabled: diff --git a/synapse/rest/synapse/client/media_upload_limit_exceeded.py b/synapse/rest/synapse/client/media_upload_limit_exceeded.py new file mode 100644 index 0000000000..48acdd2fd6 --- /dev/null +++ b/synapse/rest/synapse/client/media_upload_limit_exceeded.py @@ -0,0 +1,42 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations 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: +# . +# + +from typing import TYPE_CHECKING + +from synapse.http.server import DirectServeHtmlResource, respond_with_html +from synapse.http.site import SynapseRequest + +if TYPE_CHECKING: + from synapse.server import HomeServer + +# The path at which the fallback media upload limit exceeded info page is served. +# This is used as the `info_uri` returned in the `M_USER_LIMIT_EXCEEDED` error +# when no `info_uri` has been specified for a media upload limit. +MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH = "/_synapse/client/media_upload_limit_exceeded" + + +class MediaUploadLimitExceededResource(DirectServeHtmlResource): + """Serves a static fallback page explaining that a media upload limit has + been exceeded. + + This is used as the `info_uri` for the `M_USER_LIMIT_EXCEEDED` error when no + `info_uri` has been specified for a media upload limit. + """ + + def __init__(self, hs: "HomeServer"): + super().__init__(clock=hs.get_clock()) + self._template = hs.config.media.media_upload_limit_exceeded_template + + async def _async_render_GET(self, request: SynapseRequest) -> None: + respond_with_html(request, 200, self._template.render()) diff --git a/tests/rest/client/test_media.py b/tests/rest/client/test_media.py index 8757e27d48..1cf401ed4f 100644 --- a/tests/rest/client/test_media.py +++ b/tests/rest/client/test_media.py @@ -3,6 +3,7 @@ # # Copyright 2022 The Matrix.org Foundation C.I.C. # Copyright (C) 2024 New Vector, Ltd +# Copyright (C) 2026 Element Creations 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 @@ -58,6 +59,10 @@ from synapse.media.url_previewer import IMAGE_CACHE_EXPIRY_MS from synapse.module_api import MediaUploadLimit from synapse.rest import admin from synapse.rest.client import login, media +from synapse.rest.synapse.client import build_synapse_client_resource_tree +from synapse.rest.synapse.client.media_upload_limit_exceeded import ( + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH, +) from synapse.server import HomeServer from synapse.types import JsonDict, UserID from synapse.util.clock import Clock @@ -2933,11 +2938,20 @@ class MediaUploadLimits(unittest.HomeserverTestCase): config["media_storage_providers"] = [provider_config] - # These are the limits that we are testing - config["media_upload_limits"] = [ - {"time_period": "1d", "max_size": "1K"}, - {"time_period": "1w", "max_size": "3K"}, - ] + # These are the limits that we are testing unless overridden + if config.get("media_upload_limits") is None: + config["media_upload_limits"] = [ + { + "time_period": "1d", + "max_size": "1K", + "info_uri": "https://example.com/limits#daily", + }, + { + "time_period": "1w", + "max_size": "3K", + "info_uri": "https://example.com/limits#weekly", + }, + ] return self.setup_test_homeserver(config=config) @@ -2951,6 +2965,9 @@ class MediaUploadLimits(unittest.HomeserverTestCase): def create_resource_dict(self) -> dict[str, Resource]: resources = super().create_resource_dict() resources["/_matrix/media"] = self.hs.get_media_repository_resource() + # Mount the `/_synapse/client` tree, which includes the fallback page + # served at `MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH` which we use in the tests. + resources.update(build_synapse_client_resource_tree(self.hs)) return resources def upload_media(self, size: int) -> FakeChannel: @@ -2975,7 +2992,8 @@ class MediaUploadLimits(unittest.HomeserverTestCase): self.assertEqual(channel.code, 200) channel = self.upload_media(800) - self.assertEqual(channel.code, 400) + self.assertEqual(channel.code, 403) + self.assertEqual(channel.json_body["errcode"], "M_USER_LIMIT_EXCEEDED") def test_under_daily_limit(self) -> None: """Test that uploading media under the daily limit fails.""" @@ -3013,7 +3031,8 @@ class MediaUploadLimits(unittest.HomeserverTestCase): # This will fail as the weekly limit has been exceeded channel = self.upload_media(900) - self.assertEqual(channel.code, 400) + self.assertEqual(channel.code, 403) + self.assertEqual(channel.json_body["errcode"], "M_USER_LIMIT_EXCEEDED") # Reset the weekly limit by advancing a week self.reactor.advance(7 * 60 * 60 * 24) # Advance by 7 days @@ -3022,6 +3041,104 @@ class MediaUploadLimits(unittest.HomeserverTestCase): channel = self.upload_media(900) self.assertEqual(channel.code, 200) + @override_config( + { + "media_upload_limits": [ + { + "time_period": "1d", + "max_size": "1K", + # No `info_uri` is configured, so the fallback resource + # should be used instead. + } + ], + } + ) + def test_falls_back_to_served_page_when_no_info_uri(self) -> None: + """When no info_uri is configured, the error should point at a page + served by Synapse, and that page should return HTML.""" + channel = self.upload_media(1300) + self.assertEqual(channel.code, 403) + self.assertEqual(channel.json_body["errcode"], "M_USER_LIMIT_EXCEEDED") + + expected_info_uri = ( + self.hs.config.server.public_baseurl + + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH.lstrip("/") + ) + self.assertEqual(channel.json_body["info_uri"], expected_info_uri) + + # The fallback page should be served by Synapse and return HTML. + page = self.make_request( + "GET", + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH, + shorthand=False, + ) + self.assertEqual(page.code, 200) + self.assertEqual( + page.headers.getRawHeaders("Content-Type"), + ["text/html; charset=utf-8"], + ) + self.assertIn(b"upload limit", page.result["body"]) + + def test_fallback_page_mounted(self) -> None: + """The fallback resource should always be mounted, even if every configured + limit has an explicit info_uri, since module callbacks can return + limits without an info_uri at any time.""" + # The default config for this test case sets an info_uri on every limit. + page = self.make_request( + "GET", + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH, + shorthand=False, + ) + self.assertEqual(page.code, 200) + self.assertEqual( + page.headers.getRawHeaders("Content-Type"), + ["text/html; charset=utf-8"], + ) + + @override_config( + { + "media_upload_limits": [ + { + "time_period": "1d", + "max_size": "1K", + "info_uri": "https://example.com", + } + ], + } + ) + def test_returns_hard_user_limit_exceeded_by_default(self) -> None: + """Test that the error is returned with can_upgrade False by default.""" + channel = self.upload_media(1300) + self.assertEqual(channel.code, 403) + self.assertEqual(channel.json_body["errcode"], "M_USER_LIMIT_EXCEEDED") + self.assertEqual(channel.json_body["info_uri"], "https://example.com/") + # the spec says that can_upgrade should not be included if it is False + self.assertIsNone(channel.json_body.get("can_upgrade")) + + @override_config( + { + "media_upload_limits": [ + { + "time_period": "1d", + "max_size": "1K", + "info_uri": "https://example.com", + "can_upgrade": True, + } + ], + } + ) + def test_returns_soft_user_limit_exceeded(self) -> None: + """Test that the M_USER_LIMIT_EXCEEDED error is returned with + can_upgrade True when specified in config.""" + channel = self.upload_media(500) + self.assertEqual(channel.code, 200) + + channel = self.upload_media(800) + self.assertEqual(channel.code, 403) + self.assertEqual(channel.json_body["errcode"], "M_USER_LIMIT_EXCEEDED") + self.assertEqual(channel.json_body["info_uri"], "https://example.com/") + self.assertEqual(channel.json_body["can_upgrade"], True) + class MediaUploadLimitsModuleOverrides(unittest.HomeserverTestCase): """ @@ -3054,10 +3171,19 @@ class MediaUploadLimitsModuleOverrides(unittest.HomeserverTestCase): config["media_storage_providers"] = [provider_config] # default limits to use - config["media_upload_limits"] = [ - {"time_period": "1d", "max_size": "1K"}, - {"time_period": "1w", "max_size": "3K"}, - ] + if config.get("media_upload_limits") is None: + config["media_upload_limits"] = [ + { + "time_period": "1d", + "max_size": "1K", + "info_uri": "https://example.com/limits#daily", + }, + { + "time_period": "1w", + "max_size": "3K", + "info_uri": "https://example.com/limits#weekly", + }, + ] return self.setup_test_homeserver(config=config) @@ -3070,10 +3196,14 @@ class MediaUploadLimitsModuleOverrides(unittest.HomeserverTestCase): # n.b. we return these in increasing duration order and Synapse will need to sort them correctly return [ MediaUploadLimit( - time_period_ms=Config.parse_duration("1d"), max_bytes=5000 + time_period_ms=Config.parse_duration("1d"), + max_bytes=5000, + info_uri="https://override.example.com/limits#daily", ), MediaUploadLimit( - time_period_ms=Config.parse_duration("1w"), max_bytes=15000 + time_period_ms=Config.parse_duration("1w"), + max_bytes=15000, + info_uri="https://override.example.com/limits#weekly", ), ] # user2 has no limits @@ -3154,13 +3284,16 @@ class MediaUploadLimitsModuleOverrides(unittest.HomeserverTestCase): # User 1 attempts to upload 4000 bytes taking it over the limit channel = self.upload_media(4000, self.tok1) - self.assertEqual(channel.code, 400) + self.assertEqual(channel.code, 403) assert self.last_media_upload_limit_exceeded is not None self.assertEqual(self.last_media_upload_limit_exceeded["user_id"], self.user1) self.assertEqual( self.last_media_upload_limit_exceeded["limit"], MediaUploadLimit( - max_bytes=5000, time_period_ms=Config.parse_duration("1d") + max_bytes=5000, + time_period_ms=Config.parse_duration("1d"), + info_uri="https://override.example.com/limits#daily", + can_upgrade=False, ), ) self.assertEqual(self.last_media_upload_limit_exceeded["sent_bytes"], 3000) @@ -3169,13 +3302,16 @@ class MediaUploadLimitsModuleOverrides(unittest.HomeserverTestCase): # User 1 attempts to upload 20000 bytes which is over the weekly limit # This tests that the limits have been sorted as expected channel = self.upload_media(20000, self.tok1) - self.assertEqual(channel.code, 400) + self.assertEqual(channel.code, 403) assert self.last_media_upload_limit_exceeded is not None self.assertEqual(self.last_media_upload_limit_exceeded["user_id"], self.user1) self.assertEqual( self.last_media_upload_limit_exceeded["limit"], MediaUploadLimit( - max_bytes=15000, time_period_ms=Config.parse_duration("1w") + max_bytes=15000, + time_period_ms=Config.parse_duration("1w"), + info_uri="https://override.example.com/limits#weekly", + can_upgrade=False, ), ) self.assertEqual(self.last_media_upload_limit_exceeded["sent_bytes"], 3000) @@ -3198,18 +3334,59 @@ class MediaUploadLimitsModuleOverrides(unittest.HomeserverTestCase): # User 3 uploads 800 bytes which is over the limit channel = self.upload_media(800, self.tok3) - self.assertEqual(channel.code, 400) + self.assertEqual(channel.code, 403) assert self.last_media_upload_limit_exceeded is not None self.assertEqual(self.last_media_upload_limit_exceeded["user_id"], self.user3) self.assertEqual( self.last_media_upload_limit_exceeded["limit"], MediaUploadLimit( - max_bytes=1024, time_period_ms=Config.parse_duration("1d") + max_bytes=1024, + time_period_ms=Config.parse_duration("1d"), + info_uri="https://example.com/limits#daily", + can_upgrade=False, ), ) self.assertEqual(self.last_media_upload_limit_exceeded["sent_bytes"], 500) self.assertEqual(self.last_media_upload_limit_exceeded["attempted_bytes"], 800) + def test_module_limit_without_info_uri_falls_back(self) -> None: + """A limit returned by a module callback without an info_uri falls back + to the static page served by Synapse when the error is generated.""" + + async def _limits_without_info_uri( + user_id: str, + ) -> list[MediaUploadLimit] | None: + # info_uri is omitted (defaults to None). + return [ + MediaUploadLimit( + time_period_ms=Config.parse_duration("1d"), + max_bytes=123, + ) + ] + + self.hs.get_module_api().register_media_repository_callbacks( + get_media_upload_limits_for_user=_limits_without_info_uri, + ) + + # This is over the limit of 123 bytes, but below the default limit of 1k bytes + channel = self.upload_media(130, self.tok3) + self.assertEqual(channel.code, 403) + self.assertEqual(channel.json_body["errcode"], "M_USER_LIMIT_EXCEEDED") + + # Assert that the upload limit from the module callback was used (identified by + # max_bytes=123) (as opposed to the global default). + assert self.last_media_upload_limit_exceeded is not None + limit = self.last_media_upload_limit_exceeded["limit"] + assert isinstance(limit, MediaUploadLimit) + self.assertEqual(limit.max_bytes, 123) + + # Assert that the info_uri was correctly populated (even though not provided by the module) + expected_info_uri = ( + self.hs.config.server.public_baseurl + + MEDIA_UPLOAD_LIMIT_EXCEEDED_PATH.lstrip("/") + ) + self.assertEqual(channel.json_body["info_uri"], expected_info_uri) + class AnimatedThumbnailTestCase(unittest.HomeserverTestCase): """End-to-end tests for the `animated` query parameter on the local