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	// A v12 room id carries no server name, so fall back to any via recorded from
125	// an invite rather than declining to federate at all.
126	let root_via: Via = match room_id.server_name() {
127		| _ if bypass_visibility => Via::new(),
128		| Some(server) => [server.to_owned()].into(),
129		| None =>
130			services
131				.state_cache
132				.servers_invite_via(room_id)
133				.map(ToOwned::to_owned)
134				.collect()
135				.await,
136	};
137
138	// Fetch the root room up front so we can return precise errors for
139	// inaccessibility rather than silently dropping it.
140	let root_summary = match services
141		.spaces
142		.get_summary_and_children(room_id, &sender, &root_via)
143		.await
144	{
145		| Err(e) => {
146			debug_error!(?room_id, "space hierarchy root: {e}");
147			return Err(e);
148		},
149		| Ok(Accessibility::Inaccessible) => {
150			return Err!(Request(Forbidden(debug_error!("The requested room is inaccessible."))));
151		},
152		| Ok(Accessibility::Accessible(s)) => s,
153	};
154
155	// Seed the depth-first traversal: root is already visited; its children
156	// form the initial queue at depth 1.
157	let initial_queue: VecDeque<QueueItem> = max_depth
158		.gt(&0)
159		.then(|| {
160			get_parent_children_via(&root_summary, suggested_only)
161				.filter(|(room_id_, _)| room_id.ne(room_id_))
162				.map(|(room_id, via)| {
163					let via = match bypass_visibility {
164						| true => Via::new(),
165						| false => via.collect(),
166					};
167
168					(room_id, via, 1_usize)
169				})
170		})
171		.into_iter()
172		.flatten()
173		.collect();
174
175	// Short IDs of rooms already returned on previous pages; skip them in output
176	// but still traverse their children to preserve depth-first order.
177	let skip_ids: BTreeSet<ShortRoomId> = skip_room_ids.iter().copied().collect();
178
179	let initial_state = (initial_queue, BTreeSet::from([room_id.to_owned()]));
180
181	// Stream all accessible rooms in depth-first order: root first, then
182	// descendants discovered by unfolding the queue.
183	let rooms = once(ready(Some(root_summary)))
184		.chain(unfold(initial_state, async |(mut queue, mut visited)| {
185			let (current_room, via, depth) = queue.pop_front()?;
186
187			// Cycle guard: a room reachable via multiple parents is only
188			// visited (and queued for children) once.
189			if visited.contains(&current_room) {
190				return Some((None, (queue, visited)));
191			}
192
193			match services
194				.spaces
195				.get_summary_and_children(&current_room, &sender, &via)
196				.await
197			{
198				| Err(e) if !e.is_not_found() => {
199					error!(?current_room, ?depth, "space child error: {e}");
200
201					Some((None, (queue, visited)))
202				},
203				| Err(_) | Ok(Accessibility::Inaccessible) => {
204					trace!(?current_room, ?depth, "child inaccessible or not found");
205
206					Some((None, (queue, visited)))
207				},
208				| Ok(Accessibility::Accessible(s)) => {
209					visited.insert(current_room);
210
211					// Enqueue children only while within the depth budget.
212					if depth < max_depth {
213						get_parent_children_via(&s, suggested_only)
214							.filter(|(child, _)| !visited.contains(child))
215							.for_each(|(child, via)| {
216								let via = match bypass_visibility {
217									| true => Via::new(),
218									| false => via.collect(),
219								};
220
221								queue.push_back((child, via, depth.saturating_add(1)));
222							});
223					}
224
225					Some((Some(s), (queue, visited)))
226				},
227			}
228		}))
229		.ready_filter_map(identity)
230		.wide_filter_map(async |summary| {
231			skip_ids
232				.is_empty()
233				.is_false()
234				.then_async(async || {
235					services
236						.short
237						.get_shortroomid(&summary.summary.room_id)
238						.await
239						.ok()
240						.filter(|shortid| skip_ids.contains(shortid))
241				})
242				.await
243				.flatten()
244				.is_none()
245				.then_some(summary)
246				.filter(is_summary_serializable)
247				.map(summary_to_chunk)
248		})
249		.take(limit)
250		.collect::<Vec<_>>()
251		.await;
252
253	// If we filled the page, produce a continuation token encoding every room
254	// emitted so far (previous pages + this page). The next request skips all
255	// of them and resumes from the next position in the traversal order.
256	let next_batch = (limit > 0 && rooms.len() >= limit)
257		.then_async(async || {
258			let next_skip = skip_room_ids
259				.iter()
260				.copied()
261				.stream()
262				.chain(rooms.iter().stream().then(async |chunk| {
263					// `get_or_create_shortroomid` is used (not `get_shortroomid`) because rooms
264					// in a remote hierarchy our server has never touched have no shortroomid
265					// allocated yet; `get_shortroomid` would return `Err` and the room would
266					// silently fall out of the skip set, causing the next page to re-emit the
267					// same rooms with the same token — an infinite loop.
268					services
269						.short
270						.get_or_create_shortroomid(&chunk.summary.room_id)
271						.await
272				}))
273				.collect::<Vec<_>>()
274				.await;
275
276			// Backstop against pagination loops: only return a token if the skip
277			// set strictly grew. With `get_or_create_shortroomid` above this should
278			// always hold when `rooms.len() >= limit`, but checking is cheap.
279			(next_skip.len() > skip_room_ids.len()).then_some(PaginationToken {
280				suggested_only,
281				short_room_ids: next_skip,
282				limit: limit.try_into().unwrap_or_default(),
283				max_depth: max_depth.try_into().unwrap_or_default(),
284			})
285		})
286		.await
287		.flatten()
288		.as_ref()
289		.map(ToString::to_string);
290
291	Ok(get_hierarchy::v1::Response { rooms, next_batch })
292}