tuwunel_service/rooms/spaces/
mod.rs1mod 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#[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#[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#[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#[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#[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 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, }
243}
244
245pub 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#[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 !matches!(summary.summary.join_rule, JoinRuleSummary::_Custom(_))
277}