Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 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).
19 changes: 19 additions & 0 deletions docs/usage/configuration/config_documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -1937,6 +1937,25 @@ rc_delayed_event_mgmt:
burst_count: 20.0
```
---
### `rc_reports`

Ratelimiting settings for reporting content.

This is a ratelimiting option that ratelimits reports made by users
about content they see.

It defaults to: `per_second: 1`, `burst_count: 5`.

Setting this to a high value allows users to report content quickly, possibly in
duplicate. This can result in higher database usage.

Example configuration:
```yaml
rc_reports:
per_second: 2
burst_count: 20
```
---
### `federation_rr_transactions_per_room_per_second`

*(integer)* Sets outgoing federation transaction frequency for sending read-receipts, per-room.
Expand Down
6 changes: 6 additions & 0 deletions synapse/config/ratelimiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,9 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
"rc_delayed_event_mgmt",
defaults={"per_second": 1, "burst_count": 5},
)

self.rc_reports = RatelimitSettings.parse(
config,
"rc_reports",
defaults={"per_second": 1, "burst_count": 5},
)
98 changes: 98 additions & 0 deletions synapse/handlers/reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright 2015, 2016 OpenMarket Ltd
# Copyright (C) 2023 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>.
#
#
import logging
from http import HTTPStatus
from typing import TYPE_CHECKING

from synapse.api.errors import Codes, SynapseError
from synapse.api.ratelimiting import Ratelimiter
from synapse.types import (
Requester,
)

if TYPE_CHECKING:
from synapse.server import HomeServer

logger = logging.getLogger(__name__)


class ReportsHandler:
def __init__(self, hs: "HomeServer"):
self._hs = hs
self._store = hs.get_datastores().main
self._clock = hs.get_clock()

# Ratelimiter for management of existing delayed events,
# keyed by the requesting user ID.
self._reports_ratelimiter = Ratelimiter(
store=self._store,
clock=self._clock,
cfg=hs.config.ratelimiting.rc_reports,
)

async def report_user(
self, requester: Requester, target_user_id: str, reason: str
) -> None:
"""Files a report against a user from a user.
Rate and size limits are applied to the report. If the user being reported
does not belong to this server, the report is ignored. This check is done
after the limits to reduce DoS potential.
If the user being reported belongs to this server, but doesn't exist, we
similarly ignore the report. The spec allows us to return an error if we
want to, but we choose to hide that user's existence instead.
If the report is otherwise valid (for a user which exists on our server),
we append it to the database for later processing.
Args:
requester - The user filing the report.
target_user_id - The user being reported.
reason - The user-supplied reason the user is being reported.
Raises:
SynapseError for BAD_REQUEST/BAD_JSON if the reason is too long.
"""

await self._check_limits(requester)

if len(reason) > 1000:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Reason must be less than 1000 characters",
Codes.BAD_JSON,
)

if not self._hs.is_mine_id(target_user_id):
return # hide that they're not ours/that we can't do anything about them
Comment on lines +80 to +81
Copy link
Member

Choose a reason for hiding this comment

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

Non-blocking, but would it not be useful for the reporter to get an error here telling them that the homeserver operator likely don't do much about remote users? And tell them to contact the remote homeserver's operator instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

Possibly/probably, though IIRC this is in the MSC.

One day we do also hope to support federated reports.


user = await self._store.get_user_by_id(target_user_id)
if user is None:
return # hide that they don't exist

await self._store.add_user_report(
target_user_id=target_user_id,
user_id=requester.user.to_string(),
reason=reason,
received_ts=self._clock.time_msec(),
)

async def _check_limits(self, requester: Requester) -> None:
await self._reports_ratelimiter.ratelimit(
requester,
requester.user.to_string(),
)
38 changes: 38 additions & 0 deletions synapse/rest/client/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,44 @@ 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
self.handler = hs.get_reports_handler()

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)
body = parse_and_validate_json_object_from_request(request, self.PostBody)

await self.handler.report_user(requester, target_user_id, body.reason)

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)
5 changes: 5 additions & 0 deletions synapse/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
from synapse.handlers.receipts import ReceiptsHandler
from synapse.handlers.register import RegistrationHandler
from synapse.handlers.relations import RelationsHandler
from synapse.handlers.reports import ReportsHandler
from synapse.handlers.room import (
RoomContextHandler,
RoomCreationHandler,
Expand Down Expand Up @@ -718,6 +719,10 @@ def get_federation_sender(self) -> AbstractFederationSender:
def get_receipts_handler(self) -> ReceiptsHandler:
return ReceiptsHandler(self)

@cache_in_self
def get_reports_handler(self) -> ReportsHandler:
return ReportsHandler(self)

@cache_in_self
def get_read_marker_handler(self) -> ReadMarkerHandler:
return ReadMarkerHandler(self)
Expand Down
32 changes: 32 additions & 0 deletions synapse/storage/databases/main/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -2421,6 +2421,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 @@ -2662,6 +2663,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
22 changes: 22 additions & 0 deletions synapse/storage/schema/main/delta/92/07_add_user_reports.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
--
-- 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
CREATE INDEX user_reports_user_id ON user_reports(user_id); -- for lookups
89 changes: 89 additions & 0 deletions tests/rest/client/test_reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
# [This file includes modifications made by New Vector Limited]
#
#
from typing import Optional

from twisted.test.proto_helpers import MemoryReactor

Expand Down Expand Up @@ -201,3 +202,91 @@ 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")

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_reason_long(self) -> None:
data = {"reason": "x" * 1001}
self._assert_status(400, data)

def test_cannot_report_nonlocal_user(self) -> None:
"""
Tests that we ignore reports for nonlocal users.
"""
target_user_id = "@bloop:example.org"
data = {"reason": "i am very sad"}
self._assert_status(200, data, target_user_id)
self._assert_no_reports_for_user(target_user_id)

def test_can_report_nonexistent_user(self) -> None:
"""
Tests that we ignore reports for nonexistent users.
"""
target_user_id = f"@bloop:{self.hs.hostname}"
data = {"reason": "i am very sad"}
self._assert_status(200, data, target_user_id)
self._assert_no_reports_for_user(target_user_id)

def _assert_no_reports_for_user(self, target_user_id: str) -> None:
rows = self.get_success(
self.hs.get_datastores().main.db_pool.simple_select_onecol(
table="user_reports",
keyvalues={"target_user_id": target_user_id},
retcol="id",
desc="get_user_report_ids",
)
)
self.assertEqual(len(rows), 0)

def _assert_status(
self, response_status: int, data: JsonDict, user_id: Optional[str] = None
) -> None:
if user_id is None:
user_id = self.target_user_id
channel = self.make_request(
"POST",
f"/_matrix/client/v3/users/{user_id}/report",
data,
access_token=self.other_user_tok,
shorthand=False,
)
self.assertEqual(response_status, channel.code, msg=channel.result["body"])
Loading