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