Skip to main content

tuwunel_api_client/room/
summary.rs

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