Skip to main content

tuwunel_api/client/admin/rooms/
list_rooms.rs

1use std::cmp::Ordering;
2
3use axum::extract::State;
4use futures::StreamExt;
5use ruma::uint;
6use synapse_admin_api::rooms::list_rooms::v1::{
7	Request, Response, RoomDetails, RoomSortOrder, SortDirection,
8};
9use tuwunel_core::{
10	Result,
11	utils::stream::{BroadbandExt, ReadyExt},
12};
13
14use super::{room_row, usize_to_uint};
15use crate::{Ruma, client::admin::require_admin};
16
17/// # `GET /_synapse/admin/v1/rooms`
18///
19/// Lists rooms known to the server with search, filtering, ordering, and
20/// integer-offset pagination, mirroring Synapse's List Room API.
21pub(crate) async fn admin_list_rooms_route(
22	State(services): State<crate::State>,
23	body: Ruma<Request>,
24) -> Result<Response> {
25	require_admin(&services, body.sender_user()).await?;
26
27	let search_term = body.search_term.as_deref().map(str::to_lowercase);
28	let order_by = body
29		.order_by
30		.clone()
31		.unwrap_or(RoomSortOrder::Name);
32
33	let backward = matches!(body.dir, Some(SortDirection::Backward));
34
35	let mut rooms: Vec<RoomDetails> = services
36		.metadata
37		.iter_ids()
38		.map(ToOwned::to_owned)
39		.broad_then(async |room_id| room_row(&services, &room_id).await)
40		.ready_filter(|room| {
41			matches_search(room, search_term.as_deref())
42				&& matches_public(room, body.public_rooms)
43				&& matches_empty(room, body.empty_rooms)
44		})
45		.collect()
46		.await;
47
48	sort_rooms(&mut rooms, &order_by);
49
50	if backward {
51		rooms.reverse();
52	}
53
54	let total_rooms = usize_to_uint(rooms.len());
55	let from = body.from.unwrap_or_else(|| uint!(0));
56	let limit = body.limit.unwrap_or_else(|| uint!(100));
57
58	let offset = usize::try_from(from).unwrap_or(usize::MAX);
59	let window = usize::try_from(limit).unwrap_or(usize::MAX);
60	let next_offset = offset.saturating_add(window);
61
62	let next_batch = (next_offset < rooms.len()).then(|| usize_to_uint(next_offset));
63	let prev_batch = (offset > 0).then(|| usize_to_uint(offset.saturating_sub(window)));
64
65	let page: Vec<RoomDetails> = rooms
66		.into_iter()
67		.skip(offset)
68		.take(window)
69		.collect();
70
71	Ok(Response {
72		rooms: page,
73		offset: usize_to_uint(offset),
74		total_rooms,
75		next_batch,
76		prev_batch,
77	})
78}
79
80/// Matches a room against the search term: the name and canonical alias by
81/// case-insensitive substring, but the room id by exact equality.
82fn matches_search(room: &RoomDetails, search_term: Option<&str>) -> bool {
83	let Some(term) = search_term else {
84		return true;
85	};
86
87	let name_hit = room
88		.name
89		.as_deref()
90		.is_some_and(|name| name.to_lowercase().contains(term));
91
92	let alias_hit = room
93		.canonical_alias
94		.as_ref()
95		.is_some_and(|alias| alias.as_str().to_lowercase().contains(term));
96
97	name_hit || alias_hit || room.room_id.as_str() == term
98}
99
100fn matches_public(room: &RoomDetails, public_rooms: Option<bool>) -> bool {
101	public_rooms.is_none_or(|want| room.public == want)
102}
103
104fn matches_empty(room: &RoomDetails, empty_rooms: Option<bool>) -> bool {
105	empty_rooms.is_none_or(|want| (room.joined_local_members == uint!(0)) == want)
106}
107
108/// Orders rooms ascending by the requested column, tiebreaking on room id for
109/// deterministic pagination. The deprecated `Alphabetical` and `Size` aliases
110/// resolve to their current columns.
111fn sort_rooms(rooms: &mut [RoomDetails], order_by: &RoomSortOrder) {
112	let tiebreak = |a: &RoomDetails, b: &RoomDetails, primary: Ordering| {
113		primary.then_with(|| a.room_id.cmp(&b.room_id))
114	};
115
116	match order_by {
117		| RoomSortOrder::CanonicalAlias =>
118			rooms.sort_by(|a, b| tiebreak(a, b, a.canonical_alias.cmp(&b.canonical_alias))),
119		| RoomSortOrder::JoinedMembers | RoomSortOrder::Size =>
120			rooms.sort_by(|a, b| tiebreak(a, b, a.joined_members.cmp(&b.joined_members))),
121		| RoomSortOrder::JoinedLocalMembers => rooms.sort_by(|a, b| {
122			tiebreak(
123				a,
124				b,
125				a.joined_local_members
126					.cmp(&b.joined_local_members),
127			)
128		}),
129		| RoomSortOrder::Version =>
130			rooms.sort_by(|a, b| tiebreak(a, b, a.version.cmp(&b.version))),
131		| RoomSortOrder::Creator =>
132			rooms.sort_by(|a, b| tiebreak(a, b, a.creator.cmp(&b.creator))),
133		| RoomSortOrder::Encryption =>
134			rooms.sort_by(|a, b| tiebreak(a, b, a.encryption.cmp(&b.encryption))),
135		| RoomSortOrder::Federatable =>
136			rooms.sort_by(|a, b| tiebreak(a, b, a.federatable.cmp(&b.federatable))),
137		| RoomSortOrder::Public => rooms.sort_by(|a, b| tiebreak(a, b, a.public.cmp(&b.public))),
138		| RoomSortOrder::JoinRules => rooms.sort_by(|a, b| {
139			tiebreak(a, b, enum_key(a.join_rules.as_ref()).cmp(&enum_key(b.join_rules.as_ref())))
140		}),
141		| RoomSortOrder::GuestAccess => rooms.sort_by(|a, b| {
142			tiebreak(
143				a,
144				b,
145				enum_key(a.guest_access.as_ref()).cmp(&enum_key(b.guest_access.as_ref())),
146			)
147		}),
148		| RoomSortOrder::HistoryVisibility => rooms.sort_by(|a, b| {
149			tiebreak(
150				a,
151				b,
152				enum_key(a.history_visibility.as_ref())
153					.cmp(&enum_key(b.history_visibility.as_ref())),
154			)
155		}),
156		| RoomSortOrder::StateEvents =>
157			rooms.sort_by(|a, b| tiebreak(a, b, a.state_events.cmp(&b.state_events))),
158		| _ => rooms.sort_by(|a, b| tiebreak(a, b, a.name.cmp(&b.name))),
159	}
160}
161
162/// Projects an optional string enum to a comparable key.
163fn enum_key<T: AsRef<str>>(value: Option<&T>) -> Option<&str> { value.map(AsRef::as_ref) }
164
165#[cfg(test)]
166mod tests {
167	use ruma::{room_alias_id, room_id};
168
169	use super::{RoomDetails, matches_search};
170
171	fn room() -> RoomDetails { RoomDetails::new(room_id!("!abcdef:example.org").to_owned()) }
172
173	#[test]
174	fn search_none_matches_every_room() {
175		assert!(matches_search(&room(), None));
176	}
177
178	#[test]
179	fn search_matches_name_substring_case_insensitively() {
180		let mut room = room();
181		room.name = Some("The Lounge".to_owned());
182
183		assert!(matches_search(&room, Some("lounge")));
184		assert!(!matches_search(&room, Some("kitchen")));
185	}
186
187	#[test]
188	fn search_matches_canonical_alias_substring() {
189		let mut room = room();
190		room.canonical_alias = Some(room_alias_id!("#lounge:example.org").to_owned());
191
192		assert!(matches_search(&room, Some("loung")));
193	}
194
195	#[test]
196	fn search_matches_room_id_only_by_exact_equality() {
197		let room = room();
198
199		assert!(matches_search(&room, Some("!abcdef:example.org")));
200		// A substring of the room id must not match: the id is compared exactly.
201		assert!(!matches_search(&room, Some("abcdef")));
202	}
203}