1use std::cmp;
2
3use axum::extract::State;
4use futures::{
5 FutureExt, StreamExt, TryFutureExt,
6 future::{join, join4, join5},
7};
8use ruma::{
9 OwnedRoomAliasId, OwnedRoomId, RoomAliasId, RoomId, ServerName, UInt, UserId,
10 api::{
11 client::{
12 directory::{
13 get_public_rooms, get_public_rooms_filtered, get_room_visibility,
14 set_room_visibility,
15 },
16 room,
17 },
18 federation,
19 },
20 directory::{Filter, PublicRoomsChunk, RoomNetwork, RoomTypeFilter},
21 events::StateEventType,
22 uint,
23};
24use tuwunel_core::{
25 Err, Error, Result, err, info,
26 utils::{
27 TryFutureExtExt,
28 math::{Expected, usize_from_ruma_bounded},
29 stream::{IterStream, ReadyExt, WidebandExt},
30 },
31 warn,
32};
33use tuwunel_matrix::Event;
34use tuwunel_service::Services;
35
36use crate::{ClientIp, Ruma, utils::sender_is_guest};
37
38const DEFAULT_LIMIT: usize = 100;
39
40#[tracing::instrument(skip_all, fields(%client), name = "publicrooms")]
46pub(crate) async fn get_public_rooms_filtered_route(
47 State(services): State<crate::State>,
48 ClientIp(client): ClientIp,
49 body: Ruma<get_public_rooms_filtered::v3::Request>,
50) -> Result<get_public_rooms_filtered::v3::Response> {
51 check_server_banned(&services, body.server.as_deref())?;
52
53 get_public_rooms_filtered_helper(
54 &services,
55 body.server.as_deref(),
56 body.limit,
57 body.since.as_deref(),
58 &body.filter,
59 &body.room_network,
60 )
61 .map_err(|e| mask_remote_failure(&services, body.server.as_deref(), e))
62 .await
63}
64
65#[tracing::instrument(skip_all, fields(%client), name = "publicrooms")]
71pub(crate) async fn get_public_rooms_route(
72 State(services): State<crate::State>,
73 ClientIp(client): ClientIp,
74 body: Ruma<get_public_rooms::v3::Request>,
75) -> Result<get_public_rooms::v3::Response> {
76 check_server_banned(&services, body.server.as_deref())?;
77
78 let response = get_public_rooms_filtered_helper(
79 &services,
80 body.server.as_deref(),
81 body.limit,
82 body.since.as_deref(),
83 &Filter::default(),
84 &RoomNetwork::Matrix,
85 )
86 .map_err(|e| mask_remote_failure(&services, body.server.as_deref(), e))
87 .await?;
88
89 Ok(get_public_rooms::v3::Response {
90 chunk: response.chunk,
91 prev_batch: response.prev_batch,
92 next_batch: response.next_batch,
93 total_room_count_estimate: response.total_room_count_estimate,
94 })
95}
96
97#[tracing::instrument(skip_all, fields(%client), name = "room_directory")]
101pub(crate) async fn set_room_visibility_route(
102 State(services): State<crate::State>,
103 ClientIp(client): ClientIp,
104 body: Ruma<set_room_visibility::v3::Request>,
105) -> Result<set_room_visibility::v3::Response> {
106 let sender_user = body.sender_user();
107
108 if !services.metadata.exists(&body.room_id).await {
109 return Err!(Request(NotFound("Room not found")));
111 }
112
113 if sender_is_guest(&services, &body).await {
114 return Err!(Request(Forbidden("Guests cannot publish to room directories")));
115 }
116
117 if !user_can_publish_room(&services, sender_user, &body.room_id).await? {
118 return Err!(Request(Forbidden("User is not allowed to publish this room")));
119 }
120
121 match &body.visibility {
122 | room::Visibility::Public => {
123 if services.server.config.directory.rooms.lockdown
124 && !services.admin.user_is_admin(sender_user).await
125 && body.appservice_info.is_none()
126 {
127 info!(
128 %sender_user,
129 room_id = %body.room_id,
130 "Non-admin user tried to publish a room to the directory while \
131 directory.rooms.lockdown is enabled"
132 );
133
134 services
135 .admin
136 .notify_loud(&format!(
137 "Non-admin user {sender_user} tried to publish {0} to the room \
138 directory while \"directory.rooms.lockdown\" is enabled",
139 body.room_id
140 ))
141 .await;
142
143 return Err!(Request(Forbidden(
144 "Publishing rooms to the room directory is not allowed",
145 )));
146 }
147
148 let published_alias = services
150 .directory
151 .published_alias(&body.room_id)
152 .await
153 .ok();
154
155 services
156 .directory
157 .set_public(&body.room_id, published_alias.as_deref());
158
159 services
160 .admin
161 .notify_loud(&format!(
162 "{sender_user} made {} public to the room directory",
163 body.room_id
164 ))
165 .await;
166
167 info!("{sender_user} made {0} public to the room directory", body.room_id);
168 },
169 | room::Visibility::Private => services.directory.set_not_public(&body.room_id),
170 | _ => {
171 return Err!(Request(InvalidParam("Room visibility type is not supported.",)));
172 },
173 }
174
175 Ok(set_room_visibility::v3::Response {})
176}
177
178pub(crate) async fn get_room_visibility_route(
182 State(services): State<crate::State>,
183 body: Ruma<get_room_visibility::v3::Request>,
184) -> Result<get_room_visibility::v3::Response> {
185 if !services.metadata.exists(&body.room_id).await {
186 return Err!(Request(NotFound("Room not found")));
188 }
189
190 Ok(get_room_visibility::v3::Response {
191 visibility: if services
192 .directory
193 .is_public_room(&body.room_id)
194 .await
195 {
196 room::Visibility::Public
197 } else {
198 room::Visibility::Private
199 },
200 })
201}
202
203pub async fn get_public_rooms_filtered_helper(
209 services: &Services,
210 server: Option<&ServerName>,
211 limit: Option<UInt>,
212 since: Option<&str>,
213 filter: &Filter,
214 _network: &RoomNetwork,
215) -> Result<get_public_rooms_filtered::v3::Response> {
216 if let Some(other_server) = remote_server(services, server) {
217 let response = services
218 .federation
219 .execute(
220 other_server,
221 federation::directory::get_public_rooms_filtered::v1::Request {
222 limit,
223 since: since.map(ToOwned::to_owned),
224 filter: Filter {
225 generic_search_term: filter.generic_search_term.clone(),
226 room_types: filter.room_types.clone(),
227 },
228 room_network: RoomNetwork::Matrix,
229 },
230 )
231 .await?;
232
233 return Ok(get_public_rooms_filtered::v3::Response {
234 chunk: response.chunk,
235 prev_batch: response.prev_batch,
236 next_batch: response.next_batch,
237 total_room_count_estimate: response.total_room_count_estimate,
238 });
239 }
240
241 let limit = limit
242 .filter(|&limit| limit > uint!(0))
243 .map_or(DEFAULT_LIMIT, |limit| usize_from_ruma_bounded(limit, usize::MAX, usize::MAX));
244
245 let mut num_since: usize = 0;
246
247 if let Some(s) = &since {
248 let mut characters = s.chars();
249 let backwards = match characters.next() {
250 | Some('n') => false,
251 | Some('p') => true,
252 | _ => {
253 return Err!(Request(InvalidParam("Invalid `since` token")));
254 },
255 };
256
257 num_since = characters
258 .collect::<String>()
259 .parse()
260 .map_err(|_| err!(Request(InvalidParam("Invalid `since` token."))))?;
261
262 if backwards {
263 num_since = num_since.saturating_sub(limit);
264 }
265 }
266
267 let search_term = filter
268 .generic_search_term
269 .as_deref()
270 .map(str::to_lowercase);
271
272 let search_room_id = filter
273 .generic_search_term
274 .as_deref()
275 .filter(|_| {
276 services
277 .config
278 .directory
279 .rooms
280 .allow_public_search_by_id
281 })
282 .filter(|s| s.starts_with('!'))
283 .filter(|s| s.len() > 5); let meta_public_rooms = search_room_id
286 .filter(|_| {
287 services
288 .config
289 .directory
290 .rooms
291 .allow_unlisted_search_by_id
292 })
293 .map(|prefix| services.metadata.public_ids_prefix(prefix))
294 .into_iter()
295 .stream()
296 .flatten();
297
298 let mut all_rooms: Vec<PublicRoomsChunk> = services
299 .directory
300 .public_rooms()
301 .map(ToOwned::to_owned)
302 .chain(meta_public_rooms)
303 .wide_then(|room_id| public_rooms_chunk(services, room_id))
304 .ready_filter_map(|chunk| {
305 if !filter.room_types.is_empty()
306 && !filter
307 .room_types
308 .contains(&RoomTypeFilter::from(chunk.room_type.clone()))
309 {
310 return None;
311 }
312
313 if let Some(query) = search_room_id
314 && chunk.room_id.as_str().contains(query)
315 {
316 return Some(chunk);
317 }
318
319 if let Some(query) = search_term.as_deref() {
320 if let Some(name) = &chunk.name
321 && name.as_str().to_lowercase().contains(query)
322 {
323 return Some(chunk);
324 }
325
326 if let Some(topic) = &chunk.topic
327 && topic.to_lowercase().contains(query)
328 {
329 return Some(chunk);
330 }
331
332 if let Some(canonical_alias) = &chunk.canonical_alias
333 && canonical_alias
334 .as_str()
335 .to_lowercase()
336 .contains(query)
337 {
338 return Some(chunk);
339 }
340
341 return None;
342 }
343
344 Some(chunk)
346 })
347 .collect()
349 .await;
350
351 all_rooms.sort_by_key(|r| cmp::Reverse(r.num_joined_members));
352
353 let total_room_count_estimate = UInt::try_from(all_rooms.len())
354 .unwrap_or_else(|_| uint!(0))
355 .into();
356
357 let chunk: Vec<_> = all_rooms
358 .into_iter()
359 .skip(num_since)
360 .take(limit)
361 .collect();
362
363 let prev_batch = num_since.ne(&0).then(|| format!("p{num_since}"));
364
365 let next_batch = chunk
366 .len()
367 .ge(&limit)
368 .then(|| format!("n{}", num_since.expected_add(limit)));
369
370 Ok(get_public_rooms_filtered::v3::Response {
371 chunk,
372 prev_batch,
373 next_batch,
374 total_room_count_estimate,
375 })
376}
377
378async fn user_can_publish_room(
381 services: &Services,
382 user_id: &UserId,
383 room_id: &RoomId,
384) -> Result<bool> {
385 match services
386 .state_accessor
387 .get_power_levels(room_id)
388 .await
389 {
390 | Ok(power_levels) =>
391 Ok(power_levels.user_can_send_state(user_id, StateEventType::RoomHistoryVisibility)),
392 | _ => {
393 match services
394 .state_accessor
395 .room_state_get(room_id, &StateEventType::RoomCreate, "")
396 .await
397 {
398 | Ok(event) => Ok(event.sender() == user_id),
399 | _ => Err!(Request(Forbidden("User is not allowed to publish this room"))),
400 }
401 },
402 }
403}
404
405async fn public_rooms_chunk(services: &Services, room_id: OwnedRoomId) -> PublicRoomsChunk {
406 let name = services.state_accessor.get_name(&room_id).ok();
407
408 let room_type = services
409 .state_accessor
410 .get_room_type(&room_id)
411 .ok();
412
413 let canonical_alias = directory_alias(services, &room_id);
414
415 let avatar_url = services
416 .state_accessor
417 .get_avatar(&room_id)
418 .map_ok(|content| content.url)
419 .ok();
420
421 let topic = services
422 .state_accessor
423 .get_room_topic(&room_id)
424 .ok();
425
426 let world_readable = services
427 .state_accessor
428 .is_world_readable(&room_id);
429
430 let join_rule = services
431 .state_accessor
432 .get_join_rules(&room_id)
433 .map(|join_rule| join_rule.kind());
434
435 let guest_can_join = services.state_accessor.guest_can_join(&room_id);
436
437 let num_joined_members = services
438 .state_cache
439 .room_joined_count(&room_id)
440 .map(|x| {
441 x.ok()
442 .and_then(|x| x.try_into().ok())
443 .unwrap_or_else(|| uint!(0))
444 });
445
446 let (
447 (avatar_url, canonical_alias, guest_can_join, join_rule, name),
448 (num_joined_members, room_type, topic, world_readable),
449 ) = join(
450 join5(avatar_url, canonical_alias, guest_can_join, join_rule, name),
451 join4(num_joined_members, room_type, topic, world_readable),
452 )
453 .boxed() .await;
455
456 PublicRoomsChunk {
457 avatar_url: avatar_url.flatten(),
458 canonical_alias,
459 guest_can_join,
460 join_rule,
461 name,
462 num_joined_members,
463 room_id,
464 room_type,
465 topic,
466 world_readable,
467 }
468}
469
470async fn directory_alias(services: &Services, room_id: &RoomId) -> Option<OwnedRoomAliasId> {
473 if let Ok(alias) = services.directory.published_alias(room_id).await
474 && alias_resolves_to(services, &alias, room_id).await
475 {
476 return Some(alias);
477 }
478
479 let alias = services
480 .state_accessor
481 .get_canonical_alias(room_id)
482 .await
483 .ok()?;
484
485 alias_resolves_to(services, &alias, room_id)
486 .await
487 .then_some(alias)
488}
489
490async fn alias_resolves_to(services: &Services, alias: &RoomAliasId, room_id: &RoomId) -> bool {
491 services.globals.alias_is_local(alias)
492 && services
493 .alias
494 .resolve_local_alias(alias)
495 .await
496 .is_ok_and(|resolved| resolved == room_id)
497}
498
499fn check_server_banned(services: &Services, server: Option<&ServerName>) -> Result {
500 let Some(server) = server else {
501 return Ok(());
502 };
503
504 if services
505 .config
506 .directory
507 .rooms
508 .forbidden_remote_server_names
509 .is_match(server.host())
510 || services
511 .config
512 .is_forbidden_remote_server_name(server)
513 {
514 return Err!(Request(Forbidden("Server is banned on this homeserver.")));
515 }
516
517 Ok(())
518}
519
520fn mask_remote_failure(services: &Services, server: Option<&ServerName>, error: Error) -> Error {
527 let Some(server) = remote_server(services, server) else {
528 return error;
529 };
530
531 warn!(%server, %error, "Failed to query remote public rooms directory");
532
533 err!(Request(ConnectionFailed("Unable to query the remote public rooms directory.")))
534}
535
536fn remote_server<'a>(
542 services: &Services,
543 server: Option<&'a ServerName>,
544) -> Option<&'a ServerName> {
545 server.filter(|server| !services.globals.server_is_ours(server))
546}