Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
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/19648.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add [Admin API](https://matrix-org.github.io/synapse/develop/usage/administration/admin_api/index.html) endpoints to list, fetch and delete room reports.
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
6 changes: 6 additions & 0 deletions synapse/rest/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
NewRegistrationTokenRestServlet,
RegistrationTokenRestServlet,
)
from synapse.rest.admin.room_reports import (
RoomReportDetailRestServlet,
RoomReportsRestServlet,
)
from synapse.rest.admin.rooms import (
AdminRoomHierarchy,
BlockRoomRestServlet,
Expand Down Expand Up @@ -312,6 +316,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
LargestRoomsStatistics(hs).register(http_server)
EventReportDetailRestServlet(hs).register(http_server)
EventReportsRestServlet(hs).register(http_server)
RoomReportDetailRestServlet(hs).register(http_server)
RoomReportsRestServlet(hs).register(http_server)
AccountDataRestServlet(hs).register(http_server)
PushersRestServlet(hs).register(http_server)
MakeRoomAdminRestServlet(hs).register(http_server)
Expand Down
165 changes: 165 additions & 0 deletions synapse/rest/admin/room_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations 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>.
#


import logging
from http import HTTPStatus
from typing import TYPE_CHECKING

from synapse.api.constants import Direction
from synapse.api.errors import Codes, NotFoundError, SynapseError
from synapse.http.servlet import RestServlet, parse_enum, parse_integer, parse_string
from synapse.http.site import SynapseRequest
from synapse.rest.admin._base import admin_patterns, assert_requester_is_admin
from synapse.types import JsonDict

if TYPE_CHECKING:
from synapse.server import HomeServer

logger = logging.getLogger(__name__)


class RoomReportsRestServlet(RestServlet):
"""
List all existing rooms that have been reported to the homeserver. Results are returned
in a dictionary containing report information. Supports pagination. Does not return results
for deleted/purged rooms.
The requester must have administrator access in Synapse.

GET /_synapse/admin/v1/room_reports
returns:
200 OK with list of reports if success otherwise an error.

Args:
The parameters `from` and `limit` are required only for pagination.
By default, a `limit` of 100 is used.
The parameter `dir` can be used to define the order of results.
The `room_id` query parameter filters by room id.
The `user_id` query parameter filters by the user ID of the reporter of the room.
Returns:
A list of reported rooms and an integer representing the total number of
reported rooms that exist given this query
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
"""
Comment thread
H-Shay marked this conversation as resolved.

PATTERNS = admin_patterns("/room_reports$")

def __init__(self, hs: "HomeServer"):
self._auth = hs.get_auth()
self._store = hs.get_datastores().main

async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
Comment thread
MadLittleMods marked this conversation as resolved.
await assert_requester_is_admin(self._auth, request)

start = parse_integer(request, "from", default=0)
limit = parse_integer(request, "limit", default=100)
direction = parse_enum(request, "dir", Direction, Direction.BACKWARDS)
room_id = parse_string(request, "room_id")
user_id = parse_string(request, "user_id")

if start < 0:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"The start parameter must be a positive integer.",
errcode=Codes.INVALID_PARAM,
)

if limit < 0:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"The limit parameter must be a positive integer.",
errcode=Codes.INVALID_PARAM,
)

room_reports, total = await self._store.get_room_reports_paginate(
start, limit, direction, user_id, room_id
)
ret = {"room_reports": room_reports, "total": total}
if (start + limit) < total:
ret["next_token"] = start + len(room_reports)

return HTTPStatus.OK, ret


class RoomReportDetailRestServlet(RestServlet):
"""
Get a specific reported room that is known to the homeserver. Results are returned
in a dictionary containing report information.
The requester must have administrator access in Synapse.

GET /_synapse/admin/v1/room_reports/<report_id>
returns:
200 OK with details report if success otherwise an error.

Args:
The parameter `report_id` is the ID of the room report in the database.
Returns:
JSON blob of information about the room report
"""

PATTERNS = admin_patterns("/room_reports/(?P<report_id>[^/]*)$")

def __init__(self, hs: "HomeServer"):
self._auth = hs.get_auth()
self._store = hs.get_datastores().main

async def on_GET(
self, request: SynapseRequest, report_id: str
) -> tuple[int, JsonDict]:
await assert_requester_is_admin(self._auth, request)

message = (
"The report_id parameter must be a string representing a positive integer."
)
try:
resolved_report_id = int(report_id)
except ValueError:
raise SynapseError(
HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM
)

if resolved_report_id < 0:
raise SynapseError(
HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM
)
Comment thread
H-Shay marked this conversation as resolved.
Outdated

ret = await self._store.get_room_report(resolved_report_id)
if not ret:
raise NotFoundError("Room report not found")

return HTTPStatus.OK, ret

async def on_DELETE(
self, request: SynapseRequest, report_id: str
) -> tuple[int, JsonDict]:
await assert_requester_is_admin(self._auth, request)

message = (
"The report_id parameter must be a string representing a positive integer."
)
try:
resolved_report_id = int(report_id)
except ValueError:
raise SynapseError(
HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM
)

if resolved_report_id < 0:
raise SynapseError(
HTTPStatus.BAD_REQUEST, message, errcode=Codes.INVALID_PARAM
)

if await self._store.delete_room_report(resolved_report_id):
return HTTPStatus.OK, {}

raise NotFoundError("Room report not found")
168 changes: 168 additions & 0 deletions synapse/storage/databases/main/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,57 @@ def get_un_partial_stated_rooms_from_stream_txn(
get_un_partial_stated_rooms_from_stream_txn,
)

async def get_room_report(self, report_id: int) -> dict[str, Any] | None:
"""Retrieve a room report

Args:
report_id: ID of reported room in database
Returns:
JSON dict of information from an event report or None if the
report does not exist.
"""

def _get_room_report_txn(
txn: LoggingTransaction, report_id: int
) -> dict[str, Any] | None:
sql = """
SELECT rr.id, \
rr.received_ts, \
rr.room_id, \
rr.user_id, \
rr.reason, \
room_stats_state.canonical_alias, \
room_stats_state.name, \
room_stats_state.topic \
FROM room_reports AS rr
Comment thread
H-Shay marked this conversation as resolved.
Outdated
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
JOIN room_stats_state
ON room_stats_state.room_id = rr.room_id
WHERE rr.id = ? \
"""

txn.execute(sql, [report_id])
row = txn.fetchone()

if not row:
return None

room_report = {
"id": row[0],
"received_ts": row[1],
"room_id": row[2],
"user_id": row[3],
"reason": row[4],
"canonical_alias": row[5],
"name": row[6],
"topic": row[7],
}

return room_report

return await self.db_pool.runInteraction(
"get_room_report", _get_room_report_txn, report_id
)

async def get_event_report(self, report_id: int) -> dict[str, Any] | None:
"""Retrieve an event report

Expand Down Expand Up @@ -1740,6 +1791,102 @@ def _get_event_report_txn(
"get_event_report", _get_event_report_txn, report_id
)

async def get_room_reports_paginate(
self,
start: int,
limit: int,
direction: Direction = Direction.BACKWARDS,
user_id: str | None = None,
room_id: str | None = None,
) -> tuple[list[dict[str, Any]], int]:
"""Retrieve a paginated list of room reports

Args:
start: event offset to begin the query from
limit: number of rows to retrieve
direction: Whether to fetch the most recent first (backwards) or the
oldest first (forwards)
user_id: search for user_id. Ignored if user_id is None
room_id: filter reports against a specific room_id. Ignored if room_id is None
Returns:
Tuple of:
json list of room reports
total number of room reports matching the filter criteria
"""

def _get_room_reports_paginate_txn(
txn: LoggingTransaction,
) -> tuple[list[dict[str, Any]], int]:
filters = []
args: list[object] = []
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated

if user_id:
filters.append("rr.user_id LIKE ?")
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
args.extend(["%" + user_id + "%"])
if room_id:
filters.append("rr.room_id LIKE ?")
args.extend(["%" + room_id + "%"])
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated

if direction == Direction.BACKWARDS:
order = "DESC"
else:
order = "ASC"

where_clause = "WHERE " + " AND ".join(filters) if len(filters) > 0 else ""

# Don't count reports against rooms which have been deleted/purged
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
sql = f"""
SELECT COUNT(*) as total_room_reports
FROM room_reports AS rr
JOIN room_stats_state ON room_stats_state.room_id = rr.room_id
{where_clause}
"""
txn.execute(sql, args)
count = cast(tuple[int], txn.fetchone())[0]

sql = f"""
SELECT
rr.id,
rr.received_ts,
rr.room_id,
rr.user_id,
rr.reason,
room_stats_state.canonical_alias,
room_stats_state.name,
room_stats_state.topic
FROM room_reports AS rr
JOIN room_stats_state
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
ON room_stats_state.room_id = rr.room_id
{where_clause}
ORDER BY rr.received_ts {order}
LIMIT ?
OFFSET ?
Comment thread
MadLittleMods marked this conversation as resolved.
Outdated
"""

args += [limit, start]
txn.execute(sql, args)

room_reports = []
for row in txn:
room_reports.append(
{
"id": row[0],
"received_ts": row[1],
"room_id": row[2],
"user_id": row[3],
"reason": row[4],
"canonical_alias": row[5],
"name": row[6],
"topic": row[7],
}
)

return room_reports, count

return await self.db_pool.runInteraction(
"get_room_reports_paginate", _get_room_reports_paginate_txn
)

async def get_event_reports_paginate(
self,
start: int,
Expand Down Expand Up @@ -1861,6 +2008,27 @@ def _get_event_reports_paginate_txn(
"get_event_reports_paginate", _get_event_reports_paginate_txn
)

async def delete_room_report(self, report_id: int) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in bbf7ad9

"""Remove a room report from database.

Args:
report_id: Report to delete

Returns:
Whether the report was successfully deleted or not.
"""
try:
await self.db_pool.simple_delete_one(
table="room_reports",
keyvalues={"id": report_id},
desc="delete_room_report",
)
except StoreError:
# Deletion failed because report does not exist
return False

return True

async def delete_event_report(self, report_id: int) -> bool:
"""Remove an event report from database.

Expand Down
Loading
Loading