From f347547d32669c285f06fcde74cab28d69fe23e9 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jan 2025 16:05:31 -0700 Subject: [PATCH 01/12] Add report user API from MSC4260 --- synapse/config/experimental.py | 3 + synapse/rest/client/reporting.py | 56 +++++++++++++ synapse/storage/databases/main/room.py | 40 ++++++++++ .../main/delta/88/07_add_user_reports.sql | 21 +++++ tests/rest/client/test_reporting.py | 78 +++++++++++++++++++ 5 files changed, 198 insertions(+) create mode 100644 synapse/storage/schema/main/delta/88/07_add_user_reports.sql diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index 94a25c7ee83..2a482d6360a 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -447,3 +447,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/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index d673adba164..794798b4be8 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,45 @@ 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 get_user_report_ids(self, target_user_id: str) -> List[str]: + return await self.db_pool.simple_select_onecol( + table="user_reports", + keyvalues={"target_user_id": target_user_id}, + retcol="id", + desc="get_user_report_ids", + ) + 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..84206680db0 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,80 @@ def _assert_status(self, response_status: int, data: JsonDict) -> None: shorthand=False, ) self.assertEqual(response_status, channel.code, msg=channel.result["body"]) + + +@override_config({"experimental_features": {"msc4260_enabled": True}}) +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" + + def test_reason_str(self) -> None: + data = {"reason": "this makes me sad"} + self._assert_status(200, data) + self.assertEqual(1, self.hs.get_datastores().main.get_user_report_ids(self.target_user_id)) + + 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/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"], + ) + + 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"]) + self.assertEqual(0, self.hs.get_datastores().main.get_user_report_ids(target_user_id)) + + 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"]) From b5f359aa0c1884403df7208adfabf061fccaa738 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jan 2025 16:08:11 -0700 Subject: [PATCH 02/12] changelog --- changelog.d/18120.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/18120.feature 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 From f0dcc7a802f729e2c01700370a1990d84d116e99 Mon Sep 17 00:00:00 2001 From: turt2live <1190097+turt2live@users.noreply.github.com> Date: Thu, 30 Jan 2025 23:13:19 +0000 Subject: [PATCH 03/12] Attempt to fix linting --- tests/rest/client/test_reporting.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/rest/client/test_reporting.py b/tests/rest/client/test_reporting.py index 84206680db0..f6ed28d76f5 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -223,7 +223,9 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: def test_reason_str(self) -> None: data = {"reason": "this makes me sad"} self._assert_status(200, data) - self.assertEqual(1, self.hs.get_datastores().main.get_user_report_ids(self.target_user_id)) + self.assertEqual( + 1, self.hs.get_datastores().main.get_user_report_ids(self.target_user_id) + ) def test_no_reason(self) -> None: data = {"not_reason": "for typechecking"} @@ -268,7 +270,9 @@ def test_can_report_nonexistent_user(self) -> None: shorthand=False, ) self.assertEqual(200, channel.code, msg=channel.result["body"]) - self.assertEqual(0, self.hs.get_datastores().main.get_user_report_ids(target_user_id)) + self.assertEqual( + 0, self.hs.get_datastores().main.get_user_report_ids(target_user_id) + ) def _assert_status(self, response_status: int, data: JsonDict) -> None: channel = self.make_request( From 61f2750c9b7e578fee9aae3b46c4a45638e55aa7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jan 2025 16:16:25 -0700 Subject: [PATCH 04/12] kick ci From facf07a568113f6645d1bec47893a340fc70c8c7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jan 2025 17:13:45 -0700 Subject: [PATCH 05/12] Include in /versions --- synapse/rest/client/versions.py | 2 ++ 1 file changed, 2 insertions(+) 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, }, }, ) From 85747b70a7cec36c5f90c32eab3a8dfa5268adfe Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jan 2025 17:13:56 -0700 Subject: [PATCH 06/12] Annotate the tests instead --- tests/rest/client/test_reporting.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/rest/client/test_reporting.py b/tests/rest/client/test_reporting.py index f6ed28d76f5..450453d4cbd 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -204,7 +204,6 @@ def _assert_status(self, response_status: int, data: JsonDict) -> None: self.assertEqual(response_status, channel.code, msg=channel.result["body"]) -@override_config({"experimental_features": {"msc4260_enabled": True}}) class ReportUserTestCase(unittest.HomeserverTestCase): servlets = [ synapse.rest.admin.register_servlets, @@ -220,6 +219,7 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: 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) @@ -227,18 +227,22 @@ def test_reason_str(self) -> None: 1, self.hs.get_datastores().main.get_user_report_ids(self.target_user_id) ) + @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. @@ -257,6 +261,7 @@ def test_cannot_report_nonlcoal_user(self) -> None: 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. From e8d102d8c5f7afa4734d139381d77cdd3e183f2d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 30 Jan 2025 17:30:57 -0700 Subject: [PATCH 07/12] await --- tests/rest/client/test_reporting.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/rest/client/test_reporting.py b/tests/rest/client/test_reporting.py index 450453d4cbd..8934cbeff8a 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -220,11 +220,11 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: 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: + async def test_reason_str(self) -> None: data = {"reason": "this makes me sad"} self._assert_status(200, data) self.assertEqual( - 1, self.hs.get_datastores().main.get_user_report_ids(self.target_user_id) + 1, await self.hs.get_datastores().main.get_user_report_ids(self.target_user_id) ) @override_config({"experimental_features": {"msc4260_enabled": True}}) @@ -262,7 +262,7 @@ def test_cannot_report_nonlcoal_user(self) -> None: ) @override_config({"experimental_features": {"msc4260_enabled": True}}) - def test_can_report_nonexistent_user(self) -> None: + async def test_can_report_nonexistent_user(self) -> None: """ Tests that we ignore reports for nonexistent users. """ @@ -276,7 +276,7 @@ def test_can_report_nonexistent_user(self) -> None: ) self.assertEqual(200, channel.code, msg=channel.result["body"]) self.assertEqual( - 0, self.hs.get_datastores().main.get_user_report_ids(target_user_id) + 0, await self.hs.get_datastores().main.get_user_report_ids(target_user_id) ) def _assert_status(self, response_status: int, data: JsonDict) -> None: From a842c66ecb8dee8e711661e7ecbd9ae8512717cc Mon Sep 17 00:00:00 2001 From: turt2live <1190097+turt2live@users.noreply.github.com> Date: Fri, 31 Jan 2025 00:32:51 +0000 Subject: [PATCH 08/12] Attempt to fix linting --- tests/rest/client/test_reporting.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/rest/client/test_reporting.py b/tests/rest/client/test_reporting.py index 8934cbeff8a..57e2ff08273 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -224,7 +224,10 @@ async def test_reason_str(self) -> None: data = {"reason": "this makes me sad"} self._assert_status(200, data) self.assertEqual( - 1, await self.hs.get_datastores().main.get_user_report_ids(self.target_user_id) + 1, + await self.hs.get_datastores().main.get_user_report_ids( + self.target_user_id + ), ) @override_config({"experimental_features": {"msc4260_enabled": True}}) From 44dbcab047b82889c12328c7e99b22a9c4966aea Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 31 Jan 2025 11:29:14 -0700 Subject: [PATCH 09/12] kick ci From 41d185c71cf1da26d71847d00a4dfe873d7efe57 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 31 Jan 2025 12:02:12 -0700 Subject: [PATCH 10/12] Adjust testing --- synapse/storage/databases/main/room.py | 8 ------- tests/rest/client/test_reporting.py | 29 ++++++++++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py index 794798b4be8..d0c57b29d84 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py @@ -2576,14 +2576,6 @@ async def add_user_report( ) return next_id - async def get_user_report_ids(self, target_user_id: str) -> List[str]: - return await self.db_pool.simple_select_onecol( - table="user_reports", - keyvalues={"target_user_id": target_user_id}, - retcol="id", - desc="get_user_report_ids", - ) - async def clear_partial_state_room(self, room_id: str) -> Optional[int]: """Clears the partial state flag for a room. diff --git a/tests/rest/client/test_reporting.py b/tests/rest/client/test_reporting.py index 57e2ff08273..2789a20e078 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -220,15 +220,17 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: self.report_path = f"/_matrix/client/unstable/org.matrix.msc4260/users/{self.target_user_id}/report" @override_config({"experimental_features": {"msc4260_enabled": True}}) - async def test_reason_str(self) -> None: + def test_reason_str(self) -> None: data = {"reason": "this makes me sad"} self._assert_status(200, data) - self.assertEqual( - 1, - await self.hs.get_datastores().main.get_user_report_ids( - self.target_user_id - ), - ) + + 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: @@ -265,7 +267,7 @@ def test_cannot_report_nonlcoal_user(self) -> None: ) @override_config({"experimental_features": {"msc4260_enabled": True}}) - async def test_can_report_nonexistent_user(self) -> None: + def test_can_report_nonexistent_user(self) -> None: """ Tests that we ignore reports for nonexistent users. """ @@ -278,9 +280,14 @@ async def test_can_report_nonexistent_user(self) -> None: shorthand=False, ) self.assertEqual(200, channel.code, msg=channel.result["body"]) - self.assertEqual( - 0, await self.hs.get_datastores().main.get_user_report_ids(target_user_id) - ) + + 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( From 133380ff6d916db50a513660af9e1b2f53b5ba0e Mon Sep 17 00:00:00 2001 From: turt2live <1190097+turt2live@users.noreply.github.com> Date: Fri, 31 Jan 2025 19:03:54 +0000 Subject: [PATCH 11/12] Attempt to fix linting --- tests/rest/client/test_reporting.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/rest/client/test_reporting.py b/tests/rest/client/test_reporting.py index 2789a20e078..86ca1a3a166 100644 --- a/tests/rest/client/test_reporting.py +++ b/tests/rest/client/test_reporting.py @@ -224,12 +224,14 @@ 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", - )) + 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}}) @@ -281,12 +283,14 @@ def test_can_report_nonexistent_user(self) -> None: ) 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", - )) + 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: From 6ef7a87dc157f62921b470bcafc6971d24d4cf60 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 31 Jan 2025 12:04:03 -0700 Subject: [PATCH 12/12] kick ci