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