diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs new file mode 100644 index 0000000000..ea91726cf5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/edit_email.rs @@ -0,0 +1,12 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EditEmailRequest { + pub email_address: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct EditEmailResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs index 888acbae16..1173ee5669 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs @@ -1 +1,2 @@ +pub mod edit_email; pub mod edit_username; \ No newline at end of file diff --git a/crates/lib/users/src/email/mod.rs b/crates/lib/users/src/email/mod.rs index fd7c8cc925..2ebd275e5f 100644 --- a/crates/lib/users/src/email/mod.rs +++ b/crates/lib/users/src/email/mod.rs @@ -1,2 +1,3 @@ pub mod email_to_gravatar; -pub mod generate_random_synthetic_email; \ No newline at end of file +pub mod generate_random_synthetic_email; +pub mod validate_email_address; \ No newline at end of file diff --git a/crates/lib/users/src/email/validate_email_address.rs b/crates/lib/users/src/email/validate_email_address.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/update/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/user/update/mod.rs index 1d0def93ff..bd6104eeba 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user/update/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user/update/mod.rs @@ -2,5 +2,6 @@ pub mod set_can_access_studio_transactional; pub mod set_user_ban_status; pub mod set_user_feature_flags; pub mod set_user_feature_flags_transactional; +pub mod update_email; pub mod update_user_record_with_new_stripe_customer_id; pub mod update_username; \ No newline at end of file diff --git a/crates/schema/database/mysql_queries/src/queries/users/user/update/update_email.rs b/crates/schema/database/mysql_queries/src/queries/users/user/update/update_email.rs new file mode 100644 index 0000000000..3f7dd66a6e --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user/update/update_email.rs @@ -0,0 +1,83 @@ +use crate::utils::transactor::Transactor; +use sqlx; +use sqlx::Error::Database; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use tokens::tokens::users::UserToken; + +pub struct UpdateEmailArgs<'e, 't> { + pub token: &'e UserToken, + pub email_address: &'e str, + pub email_gravatar_hash: &'e str, + pub ip_address: &'e str, + pub transactor: Transactor<'e, 't>, +} + +#[derive(Debug)] +pub enum UpdateEmailError { + EmailIsTaken, + DatabaseError { source: sqlx::Error }, +} + +impl Error for UpdateEmailError {} + +impl Display for UpdateEmailError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + UpdateEmailError::EmailIsTaken => { + write!(f, "UpdateEmailError: email is taken") + } + UpdateEmailError::DatabaseError { source } => { + write!(f, "UpdateEmailError: database error: {:?}", source) + } + } + } +} + +pub async fn update_email<'e, 't>( + args: UpdateEmailArgs<'e, 't> +) -> Result<(), UpdateEmailError> +{ + let query = sqlx::query!( + r#" +UPDATE users +SET + email_address = ?, + email_confirmed = false, + email_confirmed_by_google = false, + email_is_synthetic = false, + email_gravatar_hash = ?, + ip_address_last_update = ?, + version = version + 1 +WHERE + token = ? +LIMIT 1 + "#, + args.email_address, + args.email_gravatar_hash, + args.ip_address, + args.token, + ); + + let query_result = args.transactor.execute(query).await; + + match query_result { + Ok(_) => Ok(()), + Err(Database(err)) => { + let maybe_code = err.code() + .map(|c| c.into_owned()); + + // MySQL error code 23000 is a duplicate key constraint violation + if maybe_code.as_deref() == Some("23000") + && err.message().contains("email_address") + { + return Err(UpdateEmailError::EmailIsTaken); + } + + Err(UpdateEmailError::DatabaseError { source: Database(err) }) + }, + Err(err) => { + Err(UpdateEmailError::DatabaseError { source: err }) + }, + } +} diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index e0f93f0edf..4a0fd106b1 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -103,6 +103,7 @@ use crate::http_server::endpoints::user_ratings::batch_get_user_rating_handler:: use crate::http_server::endpoints::user_ratings::get_user_rating_handler::*; use crate::http_server::endpoints::user_ratings::set_user_rating_handler::*; use crate::http_server::endpoints::users::create_account_handler::*; +use crate::http_server::endpoints::users::edit_email_handler::*; use crate::http_server::endpoints::users::edit_username_handler::*; use crate::http_server::endpoints::users::get_profile_handler::*; use crate::http_server::endpoints::users::google_sso::google_sso_handler::*; @@ -122,6 +123,8 @@ use crate::http_server::endpoints::weights::list::list_weights_by_user_handler:: use crate::http_server::endpoints::weights::search::search_model_weights_impl::*; use crate::http_server::endpoints::weights::update::set_model_weight_cover_image_handler::*; use crate::http_server::endpoints::weights::update::update_weight_handler::*; +use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse}; +use artcraft_api_defs::users::edit_username::{EditUsernameRequest, EditUsernameResponse}; use artcraft_api_defs::common::responses::job_details::JobDetailsLipsyncRequest; use artcraft_api_defs::common::responses::job_details::JobDetailsLivePortraitRequest; use artcraft_api_defs::common::responses::media_links::*; @@ -314,6 +317,7 @@ use tokens::tokens::zs_voice_datasets::*; crate::http_server::endpoints::user_ratings::get_user_rating_handler::get_user_rating_handler, crate::http_server::endpoints::user_ratings::set_user_rating_handler::set_user_rating_handler, crate::http_server::endpoints::users::create_account_handler::create_account_handler, + crate::http_server::endpoints::users::edit_email_handler::edit_email_handler, crate::http_server::endpoints::users::edit_username_handler::edit_username_handler, crate::http_server::endpoints::users::get_profile_handler::get_profile_handler, crate::http_server::endpoints::users::google_sso::google_sso_handler::google_sso_handler, @@ -508,6 +512,9 @@ use tokens::tokens::zs_voice_datasets::*; GptImage1EditImageNumImages, GptImage1EditImageImageQuality, GptImage1EditImageResponse, + EditEmailError, + EditEmailRequest, + EditEmailResponse, EditUsernameError, EditUsernameRequest, EditUsernameResponse, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs new file mode 100644 index 0000000000..244c020866 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/edit_email_handler.rs @@ -0,0 +1,177 @@ +// NB: Incrementally getting rid of build warnings... +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use std::fmt; +use std::sync::Arc; + +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::web::Json; +use actix_web::{web, HttpRequest, HttpResponse}; +use log::warn; +use utoipa::ToSchema; +use artcraft_api_defs::users::edit_email::{EditEmailRequest, EditEmailResponse}; +use crate::http_server::web_utils::response_error_helpers::to_simple_json_error; +use crate::http_server::web_utils::user_session::require_user_session_using_connection::require_user_session_using_connection; +use crate::state::server_state::ServerState; +use hashing::md5::email_to_gravatar_hash::email_to_gravatar_hash; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::users::user::update::update_email::{update_email, UpdateEmailArgs, UpdateEmailError}; +use mysql_queries::utils::transactor::Transactor; + + +#[derive(Debug, ToSchema)] +pub enum EditEmailError { + BadInput(String), + NotAuthorized, + ServerError, +} + +impl ResponseError for EditEmailError { + fn status_code(&self) -> StatusCode { + match *self { + EditEmailError::BadInput(_) => StatusCode::BAD_REQUEST, + EditEmailError::NotAuthorized => StatusCode::UNAUTHORIZED, + EditEmailError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + EditEmailError::BadInput(reason) => reason.to_string(), + EditEmailError::NotAuthorized => "unauthorized".to_string(), + EditEmailError::ServerError => "server error".to_string(), + }; + + to_simple_json_error(&error_reason, self.status_code()) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for EditEmailError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +/// Basic email format validation +fn validate_email(email: &str) -> Result<(), String> { + let email = email.trim(); + + if email.is_empty() { + return Err("email address cannot be empty".to_string()); + } + + if email.len() > 255 { + return Err("email address is too long".to_string()); + } + + // Basic format check: must contain exactly one @ with content on both sides + let at_count = email.matches('@').count(); + if at_count != 1 { + return Err("email address must contain exactly one @".to_string()); + } + + let parts: Vec<&str> = email.split('@').collect(); + if parts.len() != 2 { + return Err("invalid email format".to_string()); + } + + let local = parts[0]; + let domain = parts[1]; + + if local.is_empty() { + return Err("email address must have content before @".to_string()); + } + + if domain.is_empty() { + return Err("email address must have a domain after @".to_string()); + } + + // Domain must contain at least one dot + if !domain.contains('.') { + return Err("email domain must contain a dot".to_string()); + } + + // Domain cannot start or end with a dot + if domain.starts_with('.') || domain.ends_with('.') { + return Err("email domain cannot start or end with a dot".to_string()); + } + + Ok(()) +} + +/// Edit email address of the current user. +#[utoipa::path( + post, + tag = "Users", + path = "/v1/user/edit_email", + responses( + (status = 200, description = "Success", body = EditEmailResponse), + (status = 400, description = "Bad input", body = EditEmailError), + (status = 401, description = "Not authorized", body = EditEmailError), + (status = 500, description = "Server error", body = EditEmailError), + ), + params( + ("request" = EditEmailRequest, description = "Payload for Request"), + ) +)] +pub async fn edit_email_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, EditEmailError> +{ + let email_address = request.email_address.trim().to_lowercase(); + + if let Err(reason) = validate_email(&email_address) { + return Err(EditEmailError::BadInput(format!("bad email: {}", &reason))); + } + + let mut mysql_connection = server_state.mysql_pool + .acquire() + .await + .map_err(|err| { + warn!("MySql pool error: {:?}", err); + EditEmailError::ServerError + })?; + + let user_session = require_user_session_using_connection( + &http_request, + &server_state.session_checker, + &mut mysql_connection) + .await + .map_err(|_err| { + EditEmailError::NotAuthorized + })?; + + if user_session.role.is_banned { + return Err(EditEmailError::NotAuthorized); + } + + let ip_address = get_request_ip(&http_request); + let gravatar_hash = email_to_gravatar_hash(&email_address); + + let result = update_email(UpdateEmailArgs { + token: &user_session.user_token_typed, + email_address: &email_address, + email_gravatar_hash: &gravatar_hash, + ip_address: &ip_address, + transactor: Transactor::for_connection(&mut mysql_connection), + }).await; + + match result { + Ok(()) => {}, + Err(UpdateEmailError::EmailIsTaken) => { + return Err(EditEmailError::BadInput("email address is already in use".to_string())); + } + Err(UpdateEmailError::DatabaseError { source }) => { + warn!("Error updating email: {:?}", source); + return Err(EditEmailError::ServerError); + } + } + + Ok(Json(EditEmailResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs index ce5ad1437d..8c39b76a02 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs @@ -1,4 +1,5 @@ pub mod create_account_handler; +pub mod edit_email_handler; pub mod edit_profile_handler; pub mod edit_username_handler; pub mod get_profile_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_routes.rs index 2fe15c208d..d61ad4d146 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/user_routes.rs @@ -9,6 +9,7 @@ use crate::http_server::deprecated_endpoints::w2l::list_user_w2l_templates::list use crate::http_server::endpoints::tts::list_user_tts_inference_results::list_user_tts_inference_results_handler; use crate::http_server::endpoints::tts::list_user_tts_models::list_user_tts_models_handler; use crate::http_server::endpoints::users::create_account_handler::create_account_handler; +use crate::http_server::endpoints::users::edit_email_handler::edit_email_handler; use crate::http_server::endpoints::users::edit_profile_handler::edit_profile_handler; use crate::http_server::endpoints::users::edit_username_handler::edit_username_handler; use crate::http_server::endpoints::users::get_profile_handler::get_profile_handler; @@ -99,6 +100,11 @@ pub fn add_user_routes (app: App) -> App // NB(bt): Modern user profile routes .service(web::scope("/v1/user") + .service( + web::resource("/edit_email") + .route(web::post().to(edit_email_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) .service( web::resource("/edit_username") .route(web::post().to(edit_username_handler))