1
0

Compare commits

...

6 Commits

Author SHA1 Message Date
Hugh Nimmo-Smith
82fcdc5eef Changelog 2025-06-23 14:50:08 +01:00
Hugh Nimmo-Smith
2cfc292c77 Allow sending server notices via Module API 2025-06-23 14:50:08 +01:00
Hugh Nimmo-Smith
7ba64b6caf Changelog 2025-06-23 14:49:06 +01:00
Hugh Nimmo-Smith
68699d5338 Support workers sending server notices 2025-06-23 14:49:05 +01:00
V02460
3cabaa84ca Update PyO3 to version 0.25 (#18578)
Updates `pyo3` to version 0.25.1 and, accordingly, `pyo3-log` to v0.12.4
and `pythonize` to v0.25.0.

PyO3 v0.25 enables Python 3.14 support.
2025-06-23 13:48:07 +01:00
Travis Ralston
74ca7ae720 Add report user API from MSC4260 (#18120)
Co-authored-by: turt2live <1190097+turt2live@users.noreply.github.com>
Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
2025-06-20 13:02:14 +01:00
24 changed files with 608 additions and 96 deletions

25
Cargo.lock generated
View File

@@ -809,12 +809,11 @@ dependencies = [
[[package]]
name = "pyo3"
version = "0.24.2"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219"
checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a"
dependencies = [
"anyhow",
"cfg-if",
"indoc",
"libc",
"memoffset",
@@ -828,9 +827,9 @@ dependencies = [
[[package]]
name = "pyo3-build-config"
version = "0.24.2"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999"
checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598"
dependencies = [
"once_cell",
"target-lexicon",
@@ -838,9 +837,9 @@ dependencies = [
[[package]]
name = "pyo3-ffi"
version = "0.24.2"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33"
checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c"
dependencies = [
"libc",
"pyo3-build-config",
@@ -859,9 +858,9 @@ dependencies = [
[[package]]
name = "pyo3-macros"
version = "0.24.2"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9"
checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
@@ -871,9 +870,9 @@ dependencies = [
[[package]]
name = "pyo3-macros-backend"
version = "0.24.2"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a"
checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc"
dependencies = [
"heck",
"proc-macro2",
@@ -884,9 +883,9 @@ dependencies = [
[[package]]
name = "pythonize"
version = "0.24.0"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5bcac0d0b71821f0d69e42654f1e15e5c94b85196446c4de9588951a2117e7b"
checksum = "597907139a488b22573158793aa7539df36ae863eba300c75f3a0d65fc475e27"
dependencies = [
"pyo3",
"serde",

View File

@@ -0,0 +1 @@
Add support for the [MSC4260 user report API](https://github.com/matrix-org/matrix-spec-proposals/pull/4260).

1
changelog.d/18569.misc Normal file
View File

@@ -0,0 +1 @@
Allow worker processes to send server notices.

1
changelog.d/18570.feat Normal file
View File

@@ -0,0 +1 @@
Support modules sending server notices.

1
changelog.d/18578.misc Normal file
View File

@@ -0,0 +1 @@
Update PyO3 to version 0.25.

View File

@@ -1937,6 +1937,33 @@ rc_delayed_event_mgmt:
burst_count: 20.0
```
---
### `rc_reports`
*(object)* Ratelimiting settings for reporting content.
This is a ratelimiting option that ratelimits reports made by users about content they see.
Setting this to a high value allows users to report content quickly, possibly in duplicate. This can result in higher database usage.
This setting has the following sub-options:
* `per_second` (number): Maximum number of requests a client can send per second.
* `burst_count` (number): Maximum number of requests a client can send before being throttled.
Default configuration:
```yaml
rc_reports:
per_user:
per_second: 1.0
burst_count: 5.0
```
Example configuration:
```yaml
rc_reports:
per_second: 2.0
burst_count: 20.0
```
---
### `federation_rr_transactions_per_room_per_second`
*(integer)* Sets outgoing federation transaction frequency for sending read-receipts, per-room.

View File

@@ -30,14 +30,14 @@ http = "1.1.0"
lazy_static = "1.4.0"
log = "0.4.17"
mime = "0.3.17"
pyo3 = { version = "0.24.2", features = [
pyo3 = { version = "0.25.1", features = [
"macros",
"anyhow",
"abi3",
"abi3-py39",
] }
pyo3-log = "0.12.3"
pythonize = "0.24.0"
pyo3-log = "0.12.4"
pythonize = "0.25.0"
regex = "1.6.0"
sha2 = "0.10.8"
serde = { version = "1.0.144", features = ["derive"] }

View File

@@ -2185,6 +2185,23 @@ properties:
examples:
- per_second: 2.0
burst_count: 20.0
rc_reports:
$ref: "#/$defs/rc"
description: >-
Ratelimiting settings for reporting content.
This is a ratelimiting option that ratelimits reports made by users
about content they see.
Setting this to a high value allows users to report content quickly, possibly in
duplicate. This can result in higher database usage.
default:
per_user:
per_second: 1.0
burst_count: 5.0
examples:
- per_second: 2.0
burst_count: 20.0
federation_rr_transactions_per_room_per_second:
type: integer
description: >-

View File

@@ -240,3 +240,9 @@ class RatelimitConfig(Config):
"rc_delayed_event_mgmt",
defaults={"per_second": 1, "burst_count": 5},
)
self.rc_reports = RatelimitSettings.parse(
config,
"rc_reports",
defaults={"per_second": 1, "burst_count": 5},
)

View File

@@ -0,0 +1,98 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright 2015, 2016 OpenMarket Ltd
# Copyright (C) 2023 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 http import HTTPStatus
from typing import TYPE_CHECKING
from synapse.api.errors import Codes, SynapseError
from synapse.api.ratelimiting import Ratelimiter
from synapse.types import (
Requester,
)
if TYPE_CHECKING:
from synapse.server import HomeServer
logger = logging.getLogger(__name__)
class ReportsHandler:
def __init__(self, hs: "HomeServer"):
self._hs = hs
self._store = hs.get_datastores().main
self._clock = hs.get_clock()
# Ratelimiter for management of existing delayed events,
# keyed by the requesting user ID.
self._reports_ratelimiter = Ratelimiter(
store=self._store,
clock=self._clock,
cfg=hs.config.ratelimiting.rc_reports,
)
async def report_user(
self, requester: Requester, target_user_id: str, reason: str
) -> None:
"""Files a report against a user from a user.
Rate and size limits are applied to the report. If the user being reported
does not belong to this server, the report is ignored. This check is done
after the limits to reduce DoS potential.
If the user being reported belongs to this server, but doesn't exist, we
similarly ignore the report. The spec allows us to return an error if we
want to, but we choose to hide that user's existence instead.
If the report is otherwise valid (for a user which exists on our server),
we append it to the database for later processing.
Args:
requester - The user filing the report.
target_user_id - The user being reported.
reason - The user-supplied reason the user is being reported.
Raises:
SynapseError for BAD_REQUEST/BAD_JSON if the reason is too long.
"""
await self._check_limits(requester)
if len(reason) > 1000:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Reason must be less than 1000 characters",
Codes.BAD_JSON,
)
if not self._hs.is_mine_id(target_user_id):
return # hide that they're not ours/that we can't do anything about them
user = await self._store.get_user_by_id(target_user_id)
if user is None:
return # hide that they don't exist
await self._store.add_user_report(
target_user_id=target_user_id,
user_id=requester.user.to_string(),
reason=reason,
received_ts=self._clock.time_msec(),
)
async def _check_limits(self, requester: Requester) -> None:
await self._reports_ratelimiter.ratelimit(
requester,
requester.user.to_string(),
)

View File

@@ -1892,6 +1892,33 @@ class ModuleApi:
"""Returns the current server time in milliseconds."""
return self._clock.time_msec()
async def send_server_notice(
self, user_id: str, type: str, event_content: JsonDict
) -> JsonDict:
"""Send a server notice to a user.
Added in Synapse v1.133.0.
Args:
user_id: The full user ID to send the server notice to. This must be a user
local to this homeserver.
type: The type of event to send. e.g. m.room.message
event_content: A dictionary representing the content of the event to send.
Returns:
The event that was sent. If state event deduplication happened, then
the previous, duplicate event instead.
Raises:
SynapseError if the event was not allowed.
"""
# Create and send the notice
event = await self._hs.get_server_notices_manager().send_notice(
user_id=user_id, event_content=event_content, type=type
)
return event
class PublicRoomListManager:
"""Contains methods for adding to, removing from and querying whether a room

View File

@@ -1,7 +1,7 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2023-2024 New Vector, Ltd
# Copyright (C) 2023-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
@@ -33,6 +33,7 @@ from synapse.replication.http import (
register,
send_event,
send_events,
server_notices,
state,
streams,
)
@@ -66,3 +67,4 @@ class ReplicationRestResource(JsonResource):
register.register_servlets(hs, self)
devices.register_servlets(hs, self)
delayed_events.register_servlets(hs, self)
server_notices.register_servlets(hs, self)

View File

@@ -0,0 +1,80 @@
#
# 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, Optional, Tuple
from twisted.web.server import Request
from synapse.http.server import HttpServer
from synapse.replication.http._base import ReplicationEndpoint
from synapse.types import JsonDict
if TYPE_CHECKING:
from synapse.server import HomeServer
logger = logging.getLogger(__name__)
class ReplicationSendServerNoticeServlet(ReplicationEndpoint):
"""Send a server notice to a user"""
NAME = "send_server_notice"
PATH_ARGS = ()
def __init__(self, hs: "HomeServer"):
super().__init__(hs)
self.server_notices_manager = hs.get_server_notices_manager()
@staticmethod
async def _serialize_payload( # type: ignore[override]
user_id: str,
event_content: dict,
type: str,
state_key: Optional[str] = None,
txn_id: Optional[str] = None,
) -> JsonDict:
"""
Args:
user_id: mxid of user to send event to.
event_content: content of event to send
type: type of event
state_key: the state key for the event, if it is a state event
txn_id: the transaction ID
"""
return {
"user_id": user_id,
"event_content": event_content,
"type": type,
"state_key": state_key,
"txn_id": txn_id,
}
async def _handle_request( # type: ignore[override]
self, request: Request, content: JsonDict
) -> Tuple[int, JsonDict]:
event = await self.server_notices_manager.send_notice(
user_id=content["user_id"],
event_content=content["event_content"],
type=content["type"],
state_key=content["state_key"],
txn_id=content["txn_id"],
)
return 200, event
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
ReplicationSendServerNoticeServlet(hs).register(http_server)

View File

@@ -88,9 +88,6 @@ class SendServerNoticeServlet(RestServlet):
event_type = body.get("type", EventTypes.Message)
state_key = body.get("state_key")
# We grab the server notices manager here as its initialisation has a check for worker processes,
# but worker processes still need to initialise SendServerNoticeServlet (as it is part of the
# admin api).
if not self.server_notices_manager.is_enabled():
raise SynapseError(
HTTPStatus.BAD_REQUEST, "Server notices are not enabled on this server"
@@ -113,7 +110,7 @@ class SendServerNoticeServlet(RestServlet):
txn_id=txn_id,
)
return HTTPStatus.OK, {"event_id": event.event_id}
return HTTPStatus.OK, {"event_id": event["event_id"]}
async def on_POST(
self,

View File

@@ -150,6 +150,44 @@ class ReportRoomRestServlet(RestServlet):
return 200, {}
class ReportUserRestServlet(RestServlet):
"""This endpoint lets clients report a user for abuse.
Introduced by MSC4260: https://github.com/matrix-org/matrix-spec-proposals/pull/4260
"""
PATTERNS = list(
client_patterns(
"/users/(?P<target_user_id>[^/]*)/report$",
releases=("v3",),
unstable=False,
v1=False,
)
)
def __init__(self, hs: "HomeServer"):
super().__init__()
self.hs = hs
self.auth = hs.get_auth()
self.clock = hs.get_clock()
self.store = hs.get_datastores().main
self.handler = hs.get_reports_handler()
class PostBody(RequestBodyModel):
reason: StrictStr
async def on_POST(
self, request: SynapseRequest, target_user_id: str
) -> Tuple[int, JsonDict]:
requester = await self.auth.get_user_by_req(request)
body = parse_and_validate_json_object_from_request(request, self.PostBody)
await self.handler.report_user(requester, target_user_id, body.reason)
return 200, {}
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
ReportEventRestServlet(hs).register(http_server)
ReportRoomRestServlet(hs).register(http_server)
ReportUserRestServlet(hs).register(http_server)

View File

@@ -94,6 +94,7 @@ from synapse.handlers.read_marker import ReadMarkerHandler
from synapse.handlers.receipts import ReceiptsHandler
from synapse.handlers.register import RegistrationHandler
from synapse.handlers.relations import RelationsHandler
from synapse.handlers.reports import ReportsHandler
from synapse.handlers.room import (
RoomContextHandler,
RoomCreationHandler,
@@ -141,6 +142,9 @@ from synapse.replication.tcp.streams import STREAMS_MAP, Stream
from synapse.rest.media.media_repository_resource import MediaRepositoryResource
from synapse.server_notices.server_notices_manager import ServerNoticesManager
from synapse.server_notices.server_notices_sender import ServerNoticesSender
from synapse.server_notices.worker_server_notices_manager import (
WorkerServerNoticesManager,
)
from synapse.server_notices.worker_server_notices_sender import (
WorkerServerNoticesSender,
)
@@ -718,6 +722,10 @@ class HomeServer(metaclass=abc.ABCMeta):
def get_receipts_handler(self) -> ReceiptsHandler:
return ReceiptsHandler(self)
@cache_in_self
def get_reports_handler(self) -> ReportsHandler:
return ReportsHandler(self)
@cache_in_self
def get_read_marker_handler(self) -> ReadMarkerHandler:
return ReadMarkerHandler(self)
@@ -753,9 +761,9 @@ class HomeServer(metaclass=abc.ABCMeta):
return FederationHandlerRegistry(self)
@cache_in_self
def get_server_notices_manager(self) -> ServerNoticesManager:
def get_server_notices_manager(self) -> WorkerServerNoticesManager:
if self.config.worker.worker_app:
raise Exception("Workers cannot send server notices")
return WorkerServerNoticesManager(self)
return ServerNoticesManager(self)
@cache_in_self

View File

@@ -165,7 +165,7 @@ class ResourceLimitsServerNotices:
user_id, content, EventTypes.Message
)
content = {"pinned": [event.event_id]}
content = {"pinned": [event["event_id"]]}
await self._server_notices_manager.send_notice(
user_id, content, EventTypes.Pinned, ""
)

View File

@@ -21,7 +21,9 @@ import logging
from typing import TYPE_CHECKING, Optional
from synapse.api.constants import EventTypes, Membership, RoomCreationPreset
from synapse.events import EventBase
from synapse.server_notices.worker_server_notices_manager import (
WorkerServerNoticesManager,
)
from synapse.types import JsonDict, Requester, StreamKeyType, UserID, create_requester
from synapse.util.caches.descriptors import cached
@@ -33,9 +35,9 @@ logger = logging.getLogger(__name__)
SERVER_NOTICE_ROOM_TAG = "m.server_notice"
class ServerNoticesManager:
class ServerNoticesManager(WorkerServerNoticesManager):
def __init__(self, hs: "HomeServer"):
self._store = hs.get_datastores().main
super().__init__(hs)
self._config = hs.config
self._account_data_handler = hs.get_account_data_handler()
self._room_creation_handler = hs.get_room_creation_handler()
@@ -47,11 +49,6 @@ class ServerNoticesManager:
self._server_name = hs.hostname
self._notifier = hs.get_notifier()
self.server_notices_mxid = self._config.servernotices.server_notices_mxid
def is_enabled(self) -> bool:
"""Checks if server notices are enabled on this server."""
return self.server_notices_mxid is not None
async def send_notice(
self,
@@ -60,7 +57,7 @@ class ServerNoticesManager:
type: str = EventTypes.Message,
state_key: Optional[str] = None,
txn_id: Optional[str] = None,
) -> EventBase:
) -> JsonDict:
"""Send a notice to the given user
Creates the server notices room, if none exists.
@@ -69,15 +66,16 @@ class ServerNoticesManager:
user_id: mxid of user to send event to.
event_content: content of event to send
type: type of event
is_state_event: Is the event a state event
state_key: the state key for the event, if it is a state event
txn_id: The transaction ID.
"""
room_id = await self.get_or_create_notice_room_for_user(user_id)
await self.maybe_invite_user_to_room(user_id, room_id)
room_id = await self._get_or_create_notice_room_for_user(user_id)
await self._maybe_invite_user_to_room(user_id, room_id)
assert self.server_notices_mxid is not None
assert self._server_notices_mxid is not None
requester = create_requester(
self.server_notices_mxid, authenticated_entity=self._server_name
self._server_notices_mxid,
authenticated_entity=self._server_name,
)
logger.info("Sending server notice to %s", user_id)
@@ -85,7 +83,7 @@ class ServerNoticesManager:
event_dict = {
"type": type,
"room_id": room_id,
"sender": self.server_notices_mxid,
"sender": self._server_notices_mxid,
"content": event_content,
}
@@ -95,45 +93,10 @@ class ServerNoticesManager:
event, _ = await self._event_creation_handler.create_and_send_nonmember_event(
requester, event_dict, ratelimit=False, txn_id=txn_id
)
return event
return event.get_dict()
@cached()
async def maybe_get_notice_room_for_user(self, user_id: str) -> Optional[str]:
"""Try to look up the server notice room for this user if it exists.
Does not create one if none can be found.
Args:
user_id: the user we want a server notice room for.
Returns:
The room's ID, or None if no room could be found.
"""
# If there is no server notices MXID, then there is no server notices room
if self.server_notices_mxid is None:
return None
rooms = await self._store.get_rooms_for_local_user_where_membership_is(
user_id, [Membership.INVITE, Membership.JOIN]
)
for room in rooms:
# it's worth noting that there is an asymmetry here in that we
# expect the user to be invited or joined, but the system user must
# be joined. This is kinda deliberate, in that if somebody somehow
# manages to invite the system user to a room, that doesn't make it
# the server notices room.
is_server_notices_room = await self._store.check_local_user_in_room(
user_id=self.server_notices_mxid, room_id=room.room_id
)
if is_server_notices_room:
# we found a room which our user shares with the system notice
# user
return room.room_id
return None
@cached()
async def get_or_create_notice_room_for_user(self, user_id: str) -> str:
async def _get_or_create_notice_room_for_user(self, user_id: str) -> str:
"""Get the room for notices for a given user
If we have not yet created a notice room for this user, create it, but don't
@@ -145,13 +108,13 @@ class ServerNoticesManager:
Returns:
room id of notice room.
"""
if self.server_notices_mxid is None:
if self._server_notices_mxid is None:
raise Exception("Server notices not enabled")
assert self._is_mine_id(user_id), "Cannot send server notices to remote users"
requester = create_requester(
self.server_notices_mxid, authenticated_entity=self._server_name
self._server_notices_mxid, authenticated_entity=self._server_name
)
room_id = await self.maybe_get_notice_room_for_user(user_id)
@@ -246,7 +209,7 @@ class ServerNoticesManager:
logger.info("Created server notices room %s for %s", room_id, user_id)
return room_id
async def maybe_invite_user_to_room(self, user_id: str, room_id: str) -> None:
async def _maybe_invite_user_to_room(self, user_id: str, room_id: str) -> None:
"""Invite the given user to the given server room, unless the user has already
joined or been invited to it.
@@ -254,9 +217,9 @@ class ServerNoticesManager:
user_id: The ID of the user to invite.
room_id: The ID of the room to invite the user to.
"""
assert self.server_notices_mxid is not None
assert self._server_notices_mxid is not None
requester = create_requester(
self.server_notices_mxid, authenticated_entity=self._server_name
self._server_notices_mxid, authenticated_entity=self._server_name
)
# Check whether the user has already joined or been invited to this room. If
@@ -307,13 +270,13 @@ class ServerNoticesManager:
"""
logger.debug("Checking whether notice user profile has changed for %s", room_id)
assert self.server_notices_mxid is not None
assert self._server_notices_mxid is not None
notice_user_data_in_room = (
await self._storage_controllers.state.get_current_state_event(
room_id,
EventTypes.Member,
self.server_notices_mxid,
self._server_notices_mxid,
)
)
@@ -327,7 +290,7 @@ class ServerNoticesManager:
logger.info("Updating notice user profile in room %s", room_id)
await self._room_member_handler.update_membership(
requester=requester,
target=UserID.from_string(self.server_notices_mxid),
target=UserID.from_string(self._server_notices_mxid),
room_id=room_id,
action="join",
ratelimit=False,

View File

@@ -0,0 +1,101 @@
#
# 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, Optional
from synapse.api.constants import EventTypes, Membership
from synapse.events import JsonDict
from synapse.replication.http.server_notices import (
ReplicationSendServerNoticeServlet,
)
from synapse.util.caches.descriptors import cached
if TYPE_CHECKING:
from synapse.server import HomeServer
logger = logging.getLogger(__name__)
class WorkerServerNoticesManager:
def __init__(self, hs: "HomeServer"):
self._store = hs.get_datastores().main
self._server_notices_mxid = hs.config.servernotices.server_notices_mxid
self._send_server_notice = ReplicationSendServerNoticeServlet.make_client(hs)
def is_enabled(self) -> bool:
"""Checks if server notices are enabled on this server."""
return self._server_notices_mxid is not None
async def send_notice(
self,
user_id: str,
event_content: dict,
type: str = EventTypes.Message,
state_key: Optional[str] = None,
txn_id: Optional[str] = None,
) -> JsonDict:
"""Send a notice to the given user
Creates the server notices room, if none exists.
Args:
user_id: mxid of user to send event to.
event_content: content of event to send
type: type of event
is_state_event: Is the event a state event
txn_id: The transaction ID.
"""
return await self._send_server_notice(
user_id=user_id,
event_content=event_content,
type=type,
state_key=state_key,
txn_id=txn_id,
)
@cached()
async def maybe_get_notice_room_for_user(self, user_id: str) -> Optional[str]:
"""Try to look up the server notice room for this user if it exists.
Does not create one if none can be found.
Args:
user_id: the user we want a server notice room for.
Returns:
The room's ID, or None if no room could be found.
"""
# If there is no server notices MXID, then there is no server notices room
if self._server_notices_mxid is None:
return None
rooms = await self._store.get_rooms_for_local_user_where_membership_is(
user_id, [Membership.INVITE, Membership.JOIN]
)
for room in rooms:
# it's worth noting that there is an asymmetry here in that we
# expect the user to be invited or joined, but the system user must
# be joined. This is kinda deliberate, in that if somebody somehow
# manages to invite the system user to a room, that doesn't make it
# the server notices room.
is_server_notices_room = await self._store.check_local_user_in_room(
user_id=self._server_notices_mxid, room_id=room.room_id
)
if is_server_notices_room:
# we found a room which our user shares with the system notice
# user
return room.room_id
return None

View File

@@ -2421,6 +2421,7 @@ class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore):
self._event_reports_id_gen = IdGenerator(db_conn, "event_reports", "id")
self._room_reports_id_gen = IdGenerator(db_conn, "room_reports", "id")
self._user_reports_id_gen = IdGenerator(db_conn, "user_reports", "id")
self._instance_name = hs.get_instance_name()
@@ -2662,6 +2663,37 @@ class RoomStore(RoomBackgroundUpdateStore, RoomWorkerStore):
)
return next_id
async def add_user_report(
self,
target_user_id: str,
user_id: str,
reason: str,
received_ts: int,
) -> int:
"""Add a user report
Args:
target_user_id: The user ID being reported.
user_id: User who reported the user.
reason: Description that the user specifies.
received_ts: Time when the user submitted the report (milliseconds).
Returns:
ID of the room report.
"""
next_id = self._user_reports_id_gen.get_next()
await self.db_pool.simple_insert(
table="user_reports",
values={
"id": next_id,
"received_ts": received_ts,
"target_user_id": target_user_id,
"user_id": user_id,
"reason": reason,
},
desc="add_user_report",
)
return next_id
async def clear_partial_state_room(self, room_id: str) -> Optional[int]:
"""Clears the partial state flag for a room.

View File

@@ -0,0 +1,22 @@
--
-- 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>.
CREATE TABLE user_reports (
id BIGINT NOT NULL PRIMARY KEY,
received_ts BIGINT NOT NULL,
target_user_id TEXT NOT NULL,
user_id TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE INDEX user_reports_target_user_id ON user_reports(target_user_id); -- for lookups
CREATE INDEX user_reports_user_id ON user_reports(user_id); -- for lookups

View File

@@ -18,7 +18,7 @@
# [This file includes modifications made by New Vector Limited]
#
#
from typing import List, Sequence
from typing import List, Sequence, cast
from twisted.test.proto_helpers import MemoryReactor
@@ -26,6 +26,7 @@ import synapse.rest.admin
from synapse.api.errors import Codes
from synapse.rest.client import login, room, sync
from synapse.server import HomeServer
from synapse.server_notices.server_notices_manager import ServerNoticesManager
from synapse.storage.roommember import RoomsForUser
from synapse.types import JsonDict
from synapse.util import Clock
@@ -47,7 +48,9 @@ class ServerNoticeTestCase(unittest.HomeserverTestCase):
self.store = hs.get_datastores().main
self.room_shutdown_handler = hs.get_room_shutdown_handler()
self.pagination_handler = hs.get_pagination_handler()
self.server_notices_manager = self.hs.get_server_notices_manager()
self.server_notices_manager = cast(
ServerNoticesManager, self.hs.get_server_notices_manager()
)
# Create user
self.admin_user = self.register_user("admin", "pass", admin=True)
@@ -276,7 +279,7 @@ class ServerNoticeTestCase(unittest.HomeserverTestCase):
self.assertEqual(messages[0]["sender"], "@notices:test")
# invalidate cache of server notices room_ids
self.server_notices_manager.get_or_create_notice_room_for_user.invalidate_all()
self.server_notices_manager._get_or_create_notice_room_for_user.invalidate_all()
# send second message
channel = self.make_request(
@@ -351,7 +354,7 @@ class ServerNoticeTestCase(unittest.HomeserverTestCase):
# invalidate cache of server notices room_ids
# if server tries to send to a cached room_id the user gets the message
# in old room
self.server_notices_manager.get_or_create_notice_room_for_user.invalidate_all()
self.server_notices_manager._get_or_create_notice_room_for_user.invalidate_all()
# send second message
channel = self.make_request(
@@ -451,7 +454,7 @@ class ServerNoticeTestCase(unittest.HomeserverTestCase):
# invalidate cache of server notices room_ids
# if server tries to send to a cached room_id it gives an error
self.server_notices_manager.get_or_create_notice_room_for_user.invalidate_all()
self.server_notices_manager._get_or_create_notice_room_for_user.invalidate_all()
# send second message
channel = self.make_request(
@@ -532,7 +535,7 @@ class ServerNoticeTestCase(unittest.HomeserverTestCase):
# simulate a change in server config after a server restart.
new_display_name = "new display name"
self.server_notices_manager._config.servernotices.server_notices_mxid_display_name = new_display_name
self.server_notices_manager.get_or_create_notice_room_for_user.cache.invalidate_all()
self.server_notices_manager._get_or_create_notice_room_for_user.cache.invalidate_all()
self.make_request(
"POST",
@@ -576,7 +579,7 @@ class ServerNoticeTestCase(unittest.HomeserverTestCase):
# simulate a change in server config after a server restart.
new_avatar_url = "test/new-url"
self.server_notices_manager._config.servernotices.server_notices_mxid_avatar_url = new_avatar_url
self.server_notices_manager.get_or_create_notice_room_for_user.cache.invalidate_all()
self.server_notices_manager._get_or_create_notice_room_for_user.cache.invalidate_all()
self.make_request(
"POST",
@@ -689,7 +692,7 @@ class ServerNoticeTestCase(unittest.HomeserverTestCase):
# simulate a change in server config after a server restart.
new_avatar_url = "test/new-url"
self.server_notices_manager._config.servernotices.server_notices_room_avatar_url = new_avatar_url
self.server_notices_manager.get_or_create_notice_room_for_user.cache.invalidate_all()
self.server_notices_manager._get_or_create_notice_room_for_user.cache.invalidate_all()
self.make_request(
"POST",

View File

@@ -18,6 +18,7 @@
# [This file includes modifications made by New Vector Limited]
#
#
from typing import Optional
from twisted.test.proto_helpers import MemoryReactor
@@ -201,3 +202,91 @@ class ReportRoomTestCase(unittest.HomeserverTestCase):
shorthand=False,
)
self.assertEqual(response_status, channel.code, msg=channel.result["body"])
class ReportUserTestCase(unittest.HomeserverTestCase):
servlets = [
synapse.rest.admin.register_servlets,
login.register_servlets,
room.register_servlets,
reporting.register_servlets,
]
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.other_user = self.register_user("user", "pass")
self.other_user_tok = self.login("user", "pass")
self.target_user_id = self.register_user("target_user", "pass")
def test_reason_str(self) -> None:
data = {"reason": "this makes me sad"}
self._assert_status(200, data)
rows = self.get_success(
self.hs.get_datastores().main.db_pool.simple_select_onecol(
table="user_reports",
keyvalues={"target_user_id": self.target_user_id},
retcol="id",
desc="get_user_report_ids",
)
)
self.assertEqual(len(rows), 1)
def test_no_reason(self) -> None:
data = {"not_reason": "for typechecking"}
self._assert_status(400, data)
def test_reason_nonstring(self) -> None:
data = {"reason": 42}
self._assert_status(400, data)
def test_reason_null(self) -> None:
data = {"reason": None}
self._assert_status(400, data)
def test_reason_long(self) -> None:
data = {"reason": "x" * 1001}
self._assert_status(400, data)
def test_cannot_report_nonlocal_user(self) -> None:
"""
Tests that we ignore reports for nonlocal users.
"""
target_user_id = "@bloop:example.org"
data = {"reason": "i am very sad"}
self._assert_status(200, data, target_user_id)
self._assert_no_reports_for_user(target_user_id)
def test_can_report_nonexistent_user(self) -> None:
"""
Tests that we ignore reports for nonexistent users.
"""
target_user_id = f"@bloop:{self.hs.hostname}"
data = {"reason": "i am very sad"}
self._assert_status(200, data, target_user_id)
self._assert_no_reports_for_user(target_user_id)
def _assert_no_reports_for_user(self, target_user_id: str) -> None:
rows = self.get_success(
self.hs.get_datastores().main.db_pool.simple_select_onecol(
table="user_reports",
keyvalues={"target_user_id": target_user_id},
retcol="id",
desc="get_user_report_ids",
)
)
self.assertEqual(len(rows), 0)
def _assert_status(
self, response_status: int, data: JsonDict, user_id: Optional[str] = None
) -> None:
if user_id is None:
user_id = self.target_user_id
channel = self.make_request(
"POST",
f"/_matrix/client/v3/users/{user_id}/report",
data,
access_token=self.other_user_tok,
shorthand=False,
)
self.assertEqual(response_status, channel.code, msg=channel.result["body"])

View File

@@ -82,9 +82,6 @@ class TestResourceLimitsServerNotices(unittest.HomeserverTestCase):
self.user_id = "@user_id:test"
self._rlsn._server_notices_manager.get_or_create_notice_room_for_user = (
AsyncMock(return_value="!something:localhost")
)
self._rlsn._server_notices_manager.maybe_get_notice_room_for_user = AsyncMock(
return_value="!something:localhost"
)
@@ -297,9 +294,11 @@ class TestResourceLimitsServerNoticesWithRealRooms(unittest.HomeserverTestCase):
# Now lets get the last load of messages in the service notice room and
# check that there is only one server notice
room_id = self.get_success(
self.server_notices_manager.get_or_create_notice_room_for_user(self.user_id)
self.server_notices_manager.maybe_get_notice_room_for_user(self.user_id)
)
assert room_id is not None, "No server notices room found"
token = self.event_source.get_current_token()
events, _ = self.get_success(
self.store.get_recent_events_for_room(