From d75aebc37380f63239ab4a3858f7e8b1704033ed Mon Sep 17 00:00:00 2001 From: strawberry Date: Sat, 14 Sep 2024 11:16:19 -0400 Subject: [PATCH] implement generic K-V support for MSC4133, GET/PUT/DELETE no PATCH still yet Signed-off-by: strawberry --- Cargo.lock | 26 ++--- Cargo.toml | 3 +- src/api/client/membership.rs | 6 +- src/api/client/profile.rs | 12 ++ src/api/client/unstable.rs | 217 ++++++++++++++++++++++++++++++++++- src/api/router.rs | 3 + src/api/server/query.rs | 19 ++- src/service/users/data.rs | 54 +++++++++ src/service/users/mod.rs | 20 ++++ 9 files changed, 340 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3dcbb678..67ba00f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2975,7 +2975,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.10.1" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "assign", "js_int", @@ -2997,7 +2997,7 @@ dependencies = [ [[package]] name = "ruma-appservice-api" version = "0.10.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "js_int", "ruma-common", @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.18.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "as_variant", "assign", @@ -3032,7 +3032,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.13.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "as_variant", "base64 0.22.1", @@ -3062,7 +3062,7 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.28.1" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "as_variant", "indexmap 2.5.0", @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.9.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "bytes", "http", @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.9.5" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "js_int", "thiserror", @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "ruma-identity-service-api" version = "0.9.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "js_int", "ruma-common", @@ -3123,7 +3123,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.13.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "cfg-if", "once_cell", @@ -3139,7 +3139,7 @@ dependencies = [ [[package]] name = "ruma-push-gateway-api" version = "0.9.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "js_int", "ruma-common", @@ -3151,7 +3151,7 @@ dependencies = [ [[package]] name = "ruma-server-util" version = "0.3.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "headers", "http", @@ -3164,7 +3164,7 @@ dependencies = [ [[package]] name = "ruma-signatures" version = "0.15.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "base64 0.22.1", "ed25519-dalek", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "ruma-state-res" version = "0.11.0" -source = "git+https://github.com/girlbossceo/ruwuma?rev=11155e576a1382783c0bcf5ad4458708777ec36e#11155e576a1382783c0bcf5ad4458708777ec36e" +source = "git+https://github.com/girlbossceo/ruwuma?rev=b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad#b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" dependencies = [ "itertools 0.12.1", "js_int", diff --git a/Cargo.toml b/Cargo.toml index 9f274abf..86c2bcc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -314,7 +314,7 @@ version = "0.1.2" [workspace.dependencies.ruma] git = "https://github.com/girlbossceo/ruwuma" #branch = "conduwuit-changes" -rev = "11155e576a1382783c0bcf5ad4458708777ec36e" +rev = "b6f82a72b6c0899d8ac8e53206d375c2c6f0a2ad" features = [ "compat", "rand", @@ -341,6 +341,7 @@ features = [ "unstable-msc3575", "unstable-msc4121", "unstable-msc4125", + "unstable-msc4186", "unstable-extensible-events", ] diff --git a/src/api/client/membership.rs b/src/api/client/membership.rs index c7d37c45..234f3571 100644 --- a/src/api/client/membership.rs +++ b/src/api/client/membership.rs @@ -236,7 +236,7 @@ pub(crate) async fn join_room_by_id_or_alias_route( Ok(room_id) => { banned_room_check(&services, sender_user, Some(&room_id), room_id.server_name(), client).await?; - let mut servers = body.server_name.clone(); + let mut servers = body.via.clone(); servers.extend( services .rooms @@ -269,13 +269,13 @@ pub(crate) async fn join_room_by_id_or_alias_route( let response = services .rooms .alias - .resolve_alias(&room_alias, Some(&body.server_name.clone())) + .resolve_alias(&room_alias, Some(&body.via.clone())) .await?; let (room_id, mut pre_servers) = response; banned_room_check(&services, sender_user, Some(&room_id), Some(room_alias.server_name()), client).await?; - let mut servers = body.server_name; + let mut servers = body.via; if let Some(pre_servers) = &mut pre_servers { servers.append(pre_servers); } diff --git a/src/api/client/profile.rs b/src/api/client/profile.rs index 789af643..bf47a3f8 100644 --- a/src/api/client/profile.rs +++ b/src/api/client/profile.rs @@ -247,11 +247,18 @@ pub(crate) async fn get_profile_route( .set_timezone(&body.user_id, response.tz.clone()) .await?; + for (profile_key, profile_key_value) in &response.custom_profile_fields { + services + .users + .set_profile_key(&body.user_id, profile_key, Some(profile_key_value.clone()))?; + } + return Ok(get_profile::v3::Response { displayname: response.displayname, avatar_url: response.avatar_url, blurhash: response.blurhash, tz: response.tz, + custom_profile_fields: response.custom_profile_fields, }); } } @@ -267,6 +274,11 @@ pub(crate) async fn get_profile_route( blurhash: services.users.blurhash(&body.user_id)?, displayname: services.users.displayname(&body.user_id)?, tz: services.users.timezone(&body.user_id)?, + custom_profile_fields: services + .users + .all_profile_keys(&body.user_id) + .filter_map(Result::ok) + .collect(), }) } diff --git a/src/api/client/unstable.rs b/src/api/client/unstable.rs index d672ad46..ab4703fd 100644 --- a/src/api/client/unstable.rs +++ b/src/api/client/unstable.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use axum::extract::State; use axum_client_ip::InsecureClientIp; use conduit::{warn, Err}; @@ -6,7 +8,10 @@ use ruma::{ client::{ error::ErrorKind, membership::mutual_rooms, - profile::{delete_timezone_key, get_timezone_key, set_timezone_key}, + profile::{ + delete_profile_key, delete_timezone_key, get_profile_key, get_timezone_key, set_profile_key, + set_timezone_key, + }, room::get_summary, }, federation, @@ -16,6 +21,7 @@ use ruma::{ OwnedRoomId, }; +use super::{update_avatar_url, update_displayname}; use crate::{Error, Result, Ruma, RumaResponse}; /// # `GET /_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms` @@ -226,6 +232,138 @@ pub(crate) async fn set_timezone_key_route( Ok(set_timezone_key::unstable::Response {}) } +/// # `PUT /_matrix/client/unstable/uk.tcpip.msc4133/profile/{user_id}/{field}` +/// +/// Updates the profile key-value field of a user, as per MSC4133. +/// +/// This also handles the avatar_url and displayname being updated. +pub(crate) async fn set_profile_key_route( + State(services): State, body: Ruma, +) -> Result { + let sender_user = body.sender_user.as_ref().expect("user is authenticated"); + + if *sender_user != body.user_id && body.appservice_info.is_none() { + return Err!(Request(Forbidden("You cannot update the profile of another user"))); + } + + if body.kv_pair.is_empty() { + return Err!(Request(BadJson( + "The key-value pair JSON body is empty. Use DELETE to delete a key" + ))); + } + + if body.kv_pair.len() > 1 { + // TODO: support PATCH or "recursively" adding keys in some sort + return Err!(Request(BadJson("This endpoint can only take one key-value pair at a time"))); + } + + let Some(profile_key_value) = body.kv_pair.get(&body.key) else { + return Err!(Request(BadJson( + "The key does not match the URL field key, or JSON body is empty (use DELETE)" + ))); + }; + + if body + .kv_pair + .keys() + .any(|key| key.starts_with("u.") && !profile_key_value.is_string()) + { + return Err!(Request(BadJson("u.* profile key fields must be strings"))); + } + + if body.kv_pair.keys().any(|key| key.len() > 128) { + return Err!(Request(BadJson("Key names cannot be longer than 128 bytes"))); + } + + if body.key == "displayname" { + let all_joined_rooms: Vec = services + .rooms + .state_cache + .rooms_joined(&body.user_id) + .filter_map(Result::ok) + .collect(); + + update_displayname(&services, &body.user_id, Some(profile_key_value.to_string()), all_joined_rooms).await?; + } else if body.key == "avatar_url" { + let mxc = ruma::OwnedMxcUri::from(profile_key_value.to_string()); + + let all_joined_rooms: Vec = services + .rooms + .state_cache + .rooms_joined(&body.user_id) + .filter_map(Result::ok) + .collect(); + + update_avatar_url(&services, &body.user_id, Some(mxc), None, all_joined_rooms).await?; + } else { + services + .users + .set_profile_key(&body.user_id, &body.key, Some(profile_key_value.clone()))?; + } + + if services.globals.allow_local_presence() { + // Presence update + services + .presence + .ping_presence(&body.user_id, &PresenceState::Online)?; + } + + Ok(set_profile_key::unstable::Response {}) +} + +/// # `DELETE /_matrix/client/unstable/uk.tcpip.msc4133/profile/{user_id}/{field}` +/// +/// Deletes the profile key-value field of a user, as per MSC4133. +/// +/// This also handles the avatar_url and displayname being updated. +pub(crate) async fn delete_profile_key_route( + State(services): State, body: Ruma, +) -> Result { + let sender_user = body.sender_user.as_ref().expect("user is authenticated"); + + if *sender_user != body.user_id && body.appservice_info.is_none() { + return Err!(Request(Forbidden("You cannot update the profile of another user"))); + } + + if body.kv_pair.len() > 1 { + // TODO: support PATCH or "recursively" adding keys in some sort + return Err!(Request(BadJson("This endpoint can only take one key-value pair at a time"))); + } + + if body.key == "displayname" { + let all_joined_rooms: Vec = services + .rooms + .state_cache + .rooms_joined(&body.user_id) + .filter_map(Result::ok) + .collect(); + + update_displayname(&services, &body.user_id, None, all_joined_rooms).await?; + } else if body.key == "avatar_url" { + let all_joined_rooms: Vec = services + .rooms + .state_cache + .rooms_joined(&body.user_id) + .filter_map(Result::ok) + .collect(); + + update_avatar_url(&services, &body.user_id, None, None, all_joined_rooms).await?; + } else { + services + .users + .set_profile_key(&body.user_id, &body.key, None)?; + } + + if services.globals.allow_local_presence() { + // Presence update + services + .presence + .ping_presence(&body.user_id, &PresenceState::Online)?; + } + + Ok(delete_profile_key::unstable::Response {}) +} + /// # `GET /_matrix/client/unstable/uk.tcpip.msc4133/profile/:user_id/us.cloke.msc4175.tz` /// /// Returns the `timezone` of the user as per MSC4133 and MSC4175. @@ -285,3 +423,80 @@ pub(crate) async fn get_timezone_key_route( tz: services.users.timezone(&body.user_id)?, }) } + +/// # `GET /_matrix/client/unstable/uk.tcpip.msc4133/profile/{userId}/{field}}` +/// +/// Gets the profile key-value field of a user, as per MSC4133. +/// +/// - If user is on another server and we do not have a local copy already fetch +/// `timezone` over federation +pub(crate) async fn get_profile_key_route( + State(services): State, body: Ruma, +) -> Result { + let mut profile_key_value: BTreeMap = BTreeMap::new(); + + if !services.globals.user_is_local(&body.user_id) { + // Create and update our local copy of the user + if let Ok(response) = services + .sending + .send_federation_request( + body.user_id.server_name(), + federation::query::get_profile_information::v1::Request { + user_id: body.user_id.clone(), + field: None, // we want the full user's profile to update locally as well + }, + ) + .await + { + if !services.users.exists(&body.user_id)? { + services.users.create(&body.user_id, None)?; + } + + services + .users + .set_displayname(&body.user_id, response.displayname.clone()) + .await?; + services + .users + .set_avatar_url(&body.user_id, response.avatar_url.clone()) + .await?; + services + .users + .set_blurhash(&body.user_id, response.blurhash.clone()) + .await?; + services + .users + .set_timezone(&body.user_id, response.tz.clone()) + .await?; + + if let Some(value) = response.custom_profile_fields.get(&body.key) { + profile_key_value.insert(body.key.clone(), value.clone()); + services + .users + .set_profile_key(&body.user_id, &body.key, Some(value.clone()))?; + } else { + return Err!(Request(NotFound("The requested profile key does not exist."))); + } + + return Ok(get_profile_key::unstable::Response { + value: profile_key_value, + }); + } + } + + if !services.users.exists(&body.user_id)? { + // Return 404 if this user doesn't exist and we couldn't fetch it over + // federation + return Err(Error::BadRequest(ErrorKind::NotFound, "Profile was not found.")); + } + + if let Some(value) = services.users.profile_key(&body.user_id, &body.key)? { + profile_key_value.insert(body.key.clone(), value); + } else { + return Err!(Request(NotFound("The requested profile key does not exist."))); + } + + Ok(get_profile_key::unstable::Response { + value: profile_key_value, + }) +} diff --git a/src/api/router.rs b/src/api/router.rs index 77e9a11a..4264e01d 100644 --- a/src/api/router.rs +++ b/src/api/router.rs @@ -23,6 +23,9 @@ pub fn build(router: Router, server: &Server) -> Router { let config = &server.config; let mut router = router .ruma_route(client::get_timezone_key_route) + .ruma_route(client::get_profile_key_route) + .ruma_route(client::set_profile_key_route) + .ruma_route(client::delete_profile_key_route) .ruma_route(client::set_timezone_key_route) .ruma_route(client::delete_timezone_key_route) .ruma_route(client::appservice_ping) diff --git a/src/api/server/query.rs b/src/api/server/query.rs index 45830366..c2b78bde 100644 --- a/src/api/server/query.rs +++ b/src/api/server/query.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use axum::extract::State; use conduit::{Error, Result}; use get_profile_information::v1::ProfileField; @@ -76,6 +78,7 @@ pub(crate) async fn get_profile_information_route( let mut avatar_url = None; let mut blurhash = None; let mut tz = None; + let mut custom_profile_fields = BTreeMap::new(); match &body.field { Some(ProfileField::DisplayName) => { @@ -85,13 +88,24 @@ pub(crate) async fn get_profile_information_route( avatar_url = services.users.avatar_url(&body.user_id)?; blurhash = services.users.blurhash(&body.user_id)?; }, - // TODO: what to do with custom - Some(_) => {}, + Some(custom_field) => { + if let Some(value) = services + .users + .profile_key(&body.user_id, custom_field.as_str())? + { + custom_profile_fields.insert(custom_field.to_string(), value); + } + }, None => { displayname = services.users.displayname(&body.user_id)?; avatar_url = services.users.avatar_url(&body.user_id)?; blurhash = services.users.blurhash(&body.user_id)?; tz = services.users.timezone(&body.user_id)?; + custom_profile_fields = services + .users + .all_profile_keys(&body.user_id) + .filter_map(Result::ok) + .collect(); }, } @@ -100,5 +114,6 @@ pub(crate) async fn get_profile_information_route( avatar_url, blurhash, tz, + custom_profile_fields, }) } diff --git a/src/service/users/data.rs b/src/service/users/data.rs index 70785d68..70ff12e3 100644 --- a/src/service/users/data.rs +++ b/src/service/users/data.rs @@ -233,6 +233,60 @@ impl Data { .transpose() } + /// Gets a specific user profile key + pub(super) fn profile_key(&self, user_id: &UserId, profile_key: &str) -> Result> { + let mut key = user_id.as_bytes().to_vec(); + key.push(0xFF); + key.extend_from_slice(profile_key.as_bytes()); + + self.useridprofilekey_value + .get(&key)? + .map_or(Ok(None), |bytes| Ok(Some(serde_json::from_slice(&bytes).unwrap()))) + } + + /// Gets all the user's profile keys and values in an iterator + pub(super) fn all_profile_keys<'a>( + &'a self, user_id: &UserId, + ) -> Box> + 'a + Send> { + let prefix = user_id.as_bytes().to_vec(); + + Box::new( + self.useridprofilekey_value + .scan_prefix(prefix) + .map(|(key, value)| { + let profile_key_name = utils::string_from_bytes( + key.rsplit(|&b| b == 0xFF) + .next() + .ok_or_else(|| err!(Database("Profile key in db is invalid")))?, + ) + .map_err(|e| err!(Database("Profile key in db is invalid. {e}")))?; + + let profile_key_value = serde_json::from_slice(&value) + .map_err(|e| err!(Database("Profile key in db is invalid. {e}")))?; + + Ok((profile_key_name, profile_key_value)) + }), + ) + } + + /// Sets a new profile key value, removes the key if value is None + pub(super) fn set_profile_key( + &self, user_id: &UserId, profile_key: &str, profile_key_value: Option, + ) -> Result<()> { + let mut key = user_id.as_bytes().to_vec(); + key.push(0xFF); + key.extend_from_slice(profile_key.as_bytes()); + + // TODO: insert to the stable MSC4175 key when it's stable + if let Some(value) = profile_key_value { + let value = serde_json::to_vec(&value).unwrap(); + + self.useridprofilekey_value.insert(&key, &value) + } else { + self.useridprofilekey_value.remove(&key) + } + } + /// Get the timezone of a user. pub(super) fn timezone(&self, user_id: &UserId) -> Result> { // first check the unstable prefix diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 544614d4..80897b5f 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -329,6 +329,26 @@ impl Service { pub fn timezone(&self, user_id: &UserId) -> Result> { self.db.timezone(user_id) } + /// Gets a specific user profile key + pub fn profile_key(&self, user_id: &UserId, profile_key: &str) -> Result> { + self.db.profile_key(user_id, profile_key) + } + + /// Gets all the user's profile keys and values in an iterator + pub fn all_profile_keys<'a>( + &'a self, user_id: &UserId, + ) -> Box> + 'a + Send> { + self.db.all_profile_keys(user_id) + } + + /// Sets a new profile key value, removes the key if value is None + pub fn set_profile_key( + &self, user_id: &UserId, profile_key: &str, profile_key_value: Option, + ) -> Result<()> { + self.db + .set_profile_key(user_id, profile_key, profile_key_value) + } + /// Sets a new tz or removes it if tz is None. pub async fn set_timezone(&self, user_id: &UserId, tz: Option) -> Result<()> { self.db.set_timezone(user_id, tz)