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	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/// # `POST /_matrix/client/v3/publicRooms`
41///
42/// Lists the public rooms on this server.
43///
44/// - Rooms are ordered by the number of joined members
45#[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/// # `GET /_matrix/client/v3/publicRooms`
66///
67/// Lists the public rooms on this server.
68///
69/// - Rooms are ordered by the number of joined members
70#[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/// # `PUT /_matrix/client/r0/directory/list/room/{roomId}`
98///
99/// Sets the visibility of a given room in the room directory.
100#[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 404 if the room doesn't exist
110		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			// Preserve the alias the room was published under.
149			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
178/// # `GET /_matrix/client/r0/directory/list/room/{roomId}`
179///
180/// Gets the visibility of a given room in the room directory.
181pub(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 404 if the room doesn't exist
187		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
203/// Lists public rooms matching the requested filter.
204///
205/// A remote server is queried when specified; otherwise the local directory is
206/// used. A local query with a zero or absent `limit` takes a page of 100, as
207/// Synapse does.
208pub 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); // require some characters to limit scope.
284
285	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			// No search term
345			Some(chunk)
346		})
347		// We need to collect all, so we can sort by member count
348		.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
378/// Check whether the user can publish to the room directory via power levels of
379/// room history visibility event or room creator
380async 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() // size firewall
454	.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
470/// Alias for the room's directory entry: the alias it was published under
471/// while it still resolves to the room, else the room's canonical alias.
472async 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
520/// Masks a remote directory failure behind a generic gateway error.
521///
522/// The remote chooses its own error, so forwarding one verbatim lets a third
523/// party pick what our client sees. A query served locally contacts nobody, so
524/// its error is returned unchanged rather than relabelled as an upstream
525/// failure.
526fn 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
536/// The server a directory query is routed to, when that server is not us.
537///
538/// Routing the query and masking its failure both read this, so the set of
539/// requests that reach a third party cannot drift from the set whose errors are
540/// masked.
541fn 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}