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