Skip to main content

tuwunel_api_client/
capabilities.rs

1use std::future::ready;
2
3use axum::extract::State;
4#[expect(deprecated)]
5use ruma::api::client::discovery::get_capabilities::v3::{
6	SetAvatarUrlCapability, SetDisplayNameCapability,
7};
8use ruma::{
9	UInt,
10	api::client::discovery::get_capabilities::v3::{
11		AccountModerationCapability, Capabilities, ChangePasswordCapability,
12		DelayedEventsCapability, ForgetForcedUponLeaveCapability, GetLoginTokenCapability,
13		ProfileFieldsCapability, Request, Response, RoomVersionsCapability,
14		ThirdPartyIdChangesCapability,
15	},
16	profile::ProfileFieldName,
17};
18use serde_json::json;
19use tuwunel_core::{Result, utils::BoolExt};
20use tuwunel_service::{Services, delayed_events::Limits};
21
22use crate::{Ruma, utils::may_set_profile_field};
23
24/// # `GET /_matrix/client/v3/capabilities`
25///
26/// Get information on the supported feature set and other relevant capabilities
27/// of this server.
28pub(crate) async fn get_capabilities_route(
29	State(services): State<crate::State>,
30	body: Ruma<Request>,
31) -> Result<Response> {
32	// MSC4323: advertise admin moderation only to admins; absence implies
33	// neither suspend nor lock is available to the caller.
34	let account_moderation = services
35		.admin
36		.user_is_admin(body.sender_user())
37		.await;
38
39	let may_set = async |field| {
40		may_set_profile_field(&services, &body, &field, || ready(account_moderation)).await
41	};
42
43	let set_displayname = may_set(ProfileFieldName::DisplayName).await;
44	let set_avatar_url = may_set(ProfileFieldName::AvatarUrl).await;
45
46	capabilities(&services, set_displayname, set_avatar_url, account_moderation)
47		.map(Response::new)
48}
49
50#[expect(deprecated)]
51fn capabilities(
52	services: &Services,
53	set_displayname: bool,
54	set_avatar_url: bool,
55	account_moderation: bool,
56) -> Result<Capabilities> {
57	let available = services
58		.config
59		.supported_room_versions()
60		.collect();
61
62	let default = services
63		.server
64		.config
65		.rooms
66		.default_version
67		.clone();
68
69	// Matrix 1.16 clients read the profile field policy from m.profile_fields.
70	let disallowed: Vec<_> = [
71		(set_displayname, ProfileFieldName::DisplayName),
72		(set_avatar_url, ProfileFieldName::AvatarUrl),
73	]
74	.into_iter()
75	.filter_map(|(allowed, field)| allowed.is_false().then_some(field))
76	.collect();
77
78	let disallowed = disallowed
79		.is_empty()
80		.is_false()
81		.then_some(disallowed);
82
83	let mut capabilities = Capabilities::default(); // non_exhaustive: no struct literal
84
85	capabilities.room_versions = RoomVersionsCapability { available, default };
86
87	// MSC3283: deprecated displayname/avatar capabilities for pre-1.16 clients.
88	capabilities.set_displayname = SetDisplayNameCapability::new(set_displayname);
89	capabilities.set_avatar_url = SetAvatarUrlCapability::new(set_avatar_url);
90
91	// 3PID add/remove is available only when the email subsystem can send.
92	capabilities.thirdparty_id_changes =
93		ThirdPartyIdChangesCapability { enabled: services.sendmail.is_enabled() };
94
95	let enabled = services
96		.server
97		.config
98		.authentication
99		.login
100		.via_existing_session;
101
102	capabilities.get_login_token = GetLoginTokenCapability { enabled };
103
104	capabilities.profile_fields = ProfileFieldsCapability {
105		disallowed,
106		..ProfileFieldsCapability::new(true)
107	}
108	.into();
109
110	let enabled = services
111		.server
112		.config
113		.authentication
114		.login
115		.with_password;
116
117	capabilities.change_password = ChangePasswordCapability { enabled };
118
119	capabilities.forget_forced_upon_leave =
120		ForgetForcedUponLeaveCapability::new(services.config.rooms.forget_forced_upon_leave);
121
122	capabilities.set(
123		"org.matrix.msc4267.forget_forced_upon_leave",
124		json!({"enabled": services.config.rooms.forget_forced_upon_leave}),
125	)?;
126
127	// MSC4452: stable and unstable names share the media service gate.
128	set_preview_url(&mut capabilities, services.media.url_preview_enabled())?;
129
130	// MSC3664: absent rather than present-and-disabled, since a client testing
131	// for the key rather than its value would otherwise read support.
132	if services.config.push.msc3664_related_event_match {
133		capabilities.set("im.nheko.msc3664.related_event_match", json!({"enabled": true}))?;
134	}
135
136	if account_moderation {
137		capabilities.account_moderation = AccountModerationCapability::new(true, true);
138	}
139
140	// MSC4140: absent while disabled; current clients skip a delayed leave
141	// without one of the two names.
142	let delayed_events = services
143		.delayed_events
144		.limits()
145		.map(|limits| delayed_events_capability(DelayedEventsCapability::default(), limits));
146
147	capabilities.delayed_events = delayed_events;
148	capabilities.unstable_delayed_events = delayed_events;
149
150	Ok(capabilities)
151}
152
153fn set_preview_url(capabilities: &mut Capabilities, enabled: bool) -> Result {
154	let value = json!({"enabled": enabled});
155
156	capabilities.set("m.preview_url", value.clone())?;
157	capabilities.set("io.element.msc4452.preview_url", value)?;
158	Ok(())
159}
160
161// non_exhaustive: no struct literal, so the limits fill a default in place
162fn delayed_events_capability(
163	mut capability: DelayedEventsCapability,
164	limits: Limits,
165) -> DelayedEventsCapability {
166	capability.max_delay_ms = Some(UInt::new_saturating(limits.max_delay_ms));
167	capability.max_scheduled = u64::try_from(limits.max_scheduled)
168		.ok()
169		.map(UInt::new_saturating);
170
171	capability
172}
173
174#[cfg(test)]
175mod tests {
176	use super::{Capabilities, json, set_preview_url};
177
178	#[test]
179	fn preview_url_capability_names_agree() {
180		for enabled in [false, true] {
181			let mut capabilities = Capabilities::default();
182
183			set_preview_url(&mut capabilities, enabled).expect("custom capabilities");
184			let stable = capabilities
185				.get("m.preview_url")
186				.expect("stable capability");
187
188			assert_eq!(stable.as_ref(), &json!({"enabled": enabled}));
189			assert_eq!(capabilities.get("io.element.msc4452.preview_url"), Some(stable));
190		}
191	}
192}