Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f347547
Add report user API from MSC4260
turt2live Jan 30, 2025
b5f359a
changelog
turt2live Jan 30, 2025
f0dcc7a
Attempt to fix linting
turt2live Jan 30, 2025
61f2750
kick ci
turt2live Jan 30, 2025
facf07a
Include in /versions
turt2live Jan 31, 2025
85747b7
Annotate the tests instead
turt2live Jan 31, 2025
e8d102d
await
turt2live Jan 31, 2025
a842c66
Attempt to fix linting
turt2live Jan 31, 2025
44dbcab
kick ci
turt2live Jan 31, 2025
41d185c
Adjust testing
turt2live Jan 31, 2025
133380f
Attempt to fix linting
turt2live Jan 31, 2025
6ef7a87
kick ci
turt2live Jan 31, 2025
dbe43a1
Merge branch 'develop' into travis/report-user
turt2live Feb 13, 2025
8b84c23
Merge branch 'develop' into travis/report-user
turt2live Mar 18, 2025
79c2a0a
Move delta
turt2live Mar 18, 2025
1029a79
Unstable -> Stable
turt2live Mar 18, 2025
6ce8cc0
Attempt to fix linting
turt2live Mar 18, 2025
42b4207
Empty commit to fix CI
turt2live Mar 18, 2025
386a9e6
Apply suggestions from code review
turt2live May 2, 2025
2058b2b
Merge branch 'develop' into travis/report-user
turt2live May 2, 2025
c394ad1
Fix local user check
turt2live May 2, 2025
2f8958c
Limit length of `reason`; add rate limit; move to handler
turt2live May 2, 2025
9251b45
Attempt to fix linting
turt2live May 2, 2025
ff53217
Empty commit to kick CI
turt2live May 2, 2025
51aceab
move delta again
turt2live May 2, 2025
ba32d12
I guess imports are important
turt2live May 2, 2025
223df14
Add types
turt2live May 2, 2025
592a32d
Merge branch 'develop' into travis/report-user
turt2live Jun 19, 2025
cf03535
add docs
turt2live Jun 19, 2025
746c506
Add second index; move to new delta
turt2live Jun 19, 2025
7d46b07
move config
turt2live Jun 19, 2025
c4cdded
Use generated config
turt2live Jun 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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