This commit is contained in:
Travis Ralston 2025-03-11 13:25:05 -06:00 committed by GitHub
commit ca5c170c43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 216 additions and 0 deletions

View file

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

View file

@ -560,3 +560,6 @@ class ExperimentalConfig(Config):
# 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)

View file

@ -150,6 +150,62 @@ 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
"""
# Cast the Iterable to a list so that we can `append` below.
PATTERNS = list(
client_patterns(
"/org.matrix.msc4260/users/(?P<target_user_id>[^/]*)/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)

View file

@ -174,6 +174,8 @@ class VersionsRestServlet(RestServlet):
"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,
},
},
)

View file

@ -2303,6 +2303,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()
@ -2544,6 +2545,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,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:
-- <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

View file

@ -28,6 +28,7 @@ from synapse.types import JsonDict
from synapse.util import Clock
from tests import unittest
from tests.unittest import override_config
class ReportEventTestCase(unittest.HomeserverTestCase):
@ -201,3 +202,103 @@ 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/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"])