Skip to content

Commit 647fb59

Browse files
authored
Add Admin API endpoints to manage user reports (#19657)
Adds [Admin API](https://element-hq.github.io/synapse/latest/usage/administration/admin_api/index.html) endpoints to list, fetch and delete user reports from the homeserver. Follows on from #18120, which added the endpoints to report users.
1 parent bdb1cf7 commit 647fb59

5 files changed

Lines changed: 951 additions & 0 deletions

File tree

changelog.d/19657.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Adds [Admin API](https://element-hq.github.io/synapse/latest/usage/administration/admin_api/index.html) endpoints to
2+
list, fetch and delete user reports.

synapse/rest/admin/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@
9696
LargestRoomsStatistics,
9797
UserMediaStatisticsRestServlet,
9898
)
99+
from synapse.rest.admin.user_reports import (
100+
UserReportDetailRestServlet,
101+
UserReportsRestServlet,
102+
)
99103
from synapse.rest.admin.username_available import UsernameAvailableRestServlet
100104
from synapse.rest.admin.users import (
101105
AccountDataRestServlet,
@@ -312,6 +316,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
312316
LargestRoomsStatistics(hs).register(http_server)
313317
EventReportDetailRestServlet(hs).register(http_server)
314318
EventReportsRestServlet(hs).register(http_server)
319+
UserReportsRestServlet(hs).register(http_server)
320+
UserReportDetailRestServlet(hs).register(http_server)
315321
AccountDataRestServlet(hs).register(http_server)
316322
PushersRestServlet(hs).register(http_server)
317323
MakeRoomAdminRestServlet(hs).register(http_server)

synapse/rest/admin/user_reports.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2026 Element Creations, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl-3.0.html>.
13+
#
14+
15+
16+
import logging
17+
from http import HTTPStatus
18+
from typing import TYPE_CHECKING
19+
20+
from synapse.api.constants import Direction
21+
from synapse.api.errors import Codes, NotFoundError, SynapseError
22+
from synapse.http.servlet import RestServlet, parse_enum, parse_integer, parse_string
23+
from synapse.http.site import SynapseRequest
24+
from synapse.rest.admin._base import admin_patterns, assert_requester_is_admin
25+
from synapse.types import JsonDict
26+
27+
if TYPE_CHECKING:
28+
from synapse.server import HomeServer
29+
30+
logger = logging.getLogger(__name__)
31+
32+
33+
class UserReportsRestServlet(RestServlet):
34+
"""
35+
List all reported users that are known to the homeserver. Results are returned
36+
in a dictionary containing report information. Supports pagination.
37+
The requester must have administrator access in Synapse.
38+
39+
GET /_synapse/admin/v1/user_reports
40+
returns:
41+
200 OK with list of reports if success otherwise an error.
42+
43+
Args:
44+
The parameters `from` and `limit` are required only for pagination.
45+
By default, a `limit` of 100 is used.
46+
The parameter `dir` can be used to define the order of results.
47+
The `user_id` query parameter filters by the user ID of the reporter of the target user.
48+
The `target_user_id` query parameter filters by user id of the target user.
49+
Returns:
50+
A list of user reprots and an integer representing the total number of user
51+
reports that exist given this query
52+
"""
53+
54+
PATTERNS = admin_patterns("/user_reports$")
55+
56+
def __init__(self, hs: "HomeServer"):
57+
self._auth = hs.get_auth()
58+
self._store = hs.get_datastores().main
59+
60+
async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
61+
await assert_requester_is_admin(self._auth, request)
62+
63+
start = parse_integer(request, "from", default=0)
64+
limit = parse_integer(request, "limit", default=100)
65+
direction = parse_enum(request, "dir", Direction, Direction.BACKWARDS)
66+
user_id = parse_string(request, "user_id")
67+
target_user_id = parse_string(request, "target_user_id")
68+
69+
if start < 0:
70+
raise SynapseError(
71+
HTTPStatus.BAD_REQUEST,
72+
"The start parameter must be a positive integer.",
73+
errcode=Codes.INVALID_PARAM,
74+
)
75+
76+
if limit < 0:
77+
raise SynapseError(
78+
HTTPStatus.BAD_REQUEST,
79+
"The limit parameter must be a positive integer.",
80+
errcode=Codes.INVALID_PARAM,
81+
)
82+
83+
user_reports, total = await self._store.get_user_reports_paginate(
84+
start, limit, direction, user_id, target_user_id
85+
)
86+
ret = {"user_reports": user_reports, "total": total}
87+
if (start + limit) < total:
88+
ret["next_token"] = start + len(user_reports)
89+
90+
return HTTPStatus.OK, ret
91+
92+
93+
class UserReportDetailRestServlet(RestServlet):
94+
"""
95+
Get a specific user report that is known to the homeserver. Results are returned
96+
in a dictionary containing report information.
97+
The requester must have administrator access in Synapse.
98+
99+
GET /_synapse/admin/v1/user_reports/<report_id>
100+
returns:
101+
200 OK with details report if success otherwise an error.
102+
103+
Args:
104+
The parameter `report_id` is the ID of the user report in the database.
105+
Returns:
106+
JSON blob of information about the user report
107+
"""
108+
109+
PATTERNS = admin_patterns("/user_reports/(?P<report_id>[^/]*)$")
110+
111+
def __init__(self, hs: "HomeServer"):
112+
self._auth = hs.get_auth()
113+
self._store = hs.get_datastores().main
114+
115+
async def on_GET(
116+
self, request: SynapseRequest, report_id: str
117+
) -> tuple[int, JsonDict]:
118+
await assert_requester_is_admin(self._auth, request)
119+
120+
message = (
121+
"The report_id parameter must be a string representing a positive integer."
122+
)
123+
try:
124+
resolved_report_id = int(report_id)
125+
except ValueError:
126+
raise SynapseError(
127+
HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM
128+
)
129+
130+
if resolved_report_id < 0:
131+
raise SynapseError(
132+
HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM
133+
)
134+
135+
ret = await self._store.get_user_report(resolved_report_id)
136+
if not ret:
137+
raise NotFoundError("User report not found")
138+
139+
id, received_ts, target_user_id, user_id, reason = ret
140+
response = {
141+
"id": id,
142+
"received_ts": received_ts,
143+
"target_user_id": target_user_id,
144+
"user_id": user_id,
145+
"reason": reason,
146+
}
147+
148+
return HTTPStatus.OK, response
149+
150+
async def on_DELETE(
151+
self, request: SynapseRequest, report_id: str
152+
) -> tuple[int, JsonDict]:
153+
await assert_requester_is_admin(self._auth, request)
154+
155+
message = (
156+
"The report_id parameter must be a string representing a positive integer."
157+
)
158+
try:
159+
resolved_report_id = int(report_id)
160+
except ValueError:
161+
raise SynapseError(
162+
HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM
163+
)
164+
165+
if resolved_report_id < 0:
166+
raise SynapseError(
167+
HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM
168+
)
169+
170+
if await self._store.delete_user_report(resolved_report_id):
171+
return HTTPStatus.OK, {}
172+
173+
raise NotFoundError("User report not found")

synapse/storage/databases/main/room.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2277,6 +2277,132 @@ async def delete_event_report(self, report_id: int) -> bool:
22772277

22782278
return True
22792279

2280+
async def get_user_report(
2281+
self, report_id: int
2282+
) -> tuple[int, int, str, str, str] | None:
2283+
"""Retrieve a user report
2284+
2285+
Args:
2286+
report_id: ID of user report in database
2287+
Returns:
2288+
JSON dict of information from a user report or None if the
2289+
report does not exist.
2290+
"""
2291+
2292+
return await self.db_pool.simple_select_one(
2293+
table="user_reports",
2294+
keyvalues={"id": report_id},
2295+
retcols=("id", "received_ts", "target_user_id", "user_id", "reason"),
2296+
allow_none=True,
2297+
desc="get_user_report",
2298+
)
2299+
2300+
async def get_user_reports_paginate(
2301+
self,
2302+
start: int,
2303+
limit: int,
2304+
direction: Direction = Direction.BACKWARDS,
2305+
user_id: str | None = None,
2306+
target_user_id: str | None = None,
2307+
) -> tuple[list[JsonDict], int]:
2308+
"""Retrieve a paginated list of user reports
2309+
2310+
Args:
2311+
start: event offset to begin the query from
2312+
limit: number of rows to retrieve
2313+
direction: Whether to fetch the most recent first (backwards) or the
2314+
oldest first (forwards)
2315+
user_id: search for user_id of the reporter. Ignored if user_id is None
2316+
target_user_id: search for user_id of the target. Ignored if target_user_id is None
2317+
Returns:
2318+
Tuple of:
2319+
json list of user reports
2320+
total number of user reports matching the filter criteria
2321+
"""
2322+
2323+
def _get_user_reports_paginate_txn(
2324+
txn: LoggingTransaction,
2325+
) -> tuple[list[dict[str, Any]], int]:
2326+
filters = []
2327+
args: list[object] = []
2328+
2329+
if user_id:
2330+
filters.append("user_id LIKE ?")
2331+
args.extend(["%" + user_id + "%"])
2332+
if target_user_id:
2333+
filters.append("target_user_id LIKE ?")
2334+
args.extend(["%" + target_user_id + "%"])
2335+
2336+
if direction == Direction.BACKWARDS:
2337+
order = "DESC"
2338+
else:
2339+
order = "ASC"
2340+
2341+
where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else ""
2342+
2343+
sql = f"""
2344+
SELECT COUNT(*) as total_user_reports
2345+
FROM user_reports {where_clause}
2346+
"""
2347+
txn.execute(sql, args)
2348+
count = cast(tuple[int], txn.fetchone())[0]
2349+
2350+
sql = f"""
2351+
SELECT
2352+
id,
2353+
received_ts,
2354+
target_user_id,
2355+
user_id,
2356+
reason
2357+
FROM user_reports
2358+
{where_clause}
2359+
ORDER BY received_ts {order}
2360+
LIMIT ?
2361+
OFFSET ?
2362+
"""
2363+
2364+
args += [limit, start]
2365+
txn.execute(sql, args)
2366+
2367+
user_reports = []
2368+
for row in txn:
2369+
user_reports.append(
2370+
{
2371+
"id": row[0],
2372+
"received_ts": row[1],
2373+
"target_user_id": row[2],
2374+
"user_id": row[3],
2375+
"reason": row[4],
2376+
}
2377+
)
2378+
2379+
return user_reports, count
2380+
2381+
return await self.db_pool.runInteraction(
2382+
"get_user_reports_paginate", _get_user_reports_paginate_txn
2383+
)
2384+
2385+
async def delete_user_report(self, report_id: int) -> bool:
2386+
"""Remove a user report from database.
2387+
2388+
Args:
2389+
report_id: Report to delete
2390+
2391+
Returns:
2392+
Whether the report was successfully deleted or not.
2393+
"""
2394+
try:
2395+
await self.db_pool.simple_delete_one(
2396+
table="user_reports",
2397+
keyvalues={"id": report_id},
2398+
desc="delete_user_report",
2399+
)
2400+
except StoreError:
2401+
# Deletion failed because report does not exist
2402+
return False
2403+
2404+
return True
2405+
22802406
async def set_room_is_public(self, room_id: str, is_public: bool) -> None:
22812407
await self.db_pool.simple_update_one(
22822408
table="rooms",

0 commit comments

Comments
 (0)