Skip to main content

tuwunel_service/rooms/spaces/
mod.rs

1mod cache;
2mod federation;
3mod local;
4mod pagination_token;
5#[cfg(test)]
6mod tests;
7
8use std::{fmt::Debug, sync::Arc};
9
10use async_trait::async_trait;
11use futures::{FutureExt, Stream, StreamExt, TryFutureExt, pin_mut};
12use ruma::{
13	OwnedEventId, OwnedRoomId, OwnedServerName, RoomId, ServerName, UserId,
14	api::{
15		client::space::SpaceHierarchyRoomsChunk,
16		federation::space::SpaceHierarchyParentSummary as ParentSummary,
17	},
18	events::{
19		StateEventType,
20		space::child::{HierarchySpaceChildEvent as ChildEvent, SpaceChildEventContent},
21	},
22	room::{JoinRuleSummary, RestrictedSummary},
23	serde::Raw,
24};
25use tuwunel_core::{
26	Err, Event, Result, implement,
27	utils::{
28		future::{BoolExt, TryExtExt},
29		stream::{BroadbandExt, IterStream, ReadyExt, TryReadyExt},
30	},
31};
32use tuwunel_database::Map;
33
34use self::cache::Cached;
35pub use self::pagination_token::PaginationToken;
36
37pub struct Service {
38	services: Arc<crate::services::OnceServices>,
39	db: Db,
40}
41
42struct Db {
43	roomid_spacehierarchy: Arc<Map>,
44}
45
46#[expect(clippy::large_enum_variant)]
47#[derive(Clone, Debug)]
48pub enum Accessibility {
49	Accessible(ParentSummary),
50	Inaccessible,
51}
52
53/// Identifier used to check if rooms are accessible. None is used if you want
54/// to return the room, no matter if accessible or not
55#[derive(Debug)]
56pub enum Identifier<'a> {
57	UserId(&'a UserId),
58	ServerName(&'a ServerName),
59}
60
61#[async_trait]
62impl crate::Service for Service {
63	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
64		Ok(Arc::new(Self {
65			services: args.services.clone(),
66			db: Db {
67				roomid_spacehierarchy: args.db["roomid_spacehierarchy"].clone(),
68			},
69		}))
70	}
71
72	async fn clear_cache(&self) { self.db.roomid_spacehierarchy.clear().await; }
73
74	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
75}
76
77/// Gets the summary of a space using either local or remote (federation)
78/// sources
79#[implement(Service)]
80#[tracing::instrument(
81	name = "summary",
82	level = "debug",
83	ret(level = "trace")
84	skip_all,
85	fields(
86		?room_id,
87		?sender,
88		via = via.len(),
89	)
90)]
91pub async fn get_summary_and_children(
92	&self,
93	room_id: &RoomId,
94	sender: &Identifier<'_>,
95	via: &[OwnedServerName],
96) -> Result<Accessibility> {
97	debug_assert!(
98		matches!(sender, Identifier::UserId(_)) || via.is_empty(),
99		"The federation handler must not produce federation requests.",
100	);
101
102	self.get_summary_and_children_local(room_id, sender)
103		.or_else(async |e| match e {
104			| _ if !e.is_not_found() => Err(e),
105
106			| _ if via.is_empty() =>
107				Err!(Request(NotFound("Space room not found locally; not querying federation"))),
108
109			| _ =>
110				self.get_summary_and_children_federation(room_id, sender, via)
111					.boxed()
112					.await,
113		})
114		.await
115}
116
117/// Simply returns the stripped m.space.child events of a room
118#[implement(Service)]
119pub fn get_space_children<'a>(
120	&'a self,
121	room_id: &'a RoomId,
122) -> impl Stream<Item = OwnedRoomId> + Send + 'a {
123	self.services
124		.state_accessor
125		.room_state_keys(room_id, &StateEventType::SpaceChild)
126		.ready_and_then(|state_key| OwnedRoomId::parse(state_key.as_str()).map_err(Into::into))
127		.ready_filter_map(Result::ok)
128}
129
130/// Simply returns the stripped m.space.child events of a room
131#[implement(Service)]
132fn get_space_child_events<'a>(
133	&'a self,
134	room_id: &'a RoomId,
135) -> impl Stream<Item = impl Event> + Send + 'a {
136	self.services
137		.state_accessor
138		.room_state_keys_with_ids(room_id, &StateEventType::SpaceChild)
139		.ready_filter_map(Result::ok)
140		.broad_filter_map(async |(state_key, event_id): (_, OwnedEventId)| {
141			self.services
142				.timeline
143				.get_pdu(&event_id)
144				.map_ok(move |pdu| (state_key, pdu))
145				.ok()
146				.await
147		})
148		.ready_filter_map(|(state_key, pdu)| {
149			let Ok(content) = pdu.get_content::<SpaceChildEventContent>() else {
150				return None;
151			};
152
153			if content.via.is_empty() {
154				return None;
155			}
156
157			if RoomId::parse(&state_key).is_err() {
158				return None;
159			}
160
161			Some(pdu)
162		})
163}
164
165/// With the given identifier, checks if a room is accessible
166#[implement(Service)]
167#[tracing::instrument(
168	level = "debug",
169	ret,
170	skip_all,
171	fields(
172		%current_room,
173		?join_rule,
174		?sender,
175	),
176)]
177async fn is_accessible_child(
178	&self,
179	current_room: &RoomId,
180	join_rule: &JoinRuleSummary,
181	sender: &Identifier<'_>,
182) -> bool {
183	if let Identifier::ServerName(server_name) = sender {
184		// Checks if ACLs allow for the server to participate
185		if self
186			.services
187			.event_handler
188			.acl_check(server_name, current_room)
189			.await
190			.is_err()
191		{
192			return false;
193		}
194	}
195
196	if let Identifier::UserId(user_id) = sender {
197		let is_joined = self
198			.services
199			.state_cache
200			.is_joined(user_id, current_room);
201
202		let is_invited = self
203			.services
204			.state_cache
205			.is_invited(user_id, current_room);
206
207		pin_mut!(is_joined, is_invited);
208		if is_joined.or(is_invited).await {
209			return true;
210		}
211	}
212
213	match join_rule {
214		| JoinRuleSummary::Public
215		| JoinRuleSummary::Knock
216		| JoinRuleSummary::KnockRestricted(_) => true,
217
218		| JoinRuleSummary::Restricted(RestrictedSummary { allowed_room_ids })
219			if allowed_room_ids.is_empty() =>
220			true,
221
222		| JoinRuleSummary::Restricted(RestrictedSummary { allowed_room_ids }) =>
223			allowed_room_ids
224				.iter()
225				.stream()
226				.any(async |room| match sender {
227					| Identifier::UserId(user) =>
228						self.services
229							.state_cache
230							.is_joined(user, room)
231							.await,
232
233					| Identifier::ServerName(server) =>
234						self.services
235							.state_cache
236							.server_in_room(server, room)
237							.await,
238				})
239				.await,
240
241		| _ => false, // Invite only, Private, or Custom join rule
242	}
243}
244
245/// Returns the children of a SpaceHierarchyParentSummary, making use of the
246/// children_state field
247pub fn get_parent_children_via(
248	parent: &ParentSummary,
249	suggested_only: bool,
250) -> impl DoubleEndedIterator<Item = (OwnedRoomId, impl Iterator<Item = OwnedServerName>)> + '_ {
251	parent
252		.children_state
253		.iter()
254		.map(Raw::deserialize)
255		.filter_map(Result::ok)
256		.filter_map(move |ChildEvent { state_key, content, .. }: _| {
257			(content.suggested || !suggested_only).then_some((state_key, content.via.into_iter()))
258		})
259}
260
261/// Here because cannot implement `From` across ruma-federation-api and
262/// ruma-client-api types
263#[inline]
264#[must_use]
265pub fn summary_to_chunk(
266	ParentSummary { children_state, summary }: ParentSummary,
267) -> SpaceHierarchyRoomsChunk {
268	SpaceHierarchyRoomsChunk { children_state, summary }
269}
270
271#[inline]
272#[must_use]
273pub fn is_summary_serializable(summary: &ParentSummary) -> bool {
274	// Ignore case to workaround a Ruma issue which refuses to serialize unknown
275	// join rule types.
276	!matches!(summary.summary.join_rule, JoinRuleSummary::_Custom(_))
277}