Skip to main content

tuwunel_api/client/
directory.rs

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