Skip to main content

tuwunel_service/profile/
mod.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use futures::{Stream, StreamExt, future::join};
4use ruma::{
5	MxcUri, OwnedMxcUri, OwnedRoomId, RoomId, UserId,
6	api::federation::query::get_profile_information,
7	events::room::member::{MembershipState, RoomMemberEventContent},
8	profile::{ProfileFieldName, ProfileFieldValue},
9};
10use serde::Deserialize;
11use serde_json::Value;
12use tuwunel_core::{
13	Err, Result, err, extract_variant, implement,
14	matrix::PduBuilder,
15	utils::{
16		TryReadyExt,
17		future::TryExtExt,
18		stream::{IterStream, TryIgnore, automatic_width},
19	},
20	warn,
21};
22use tuwunel_database::{Deserialized, Ignore, Interfix, Json, Map};
23
24pub struct Service {
25	services: Arc<crate::services::OnceServices>,
26	useridprofilekey_value: Arc<Map>,
27}
28
29impl crate::Service for Service {
30	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
31		Ok(Arc::new(Self {
32			services: args.services.clone(),
33			useridprofilekey_value: args.db["useridprofilekey_value"].clone(),
34		}))
35	}
36
37	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
38}
39
40/// Per-update policy for fanning a global profile change out to each of
41/// the user's joined rooms as a fresh `m.room.member` event. Mirrors the
42/// MSC4466 `propagate_to` axis.
43#[derive(Copy, Clone, Debug, Eq, PartialEq)]
44pub enum Propagation {
45	/// Send a member event to every joined room.
46	All,
47
48	/// Send a member event only to rooms whose current per-room value
49	/// matches the user's prior global value; rooms with a per-room
50	/// override (e.g. set via `/myroomnick`) are skipped.
51	Unchanged,
52
53	/// Send no member events; update the global profile only.
54	None,
55}
56
57#[implement(Service)]
58pub async fn update_all_rooms(
59	&self,
60	user_id: &UserId,
61	profile_values: &[(ProfileFieldName, Option<Value>)],
62	propagation: Propagation,
63) {
64	if matches!(propagation, Propagation::None) {
65		return;
66	}
67
68	if !profile_values.iter().any(|(name, _)| {
69		matches!(name, ProfileFieldName::DisplayName | ProfileFieldName::AvatarUrl)
70	}) {
71		return;
72	}
73
74	// Suspended senders may not emit member events; OIDC, SSO, and MAS profile
75	// updates reach here without passing any suspension-blocked route.
76	if self.services.users.is_suspended(user_id).await {
77		return;
78	}
79
80	let (current_displayname, current_avatar_url) =
81		if matches!(propagation, Propagation::Unchanged) {
82			join(self.displayname(user_id).ok(), self.avatar_url(user_id).ok()).await
83		} else {
84			(None, None)
85		};
86
87	let rooms: Vec<OwnedRoomId> = self
88		.services
89		.state_cache
90		.rooms_joined(user_id)
91		.map(Into::into)
92		.collect()
93		.await;
94
95	rooms
96		.iter()
97		.stream()
98		.for_each_concurrent(automatic_width(), async |room_id| {
99			if let Err(e) = self
100				.update_room(
101					user_id,
102					room_id,
103					profile_values,
104					propagation,
105					current_displayname.as_deref(),
106					current_avatar_url.as_deref(),
107				)
108				.await
109			{
110				warn!(
111					%user_id,
112					%room_id,
113					%e,
114					"Failed to update room profile",
115				);
116			}
117		})
118		.await;
119}
120
121#[implement(Service)]
122async fn update_room(
123	&self,
124	user_id: &UserId,
125	room_id: &RoomId,
126	profile_values: &[(ProfileFieldName, Option<Value>)],
127	propagation: Propagation,
128	current_displayname: Option<&str>,
129	current_avatar_url: Option<&MxcUri>,
130) -> Result {
131	let unchanged = match propagation {
132		| Propagation::All => false,
133		| Propagation::Unchanged => true,
134		| Propagation::None => return Ok(()),
135	};
136
137	let mut content = self
138		.services
139		.state_accessor
140		.get_member(room_id, user_id)
141		.await?;
142
143	if !matches!(content.membership, MembershipState::Join) {
144		return Ok(());
145	}
146
147	let mut changed = false;
148
149	for (name, value) in profile_values {
150		match name {
151			| ProfileFieldName::DisplayName => {
152				if unchanged && content.displayname.as_deref() != current_displayname {
153					continue;
154				}
155
156				let displayname = value.clone().map(|value| {
157					extract_variant!(value, Value::String).expect("invalid profile value type")
158				});
159
160				content.displayname = displayname;
161
162				changed = true;
163			},
164			| ProfileFieldName::AvatarUrl => {
165				if unchanged && content.avatar_url.as_deref() != current_avatar_url {
166					continue;
167				}
168
169				let avatar_url = value.clone().map(|value| {
170					serde_json::from_value(value).expect("invalid profile value type")
171				});
172
173				content.avatar_url = avatar_url;
174
175				changed = true;
176			},
177			| _ => {},
178		}
179	}
180
181	if !changed {
182		return Ok(());
183	}
184
185	content.reason = None;
186
187	let state_lock = self.services.state.mutex.lock(room_id).await;
188
189	self.services
190		.timeline
191		.build_and_append_pdu(
192			PduBuilder::state(user_id.as_str(), &content),
193			user_id,
194			room_id,
195			&state_lock,
196		)
197		.await?;
198
199	Ok(())
200}
201
202/// Sets a new displayname or removes it if displayname is None. You still
203/// need to notify all rooms of this change.
204#[implement(Service)]
205pub async fn set_displayname(
206	&self,
207	user_id: &UserId,
208	displayname: Option<&str>,
209	propagation: Option<Propagation>,
210) -> Result {
211	self.set_profile_keys(
212		user_id,
213		&[(
214			ProfileFieldName::DisplayName,
215			displayname.map(|displayname| {
216				serde_json::to_value(displayname).expect("displayname serialization cannot fail")
217			}),
218		)],
219		propagation,
220	)
221	.await
222}
223
224/// Returns the displayname of a user on this homeserver.
225#[implement(Service)]
226pub async fn displayname(&self, user_id: &UserId) -> Result<String> {
227	self.profile_key(user_id, &ProfileFieldName::DisplayName)
228		.await
229}
230
231/// Sets a new avatar_url or removes it if avatar_url is None.
232#[implement(Service)]
233pub async fn set_avatar_url(
234	&self,
235	user_id: &UserId,
236	avatar_url: Option<&MxcUri>,
237	propagation: Option<Propagation>,
238) -> Result {
239	self.set_profile_keys(
240		user_id,
241		&[(
242			ProfileFieldName::AvatarUrl,
243			avatar_url.map(|avatar_url| {
244				serde_json::to_value(avatar_url).expect("avatar url serialization cannot fail")
245			}),
246		)],
247		propagation,
248	)
249	.await
250}
251
252/// Get the `avatar_url` of a user.
253#[implement(Service)]
254pub async fn avatar_url(&self, user_id: &UserId) -> Result<OwnedMxcUri> {
255	self.profile_key(user_id, &ProfileFieldName::AvatarUrl)
256		.await
257}
258
259/// Sets a new timezone or removes it if timezone is None.
260#[implement(Service)]
261pub async fn set_timezone(
262	&self,
263	user_id: &UserId,
264	timezone: Option<&str>,
265	propagation: Option<Propagation>,
266) -> Result {
267	self.set_profile_keys(
268		user_id,
269		&[(
270			ProfileFieldName::TimeZone,
271			timezone.map(|timezone| {
272				serde_json::to_value(timezone).expect("timezone serialization cannot fail")
273			}),
274		)],
275		propagation,
276	)
277	.await
278}
279
280/// Get the timezone of a user.
281#[implement(Service)]
282pub async fn timezone(&self, user_id: &UserId) -> Result<String> {
283	self.profile_key(user_id, &ProfileFieldName::TimeZone)
284		.await
285}
286
287/// Gets all the user's profile keys and values in an iterator
288#[implement(Service)]
289pub fn all_profile_keys(&self, user_id: &UserId) -> impl Stream<Item = ProfileFieldValue> + Send {
290	let prefix = (user_id, Interfix);
291	self.useridprofilekey_value
292		.stream_prefix(&prefix)
293		.ignore_err()
294		.map(move |((_, key), Json(val)): ((Ignore, _), _)| {
295			ProfileFieldValue::new(key, val).map_err(|_| {
296				err!(Database(
297					error!(%user_id, %key, "Invalid json in database profile value while iterating")
298				))
299			})
300		})
301		.ignore_err()
302}
303
304#[implement(Service)]
305pub async fn clear_profile_keys(&self, user_id: &UserId) {
306	let prefix = (user_id, Interfix);
307
308	self.useridprofilekey_value
309		.keys_prefix_raw(&prefix)
310		.ready_try_for_each(|key| {
311			self.useridprofilekey_value.remove(key);
312			Ok(())
313		})
314		.await
315		.ok();
316}
317
318/// Sets new profile key values, removes the key if value is None
319#[implement(Service)]
320pub async fn set_profile_keys(
321	&self,
322	user_id: &UserId,
323	profile_values: &[(ProfileFieldName, Option<Value>)],
324	propagation: Option<Propagation>,
325) -> Result {
326	if self.services.globals.user_is_local(user_id) {
327		for (name, value) in profile_values {
328			check_profile_key(name.as_str())?;
329
330			if let Some(value) = value {
331				self.enforce_profile_size(user_id, name.as_str(), value)
332					.await?;
333			}
334		}
335	}
336
337	let propagation = propagation.unwrap_or(
338		if self
339			.services
340			.config
341			.preserve_room_profile_overrides
342		{
343			Propagation::Unchanged
344		} else {
345			Propagation::All
346		},
347	);
348
349	if !matches!(propagation, Propagation::None) && self.services.globals.user_is_local(user_id) {
350		self.update_all_rooms(user_id, profile_values, propagation)
351			.await;
352	}
353
354	for (name, value) in profile_values {
355		let key = (user_id, name.as_str());
356
357		if let Some(value) = value {
358			self.useridprofilekey_value.put(key, Json(value));
359		} else {
360			self.useridprofilekey_value.del(key);
361		}
362	}
363
364	Ok(())
365}
366
367/// Gets a specific user profile key
368#[implement(Service)]
369pub async fn profile_key<T>(&self, user_id: &UserId, profile_key: &ProfileFieldName) -> Result<T>
370where
371	T: for<'de> Deserialize<'de> + Send,
372{
373	let key = (user_id, profile_key);
374	let Json(value) = self
375		.useridprofilekey_value
376		.qry(&key)
377		.await
378		.map_err(|_| err!(Request(NotFound("The requested profile key does not exist."))))?
379		.deserialized()
380		.map_err(|_| err!(Database("Cannot deserialize database profile value")))?;
381
382	Ok(value)
383}
384
385#[implement(Service)]
386pub async fn fill_profile_data(&self, user_id: &UserId, content: &mut RoomMemberEventContent) {
387	let displayname = self.displayname(user_id).ok();
388	let avatar_url = self.avatar_url(user_id).ok();
389
390	let (displayname, avatar_url) = join(displayname, avatar_url).await;
391
392	content.displayname = displayname;
393	content.avatar_url = avatar_url;
394}
395
396#[implement(Service)]
397pub async fn fetch_remote_profile(&self, user_id: &UserId) -> Result {
398	assert!(
399		!self.services.globals.user_is_local(user_id),
400		"fetch remote profile called with a local user"
401	);
402
403	if let Ok(response) = self
404		.services
405		.federation
406		.execute(user_id.server_name(), get_profile_information::v1::Request {
407			user_id: user_id.to_owned(),
408			field: None,
409		})
410		.await
411	{
412		if !self.services.users.exists(user_id).await {
413			self.services
414				.users
415				.create(user_id, None, None)
416				.await?;
417		}
418
419		for (key, value) in response.iter() {
420			self.set_profile_keys(
421				user_id,
422				&[(key.as_str().into(), Some(value.clone()))],
423				Some(Propagation::None),
424			)
425			.await?;
426		}
427	}
428
429	Ok(())
430}
431
432/// MSC4133 maximum total profile size (64 KiB), measured over the JSON of the
433/// full profile including displayname and avatar_url.
434pub(super) const MAX_PROFILE_SIZE: usize = 65_536;
435
436/// MSC4133: reject a prospective profile write that would push the full
437/// profile over the 64 KiB cap. `value` is what `key` will hold after the
438/// write; a removal cannot grow the profile, so callers skip it.
439#[implement(Service)]
440async fn enforce_profile_size(&self, user_id: &UserId, key: &str, value: &Value) -> Result {
441	let mut profile: BTreeMap<_, _> = self
442		.all_profile_keys(user_id)
443		.map(|profile_value| {
444			(
445				profile_value.field_name().as_str().to_owned(),
446				profile_value.value().into_owned(),
447			)
448		})
449		.collect()
450		.await;
451	profile.insert(key.to_owned(), value.clone());
452
453	let profile_size = serde_json::to_vec(&profile).map_or(0, |buf| buf.len());
454
455	if profile_size > MAX_PROFILE_SIZE {
456		return Err!(Request(ProfileTooLarge(
457			"Profile would exceed the maximum size of 64 KiB."
458		)));
459	}
460
461	Ok(())
462}
463
464/// MSC4133 maximum profile field-name length, in bytes.
465const MAX_KEY_LENGTH: usize = 255;
466
467/// Validate a profile field name against the Common Namespaced Identifier
468/// Grammar: a lowercase-leading identifier over `[a-z0-9_.-]`, matching the
469/// reference homeserver. Length is bounded separately by `MAX_KEY_LENGTH`.
470fn check_profile_key(name: &str) -> Result {
471	if name.len() > MAX_KEY_LENGTH {
472		return Err!(Request(KeyTooLarge("Profile key names cannot be longer than 255 bytes.")));
473	}
474
475	let ok = name
476		.bytes()
477		.next()
478		.is_some_and(|b| b.is_ascii_lowercase())
479		&& name.bytes().all(|b| {
480			b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
481		});
482
483	if !ok {
484		return Err!(Request(BadJson(
485			"Profile key names must follow the Common Namespaced Identifier Grammar."
486		)));
487	}
488
489	Ok(())
490}