Skip to main content

tuwunel_service/profile/
mod.rs

1mod remote;
2#[cfg(test)]
3mod tests;
4
5use std::{borrow::Cow, collections::BTreeMap, iter::once, mem::replace, sync::Arc};
6
7use futures::{
8	Stream, StreamExt, TryStreamExt,
9	future::{Either, join},
10	stream::empty,
11};
12use ruma::{
13	MxcUri, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UserId,
14	api::federation::query::get_profile_information,
15	events::room::member::{MembershipState, RoomMemberEventContent},
16	profile::{ProfileFieldName, ProfileFieldValue},
17};
18use serde::Deserialize;
19use serde_json::Value;
20use tuwunel_core::{
21	Err, Result, err, extract_variant, implement,
22	matrix::PduBuilder,
23	smallvec::SmallVec,
24	utils::{
25		MutexMap, MutexMapGuard, ReadyExt,
26		future::TryExtExt,
27		result::NotFound,
28		stream::{IterStream, TryIgnore, TryReadyExt, automatic_width},
29	},
30	warn,
31};
32use tuwunel_database::{
33	Deserialized, Ignore, Interfix, Json, KeyVal, Map, Txn, deserialize_from_slice, serialize_key,
34};
35
36type ProfileLock = MutexMapGuard<OwnedUserId, ()>;
37
38pub struct Service {
39	mutex: MutexMap<OwnedUserId, ()>,
40	services: Arc<crate::services::OnceServices>,
41	profilechangeid_userid: Arc<Map>,
42	useridprofilekey_value: Arc<Map>,
43}
44
45impl crate::Service for Service {
46	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
47		Ok(Arc::new(Self {
48			mutex: MutexMap::new(),
49			services: args.services.clone(),
50			profilechangeid_userid: args.db["profilechangeid_userid"].clone(),
51			useridprofilekey_value: args.db["useridprofilekey_value"].clone(),
52		}))
53	}
54
55	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
56}
57
58/// One logged profile write: the user whose profile was written, and the name
59/// of a field the write logged.
60///
61/// Both members borrow the database cursor that produced them, so a consumer
62/// retaining either past the cursor's next advance must own it first.
63pub type ProfileChange<'a> = (&'a UserId, &'a str);
64
65/// A row of the profile change log: the field's name rides the key so that a
66/// write covering several fields needs one count, and the value names the user
67/// for the rows keyed by room.
68type ChangeKeyVal<'a> = KeyVal<'a, (&'a str, u64, &'a str), &'a UserId>;
69
70/// One field of a profile write: the name and the value to store, or `None`
71/// to clear it.
72type ProfileValue = (ProfileFieldName, Option<Value>);
73
74/// The field names one write actually changes, almost always just the one the
75/// caller named.
76type ChangedFields<'a> = SmallVec<[&'a str; 1]>;
77
78/// The field names a profile holds when it is cleared.
79///
80/// A profile commonly holds only the two canonical fields, which sizes the
81/// inline budget.
82type ClearedFields = SmallVec<[ProfileFieldName; 2]>;
83
84/// The stored profile as it would read after one candidate field is written.
85type ProspectiveProfile<'a> = BTreeMap<ProfileFieldName, Cow<'a, Value>>;
86
87/// MSC4426 maximum `m.status` text length, in bytes.
88const MAX_STATUS_TEXT_LENGTH: usize = 256;
89
90/// MSC4426 maximum `m.status` emoji length, in bytes.
91const MAX_STATUS_EMOJI_LENGTH: usize = 32;
92
93/// Per-update policy for fanning a global profile change out to each of
94/// the user's joined rooms as a fresh `m.room.member` event. Mirrors the
95/// MSC4466 `propagate_to` axis.
96#[derive(Copy, Clone, Debug, Eq, PartialEq)]
97pub enum Propagation {
98	/// Send a member event to every joined room.
99	All,
100
101	/// Send a member event only to rooms whose current per-room value
102	/// matches the user's prior global value; rooms with a per-room
103	/// override (e.g. set via `/myroomnick`) are skipped.
104	Unchanged,
105
106	/// Send no member events; update the global profile only.
107	None,
108}
109
110#[implement(Service)]
111pub async fn update_all_rooms(
112	&self,
113	user_id: &UserId,
114	profile_values: &[(ProfileFieldName, Option<Value>)],
115	propagation: Propagation,
116) {
117	if matches!(propagation, Propagation::None) {
118		return;
119	}
120
121	if !profile_values.iter().any(|(name, _)| {
122		matches!(name, ProfileFieldName::DisplayName | ProfileFieldName::AvatarUrl)
123	}) {
124		return;
125	}
126
127	// Suspended senders may not emit member events; OIDC, SSO, and MAS profile
128	// updates reach here without passing any suspension-blocked route.
129	if self.services.users.is_suspended(user_id).await {
130		return;
131	}
132
133	let (current_displayname, current_avatar_url) =
134		if matches!(propagation, Propagation::Unchanged) {
135			join(self.displayname(user_id).ok(), self.avatar_url(user_id).ok()).await
136		} else {
137			(None, None)
138		};
139
140	let rooms: Vec<OwnedRoomId> = self
141		.services
142		.state_cache
143		.rooms_joined(user_id)
144		.map(Into::into)
145		.collect()
146		.await;
147
148	rooms
149		.iter()
150		.stream()
151		.for_each_concurrent(automatic_width(), async |room_id| {
152			if let Err(e) = self
153				.update_room(
154					user_id,
155					room_id,
156					profile_values,
157					propagation,
158					current_displayname.as_deref(),
159					current_avatar_url.as_deref(),
160				)
161				.await
162			{
163				warn!(
164					%user_id,
165					%room_id,
166					%e,
167					"Failed to update room profile",
168				);
169			}
170		})
171		.await;
172}
173
174#[implement(Service)]
175async fn update_room(
176	&self,
177	user_id: &UserId,
178	room_id: &RoomId,
179	profile_values: &[(ProfileFieldName, Option<Value>)],
180	propagation: Propagation,
181	current_displayname: Option<&str>,
182	current_avatar_url: Option<&MxcUri>,
183) -> Result {
184	let unchanged = match propagation {
185		| Propagation::All => false,
186		| Propagation::Unchanged => true,
187		| Propagation::None => return Ok(()),
188	};
189
190	// Held from the read so a membership change landing between the two
191	// cannot be overwritten by the append.
192	let state_lock = self.services.state.mutex.lock(room_id).await;
193
194	let content = self
195		.services
196		.state_accessor
197		.get_member(room_id, user_id)
198		.await?;
199
200	if !matches!(content.membership, MembershipState::Join) {
201		return Ok(());
202	}
203
204	let Some(content) =
205		apply_fields(content, profile_values, unchanged, current_displayname, current_avatar_url)
206	else {
207		return Ok(());
208	};
209
210	self.services
211		.timeline
212		.build_and_append_pdu(
213			PduBuilder::state(user_id.as_str(), &content),
214			user_id,
215			room_id,
216			&state_lock,
217		)
218		.await?;
219
220	Ok(())
221}
222
223/// Lays a profile write over a room's member content.
224///
225/// Returns the content only when a field differs from what the room holds,
226/// so a write restoring the stored value emits no member event. Under
227/// `Propagation::Unchanged` a room whose value departs from the user's prior
228/// global value keeps its override.
229fn apply_fields(
230	content: RoomMemberEventContent,
231	profile_values: &[ProfileValue],
232	unchanged: bool,
233	current_displayname: Option<&str>,
234	current_avatar_url: Option<&MxcUri>,
235) -> Option<RoomMemberEventContent> {
236	let mut content = RoomMemberEventContent { reason: None, ..content };
237	let mut changed = false;
238
239	for (name, value) in profile_values {
240		match name {
241			| ProfileFieldName::DisplayName
242				if !unchanged || content.displayname.as_deref() == current_displayname =>
243			{
244				let displayname = value.clone().map(|value| {
245					extract_variant!(value, Value::String).expect("invalid profile value type")
246				});
247
248				changed |= assign(&mut content.displayname, displayname);
249			},
250			| ProfileFieldName::AvatarUrl
251				if !unchanged || content.avatar_url.as_deref() == current_avatar_url =>
252			{
253				let avatar_url = value.clone().map(|value| {
254					serde_json::from_value(value).expect("invalid profile value type")
255				});
256
257				changed |= assign(&mut content.avatar_url, avatar_url);
258			},
259			| _ => {},
260		}
261	}
262
263	changed.then_some(content)
264}
265
266fn assign<T: PartialEq>(slot: &mut Option<T>, next: Option<T>) -> bool {
267	replace(slot, next).ne(slot)
268}
269
270/// Sets a new displayname or removes it if displayname is None. You still
271/// need to notify all rooms of this change.
272#[implement(Service)]
273pub async fn set_displayname(
274	&self,
275	user_id: &UserId,
276	displayname: Option<&str>,
277	propagation: Option<Propagation>,
278) -> Result {
279	self.set_profile_keys(
280		user_id,
281		&[(
282			ProfileFieldName::DisplayName,
283			displayname.map(|displayname| {
284				serde_json::to_value(displayname).expect("displayname serialization cannot fail")
285			}),
286		)],
287		propagation,
288	)
289	.await
290}
291
292/// Returns the displayname of a user on this homeserver.
293#[implement(Service)]
294pub async fn displayname(&self, user_id: &UserId) -> Result<String> {
295	self.profile_key(user_id, &ProfileFieldName::DisplayName)
296		.await
297}
298
299/// Sets a new avatar_url or removes it if avatar_url is None.
300#[implement(Service)]
301pub async fn set_avatar_url(
302	&self,
303	user_id: &UserId,
304	avatar_url: Option<&MxcUri>,
305	propagation: Option<Propagation>,
306) -> Result {
307	self.set_profile_keys(
308		user_id,
309		&[(
310			ProfileFieldName::AvatarUrl,
311			avatar_url.map(|avatar_url| {
312				serde_json::to_value(avatar_url).expect("avatar url serialization cannot fail")
313			}),
314		)],
315		propagation,
316	)
317	.await
318}
319
320/// Get the `avatar_url` of a user.
321#[implement(Service)]
322pub async fn avatar_url(&self, user_id: &UserId) -> Result<OwnedMxcUri> {
323	self.profile_key(user_id, &ProfileFieldName::AvatarUrl)
324		.await
325}
326
327/// Sets a new timezone or removes it if timezone is None.
328#[implement(Service)]
329pub async fn set_timezone(
330	&self,
331	user_id: &UserId,
332	timezone: Option<&str>,
333	propagation: Option<Propagation>,
334) -> Result {
335	self.set_profile_keys(
336		user_id,
337		&[(
338			ProfileFieldName::TimeZone,
339			timezone.map(|timezone| {
340				serde_json::to_value(timezone).expect("timezone serialization cannot fail")
341			}),
342		)],
343		propagation,
344	)
345	.await
346}
347
348/// Get the timezone of a user.
349#[implement(Service)]
350pub async fn timezone(&self, user_id: &UserId) -> Result<String> {
351	self.profile_key(user_id, &ProfileFieldName::TimeZone)
352		.await
353}
354
355/// Streams every stored profile field.
356///
357/// A field whose stored value no longer parses is dropped; `try_all_profile_keys`
358/// surfaces it instead.
359#[implement(Service)]
360pub fn all_profile_keys(&self, user_id: &UserId) -> impl Stream<Item = ProfileFieldValue> + Send {
361	self.try_all_profile_keys(user_id).ignore_err()
362}
363
364/// Streams every stored profile field, surfacing storage and decoding errors.
365///
366/// A stored value that no longer parses as a profile field is an error item
367/// rather than a dropped one, so a caller validating the whole profile can
368/// refuse to proceed on it.
369#[implement(Service)]
370pub fn try_all_profile_keys(
371	&self,
372	user_id: &UserId,
373) -> impl Stream<Item = Result<ProfileFieldValue>> + Send {
374	let prefix = (user_id, Interfix);
375
376	self.useridprofilekey_value
377		.stream_prefix(&prefix)
378		.ready_and_then(move |((_, key), Json(val)): ((Ignore, &str), Json<Value>)| {
379			ProfileFieldValue::new(key, val).map_err(|_| {
380				err!(Database(error!(%user_id, %key, "Invalid json in database profile value")))
381			})
382		})
383}
384
385/// Streams the names of the fields a user's profile holds.
386///
387/// The name rides the key, so a caller after names alone skips deserializing
388/// every value the way `all_profile_keys` must.
389#[implement(Service)]
390pub fn profile_field_names(
391	&self,
392	user_id: &UserId,
393) -> impl Stream<Item = ProfileFieldName> + Send {
394	self.try_profile_field_names(user_id).ignore_err()
395}
396
397/// Enumerates current profile field names, preserving storage and decoding errors.
398///
399/// Sync bases must finish this inventory before acknowledging profile delivery.
400#[implement(Service)]
401pub fn try_profile_field_names(
402	&self,
403	user_id: &UserId,
404) -> impl Stream<Item = Result<ProfileFieldName>> + Send {
405	let prefix = (user_id, Interfix);
406
407	self.useridprofilekey_value
408		.keys_prefix(&prefix)
409		.map_ok(|(_, name): (Ignore, &str)| name.into())
410}
411
412/// Clears every stored profile field and propagates canonical removals.
413///
414/// Member events are rewritten only for a local user, before the removals and
415/// their change rows commit together under the profile lock. A preparation
416/// error leaves storage unchanged but does not undo emitted member events.
417#[implement(Service)]
418pub async fn clear_profile_keys(&self, user_id: &UserId) -> Result {
419	let _profile_lock = self.mutex.lock(user_id).await;
420
421	let prefix = (user_id, Interfix);
422	let fields: ClearedFields = self
423		.useridprofilekey_value
424		.keys_prefix(&prefix)
425		.map_ok(|(_, field): (Ignore, &str)| field.into())
426		.try_collect()
427		.await?;
428
429	if self.services.globals.user_is_local(user_id) {
430		self.update_all_rooms(
431			user_id,
432			&[(ProfileFieldName::DisplayName, None), (ProfileFieldName::AvatarUrl, None)],
433			Propagation::All,
434		)
435		.await;
436	}
437
438	let txn = fields
439		.iter()
440		.fold(self.services.db.txn(), |mut txn, field| {
441			txn.del(&self.useridprofilekey_value, (user_id, field.as_str()));
442			txn
443		});
444
445	let rooms = || {
446		self.services
447			.state_cache
448			.rooms_joined_checked(user_id)
449	};
450
451	self.publish_update(user_id, &fields, &fields, txn, rooms)
452		.await
453}
454
455/// Sets profile field values, removing a field whose value is `None`.
456///
457/// Member events are rewritten first, then the stored fields and their change
458/// rows commit together under the profile lock. A preparation error leaves
459/// storage unchanged but does not undo emitted member events.
460#[implement(Service)]
461pub async fn set_profile_keys(
462	&self,
463	user_id: &UserId,
464	profile_values: &[(ProfileFieldName, Option<Value>)],
465	propagation: Option<Propagation>,
466) -> Result {
467	let profile_lock = self.mutex.lock(user_id).await;
468
469	self.set_profile_keys_locked(&profile_lock, user_id, profile_values, propagation)
470		.await
471}
472
473/// Sets profile field values under a profile lock the caller already holds.
474///
475/// A caller that must read the stored profile and write it back without an
476/// interleaved writer takes the lock once and passes it here.
477#[implement(Service)]
478async fn set_profile_keys_locked(
479	&self,
480	_profile_lock: &ProfileLock,
481	user_id: &UserId,
482	profile_values: &[(ProfileFieldName, Option<Value>)],
483	propagation: Option<Propagation>,
484) -> Result {
485	let local = self.services.globals.user_is_local(user_id);
486
487	if local {
488		for (name, value) in profile_values {
489			check_profile_key(name.as_str())?;
490
491			if let Some(value) = value {
492				check_profile_value(name.as_str(), value)?;
493				self.enforce_profile_size(user_id, name.as_str(), value)
494					.await?;
495			}
496		}
497	}
498
499	let changed = self
500		.changed_fields(user_id, profile_values)
501		.await?;
502
503	let propagation = propagation.unwrap_or(
504		if self
505			.services
506			.config
507			.preserve_room_profile_overrides
508		{
509			Propagation::Unchanged
510		} else {
511			Propagation::All
512		},
513	);
514
515	if !matches!(propagation, Propagation::None) && local {
516		self.update_all_rooms(user_id, profile_values, propagation)
517			.await;
518	}
519
520	let txn = profile_values
521		.iter()
522		.fold(self.services.db.txn(), |mut txn, (name, value)| {
523			let key = (user_id, name.as_str());
524
525			if let Some(value) = value {
526				txn.put(&self.useridprofilekey_value, key, Json(value));
527			} else {
528				txn.del(&self.useridprofilekey_value, key);
529			}
530
531			txn
532		});
533
534	let rooms = || {
535		self.services
536			.state_cache
537			.rooms_joined_checked(user_id)
538	};
539
540	// A local write restating a stored value still reaches the writer's devices.
541	let logged = profile_values
542		.iter()
543		.map(|(name, _)| name.as_str())
544		.filter(|name| local || changed.contains(name));
545
546	self.publish_update(user_id, logged, &changed, txn, rooms)
547		.await
548}
549
550/// Names the fields whose stored value the write would actually change.
551///
552/// Only these are logged for the user's rooms, or at all for a remote user. A
553/// write restoring what is already stored is news to nobody else, and the
554/// on-demand remote refresh reissues every field on every lookup of a remote
555/// profile, so logging those would multiply the log by the request rate rather
556/// than the change rate.
557#[implement(Service)]
558async fn changed_fields<'a>(
559	&self,
560	user_id: &UserId,
561	profile_values: &'a [(ProfileFieldName, Option<Value>)],
562) -> Result<ChangedFields<'a>> {
563	profile_values
564		.iter()
565		.try_stream()
566		.try_filter_map(async |(name, value)| {
567			let stored = self.profile_key(user_id, name).await.optional()?;
568
569			let changed = stored
570				.as_ref()
571				.ne(&value.as_ref())
572				.then_some(name.as_str());
573
574			Ok(changed)
575		})
576		.try_collect()
577		.await
578}
579
580/// Commits staged profile fields together with their change rows.
581///
582/// Every logged field gets a row under the user's own prefix; every changed
583/// field also gets one under each room they are joined to. The row names the
584/// field and not only the user because a removal is otherwise unreportable: a
585/// reader re-reading the live profile cannot tell a cleared field from one
586/// that was never set. A room scan error discards the whole batch, so no row
587/// for that count is ever visible.
588#[implement(Service)]
589#[tracing::instrument(
590	name = "profile_update",
591	level = "debug",
592	skip_all,
593	fields(
594		%user_id,
595	),
596)]
597async fn publish_update<'a, L, T, S>(
598	&self,
599	user_id: &UserId,
600	logged: L,
601	changed: &[T],
602	txn: Txn,
603	rooms: impl FnOnce() -> S + Send,
604) -> Result
605where
606	L: IntoIterator<Item: AsRef<str>> + Clone,
607	T: AsRef<str> + Sync,
608	S: Stream<Item = Result<&'a RoomId>> + Send,
609{
610	if logged.clone().into_iter().next().is_none() {
611		txn.execute();
612		return Ok(());
613	}
614
615	let count = self.services.globals.next_count();
616	let txn = self.stage_update(txn, user_id.as_str(), user_id, *count, logged);
617	let txn = if changed.is_empty() {
618		txn
619	} else {
620		rooms()
621			.ready_try_fold(txn, |txn, room_id| {
622				Ok(self.stage_update(txn, room_id.as_str(), user_id, *count, changed))
623			})
624			.await?
625	};
626
627	txn.execute();
628
629	Ok(())
630}
631
632#[implement(Service)]
633fn stage_update<I>(&self, txn: Txn, scope: &str, user_id: &UserId, count: u64, fields: I) -> Txn
634where
635	I: IntoIterator<Item: AsRef<str>>,
636{
637	fields.into_iter().fold(txn, |mut txn, name| {
638		txn.put_raw(&self.profilechangeid_userid, (scope, count, name.as_ref()), user_id);
639		txn
640	})
641}
642
643/// Streams the profile fields the user wrote themselves, restatements included.
644///
645/// The range is half-open on the low side, so a caller passes the sync token
646/// it already delivered. An absent `to` leaves the walk unbounded above.
647/// Storage and decoding failures are logged and skipped.
648#[implement(Service)]
649#[inline]
650pub fn profile_changed<'a>(
651	&'a self,
652	user_id: &'a UserId,
653	from: u64,
654	to: Option<u64>,
655) -> impl Stream<Item = ProfileChange<'a>> + Send + 'a {
656	self.try_profile_changed(user_id, from, to)
657		.inspect_err(|error| warn!(%error, "Profile change log row failed to read"))
658		.ignore_err()
659}
660
661/// Streams the user's logged profile fields, surfacing read failures.
662///
663/// The range excludes `from` and includes `to`, when provided. Fields and user
664/// IDs borrow the cursor and must be consumed or owned before it advances.
665#[implement(Service)]
666#[inline]
667pub fn try_profile_changed<'a>(
668	&'a self,
669	user_id: &'a UserId,
670	from: u64,
671	to: Option<u64>,
672) -> impl Stream<Item = Result<ProfileChange<'a>>> + Send + 'a {
673	self.profile_changed_user_or_room(user_id.as_str(), from, to)
674}
675
676/// Streams the profile fields any member of the room changed.
677///
678/// The range works as it does for a single user. A member appears once per
679/// field they changed, however many of the caller's rooms they share.
680/// Storage and decoding failures are logged and skipped.
681#[implement(Service)]
682#[inline]
683pub fn room_profile_changed<'a>(
684	&'a self,
685	room_id: &'a RoomId,
686	from: u64,
687	to: Option<u64>,
688) -> impl Stream<Item = ProfileChange<'a>> + Send + 'a {
689	self.try_room_profile_changed(room_id, from, to)
690		.inspect_err(|error| warn!(%error, "Profile change log row failed to read"))
691		.ignore_err()
692}
693
694/// Streams a room's logged profile fields, surfacing read failures.
695///
696/// The range excludes `from` and includes `to`, when provided. Fields and user
697/// IDs borrow the cursor and must be consumed or owned before it advances.
698#[implement(Service)]
699#[inline]
700pub fn try_room_profile_changed<'a>(
701	&'a self,
702	room_id: &'a RoomId,
703	from: u64,
704	to: Option<u64>,
705) -> impl Stream<Item = Result<ProfileChange<'a>>> + Send + 'a {
706	self.profile_changed_user_or_room(room_id.as_str(), from, to)
707}
708
709#[implement(Service)]
710fn profile_changed_user_or_room<'a>(
711	&'a self,
712	user_or_room_id: &'a str,
713	from: u64,
714	to: Option<u64>,
715) -> impl Stream<Item = Result<ProfileChange<'a>>> + Send + 'a {
716	let to = to.unwrap_or(u64::MAX);
717
718	if from >= to {
719		return Either::Left(empty());
720	}
721
722	let start = (user_or_room_id, from.saturating_add(1));
723	let prefix = serialize_key((user_or_room_id, Interfix)).expect("profile scope prefix");
724	let end = to
725		.checked_add(1)
726		.map(|count| serialize_key((user_or_room_id, count)).expect("profile range end"));
727
728	// Bound raw keys before decoding so unrelated corrupt rows cannot fail this range.
729	let changes = self
730		.profilechangeid_userid
731		.stream_from_raw(&start)
732		.ready_try_take_while(move |(key, _)| {
733			Ok(key.starts_with(prefix.as_slice())
734				&& end
735					.as_ref()
736					.is_none_or(|end| *key < end.as_slice()))
737		})
738		.ready_and_then(|(key, value)| {
739			let ((_, _, field), user_id): ChangeKeyVal<'_> =
740				(deserialize_from_slice(key)?, deserialize_from_slice(value)?);
741
742			Ok((user_id, field))
743		});
744
745	Either::Right(changes)
746}
747
748/// Gets a specific user profile key
749#[implement(Service)]
750pub async fn profile_key<T>(&self, user_id: &UserId, profile_key: &ProfileFieldName) -> Result<T>
751where
752	T: for<'de> Deserialize<'de> + Send,
753{
754	let key = (user_id, profile_key);
755	let Json(value) = self
756		.useridprofilekey_value
757		.qry(&key)
758		.await
759		.map_err(|error| {
760			if error.is_not_found() {
761				err!(Request(NotFound("The requested profile key does not exist.")))
762			} else {
763				error
764			}
765		})?
766		.deserialized()
767		.map_err(|_| err!(Database("Cannot deserialize database profile value")))?;
768
769	Ok(value)
770}
771
772/// Fill membership content with the user's display name and avatar.
773///
774/// Profile lookup failures clear the corresponding fields. All other content
775/// fields are preserved.
776#[implement(Service)]
777pub async fn fill_content(
778	&self,
779	user_id: &UserId,
780	mut content: RoomMemberEventContent,
781) -> RoomMemberEventContent {
782	let displayname = self.displayname(user_id).ok();
783	let avatar_url = self.avatar_url(user_id).ok();
784
785	let (displayname, avatar_url) = join(displayname, avatar_url).await;
786
787	content.displayname = displayname;
788	content.avatar_url = avatar_url;
789
790	content
791}
792
793#[implement(Service)]
794pub async fn fetch_remote_profile(&self, user_id: &UserId) -> Result {
795	assert!(
796		!self.services.globals.user_is_local(user_id),
797		"fetch remote profile called with a local user"
798	);
799
800	if let Ok(response) = self
801		.services
802		.federation
803		.execute(user_id.server_name(), get_profile_information::v1::Request {
804			user_id: user_id.to_owned(),
805			field: None,
806		})
807		.await
808	{
809		if !self.services.users.exists(user_id).await {
810			self.services
811				.users
812				.create(user_id, None, None)
813				.await?;
814		}
815
816		for (key, value) in response.iter() {
817			self.set_profile_keys(
818				user_id,
819				&[(key.as_str().into(), Some(value.clone()))],
820				Some(Propagation::None),
821			)
822			.await?;
823		}
824	}
825
826	Ok(())
827}
828
829/// MSC4133 maximum total profile size (64 KiB), measured over the JSON of the
830/// full profile including displayname and avatar_url.
831pub(super) const MAX_PROFILE_SIZE: usize = 65_536;
832
833/// Reject a prospective profile write exceeding the MSC4133 64 KiB cap.
834///
835/// The candidate value replaces the stored field for the size calculation. A
836/// stored field that no longer parses is logged and left out of the total, so
837/// it cannot block writes to the other fields; removals skip this check.
838#[implement(Service)]
839async fn enforce_profile_size(&self, user_id: &UserId, key: &str, value: &Value) -> Result {
840	let replacement = once((ProfileFieldName::from(key), Cow::Borrowed(value))).stream();
841	let profile: ProspectiveProfile<'_> = self
842		.try_all_profile_keys(user_id)
843		.ready_filter_map(|field| {
844			field
845				.inspect_err(|e| warn!(%user_id, %e, "Skipping unreadable profile field"))
846				.ok()
847		})
848		.map(|field| (field.field_name(), Cow::Owned(field.value().into_owned())))
849		.chain(replacement)
850		.collect()
851		.await;
852
853	let profile_size = serde_json::to_vec(&profile).map_or(0, |buf| buf.len());
854
855	if profile_size > MAX_PROFILE_SIZE {
856		return Err!(Request(ProfileTooLarge(
857			"Profile would exceed the maximum size of 64 KiB."
858		)));
859	}
860
861	Ok(())
862}
863
864/// MSC4133 maximum profile field-name length, in bytes.
865const MAX_KEY_LENGTH: usize = 255;
866
867/// Validate a profile field name against the Common Namespaced Identifier
868/// Grammar: a lowercase-leading identifier over `[a-z0-9_.-]`, matching the
869/// reference homeserver. Length is bounded separately by `MAX_KEY_LENGTH`.
870fn check_profile_key(name: &str) -> Result {
871	if name.len() > MAX_KEY_LENGTH {
872		return Err!(Request(KeyTooLarge("Profile key names cannot be longer than 255 bytes.")));
873	}
874
875	let ok = name
876		.bytes()
877		.next()
878		.is_some_and(|b| b.is_ascii_lowercase())
879		&& name.bytes().all(|b| {
880			b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
881		});
882
883	if !ok {
884		return Err!(Request(BadJson(
885			"Profile key names must follow the Common Namespaced Identifier Grammar."
886		)));
887	}
888
889	Ok(())
890}
891
892/// Validate a profile field value against the schema of the proposal naming
893/// the field.
894///
895/// MSC4133 reserves no schema of its own, so a field this does not recognize
896/// carries any JSON the size cap admits. A stored `null` clears the field for
897/// readers without removing it, and is accepted for every field name.
898fn check_profile_value(name: &str, value: &Value) -> Result {
899	if value.is_null() {
900		return Ok(());
901	}
902
903	match name {
904		| "m.status" | "org.matrix.msc4426.status" => check_status(value),
905		| "m.call" | "org.matrix.msc4426.call" => check_call(value),
906		| "m.tz" | "us.cloke.msc4175.tz" => check_timezone(value),
907		| _ => Ok(()),
908	}
909}
910
911/// Validate an MSC4426 status against its two required fields and their byte
912/// budgets.
913///
914/// Both `text` and `emoji` are required, so a partial object is rejected
915/// rather than stored for clients to render half of. The emoji budget counts
916/// bytes and never graphemes, which the proposal calls out because grapheme
917/// definitions keep growing.
918fn check_status(value: &Value) -> Result {
919	let (Some(text), Some(emoji)) = (
920		value.get("text").and_then(Value::as_str),
921		value.get("emoji").and_then(Value::as_str),
922	) else {
923		return Err!(Request(BadJson("Status requires a text and an emoji string.")));
924	};
925
926	check_status_length(text, MAX_STATUS_TEXT_LENGTH, "text")?;
927	check_status_length(emoji, MAX_STATUS_EMOJI_LENGTH, "emoji")
928}
929
930/// Bound one status field by its byte budget.
931///
932/// MSC4426 mandates both the `M_TOO_LARGE` errcode and a 400, while the
933/// kind-derived table promotes that errcode to 413, so this is one of the few
934/// call sites naming its own status.
935fn check_status_length(value: &str, max: usize, field: &str) -> Result {
936	value.len().le(&max).then_some(()).ok_or_else(|| {
937		err!(RequestStatus(
938			BAD_REQUEST,
939			TooLarge("Status {field} cannot be longer than {max} bytes.")
940		))
941	})
942}
943
944/// Validate an MSC4426 call indicator.
945///
946/// Every field is optional, so an empty object is the valid "in a call, joined
947/// at an unstated time" value the proposal's own example uses.
948fn check_call(value: &Value) -> Result {
949	let Some(call) = value.as_object() else {
950		return Err!(Request(BadJson("Call must be an object.")));
951	};
952
953	call.get("call_joined_ts")
954		.is_none_or(Value::is_number)
955		.then_some(())
956		.ok_or_else(|| err!(Request(BadJson("Call join timestamp must be a number."))))
957}
958
959/// Maximum `m.tz` length, in bytes.
960///
961/// The longest name the database ships is 32 bytes, so this is headroom for
962/// later additions rather than a limit any real zone approaches.
963const MAX_TIMEZONE_LENGTH: usize = 64;
964
965/// Maximum number of `/`-separated components in an `m.tz` name.
966///
967/// The deepest names the database ships are three deep, in the
968/// `America/Argentina/Buenos_Aires` shape.
969const MAX_TIMEZONE_COMPONENTS: usize = 3;
970
971/// Validate an MSC4175 time zone against the shape of an IANA Time Zone
972/// Database name.
973///
974/// Membership in a bundled copy of the database is deliberately not tested:
975/// the proposal's rationale for a loose check is that clients and servers
976/// carry different database versions, so a browser one release ahead of ours
977/// would have a newly added zone rejected. Shape alone still refuses offsets,
978/// platform display names, and anything else that could never name a zone.
979fn check_timezone(value: &Value) -> Result {
980	let Some(name) = value.as_str() else {
981		return Err!(Request(InvalidParam("Time zone must be a string.")));
982	};
983
984	is_timezone_name(name)
985		.then_some(())
986		.ok_or_else(|| {
987			err!(Request(InvalidParam(
988				"Time zone must be a name from the IANA Time Zone Database."
989			)))
990		})
991}
992
993/// Test a string against the tzfile naming rules, narrowed to what every name
994/// the database ships actually uses.
995///
996/// A name is one to three `/`-separated components. Draining the remainder
997/// afterwards is what rejects a fourth, leaving the whole test one pass over
998/// the string.
999fn is_timezone_name(name: &str) -> bool {
1000	let mut components = name.split('/');
1001
1002	name.len().le(&MAX_TIMEZONE_LENGTH)
1003		&& components
1004			.by_ref()
1005			.take(MAX_TIMEZONE_COMPONENTS)
1006			.all(is_timezone_component)
1007		&& components.next().is_none()
1008}
1009
1010/// Test one `/`-separated component of an `m.tz` name.
1011///
1012/// Components hold ASCII alphanumerics, `_`, `+`, and `-`. Each begins with a
1013/// letter, which is what refuses a bare numeric offset.
1014fn is_timezone_component(component: &str) -> bool {
1015	component
1016		.bytes()
1017		.next()
1018		.is_some_and(|byte| byte.is_ascii_alphabetic())
1019		&& component
1020			.bytes()
1021			.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'+' | b'-'))
1022}