1use rusqlite::Connection;
13use serde_json::{json, Value};
14
15use crate::error::MatrixError;
16use crate::store::{self, HistoryVisibility, JoinRule, Membership};
17
18pub const SPACE_TYPE: &str = "m.space";
20
21pub fn validate_space_state(event_type: &str, content: &Value) -> Result<(), MatrixError> {
26 if event_type != "m.space.child" && event_type != "m.space.parent" {
27 return Ok(());
28 }
29 let Some(obj) = content.as_object() else {
30 return Err(MatrixError::invalid_param("space link content must be an object"));
31 };
32 if obj.is_empty() {
33 return Ok(());
34 }
35 let via_ok = obj.get("via").and_then(Value::as_array).is_some_and(|a| !a.is_empty() && a.iter().all(|v| v.as_str().is_some_and(|s| !s.is_empty())));
36 if !via_ok {
37 return Err(MatrixError::invalid_param("via must be a non-empty list of server names"));
38 }
39 if let Some(order) = obj.get("order") {
40 let ok = order.as_str().is_some_and(|s| s.len() <= 50 && s.bytes().all(|b| (0x20..=0x7e).contains(&b)));
41 if !ok {
42 return Err(MatrixError::invalid_param("order must be a string of at most 50 printable ASCII characters"));
43 }
44 }
45 if obj.get("suggested").is_some_and(|v| !v.is_boolean()) {
46 return Err(MatrixError::invalid_param("suggested must be a boolean"));
47 }
48 Ok(())
49}
50
51pub(crate) fn state_content(conn: &Connection, room_id: &str, event_type: &str, key: &str) -> Result<Option<Value>, MatrixError> {
52 Ok(store::current_state_event(conn, room_id, event_type, key)?.and_then(|e| serde_json::from_str(&e.content).ok()))
53}
54
55pub fn room_type(conn: &Connection, room_id: &str) -> Result<Option<String>, MatrixError> {
57 Ok(state_content(conn, room_id, "m.room.create", "")?.and_then(|c| c.get("type").and_then(Value::as_str).map(str::to_string)))
58}
59
60pub fn restricted_allows(conn: &Connection, room_id: &str, user_id: i64) -> Result<bool, MatrixError> {
65 let Some(rules) = state_content(conn, room_id, "m.room.join_rules", "")? else { return Ok(false) };
66 if !matches!(rules.get("join_rule").and_then(Value::as_str), Some("restricted" | "knock_restricted")) {
67 return Ok(false);
68 }
69 for allow in rules.get("allow").and_then(Value::as_array).into_iter().flatten() {
70 if allow.get("type").and_then(Value::as_str) != Some("m.room_membership") {
71 continue;
72 }
73 if let Some(space) = allow.get("room_id").and_then(Value::as_str) {
74 if store::room_member(conn, space, user_id)?.is_some_and(|m| m.membership == Membership::Join) {
75 return Ok(true);
76 }
77 }
78 }
79 Ok(false)
80}
81
82pub(crate) fn join_rule_wire(conn: &Connection, room: &store::Room) -> Result<String, MatrixError> {
83 Ok(state_content(conn, &room.id, "m.room.join_rules", "")?
84 .and_then(|c| c.get("join_rule").and_then(Value::as_str).map(str::to_string))
85 .unwrap_or_else(|| match room.join_rule {
86 JoinRule::Public => "public".to_string(),
87 JoinRule::Invite => "invite".to_string(),
88 }))
89}
90
91pub(crate) fn caller_may_see(conn: &Connection, room: &store::Room, user_id: i64) -> Result<bool, MatrixError> {
92 if store::room_member(conn, &room.id, user_id)?.is_some_and(|m| matches!(m.membership, Membership::Join | Membership::Invite)) {
93 return Ok(true);
94 }
95 if room.join_rule == JoinRule::Public && room.history_visibility == HistoryVisibility::WorldReadable {
96 return Ok(true);
97 }
98 if room.join_rule == JoinRule::Public {
99 return Ok(true);
100 }
101 restricted_allows(conn, &room.id, user_id)
102}
103
104pub(crate) fn joined_count(conn: &Connection, room_id: &str) -> Result<i64, MatrixError> {
105 let mut n = 0;
106 for event in store::current_state_all(conn, room_id)? {
107 if event.event_type == "m.room.member" {
108 let c: Value = serde_json::from_str(&event.content)?;
109 if c.get("membership").and_then(Value::as_str) == Some("join") {
110 n += 1;
111 }
112 }
113 }
114 Ok(n)
115}
116
117fn children(conn: &Connection, room_id: &str, suggested_only: bool) -> Result<Vec<(String, Value, i64, String)>, MatrixError> {
120 let mut out = Vec::new();
121 for event in store::current_state_all(conn, room_id)? {
122 if event.event_type != "m.space.child" {
123 continue;
124 }
125 let content: Value = serde_json::from_str(&event.content)?;
126 let via_ok = content.get("via").and_then(Value::as_array).is_some_and(|a| !a.is_empty());
127 if !via_ok {
128 continue;
129 }
130 if suggested_only && content.get("suggested").and_then(Value::as_bool) != Some(true) {
131 continue;
132 }
133 let sender = store::mxid_of(conn, event.sender_user_id)?.unwrap_or_default();
134 out.push((event.state_key.clone().unwrap_or_default(), json!({
135 "type": "m.space.child",
136 "state_key": event.state_key,
137 "content": content,
138 "sender": sender,
139 "origin_server_ts": event.origin_server_ts,
140 }), event.origin_server_ts, content.get("order").and_then(Value::as_str).unwrap_or("\u{10ffff}").to_string()));
141 }
142 out.sort_by(|a, b| a.3.cmp(&b.3).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
143 Ok(out)
144}
145
146pub fn hierarchy(
151 conn: &Connection,
152 caller_user_id: i64,
153 root: &str,
154 suggested_only: bool,
155 limit: usize,
156 max_depth: Option<usize>,
157 from: usize,
158) -> Result<Option<Value>, MatrixError> {
159 let Some(root_room) = store::get_room(conn, root)? else { return Ok(None) };
160 if !caller_may_see(conn, &root_room, caller_user_id)? {
161 return Ok(None);
162 }
163 let mut seen = std::collections::HashSet::new();
164 let mut queue = std::collections::VecDeque::new();
165 queue.push_back((root.to_string(), 0usize));
166 seen.insert(root.to_string());
167 let mut rooms = Vec::new();
168 while let Some((room_id, depth)) = queue.pop_front() {
169 let Some(room) = store::get_room(conn, &room_id)? else { continue };
170 if !caller_may_see(conn, &room, caller_user_id)? {
171 continue;
172 }
173 let kids = children(conn, &room_id, suggested_only)?;
174 let is_space = room_type(conn, &room_id)?.as_deref() == Some(SPACE_TYPE);
175 let name = state_content(conn, &room_id, "m.room.name", "")?.and_then(|c| c.get("name").and_then(Value::as_str).map(str::to_string));
176 let topic = state_content(conn, &room_id, "m.room.topic", "")?.and_then(|c| c.get("topic").and_then(Value::as_str).map(str::to_string));
177 let mut entry = json!({
178 "room_id": room_id,
179 "num_joined_members": joined_count(conn, &room_id)?,
180 "world_readable": room.history_visibility == HistoryVisibility::WorldReadable,
181 "guest_can_join": false,
182 "join_rule": join_rule_wire(conn, &room)?,
183 "children_state": kids.iter().map(|k| k.1.clone()).collect::<Vec<_>>(),
184 });
185 let obj = entry.as_object_mut().expect("object");
186 if let Some(n) = name { obj.insert("name".into(), json!(n)); }
187 if let Some(t) = topic { obj.insert("topic".into(), json!(t)); }
188 if is_space { obj.insert("room_type".into(), json!(SPACE_TYPE)); }
189 rooms.push(entry);
190 if max_depth.is_none_or(|m| depth < m) {
191 for (child, ..) in &kids {
192 if seen.insert(child.clone()) {
193 queue.push_back((child.clone(), depth + 1));
194 }
195 }
196 }
197 }
198 let total = rooms.len();
199 let page: Vec<Value> = rooms.into_iter().skip(from).take(limit).collect();
200 let mut resp = json!({ "rooms": page });
201 if from + limit < total {
202 resp["next_batch"] = json!(format!("h{}", from + limit));
203 }
204 Ok(Some(resp))
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn space_link_validation() {
213 assert!(validate_space_state("m.space.child", &json!({})).is_ok());
214 assert!(validate_space_state("m.space.child", &json!({"via": ["a.example"], "order": "01", "suggested": true})).is_ok());
215 assert!(validate_space_state("m.space.child", &json!({"via": []})).is_err());
216 assert!(validate_space_state("m.space.parent", &json!({"nope": 1})).is_err());
217 assert!(validate_space_state("m.space.child", &json!({"via": ["a"], "order": "é"})).is_err());
218 assert!(validate_space_state("m.room.name", &json!("anything")).is_ok());
219 }
220
221 use crate::rooms::{apply_create_room, apply_put_state, decide_and_apply_join, RoomCreate, RoomCreation};
222
223 const NOW: &str = "2026-10-09T00:00:00+00:00";
224
225 fn conn() -> Connection {
226 let c = Connection::open_in_memory().unwrap();
227 store::create_matrix_schema(&c).unwrap();
228 store::ensure_matrix_user(&c, 1, "alice000000000000000000000000001", NOW).unwrap();
229 store::ensure_matrix_user(&c, 2, "bob00000000000000000000000000002", NOW).unwrap();
230 c
231 }
232
233 fn make(c: &mut Connection, public: bool, name: &str, room_type: Option<&str>) -> String {
234 let mxid = store::mxid_of(c, 1).unwrap().unwrap();
235 match apply_create_room(
236 c,
237 RoomCreate {
238 creator_user_id: 1,
239 creator_mxid: &mxid,
240 creator_displayname: "alice",
241 is_direct: false,
242 invitees: &[],
243 visibility_public: public,
244 power_level_content_override: None,
245 name: Some(name),
246 topic: None,
247 room_type,
248 predecessor: None,
249 },
250 NOW,
251 1,
252 )
253 .unwrap()
254 {
255 RoomCreation::Created { room_id, .. } => room_id,
256 RoomCreation::Reused(_) => unreachable!(),
257 }
258 }
259
260 fn put(c: &mut Connection, room: &str, ty: &str, key: &str, content: Value) {
261 let mxid = store::mxid_of(c, 1).unwrap().unwrap();
262 apply_put_state(c, room, 1, &mxid, ty, key, &content.to_string(), NOW, 2).unwrap();
263 }
264
265 #[test]
266 fn domain_with_subrooms_hierarchy_and_visibility() {
267 let mut c = conn();
268 let domain = make(&mut c, true, "acme", Some(SPACE_TYPE));
269 let chan = make(&mut c, true, "announce", None);
270 let grp = make(&mut c, false, "ops", None);
271 assert_eq!(room_type(&c, &domain).unwrap().as_deref(), Some(SPACE_TYPE));
272 assert_eq!(room_type(&c, &chan).unwrap(), None);
273 put(&mut c, &domain, "m.space.child", &chan, json!({"via": ["localhost"], "order": "a"}));
274 put(&mut c, &domain, "m.space.child", &grp, json!({"via": ["localhost"], "order": "b", "suggested": true}));
275 put(&mut c, &chan, "m.space.parent", &domain, json!({"via": ["localhost"], "canonical": true}));
276 let h = hierarchy(&c, 1, &domain, false, 50, None, 0).unwrap().unwrap();
278 let ids: Vec<&str> = h["rooms"].as_array().unwrap().iter().map(|r| r["room_id"].as_str().unwrap()).collect();
279 assert_eq!(ids, vec![domain.as_str(), chan.as_str(), grp.as_str()]);
280 assert_eq!(h["rooms"][0]["room_type"], SPACE_TYPE);
281 assert_eq!(h["rooms"][0]["children_state"].as_array().unwrap().len(), 2);
282 let h = hierarchy(&c, 2, &domain, false, 50, None, 0).unwrap().unwrap();
284 let ids: Vec<&str> = h["rooms"].as_array().unwrap().iter().map(|r| r["room_id"].as_str().unwrap()).collect();
285 assert_eq!(ids, vec![domain.as_str(), chan.as_str()]);
286 assert_eq!(hierarchy(&c, 1, &domain, true, 50, None, 0).unwrap().unwrap()["rooms"].as_array().unwrap().len(), 2);
288 assert_eq!(hierarchy(&c, 1, &domain, false, 50, Some(0), 0).unwrap().unwrap()["rooms"].as_array().unwrap().len(), 1);
289 let p = hierarchy(&c, 1, &domain, false, 2, None, 0).unwrap().unwrap();
290 assert_eq!(p["next_batch"], "h2");
291 assert_eq!(hierarchy(&c, 1, &domain, false, 2, None, 2).unwrap().unwrap()["rooms"].as_array().unwrap().len(), 1);
292 put(&mut c, &domain, "m.space.child", &chan, json!({}));
294 assert_eq!(hierarchy(&c, 1, &domain, false, 50, None, 0).unwrap().unwrap()["rooms"].as_array().unwrap().len(), 2);
295 }
296
297 #[test]
298 fn restricted_room_admits_space_members_only() {
299 let mut c = conn();
300 let domain = make(&mut c, true, "acme", Some(SPACE_TYPE));
301 let grp = make(&mut c, false, "ops", None);
302 put(&mut c, &grp, "m.room.join_rules", "", json!({"join_rule": "restricted", "allow": [{"type": "m.room_membership", "room_id": domain}]}));
303 let bob = store::mxid_of(&c, 2).unwrap().unwrap();
304 assert!(decide_and_apply_join(&mut c, &grp, 2, &bob, "bob", NOW, 3).is_err());
306 decide_and_apply_join(&mut c, &domain, 2, &bob, "bob", NOW, 4).unwrap();
308 assert!(restricted_allows(&c, &grp, 2).unwrap());
309 decide_and_apply_join(&mut c, &grp, 2, &bob, "bob", NOW, 5).unwrap();
310 }
311}