Skip to main content

tuwunel_api/client/room/
summary.rs

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