Skip to main content

tuwunel_api/client/room/
summary.rs

1use axum::extract::State;
2use futures::{FutureExt, StreamExt, TryFutureExt, future::join3, stream::FuturesUnordered};
3use ruma::{
4	OwnedServerName, RoomId, UserId,
5	api::{
6		client::room::get_summary,
7		federation::space::{SpaceHierarchyParentSummary, get_hierarchy},
8	},
9	events::room::member::MembershipState,
10	room::{JoinRuleSummary, RoomSummary},
11};
12use tuwunel_core::{
13	Err, Result, debug_warn, trace,
14	utils::{IterStream, future::TryExtExt, option::OptionExt},
15};
16use tuwunel_service::Services;
17
18use crate::{ClientIp, Ruma, RumaResponse};
19
20/// # `GET /_matrix/client/unstable/im.nheko.summary/rooms/{roomIdOrAlias}/summary`
21///
22/// Returns a short description of the state of a room.
23///
24/// This is the "wrong" endpoint that some implementations/clients may use
25/// according to the MSC. Request and response bodies are the same as
26/// `get_room_summary`.
27///
28/// An implementation of [MSC3266](https://github.com/matrix-org/matrix-spec-proposals/pull/3266)
29pub(crate) async fn get_room_summary_legacy(
30	State(services): State<crate::State>,
31	ClientIp(client): ClientIp,
32	body: Ruma<get_summary::v1::Request>,
33) -> Result<RumaResponse<get_summary::v1::Response>> {
34	get_room_summary(State(services), ClientIp(client), body)
35		.boxed()
36		.await
37		.map(RumaResponse)
38}
39
40/// # `GET /_matrix/client/unstable/im.nheko.summary/summary/{roomIdOrAlias}`
41///
42/// Returns a short description of the state of a room.
43///
44/// An implementation of [MSC3266](https://github.com/matrix-org/matrix-spec-proposals/pull/3266)
45#[tracing::instrument(skip_all, fields(%client), name = "room_summary")]
46pub(crate) async fn get_room_summary(
47	State(services): State<crate::State>,
48	ClientIp(client): ClientIp,
49	body: Ruma<get_summary::v1::Request>,
50) -> Result<get_summary::v1::Response> {
51	let (room_id, servers) = services
52		.alias
53		.maybe_resolve_with_servers(&body.room_id_or_alias, Some(&body.via))
54		.await?;
55
56	if services.metadata.is_banned(&room_id).await {
57		return Err!(Request(Forbidden("This room is banned on this homeserver.")));
58	}
59
60	room_summary_response(&services, &room_id, &servers, body.sender_user.as_deref())
61		.boxed()
62		.await
63}
64
65async fn room_summary_response(
66	services: &Services,
67	room_id: &RoomId,
68	servers: &[OwnedServerName],
69	sender_user: Option<&UserId>,
70) -> Result<get_summary::v1::Response> {
71	if services
72		.state_cache
73		.server_in_room(services.globals.server_name(), room_id)
74		.await
75	{
76		return local_room_summary_response(services, room_id, sender_user)
77			.boxed()
78			.await;
79	}
80
81	let summary = remote_room_summary_hierarchy_response(services, room_id, servers, sender_user)
82		.await?
83		.summary;
84
85	Ok(get_summary::v1::Response {
86		summary,
87		membership: sender_user
88			.is_some()
89			.then_some(MembershipState::Leave),
90	})
91}
92
93async fn local_room_summary_response(
94	services: &Services,
95	room_id: &RoomId,
96	sender_user: Option<&UserId>,
97) -> Result<get_summary::v1::Response> {
98	trace!(?sender_user, "Sending local room summary response for {room_id:?}");
99	let join_rule = services.state_accessor.get_join_rules(room_id);
100
101	let world_readable = services.state_accessor.is_world_readable(room_id);
102
103	let guest_can_join = services.state_accessor.guest_can_join(room_id);
104
105	let (join_rule, world_readable, guest_can_join) =
106		join3(join_rule, world_readable, guest_can_join).await;
107
108	trace!("{join_rule:?}, {world_readable:?}, {guest_can_join:?}");
109	user_can_see_summary(
110		services,
111		room_id,
112		&join_rule.clone().into(),
113		guest_can_join,
114		world_readable,
115		join_rule.allowed_room_ids(),
116		sender_user,
117	)
118	.await?;
119
120	let canonical_alias = services
121		.state_accessor
122		.get_canonical_alias(room_id)
123		.ok();
124
125	let name = services.state_accessor.get_name(room_id).ok();
126
127	let topic = services
128		.state_accessor
129		.get_room_topic(room_id)
130		.ok();
131
132	let room_type = services
133		.state_accessor
134		.get_room_type(room_id)
135		.ok();
136
137	let avatar_url = services
138		.state_accessor
139		.get_avatar(room_id)
140		.map_ok(|content| content.url)
141		.ok()
142		.map(Option::flatten);
143
144	let room_version = services.state.get_room_version(room_id).ok();
145
146	let encryption = services
147		.state_accessor
148		.get_room_encryption(room_id)
149		.ok();
150
151	let num_joined_members = services
152		.state_cache
153		.room_joined_count(room_id)
154		.unwrap_or(0);
155
156	let membership = sender_user.map_async(|sender_user| {
157		services
158			.state_accessor
159			.get_member(room_id, sender_user)
160			.map_ok_or(MembershipState::Leave, |content| content.membership)
161	});
162
163	let (
164		canonical_alias,
165		name,
166		num_joined_members,
167		topic,
168		avatar_url,
169		room_type,
170		room_version,
171		encryption,
172		membership,
173	) = futures::join!(
174		canonical_alias,
175		name,
176		num_joined_members,
177		topic,
178		avatar_url,
179		room_type,
180		room_version,
181		encryption,
182		membership,
183	);
184
185	Ok(get_summary::v1::Response {
186		summary: RoomSummary {
187			room_id: room_id.to_owned(),
188			canonical_alias,
189			avatar_url,
190			guest_can_join,
191			name,
192			num_joined_members: num_joined_members.try_into().unwrap_or_default(),
193			topic,
194			world_readable,
195			room_type,
196			room_version,
197			encryption,
198			join_rule: join_rule.into(),
199		},
200		membership,
201	})
202}
203
204/// used by MSC3266 to fetch a room's info if we do not know about it
205async fn remote_room_summary_hierarchy_response(
206	services: &Services,
207	room_id: &RoomId,
208	servers: &[OwnedServerName],
209	sender_user: Option<&UserId>,
210) -> Result<SpaceHierarchyParentSummary> {
211	trace!(?sender_user, ?servers, "Sending remote room summary response for {room_id:?}");
212	if !services.config.allow_federation {
213		return Err!(Request(Forbidden("Federation is disabled.")));
214	}
215
216	if services.metadata.is_disabled(room_id).await {
217		return Err!(Request(Forbidden(
218			"Federaton of room {room_id} is currently disabled on this server."
219		)));
220	}
221
222	let request = get_hierarchy::v1::Request::new(room_id.to_owned());
223
224	let mut requests: FuturesUnordered<_> = servers
225		.iter()
226		.map(|server| {
227			services
228				.federation
229				.execute(server, request.clone())
230		})
231		.collect();
232
233	while let Some(Ok(response)) = requests.next().await {
234		trace!("{response:?}");
235		let room = response.room.clone();
236		let summary = &room.summary;
237		if summary.room_id != room_id {
238			debug_warn!(
239				"Room ID {} returned does not belong to the requested room ID {}",
240				summary.room_id,
241				room_id
242			);
243			continue;
244		}
245
246		return user_can_see_summary(
247			services,
248			room_id,
249			&summary.join_rule,
250			summary.guest_can_join,
251			summary.world_readable,
252			summary.join_rule.allowed_room_ids(),
253			sender_user,
254		)
255		.await
256		.map(|()| room);
257	}
258
259	Err!(Request(NotFound(
260		"Room is unknown to this server and was unable to fetch over federation with the \
261		 provided servers available"
262	)))
263}
264
265async fn user_can_see_summary<'a, I>(
266	services: &Services,
267	room_id: &RoomId,
268	join_rule: &JoinRuleSummary,
269	guest_can_join: bool,
270	world_readable: bool,
271	allowed_room_ids: I,
272	sender_user: Option<&UserId>,
273) -> Result
274where
275	I: Iterator<Item = &'a RoomId> + Send,
276{
277	let is_public_room = matches!(
278		join_rule,
279		JoinRuleSummary::Public | JoinRuleSummary::Knock | JoinRuleSummary::KnockRestricted(_)
280	);
281
282	match sender_user {
283		| Some(sender_user) => {
284			let user_can_see_state_events = services
285				.state_accessor
286				.user_can_see_state_events(sender_user, room_id);
287
288			let is_guest = services
289				.users
290				.is_deactivated(sender_user)
291				.unwrap_or(false);
292
293			let user_in_allowed_restricted_room = allowed_room_ids
294				.stream()
295				.any(|room| services.state_cache.is_joined(sender_user, room));
296
297			let (user_can_see_state_events, is_guest, user_in_allowed_restricted_room) =
298				join3(user_can_see_state_events, is_guest, user_in_allowed_restricted_room)
299					.boxed()
300					.await;
301
302			if user_can_see_state_events
303				|| (is_guest && guest_can_join)
304				|| is_public_room
305				|| user_in_allowed_restricted_room
306			{
307				return Ok(());
308			}
309
310			Err!(Request(Forbidden(
311				"Room is not world readable, not publicly accessible/joinable, restricted room \
312				 conditions not met, and guest access is forbidden. Not allowed to see details \
313				 of this room."
314			)))
315		},
316		| None => {
317			if is_public_room || world_readable {
318				return Ok(());
319			}
320
321			Err!(Request(Forbidden(
322				"Room is not world readable or publicly accessible/joinable, authentication is \
323				 required"
324			)))
325		},
326	}
327}