mirror of
https://github.com/element-hq/synapse.git
synced 2025-11-28 00:02:07 -05:00
Add media repository callbacks to module API to control media upload size (#18457)
Adds new callbacks for media related functionality: - `get_media_config_for_user` - `is_user_allowed_to_upload_media_of_size`
This commit is contained in:
parent
fbe7a898f0
commit
379356c0ea
1
changelog.d/18457.feature
Normal file
1
changelog.d/18457.feature
Normal file
@ -0,0 +1 @@
|
|||||||
|
Add new module API callbacks that allows overriding of media repository maximum upload size.
|
||||||
@ -49,6 +49,7 @@
|
|||||||
- [Background update controller callbacks](modules/background_update_controller_callbacks.md)
|
- [Background update controller callbacks](modules/background_update_controller_callbacks.md)
|
||||||
- [Account data callbacks](modules/account_data_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)
|
- [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)
|
- [Porting a legacy module to the new interface](modules/porting_legacy_module.md)
|
||||||
- [Workers](workers.md)
|
- [Workers](workers.md)
|
||||||
- [Using `synctl` with Workers](synctl_workers.md)
|
- [Using `synctl` with Workers](synctl_workers.md)
|
||||||
|
|||||||
56
docs/modules/media_repository_callbacks.md
Normal file
56
docs/modules/media_repository_callbacks.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 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.132.0_
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_media_config_for_user(user_id: str) -> Optional[JsonDict]
|
||||||
|
```
|
||||||
|
|
||||||
|
Called when processing a request from a client for the
|
||||||
|
[media config endpoint](https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediaconfig).
|
||||||
|
|
||||||
|
The arguments passed to this callback are:
|
||||||
|
|
||||||
|
* `user_id`: The Matrix user ID of the user (e.g. `@alice:example.com`) making the request.
|
||||||
|
|
||||||
|
If the callback returns a dictionary then it will be used as the body of the response to the
|
||||||
|
client.
|
||||||
|
|
||||||
|
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 media config will be returned.
|
||||||
|
|
||||||
|
### `is_user_allowed_to_upload_media_of_size`
|
||||||
|
|
||||||
|
_First introduced in Synapse v1.132.0_
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def is_user_allowed_to_upload_media_of_size(user_id: 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 arguments passed to this callback are:
|
||||||
|
|
||||||
|
* `user_id`: The Matrix user ID of the user (e.g. `@alice:example.com`) making the request.
|
||||||
|
* `size`: The size in bytes of media that is being requested to upload.
|
||||||
|
|
||||||
|
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.
|
||||||
@ -90,6 +90,10 @@ from synapse.module_api.callbacks.account_validity_callbacks import (
|
|||||||
ON_USER_LOGIN_CALLBACK,
|
ON_USER_LOGIN_CALLBACK,
|
||||||
ON_USER_REGISTRATION_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 (
|
from synapse.module_api.callbacks.spamchecker_callbacks import (
|
||||||
CHECK_EVENT_FOR_SPAM_CALLBACK,
|
CHECK_EVENT_FOR_SPAM_CALLBACK,
|
||||||
CHECK_LOGIN_FOR_SPAM_CALLBACK,
|
CHECK_LOGIN_FOR_SPAM_CALLBACK,
|
||||||
@ -360,6 +364,22 @@ class ModuleApi:
|
|||||||
on_legacy_admin_request=on_legacy_admin_request,
|
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(
|
def register_third_party_rules_callbacks(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@ -27,6 +27,9 @@ if TYPE_CHECKING:
|
|||||||
from synapse.module_api.callbacks.account_validity_callbacks import (
|
from synapse.module_api.callbacks.account_validity_callbacks import (
|
||||||
AccountValidityModuleApiCallbacks,
|
AccountValidityModuleApiCallbacks,
|
||||||
)
|
)
|
||||||
|
from synapse.module_api.callbacks.media_repository_callbacks import (
|
||||||
|
MediaRepositoryModuleApiCallbacks,
|
||||||
|
)
|
||||||
from synapse.module_api.callbacks.spamchecker_callbacks import (
|
from synapse.module_api.callbacks.spamchecker_callbacks import (
|
||||||
SpamCheckerModuleApiCallbacks,
|
SpamCheckerModuleApiCallbacks,
|
||||||
)
|
)
|
||||||
@ -38,5 +41,6 @@ from synapse.module_api.callbacks.third_party_event_rules_callbacks import (
|
|||||||
class ModuleApiCallbacks:
|
class ModuleApiCallbacks:
|
||||||
def __init__(self, hs: "HomeServer") -> None:
|
def __init__(self, hs: "HomeServer") -> None:
|
||||||
self.account_validity = AccountValidityModuleApiCallbacks()
|
self.account_validity = AccountValidityModuleApiCallbacks()
|
||||||
|
self.media_repository = MediaRepositoryModuleApiCallbacks(hs)
|
||||||
self.spam_checker = SpamCheckerModuleApiCallbacks(hs)
|
self.spam_checker = SpamCheckerModuleApiCallbacks(hs)
|
||||||
self.third_party_event_rules = ThirdPartyEventRulesModuleApiCallbacks(hs)
|
self.third_party_event_rules = ThirdPartyEventRulesModuleApiCallbacks(hs)
|
||||||
|
|||||||
76
synapse/module_api/callbacks/media_repository_callbacks.py
Normal file
76
synapse/module_api/callbacks/media_repository_callbacks.py
Normal file
@ -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
|
||||||
@ -102,10 +102,17 @@ class MediaConfigResource(RestServlet):
|
|||||||
self.clock = hs.get_clock()
|
self.clock = hs.get_clock()
|
||||||
self.auth = hs.get_auth()
|
self.auth = hs.get_auth()
|
||||||
self.limits_dict = {"m.upload.size": config.media.max_upload_size}
|
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:
|
async def on_GET(self, request: SynapseRequest) -> None:
|
||||||
await self.auth.get_user_by_req(request)
|
requester = await self.auth.get_user_by_req(request)
|
||||||
respond_with_json(request, 200, self.limits_dict, send_cors=True)
|
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):
|
class ThumbnailResource(RestServlet):
|
||||||
|
|||||||
@ -40,7 +40,14 @@ class MediaConfigResource(RestServlet):
|
|||||||
self.clock = hs.get_clock()
|
self.clock = hs.get_clock()
|
||||||
self.auth = hs.get_auth()
|
self.auth = hs.get_auth()
|
||||||
self.limits_dict = {"m.upload.size": config.media.max_upload_size}
|
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:
|
async def on_GET(self, request: SynapseRequest) -> None:
|
||||||
await self.auth.get_user_by_req(request)
|
requester = await self.auth.get_user_by_req(request)
|
||||||
respond_with_json(request, 200, self.limits_dict, send_cors=True)
|
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)
|
||||||
|
|||||||
@ -50,9 +50,12 @@ class BaseUploadServlet(RestServlet):
|
|||||||
self.server_name = hs.hostname
|
self.server_name = hs.hostname
|
||||||
self.auth = hs.get_auth()
|
self.auth = hs.get_auth()
|
||||||
self.max_upload_size = hs.config.media.max_upload_size
|
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(
|
async def _get_file_metadata(
|
||||||
self, request: SynapseRequest
|
self, request: SynapseRequest, user_id: str
|
||||||
) -> Tuple[int, Optional[str], str]:
|
) -> Tuple[int, Optional[str], str]:
|
||||||
raw_content_length = request.getHeader("Content-Length")
|
raw_content_length = request.getHeader("Content-Length")
|
||||||
if raw_content_length is None:
|
if raw_content_length is None:
|
||||||
@ -67,7 +70,14 @@ class BaseUploadServlet(RestServlet):
|
|||||||
code=413,
|
code=413,
|
||||||
errcode=Codes.TOO_LARGE,
|
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
|
args: Dict[bytes, List[bytes]] = request.args # type: ignore
|
||||||
upload_name_bytes = parse_bytes_from_args(args, "filename")
|
upload_name_bytes = parse_bytes_from_args(args, "filename")
|
||||||
if upload_name_bytes:
|
if upload_name_bytes:
|
||||||
@ -104,7 +114,9 @@ class UploadServlet(BaseUploadServlet):
|
|||||||
|
|
||||||
async def on_POST(self, request: SynapseRequest) -> None:
|
async def on_POST(self, request: SynapseRequest) -> None:
|
||||||
requester = await self.auth.get_user_by_req(request)
|
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:
|
try:
|
||||||
content: IO = request.content # type: ignore
|
content: IO = request.content # type: ignore
|
||||||
@ -152,7 +164,9 @@ class AsyncUploadServlet(BaseUploadServlet):
|
|||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
await self.media_repo.verify_can_upload(media_id, requester.user)
|
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:
|
try:
|
||||||
content: IO = request.content # type: ignore
|
content: IO = request.content # type: ignore
|
||||||
|
|||||||
@ -1360,3 +1360,42 @@ class MediaHashesTestCase(unittest.HomeserverTestCase):
|
|||||||
store_media.sha256,
|
store_media.sha256,
|
||||||
SMALL_PNG_SHA256,
|
SMALL_PNG_SHA256,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaRepoSizeModuleCallbackTestCase(unittest.HomeserverTestCase):
|
||||||
|
servlets = [
|
||||||
|
login.register_servlets,
|
||||||
|
admin.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||||
|
self.user = self.register_user("user", "pass")
|
||||||
|
self.tok = self.login("user", "pass")
|
||||||
|
self.mock_result = True # Allow all uploads by default
|
||||||
|
|
||||||
|
hs.get_module_api().register_media_repository_callbacks(
|
||||||
|
is_user_allowed_to_upload_media_of_size=self.is_user_allowed_to_upload_media_of_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_resource_dict(self) -> Dict[str, Resource]:
|
||||||
|
resources = super().create_resource_dict()
|
||||||
|
resources["/_matrix/media"] = self.hs.get_media_repository_resource()
|
||||||
|
return resources
|
||||||
|
|
||||||
|
async def is_user_allowed_to_upload_media_of_size(
|
||||||
|
self, user_id: str, size: int
|
||||||
|
) -> bool:
|
||||||
|
self.last_user_id = user_id
|
||||||
|
self.last_size = size
|
||||||
|
return self.mock_result
|
||||||
|
|
||||||
|
def test_upload_allowed(self) -> None:
|
||||||
|
self.helper.upload_media(SMALL_PNG, tok=self.tok, expect_code=200)
|
||||||
|
assert self.last_user_id == self.user
|
||||||
|
assert self.last_size == len(SMALL_PNG)
|
||||||
|
|
||||||
|
def test_upload_not_allowed(self) -> None:
|
||||||
|
self.mock_result = False
|
||||||
|
self.helper.upload_media(SMALL_PNG, tok=self.tok, expect_code=413)
|
||||||
|
assert self.last_user_id == self.user
|
||||||
|
assert self.last_size == len(SMALL_PNG)
|
||||||
|
|||||||
@ -1618,6 +1618,63 @@ class MediaConfigTest(unittest.HomeserverTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaConfigModuleCallbackTestCase(unittest.HomeserverTestCase):
|
||||||
|
servlets = [
|
||||||
|
media.register_servlets,
|
||||||
|
admin.register_servlets,
|
||||||
|
login.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def make_homeserver(
|
||||||
|
self, reactor: ThreadedMemoryReactorClock, clock: Clock
|
||||||
|
) -> HomeServer:
|
||||||
|
config = self.default_config()
|
||||||
|
|
||||||
|
self.storage_path = self.mktemp()
|
||||||
|
self.media_store_path = self.mktemp()
|
||||||
|
os.mkdir(self.storage_path)
|
||||||
|
os.mkdir(self.media_store_path)
|
||||||
|
config["media_store_path"] = self.media_store_path
|
||||||
|
|
||||||
|
provider_config = {
|
||||||
|
"module": "synapse.media.storage_provider.FileStorageProviderBackend",
|
||||||
|
"store_local": True,
|
||||||
|
"store_synchronous": False,
|
||||||
|
"store_remote": True,
|
||||||
|
"config": {"directory": self.storage_path},
|
||||||
|
}
|
||||||
|
|
||||||
|
config["media_storage_providers"] = [provider_config]
|
||||||
|
|
||||||
|
return self.setup_test_homeserver(config=config)
|
||||||
|
|
||||||
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||||
|
self.user = self.register_user("user", "password")
|
||||||
|
self.tok = self.login("user", "password")
|
||||||
|
|
||||||
|
hs.get_module_api().register_media_repository_callbacks(
|
||||||
|
get_media_config_for_user=self.get_media_config_for_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_media_config_for_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
) -> Optional[JsonDict]:
|
||||||
|
# We echo back the user_id and set a custom upload size.
|
||||||
|
return {"m.upload.size": 1024, "user_id": user_id}
|
||||||
|
|
||||||
|
def test_media_config(self) -> None:
|
||||||
|
channel = self.make_request(
|
||||||
|
"GET",
|
||||||
|
"/_matrix/client/v1/media/config",
|
||||||
|
shorthand=False,
|
||||||
|
access_token=self.tok,
|
||||||
|
)
|
||||||
|
self.assertEqual(channel.code, 200)
|
||||||
|
self.assertEqual(channel.json_body["m.upload.size"], 1024)
|
||||||
|
self.assertEqual(channel.json_body["user_id"], self.user)
|
||||||
|
|
||||||
|
|
||||||
class RemoteDownloadLimiterTestCase(unittest.HomeserverTestCase):
|
class RemoteDownloadLimiterTestCase(unittest.HomeserverTestCase):
|
||||||
servlets = [
|
servlets = [
|
||||||
media.register_servlets,
|
media.register_servlets,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user