Skip to main content

tuwunel_api/client/
profile.rs

1use std::collections::BTreeMap;
2
3use axum::extract::State;
4use futures::{
5	StreamExt,
6	future::{join, join4},
7};
8use ruma::{
9	MxcUri, OwnedRoomId,
10	api::{
11		client::profile::{
12			PropagateTo, get_avatar_url, get_display_name, get_profile, set_avatar_url,
13			set_display_name,
14		},
15		federation::query::get_profile_information,
16	},
17	presence::PresenceState,
18};
19use serde_json::Value as JsonValue;
20use tuwunel_core::{Err, Result, utils::future::TryExtExt};
21use tuwunel_service::users::{Propagation, propagation_default};
22
23use crate::{ClientIp, Ruma};
24
25pub(super) type ProfileResponse = get_profile_information::v1::Response;
26
27/// Pull a string field out of a federation profile-info response. The body
28/// shape switched from explicit fields to a flat `BTreeMap<String, JsonValue>`
29/// once extended profile fields stabilised.
30pub(super) fn profile_str<'a>(resp: &'a ProfileResponse, field: &str) -> Option<&'a str> {
31	resp.get(field).and_then(JsonValue::as_str)
32}
33
34pub(super) fn profile_mxc<'a>(resp: &'a ProfileResponse, field: &str) -> Option<&'a MxcUri> {
35	profile_str(resp, field).map(<&MxcUri>::from)
36}
37
38/// Resolve a `PropagateTo` request value against the server default.
39///
40/// MSC4466's `_Custom` variant is treated as the server default so
41/// unknown values do not silently change behavior.
42pub(super) fn resolve_propagation(
43	propagate_to: &PropagateTo,
44	server_default: Propagation,
45) -> Propagation {
46	match propagate_to {
47		| PropagateTo::All => Propagation::All,
48		| PropagateTo::Unchanged => Propagation::Unchanged,
49		| PropagateTo::None => Propagation::None,
50		| _ => server_default,
51	}
52}
53
54/// # `PUT /_matrix/client/r0/profile/{userId}/displayname`
55///
56/// Updates the displayname.
57///
58/// - Also makes sure other users receive the update using presence EDUs
59pub(crate) async fn set_displayname_route(
60	State(services): State<crate::State>,
61	ClientIp(client): ClientIp,
62	body: Ruma<set_display_name::v3::Request>,
63) -> Result<set_display_name::v3::Response> {
64	let sender_user = body.sender_user();
65
66	if *sender_user != body.user_id && body.appservice_info.is_none() {
67		return Err!(Request(Forbidden("You cannot update the profile of another user")));
68	}
69
70	let all_joined_rooms: Vec<OwnedRoomId> = services
71		.state_cache
72		.rooms_joined(&body.user_id)
73		.map(ToOwned::to_owned)
74		.collect()
75		.await;
76
77	let propagation = resolve_propagation(
78		&body.propagate_to,
79		propagation_default(
80			services
81				.server
82				.config
83				.preserve_room_profile_overrides,
84		),
85	);
86
87	services
88		.users
89		.update_displayname(
90			&body.user_id,
91			body.displayname.as_deref(),
92			&all_joined_rooms,
93			propagation,
94		)
95		.await;
96
97	// Presence update
98	services
99		.presence
100		.maybe_ping_presence(
101			&body.user_id,
102			body.sender_device.as_deref(),
103			Some(client),
104			&PresenceState::Online,
105		)
106		.await?;
107
108	Ok(set_display_name::v3::Response {})
109}
110
111/// # `GET /_matrix/client/v3/profile/{userId}/displayname`
112///
113/// Returns the displayname of the user.
114///
115/// - If user is on another server and we do not have a local copy already fetch
116///   displayname over federation
117pub(crate) async fn get_displayname_route(
118	State(services): State<crate::State>,
119	body: Ruma<get_display_name::v3::Request>,
120) -> Result<get_display_name::v3::Response> {
121	if !services.globals.user_is_local(&body.user_id) {
122		// Create and update our local copy of the user
123		if let Ok(response) = services
124			.federation
125			.execute(body.user_id.server_name(), get_profile_information::v1::Request {
126				user_id: body.user_id.clone(),
127				field: None, // we want the full user's profile to update locally too
128			})
129			.await
130		{
131			if !services.users.exists(&body.user_id).await {
132				services
133					.users
134					.create(&body.user_id, None, None)
135					.await?;
136			}
137
138			let displayname = profile_str(&response, "displayname");
139			services
140				.users
141				.set_displayname(&body.user_id, displayname);
142			services
143				.users
144				.set_avatar_url(&body.user_id, profile_mxc(&response, "avatar_url"));
145			services
146				.users
147				.set_blurhash(&body.user_id, profile_str(&response, "blurhash"));
148
149			return Ok(get_display_name::v3::Response {
150				displayname: displayname.map(str::to_owned),
151			});
152		}
153	}
154
155	if !services.users.exists(&body.user_id).await {
156		// Return 404 if this user doesn't exist and we couldn't fetch it over
157		// federation
158		return Err!(Request(NotFound("Profile was not found.")));
159	}
160
161	Ok(get_display_name::v3::Response {
162		displayname: services
163			.users
164			.displayname(&body.user_id)
165			.await
166			.ok(),
167	})
168}
169
170/// # `PUT /_matrix/client/v3/profile/{userId}/avatar_url`
171///
172/// Updates the `avatar_url` and `blurhash`.
173///
174/// - Also makes sure other users receive the update using presence EDUs
175pub(crate) async fn set_avatar_url_route(
176	State(services): State<crate::State>,
177	ClientIp(client): ClientIp,
178	body: Ruma<set_avatar_url::v3::Request>,
179) -> Result<set_avatar_url::v3::Response> {
180	let sender_user = body.sender_user();
181
182	if *sender_user != body.user_id && body.appservice_info.is_none() {
183		return Err!(Request(Forbidden("You cannot update the profile of another user")));
184	}
185
186	let all_joined_rooms: Vec<OwnedRoomId> = services
187		.state_cache
188		.rooms_joined(&body.user_id)
189		.map(ToOwned::to_owned)
190		.collect()
191		.await;
192
193	let propagation = resolve_propagation(
194		&body.propagate_to,
195		propagation_default(
196			services
197				.server
198				.config
199				.preserve_room_profile_overrides,
200		),
201	);
202
203	services
204		.users
205		.update_avatar_url(
206			&body.user_id,
207			body.avatar_url.as_deref(),
208			body.blurhash.as_deref(),
209			&all_joined_rooms,
210			propagation,
211		)
212		.await;
213
214	// Presence update
215	services
216		.presence
217		.maybe_ping_presence(
218			&body.user_id,
219			body.sender_device.as_deref(),
220			Some(client),
221			&PresenceState::Online,
222		)
223		.await
224		.ok();
225
226	Ok(set_avatar_url::v3::Response {})
227}
228
229/// # `GET /_matrix/client/v3/profile/{userId}/avatar_url`
230///
231/// Returns the `avatar_url` and `blurhash` of the user.
232///
233/// - If user is on another server and we do not have a local copy already fetch
234///   `avatar_url` and blurhash over federation
235pub(crate) async fn get_avatar_url_route(
236	State(services): State<crate::State>,
237	body: Ruma<get_avatar_url::v3::Request>,
238) -> Result<get_avatar_url::v3::Response> {
239	if !services.globals.user_is_local(&body.user_id) {
240		// Create and update our local copy of the user
241		if let Ok(response) = services
242			.federation
243			.execute(body.user_id.server_name(), get_profile_information::v1::Request {
244				user_id: body.user_id.clone(),
245				field: None, // we want the full user's profile to update locally as well
246			})
247			.await
248		{
249			if !services.users.exists(&body.user_id).await {
250				services
251					.users
252					.create(&body.user_id, None, None)
253					.await?;
254			}
255
256			let avatar_url = profile_mxc(&response, "avatar_url");
257			let blurhash = profile_str(&response, "blurhash");
258			services
259				.users
260				.set_displayname(&body.user_id, profile_str(&response, "displayname"));
261			services
262				.users
263				.set_avatar_url(&body.user_id, avatar_url);
264			services
265				.users
266				.set_blurhash(&body.user_id, blurhash);
267
268			return Ok(get_avatar_url::v3::Response {
269				avatar_url: avatar_url.map(ToOwned::to_owned),
270				blurhash: blurhash.map(str::to_owned),
271			});
272		}
273	}
274
275	if !services.users.exists(&body.user_id).await {
276		// Return 404 if this user doesn't exist and we couldn't fetch it over
277		// federation
278		return Err!(Request(NotFound("Profile was not found.")));
279	}
280
281	let (avatar_url, blurhash) = join(
282		services.users.avatar_url(&body.user_id).ok(),
283		services.users.blurhash(&body.user_id).ok(),
284	)
285	.await;
286
287	Ok(get_avatar_url::v3::Response { avatar_url, blurhash })
288}
289
290/// # `GET /_matrix/client/v3/profile/{userId}`
291///
292/// Returns the displayname, avatar_url, blurhash, and tz of the user.
293///
294/// - If user is on another server and we do not have a local copy already,
295///   fetch profile over federation.
296pub(crate) async fn get_profile_route(
297	State(services): State<crate::State>,
298	body: Ruma<get_profile::v3::Request>,
299) -> Result<get_profile::v3::Response> {
300	const CANONICAL_FIELDS: &[&str] = &["avatar_url", "blurhash", "displayname", "m.tz"];
301
302	if !services.globals.user_is_local(&body.user_id) {
303		// Create and update our local copy of the user
304		if let Ok(response) = services
305			.federation
306			.execute(body.user_id.server_name(), get_profile_information::v1::Request {
307				user_id: body.user_id.clone(),
308				field: None,
309			})
310			.await
311		{
312			if !services.users.exists(&body.user_id).await {
313				services
314					.users
315					.create(&body.user_id, None, None)
316					.await?;
317			}
318
319			services
320				.users
321				.set_displayname(&body.user_id, profile_str(&response, "displayname"));
322			services
323				.users
324				.set_avatar_url(&body.user_id, profile_mxc(&response, "avatar_url"));
325			services
326				.users
327				.set_blurhash(&body.user_id, profile_str(&response, "blurhash"));
328			services
329				.users
330				.set_timezone(&body.user_id, profile_str(&response, "m.tz"));
331
332			for (key, value) in response.iter() {
333				if CANONICAL_FIELDS.contains(&key.as_str()) {
334					continue;
335				}
336				services
337					.users
338					.set_profile_key(&body.user_id, key, Some(value));
339			}
340
341			return Ok(response
342				.iter()
343				.map(|(key, val)| (key.clone(), val.clone()))
344				.collect::<get_profile::v3::Response>());
345		}
346	}
347
348	if !services.users.exists(&body.user_id).await {
349		// Return 404 if this user doesn't exist and we couldn't fetch it over
350		// federation
351		return Err!(Request(NotFound("Profile was not found.")));
352	}
353
354	let mut custom_profile_fields: BTreeMap<String, _> = services
355		.users
356		.all_profile_keys(&body.user_id)
357		.collect()
358		.await;
359
360	// services.users.timezone will collect the MSC4175 timezone key if it exists
361	custom_profile_fields.remove("us.cloke.msc4175.tz");
362	custom_profile_fields.remove("m.tz");
363
364	let (avatar_url, blurhash, displayname, tz) = join4(
365		services.users.avatar_url(&body.user_id).ok(),
366		services.users.blurhash(&body.user_id).ok(),
367		services.users.displayname(&body.user_id).ok(),
368		services.users.timezone(&body.user_id).ok(),
369	)
370	.await;
371
372	let canonical_fields = [
373		("avatar_url", avatar_url.map(Into::into)),
374		("blurhash", blurhash),
375		("displayname", displayname),
376		("m.tz", tz),
377	];
378
379	Ok(canonical_fields
380		.into_iter()
381		.map(|(key, val)| (key.to_owned(), val))
382		.filter_map(|(key, val)| {
383			val.map(serde_json::to_value)
384				.transpose()
385				.ok()
386				.flatten()
387				.map(|val| (key, val))
388		})
389		.chain(
390			custom_profile_fields
391				.into_iter()
392				.filter_map(|(key, val)| {
393					serde_json::to_value(val.json())
394						.map(|val| (key, val))
395						.ok()
396				}),
397		)
398		.collect())
399}