Files
continuwuity/src/service/users/profile.rs
T
2026-10-14 10:34:50 +01:00

367 lines
11 KiB
Rust

use std::{borrow::Cow, collections::BTreeMap};
use conduwuit::{
Err, Result,
pdu::PartialPdu,
utils::{ReadyExt, stream::TryIgnore, to_canonical_object},
warn,
};
use database::{Deserialized, Ignore, Interfix, Json};
use futures::{Stream, StreamExt};
use ruma::{
OwnedMxcUri, UserId,
api::client::profile::PropagateTo,
events::room::member::MembershipState,
presence::PresenceState,
profile::{ProfileFieldName, ProfileFieldValue},
};
use serde_json::{Value, to_value};
pub enum ProfileFieldChange {
Set(ProfileFieldValue),
Delete(ProfileFieldName),
}
impl ProfileFieldChange {
fn field_name(&self) -> ProfileFieldName {
match self {
| &Self::Delete(ref name) => name.clone(),
| &Self::Set(ref value) => value.field_name(),
}
}
fn value(&self) -> Option<Cow<'_, Value>> {
if let Self::Set(value) = self {
Some(value.value())
} else {
None
}
}
}
impl super::Service {
pub async fn set_profile_field(
&self,
user_id: &UserId,
change: ProfileFieldChange,
propagate_to: PropagateTo,
) -> Result<()> {
const MAX_KEY_LENGTH_BYTES: usize = 255;
const MAX_PROFILE_LENGTH_BYTES: usize = 65536;
let field_name = change.field_name();
// TODO: The spec mentions special error codes (M_PROFILE_TOO_LARGE,
// M_KEY_TOO_LARGE) for profile field size limits, but they're not in
// its list of error codes and Ruma doesn't have them. Should we
// return those, or is M_TOO_LARGE okay?
if field_name.as_str().len() > MAX_KEY_LENGTH_BYTES {
return Err!(Request(TooLarge(
"Individual profile keys must not exceed {MAX_KEY_LENGTH_BYTES} bytes in length."
)));
}
// Serialize the entire profile as canonical JSON, including the new
// change, to check if it exceeds 64 KiB
{
let mut full_profile = self.get_local_profile(user_id).await;
match &change {
| ProfileFieldChange::Set(value) => {
full_profile.insert(
value.field_name().as_str().to_owned(),
value.value().clone().into_owned(),
);
},
| ProfileFieldChange::Delete(key) => {
full_profile.remove(key.as_str());
},
}
if let Ok(canonical_profile) = to_canonical_object(full_profile) {
if serde_json::to_string(&canonical_profile)
.expect("should be able to serialize to string")
.len() > MAX_PROFILE_LENGTH_BYTES
{
return Err!(
"Profile data must not exceed {MAX_PROFILE_LENGTH_BYTES} bytes in \
length."
);
}
} else {
return Err!(Request(BadJson("Failed to canonicalize profile.")));
}
}
// Check if this change would be a no-op
if self
.get_local_profile_field(user_id, field_name.clone())
.await
.is_some_and(|value| Some(value.value()) == change.value())
{
return Ok(());
}
// If the user is local and changed their displayname or avatar_url,
// update it in all their joined rooms. This is done before updating
// their profile data so we can check the old value of the field if
// `propagate_to` is `unchanged`.
if matches!(field_name, ProfileFieldName::AvatarUrl | ProfileFieldName::DisplayName)
&& matches!(propagate_to, PropagateTo::All | PropagateTo::Unchanged)
&& self.services.globals.user_is_local(user_id)
{
let current_displayname = self.displayname(user_id).await.ok();
let current_avatar_url = self.avatar_url(user_id).await.ok();
let mut all_joined_rooms = self.services.state_cache.rooms_joined(user_id);
while let Some(room_id) = all_joined_rooms.next().await {
// TODO: this clobbers any custom fields on the event content
let mut current_membership = match self
.services
.state_accessor
.get_member(&room_id, user_id)
.await
{
| Ok(current_membership)
if current_membership.membership == MembershipState::Join =>
current_membership,
| Ok(current_membership) => {
warn!(
?user_id,
?room_id,
"User is not joined in joined room: {current_membership:?}"
);
continue;
},
| Err(err) => {
warn!(
?user_id,
?room_id,
"Could not load membership event for joined room: {err}"
);
continue;
},
};
// If `propagate_to` is `unchanged`, and the current value of
// the field we're updating was changed from its global
// value in this room, skip it.
if matches!(propagate_to, PropagateTo::Unchanged) {
let field_changed_from_global = match field_name {
| ProfileFieldName::AvatarUrl =>
current_membership.avatar_url.as_ref() != current_avatar_url.as_ref(),
| ProfileFieldName::DisplayName =>
current_membership.displayname.as_ref()
!= current_displayname.as_ref(),
| _ => unreachable!(),
};
if field_changed_from_global {
continue;
}
}
let state_lock = self.services.state.mutex.lock(room_id.as_str()).await;
// Preserve keys in accordance with the key copying rules
current_membership.reason = None;
current_membership.join_authorized_via_users_server = None;
match &change {
| ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(avatar_url)) => {
current_membership.avatar_url = Some(avatar_url.clone());
},
| ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)) => {
current_membership.displayname = Some(displayname.clone());
},
| ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => {
current_membership.avatar_url = None;
},
| ProfileFieldChange::Delete(ProfileFieldName::DisplayName) => {
current_membership.displayname = None;
},
| _ => unreachable!(),
}
let _ = self
.services
.timeline
.build_and_append_pdu(
PartialPdu::state(user_id.to_string(), &current_membership),
user_id,
Some(&room_id),
&state_lock,
)
.await;
}
if self.services.config.allow_local_presence {
// Send a presence EDU to indicate the profile changed
let _ = self
.services
.presence
.ping_presence(user_id, &PresenceState::Online)
.await;
}
}
match change {
| ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)) => {
self.set_displayname(user_id, Some(displayname).filter(|dn| !dn.is_empty()));
},
| ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(avatar_url)) => {
self.set_avatar_url(user_id, Some(avatar_url).filter(|av| av.is_valid()));
},
| ProfileFieldChange::Delete(ProfileFieldName::DisplayName) => {
self.set_displayname(user_id, None);
},
| ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => {
self.set_avatar_url(user_id, None);
},
| other => self.set_profile_key(
user_id,
other.field_name().as_str(),
other.value().map(Cow::into_owned),
),
}
Ok(())
}
pub async fn get_local_profile(&self, user_id: &UserId) -> BTreeMap<String, Value> {
let mut profile = BTreeMap::new();
// Get displayname and avatar_url independently because
// `all_profile_keys` doesn't include them
for field in [ProfileFieldName::AvatarUrl, ProfileFieldName::DisplayName] {
let key = field.as_str().to_owned();
if let Some(value) = self.get_local_profile_field(user_id, field).await {
profile.insert(key, value.value().into_owned());
}
}
// Insert all other profile fields
let mut all_fields = self.all_profile_keys(user_id);
while let Some((key, value)) = all_fields.next().await {
profile.insert(key, value);
}
profile
}
pub async fn get_local_profile_field(
&self,
user_id: &UserId,
field: ProfileFieldName,
) -> Option<ProfileFieldValue> {
let value = match field.clone() {
| ProfileFieldName::AvatarUrl => self
.avatar_url(user_id)
.await
.ok()
.map(to_value)
.transpose()
.expect("converting avatar url to value should succeed"),
| ProfileFieldName::DisplayName => self
.displayname(user_id)
.await
.ok()
.map(to_value)
.transpose()
.expect("converting displayname to value should succeed"),
| other => self.profile_key(user_id, other.as_str()).await.ok(),
}?;
Some(
ProfileFieldValue::new(field.as_str(), value)
.expect("local profile field should be valid"),
)
}
/// Returns the displayname of a user on this homeserver.
pub async fn displayname(&self, user_id: &UserId) -> Result<String> {
self.db.userid_displayname.get(user_id).await.deserialized()
}
/// Sets a new displayname or removes it if displayname is None. You still
/// need to notify all rooms of this change.
fn set_displayname(&self, user_id: &UserId, displayname: Option<String>) {
if let Some(displayname) = displayname {
self.db.userid_displayname.insert(user_id, displayname);
} else {
self.db.userid_displayname.remove(user_id);
}
}
/// Get the `avatar_url` of a user.
pub async fn avatar_url(&self, user_id: &UserId) -> Result<OwnedMxcUri> {
self.db.userid_avatarurl.get(user_id).await.deserialized()
}
/// Sets a new avatar_url or removes it if avatar_url is None.
fn set_avatar_url(&self, user_id: &UserId, avatar_url: Option<OwnedMxcUri>) {
match avatar_url {
| Some(avatar_url) => {
self.db.userid_avatarurl.insert(user_id, &avatar_url);
},
| _ => {
self.db.userid_avatarurl.remove(user_id);
},
}
}
/// Gets a specific user profile key
pub async fn profile_key(&self, user_id: &UserId, profile_key: &str) -> Result<Value> {
let key = (user_id, profile_key);
self.db
.useridprofilekey_value
.qry(&key)
.await
.and_then(|handle| serde_json::from_slice(&handle).map_err(Into::into))
}
/// Gets all the user's profile keys and values in an iterator
pub fn all_profile_keys<'a>(
&'a self,
user_id: &'a UserId,
) -> impl Stream<Item = (String, Value)> + 'a + Send {
type KeyVal<'a> = ((Ignore, String), &'a [u8]);
let prefix = (user_id, Interfix);
self.db
.useridprofilekey_value
.stream_prefix(&prefix)
.ignore_err()
.map(|((_, key), value): KeyVal<'_>| Ok((key, serde_json::from_slice(value)?)))
.ignore_err()
}
/// Sets a new profile key value, removes the key if value is None
fn set_profile_key(
&self,
user_id: &UserId,
profile_key: &str,
profile_key_value: Option<Value>,
) {
let key = (user_id, profile_key);
if let Some(value) = profile_key_value {
self.db.useridprofilekey_value.put(key, Json(value));
} else {
self.db.useridprofilekey_value.del(key);
}
}
/// Clears all profile data for a user, including display name and avatar
/// url.
pub async fn clear_profile(&self, user_id: &UserId) {
self.set_displayname(user_id, None);
self.set_avatar_url(user_id, None);
self.all_profile_keys(user_id)
.ready_for_each(|(key, _)| self.set_profile_key(user_id, &key, None))
.await;
}
}