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: +#