edit email address

This commit is contained in:
Brandon Thomas committed 2026-01-31 10:45:54 -05:00
1 parent b311719e9b
commit 654bd160ef
10 files changed
+290 -1

No files matched your search

@@ -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,
}
@@ -1 +1,2 @@
pub mod edit_email;
pub mod edit_username;
+2 -1
View File
@@ -1,2 +1,3 @@
pub mod email_to_gravatar;
pub mod generate_random_synthetic_email;
pub mod generate_random_synthetic_email;
pub mod validate_email_address;
Whitespace-only changes.
@@ -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;
@@ -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 })
},
}
}
@@ -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,
@@ -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<EditEmailRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<EditEmailResponse>, 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 }))
}
@@ -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;
@@ -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<T, B> (app: App<T>) -> App<T>
// 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))