From 0f9a6ba0362d8da975e8dcf970bcbfd7a17a461f Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 18 Dec 2024 12:03:56 -0800 Subject: [PATCH 1/6] add /participants endpoint --- synapse/rest/admin/__init__.py | 2 ++ synapse/rest/admin/rooms.py | 25 +++++++++++++++++ synapse/storage/databases/main/roommember.py | 28 ++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index f3c99663e84..1d527a96516 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -81,6 +81,7 @@ RoomEventContextServlet, RoomMembersRestServlet, RoomMessagesRestServlet, + RoomParticipantsRestServlet, RoomRestServlet, RoomRestV2Servlet, RoomStateRestServlet, @@ -320,6 +321,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ListDestinationsRestServlet(hs).register(http_server) RoomMessagesRestServlet(hs).register(http_server) RoomTimestampToEventRestServlet(hs).register(http_server) + RoomParticipantsRestServlet(hs).register(http_server) UserReplaceMasterCrossSigningKeyRestServlet(hs).register(http_server) UserByExternalId(hs).register(http_server) UserByThreePid(hs).register(http_server) diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py index 01f9de9ffa5..601c1f69542 100644 --- a/synapse/rest/admin/rooms.py +++ b/synapse/rest/admin/rooms.py @@ -989,3 +989,28 @@ async def on_GET( "event_id": event_id, "origin_server_ts": origin_server_ts, } + + +class RoomParticipantsRestServlet(RestServlet): + """ + This endpoint allows admins to fetch currently joined members of a room who + have also posted to the room. + + GET /_synapse/admin/v1/rooms/{room_id}/participants + """ + + PATTERNS = admin_patterns("/rooms/(?P[^/]*)/participants$") + + def __init__(self, hs: "HomeServer"): + self._auth = hs.get_auth() + self._store = hs.get_datastores().main + + async def on_GET( + self, request: SynapseRequest, room_id: str + ) -> Tuple[int, JsonDict]: + requester = await self._auth.get_user_by_req(request) + await assert_user_is_admin(self._auth, requester) + + participants = await self._store.get_participants_in_room(room_id) + + return HTTPStatus.OK, {"participants": list(participants)} diff --git a/synapse/storage/databases/main/roommember.py b/synapse/storage/databases/main/roommember.py index 50ed6a28bf0..007521bcb46 100644 --- a/synapse/storage/databases/main/roommember.py +++ b/synapse/storage/databases/main/roommember.py @@ -1606,6 +1606,34 @@ def _get_rooms_for_user_by_join_date_txn( from_ts, ) + async def get_participants_in_room(self, room_id: str) -> FrozenSet[str]: + """ + Return a list of all currently joined room members who have posted + in the given room. + + Args: + room_id: room ID of the room to search in + """ + + def _get_participants_in_room_txn( + txn: LoggingTransaction, room_id: str + ) -> frozenset: + sql = """ + SELECT DISTINCT c.state_key + FROM current_state_events AS c + INNER JOIN events AS e USING(room_id) + WHERE room_id = ? + AND c.membership = 'join' + AND e.type = 'm.room.message' + AND c.state_key = e.sender + """ + txn.execute(sql, (room_id,)) + return frozenset([r[0] for r in txn]) + + return await self.db_pool.runInteraction( + "_get_participants_in_room_txn", _get_participants_in_room_txn, room_id + ) + class RoomMemberBackgroundUpdateStore(SQLBaseStore): def __init__( From 9ec4d7ca5e93c7d7bfa88fd626b0013da2559e43 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 18 Dec 2024 12:04:02 -0800 Subject: [PATCH 2/6] docs + test --- docs/admin_api/rooms.md | 24 +++++++++++ tests/rest/admin/test_room.py | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/docs/admin_api/rooms.md b/docs/admin_api/rooms.md index 8e3a367e90a..7ebf463768e 100644 --- a/docs/admin_api/rooms.md +++ b/docs/admin_api/rooms.md @@ -371,6 +371,30 @@ A response body like the following is returned: } ``` +# Room Participants + +The room participants API allows the admin to get a list of current room members who have +also posted to the room. + +The API is: + +``` +GET /_synapse/admin/v1/rooms//participants +``` + +A response body like the following is returned: + +```json +{ + "participants": [ + "@foo:matrix.org", + "@bar:matrix.org", + "@foobar:matrix.org" + ] +} +``` + + # Room State API The Room State admin API allows server admins to get a list of all state events in a room. diff --git a/tests/rest/admin/test_room.py b/tests/rest/admin/test_room.py index 95ed7364510..e8b866cd73a 100644 --- a/tests/rest/admin/test_room.py +++ b/tests/rest/admin/test_room.py @@ -2078,6 +2078,82 @@ def test_get_joined_members_after_leave_room(self) -> None: self.assertEqual(403, channel.code, msg=channel.json_body) self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) + def test_get_room_participants(self) -> None: + """ + Test that /_synapse/admin/v1/rooms/{room_id}/participants returns a list of + currently joined users who have posted, and that the list does not contain room + members who have not posted + """ + + participant = self.register_user("participant", "pass") + participant_tok = self.login("participant", "pass") + room_id = self.helper.create_room_as(participant, tok=participant_tok) + + participant2 = self.register_user("participant2", "pass") + participant2_tok = self.login("participant2", "pass") + self.helper.join(room_id, participant2, tok=participant2_tok) + + # have some lurkers join the room + for i in range(5): + user = self.register_user(f"user{i}", "pass") + user_tok = self.login(f"user{i}", "pass") + self.helper.join(room_id, user, tok=user_tok) + + # double-check the room membership total, should be 7 + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/rooms/{room_id}/members", + access_token=self.admin_user_tok, + ) + self.assertEqual(200, channel.code) + self.assertEqual(channel.json_body["total"], 7) + + # participants send messages + self.helper.send_event( + room_id, + "m.room.message", + content={ + "msgtype": "m.text", + "body": "nefarious activity", + }, + tok=participant_tok, + ) + + self.helper.send_event( + room_id, + "m.room.message", + content={ + "msgtype": "m.text", + "body": "more nefarious activity", + }, + tok=participant2_tok, + ) + + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/rooms/{room_id}/participants", + access_token=self.admin_user_tok, + ) + self.assertEqual(200, channel.code) + participants = channel.json_body["participants"] + self.assertEqual(len(participants), 2) + self.assertIn(participant, participants) + self.assertIn(participant2, participants) + + # have participants leave room + self.helper.leave(room_id, participant, tok=participant_tok) + self.helper.leave(room_id, participant2, tok=participant2_tok) + + # there should no longer be any active participants + channel = self.make_request( + "GET", + f"/_synapse/admin/v1/rooms/{room_id}/participants", + access_token=self.admin_user_tok, + ) + self.assertEqual(200, channel.code) + participants = channel.json_body["participants"] + self.assertEqual(len(participants), 0) + class RoomMessagesTestCase(unittest.HomeserverTestCase): servlets = [ From 827cb66a94b009d329f2ec8486283a9337d37f37 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Wed, 18 Dec 2024 12:11:54 -0800 Subject: [PATCH 3/6] newsfragment --- changelog.d/18040.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/18040.feature diff --git a/changelog.d/18040.feature b/changelog.d/18040.feature new file mode 100644 index 00000000000..46f8806847a --- /dev/null +++ b/changelog.d/18040.feature @@ -0,0 +1 @@ +Add an admin endpoint to fetch the participants in a room. \ No newline at end of file From b4d588564f414fbe5e45b51500703f3a529392c0 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Fri, 20 Dec 2024 10:37:20 -0800 Subject: [PATCH 4/6] use extant /members endpoint with participant param --- synapse/rest/admin/__init__.py | 2 -- synapse/rest/admin/rooms.py | 32 ++++---------------- synapse/storage/databases/main/roommember.py | 6 ++-- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py index 1d527a96516..f3c99663e84 100644 --- a/synapse/rest/admin/__init__.py +++ b/synapse/rest/admin/__init__.py @@ -81,7 +81,6 @@ RoomEventContextServlet, RoomMembersRestServlet, RoomMessagesRestServlet, - RoomParticipantsRestServlet, RoomRestServlet, RoomRestV2Servlet, RoomStateRestServlet, @@ -321,7 +320,6 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ListDestinationsRestServlet(hs).register(http_server) RoomMessagesRestServlet(hs).register(http_server) RoomTimestampToEventRestServlet(hs).register(http_server) - RoomParticipantsRestServlet(hs).register(http_server) UserReplaceMasterCrossSigningKeyRestServlet(hs).register(http_server) UserByExternalId(hs).register(http_server) UserByThreePid(hs).register(http_server) diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py index 601c1f69542..794c8118720 100644 --- a/synapse/rest/admin/rooms.py +++ b/synapse/rest/admin/rooms.py @@ -434,7 +434,12 @@ async def on_GET( if not room: raise NotFoundError("Room not found") - members = await self.store.get_users_in_room(room_id) + participant = parse_boolean(request, "participant", False) + + if participant: + members = await self.store.get_participants_in_room(room_id) + else: + members = await self.store.get_users_in_room(room_id) ret = {"members": members, "total": len(members)} return HTTPStatus.OK, ret @@ -989,28 +994,3 @@ async def on_GET( "event_id": event_id, "origin_server_ts": origin_server_ts, } - - -class RoomParticipantsRestServlet(RestServlet): - """ - This endpoint allows admins to fetch currently joined members of a room who - have also posted to the room. - - GET /_synapse/admin/v1/rooms/{room_id}/participants - """ - - PATTERNS = admin_patterns("/rooms/(?P[^/]*)/participants$") - - def __init__(self, hs: "HomeServer"): - self._auth = hs.get_auth() - self._store = hs.get_datastores().main - - async def on_GET( - self, request: SynapseRequest, room_id: str - ) -> Tuple[int, JsonDict]: - requester = await self._auth.get_user_by_req(request) - await assert_user_is_admin(self._auth, requester) - - participants = await self._store.get_participants_in_room(room_id) - - return HTTPStatus.OK, {"participants": list(participants)} diff --git a/synapse/storage/databases/main/roommember.py b/synapse/storage/databases/main/roommember.py index 007521bcb46..d386dd8499a 100644 --- a/synapse/storage/databases/main/roommember.py +++ b/synapse/storage/databases/main/roommember.py @@ -1606,7 +1606,7 @@ def _get_rooms_for_user_by_join_date_txn( from_ts, ) - async def get_participants_in_room(self, room_id: str) -> FrozenSet[str]: + async def get_participants_in_room(self, room_id: str) -> Sequence[str]: """ Return a list of all currently joined room members who have posted in the given room. @@ -1617,7 +1617,7 @@ async def get_participants_in_room(self, room_id: str) -> FrozenSet[str]: def _get_participants_in_room_txn( txn: LoggingTransaction, room_id: str - ) -> frozenset: + ) -> List[str]: sql = """ SELECT DISTINCT c.state_key FROM current_state_events AS c @@ -1628,7 +1628,7 @@ def _get_participants_in_room_txn( AND c.state_key = e.sender """ txn.execute(sql, (room_id,)) - return frozenset([r[0] for r in txn]) + return [r[0] for r in txn] return await self.db_pool.runInteraction( "_get_participants_in_room_txn", _get_participants_in_room_txn, room_id From c13e2fb0c562bac45d9be65af06fe153529f3937 Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Fri, 20 Dec 2024 10:38:26 -0800 Subject: [PATCH 5/6] docs and tests --- docs/admin_api/rooms.md | 31 ++------ tests/rest/admin/test_room.py | 140 +++++++++++++++++----------------- 2 files changed, 77 insertions(+), 94 deletions(-) diff --git a/docs/admin_api/rooms.md b/docs/admin_api/rooms.md index 7ebf463768e..442ad3b15ec 100644 --- a/docs/admin_api/rooms.md +++ b/docs/admin_api/rooms.md @@ -358,43 +358,26 @@ The API is: GET /_synapse/admin/v1/rooms//members ``` -A response body like the following is returned: - -```json -{ - "members": [ - "@foo:matrix.org", - "@bar:matrix.org", - "@foobar:matrix.org" - ], - "total": 3 -} -``` - -# Room Participants - -The room participants API allows the admin to get a list of current room members who have -also posted to the room. +**Parameters** -The API is: +The following query parameters are available: -``` -GET /_synapse/admin/v1/rooms//participants -``` +* `participant` - Optional. If provided and set to true, only returns currently joined members who have + also posted to the room. Defaults to false. A response body like the following is returned: ```json { - "participants": [ + "members": [ "@foo:matrix.org", "@bar:matrix.org", "@foobar:matrix.org" - ] + ], + "total": 3 } ``` - # Room State API The Room State admin API allows server admins to get a list of all state events in a room. diff --git a/tests/rest/admin/test_room.py b/tests/rest/admin/test_room.py index e8b866cd73a..617c15071a3 100644 --- a/tests/rest/admin/test_room.py +++ b/tests/rest/admin/test_room.py @@ -2018,71 +2018,11 @@ def test_room_members(self) -> None: ) self.assertEqual(channel.json_body["total"], 3) - def test_room_state(self) -> None: - """Test that room state can be requested correctly""" - # Create two test rooms - room_id = self.helper.create_room_as(self.admin_user, tok=self.admin_user_tok) - - url = "/_synapse/admin/v1/rooms/%s/state" % (room_id,) - channel = self.make_request( - "GET", - url.encode("ascii"), - access_token=self.admin_user_tok, - ) - self.assertEqual(200, channel.code, msg=channel.json_body) - self.assertIn("state", channel.json_body) - # testing that the state events match is painful and not done here. We assume that - # the create_room already does the right thing, so no need to verify that we got - # the state events it created. - - def _set_canonical_alias( - self, room_id: str, test_alias: str, admin_user_tok: str - ) -> None: - # Create a new alias to this room - url = "/_matrix/client/r0/directory/room/%s" % (urllib.parse.quote(test_alias),) - channel = self.make_request( - "PUT", - url.encode("ascii"), - {"room_id": room_id}, - access_token=admin_user_tok, - ) - self.assertEqual(200, channel.code, msg=channel.json_body) - - # Set this new alias as the canonical alias for this room - self.helper.send_state( - room_id, - "m.room.aliases", - {"aliases": [test_alias]}, - tok=admin_user_tok, - state_key="test", - ) - self.helper.send_state( - room_id, - "m.room.canonical_alias", - {"alias": test_alias}, - tok=admin_user_tok, - ) - - def test_get_joined_members_after_leave_room(self) -> None: - """Test that requesting room members after leaving the room raises a 403 error.""" - - # create the room - user = self.register_user("foo", "pass") - user_tok = self.login("foo", "pass") - room_id = self.helper.create_room_as(user, tok=user_tok) - self.helper.leave(room_id, user, tok=user_tok) - - # delete the rooms and get joined roomed membership - url = f"/_matrix/client/r0/rooms/{room_id}/joined_members" - channel = self.make_request("GET", url.encode("ascii"), access_token=user_tok) - self.assertEqual(403, channel.code, msg=channel.json_body) - self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) - - def test_get_room_participants(self) -> None: + def test_room_members_participants_param(self) -> None: """ - Test that /_synapse/admin/v1/rooms/{room_id}/participants returns a list of - currently joined users who have posted, and that the list does not contain room - members who have not posted + Test that /_synapse/admin/v1/rooms/{room_id}/members with a query param value of + participant=true returns a list of currently joined users who have posted, and + that the list does not contain room members who have not posted """ participant = self.register_user("participant", "pass") @@ -2131,12 +2071,12 @@ def test_get_room_participants(self) -> None: channel = self.make_request( "GET", - f"/_synapse/admin/v1/rooms/{room_id}/participants", + f"/_synapse/admin/v1/rooms/{room_id}/members?participant=true", access_token=self.admin_user_tok, ) self.assertEqual(200, channel.code) - participants = channel.json_body["participants"] - self.assertEqual(len(participants), 2) + participants = channel.json_body["members"] + self.assertEqual(channel.json_body["total"], 2) self.assertIn(participant, participants) self.assertIn(participant2, participants) @@ -2147,12 +2087,72 @@ def test_get_room_participants(self) -> None: # there should no longer be any active participants channel = self.make_request( "GET", - f"/_synapse/admin/v1/rooms/{room_id}/participants", + f"/_synapse/admin/v1/rooms/{room_id}/members?participant=true", access_token=self.admin_user_tok, ) self.assertEqual(200, channel.code) - participants = channel.json_body["participants"] - self.assertEqual(len(participants), 0) + participants = channel.json_body["total"] + self.assertEqual(participants, 0) + + def test_room_state(self) -> None: + """Test that room state can be requested correctly""" + # Create two test rooms + room_id = self.helper.create_room_as(self.admin_user, tok=self.admin_user_tok) + + url = "/_synapse/admin/v1/rooms/%s/state" % (room_id,) + channel = self.make_request( + "GET", + url.encode("ascii"), + access_token=self.admin_user_tok, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + self.assertIn("state", channel.json_body) + # testing that the state events match is painful and not done here. We assume that + # the create_room already does the right thing, so no need to verify that we got + # the state events it created. + + def _set_canonical_alias( + self, room_id: str, test_alias: str, admin_user_tok: str + ) -> None: + # Create a new alias to this room + url = "/_matrix/client/r0/directory/room/%s" % (urllib.parse.quote(test_alias),) + channel = self.make_request( + "PUT", + url.encode("ascii"), + {"room_id": room_id}, + access_token=admin_user_tok, + ) + self.assertEqual(200, channel.code, msg=channel.json_body) + + # Set this new alias as the canonical alias for this room + self.helper.send_state( + room_id, + "m.room.aliases", + {"aliases": [test_alias]}, + tok=admin_user_tok, + state_key="test", + ) + self.helper.send_state( + room_id, + "m.room.canonical_alias", + {"alias": test_alias}, + tok=admin_user_tok, + ) + + def test_get_joined_members_after_leave_room(self) -> None: + """Test that requesting room members after leaving the room raises a 403 error.""" + + # create the room + user = self.register_user("foo", "pass") + user_tok = self.login("foo", "pass") + room_id = self.helper.create_room_as(user, tok=user_tok) + self.helper.leave(room_id, user, tok=user_tok) + + # delete the rooms and get joined roomed membership + url = f"/_matrix/client/r0/rooms/{room_id}/joined_members" + channel = self.make_request("GET", url.encode("ascii"), access_token=user_tok) + self.assertEqual(403, channel.code, msg=channel.json_body) + self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"]) class RoomMessagesTestCase(unittest.HomeserverTestCase): From 74d95f2556e03b368e9073863988ec197ab72bac Mon Sep 17 00:00:00 2001 From: "H. Shay" Date: Fri, 20 Dec 2024 10:42:09 -0800 Subject: [PATCH 6/6] update changelog --- changelog.d/18040.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/18040.feature b/changelog.d/18040.feature index 46f8806847a..76c41cd08f0 100644 --- a/changelog.d/18040.feature +++ b/changelog.d/18040.feature @@ -1 +1 @@ -Add an admin endpoint to fetch the participants in a room. \ No newline at end of file +Add a query param `participant` to `/members` admin api to filter for room participants. \ No newline at end of file