diff --git a/crates/graphql/src/mutations/matrix.rs b/crates/graphql/src/mutations/matrix.rs new file mode 100644 index 00000000..6d7ec2cd --- /dev/null +++ b/crates/graphql/src/mutations/matrix.rs @@ -0,0 +1,115 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use anyhow::Context as _; +use async_graphql::{Context, Description, Enum, InputObject, Object, ID}; + +use crate::{ + model::{NodeType, User}, + state::ContextExt, +}; + +#[derive(Default)] +pub struct MatrixMutations { + _private: (), +} + +/// The input for the `addEmail` mutation +#[derive(InputObject)] +struct SetDisplayNameInput { + /// The ID of the user to add the email address to + user_id: ID, + + /// The display name to set. If `None`, the display name will be removed. + display_name: Option, +} + +/// The status of the `setDisplayName` mutation +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum SetDisplayNameStatus { + /// The display name was set + Set, + /// The display name is invalid + Invalid, +} + +/// The payload of the `setDisplayName` mutation +#[derive(Description)] +enum SetDisplayNamePayload { + Set(User), + Invalid, +} + +#[Object(use_type_description)] +impl SetDisplayNamePayload { + /// Status of the operation + async fn status(&self) -> SetDisplayNameStatus { + match self { + SetDisplayNamePayload::Set(_) => SetDisplayNameStatus::Set, + SetDisplayNamePayload::Invalid => SetDisplayNameStatus::Invalid, + } + } + + /// The user that was updated + async fn user(&self) -> Option<&User> { + match self { + SetDisplayNamePayload::Set(user) => Some(user), + SetDisplayNamePayload::Invalid => None, + } + } +} + +#[Object] +impl MatrixMutations { + /// Set the display name of a user + async fn set_display_name( + &self, + ctx: &Context<'_>, + input: SetDisplayNameInput, + ) -> Result { + let state = ctx.state(); + let id = NodeType::User.extract_ulid(&input.user_id)?; + let requester = ctx.requester(); + + let user = requester.user().context("Unauthorized")?; + + if user.id != id { + return Err(async_graphql::Error::new("Unauthorized")); + } + + let conn = state.homeserver_connection(); + let mxid = conn.mxid(&user.username); + + if let Some(display_name) = &input.display_name { + // Let's do some basic validation on the display name + if display_name.len() > 256 { + return Ok(SetDisplayNamePayload::Invalid); + } + + if display_name.is_empty() { + return Ok(SetDisplayNamePayload::Invalid); + } + + conn.set_displayname(&mxid, display_name) + .await + .context("Failed to set display name")?; + } else { + conn.unset_displayname(&mxid) + .await + .context("Failed to unset display name")?; + } + + Ok(SetDisplayNamePayload::Set(User(user.clone()))) + } +} diff --git a/crates/graphql/src/mutations/mod.rs b/crates/graphql/src/mutations/mod.rs index 7ac04425..c34ac79e 100644 --- a/crates/graphql/src/mutations/mod.rs +++ b/crates/graphql/src/mutations/mod.rs @@ -14,6 +14,7 @@ mod browser_session; mod compat_session; +mod matrix; mod oauth2_session; mod user_email; @@ -26,6 +27,7 @@ pub struct Mutation( oauth2_session::OAuth2SessionMutations, compat_session::CompatSessionMutations, browser_session::BrowserSessionMutations, + matrix::MatrixMutations, ); impl Mutation { diff --git a/crates/matrix-synapse/src/lib.rs b/crates/matrix-synapse/src/lib.rs index 3b888e61..e2a62020 100644 --- a/crates/matrix-synapse/src/lib.rs +++ b/crates/matrix-synapse/src/lib.rs @@ -119,9 +119,14 @@ struct SynapseUser { external_ids: Option>, } -#[derive(Serialize, Deserialize)] -struct SynapseDevice { - device_id: String, +#[derive(Serialize)] +struct SynapseDevice<'a> { + device_id: &'a str, +} + +#[derive(Serialize)] +struct SetDisplayNameRequest<'a> { + displayname: &'a str, } #[derive(Serialize)] @@ -257,9 +262,7 @@ impl HomeserverConnection for SynapseConnection { let request = self .post(&format!("_synapse/admin/v2/users/{mxid}/devices")) - .body(SynapseDevice { - device_id: device_id.to_owned(), - })?; + .body(SynapseDevice { device_id })?; let response = client.ready().await?.call(request).await?; @@ -328,4 +331,48 @@ impl HomeserverConnection for SynapseConnection { Ok(()) } + + #[tracing::instrument( + name = "homeserver.set_displayname", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.mxid = mxid, + matrix.displayname = displayname, + ), + err(Display), + )] + async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), Self::Error> { + let mut client = self + .http_client_factory + .client() + .await? + .request_bytes_to_body() + .json_request(); + + let request = self + .put(&format!("_matrix/client/v3/profile/{mxid}/displayname")) + .body(SetDisplayNameRequest { displayname })?; + + let response = client.ready().await?.call(request).await?; + + if response.status() != StatusCode::OK { + return Err(anyhow::anyhow!("Failed to set displayname in Synapse")); + } + + Ok(()) + } + + #[tracing::instrument( + name = "homeserver.unset_displayname", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.mxid = mxid, + ), + err(Display), + )] + async fn unset_displayname(&self, mxid: &str) -> Result<(), Self::Error> { + self.set_displayname(mxid, "").await + } } diff --git a/crates/matrix/src/lib.rs b/crates/matrix/src/lib.rs index 14480184..555e532a 100644 --- a/crates/matrix/src/lib.rs +++ b/crates/matrix/src/lib.rs @@ -95,7 +95,10 @@ impl ProvisionRequest { /// # Parameters /// /// * `callback` - The callback to call. - pub fn on_displayname(&self, callback: impl FnOnce(Option<&str>)) -> &Self { + pub fn on_displayname(&self, callback: F) -> &Self + where + F: FnOnce(Option<&str>), + { match &self.displayname { FieldAction::Unset => callback(None), FieldAction::Set(displayname) => callback(Some(displayname)), @@ -128,7 +131,10 @@ impl ProvisionRequest { /// # Parameters /// /// * `callback` - The callback to call. - pub fn on_avatar_url(&self, callback: impl FnOnce(Option<&str>)) -> &Self { + pub fn on_avatar_url(&self, callback: F) -> &Self + where + F: FnOnce(Option<&str>), + { match &self.avatar_url { FieldAction::Unset => callback(None), FieldAction::Set(avatar_url) => callback(Some(avatar_url)), @@ -161,7 +167,10 @@ impl ProvisionRequest { /// # Parameters /// /// * `callback` - The callback to call. - pub fn on_emails(&self, callback: impl FnOnce(Option<&[String]>)) -> &Self { + pub fn on_emails(&self, callback: F) -> &Self + where + F: FnOnce(Option<&[String]>), + { match &self.emails { FieldAction::Unset => callback(None), FieldAction::Set(emails) => callback(Some(emails)), @@ -252,6 +261,31 @@ pub trait HomeserverConnection: Send + Sync { /// Returns an error if the homeserver is unreachable or the user could not /// be deleted. async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), Self::Error>; + + /// Set the displayname of a user on the homeserver. + /// + /// # Parameters + /// + /// * `mxid` - The Matrix ID of the user to set the displayname for. + /// * `displayname` - The displayname to set. + /// + /// # Errors + /// + /// Returns an error if the homeserver is unreachable or the displayname + /// could not be set. + async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), Self::Error>; + + /// Unset the displayname of a user on the homeserver. + /// + /// # Parameters + /// + /// * `mxid` - The Matrix ID of the user to unset the displayname for. + /// + /// # Errors + /// + /// Returns an error if the homeserver is unreachable or the displayname + /// could not be unset. + async fn unset_displayname(&self, mxid: &str) -> Result<(), Self::Error>; } #[async_trait::async_trait] @@ -281,4 +315,12 @@ impl HomeserverConnection for &T async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), Self::Error> { (**self).delete_user(mxid, erase).await } + + async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), Self::Error> { + (**self).set_displayname(mxid, displayname).await + } + + async fn unset_displayname(&self, mxid: &str) -> Result<(), Self::Error> { + (**self).unset_displayname(mxid).await + } } diff --git a/crates/matrix/src/mock.rs b/crates/matrix/src/mock.rs index 53bfbb0a..c9209334 100644 --- a/crates/matrix/src/mock.rs +++ b/crates/matrix/src/mock.rs @@ -122,6 +122,20 @@ impl crate::HomeserverConnection for HomeserverConnection { Ok(()) } + + async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), Self::Error> { + let mut users = self.users.write().await; + let user = users.get_mut(mxid).context("User not found")?; + user.displayname = Some(displayname.to_owned()); + Ok(()) + } + + async fn unset_displayname(&self, mxid: &str) -> Result<(), Self::Error> { + let mut users = self.users.write().await; + let user = users.get_mut(mxid).context("User not found")?; + user.displayname = None; + Ok(()) + } } #[cfg(test)] @@ -150,10 +164,22 @@ mod tests { let inserted = conn.provision_user(&request).await.unwrap(); assert!(inserted); - let user = conn.query_user("@test:example.org").await.unwrap(); + let user = conn.query_user(mxid).await.unwrap(); assert_eq!(user.displayname, Some("Test User".into())); assert_eq!(user.avatar_url, Some("mxc://example.org/1234567890".into())); + // Set the displayname again + assert!(conn.set_displayname(mxid, "John").await.is_ok()); + + let user = conn.query_user(mxid).await.unwrap(); + assert_eq!(user.displayname, Some("John".into())); + + // Unset the displayname + assert!(conn.unset_displayname(mxid).await.is_ok()); + + let user = conn.query_user(mxid).await.unwrap(); + assert_eq!(user.displayname, None); + // Deleting a non-existent device should not fail assert!(conn.delete_device(mxid, device).await.is_ok()); diff --git a/frontend/schema.graphql b/frontend/schema.graphql index f2feaf47..0a190feb 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -459,6 +459,10 @@ type Mutation { endOauth2Session(input: EndOAuth2SessionInput!): EndOAuth2SessionPayload! endCompatSession(input: EndCompatSessionInput!): EndCompatSessionPayload! endBrowserSession(input: EndBrowserSessionInput!): EndBrowserSessionPayload! + """ + Set the display name of a user + """ + setDisplayName(input: SetDisplayNameInput!): SetDisplayNamePayload! } """ @@ -757,6 +761,48 @@ enum SendVerificationEmailStatus { ALREADY_VERIFIED } +""" +The input for the `addEmail` mutation +""" +input SetDisplayNameInput { + """ + The ID of the user to add the email address to + """ + userId: ID! + """ + The display name to set. If `None`, the display name will be removed. + """ + displayName: String +} + +""" +The payload of the `setDisplayName` mutation +""" +type SetDisplayNamePayload { + """ + Status of the operation + """ + status: SetDisplayNameStatus! + """ + The user that was updated + """ + user: User +} + +""" +The status of the `setDisplayName` mutation +""" +enum SetDisplayNameStatus { + """ + The display name was set + """ + SET + """ + The display name is invalid + """ + INVALID +} + """ The input for the `setPrimaryEmail` mutation """ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 641c56d6..f48966b2 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -323,6 +323,8 @@ export type Mutation = { removeEmail: RemoveEmailPayload; /** Send a verification code for an email address */ sendVerificationEmail: SendVerificationEmailPayload; + /** Set the display name of a user */ + setDisplayName: SetDisplayNamePayload; /** Set an email address as primary */ setPrimaryEmail: SetPrimaryEmailPayload; /** Submit a verification code for an email address */ @@ -359,6 +361,11 @@ export type MutationSendVerificationEmailArgs = { input: SendVerificationEmailInput; }; +/** The mutations root of the GraphQL interface. */ +export type MutationSetDisplayNameArgs = { + input: SetDisplayNameInput; +}; + /** The mutations root of the GraphQL interface. */ export type MutationSetPrimaryEmailArgs = { input: SetPrimaryEmailInput; @@ -589,6 +596,31 @@ export enum SendVerificationEmailStatus { Sent = "SENT", } +/** The input for the `addEmail` mutation */ +export type SetDisplayNameInput = { + /** The display name to set. If `None`, the display name will be removed. */ + displayName?: InputMaybe; + /** The ID of the user to add the email address to */ + userId: Scalars["ID"]["input"]; +}; + +/** The payload of the `setDisplayName` mutation */ +export type SetDisplayNamePayload = { + __typename?: "SetDisplayNamePayload"; + /** Status of the operation */ + status: SetDisplayNameStatus; + /** The user that was updated */ + user?: Maybe; +}; + +/** The status of the `setDisplayName` mutation */ +export enum SetDisplayNameStatus { + /** The display name is invalid */ + Invalid = "INVALID", + /** The display name was set */ + Set = "SET", +} + /** The input for the `setPrimaryEmail` mutation */ export type SetPrimaryEmailInput = { /** The ID of the email address to set as primary */ diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index 8d999beb..20b28be8 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -906,6 +906,29 @@ export default { }, ], }, + { + name: "setDisplayName", + type: { + kind: "NON_NULL", + ofType: { + kind: "OBJECT", + name: "SetDisplayNamePayload", + ofType: null, + }, + }, + args: [ + { + name: "input", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + }, + ], + }, { name: "setPrimaryEmail", type: { @@ -1644,6 +1667,33 @@ export default { ], interfaces: [], }, + { + kind: "OBJECT", + name: "SetDisplayNamePayload", + fields: [ + { + name: "status", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + args: [], + }, + { + name: "user", + type: { + kind: "OBJECT", + name: "User", + ofType: null, + }, + args: [], + }, + ], + interfaces: [], + }, { kind: "OBJECT", name: "SetPrimaryEmailPayload",