diff --git a/changelog.d/18120.feature b/changelog.d/18120.feature new file mode 100644 index 0000000000..15cfabba42 --- /dev/null +++ b/changelog.d/18120.feature @@ -0,0 +1 @@ +Add support for the [MSC4260 user report API](https://github.com/matrix-org/matrix-spec-proposals/pull/4260). \ No newline at end of file diff --git a/synapse/rest/client/reporting.py b/synapse/rest/client/reporting.py index c5037be8b7..17f4f12033 100644 --- a/synapse/rest/client/reporting.py +++ b/synapse/rest/client/reporting.py @@ -150,6 +150,59 @@ 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[^/]*)/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 + + 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) + ReportUserRestServlet(hs).register(http_server) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 56217fccdf..387fb3dcc4 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2419,6 +2419,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() @@ -2660,6 +2661,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. diff --git a/synapse/storage/schema/main/delta/90/02_add_user_reports.sql b/synapse/storage/schema/main/delta/90/02_add_user_reports.sql new file mode 100644 index 0000000000..2521aefc51 --- /dev/null +++ b/synapse/storage/schema/main/delta/90/02_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 723553979f..80281a2e75 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -201,3 +201,97 @@ 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") + self.report_path = f"/_matrix/client/v3/users/{self.target_user_id}/report" + + 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_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/v3/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"], + ) + + 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/v3/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"])