diff --git a/changelog.d/18120.feature b/changelog.d/18120.feature new file mode 100644 index 00000000000..8441a0811a4 --- /dev/null +++ b/changelog.d/18120.feature @@ -0,0 +1 @@ +Add support for the unstable [MSC4260](https://github.com/matrix-org/matrix-spec-proposals/pull/4260) report user API. \ No newline at end of file diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index 3beaeb88693..28c388ff92f 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -536,3 +536,6 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: # MSC4076: Add `disable_badge_count`` to pusher configuration self.msc4076_enabled: bool = experimental.get("msc4076_enabled", False) + + # MSC4260: Report user API (Client-Server) + self.msc4260_enabled: bool = experimental.get("msc4260_enabled", False) diff --git a/synapse/rest/client/reporting.py b/synapse/rest/client/reporting.py index c5037be8b75..9428fc093e5 100644 --- a/synapse/rest/client/reporting.py +++ b/synapse/rest/client/reporting.py @@ -150,6 +150,62 @@ async def on_POST( 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 + """ + + # Cast the Iterable to a list so that we can `append` below. + PATTERNS = list( + client_patterns( + "/org.matrix.msc4260/users/(?P[^/]*)/report$", + releases=[], # unstable only + unstable=True, + 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 + + 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) + user_id = requester.user.to_string() + + body = parse_and_validate_json_object_from_request(request, self.PostBody) + + # We can't deal with non-local users. + if not self.hs.is_mine_id(target_user_id): + raise NotFoundError("User does not belong to this server") + + user = await self.store.get_user_by_id(target_user_id) + if user is None: + # raise NotFoundError("User does not exist") + return 200, {} # hide existence + + await self.store.add_user_report( + target_user_id=target_user_id, + user_id=user_id, + reason=body.reason, + received_ts=self.clock.time_msec(), + ) + + return 200, {} + + def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ReportEventRestServlet(hs).register(http_server) ReportRoomRestServlet(hs).register(http_server) + + if hs.config.experimental.msc4260_enabled: + ReportUserRestServlet(hs).register(http_server) diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py index 266a0b835b9..caad93104e7 100644 --- a/synapse/rest/client/versions.py +++ b/synapse/rest/client/versions.py @@ -174,6 +174,8 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]: "org.matrix.simplified_msc3575": msc3575_enabled, # Arbitrary key-value profile fields. "uk.tcpip.msc4133": self.config.experimental.msc4133_enabled, + # MSC4260: Report users API (Client-Server) + "org.matrix.msc4260": self.config.experimental.msc4260_enabled, }, }, ) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index d673adba164..d0c57b29d84 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2303,6 +2303,7 @@ def __init__( 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() @@ -2544,6 +2545,37 @@ async def add_room_report( ) 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. diff --git a/synapse/storage/schema/main/delta/88/07_add_user_reports.sql b/synapse/storage/schema/main/delta/88/07_add_user_reports.sql new file mode 100644 index 00000000000..2521aefc51c --- /dev/null +++ b/synapse/storage/schema/main/delta/88/07_add_user_reports.sql @@ -0,0 +1,21 @@ +-- +-- 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: +-- . + +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 diff --git a/tests/rest/client/test_reporting.py b/tests/rest/client/test_reporting.py index 723553979f7..86ca1a3a166 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -28,6 +28,7 @@ from synapse.util import Clock from tests import unittest +from tests.unittest import override_config class ReportEventTestCase(unittest.HomeserverTestCase): @@ -201,3 +202,103 @@ def _assert_status(self, response_status: int, data: JsonDict) -> None: 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") + self.report_path = f"/_matrix/client/unstable/org.matrix.msc4260/users/{self.target_user_id}/report" + + @override_config({"experimental_features": {"msc4260_enabled": True}}) + 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) + + @override_config({"experimental_features": {"msc4260_enabled": True}}) + def test_no_reason(self) -> None: + data = {"not_reason": "for typechecking"} + self._assert_status(400, data) + + @override_config({"experimental_features": {"msc4260_enabled": True}}) + def test_reason_nonstring(self) -> None: + data = {"reason": 42} + self._assert_status(400, data) + + @override_config({"experimental_features": {"msc4260_enabled": True}}) + def test_reason_null(self) -> None: + data = {"reason": None} + self._assert_status(400, data) + + @override_config({"experimental_features": {"msc4260_enabled": True}}) + def test_cannot_report_nonlcoal_user(self) -> None: + """ + Tests that we don't accept event reports for users which aren't local users. + """ + channel = self.make_request( + "POST", + "/_matrix/client/unstable/org.matrix.msc4260/users/@bloop:example.org/report", + {"reason": "i am very sad"}, + access_token=self.other_user_tok, + shorthand=False, + ) + self.assertEqual(404, channel.code, msg=channel.result["body"]) + self.assertEqual( + "User does not belong to this server", + channel.json_body["error"], + msg=channel.result["body"], + ) + + @override_config({"experimental_features": {"msc4260_enabled": True}}) + def test_can_report_nonexistent_user(self) -> None: + """ + Tests that we ignore reports for nonexistent users. + """ + target_user_id = f"@bloop:{self.hs.hostname}" + channel = self.make_request( + "POST", + f"/_matrix/client/unstable/org.matrix.msc4260/users/{target_user_id}/report", + {"reason": "i am very sad"}, + access_token=self.other_user_tok, + shorthand=False, + ) + self.assertEqual(200, channel.code, msg=channel.result["body"]) + + 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), 0) + + def _assert_status(self, response_status: int, data: JsonDict) -> None: + channel = self.make_request( + "POST", + self.report_path, + data, + access_token=self.other_user_tok, + shorthand=False, + ) + self.assertEqual(response_status, channel.code, msg=channel.result["body"])