Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add report user API from MSC4260 #18120

Open
wants to merge 18 commits into
base: develop
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions changelog.d/18120.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for the [MSC4260 user report API](https://github.com/matrix-org/matrix-spec-proposals/pull/4260).
53 changes: 53 additions & 0 deletions synapse/rest/client/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,59 @@ 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
"""

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

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)
32 changes: 32 additions & 0 deletions synapse/storage/databases/main/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions synapse/storage/schema/main/delta/90/02_add_user_reports.sql
Original file line number Diff line number Diff line change
@@ -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
94 changes: 94 additions & 0 deletions tests/rest/client/test_reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,97 @@ 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/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"])
Loading