From 18f369d7ff84719d55b24f897903b212cdd6838d Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 30 Jul 2024 14:49:32 -0700 Subject: [PATCH 01/14] add admin API to redact all messages of user --- docs/admin_api/user_admin_api.md | 23 ++++++++++ synapse/handlers/admin.py | 43 ++++++++++++++++++- synapse/rest/admin/__init__.py | 2 + synapse/rest/admin/users.py | 30 +++++++++++++ .../storage/databases/main/events_worker.py | 21 +++++++++ 5 files changed, 117 insertions(+), 2 deletions(-) diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index 2281385830..c5e445241b 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -1361,3 +1361,26 @@ Returns a `404` HTTP status code if no user was found, with a response body like ``` _Added in Synapse 1.72.0._ + + +## Redact all the events of a user + +The API is +``` +POST /_synapse/admin/v1/user/$user_id/redact + +{ + "rooms": [!roomid1, !roomid2] +} +``` +If an empty dict is provided as the key for `rooms`, all events in all the rooms the user is member of will be redacted, +otherwise all the events in the rooms provided in the request will be redacted. + +An empty JSON dict is returned. + +**Parameters** + +The following parameters should be set in the URL: + +- `user_id` - The fully qualified MXID of the user: for example, `@user:server.com`. + diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index ec35784c5f..0efa91dd5e 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -25,9 +25,16 @@ import attr -from synapse.api.constants import Direction, Membership +from synapse.api.constants import Direction, EventTypes, Membership from synapse.events import EventBase -from synapse.types import JsonMapping, RoomStreamToken, StateMap, UserID, UserInfo +from synapse.types import ( + JsonMapping, + Requester, + RoomStreamToken, + StateMap, + UserID, + UserInfo, +) from synapse.visibility import filter_events_for_client if TYPE_CHECKING: @@ -43,6 +50,7 @@ def __init__(self, hs: "HomeServer"): self._storage_controllers = hs.get_storage_controllers() self._state_storage_controller = self._storage_controllers.state self._msc3866_enabled = hs.config.experimental.msc3866.enabled + self.event_creation_handler = hs.get_event_creation_handler() async def get_whois(self, user: UserID) -> JsonMapping: connections = [] @@ -305,6 +313,37 @@ async def export_user_data(self, user_id: str, writer: "ExfiltrationWriter") -> return writer.finished() + async def redact_events( + self, user_id: str, rooms: list, requester: Requester + ) -> None: + """ + For a given set of rooms, redact all the events in those rooms sent by the user + + Args: + user_id: user ID of the user whose events should be redacted + rooms: list of rooms to redact their events in + requester: the user requesting the redactions + """ + for room in rooms: + room_version = await self._store.get_room_version(room) + events = await self._store.get_events_sent_by_user(user_id, room) + + for event in events: + event_dict = { + "type": EventTypes.Redaction, + "content": {}, + "room_id": room, + "sender": requester.user.to_string(), + } + if room_version.updated_redaction_rules: + event_dict["content"]["redacts"] = event[0] + else: + event_dict["redacts"] = event[0] + + await self.event_creation_handler.create_and_send_nonmember_event( + requester, event_dict + ) + class ExfiltrationWriter(metaclass=abc.ABCMeta): """Interface used to specify how to write exported data.""" diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index cdaee17451..806ddafee0 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -98,6 +98,7 @@ DeactivateAccountRestServlet, PushersRestServlet, RateLimitRestServlet, + RedactUser, ResetPasswordRestServlet, SearchUsersRestServlet, ShadowBanRestServlet, @@ -319,6 +320,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: UserReplaceMasterCrossSigningKeyRestServlet(hs).register(http_server) UserByExternalId(hs).register(http_server) UserByThreePid(hs).register(http_server) + RedactUser(hs).register(http_server) DeviceRestServlet(hs).register(http_server) DevicesRestServlet(hs).register(http_server) diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index ad515bd5a3..66fd922858 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -1410,3 +1410,33 @@ async def on_GET( raise NotFoundError("User not found") return HTTPStatus.OK, {"user_id": user_id} + + +class RedactUser(RestServlet): + """ + Redact all the events of a given user in the given rooms or if empty dict is provided + then all events in all rooms user is member of + """ + + PATTERNS = admin_patterns("/user/(?P[^/]*)/redact") + + def __init__(self, hs: "HomeServer"): + self._auth = hs.get_auth() + self._store = hs.get_datastores().main + self.admin_handler = hs.get_admin_handler() + + async def on_POST( + self, request: SynapseRequest, user_id: str + ) -> Tuple[int, JsonDict]: + requester = await self._auth.get_user_by_req(request) + await assert_user_is_admin(self._auth, requester) + + body = parse_json_object_from_request(request, allow_empty_body=True) + rooms = body["rooms"] + + if rooms == {}: + rooms = await self._store.get_rooms_for_user(user_id) + + await self.admin_handler.redact_events(user_id, rooms, requester) + + return HTTPStatus.OK, {} diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index 4d4877c4c3..45be0a76b4 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -2439,3 +2439,24 @@ def mark_event_rejected_txn( ) self.invalidate_get_event_cache_after_txn(txn, event_id) + + async def get_events_sent_by_user(self, user_id: str, room_id: str) -> List[tuple]: + """ + Get a list of event ids of events sent by user in room + """ + + def _get_events_by_user_txn( + txn: LoggingTransaction, user_id: str, room_id: str + ) -> List[tuple]: + return self.db_pool.simple_select_many_txn( + txn, + "events", + "sender", + [user_id], + {"room_id": room_id}, + retcols=["event_id"], + ) + + return await self.db_pool.runInteraction( + "get_events_by_user", _get_events_by_user_txn, user_id, room_id + ) From 9a7a30453281f5a7ced044c0cad50ea45846f27f Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 30 Jul 2024 14:49:37 -0700 Subject: [PATCH 02/14] tests --- tests/rest/admin/test_user.py | 126 ++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index 16bb4349f5..f22c06b6a5 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -5089,3 +5089,129 @@ def test_suspend_user(self) -> None: res5 = self.get_success(self.store.get_user_suspended_status(self.bad_user)) self.assertEqual(True, res5) + + +class UserRedactionTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets, + login.register_servlets, + admin.register_servlets, + room.register_servlets, + sync.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.admin = self.register_user("thomas", "pass", True) + self.admin_tok = self.login("thomas", "pass") + + self.bad_user = self.register_user("teresa", "pass") + self.bad_user_tok = self.login("teresa", "pass") + + self.store = hs.get_datastores().main + + def test_redact_messages_all_rooms(self) -> None: + """ + Test that request to redact events in all rooms user is member of is successful + """ + # create rooms, some with updated redaction rules + rm1 = self.helper.create_room_as( + self.admin, tok=self.admin_tok, room_version="7" + ) + rm2 = self.helper.create_room_as( + self.admin, tok=self.admin_tok, room_version="11" + ) + rm3 = self.helper.create_room_as(self.admin, tok=self.admin_tok) + + # join rooms, send some messages + originals = [] + for rm in [rm1, rm2, rm3]: + join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + originals.append(join["event_id"]) + for i in range(5): + event = {"body": f"hello{i}", "msgtype": "m.text"} + res = self.helper.send_event( + rm, "m.text", event, tok=self.bad_user_tok, expect_code=200 + ) + originals.append(res["event_id"]) + + # redact all events in all rooms + channel = self.make_request( + "POST", + f"/_synapse/admin/v1/user/{self.bad_user}/redact", + content={"rooms": {}}, + access_token=self.admin_tok, + ) + self.assertEqual(channel.code, 200) + + for rm in [rm1, rm2, rm3]: + channel = self.make_request("GET", "sync", access_token=self.admin_tok) + self.assertEqual(channel.code, 200) + room_sync = channel.json_body["rooms"]["join"][rm] + timeline = room_sync["timeline"]["events"] + + matches = [] + for event in timeline: + for event_id in originals: + if ( + event["type"] == "m.room.redaction" + and event["redacts"] == event_id + ): + matches.append((event_id, event)) + + self.assertEqual(len(matches), 6) + + def test_redact_messages_specific_rooms(self) -> None: + """ + Test that request to redact events in specified rooms user is member of is successful + """ + rm1 = self.helper.create_room_as( + self.admin, tok=self.admin_tok, room_version="7" + ) + rm2 = self.helper.create_room_as(self.admin, tok=self.admin_tok) + rm3 = self.helper.create_room_as( + self.admin, tok=self.admin_tok, room_version="11" + ) + + originals = [] + for rm in [rm1, rm2, rm3]: + join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + originals.append(join["event_id"]) + for i in range(5): + event = {"body": f"hello{i}", "msgtype": "m.text"} + res = self.helper.send_event(rm, "m.text", event, tok=self.bad_user_tok) + originals.append(res["event_id"]) + + # redact messages in rooms 1 and 3 + channel = self.make_request( + "POST", + f"/_synapse/admin/v1/user/{self.bad_user}/redact", + content={"rooms": [rm1, rm3]}, + access_token=self.admin_tok, + ) + self.assertEqual(channel.code, 200) + + # messages in requested rooms are redacted + for rm in [rm1, rm3]: + channel = self.make_request("GET", "sync", access_token=self.admin_tok) + self.assertEqual(channel.code, 200) + room_sync = channel.json_body["rooms"]["join"][rm] + timeline = room_sync["timeline"]["events"] + + matches = [] + for event in timeline: + for event_id in originals: + if ( + event["type"] == "m.room.redaction" + and event["redacts"] == event_id + ): + matches.append((event_id, event)) + + self.assertEqual(len(matches), 6) + + rm2_sync = channel.json_body["rooms"]["join"][rm2] + rm2_timeline = rm2_sync["timeline"]["events"] + + # messages in remaining room are not + for event in rm2_timeline: + if event["type"] == "m.room.redaction": + self.fail("found redaction in room 2") From 2eb245b974c79cc511e49bedc30bbe5006acb8e9 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 30 Jul 2024 14:56:19 -0700 Subject: [PATCH 03/14] newsfragment --- changelog.d/17506.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/17506.feature diff --git a/changelog.d/17506.feature b/changelog.d/17506.feature new file mode 100644 index 0000000000..25cd6f2823 --- /dev/null +++ b/changelog.d/17506.feature @@ -0,0 +1 @@ +Add an Admin API endpoint to redact all a user's events. \ No newline at end of file From 13312d2f28a8d6d0679a579387b5d34e996d65aa Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Thu, 1 Aug 2024 10:47:50 -0700 Subject: [PATCH 04/14] make reaction resumable and api asynchronous --- changelog.d/17506.feature | 2 +- docs/admin_api/user_admin_api.md | 49 ++++++++++++++- synapse/handlers/admin.py | 101 +++++++++++++++++++++++++++---- synapse/rest/admin/__init__.py | 2 + synapse/rest/admin/users.py | 38 +++++++++++- 5 files changed, 176 insertions(+), 16 deletions(-) diff --git a/changelog.d/17506.feature b/changelog.d/17506.feature index 25cd6f2823..ac1e76955c 100644 --- a/changelog.d/17506.feature +++ b/changelog.d/17506.feature @@ -1 +1 @@ -Add an Admin API endpoint to redact all a user's events. \ No newline at end of file +Add an asynchronous Admin API endpoint to redact all a user's events, and an endpoint to check on the status of that redaction task. \ No newline at end of file diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index c5e445241b..703b2f2c9f 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -1376,7 +1376,14 @@ POST /_synapse/admin/v1/user/$user_id/redact If an empty dict is provided as the key for `rooms`, all events in all the rooms the user is member of will be redacted, otherwise all the events in the rooms provided in the request will be redacted. -An empty JSON dict is returned. +The API starts redaction process running, and returns immediately with a JSON body with +a redact id which can be used to query the status of the redaction process: + +```json +{ + "redact_id": "" +} +``` **Parameters** @@ -1384,3 +1391,43 @@ The following parameters should be set in the URL: - `user_id` - The fully qualified MXID of the user: for example, `@user:server.com`. +The following JSON body parameter must be provided: + +- `rooms` - A list of rooms to redact the user's events in, if an empty list is provided all events in all rooms + the user is a member of will be redacted + + +## Check the status of a redaction process + +It is possible to query the status of the background task for redacting a user's events. +The status can be queried up to 24 hours after completion of the task, +or until Synapse is restarted (whichever happens first). + +The API is: + +``` +GET /_synapse/admin/v1/user/redact_status/$redact_id +``` + +A response body like the following is returned: + +``` +{ + "status": "active", + "failed_redactions": [], +} +``` + +**Parameters** + +The following parameters should be set in the URL: + +* `redact_id` - The ID for this redaction, provided when the redaction was requested. + + +**Response** + +The following fields are returned in the JSON response body: + +- status: one of scheduled/active/completed/failed, indicating the status of the redaction job +- failed: a list of event ids the process was unable to redact, if any \ No newline at end of file diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index 0efa91dd5e..28b64b5fa3 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -21,17 +21,30 @@ import abc import logging -from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Sequence, Set +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, +) import attr from synapse.api.constants import Direction, EventTypes, Membership +from synapse.api.errors import SynapseError from synapse.events import EventBase from synapse.types import ( JsonMapping, Requester, RoomStreamToken, + ScheduledTask, StateMap, + TaskStatus, UserID, UserInfo, ) @@ -42,6 +55,8 @@ logger = logging.getLogger(__name__) +REDACT_ALL_EVENTS_ACTION_NAME = "redact_all_events" + class AdminHandler: def __init__(self, hs: "HomeServer"): @@ -51,6 +66,19 @@ def __init__(self, hs: "HomeServer"): self._state_storage_controller = self._storage_controllers.state self._msc3866_enabled = hs.config.experimental.msc3866.enabled self.event_creation_handler = hs.get_event_creation_handler() + self._task_scheduler = hs.get_task_scheduler() + + self._task_scheduler.register_action( + self._redact_all_events, REDACT_ALL_EVENTS_ACTION_NAME + ) + + async def get_redact_task(self, redact_id: str) -> Optional[ScheduledTask]: + """Get the current status of an active redaction process + + Args: + redact_id: redact_id returned by start_redact_events. + """ + return await self._task_scheduler.get_task(redact_id) async def get_whois(self, user: UserID) -> JsonMapping: connections = [] @@ -313,17 +341,61 @@ async def export_user_data(self, user_id: str, writer: "ExfiltrationWriter") -> return writer.finished() - async def redact_events( - self, user_id: str, rooms: list, requester: Requester - ) -> None: + async def start_redact_events( + self, user_id: str, rooms: list, requester: JsonMapping + ) -> str: """ - For a given set of rooms, redact all the events in those rooms sent by the user + Start a task redacting the events of the given user in the givent rooms Args: - user_id: user ID of the user whose events should be redacted - rooms: list of rooms to redact their events in - requester: the user requesting the redactions + user_id: the user ID of the user whose events should be redacted + rooms: the rooms in which to redact the user's events + requester: the user requesting the events """ + active_tasks = await self._task_scheduler.get_tasks( + actions=[REDACT_ALL_EVENTS_ACTION_NAME], + resource_id=user_id, + statuses=[TaskStatus.ACTIVE], + ) + + if len(active_tasks) > 0: + raise SynapseError( + 400, "Redact already in progress for user %s" % (user_id,) + ) + + redact_id = await self._task_scheduler.schedule_task( + REDACT_ALL_EVENTS_ACTION_NAME, + resource_id=user_id, + params={"rooms": rooms, "requester": requester, "user_id": user_id}, + ) + + logger.info( + "starting redact events with redact_id %s", + redact_id, + ) + + return redact_id + + async def _redact_all_events( + self, task: ScheduledTask + ) -> Tuple[TaskStatus, Optional[Mapping[str, Any]], Optional[str]]: + """ + Task to redact all a users events in the given rooms, tracking which, if any, events + whose redaction failed + """ + + assert task.params is not None + rooms = task.params.get("rooms") + assert rooms is not None + + r = task.params.get("requester") + assert r is not None + requester = Requester.deserialize(self._store, r) + + user_id = task.params.get("user_id") + assert user_id is not None + + result: Dict[str, Any] = {"result": []} for room in rooms: room_version = await self._store.get_room_version(room) events = await self._store.get_events_sent_by_user(user_id, room) @@ -340,9 +412,16 @@ async def redact_events( else: event_dict["redacts"] = event[0] - await self.event_creation_handler.create_and_send_nonmember_event( - requester, event_dict - ) + try: + await self.event_creation_handler.create_and_send_nonmember_event( + requester, event_dict + ) + except Exception as ex: + logger.info(f"Redaction of event {event[0]} failed due to: {ex}") + result["result"].append(event[0]) + await self._task_scheduler.update_task(task.id, result=result) + + return TaskStatus.COMPLETE, result, None class ExfiltrationWriter(metaclass=abc.ABCMeta): diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index 806ddafee0..4db8975674 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -99,6 +99,7 @@ PushersRestServlet, RateLimitRestServlet, RedactUser, + RedactUserStatus, ResetPasswordRestServlet, SearchUsersRestServlet, ShadowBanRestServlet, @@ -321,6 +322,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: UserByExternalId(hs).register(http_server) UserByThreePid(hs).register(http_server) RedactUser(hs).register(http_server) + RedactUserStatus(hs).register(http_server) DeviceRestServlet(hs).register(http_server) DevicesRestServlet(hs).register(http_server) diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index 66fd922858..01dc9e63dc 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -1415,7 +1415,8 @@ async def on_GET( class RedactUser(RestServlet): """ Redact all the events of a given user in the given rooms or if empty dict is provided - then all events in all rooms user is member of + then all events in all rooms user is member of. Kicks off a background process and + returns an id that can be used to check on the progress of the redaction progress """ PATTERNS = admin_patterns("/user/(?P[^/]*)/redact") @@ -1437,6 +1438,37 @@ async def on_POST( if rooms == {}: rooms = await self._store.get_rooms_for_user(user_id) - await self.admin_handler.redact_events(user_id, rooms, requester) + redact_id = await self.admin_handler.start_redact_events( + user_id, list(rooms), requester.serialize() + ) - return HTTPStatus.OK, {} + return HTTPStatus.OK, {"redact_id": redact_id} + + +class RedactUserStatus(RestServlet): + """ + Check on the progress of the redaction request represented by the provided ID, returning + the status of the process and a list of events that were unable to be redacted, if any + """ + + PATTERNS = admin_patterns("/user/redact_status/(?P[^/]*)$") + + def __init__(self, hs: "HomeServer"): + self._auth = hs.get_auth() + self.admin_handler = hs.get_admin_handler() + + async def on_GET( + self, request: SynapseRequest, redact_id: str + ) -> Tuple[int, JsonDict]: + await assert_requester_is_admin(self._auth, request) + + task = await self.admin_handler.get_redact_task(redact_id) + + if task: + assert task.result is not None + return HTTPStatus.OK, { + "status": task.status, + "failed_redactions": task.result["result"], + } + else: + raise NotFoundError("redact id '%s' not found" % redact_id) From 17d782a814efddc646213476b73d487289ad69f4 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Thu, 1 Aug 2024 10:47:56 -0700 Subject: [PATCH 05/14] update tests --- tests/rest/admin/test_user.py | 65 +++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index f22c06b6a5..dbf90ed478 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -5109,6 +5109,8 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: self.store = hs.get_datastores().main + self.spam_checker = hs.get_module_api_callbacks().spam_checker + def test_redact_messages_all_rooms(self) -> None: """ Test that request to redact events in all rooms user is member of is successful @@ -5215,3 +5217,66 @@ def test_redact_messages_specific_rooms(self) -> None: for event in rm2_timeline: if event["type"] == "m.room.redaction": self.fail("found redaction in room 2") + + def test_redact_status(self) -> None: + rm1 = self.helper.create_room_as( + self.admin, tok=self.admin_tok, room_version="7" + ) + rm2 = self.helper.create_room_as(self.admin, tok=self.admin_tok) + rm3 = self.helper.create_room_as( + self.admin, tok=self.admin_tok, room_version="11" + ) + + originals = [] + for rm in [rm1, rm2, rm3]: + join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + if rm == rm2: + originals.append(join["event_id"]) + for i in range(5): + event = {"body": f"hello{i}", "msgtype": "m.text"} + res = self.helper.send_event(rm, "m.text", event, tok=self.bad_user_tok) + if rm == rm2: + originals.append(res["event_id"]) + + # redact messages in rooms 1 and 3 + channel = self.make_request( + "POST", + f"/_synapse/admin/v1/user/{self.bad_user}/redact", + content={"rooms": [rm1, rm3]}, + access_token=self.admin_tok, + ) + self.assertEqual(channel.code, 200) + id = channel.json_body.get("redact_id") + + channel2 = self.make_request( + "GET", + f"/_synapse/admin/v1/user/redact_status/{id}", + access_token=self.admin_tok, + ) + self.assertEqual(channel2.code, 200) + self.assertEqual(channel2.json_body.get("status"), "complete") + self.assertEqual(channel2.json_body.get("failed_redactions"), []) + + # mock that will cause persisting the redaction events to fail + async def check_event_for_spam(event: str) -> str: + return "spam" + + self.spam_checker.check_event_for_spam = check_event_for_spam # type: ignore + + channel3 = self.make_request( + "POST", + f"/_synapse/admin/v1/user/{self.bad_user}/redact", + content={"rooms": [rm2]}, + access_token=self.admin_tok, + ) + self.assertEqual(channel.code, 200) + id = channel3.json_body.get("redact_id") + + channel4 = self.make_request( + "GET", + f"/_synapse/admin/v1/user/redact_status/{id}", + access_token=self.admin_tok, + ) + self.assertEqual(channel4.code, 200) + self.assertEqual(channel4.json_body.get("status"), "complete") + self.assertEqual(channel4.json_body.get("failed_redactions"), originals) From 81a1f2b49eca13dac9a93374e9e087963d3f1953 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 20 Aug 2024 12:41:36 -0700 Subject: [PATCH 06/14] update redaction process --- synapse/handlers/admin.py | 70 ++++++++++++++---- .../storage/databases/main/events_worker.py | 73 +++++++++++++++---- 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index 28b64b5fa3..3415ac5513 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -342,15 +342,25 @@ async def export_user_data(self, user_id: str, writer: "ExfiltrationWriter") -> return writer.finished() async def start_redact_events( - self, user_id: str, rooms: list, requester: JsonMapping + self, + user_id: str, + rooms: list, + requester: JsonMapping, + reason: Optional[str], + limit: Optional[int], ) -> str: """ - Start a task redacting the events of the given user in the givent rooms + Start a task redacting the events of the given user in the given rooms Args: user_id: the user ID of the user whose events should be redacted rooms: the rooms in which to redact the user's events requester: the user requesting the events + reason: reason for requesting the redaction, ie spam, etc + limit: limit on the number of events in each room to redact + + Returns: + a unique ID which can be used to query the status of the task """ active_tasks = await self._task_scheduler.get_tasks( actions=[REDACT_ALL_EVENTS_ACTION_NAME], @@ -366,7 +376,13 @@ async def start_redact_events( redact_id = await self._task_scheduler.schedule_task( REDACT_ALL_EVENTS_ACTION_NAME, resource_id=user_id, - params={"rooms": rooms, "requester": requester, "user_id": user_id}, + params={ + "rooms": rooms, + "requester": requester, + "user_id": user_id, + "reason": reason, + "limit": limit, + }, ) logger.info( @@ -380,7 +396,7 @@ async def _redact_all_events( self, task: ScheduledTask ) -> Tuple[TaskStatus, Optional[Mapping[str, Any]], Optional[str]]: """ - Task to redact all a users events in the given rooms, tracking which, if any, events + Task to redact all of a users events in the given rooms, tracking which, if any, events whose redaction failed """ @@ -395,30 +411,56 @@ async def _redact_all_events( user_id = task.params.get("user_id") assert user_id is not None - result: Dict[str, Any] = {"result": []} + reason = task.params.get("reason") + limit = task.params.get("limit") + + result: Mapping[str, Any] = ( + task.result + if task.result + else {"failed_redactions": {}, "successful_redactions": []} + ) for room in rooms: room_version = await self._store.get_room_version(room) - events = await self._store.get_events_sent_by_user(user_id, room) + events = await self._store.get_events_sent_by_user_in_room( + user_id, room, limit, "m.room.redaction" + ) + + if not events: + # there's nothing to redact + return TaskStatus.COMPLETE, result, None for event in events: + # if we've already successfully redacted this event then skip processing it + if event in result["successful_redactions"]: + continue + event_dict = { "type": EventTypes.Redaction, - "content": {}, + "content": {"reason": reason} if reason else {}, "room_id": room, - "sender": requester.user.to_string(), + "sender": user_id, } if room_version.updated_redaction_rules: - event_dict["content"]["redacts"] = event[0] + event_dict["content"]["redacts"] = event else: - event_dict["redacts"] = event[0] + event_dict["redacts"] = event try: - await self.event_creation_handler.create_and_send_nonmember_event( - requester, event_dict + # set the prev event to the offending message to allow for redactions + # to be processed in the case where the user has been kicked/banned before + # redactions are requested + redaction, _ = ( + await self.event_creation_handler.create_and_send_nonmember_event( + requester, + event_dict, + prev_event_ids=[event], + ratelimit=False, + ) ) + result["successful_redactions"].append(event) except Exception as ex: - logger.info(f"Redaction of event {event[0]} failed due to: {ex}") - result["result"].append(event[0]) + logger.info(f"Redaction of event {event} failed due to: {ex}") + result["failed_redactions"][event] = str(ex) await self._task_scheduler.update_task(task.id, result=result) return TaskStatus.COMPLETE, result, None diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index 45be0a76b4..35cafcf32f 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -2440,23 +2440,66 @@ def mark_event_rejected_txn( self.invalidate_get_event_cache_after_txn(txn, event_id) - async def get_events_sent_by_user(self, user_id: str, room_id: str) -> List[tuple]: + async def get_events_sent_by_user_in_room( + self, user_id: str, room_id: str, limit: Optional[int], filter: str = "none" + ) -> Optional[List[str]]: """ - Get a list of event ids of events sent by user in room + Get a list of event ids and event info of events sent by the user in the specified room + + Args: + user_id: user ID to search against + room_id: room ID of the room to search for events in + filter: type of event to filter out + limit: maximum number of event ids to return """ def _get_events_by_user_txn( - txn: LoggingTransaction, user_id: str, room_id: str - ) -> List[tuple]: - return self.db_pool.simple_select_many_txn( - txn, - "events", - "sender", - [user_id], - {"room_id": room_id}, - retcols=["event_id"], - ) + txn: LoggingTransaction, + user_id: str, + room_id: str, + filter: Optional[str], + batch_size: int, + offset: int, + ) -> Tuple[Optional[List[str]], int]: - return await self.db_pool.runInteraction( - "get_events_by_user", _get_events_by_user_txn, user_id, room_id - ) + sql = """ + SELECT event_id FROM events + WHERE sender = ? AND room_id = ? + AND type != ? + ORDER BY received_ts DESC + LIMIT ? + OFFSET ? + """ + txn.execute(sql, (user_id, room_id, filter, batch_size, offset)) + res = txn.fetchall() + if res: + events = [row[0] for row in res] + else: + events = None + + return events, offset + batch_size + + if not limit: + limit = 1000 + + offset = 0 + batch_size = 100 + if batch_size < limit: + batch_size = limit + + selected_ids: List[str] = [] + while offset < limit: + res, offset = await self.db_pool.runInteraction( + "get_events_by_user", + _get_events_by_user_txn, + user_id, + room_id, + filter, + batch_size, + offset, + ) + if res: + selected_ids = selected_ids + res + else: + return selected_ids + return selected_ids From 455198e64e0d37ec09593d72bec8421d4a97f439 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 20 Aug 2024 12:42:03 -0700 Subject: [PATCH 07/14] update docs and response --- docs/admin_api/user_admin_api.md | 19 +++++++++++--- synapse/rest/admin/users.py | 44 ++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index 703b2f2c9f..f7488b3dd8 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -1370,10 +1370,10 @@ The API is POST /_synapse/admin/v1/user/$user_id/redact { - "rooms": [!roomid1, !roomid2] + "rooms": ["!roomid1", "!roomid2"] } ``` -If an empty dict is provided as the key for `rooms`, all events in all the rooms the user is member of will be redacted, +If an empty list is provided as the key for `rooms`, all events in all the rooms the user is member of will be redacted, otherwise all the events in the rooms provided in the request will be redacted. The API starts redaction process running, and returns immediately with a JSON body with @@ -1393,9 +1393,16 @@ The following parameters should be set in the URL: The following JSON body parameter must be provided: -- `rooms` - A list of rooms to redact the user's events in, if an empty list is provided all events in all rooms +- `rooms` - A list of rooms to redact the user's events in. If an empty list is provided all events in all rooms the user is a member of will be redacted +_Added in Synapse 1.114.0._ + +The following JSON body parameters are optional: + +- `reason` - Reason the redaction is being requested, ie "spam", "abuse", etc +- `limit` - a limit on the number of events to redact (events are redacted newest to oldest) in each room, defaults to 1000 if not provided + ## Check the status of a redaction process @@ -1430,4 +1437,8 @@ The following parameters should be set in the URL: The following fields are returned in the JSON response body: - status: one of scheduled/active/completed/failed, indicating the status of the redaction job -- failed: a list of event ids the process was unable to redact, if any \ No newline at end of file +- failed_redactions: a dict where the keys are event ids the process was unable to redact, if any, and the values are + the corresponding error that caused the redaction to fail +- successful_redactions: a list of event ids that were successfully redacted + +_Added in Synapse 1.114.0._ \ No newline at end of file diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index 01dc9e63dc..6b18d122a5 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -50,7 +50,7 @@ from synapse.rest.client._base import client_patterns from synapse.storage.databases.main.registration import ExternalIDReuseException from synapse.storage.databases.main.stats import UserSortOrder -from synapse.types import JsonDict, JsonMapping, UserID +from synapse.types import JsonDict, JsonMapping, TaskStatus, UserID from synapse.types.rest import RequestBodyModel if TYPE_CHECKING: @@ -1433,13 +1433,18 @@ async def on_POST( await assert_user_is_admin(self._auth, requester) body = parse_json_object_from_request(request, allow_empty_body=True) - rooms = body["rooms"] + rooms = body.get("rooms") + if rooms is None: + raise SynapseError(400, "Must provide a value for rooms.") - if rooms == {}: + reason = body.get("reason") + limit = body.get("limit") + + if not rooms: rooms = await self._store.get_rooms_for_user(user_id) redact_id = await self.admin_handler.start_redact_events( - user_id, list(rooms), requester.serialize() + user_id, list(rooms), requester.serialize(), reason, limit ) return HTTPStatus.OK, {"redact_id": redact_id} @@ -1448,7 +1453,7 @@ async def on_POST( class RedactUserStatus(RestServlet): """ Check on the progress of the redaction request represented by the provided ID, returning - the status of the process and a list of events that were unable to be redacted, if any + the status of the process and a dict of events that were unable to be redacted, if any """ PATTERNS = admin_patterns("/user/redact_status/(?P[^/]*)$") @@ -1465,10 +1470,29 @@ async def on_GET( task = await self.admin_handler.get_redact_task(redact_id) if task: - assert task.result is not None - return HTTPStatus.OK, { - "status": task.status, - "failed_redactions": task.result["result"], - } + if task.status == TaskStatus.ACTIVE: + return HTTPStatus.OK, {"status": TaskStatus.ACTIVE} + elif task.status == TaskStatus.COMPLETE: + assert task.result is not None + failed_redactions = task.result.get("failed_redactions") + successful_redactions = task.result.get("successful_redactions") + return HTTPStatus.OK, { + "status": TaskStatus.COMPLETE, + "failed_redactions": failed_redactions if failed_redactions else {}, + "successful_redactions": ( + successful_redactions if successful_redactions else [] + ), + } + elif task.status == TaskStatus.SCHEDULED: + return HTTPStatus.OK, {"status": TaskStatus.SCHEDULED} + else: + return HTTPStatus.OK, { + "status": TaskStatus.FAILED, + "error": ( + task.error + if task.error + else "Unknown error, please check the logs for more information." + ), + } else: raise NotFoundError("redact id '%s' not found" % redact_id) From d32bc95f70ce1ebfb3ab80d39e4515b4922539c0 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 20 Aug 2024 12:42:08 -0700 Subject: [PATCH 08/14] tests --- tests/rest/admin/test_user.py | 140 ++++++++++++++++++++++++++++------ 1 file changed, 117 insertions(+), 23 deletions(-) diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index dbf90ed478..49a8a0b4d1 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -21,9 +21,11 @@ import hashlib import hmac +import json import os import urllib.parse from binascii import unhexlify +from http import HTTPStatus from typing import Dict, List, Optional from unittest.mock import AsyncMock, Mock, patch @@ -33,7 +35,7 @@ from twisted.web.resource import Resource import synapse.rest.admin -from synapse.api.constants import ApprovalNoticeMedium, LoginType, UserTypes +from synapse.api.constants import ApprovalNoticeMedium, EventTypes, LoginType, UserTypes from synapse.api.errors import Codes, HttpResponseException, ResourceLimitError from synapse.api.room_versions import RoomVersions from synapse.media.filepath import MediaFilePaths @@ -5129,7 +5131,7 @@ def test_redact_messages_all_rooms(self) -> None: for rm in [rm1, rm2, rm3]: join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) originals.append(join["event_id"]) - for i in range(5): + for i in range(15): event = {"body": f"hello{i}", "msgtype": "m.text"} res = self.helper.send_event( rm, "m.text", event, tok=self.bad_user_tok, expect_code=200 @@ -5140,27 +5142,29 @@ def test_redact_messages_all_rooms(self) -> None: channel = self.make_request( "POST", f"/_synapse/admin/v1/user/{self.bad_user}/redact", - content={"rooms": {}}, + content={"rooms": []}, access_token=self.admin_tok, ) self.assertEqual(channel.code, 200) + matched = [] for rm in [rm1, rm2, rm3]: - channel = self.make_request("GET", "sync", access_token=self.admin_tok) + filter = json.dumps({"types": [EventTypes.Redaction]}) + channel = self.make_request( + "GET", + f"rooms/{rm}/messages?filter={filter}&limit=50", + access_token=self.admin_tok, + ) self.assertEqual(channel.code, 200) - room_sync = channel.json_body["rooms"]["join"][rm] - timeline = room_sync["timeline"]["events"] - matches = [] - for event in timeline: + for event in channel.json_body["chunk"]: for event_id in originals: if ( event["type"] == "m.room.redaction" and event["redacts"] == event_id ): - matches.append((event_id, event)) - - self.assertEqual(len(matches), 6) + matched.append(event_id) + self.assertEqual(len(matched), len(originals)) def test_redact_messages_specific_rooms(self) -> None: """ @@ -5178,7 +5182,7 @@ def test_redact_messages_specific_rooms(self) -> None: for rm in [rm1, rm2, rm3]: join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) originals.append(join["event_id"]) - for i in range(5): + for i in range(15): event = {"body": f"hello{i}", "msgtype": "m.text"} res = self.helper.send_event(rm, "m.text", event, tok=self.bad_user_tok) originals.append(res["event_id"]) @@ -5194,27 +5198,32 @@ def test_redact_messages_specific_rooms(self) -> None: # messages in requested rooms are redacted for rm in [rm1, rm3]: - channel = self.make_request("GET", "sync", access_token=self.admin_tok) + filter = json.dumps({"types": [EventTypes.Redaction]}) + channel = self.make_request( + "GET", + f"rooms/{rm}/messages?filter={filter}&limit=50", + access_token=self.admin_tok, + ) self.assertEqual(channel.code, 200) - room_sync = channel.json_body["rooms"]["join"][rm] - timeline = room_sync["timeline"]["events"] matches = [] - for event in timeline: + for event in channel.json_body["chunk"]: for event_id in originals: if ( event["type"] == "m.room.redaction" and event["redacts"] == event_id ): matches.append((event_id, event)) + # we redacted 16 messages + self.assertEqual(len(matches), 16) - self.assertEqual(len(matches), 6) - - rm2_sync = channel.json_body["rooms"]["join"][rm2] - rm2_timeline = rm2_sync["timeline"]["events"] + channel = self.make_request( + "GET", f"rooms/{rm2}/messages?limit=50", access_token=self.admin_tok + ) + self.assertEqual(channel.code, 200) # messages in remaining room are not - for event in rm2_timeline: + for event in channel.json_body["chunk"]: if event["type"] == "m.room.redaction": self.fail("found redaction in room 2") @@ -5255,7 +5264,7 @@ def test_redact_status(self) -> None: ) self.assertEqual(channel2.code, 200) self.assertEqual(channel2.json_body.get("status"), "complete") - self.assertEqual(channel2.json_body.get("failed_redactions"), []) + self.assertEqual(channel2.json_body.get("failed_redactions"), {}) # mock that will cause persisting the redaction events to fail async def check_event_for_spam(event: str) -> str: @@ -5279,4 +5288,89 @@ async def check_event_for_spam(event: str) -> str: ) self.assertEqual(channel4.code, 200) self.assertEqual(channel4.json_body.get("status"), "complete") - self.assertEqual(channel4.json_body.get("failed_redactions"), originals) + failed_redactions = channel4.json_body.get("failed_redactions") + assert failed_redactions is not None + matched = [] + for original in originals: + if failed_redactions.get(original) is not None: + matched.append(original) + self.assertEqual(len(matched), len(originals)) + + def test_admin_redact_works_if_user_kicked_or_banned(self) -> None: + rm1 = self.helper.create_room_as( + self.admin, tok=self.admin_tok, room_version="7" + ) + rm2 = self.helper.create_room_as(self.admin, tok=self.admin_tok) + rm3 = self.helper.create_room_as( + self.admin, tok=self.admin_tok, room_version="11" + ) + + originals = [] + for rm in [rm1, rm2, rm3]: + join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + originals.append(join["event_id"]) + for i in range(5): + event = {"body": f"hello{i}", "msgtype": "m.text"} + res = self.helper.send_event(rm, "m.text", event, tok=self.bad_user_tok) + originals.append(res["event_id"]) + + # kick user from rooms 1 and 3 + for r in [rm1, rm2]: + channel = self.make_request( + "POST", + f"/_matrix/client/r0/rooms/{r}/kick", + content={"reason": "being a bummer", "user_id": self.bad_user}, + access_token=self.admin_tok, + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.result) + + # redact messages in room 1 and 3 + channel1 = self.make_request( + "POST", + f"/_synapse/admin/v1/user/{self.bad_user}/redact", + content={"rooms": [rm1, rm3]}, + access_token=self.admin_tok, + ) + self.assertEqual(channel1.code, 200) + id = channel1.json_body.get("redact_id") + + # check that there were no failed redactions in room 1 and 3 + channel2 = self.make_request( + "GET", + f"/_synapse/admin/v1/user/redact_status/{id}", + access_token=self.admin_tok, + ) + self.assertEqual(channel2.code, 200) + self.assertEqual(channel2.json_body.get("status"), "complete") + failed_redactions = channel2.json_body.get("failed_redactions") + self.assertEqual(failed_redactions, {}) + + # ban user + channel3 = self.make_request( + "POST", + f"/_matrix/client/r0/rooms/{rm2}/ban", + content={"reason": "being a bummer", "user_id": self.bad_user}, + access_token=self.admin_tok, + ) + self.assertEqual(channel3.code, HTTPStatus.OK, channel3.result) + + # redact messages in room 2 + channel4 = self.make_request( + "POST", + f"/_synapse/admin/v1/user/{self.bad_user}/redact", + content={"rooms": [rm2]}, + access_token=self.admin_tok, + ) + self.assertEqual(channel4.code, 200) + id2 = channel1.json_body.get("redact_id") + + # check that there were no failed redactions in room 2 + channel5 = self.make_request( + "GET", + f"/_synapse/admin/v1/user/redact_status/{id2}", + access_token=self.admin_tok, + ) + self.assertEqual(channel5.code, 200) + self.assertEqual(channel5.json_body.get("status"), "complete") + failed_redactions = channel5.json_body.get("failed_redactions") + self.assertEqual(failed_redactions, {}) From 9ff860a27f4479204c997d94ac49acbb140282be Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Thu, 29 Aug 2024 13:25:52 -0700 Subject: [PATCH 09/14] requested changes --- docs/admin_api/user_admin_api.md | 1 - synapse/handlers/admin.py | 61 ++++++++++++++----- synapse/rest/admin/users.py | 4 -- .../storage/databases/main/events_worker.py | 53 ++++++++++------ 4 files changed, 79 insertions(+), 40 deletions(-) diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index f7488b3dd8..f964bdc04a 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -1439,6 +1439,5 @@ The following fields are returned in the JSON response body: - status: one of scheduled/active/completed/failed, indicating the status of the redaction job - failed_redactions: a dict where the keys are event ids the process was unable to redact, if any, and the values are the corresponding error that caused the redaction to fail -- successful_redactions: a list of event ids that were successfully redacted _Added in Synapse 1.114.0._ \ No newline at end of file diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index 3415ac5513..d5e946ade9 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -47,6 +47,7 @@ TaskStatus, UserID, UserInfo, + create_requester, ) from synapse.visibility import filter_events_for_client @@ -406,32 +407,61 @@ async def _redact_all_events( r = task.params.get("requester") assert r is not None - requester = Requester.deserialize(self._store, r) + admin = Requester.deserialize(self._store, r) user_id = task.params.get("user_id") assert user_id is not None + requester = create_requester( + user_id, authenticated_entity=admin.user.to_string() + ) + reason = task.params.get("reason") limit = task.params.get("limit") + if not limit: + limit = 1000 + result: Mapping[str, Any] = ( - task.result - if task.result - else {"failed_redactions": {}, "successful_redactions": []} + task.result if task.result else {"failed_redactions": {}} ) for room in rooms: room_version = await self._store.get_room_version(room) - events = await self._store.get_events_sent_by_user_in_room( - user_id, room, limit, "m.room.redaction" + event_ids = await self._store.get_events_sent_by_user_in_room( + user_id, + room, + limit, + [ + "m.room.member", + "m.text", + "m.emote", + "m.image", + "m.file", + "m.audio", + "m.video", + ], ) - - if not events: + if not event_ids: # there's nothing to redact return TaskStatus.COMPLETE, result, None + events = await self._store.get_events_as_list(set(event_ids)) for event in events: + # we care about join events but not other membership events + if event.type == "m.room.member": + dict = event.get_dict() + content = dict.get("content") + if content: + if content.get("membership") == "join": + pass + else: + continue + relations = await self._store.get_relations_for_event( + room, event.event_id, event, event_type=EventTypes.Redaction + ) + # if we've already successfully redacted this event then skip processing it - if event in result["successful_redactions"]: + if relations[0]: continue event_dict = { @@ -441,9 +471,9 @@ async def _redact_all_events( "sender": user_id, } if room_version.updated_redaction_rules: - event_dict["content"]["redacts"] = event + event_dict["content"]["redacts"] = event.event_id else: - event_dict["redacts"] = event + event_dict["redacts"] = event.event_id try: # set the prev event to the offending message to allow for redactions @@ -453,14 +483,15 @@ async def _redact_all_events( await self.event_creation_handler.create_and_send_nonmember_event( requester, event_dict, - prev_event_ids=[event], + prev_event_ids=[event.event_id], ratelimit=False, ) ) - result["successful_redactions"].append(event) except Exception as ex: - logger.info(f"Redaction of event {event} failed due to: {ex}") - result["failed_redactions"][event] = str(ex) + logger.info( + f"Redaction of event {event.event_id} failed due to: {ex}" + ) + result["failed_redactions"][event.event_id] = str(ex) await self._task_scheduler.update_task(task.id, result=result) return TaskStatus.COMPLETE, result, None diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index 6b18d122a5..c5416f97ac 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -1475,13 +1475,9 @@ async def on_GET( elif task.status == TaskStatus.COMPLETE: assert task.result is not None failed_redactions = task.result.get("failed_redactions") - successful_redactions = task.result.get("successful_redactions") return HTTPStatus.OK, { "status": TaskStatus.COMPLETE, "failed_redactions": failed_redactions if failed_redactions else {}, - "successful_redactions": ( - successful_redactions if successful_redactions else [] - ), } elif task.status == TaskStatus.SCHEDULED: return HTTPStatus.OK, {"status": TaskStatus.SCHEDULED} diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index 35cafcf32f..d958b45bc3 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -2441,36 +2441,52 @@ def mark_event_rejected_txn( self.invalidate_get_event_cache_after_txn(txn, event_id) async def get_events_sent_by_user_in_room( - self, user_id: str, room_id: str, limit: Optional[int], filter: str = "none" + self, user_id: str, room_id: str, limit: int, filter: Optional[List[str]] = None ) -> Optional[List[str]]: """ - Get a list of event ids and event info of events sent by the user in the specified room + Get a list of event ids of events sent by the user in the specified room Args: user_id: user ID to search against room_id: room ID of the room to search for events in - filter: type of event to filter out + filter: type of events to filter for limit: maximum number of event ids to return """ - def _get_events_by_user_txn( + def _get_events_by_user_in_room_txn( txn: LoggingTransaction, user_id: str, room_id: str, - filter: Optional[str], + filter: Optional[List[str]], batch_size: int, offset: int, ) -> Tuple[Optional[List[str]], int]: - - sql = """ - SELECT event_id FROM events - WHERE sender = ? AND room_id = ? - AND type != ? - ORDER BY received_ts DESC - LIMIT ? - OFFSET ? - """ - txn.execute(sql, (user_id, room_id, filter, batch_size, offset)) + if filter: + filter_sql = " AND type in (" + for i, _ in enumerate(filter): + if i < len(filter) - 1: + filter_sql += "?, " + else: + filter_sql += "?)" + + sql = f""" + SELECT event_id FROM events + WHERE sender = ? AND room_id = ? + {filter_sql} + ORDER BY received_ts DESC + LIMIT ? + OFFSET ? + """ + txn.execute(sql, (user_id, room_id, *filter, batch_size, offset)) + else: + sql = """ + SELECT event_id FROM events + WHERE sender = ? AND room_id = ? + ORDER BY received_ts DESC + LIMIT ? + OFFSET ? + """ + txn.execute(sql, (user_id, room_id, batch_size, offset)) res = txn.fetchall() if res: events = [row[0] for row in res] @@ -2479,9 +2495,6 @@ def _get_events_by_user_txn( return events, offset + batch_size - if not limit: - limit = 1000 - offset = 0 batch_size = 100 if batch_size < limit: @@ -2491,7 +2504,7 @@ def _get_events_by_user_txn( while offset < limit: res, offset = await self.db_pool.runInteraction( "get_events_by_user", - _get_events_by_user_txn, + _get_events_by_user_in_room_txn, user_id, room_id, filter, @@ -2501,5 +2514,5 @@ def _get_events_by_user_txn( if res: selected_ids = selected_ids + res else: - return selected_ids + break return selected_ids From 24afcd813444cb39b3560988a8c2fff4c242c56c Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 11 Sep 2024 14:00:36 -0700 Subject: [PATCH 10/14] requested changes --- docs/admin_api/user_admin_api.md | 2 +- synapse/handlers/admin.py | 17 ++------ synapse/rest/admin/users.py | 4 +- .../storage/databases/main/events_worker.py | 41 ++++++++----------- tests/rest/admin/test_user.py | 31 +++++++------- 5 files changed, 41 insertions(+), 54 deletions(-) diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index f964bdc04a..d4c4ef0f96 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -1440,4 +1440,4 @@ The following fields are returned in the JSON response body: - failed_redactions: a dict where the keys are event ids the process was unable to redact, if any, and the values are the corresponding error that caused the redaction to fail -_Added in Synapse 1.114.0._ \ No newline at end of file +_Added in Synapse 1.115.0._ \ No newline at end of file diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index d5e946ade9..2e0adcdcb1 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -431,28 +431,19 @@ async def _redact_all_events( user_id, room, limit, - [ - "m.room.member", - "m.text", - "m.emote", - "m.image", - "m.file", - "m.audio", - "m.video", - ], + ["m.room.member", "m.room.message"], ) if not event_ids: # there's nothing to redact return TaskStatus.COMPLETE, result, None - events = await self._store.get_events_as_list(set(event_ids)) + events = await self._store.get_events_as_list(event_ids) for event in events: # we care about join events but not other membership events if event.type == "m.room.member": - dict = event.get_dict() - content = dict.get("content") + content = event.content if content: - if content.get("membership") == "join": + if content.get("membership") == "Membership.JOIN": pass else: continue diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index c5416f97ac..0bc6c1259a 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -1435,7 +1435,9 @@ async def on_POST( body = parse_json_object_from_request(request, allow_empty_body=True) rooms = body.get("rooms") if rooms is None: - raise SynapseError(400, "Must provide a value for rooms.") + raise SynapseError( + HTTPStatus.BAD_REQUEST, "Must provide a value for rooms." + ) reason = body.get("reason") limit = body.get("limit") diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index d958b45bc3..8ba69f1704 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -2462,31 +2462,24 @@ def _get_events_by_user_in_room_txn( offset: int, ) -> Tuple[Optional[List[str]], int]: if filter: - filter_sql = " AND type in (" - for i, _ in enumerate(filter): - if i < len(filter) - 1: - filter_sql += "?, " - else: - filter_sql += "?)" - - sql = f""" - SELECT event_id FROM events - WHERE sender = ? AND room_id = ? - {filter_sql} - ORDER BY received_ts DESC - LIMIT ? - OFFSET ? - """ - txn.execute(sql, (user_id, room_id, *filter, batch_size, offset)) + base_clause, args = make_in_list_sql_clause( + txn.database_engine, "type", filter + ) + clause = f"AND {base_clause}" + parameters = (user_id, room_id, *args, batch_size, offset) else: - sql = """ - SELECT event_id FROM events - WHERE sender = ? AND room_id = ? - ORDER BY received_ts DESC - LIMIT ? - OFFSET ? - """ - txn.execute(sql, (user_id, room_id, batch_size, offset)) + clause = "" + parameters = (user_id, room_id, batch_size, offset) + + sql = f""" + SELECT event_id FROM events + WHERE sender = ? AND room_id = ? + {clause} + ORDER BY received_ts DESC + LIMIT ? + OFFSET ? + """ + txn.execute(sql, parameters) res = txn.fetchall() if res: events = [row[0] for row in res] diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index 49a8a0b4d1..addfdd7c56 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -5129,12 +5129,11 @@ def test_redact_messages_all_rooms(self) -> None: # join rooms, send some messages originals = [] for rm in [rm1, rm2, rm3]: - join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) - originals.append(join["event_id"]) + self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) for i in range(15): event = {"body": f"hello{i}", "msgtype": "m.text"} res = self.helper.send_event( - rm, "m.text", event, tok=self.bad_user_tok, expect_code=200 + rm, "m.room.message", event, tok=self.bad_user_tok, expect_code=200 ) originals.append(res["event_id"]) @@ -5180,11 +5179,12 @@ def test_redact_messages_specific_rooms(self) -> None: originals = [] for rm in [rm1, rm2, rm3]: - join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) - originals.append(join["event_id"]) + self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) for i in range(15): event = {"body": f"hello{i}", "msgtype": "m.text"} - res = self.helper.send_event(rm, "m.text", event, tok=self.bad_user_tok) + res = self.helper.send_event( + rm, "m.room.message", event, tok=self.bad_user_tok + ) originals.append(res["event_id"]) # redact messages in rooms 1 and 3 @@ -5214,8 +5214,8 @@ def test_redact_messages_specific_rooms(self) -> None: and event["redacts"] == event_id ): matches.append((event_id, event)) - # we redacted 16 messages - self.assertEqual(len(matches), 16) + # we redacted 15 messages + self.assertEqual(len(matches), 15) channel = self.make_request( "GET", f"rooms/{rm2}/messages?limit=50", access_token=self.admin_tok @@ -5238,12 +5238,12 @@ def test_redact_status(self) -> None: originals = [] for rm in [rm1, rm2, rm3]: - join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) - if rm == rm2: - originals.append(join["event_id"]) + self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) for i in range(5): event = {"body": f"hello{i}", "msgtype": "m.text"} - res = self.helper.send_event(rm, "m.text", event, tok=self.bad_user_tok) + res = self.helper.send_event( + rm, "m.room.message", event, tok=self.bad_user_tok + ) if rm == rm2: originals.append(res["event_id"]) @@ -5307,11 +5307,12 @@ def test_admin_redact_works_if_user_kicked_or_banned(self) -> None: originals = [] for rm in [rm1, rm2, rm3]: - join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) - originals.append(join["event_id"]) + self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) for i in range(5): event = {"body": f"hello{i}", "msgtype": "m.text"} - res = self.helper.send_event(rm, "m.text", event, tok=self.bad_user_tok) + res = self.helper.send_event( + rm, "m.room.message", event, tok=self.bad_user_tok + ) originals.append(res["event_id"]) # kick user from rooms 1 and 3 From f8b055ea6966280ad0b39031905620db8cbd5d22 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 11 Sep 2024 14:44:56 -0700 Subject: [PATCH 11/14] lint --- synapse/handlers/admin.py | 15 ++++++++------- synapse/storage/databases/main/events_worker.py | 1 - 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index 52cc969ff8..704d3f8bce 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -478,13 +478,14 @@ async def _redact_all_events( # set the prev event to the offending message to allow for redactions # to be processed in the case where the user has been kicked/banned before # redactions are requested - redaction, _ = ( - await self.event_creation_handler.create_and_send_nonmember_event( - requester, - event_dict, - prev_event_ids=[event.event_id], - ratelimit=False, - ) + ( + redaction, + _, + ) = await self.event_creation_handler.create_and_send_nonmember_event( + requester, + event_dict, + prev_event_ids=[event.event_id], + ratelimit=False, ) except Exception as ex: logger.info( diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index 3510761adf..f27a9358e8 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -2547,4 +2547,3 @@ async def have_finished_sliding_sync_background_jobs(self) -> bool: _BackgroundUpdates.SLIDING_SYNC_MEMBERSHIP_SNAPSHOTS_BG_UPDATE, ) ) - From c92ca2d679b392cb527cbb95ca8f821527561846 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Thu, 12 Sep 2024 12:37:10 -0700 Subject: [PATCH 12/14] requested changes --- docs/admin_api/user_admin_api.md | 2 +- synapse/handlers/admin.py | 2 +- tests/rest/admin/test_user.py | 20 +++++++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index d4c4ef0f96..148bd1ef52 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -1440,4 +1440,4 @@ The following fields are returned in the JSON response body: - failed_redactions: a dict where the keys are event ids the process was unable to redact, if any, and the values are the corresponding error that caused the redaction to fail -_Added in Synapse 1.115.0._ \ No newline at end of file +_Added in Synapse 1.116.0._ \ No newline at end of file diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index 704d3f8bce..a28c92d21a 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -451,7 +451,7 @@ async def _redact_all_events( if event.type == "m.room.member": content = event.content if content: - if content.get("membership") == "Membership.JOIN": + if content.get("membership") == "join": pass else: continue diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index addfdd7c56..c5ef63334e 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -5117,7 +5117,8 @@ def test_redact_messages_all_rooms(self) -> None: """ Test that request to redact events in all rooms user is member of is successful """ - # create rooms, some with updated redaction rules + # create rooms - room versions 11+ store the `redacts` key in content while + # earlier ones don't so we use a mix of room versions rm1 = self.helper.create_room_as( self.admin, tok=self.admin_tok, room_version="7" ) @@ -5129,7 +5130,8 @@ def test_redact_messages_all_rooms(self) -> None: # join rooms, send some messages originals = [] for rm in [rm1, rm2, rm3]: - self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + originals.append(join["event_id"]) for i in range(15): event = {"body": f"hello{i}", "msgtype": "m.text"} res = self.helper.send_event( @@ -5179,7 +5181,8 @@ def test_redact_messages_specific_rooms(self) -> None: originals = [] for rm in [rm1, rm2, rm3]: - self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + originals.append(join["event_id"]) for i in range(15): event = {"body": f"hello{i}", "msgtype": "m.text"} res = self.helper.send_event( @@ -5214,8 +5217,8 @@ def test_redact_messages_specific_rooms(self) -> None: and event["redacts"] == event_id ): matches.append((event_id, event)) - # we redacted 15 messages - self.assertEqual(len(matches), 15) + # we redacted 16 messages + self.assertEqual(len(matches), 16) channel = self.make_request( "GET", f"rooms/{rm2}/messages?limit=50", access_token=self.admin_tok @@ -5238,7 +5241,9 @@ def test_redact_status(self) -> None: originals = [] for rm in [rm1, rm2, rm3]: - self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + if rm == rm2: + originals.append(join["event_id"]) for i in range(5): event = {"body": f"hello{i}", "msgtype": "m.text"} res = self.helper.send_event( @@ -5307,7 +5312,8 @@ def test_admin_redact_works_if_user_kicked_or_banned(self) -> None: originals = [] for rm in [rm1, rm2, rm3]: - self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) + originals.append(join["event_id"]) for i in range(5): event = {"body": f"hello{i}", "msgtype": "m.text"} res = self.helper.send_event( From 22af98dfbc5b040d20d9f7c3752bc759f77cf477 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Tue, 17 Sep 2024 12:49:33 -0700 Subject: [PATCH 13/14] requested changes --- changelog.d/17506.feature | 3 +- docs/admin_api/user_admin_api.md | 10 +-- synapse/handlers/admin.py | 9 ++- synapse/rest/admin/users.py | 13 +++ .../storage/databases/main/events_worker.py | 2 +- tests/rest/admin/test_user.py | 80 +++++++------------ 6 files changed, 55 insertions(+), 62 deletions(-) diff --git a/changelog.d/17506.feature b/changelog.d/17506.feature index ac1e76955c..dc71e43fe3 100644 --- a/changelog.d/17506.feature +++ b/changelog.d/17506.feature @@ -1 +1,2 @@ -Add an asynchronous Admin API endpoint to redact all a user's events, and an endpoint to check on the status of that redaction task. \ No newline at end of file +Add an asynchronous Admin API endpoint [to redact all a user's events](https://element-hq.github.io/synapse/v1.116/admin_api/user_admin_api.html#redact-all-the-events-of-a-user), +and [an endpoint to check on the status of that redaction task](https://element-hq.github.io/synapse/v1.116/admin_api/user_admin_api.html#check-the-status-of-a-redaction-process). \ No newline at end of file diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index 148bd1ef52..ff74a87a38 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -1400,8 +1400,8 @@ _Added in Synapse 1.114.0._ The following JSON body parameters are optional: -- `reason` - Reason the redaction is being requested, ie "spam", "abuse", etc -- `limit` - a limit on the number of events to redact (events are redacted newest to oldest) in each room, defaults to 1000 if not provided +- `reason` - Reason the redaction is being requested, ie "spam", "abuse", etc. This will be included in the each redaction event, and be visible to users. +- `limit` - a limit on the number of the user's events to search for ones that can be redacted (events are redacted newest to oldest) in each room, defaults to 1000 if not provided ## Check the status of a redaction process @@ -1429,15 +1429,15 @@ A response body like the following is returned: The following parameters should be set in the URL: -* `redact_id` - The ID for this redaction, provided when the redaction was requested. +* `redact_id` - string - The ID for this redaction process, provided when the redaction was requested. **Response** The following fields are returned in the JSON response body: -- status: one of scheduled/active/completed/failed, indicating the status of the redaction job -- failed_redactions: a dict where the keys are event ids the process was unable to redact, if any, and the values are +- `status` - string - one of scheduled/active/completed/failed, indicating the status of the redaction job +- `failed_redactions` - dictionary - the keys of the dict are event ids the process was unable to redact, if any, and the values are the corresponding error that caused the redaction to fail _Added in Synapse 1.116.0._ \ No newline at end of file diff --git a/synapse/handlers/admin.py b/synapse/handlers/admin.py index a28c92d21a..58d89080ff 100644 --- a/synapse/handlers/admin.py +++ b/synapse/handlers/admin.py @@ -382,6 +382,9 @@ async def start_redact_events( 400, "Redact already in progress for user %s" % (user_id,) ) + if not limit: + limit = 1000 + redact_id = await self._task_scheduler.schedule_task( REDACT_ALL_EVENTS_ACTION_NAME, resource_id=user_id, @@ -426,9 +429,7 @@ async def _redact_all_events( reason = task.params.get("reason") limit = task.params.get("limit") - - if not limit: - limit = 1000 + assert limit is not None result: Mapping[str, Any] = ( task.result if task.result else {"failed_redactions": {}} @@ -451,7 +452,7 @@ async def _redact_all_events( if event.type == "m.room.member": content = event.content if content: - if content.get("membership") == "join": + if content.get("membership") == Membership.JOIN: pass else: continue diff --git a/synapse/rest/admin/users.py b/synapse/rest/admin/users.py index 9dcf116439..81dfb57a95 100644 --- a/synapse/rest/admin/users.py +++ b/synapse/rest/admin/users.py @@ -1435,7 +1435,20 @@ async def on_POST( ) reason = body.get("reason") + if reason: + if not isinstance(reason, str): + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "If a reason is provided it must be a string.", + ) + limit = body.get("limit") + if limit: + if not isinstance(limit, int) or limit <= 0: + raise SynapseError( + HTTPStatus.BAD_REQUEST, + "If limit is provided it must be a non-negative integer greater than 0.", + ) if not rooms: rooms = await self._store.get_rooms_for_user(user_id) diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index f27a9358e8..c029228422 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -2517,7 +2517,7 @@ def _get_events_by_user_in_room_txn( offset = 0 batch_size = 100 - if batch_size < limit: + if batch_size > limit: batch_size = limit selected_ids: List[str] = [] diff --git a/tests/rest/admin/test_user.py b/tests/rest/admin/test_user.py index c5ef63334e..ef918efe49 100644 --- a/tests/rest/admin/test_user.py +++ b/tests/rest/admin/test_user.py @@ -5113,23 +5113,24 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: self.spam_checker = hs.get_module_api_callbacks().spam_checker - def test_redact_messages_all_rooms(self) -> None: - """ - Test that request to redact events in all rooms user is member of is successful - """ # create rooms - room versions 11+ store the `redacts` key in content while # earlier ones don't so we use a mix of room versions - rm1 = self.helper.create_room_as( + self.rm1 = self.helper.create_room_as( self.admin, tok=self.admin_tok, room_version="7" ) - rm2 = self.helper.create_room_as( + self.rm2 = self.helper.create_room_as(self.admin, tok=self.admin_tok) + self.rm3 = self.helper.create_room_as( self.admin, tok=self.admin_tok, room_version="11" ) - rm3 = self.helper.create_room_as(self.admin, tok=self.admin_tok) + + def test_redact_messages_all_rooms(self) -> None: + """ + Test that request to redact events in all rooms user is member of is successful + """ # join rooms, send some messages originals = [] - for rm in [rm1, rm2, rm3]: + for rm in [self.rm1, self.rm2, self.rm3]: join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) originals.append(join["event_id"]) for i in range(15): @@ -5149,7 +5150,7 @@ def test_redact_messages_all_rooms(self) -> None: self.assertEqual(channel.code, 200) matched = [] - for rm in [rm1, rm2, rm3]: + for rm in [self.rm1, self.rm2, self.rm3]: filter = json.dumps({"types": [EventTypes.Redaction]}) channel = self.make_request( "GET", @@ -5171,16 +5172,9 @@ def test_redact_messages_specific_rooms(self) -> None: """ Test that request to redact events in specified rooms user is member of is successful """ - rm1 = self.helper.create_room_as( - self.admin, tok=self.admin_tok, room_version="7" - ) - rm2 = self.helper.create_room_as(self.admin, tok=self.admin_tok) - rm3 = self.helper.create_room_as( - self.admin, tok=self.admin_tok, room_version="11" - ) originals = [] - for rm in [rm1, rm2, rm3]: + for rm in [self.rm1, self.rm2, self.rm3]: join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) originals.append(join["event_id"]) for i in range(15): @@ -5194,13 +5188,13 @@ def test_redact_messages_specific_rooms(self) -> None: channel = self.make_request( "POST", f"/_synapse/admin/v1/user/{self.bad_user}/redact", - content={"rooms": [rm1, rm3]}, + content={"rooms": [self.rm1, self.rm3]}, access_token=self.admin_tok, ) self.assertEqual(channel.code, 200) # messages in requested rooms are redacted - for rm in [rm1, rm3]: + for rm in [self.rm1, self.rm3]: filter = json.dumps({"types": [EventTypes.Redaction]}) channel = self.make_request( "GET", @@ -5221,7 +5215,7 @@ def test_redact_messages_specific_rooms(self) -> None: self.assertEqual(len(matches), 16) channel = self.make_request( - "GET", f"rooms/{rm2}/messages?limit=50", access_token=self.admin_tok + "GET", f"rooms/{self.rm2}/messages?limit=50", access_token=self.admin_tok ) self.assertEqual(channel.code, 200) @@ -5231,32 +5225,24 @@ def test_redact_messages_specific_rooms(self) -> None: self.fail("found redaction in room 2") def test_redact_status(self) -> None: - rm1 = self.helper.create_room_as( - self.admin, tok=self.admin_tok, room_version="7" - ) - rm2 = self.helper.create_room_as(self.admin, tok=self.admin_tok) - rm3 = self.helper.create_room_as( - self.admin, tok=self.admin_tok, room_version="11" - ) - - originals = [] - for rm in [rm1, rm2, rm3]: + rm2_originals = [] + for rm in [self.rm1, self.rm2, self.rm3]: join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) - if rm == rm2: - originals.append(join["event_id"]) + if rm == self.rm2: + rm2_originals.append(join["event_id"]) for i in range(5): event = {"body": f"hello{i}", "msgtype": "m.text"} res = self.helper.send_event( rm, "m.room.message", event, tok=self.bad_user_tok ) - if rm == rm2: - originals.append(res["event_id"]) + if rm == self.rm2: + rm2_originals.append(res["event_id"]) # redact messages in rooms 1 and 3 channel = self.make_request( "POST", f"/_synapse/admin/v1/user/{self.bad_user}/redact", - content={"rooms": [rm1, rm3]}, + content={"rooms": [self.rm1, self.rm3]}, access_token=self.admin_tok, ) self.assertEqual(channel.code, 200) @@ -5280,7 +5266,7 @@ async def check_event_for_spam(event: str) -> str: channel3 = self.make_request( "POST", f"/_synapse/admin/v1/user/{self.bad_user}/redact", - content={"rooms": [rm2]}, + content={"rooms": [self.rm2]}, access_token=self.admin_tok, ) self.assertEqual(channel.code, 200) @@ -5296,22 +5282,14 @@ async def check_event_for_spam(event: str) -> str: failed_redactions = channel4.json_body.get("failed_redactions") assert failed_redactions is not None matched = [] - for original in originals: + for original in rm2_originals: if failed_redactions.get(original) is not None: matched.append(original) - self.assertEqual(len(matched), len(originals)) + self.assertEqual(len(matched), len(rm2_originals)) def test_admin_redact_works_if_user_kicked_or_banned(self) -> None: - rm1 = self.helper.create_room_as( - self.admin, tok=self.admin_tok, room_version="7" - ) - rm2 = self.helper.create_room_as(self.admin, tok=self.admin_tok) - rm3 = self.helper.create_room_as( - self.admin, tok=self.admin_tok, room_version="11" - ) - originals = [] - for rm in [rm1, rm2, rm3]: + for rm in [self.rm1, self.rm2, self.rm3]: join = self.helper.join(rm, self.bad_user, tok=self.bad_user_tok) originals.append(join["event_id"]) for i in range(5): @@ -5322,7 +5300,7 @@ def test_admin_redact_works_if_user_kicked_or_banned(self) -> None: originals.append(res["event_id"]) # kick user from rooms 1 and 3 - for r in [rm1, rm2]: + for r in [self.rm1, self.rm2]: channel = self.make_request( "POST", f"/_matrix/client/r0/rooms/{r}/kick", @@ -5335,7 +5313,7 @@ def test_admin_redact_works_if_user_kicked_or_banned(self) -> None: channel1 = self.make_request( "POST", f"/_synapse/admin/v1/user/{self.bad_user}/redact", - content={"rooms": [rm1, rm3]}, + content={"rooms": [self.rm1, self.rm3]}, access_token=self.admin_tok, ) self.assertEqual(channel1.code, 200) @@ -5355,7 +5333,7 @@ def test_admin_redact_works_if_user_kicked_or_banned(self) -> None: # ban user channel3 = self.make_request( "POST", - f"/_matrix/client/r0/rooms/{rm2}/ban", + f"/_matrix/client/r0/rooms/{self.rm2}/ban", content={"reason": "being a bummer", "user_id": self.bad_user}, access_token=self.admin_tok, ) @@ -5365,7 +5343,7 @@ def test_admin_redact_works_if_user_kicked_or_banned(self) -> None: channel4 = self.make_request( "POST", f"/_synapse/admin/v1/user/{self.bad_user}/redact", - content={"rooms": [rm2]}, + content={"rooms": [self.rm2]}, access_token=self.admin_tok, ) self.assertEqual(channel4.code, 200) From f1ac8f5e01c1f918b4ac1a9a15987d86529b3d08 Mon Sep 17 00:00:00 2001 From: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:45:27 +0100 Subject: [PATCH 14/14] Small wording changes --- docs/admin_api/user_admin_api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md index ff74a87a38..cb38e26005 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md @@ -1396,11 +1396,11 @@ The following JSON body parameter must be provided: - `rooms` - A list of rooms to redact the user's events in. If an empty list is provided all events in all rooms the user is a member of will be redacted -_Added in Synapse 1.114.0._ +_Added in Synapse 1.116.0._ The following JSON body parameters are optional: -- `reason` - Reason the redaction is being requested, ie "spam", "abuse", etc. This will be included in the each redaction event, and be visible to users. +- `reason` - Reason the redaction is being requested, ie "spam", "abuse", etc. This will be included in each redaction event, and be visible to users. - `limit` - a limit on the number of the user's events to search for ones that can be redacted (events are redacted newest to oldest) in each room, defaults to 1000 if not provided