tuwunel_api/client/admin/rooms/
list_rooms.rs1use 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
17pub(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
80fn 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
108fn 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
162fn 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 assert!(!matches_search(&room, Some("abcdef")));
202 }
203}