Skip to main content

tuwunel_api_client/
space.rs

1use std::{
2	collections::{BTreeSet, VecDeque},
3	convert::identity,
4	str::FromStr,
5};
6
7use axum::extract::State;
8use futures::{
9	StreamExt,
10	future::ready,
11	stream::{once, unfold},
12};
13use ruma::{
14	OwnedRoomId, OwnedServerName, RoomId, UInt, UserId, api::client::space::get_hierarchy,
15};
16use tuwunel_core::{
17	Err, Result, debug_error, error,
18	smallvec::SmallVec,
19	trace,
20	utils::{
21		BoolExt,
22		stream::{IterStream, ReadyExt, WidebandExt},
23	},
24};
25use tuwunel_service::{
26	Services,
27	rooms::{
28		short::ShortRoomId,
29		spaces::{
30			Accessibility, Identifier, PaginationToken, get_parent_children_via,
31			is_summary_serializable, summary_to_chunk,
32		},
33	},
34};
35
36use crate::Ruma;
37
38/// Inputs for [`get_client_hierarchy`], shared by the client-server
39/// `/hierarchy` route and the admin room-hierarchy endpoint.
40pub struct HierarchyArgs<'a> {
41	/// User requesting the room hierarchy.
42	pub sender_user: &'a UserId,
43
44	/// Root room of the hierarchy traversal.
45	pub room_id: &'a RoomId,
46
47	/// Maximum number of rooms returned on the page.
48	pub limit: usize,
49
50	/// Maximum depth to traverse below the root room.
51	pub max_depth: usize,
52
53	/// Whether traversal follows only suggested child links.
54	pub suggested_only: bool,
55
56	/// Short room IDs omitted from the returned page.
57	pub skip_room_ids: &'a [ShortRoomId],
58
59	/// Traverse from the server's perspective without federating and skip the
60	/// per-user visibility gate, for admin callers.
61	pub bypass_visibility: bool,
62}
63
64/// # `GET /_matrix/client/v1/rooms/{room_id}/hierarchy`
65///
66/// Paginates over the space tree in a depth-first manner to locate child rooms
67/// of a given space.
68pub(crate) async fn get_hierarchy_route(
69	State(services): State<crate::State>,
70	body: Ruma<get_hierarchy::v1::Request>,
71) -> Result<get_hierarchy::v1::Response> {
72	let limit = body
73		.limit
74		.unwrap_or_else(|| UInt::from(10_u32))
75		.min(UInt::from(100_u32));
76
77	let max_depth = body
78		.max_depth
79		.unwrap_or_else(|| UInt::from(3_u32))
80		.min(UInt::from(10_u32));
81
82	let key = body
83		.from
84		.as_ref()
85		.and_then(|s| PaginationToken::from_str(s).ok());
86
87	// Should prevent unexpected behaviour in (bad) clients
88	if let Some(ref token) = key
89		&& (token.suggested_only != body.suggested_only || token.max_depth != max_depth)
90	{
91		return Err!(Request(InvalidParam(
92			"suggested_only and max_depth cannot change on paginated requests"
93		)));
94	}
95
96	get_client_hierarchy(&services, HierarchyArgs {
97		sender_user: body.sender_user(),
98		room_id: &body.room_id,
99		limit: limit.try_into().unwrap_or(10),
100		max_depth: max_depth.try_into().unwrap_or(usize::MAX),
101		suggested_only: body.suggested_only,
102		skip_room_ids: key
103			.as_ref()
104			.map(|t| t.short_room_ids.as_slice())
105			.unwrap_or_default(),
106		bypass_visibility: false,
107	})
108	.await
109}
110
111/// Traverses a room hierarchy with the supplied visibility policy.
112///
113/// Administrator visibility bypass uses the server's perspective without federating.
114pub async fn get_client_hierarchy(
115	services: &Services,
116	args: HierarchyArgs<'_>,
117) -> Result<get_hierarchy::v1::Response> {
118	type Via = SmallVec<[OwnedServerName; 1]>;
119	type QueueItem = (OwnedRoomId, Via, usize);
120
121	let HierarchyArgs {
122		sender_user,
123		room_id,
124		limit,
125		max_depth,
126		suggested_only,
127		skip_room_ids,
128		bypass_visibility,
129	} = args;
130
131	// Admin callers traverse from the server's perspective and never federate,
132	// so remote children surface as holes and the visibility gate is skipped.
133	let sender = match bypass_visibility {
134		| true => Identifier::ServerName(services.globals.server_name()),
135		| false => Identifier::UserId(sender_user),
136	};
137
138	// A v12 room id carries no server name, so fall back to any via recorded from
139	// an invite rather than declining to federate at all.
140	let root_via: Via = match room_id.server_name() {
141		| _ if bypass_visibility => Via::new(),
142		| Some(server) => [server.to_owned()].into(),
143		| None =>
144			services
145				.state_cache
146				.servers_invite_via(room_id)
147				.map(ToOwned::to_owned)
148				.collect()
149				.await,
150	};
151
152	// Fetch the root room up front so we can return precise errors for
153	// inaccessibility rather than silently dropping it.
154	let root_summary = match services
155		.spaces
156		.get_summary_and_children(room_id, &sender, &root_via)
157		.await
158	{
159		| Err(e) => {
160			debug_error!(?room_id, "space hierarchy root: {e}");
161			return Err(e);
162		},
163		| Ok(Accessibility::Inaccessible) => {
164			return Err!(Request(Forbidden(debug_error!("The requested room is inaccessible."))));
165		},
166		| Ok(Accessibility::Accessible(s)) => s,
167	};
168
169	// Seed the depth-first traversal: root is already visited; its children
170	// form the initial queue at depth 1.
171	let initial_queue: VecDeque<QueueItem> = max_depth
172		.gt(&0)
173		.then(|| {
174			get_parent_children_via(&root_summary, suggested_only)
175				.filter(|(room_id_, _)| room_id.ne(room_id_))
176				.map(|(room_id, via)| {
177					let via = match bypass_visibility {
178						| true => Via::new(),
179						| false => via.collect(),
180					};
181
182					(room_id, via, 1_usize)
183				})
184		})
185		.into_iter()
186		.flatten()
187		.collect();
188
189	// Short IDs of rooms already returned on previous pages; skip them in output
190	// but still traverse their children to preserve depth-first order.
191	let skip_ids: BTreeSet<ShortRoomId> = skip_room_ids.iter().copied().collect();
192
193	let initial_state = (initial_queue, BTreeSet::from([room_id.to_owned()]));
194
195	// Stream all accessible rooms in depth-first order: root first, then
196	// descendants discovered by unfolding the queue.
197	let rooms = once(ready(Some(root_summary)))
198		.chain(unfold(initial_state, async |(mut queue, mut visited)| {
199			let (current_room, via, depth) = queue.pop_front()?;
200
201			// Cycle guard: a room reachable via multiple parents is only
202			// visited (and queued for children) once.
203			if visited.contains(&current_room) {
204				return Some((None, (queue, visited)));
205			}
206
207			match services
208				.spaces
209				.get_summary_and_children(&current_room, &sender, &via)
210				.await
211			{
212				| Err(e) if !e.is_not_found() => {
213					error!(?current_room, ?depth, "space child error: {e}");
214
215					Some((None, (queue, visited)))
216				},
217				| Err(_) | Ok(Accessibility::Inaccessible) => {
218					trace!(?current_room, ?depth, "child inaccessible or not found");
219
220					Some((None, (queue, visited)))
221				},
222				| Ok(Accessibility::Accessible(s)) => {
223					visited.insert(current_room);
224
225					// Enqueue children only while within the depth budget.
226					if depth < max_depth {
227						get_parent_children_via(&s, suggested_only)
228							.filter(|(child, _)| !visited.contains(child))
229							.for_each(|(child, via)| {
230								let via = match bypass_visibility {
231									| true => Via::new(),
232									| false => via.collect(),
233								};
234
235								queue.push_back((child, via, depth.saturating_add(1)));
236							});
237					}
238
239					Some((Some(s), (queue, visited)))
240				},
241			}
242		}))
243		.ready_filter_map(identity)
244		.wide_filter_map(async |summary| {
245			skip_ids
246				.is_empty()
247				.is_false()
248				.then_async(async || {
249					services
250						.short
251						.get_shortroomid(&summary.summary.room_id)
252						.await
253						.ok()
254						.filter(|shortid| skip_ids.contains(shortid))
255				})
256				.await
257				.flatten()
258				.is_none()
259				.then_some(summary)
260				.filter(is_summary_serializable)
261				.map(summary_to_chunk)
262		})
263		.take(limit)
264		.collect::<Vec<_>>()
265		.await;
266
267	// If we filled the page, produce a continuation token encoding every room
268	// emitted so far (previous pages + this page). The next request skips all
269	// of them and resumes from the next position in the traversal order.
270	let next_batch = (limit > 0 && rooms.len() >= limit)
271		.then_async(async || {
272			let next_skip = skip_room_ids
273				.iter()
274				.copied()
275				.stream()
276				.chain(rooms.iter().stream().then(async |chunk| {
277					// `get_or_create_shortroomid` is used (not `get_shortroomid`) because rooms
278					// in a remote hierarchy our server has never touched have no shortroomid
279					// allocated yet; `get_shortroomid` would return `Err` and the room would
280					// silently fall out of the skip set, causing the next page to re-emit the
281					// same rooms with the same token — an infinite loop.
282					services
283						.short
284						.get_or_create_shortroomid(&chunk.summary.room_id)
285						.await
286				}))
287				.collect::<Vec<_>>()
288				.await;
289
290			// Backstop against pagination loops: only return a token if the skip
291			// set strictly grew. With `get_or_create_shortroomid` above this should
292			// always hold when `rooms.len() >= limit`, but checking is cheap.
293			(next_skip.len() > skip_room_ids.len()).then_some(PaginationToken {
294				suggested_only,
295				short_room_ids: next_skip,
296				limit: limit.try_into().unwrap_or_default(),
297				max_depth: max_depth.try_into().unwrap_or_default(),
298			})
299		})
300		.await
301		.flatten()
302		.as_ref()
303		.map(ToString::to_string);
304
305	Ok(get_hierarchy::v1::Response { rooms, next_batch })
306}