From d1ada07bf38ce351ba395671edd74fdac4b63fe2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 31 Jan 2026 10:51:49 -0500 Subject: [PATCH] reuse email validation --- .../lib/users/src/email/email_to_gravatar.rs | 34 ------------ .../users/src/email/email_to_gravatar_hash.rs | 34 ++++++++++++ crates/lib/users/src/email/mod.rs | 4 +- .../users/src/email/validate_email_address.rs | 0 .../email/validate_email_address_format.rs | 55 +++++++++++++++++++ .../user_creation_case.rs | 4 +- .../endpoints/users/create_account_handler.rs | 4 +- .../endpoints/users/edit_email_handler.rs | 52 +----------------- .../handle_new_sso_account_for_new_user.rs | 4 +- 9 files changed, 100 insertions(+), 91 deletions(-) delete mode 100644 crates/lib/users/src/email/email_to_gravatar.rs create mode 100644 crates/lib/users/src/email/email_to_gravatar_hash.rs delete mode 100644 crates/lib/users/src/email/validate_email_address.rs create mode 100644 crates/lib/users/src/email/validate_email_address_format.rs diff --git a/crates/lib/users/src/email/email_to_gravatar.rs b/crates/lib/users/src/email/email_to_gravatar.rs deleted file mode 100644 index df8e2e4968..0000000000 --- a/crates/lib/users/src/email/email_to_gravatar.rs +++ /dev/null @@ -1,34 +0,0 @@ -use md5::{Digest, Md5}; - -pub fn email_to_gravatar(email_address: &str) -> String { - let email = email_address.trim().to_lowercase(); - - let mut hasher = Md5::new(); - hasher.update(email); - let hash = hasher.finalize(); - let gravatar_hash = format!("{:x}", hash); - - gravatar_hash -} - -#[cfg(test)] -pub mod tests { - use crate::email::email_to_gravatar::email_to_gravatar; - - #[test] - fn test_gravatar() { - // Email 1 - assert_eq!(email_to_gravatar("example@example.com"), "23463b99b62a72f26ed677cc556c44e8".to_string()); - assert_eq!(email_to_gravatar("EXAMPLE@EXAMPLE.COM"), "23463b99b62a72f26ed677cc556c44e8".to_string()); - assert_eq!(email_to_gravatar(" example@example.com \n "), "23463b99b62a72f26ed677cc556c44e8".to_string()); - - // Email 2 - assert_eq!(email_to_gravatar("echelon@gmail.com"), "37e7e18f19d8f654a1ace405c846a9de".to_string()); - assert_eq!(email_to_gravatar("ECHELON@GMAIL.COM"), "37e7e18f19d8f654a1ace405c846a9de".to_string()); - assert_eq!(email_to_gravatar(" ECHELON@GMAIL.COM "), "37e7e18f19d8f654a1ace405c846a9de".to_string()); - - // Misc - assert_eq!(email_to_gravatar(""), "d41d8cd98f00b204e9800998ecf8427e".to_string()); - assert_eq!(email_to_gravatar(" "), "d41d8cd98f00b204e9800998ecf8427e".to_string()); - } -} \ No newline at end of file diff --git a/crates/lib/users/src/email/email_to_gravatar_hash.rs b/crates/lib/users/src/email/email_to_gravatar_hash.rs new file mode 100644 index 0000000000..c539db7ef8 --- /dev/null +++ b/crates/lib/users/src/email/email_to_gravatar_hash.rs @@ -0,0 +1,34 @@ +use md5::{Digest, Md5}; + +pub fn email_to_gravatar_hash(email_address: &str) -> String { + let email = email_address.trim().to_lowercase(); + + let mut hasher = Md5::new(); + hasher.update(email); + let hash = hasher.finalize(); + let gravatar_hash = format!("{:x}", hash); + + gravatar_hash +} + +#[cfg(test)] +pub mod tests { + use crate::email::email_to_gravatar_hash::email_to_gravatar_hash; + + #[test] + fn test_gravatar() { + // Email 1 + assert_eq!(email_to_gravatar_hash("example@example.com"), "23463b99b62a72f26ed677cc556c44e8".to_string()); + assert_eq!(email_to_gravatar_hash("EXAMPLE@EXAMPLE.COM"), "23463b99b62a72f26ed677cc556c44e8".to_string()); + assert_eq!(email_to_gravatar_hash(" example@example.com \n "), "23463b99b62a72f26ed677cc556c44e8".to_string()); + + // Email 2 + assert_eq!(email_to_gravatar_hash("echelon@gmail.com"), "37e7e18f19d8f654a1ace405c846a9de".to_string()); + assert_eq!(email_to_gravatar_hash("ECHELON@GMAIL.COM"), "37e7e18f19d8f654a1ace405c846a9de".to_string()); + assert_eq!(email_to_gravatar_hash(" ECHELON@GMAIL.COM "), "37e7e18f19d8f654a1ace405c846a9de".to_string()); + + // Misc + assert_eq!(email_to_gravatar_hash(""), "d41d8cd98f00b204e9800998ecf8427e".to_string()); + assert_eq!(email_to_gravatar_hash(" "), "d41d8cd98f00b204e9800998ecf8427e".to_string()); + } +} \ 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 2ebd275e5f..e7b17a2090 100644 --- a/crates/lib/users/src/email/mod.rs +++ b/crates/lib/users/src/email/mod.rs @@ -1,3 +1,3 @@ -pub mod email_to_gravatar; +pub mod email_to_gravatar_hash; pub mod generate_random_synthetic_email; -pub mod validate_email_address; \ No newline at end of file +pub mod validate_email_address_format; \ 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 deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/crates/lib/users/src/email/validate_email_address_format.rs b/crates/lib/users/src/email/validate_email_address_format.rs new file mode 100644 index 0000000000..74d70a869f --- /dev/null +++ b/crates/lib/users/src/email/validate_email_address_format.rs @@ -0,0 +1,55 @@ + +/// Basic email format validation +pub fn validate_email_address_format(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(()) +} + +#[cfg(test)] +mod tests { + + #[test] + fn test_invalid_empty() { + } +} \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_creation_case.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_creation_case.rs index f5151121fa..321f02a37d 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_creation_case.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout_with_user_signup/user_creation_case.rs @@ -18,7 +18,7 @@ use sqlx::{Acquire, MySql}; use std::collections::HashMap; use stripe_checkout::checkout_session::{CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionSavedPaymentMethodOptions, CreateCheckoutSessionSavedPaymentMethodOptionsAllowRedisplayFilters, CreateCheckoutSessionSavedPaymentMethodOptionsPaymentMethodSave, CreateCheckoutSessionSubscriptionData}; use stripe_shared::{CheckoutSession, CheckoutSessionMode, PriceId}; -use users::email::email_to_gravatar::email_to_gravatar; +use users::email::email_to_gravatar_hash::email_to_gravatar_hash; use users::email::generate_random_synthetic_email::generate_random_synthetic_email; use users::username::generate_random_username::generate_random_username; @@ -57,7 +57,7 @@ pub (super) async fn user_creation_case( // NB: We try a few times to make sure we don't hit an email/username collision. let user_email_address = generate_random_synthetic_email(); - let user_email_gravatar_hash = email_to_gravatar(&user_email_address); + let user_email_gravatar_hash = email_to_gravatar_hash(&user_email_address); let display_name = generate_random_username(); let username = display_name.trim().to_lowercase(); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs index 5ca429e078..1c387e7c02 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_handler.rs @@ -28,7 +28,7 @@ use password::bcrypt_hash_password::bcrypt_hash_password; use sqlx::MySqlPool; use tokens::tokens::user_sessions::UserSessionToken; use user_input_common::check_for_slurs::contains_slurs; -use users::email::email_to_gravatar::email_to_gravatar; +use users::email::email_to_gravatar_hash::email_to_gravatar_hash; use utoipa::ToSchema; #[derive(ToSchema, Deserialize)] @@ -185,7 +185,7 @@ pub async fn create_account_handler( let email_address = request.email_address.trim().to_lowercase(); - let email_gravatar_hash = email_to_gravatar(&email_address); + let email_gravatar_hash = email_to_gravatar_hash(&email_address); let ip_address = get_request_ip(&http_request); 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 index 244c020866..f4b153f6c3 100644 --- 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 @@ -16,11 +16,11 @@ 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; - +use users::email::email_to_gravatar_hash::email_to_gravatar_hash; +use users::email::validate_email_address_format::validate_email_address_format; #[derive(Debug, ToSchema)] pub enum EditEmailError { @@ -56,52 +56,6 @@ impl fmt::Display for EditEmailError { } } -/// 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( @@ -126,7 +80,7 @@ pub async fn edit_email_handler( { let email_address = request.email_address.trim().to_lowercase(); - if let Err(reason) = validate_email(&email_address) { + if let Err(reason) = validate_email_address_format(&email_address) { return Err(EditEmailError::BadInput(format!("bad email: {}", &reason))); } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs index 04d8f3b0d2..68edcb7d97 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/google_sso/handle_new_sso_account_for_new_user.rs @@ -13,7 +13,7 @@ use mysql_queries::queries::users::user::create::create_account_from_google_sso: use mysql_queries::utils::transactor::Transactor; use sqlx::pool::PoolConnection; use sqlx::{Acquire, MySql}; -use users::email::email_to_gravatar::email_to_gravatar; +use users::email::email_to_gravatar_hash::email_to_gravatar_hash; use users::username::generate_random_username::generate_random_username; pub struct CreateArgs<'a> { @@ -40,7 +40,7 @@ pub async fn handle_new_sso_account_for_new_user( let user_feature_flags = studio_feature_flags(); let ip_address = get_request_ip(&args.http_request); - let user_email_gravatar_hash = email_to_gravatar(&args.user_email_address); + let user_email_gravatar_hash = email_to_gravatar_hash(&args.user_email_address); let mut maybe_source = get_request_signup_source_enum(&args.http_request);