Compare commits
6 Commits
travis/fix
...
hughns/ser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82fcdc5eef | ||
|
|
2cfc292c77 | ||
|
|
7ba64b6caf | ||
|
|
68699d5338 | ||
|
|
3cabaa84ca | ||
|
|
74ca7ae720 |
25
Cargo.lock
generated
25
Cargo.lock
generated
@@ -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",
|
||||
|
||||
1
changelog.d/18120.feature
Normal file
1
changelog.d/18120.feature
Normal 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
1
changelog.d/18569.misc
Normal file
@@ -0,0 +1 @@
|
||||
Allow worker processes to send server notices.
|
||||
1
changelog.d/18570.feat
Normal file
1
changelog.d/18570.feat
Normal file
@@ -0,0 +1 @@
|
||||
Support modules sending server notices.
|
||||
1
changelog.d/18578.misc
Normal file
1
changelog.d/18578.misc
Normal file
@@ -0,0 +1 @@
|
||||
Update PyO3 to version 0.25.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
98
synapse/handlers/reports.py
Normal file
98
synapse/handlers/reports.py
Normal 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(),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
80
synapse/replication/http/server_notices.py
Normal file
80
synapse/replication/http/server_notices.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ""
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
101
synapse/server_notices/worker_server_notices_manager.py
Normal file
101
synapse/server_notices/worker_server_notices_manager.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
|
||||
22
synapse/storage/schema/main/delta/92/07_add_user_reports.sql
Normal file
22
synapse/storage/schema/main/delta/92/07_add_user_reports.sql
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user