From 922da4826396e61cbfaccc72fb5b1081ef6d8273 Mon Sep 17 00:00:00 2001 From: Lewis Date: Sun, 20 Sep 2026 13:49:47 +0300 Subject: [PATCH] comms: Comms ought to have better-typed channel recipients Lewis: May this revision serve well! --- Cargo.lock | 2 + .../tranquil-api/src/admin/account/email.rs | 6 +- .../tranquil-api/src/admin/account/update.rs | 8 +- crates/tranquil-api/src/common.rs | 101 ++- crates/tranquil-api/src/delegation.rs | 4 +- crates/tranquil-api/src/discord_webhook.rs | 29 +- crates/tranquil-api/src/identity/account.rs | 8 +- .../tranquil-api/src/identity/plc/request.rs | 17 +- crates/tranquil-api/src/identity/provision.rs | 14 +- crates/tranquil-api/src/notification_prefs.rs | 92 +-- .../tranquil-api/src/server/account_status.rs | 17 +- crates/tranquil-api/src/server/email.rs | 97 +-- .../src/server/passkey_account.rs | 31 +- crates/tranquil-api/src/server/password.rs | 29 +- crates/tranquil-api/src/server/session.rs | 145 ++-- .../tranquil-api/src/server/verify_email.rs | 25 +- .../tranquil-api/src/server/verify_token.rs | 61 +- crates/tranquil-api/src/telegram_webhook.rs | 37 +- crates/tranquil-comms/Cargo.toml | 1 + crates/tranquil-comms/src/email/message.rs | 35 +- crates/tranquil-comms/src/email/mod.rs | 21 +- crates/tranquil-comms/src/lib.rs | 4 +- crates/tranquil-comms/src/sender.rs | 54 +- crates/tranquil-comms/src/types.rs | 51 -- crates/tranquil-comms/tests/email_smtp.rs | 23 +- crates/tranquil-db-traits/src/infra.rs | 58 +- crates/tranquil-db-traits/src/lib.rs | 2 +- crates/tranquil-db-traits/src/user.rs | 33 +- crates/tranquil-db/src/postgres/infra.rs | 9 +- crates/tranquil-db/src/postgres/user.rs | 24 +- .../src/endpoints/authorize/login.rs | 51 +- .../src/endpoints/authorize/mod.rs | 3 +- .../src/endpoints/authorize/passkey.rs | 21 +- .../src/sso_endpoints.rs | 90 +-- crates/tranquil-pds/src/api/error.rs | 25 + crates/tranquil-pds/src/api/validation.rs | 166 ---- crates/tranquil-pds/src/comms/mod.rs | 9 +- crates/tranquil-pds/src/comms/service.rs | 721 ++++++++---------- .../tests/account_notifications.rs | 239 ++++-- crates/tranquil-pds/tests/admin_email.rs | 62 +- crates/tranquil-pds/tests/common/mod.rs | 15 + crates/tranquil-pds/tests/email_update.rs | 64 +- crates/tranquil-pds/tests/gc_after_delete.rs | 14 +- crates/tranquil-pds/tests/legacy_2fa.rs | 77 +- crates/tranquil-pds/tests/notifications.rs | 26 +- crates/tranquil-pds/tests/repair_leaf_loss.rs | 7 +- crates/tranquil-pds/tests/repo_batch.rs | 9 +- crates/tranquil-pds/tests/security_fixes.rs | 44 +- crates/tranquil-pds/tests/sso.rs | 11 +- crates/tranquil-pds/tests/store_parity.rs | 8 +- .../tests/user_blocks_reachability.rs | 8 +- .../tests/validation_edge_cases.rs | 83 +- crates/tranquil-signal/Cargo.toml | 1 + crates/tranquil-signal/src/client.rs | 66 +- crates/tranquil-signal/src/lib.rs | 5 +- crates/tranquil-storage/src/lib.rs | 6 +- crates/tranquil-store/src/metastore/client.rs | 56 +- .../tranquil-store/src/metastore/handler.rs | 27 +- .../tranquil-store/src/metastore/infra_ops.rs | 18 +- .../tranquil-store/src/metastore/user_ops.rs | 29 +- crates/tranquil-types/src/lib.rs | 428 +++++++++++ 61 files changed, 1813 insertions(+), 1614 deletions(-) delete mode 100644 crates/tranquil-comms/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 8b72c3e..33b9f00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7772,6 +7772,7 @@ dependencies = [ "tranquil-config", "tranquil-db-traits", "tranquil-signal", + "tranquil-types", "uuid", ] @@ -8129,6 +8130,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "tranquil-types", "url", "uuid", ] diff --git a/crates/tranquil-api/src/admin/account/email.rs b/crates/tranquil-api/src/admin/account/email.rs index 88d9ecd..efdf3e4 100644 --- a/crates/tranquil-api/src/admin/account/email.rs +++ b/crates/tranquil-api/src/admin/account/email.rs @@ -38,7 +38,8 @@ pub async fn send_email( .log_db_err("in send_email")? .ok_or(ApiError::AccountNotFound)?; - let email = user.email.ok_or(ApiError::NoEmail)?; + let email = tranquil_types::EmailAddress::new(&user.email.ok_or(ApiError::NoEmail)?) + .map_err(|e| ApiError::InvalidRequest(e.to_string()))?; let (user_id, handle) = (user.id, user.handle); let hostname = &tranquil_config::get().server.hostname; let subject = input @@ -50,9 +51,8 @@ pub async fn send_email( .infra .enqueue_comms( Some(user_id), - tranquil_db_traits::CommsChannel::Email, + &tranquil_types::Recipient::Email(email), tranquil_db_traits::CommsType::AdminEmail, - &email, Some(&subject), content, None, diff --git a/crates/tranquil-api/src/admin/account/update.rs b/crates/tranquil-api/src/admin/account/update.rs index 51cc006..c80c07b 100644 --- a/crates/tranquil-api/src/admin/account/update.rs +++ b/crates/tranquil-api/src/admin/account/update.rs @@ -19,10 +19,10 @@ pub async fn update_account_email( Json(input): Json, ) -> Result, ApiError> { let account = input.account.trim(); - let email = input.email.trim(); - if account.is_empty() || email.is_empty() { + let email = tranquil_types::EmailAddress::new(&input.email)?; + if account.is_empty() { return Err(ApiError::InvalidRequest( - "account and email are required".into(), + "Account is required, silly!".into(), )); } let account_did: Did = account @@ -32,7 +32,7 @@ pub async fn update_account_email( match state .repos .user - .admin_update_email(&account_did, email) + .admin_update_email(&account_did, &email) .await { Ok(0) => Err(ApiError::AccountNotFound), diff --git a/crates/tranquil-api/src/common.rs b/crates/tranquil-api/src/common.rs index adbb445..66a943f 100644 --- a/crates/tranquil-api/src/common.rs +++ b/crates/tranquil-api/src/common.rs @@ -1,11 +1,12 @@ use bcrypt::{DEFAULT_COST, hash}; use chrono::{DateTime, Utc}; use std::collections::HashMap; -use tracing::error; +use tracing::{error, warn}; use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository}; use tranquil_pds::api::error::ApiError; use tranquil_pds::api::error::DbResultExt; use tranquil_pds::types::{AtIdentifier, Did, Handle, PasswordHash}; +use tranquil_types::{DiscordUsername, SignalUsername, TelegramUsername}; pub struct ResolvedRepo { pub user_id: uuid::Uuid, @@ -156,42 +157,70 @@ pub struct ChannelInput<'a> { pub fn extract_verification_recipient( channel: CommsChannel, input: &ChannelInput<'_>, -) -> Result { +) -> Result { match channel { - CommsChannel::Email => match input.email { - Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()), - _ => Err(ApiError::MissingEmail), - }, - CommsChannel::Discord => match input.discord_username { - Some(username) if !username.trim().is_empty() => { - let clean = username.trim().to_lowercase(); - if !tranquil_pds::api::validation::is_valid_discord_username(&clean) { - return Err(ApiError::InvalidRequest( - "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), - )); - } - Ok(clean) - } - _ => Err(ApiError::MissingDiscordId), - }, - CommsChannel::Telegram => match input.telegram_username { - Some(username) if !username.trim().is_empty() => { - let clean = username.trim().trim_start_matches('@'); - if !tranquil_pds::api::validation::is_valid_telegram_username(clean) { - return Err(ApiError::InvalidRequest( - "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), - )); - } - Ok(clean.to_string()) - } - _ => Err(ApiError::MissingTelegramUsername), - }, - CommsChannel::Signal => match input.signal_username { - Some(username) if !username.trim().is_empty() => { - Ok(username.trim().trim_start_matches('@').to_lowercase()) - } - _ => Err(ApiError::MissingSignalNumber), - }, + CommsChannel::Email => { + let raw = trimmed(input.email).ok_or(ApiError::MissingEmail)?; + let address = tranquil_types::EmailAddress::new(raw)?; + Ok(tranquil_pds::comms::VerificationTarget::direct( + tranquil_db_traits::Recipient::Email(address), + )) + } + CommsChannel::Signal => { + let raw = trimmed(input.signal_username).ok_or(ApiError::MissingSignalNumber)?; + let username = SignalUsername::new(raw)?; + Ok(tranquil_pds::comms::VerificationTarget::direct( + tranquil_db_traits::Recipient::Signal(username), + )) + } + CommsChannel::Telegram => { + let raw = trimmed(input.telegram_username).ok_or(ApiError::MissingTelegramUsername)?; + let username = TelegramUsername::new(raw)?; + tranquil_pds::comms::VerificationTarget::resolve( + channel, + username.as_str(), + input.email, + ) + } + CommsChannel::Discord => { + let raw = trimmed(input.discord_username).ok_or(ApiError::MissingDiscordId)?; + let username = DiscordUsername::new(raw)?; + tranquil_pds::comms::VerificationTarget::resolve( + channel, + username.as_str(), + input.email, + ) + } + } +} + +fn trimmed(raw: Option<&str>) -> Option<&str> { + raw.map(str::trim).filter(|value| !value.is_empty()) +} + +pub async fn implicitly_verify_channel( + user_repo: &dyn UserRepository, + did: &Did, + user_id: uuid::Uuid, + preferred_channel: CommsChannel, + context: &'static str, +) { + let Ok(Some(prefs)) = user_repo.get_comms_prefs(user_id).await else { + return; + }; + let Some(recipient) = tranquil_pds::comms::recipient_for(&prefs, preferred_channel) else { + warn!( + did = %did, + preferred = ?preferred_channel, + "We skipped implicit verification on {context} because the account doesn't have a valid recipient" + ); + return; + }; + if let Err(e) = user_repo + .set_channel_verified(did, recipient.channel()) + .await + { + warn!("Implicit verification on {context} failed: {:?}", e); } } diff --git a/crates/tranquil-api/src/delegation.rs b/crates/tranquil-api/src/delegation.rs index 7262bf4..429dee4 100644 --- a/crates/tranquil-api/src/delegation.rs +++ b/crates/tranquil-api/src/delegation.rs @@ -368,8 +368,8 @@ pub async fn create_delegated_account( .as_ref() .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()); - if let Some(ref email) = email - && !tranquil_pds::api::validation::is_valid_email(email) + if let Some(email) = &email + && tranquil_types::EmailAddress::new(email).is_err() { return Err(ApiError::InvalidEmail); } diff --git a/crates/tranquil-api/src/discord_webhook.rs b/crates/tranquil-api/src/discord_webhook.rs index c5e1cb3..64de2e0 100644 --- a/crates/tranquil-api/src/discord_webhook.rs +++ b/crates/tranquil-api/src/discord_webhook.rs @@ -143,7 +143,20 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response { None => { return Json(json!({ "type": 4, - "data": {"content": "Could not identify user", "flags": 64} + "data": {"content": "Couldn't identify user", "flags": 64} + })) + .into_response(); + } + }; + let (discord_user_id, discord_username) = match ( + tranquil_types::DiscordUserId::new(&discord_user_id), + tranquil_types::DiscordUsername::new(&discord_username), + ) { + (Ok(discord_user_id), Ok(discord_username)) => (discord_user_id, discord_username), + _ => { + return Json(json!({ + "type": 4, + "data": {"content": "Couldn't verify your Discord account", "flags": 64} })) .into_response(); } @@ -184,18 +197,14 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response { discord_user_id = %discord_user_id, "Verified Discord user and stored user ID" ); - if let Err(e) = comms_repo::enqueue_channel_verified( + comms_repo::try_channel_verified_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, - tranquil_db_traits::CommsChannel::Discord, - &discord_user_id, + &tranquil_types::Recipient::Discord(discord_user_id), &tranquil_config::get().server.hostname, ) - .await - { - warn!(error = %e, "Failed to enqueue channel verified notification"); - } + .await; Json(json!({ "type": 4, "data": {"content": "Verified", "flags": 64} @@ -299,11 +308,11 @@ mod tests { fn parse_handle_whitespace_trimmed() { let options = vec![InteractionOption { name: "handle".to_string(), - value: serde_json::json!(" alice.example.com "), + value: serde_json::json!(" oystercafe.jola.dev "), }]; assert_eq!( parse_start_handle(Some(&options)), - Some("alice.example.com".to_string()), + Some("oystercafe.jola.dev".to_string()), ); } } diff --git a/crates/tranquil-api/src/identity/account.rs b/crates/tranquil-api/src/identity/account.rs index 3575c2d..2d9408f 100644 --- a/crates/tranquil-api/src/identity/account.rs +++ b/crates/tranquil-api/src/identity/account.rs @@ -51,7 +51,7 @@ async fn try_reactivate_migration( handle: &Handle, email: &Option, verification_channel: tranquil_db_traits::CommsChannel, - verification_recipient: Option<&str>, + verification_recipient: Option<&tranquil_pds::comms::VerificationTarget>, ) -> Option { let reactivate_input = tranquil_db_traits::MigrationReactivationInput { did: did.clone(), @@ -271,8 +271,8 @@ pub async fn create_account( .as_ref() .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()); - if let Some(ref email) = email - && !tranquil_pds::api::validation::is_valid_email(email) + if let Some(email) = &email + && tranquil_types::EmailAddress::new(email).is_err() { return ApiError::InvalidEmail.into_response(); } @@ -393,7 +393,7 @@ pub async fn create_account( &handle, &email, verification_channel, - verification_recipient.as_deref(), + verification_recipient.as_ref(), ) .await { diff --git a/crates/tranquil-api/src/identity/plc/request.rs b/crates/tranquil-api/src/identity/plc/request.rs index e1e9a6a..538ad5a 100644 --- a/crates/tranquil-api/src/identity/plc/request.rs +++ b/crates/tranquil-api/src/identity/plc/request.rs @@ -35,16 +35,27 @@ pub async fn request_plc_operation_signature( .log_db_err("creating PLC token")?; let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_plc_operation( + match tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, - &display_token, + tranquil_pds::comms::Notice::PlcOperation { + token: &display_token, + }, hostname, ) .await { - warn!("Failed to enqueue PLC operation notification: {:?}", e); + Ok(Some(_)) => {} + Ok(None) => { + return Err(ApiError::InvalidRequest( + "We couldn't deliver the PLC operation code to your notification channels. Please contact the PDS owner." + .into(), + )); + } + Err(e) => { + warn!("Failed to enqueue PLC operation notification: {:?}", e); + } } info!("PLC operation signature requested for user {}", auth.did); Ok(Json(EmptyResponse {})) diff --git a/crates/tranquil-api/src/identity/provision.rs b/crates/tranquil-api/src/identity/provision.rs index 5d4d387..585dce9 100644 --- a/crates/tranquil-api/src/identity/provision.rs +++ b/crates/tranquil-api/src/identity/provision.rs @@ -315,18 +315,17 @@ pub async fn enqueue_signup_verification( user_id: uuid::Uuid, did: &Did, channel: CommsChannel, - recipient: &str, + target: &tranquil_pds::comms::VerificationTarget, ) { let token = - tranquil_pds::auth::verification_token::generate_signup_token(did, channel, recipient); + tranquil_pds::auth::verification_token::generate_signup_token(did, channel, &target.id); let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token); let hostname = &tranquil_config::get().server.hostname; if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, - channel, - recipient, + target, &formatted, hostname, ) @@ -341,18 +340,17 @@ pub async fn enqueue_migration_verification( user_id: uuid::Uuid, did: &Did, channel: CommsChannel, - recipient: &str, + target: &tranquil_pds::comms::VerificationTarget, ) { let token = - tranquil_pds::auth::verification_token::generate_migration_token(did, channel, recipient); + tranquil_pds::auth::verification_token::generate_migration_token(did, channel, &target.id); let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token); let hostname = &tranquil_config::get().server.hostname; if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, - channel, - recipient, + target, &formatted, hostname, ) diff --git a/crates/tranquil-api/src/notification_prefs.rs b/crates/tranquil-api/src/notification_prefs.rs index d7cc7a8..78d24a4 100644 --- a/crates/tranquil-api/src/notification_prefs.rs +++ b/crates/tranquil-api/src/notification_prefs.rs @@ -159,12 +159,16 @@ pub async fn request_channel_verification( user_id: uuid::Uuid, did: &Did, channel: CommsChannel, - identifier: &str, + id: &str, handle: Option<&Handle>, ) -> Result { - let token = tranquil_pds::auth::verification_token::generate_channel_update_token( - did, channel, identifier, - ); + if channel.verifies_via_bot() { + return Err(ApiError::InvalidRequest( + "This channel verifies through a bot. Please message the bot first so that it's able to reply with useful info".into(), + )); + } + let token = + tranquil_pds::auth::verification_token::generate_channel_update_token(did, channel, id); let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token); match channel { @@ -173,10 +177,11 @@ pub async fn request_channel_verification( let handle = handle.ok_or_else(|| { ApiError::InternalError(Some("Email verification requires a handle".into())) })?; + let new_email = tranquil_types::EmailAddress::new(id)?; tranquil_pds::comms::comms_repo::enqueue_email_update( state.repos.infra.as_ref(), user_id, - identifier, + &new_email, handle, &formatted_token, hostname, @@ -187,10 +192,10 @@ pub async fn request_channel_verification( _ => { let hostname = &tranquil_config::get().server.hostname; let encoded_token = urlencoding::encode(&formatted_token); - let encoded_identifier = urlencoding::encode(identifier); + let encoded_id = urlencoding::encode(id); let verify_link = format!( "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_identifier + hostname, encoded_token, encoded_id ); let prefs = state .repos @@ -212,26 +217,14 @@ pub async fn request_channel_verification( strings.channel_verification_subject, &[("hostname", hostname)], ); - let recipient = match channel { - CommsChannel::Telegram => state - .repos - .user - .get_telegram_chat_id(user_id) - .await - .ok() - .flatten() - .map(|id| id.to_string()) - .unwrap_or_else(|| identifier.to_string()), - _ => identifier.to_string(), - }; + let recipient = tranquil_db_traits::Recipient::new(channel, id)?; state .repos .infra .enqueue_comms( Some(user_id), - channel, - tranquil_db_traits::CommsType::ChannelVerification, &recipient, + tranquil_db_traits::CommsType::ChannelVerification, Some(&subject), &body, Some(json!({"code": formatted_token})), @@ -253,14 +246,7 @@ async fn process_messaging_channel_update( effective_channel: CommsChannel, verification_required: &mut Vec, ) -> Result<(), ApiError> { - let clean = match channel { - CommsChannel::Discord => raw_value.trim().to_lowercase(), - CommsChannel::Telegram => raw_value.trim_start_matches('@').to_string(), - CommsChannel::Signal => raw_value.trim().trim_start_matches('@').to_lowercase(), - CommsChannel::Email => raw_value.trim().to_lowercase(), - }; - - if clean.is_empty() { + if raw_value.trim().is_empty() { if effective_channel == channel { return Err(ApiError::InvalidRequest(format!( "Cannot remove {:?} while it is the preferred notification channel", @@ -292,26 +278,12 @@ async fn process_messaging_channel_update( return Ok(()); } - let valid = match channel { - CommsChannel::Discord => tranquil_pds::api::validation::is_valid_discord_username(&clean), - CommsChannel::Telegram => tranquil_pds::api::validation::is_valid_telegram_username(&clean), - CommsChannel::Signal => tranquil_pds::comms::is_valid_signal_username(&clean), - CommsChannel::Email => tranquil_pds::api::validation::is_valid_email(&clean), + let clean = match channel { + CommsChannel::Discord => tranquil_types::DiscordUsername::new(raw_value)?.to_string(), + CommsChannel::Telegram => tranquil_types::TelegramUsername::new(raw_value)?.to_string(), + CommsChannel::Signal => tranquil_types::SignalUsername::new(raw_value)?.to_string(), + CommsChannel::Email => tranquil_types::EmailAddress::new(raw_value)?.to_string(), }; - if !valid { - return Err(match channel { - CommsChannel::Discord => ApiError::InvalidRequest( - "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), - ), - CommsChannel::Telegram => ApiError::InvalidRequest( - "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), - ), - CommsChannel::Signal => ApiError::InvalidRequest( - "Invalid Signal username. Must be a 3-32 character nickname, a dot, then a 2-20 digit discriminator".into(), - ), - CommsChannel::Email => ApiError::InvalidEmail, - }); - } match channel { CommsChannel::Discord => state @@ -394,23 +366,25 @@ pub async fn update_notification_prefs( info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel"); } - if let Some(ref new_email) = input.email { - let email_clean = new_email.trim().to_lowercase(); - if email_clean.is_empty() { - return Err(ApiError::InvalidRequest("Email cannot be empty".into())); - } + if let Some(new_email) = &input.email { + let email = tranquil_types::EmailAddress::new(new_email).map_err(|_| { + if new_email.trim().is_empty() { + ApiError::InvalidRequest("Email can't be empty".into()) + } else { + ApiError::InvalidEmail + } + })?; - if !tranquil_pds::api::validation::is_valid_email(&email_clean) { - return Err(ApiError::InvalidEmail); - } - - if current_email.as_ref().map(|e| e.to_lowercase()) != Some(email_clean.clone()) { + if !current_email + .as_deref() + .is_some_and(|e| e.eq_ignore_ascii_case(email.as_str())) + { request_channel_verification( &state, user_id, &auth.did, CommsChannel::Email, - &email_clean, + email.as_str(), Some(&handle), ) .await?; diff --git a/crates/tranquil-api/src/server/account_status.rs b/crates/tranquil-api/src/server/account_status.rs index 2c2e806..30bfe3f 100644 --- a/crates/tranquil-api/src/server/account_status.rs +++ b/crates/tranquil-api/src/server/account_status.rs @@ -586,16 +586,27 @@ pub async fn request_account_delete( .await .log_db_err("creating deletion token")?; let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_account_deletion( + match tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, - &confirmation_token, + tranquil_pds::comms::Notice::AccountDeletion { + code: &confirmation_token, + }, hostname, ) .await { - warn!("Failed to enqueue account deletion notification: {:?}", e); + Ok(Some(_)) => {} + Ok(None) => { + return Err(ApiError::InvalidRequest( + "We couldn't deliver the deletion code to your notification channels. Please contact the PDS owner." + .into(), + )); + } + Err(e) => { + warn!("Failed to enqueue account deletion notification: {:?}", e); + } } info!("Account deletion requested for user {}", session_mfa.did()); Ok(Json(EmptyResponse {})) diff --git a/crates/tranquil-api/src/server/email.rs b/crates/tranquil-api/src/server/email.rs index 401ae06..a6ea09a 100644 --- a/crates/tranquil-api/src/server/email.rs +++ b/crates/tranquil-api/src/server/email.rs @@ -71,7 +71,7 @@ pub async fn request_email_update( let Some(_current_email) = user.email else { return Err(ApiError::InvalidRequest( - "account does not have an email address".into(), + "Account doesn't have an email address".into(), )); }; @@ -89,36 +89,43 @@ pub async fn request_email_update( ApiError::InternalError(Some("Failed to generate verification code".into())) })?; - if let Some(Json(ref inp)) = input - && let Some(ref new_email) = inp.new_email + if let Some(Json(inp)) = &input + && let Some(new_email) = inp.new_email.as_deref() + && let Ok(address) = tranquil_types::EmailAddress::new(new_email) { - let new_email = new_email.trim().to_lowercase(); - if !new_email.is_empty() && tranquil_pds::api::validation::is_valid_email(&new_email) { - let pending = PendingEmailUpdate { - new_email, - token_hash: hash_token(&token), - authorized: false, - }; - if let Ok(json) = serde_json::to_string(&pending) { - let cache_key = tranquil_pds::cache_keys::email_update_key(&auth.did); - if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { - warn!("Failed to cache pending email update: {:?}", e); - } - } + let pending = PendingEmailUpdate { + new_email: address.as_str().to_string(), + token_hash: hash_token(&token), + authorized: false, + }; + let cache_key = tranquil_pds::cache_keys::email_update_key(&auth.did); + if let Ok(json) = serde_json::to_string(&pending) + && let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await + { + warn!("Failed to cache pending email update: {:?}", e); } } let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_short_token_email( + match tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user.id, - &token, + tranquil_pds::comms::Notice::ShortTokenEmail { token: &token }, hostname, ) .await { - warn!("Failed to enqueue email update notification: {:?}", e); + Ok(Some(_)) => {} + Ok(None) => { + return Err(ApiError::InvalidRequest( + "We couldn't deliver the confirmation code to your notification channels. Please contact the PDS owner." + .into(), + )); + } + Err(e) => { + warn!("Failed to enqueue email update notification: {:?}", e); + } } } @@ -150,13 +157,11 @@ pub async fn confirm_email( .log_db_err("getting email info")? .ok_or(ApiError::AccountNotFound)?; - let Some(ref email) = user.email else { + let Some(email) = &user.email else { return Err(ApiError::InvalidEmail); }; - let current_email = email.to_lowercase(); - - let provided_email = input.email.trim().to_lowercase(); - if provided_email != current_email { + let provided_email = tranquil_types::EmailAddress::new(input.email.trim())?; + if provided_email.as_str() != email.to_lowercase() { return Err(ApiError::InvalidEmail); } @@ -170,7 +175,7 @@ pub async fn confirm_email( let verified = tranquil_pds::auth::verification_token::verify_signup_token( &confirmation_code, CommsChannel::Email, - &provided_email, + provided_email.as_str(), ); match verified { @@ -226,17 +231,14 @@ pub async fn update_email( let user_id = user.id; let current_email = user.email.clone(); let email_verified = user.email_verified; - let new_email = input.email.trim().to_lowercase(); - - if !tranquil_pds::api::validation::is_valid_email(&new_email) { - return Err(ApiError::InvalidRequest( + let new_email = tranquil_types::EmailAddress::new(input.email.trim()).map_err(|_| { + ApiError::InvalidRequest( "This email address is not supported, please use a different email.".into(), - )); - } - + ) + })?; let email_unchanged = current_email .as_ref() - .map(|c| new_email == c.to_lowercase()) + .map(|c| new_email.as_str() == c.to_lowercase()) .unwrap_or(false); if email_unchanged { @@ -283,7 +285,7 @@ pub async fn update_email( if let Some(pending_json) = state.cache.get(&cache_key).await && let Ok(pending) = serde_json::from_str::(&pending_json) && pending.authorized - && pending.new_email == new_email + && pending.new_email == new_email.as_str() { authorized_via_link = true; let _ = state.cache.delete(&cache_key).await; @@ -350,24 +352,26 @@ pub async fn update_email( state .repos .user - .update_email(user_id, &new_email) + .update_email(user_id, new_email.as_str()) .await .log_db_err("updating email")?; let verification_token = tranquil_pds::auth::verification_token::generate_signup_token( did, CommsChannel::Email, - &new_email, + new_email.as_str(), ); let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&verification_token); let hostname = &tranquil_config::get().server.hostname; + let target = tranquil_pds::comms::VerificationTarget::direct( + tranquil_db_traits::Recipient::Email(new_email.clone()), + ); if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, - tranquil_db_traits::CommsChannel::Email, - &new_email, + &target, &formatted_token, hostname, ) @@ -565,15 +569,22 @@ pub async fn check_email_in_use( _rate_limit: RateLimited, Json(input): Json, ) -> Result, ApiError> { - let email = input.email.trim().to_lowercase(); - if email.is_empty() { - return Err(ApiError::InvalidRequest("email is required".into())); - } + let raw = input.email.trim(); + let email = tranquil_types::EmailAddress::new(raw).map_err(|_| { + ApiError::InvalidRequest( + if raw.is_empty() { + "Email is required" + } else { + "Invalid email address" + } + .into(), + ) + })?; let count = state .repos .user - .count_accounts_by_email(&email) + .count_accounts_by_email(email.as_str()) .await .map_err(|e| { error!("DB error checking email usage: {:?}", e); diff --git a/crates/tranquil-api/src/server/passkey_account.rs b/crates/tranquil-api/src/server/passkey_account.rs index 33d96b9..0b20fd1 100644 --- a/crates/tranquil-api/src/server/passkey_account.rs +++ b/crates/tranquil-api/src/server/passkey_account.rs @@ -116,8 +116,8 @@ pub async fn create_passkey_account( .as_ref() .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()); - if let Some(ref email) = email - && !tranquil_pds::api::validation::is_valid_email(email) + if let Some(email) = &email + && tranquil_types::EmailAddress::new(email).is_err() { return Err(ApiError::InvalidEmail); } @@ -703,11 +703,11 @@ pub async fn request_passkey_recovery( urlencoding::encode(&recovery_token) ); - let _ = tranquil_pds::comms::comms_repo::enqueue_passkey_recovery( + let _ = tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user.id, - &recovery_url, + tranquil_pds::comms::Notice::PasskeyRecovery { url: &recovery_url }, hostname, ) .await; @@ -776,21 +776,14 @@ pub async fn recover_passkey_account( if result.passkeys_deleted > 0 { info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery"); } - if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user.id).await { - let actual_channel = - tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); - if let Err(e) = state - .repos - .user - .set_channel_verified(&input.did, actual_channel) - .await - { - warn!( - "Failed to implicitly verify channel on passkey recovery: {:?}", - e - ); - } - } + crate::common::implicitly_verify_channel( + state.repos.user.as_ref(), + &input.did, + user.id, + user.preferred_comms_channel, + "passkey recovery", + ) + .await; info!(did = %input.did, "Passkey-only account recovered with temporary password"); Ok(Json(SuccessResponse { success: true })) } diff --git a/crates/tranquil-api/src/server/password.rs b/crates/tranquil-api/src/server/password.rs index db8f327..0decc0b 100644 --- a/crates/tranquil-api/src/server/password.rs +++ b/crates/tranquil-api/src/server/password.rs @@ -90,11 +90,13 @@ pub async fn request_password_reset( return Err(ApiError::InternalError(None)); } let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_password_reset( + if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, - &display_code, + tranquil_pds::comms::Notice::PasswordReset { + code: &display_code, + }, hostname, ) .await @@ -193,21 +195,14 @@ pub async fn reset_password( } })) .await; - if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user_id).await { - let actual_channel = - tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); - if let Err(e) = state - .repos - .user - .set_channel_verified(&user.did, actual_channel) - .await - { - warn!( - "Failed to implicitly verify channel on password reset: {:?}", - e - ); - } - } + crate::common::implicitly_verify_channel( + state.repos.user.as_ref(), + &user.did, + user_id, + user.preferred_comms_channel, + "password reset", + ) + .await; info!("Password reset completed for user {}", user_id); Ok(Json(EmptyResponse {})) } diff --git a/crates/tranquil-api/src/server/session.rs b/crates/tranquil-api/src/server/session.rs index d519700..7fa3a09 100644 --- a/crates/tranquil-api/src/server/session.rs +++ b/crates/tranquil-api/src/server/session.rs @@ -220,21 +220,34 @@ pub async fn create_session( } Ok(tranquil_pds::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => { let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_2fa_code( + match tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), row.id, - code.as_str(), + tranquil_pds::comms::Notice::TwoFactorCode { + code: code.as_str(), + }, hostname, ) .await { - error!("Failed to send 2FA code: {:?}", e); - tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did) - .await; - return Err(ApiError::InternalError(Some( - "Failed to send verification code. Please try again.".into(), - ))); + Ok(Some(_)) => {} + Ok(None) => { + tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did) + .await; + return Err(ApiError::InvalidRequest( + "We couldn't deliver the verification code to your notification channels. Please contact the PDS owner." + .into(), + )); + } + Err(e) => { + error!("Failed to send 2FA code: {:?}", e); + tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did) + .await; + return Err(ApiError::InternalError(Some( + "Failed to send verification code. Please try again.".into(), + ))); + } } return Err(ApiError::AuthFactorTokenRequired); } @@ -336,13 +349,15 @@ pub async fn create_session( "Legacy login on TOTP-enabled account - sending notification" ); let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_legacy_login( + if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), row.id, + tranquil_pds::comms::Notice::LegacyLoginAlert { + channel: row.preferred_comms_channel, + ip: client_ip, + }, hostname, - client_ip, - row.preferred_comms_channel, ) .await { @@ -868,15 +883,13 @@ pub async fn confirm_signup( } }; - let identifier = match row.channel { - tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(), - tranquil_db_traits::CommsChannel::Discord => { - row.discord_username.clone().unwrap_or_default() - } - tranquil_db_traits::CommsChannel::Telegram => { - row.telegram_username.clone().unwrap_or_default() - } - tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(), + let Some(id) = row.channel_identifier() else { + warn!( + did = %input.did, + channel = ?row.channel, + "We can't confirm signup because the account doesn't have an identifier on file" + ); + return Err(ApiError::InvalidRequest("Invalid verification code".into())); }; let normalized_token = @@ -884,7 +897,7 @@ pub async fn confirm_signup( match tranquil_pds::auth::verification_token::verify_signup_token( &normalized_token, row.channel, - &identifier, + id, ) { Ok(token_data) => { if token_data.did != input.did { @@ -940,10 +953,11 @@ pub async fn confirm_signup( }; let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_welcome( + if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), row.id, + tranquil_pds::comms::Notice::Welcome, hostname, ) .await @@ -962,6 +976,35 @@ pub async fn confirm_signup( })) } +async fn resend_signup_verification( + state: &AppState, + row: &tranquil_db_traits::UserResendVerification, + did: &Did, + context: &'static str, +) -> bool { + let Some(id) = row.channel_identifier() else { + warn!(did = %did, channel = ?row.channel, "We skipped {context} because the account doesn't have a recipient on file"); + return false; + }; + match tranquil_pds::comms::VerificationTarget::resolve(row.channel, id, row.email.as_deref()) { + Ok(target) => { + crate::identity::provision::enqueue_signup_verification( + state, + row.id, + did, + row.channel, + &target, + ) + .await; + true + } + Err(_) => { + warn!(did = %did, channel = ?row.channel, "We skipped {context} because the account doesn't have a valid recipient"); + false + } + } +} + const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120); pub struct AutoResendResult { @@ -990,37 +1033,15 @@ pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option row.email.clone().unwrap_or_default(), - tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(), - _ => return Some(result), - }; - if recipient.is_empty() { - warn!( - "No recipient configured for auto-resend verification: {}", - did - ); - return Some(result); + if resend_signup_verification(state, &row, did, "auto-resend verification").await { + let _ = state + .cache + .set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE) + .await; } - crate::identity::provision::enqueue_signup_verification( - state, - row.id, - did, - row.channel, - &recipient, - ) - .await; - let _ = state - .cache - .set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE) - .await; Some(result) } @@ -1050,32 +1071,12 @@ pub async fn resend_verification( return Err(ApiError::InternalError(None)); } }; - let is_verified = row.channel_verification.has_any_verified(); - if is_verified { + if row.channel_verification.has_any_verified() { return Err(ApiError::InvalidRequest( "Account is already verified".into(), )); } - - let recipient = match row.channel { - tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(), - tranquil_db_traits::CommsChannel::Discord => { - row.discord_username.clone().unwrap_or_default() - } - tranquil_db_traits::CommsChannel::Telegram => { - row.telegram_username.clone().unwrap_or_default() - } - tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(), - }; - - crate::identity::provision::enqueue_signup_verification( - &state, - row.id, - &input.did, - row.channel, - &recipient, - ) - .await; + resend_signup_verification(&state, &row, &input.did, "resend verification").await; Ok(Json(SuccessResponse { success: true })) } diff --git a/crates/tranquil-api/src/server/verify_email.rs b/crates/tranquil-api/src/server/verify_email.rs index d905c30..93370ac 100644 --- a/crates/tranquil-api/src/server/verify_email.rs +++ b/crates/tranquil-api/src/server/verify_email.rs @@ -57,9 +57,9 @@ pub async fn resend_migration_verification( let channel = input .channel .unwrap_or(tranquil_db_traits::CommsChannel::Email); - let identifier = input.identifier.trim().to_lowercase(); + let id = input.identifier.trim().to_lowercase(); - let user = match state.repos.user.get_by_email(&identifier).await { + let user = match state.repos.user.get_by_email(&id).await { Ok(Some(u)) => u, Ok(None) => { return Ok(Json(ResendMigrationVerificationOutput { sent: true })); @@ -73,15 +73,18 @@ pub async fn resend_migration_verification( if user.email_verified { return Ok(Json(ResendMigrationVerificationOutput { sent: true })); } - - crate::identity::provision::enqueue_migration_verification( - &state, - user.id, - &user.did, - channel, - &identifier, - ) - .await; + let target = tranquil_pds::comms::VerificationTarget::resolve(channel, &id, Some(&id)).ok(); + if let Some(target) = target { + crate::identity::provision::enqueue_migration_verification( + &state, user.id, &user.did, channel, &target, + ) + .await; + } else { + warn!( + channel = ?channel, + "We skipped migration verification because unfortunately the account doesn't have a valid recipient" + ); + } info!(did = %user.did, channel = ?channel, "Resent migration verification"); diff --git a/crates/tranquil-api/src/server/verify_token.rs b/crates/tranquil-api/src/server/verify_token.rs index a216a40..0626ed1 100644 --- a/crates/tranquil-api/src/server/verify_token.rs +++ b/crates/tranquil-api/src/server/verify_token.rs @@ -66,8 +66,7 @@ pub async fn verify_token_internal( handle_channel_update(state, &token_data.did, token_data.channel, &identifier).await } VerificationPurpose::Signup => { - handle_signup_verification(state, &token_data.did, token_data.channel, &identifier) - .await + handle_signup_verification(state, &token_data.did, token_data.channel).await } } } @@ -167,7 +166,7 @@ async fn handle_channel_update( info!(did = %did, channel = ?channel, "Channel verified successfully"); - notify_channel_verified(state, user_id, channel, identifier).await; + notify_channel_verified(state, user_id, channel).await; Ok(Json(VerifyTokenOutput { success: true, @@ -177,43 +176,49 @@ async fn handle_channel_update( })) } -async fn notify_channel_verified( - state: &AppState, - user_id: uuid::Uuid, - channel: CommsChannel, - identifier: &str, -) { - let recipient = match channel { - CommsChannel::Telegram => state - .repos - .user - .get_telegram_chat_id(user_id) - .await - .ok() - .flatten() - .map(|id| id.to_string()) - .unwrap_or_else(|| identifier.to_string()), - _ => identifier.to_string(), +async fn notify_channel_verified(state: &AppState, user_id: uuid::Uuid, channel: CommsChannel) { + let prefs = match state.repos.user.get_comms_prefs(user_id).await { + Ok(Some(prefs)) => prefs, + Ok(None) => { + warn!( + user_id = %user_id, + channel = ?channel, + "We skipped channel-verified notice because the account doesn't have comms preferences" + ); + return; + } + Err(e) => { + warn!( + user_id = %user_id, + channel = ?channel, + error = ?e, + "We skipped channel-verified notice because we couldn't load the account's comms preferences" + ); + return; + } }; - if let Err(e) = comms_repo::enqueue_channel_verified( + let Some(recipient) = tranquil_pds::comms::recipient_for(&prefs, channel) else { + warn!( + user_id = %user_id, + channel = ?channel, + "We skipped channel-verified notice because the account doesn't have a valid recipient" + ); + return; + }; + comms_repo::try_channel_verified_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id, - channel, &recipient, &tranquil_config::get().server.hostname, ) - .await - { - warn!(error = %e, "Failed to enqueue channel verified notification"); - } + .await; } async fn handle_signup_verification( state: &AppState, did: &Did, channel: CommsChannel, - identifier: &str, ) -> Result, ApiError> { let user = state .repos @@ -238,7 +243,7 @@ async fn handle_signup_verification( info!(did = %did, channel = ?channel, "Signup verified successfully"); - notify_channel_verified(state, user.id, channel, identifier).await; + notify_channel_verified(state, user.id, channel).await; Ok(Json(VerifyTokenOutput { success: true, diff --git a/crates/tranquil-api/src/telegram_webhook.rs b/crates/tranquil-api/src/telegram_webhook.rs index 0227c8f..4c9cc0d 100644 --- a/crates/tranquil-api/src/telegram_webhook.rs +++ b/crates/tranquil-api/src/telegram_webhook.rs @@ -76,6 +76,13 @@ pub async fn handle_telegram_webhook( return StatusCode::OK; } }; + let username = match tranquil_types::TelegramUsername::new(username) { + Ok(username) => username, + Err(e) => { + warn!(error = %e, "We ignored a /start with an invalid Telegram username"); + return StatusCode::OK; + } + }; debug!( telegram_username = %username, @@ -95,17 +102,21 @@ pub async fn handle_telegram_webhook( chat_id = from.id, "Verified Telegram user and stored chat_id" ); - if let Err(e) = comms_repo::enqueue_channel_verified( - state.repos.user.as_ref(), - state.repos.infra.as_ref(), - user_id, - tranquil_db_traits::CommsChannel::Telegram, - &from.id.to_string(), - &tranquil_config::get().server.hostname, - ) - .await - { - warn!(error = %e, "Failed to enqueue channel verified notification"); + match tranquil_types::TelegramChatId::from_i64(from.id) { + Some(chat_id) => { + comms_repo::try_channel_verified_notice( + state.repos.user.as_ref(), + state.repos.infra.as_ref(), + user_id, + &tranquil_types::Recipient::Telegram(chat_id), + &tranquil_config::get().server.hostname, + ) + .await; + } + None => warn!( + chat_id = from.id, + "We skipped verified notice because the Telegram chat ID can't be 0" + ), } } Ok(None) => { @@ -178,8 +189,8 @@ mod tests { #[test] fn payload_with_extra_whitespace_trimmed() { assert_eq!( - parse_start_handle(Some("/start alice_example_com ")), - Some("alice.example.com".to_string()), + parse_start_handle(Some("/start oys_nel_pet ")), + Some("oys.nel.pet".to_string()), ); } } diff --git a/crates/tranquil-comms/Cargo.toml b/crates/tranquil-comms/Cargo.toml index 984b117..1b975b4 100644 --- a/crates/tranquil-comms/Cargo.toml +++ b/crates/tranquil-comms/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] tranquil-config = { workspace = true } tranquil-signal = { workspace = true } +tranquil-types = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } diff --git a/crates/tranquil-comms/src/email/message.rs b/crates/tranquil-comms/src/email/message.rs index e69dfe7..bd830b3 100644 --- a/crates/tranquil-comms/src/email/message.rs +++ b/crates/tranquil-comms/src/email/message.rs @@ -6,15 +6,16 @@ use uuid::Uuid; use super::types::EmailDomain; use crate::sender::SendError; -use crate::types::{CommsType, QueuedComms}; +use crate::{CommsType, QueuedComms}; pub(super) fn build( from: &Mailbox, qc: &QueuedComms, + to: &tranquil_types::EmailAddress, apply_atmos_categories: bool, ) -> Result { - let to: Mailbox = qc - .recipient + let to: Mailbox = to + .as_str() .parse() .map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?; let subject = qc.subject.as_deref().unwrap_or("Notification"); @@ -101,7 +102,7 @@ fn atmos_category(comms_type: CommsType) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::types::{CommsChannel, CommsStatus}; + use crate::{CommsChannel, CommsStatus}; use chrono::Utc; use uuid::Uuid; @@ -109,6 +110,10 @@ mod tests { "Test Sender ".parse().unwrap() } + fn to(recipient: &str) -> tranquil_types::EmailAddress { + tranquil_types::EmailAddress::new(recipient).unwrap() + } + fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms { QueuedComms { id: Uuid::new_v4(), @@ -135,6 +140,7 @@ mod tests { let msg = build( &from_mailbox(), &fixture("user@nel.pet", Some("Welcome"), "Hello world."), + &to("user@nel.pet"), false, ) .unwrap(); @@ -153,6 +159,7 @@ mod tests { let msg = build( &from_mailbox(), &fixture("user@nel.pet", Some("héllo wörld"), "Body"), + &to("user@jola.dev"), false, ) .unwrap(); @@ -163,12 +170,8 @@ mod tests { #[test] fn header_injection_rejected() { - let result = build( - &from_mailbox(), - &fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"), - false, - ); - assert!(matches!(result, Err(SendError::InvalidRecipient(_)))); + let result = tranquil_types::EmailAddress::new("x@jola.dev\r\nBcc: evil@x"); + assert!(result.is_err()); } #[test] @@ -176,13 +179,14 @@ mod tests { let msg = build( &from_mailbox(), &fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"), + &to("user@jola.dev"), false, ) .expect("subject CRLF should be encoded, not rejected"); let raw = String::from_utf8(msg.formatted()).unwrap(); assert!( !raw.contains("Bcc:"), - "CRLF in subject must not produce a Bcc header: {raw}" + "CRLF in subject mustn't produce a Bcc header: {raw}" ); assert!( raw.contains("Subject: ="), @@ -195,6 +199,7 @@ mod tests { let msg = build( &from_mailbox(), &fixture("user@nel.pet", Some("s"), "b"), + &to("user@jola.dev"), false, ) .unwrap(); @@ -214,6 +219,7 @@ mod tests { let msg = build( &from_mailbox(), &fixture("user@nel.pet", None, "Body"), + &to("user@nel.pet"), false, ) .unwrap(); @@ -226,6 +232,7 @@ mod tests { let msg = build( &from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b"), + &to("user@nel.pet"), false, ) .unwrap(); @@ -239,7 +246,7 @@ mod tests { comms_type: CommsType::PasswordReset, ..fixture("user@nel.pet", Some("s"), "b") }; - let msg = build(&from_mailbox(), &qc, true).unwrap(); + let msg = build(&from_mailbox(), &qc, &to("user@jola.dev"), true).unwrap(); let raw = String::from_utf8(msg.formatted()).unwrap(); assert!(raw.contains("X-Atmos-Category: password-reset")); } @@ -250,7 +257,7 @@ mod tests { comms_type: CommsType::PasswordReset, ..fixture("user@nel.pet", Some("s"), "b") }; - let msg = build(&from_mailbox(), &qc, false).unwrap(); + let msg = build(&from_mailbox(), &qc, &to("user@nel.pet"), false).unwrap(); let raw = String::from_utf8(msg.formatted()).unwrap(); assert!(!raw.contains("X-Atmos-Category")); } @@ -261,7 +268,7 @@ mod tests { comms_type: CommsType::AdminEmail, ..fixture("user@nel.pet", Some("s"), "b") }; - let msg = build(&from_mailbox(), &qc, true).unwrap(); + let msg = build(&from_mailbox(), &qc, &to("user@nel.pet"), true).unwrap(); let raw = String::from_utf8(msg.formatted()).unwrap(); assert!(!raw.contains("X-Atmos-Category")); } diff --git a/crates/tranquil-comms/src/email/mod.rs b/crates/tranquil-comms/src/email/mod.rs index 8db7ea8..012eb7f 100644 --- a/crates/tranquil-comms/src/email/mod.rs +++ b/crates/tranquil-comms/src/email/mod.rs @@ -25,7 +25,7 @@ use self::types::{ SmtpUsername, TlsMode, }; use crate::sender::{CommsSender, SendError}; -use crate::types::{CommsChannel, QueuedComms}; +use crate::{CommsChannel, QueuedComms}; pub struct EmailSender { from: Mailbox, @@ -193,9 +193,22 @@ impl CommsSender for EmailSender { CommsChannel::Email } - async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { - let mut message = - message::build(&self.from, notification, wants_atmos_categories(&self.mode))?; + async fn send( + &self, + notification: &QueuedComms, + recipient: &tranquil_types::Recipient, + ) -> Result<(), SendError> { + let tranquil_types::Recipient::Email(address) = recipient else { + return Err(SendError::InvalidRecipient( + "Recipient isn't an email address".into(), + )); + }; + let mut message = message::build( + &self.from, + notification, + address, + wants_atmos_categories(&self.mode), + )?; if let Some(signer) = &self.dkim { signer.sign(&mut message); } diff --git a/crates/tranquil-comms/src/lib.rs b/crates/tranquil-comms/src/lib.rs index 425c9b5..e4a5ebd 100644 --- a/crates/tranquil-comms/src/lib.rs +++ b/crates/tranquil-comms/src/lib.rs @@ -1,7 +1,6 @@ pub mod email; mod locale; mod sender; -mod types; pub use email::EmailSender; pub use locale::{ @@ -10,6 +9,5 @@ pub use locale::{ }; pub use sender::{ CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number, - is_valid_signal_username, }; -pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms}; +pub use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType, QueuedComms}; diff --git a/crates/tranquil-comms/src/sender.rs b/crates/tranquil-comms/src/sender.rs index ec08797..940df14 100644 --- a/crates/tranquil-comms/src/sender.rs +++ b/crates/tranquil-comms/src/sender.rs @@ -3,7 +3,7 @@ use reqwest::Client; use serde_json::json; use std::time::Duration; -use super::types::{CommsChannel, QueuedComms}; +use tranquil_db_traits::{CommsChannel, QueuedComms}; const HTTP_TIMEOUT_SECS: u64 = 30; const MAX_RETRIES: u32 = 3; @@ -12,7 +12,11 @@ const INITIAL_RETRY_DELAY_MS: u64 = 500; #[async_trait] pub trait CommsSender: Send + Sync { fn channel(&self) -> CommsChannel; - async fn send(&self, notification: &QueuedComms) -> Result<(), SendError>; + async fn send( + &self, + notification: &QueuedComms, + recipient: &tranquil_types::Recipient, + ) -> Result<(), SendError>; } #[derive(Debug, thiserror::Error)] @@ -140,10 +144,6 @@ pub fn is_valid_phone_number(number: &str) -> bool { !remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit()) } -pub fn is_valid_signal_username(username: &str) -> bool { - tranquil_signal::SignalUsername::parse(username).is_ok() -} - const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; #[derive(Clone)] @@ -355,8 +355,17 @@ impl CommsSender for DiscordSender { CommsChannel::Discord } - async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { - let channel_id = self.open_dm_channel(¬ification.recipient).await?; + async fn send( + &self, + notification: &QueuedComms, + recipient: &tranquil_types::Recipient, + ) -> Result<(), SendError> { + let tranquil_types::Recipient::Discord(user_id) = recipient else { + return Err(SendError::InvalidRecipient( + "Recipient isn't a Discord user ID".into(), + )); + }; + let channel_id = self.open_dm_channel(user_id.as_str()).await?; let subject = notification.subject.as_deref().unwrap_or("Notification"); let content = format!("**{}**\n\n{}", subject, notification.body); @@ -453,14 +462,22 @@ impl CommsSender for TelegramSender { CommsChannel::Telegram } - async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { - let chat_id = ¬ification.recipient; + async fn send( + &self, + notification: &QueuedComms, + recipient: &tranquil_types::Recipient, + ) -> Result<(), SendError> { + let tranquil_types::Recipient::Telegram(chat_id) = recipient else { + return Err(SendError::InvalidRecipient( + "Recipient isn't a Telegram chat ID".into(), + )); + }; let subject = escape_html(notification.subject.as_deref().unwrap_or("Notification")); let body = escape_html(¬ification.body); let text = format!("{}\n\n{}", subject, body); let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token); let payload = json!({ - "chat_id": chat_id, + "chat_id": chat_id.as_str(), "text": text, "parse_mode": "HTML" }); @@ -488,9 +505,16 @@ impl CommsSender for SignalSender { CommsChannel::Signal } - async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { - let username = tranquil_signal::SignalUsername::parse(¬ification.recipient) - .map_err(|e| SendError::InvalidRecipient(e.to_string()))?; + async fn send( + &self, + notification: &QueuedComms, + recipient: &tranquil_types::Recipient, + ) -> Result<(), SendError> { + let tranquil_types::Recipient::Signal(username) = recipient else { + return Err(SendError::InvalidRecipient( + "Recipient isn't a Signal username".into(), + )); + }; let client = self .slot @@ -505,7 +529,7 @@ impl CommsSender for SignalSender { let mut last_error = None; for attempt in 0..MAX_RETRIES { - match client.send(&username, message.clone()).await { + match client.send(username, message.clone()).await { Ok(()) => return Ok(()), Err(e) => { let err_str = e.to_string(); diff --git a/crates/tranquil-comms/src/types.rs b/crates/tranquil-comms/src/types.rs deleted file mode 100644 index 2af7bd7..0000000 --- a/crates/tranquil-comms/src/types.rs +++ /dev/null @@ -1,51 +0,0 @@ -use uuid::Uuid; - -pub use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType, QueuedComms}; - -pub struct NewComms { - pub user_id: Uuid, - pub channel: CommsChannel, - pub comms_type: CommsType, - pub recipient: String, - pub subject: Option, - pub body: String, - pub metadata: Option, -} - -impl NewComms { - pub fn new( - user_id: Uuid, - channel: CommsChannel, - comms_type: CommsType, - recipient: String, - subject: Option, - body: String, - ) -> Self { - Self { - user_id, - channel, - comms_type, - recipient, - subject, - body, - metadata: None, - } - } - - pub fn email( - user_id: Uuid, - comms_type: CommsType, - recipient: String, - subject: String, - body: String, - ) -> Self { - Self::new( - user_id, - CommsChannel::Email, - comms_type, - recipient, - Some(subject), - body, - ) - } -} diff --git a/crates/tranquil-comms/tests/email_smtp.rs b/crates/tranquil-comms/tests/email_smtp.rs index b05aace..17d53e2 100644 --- a/crates/tranquil-comms/tests/email_smtp.rs +++ b/crates/tranquil-comms/tests/email_smtp.rs @@ -32,6 +32,10 @@ fn fixture(recipient: &str, subject: &str, body: &str) -> QueuedComms { } } +fn to(recipient: &str) -> tranquil_types::Recipient { + tranquil_types::Recipient::new(CommsChannel::Email, recipient).unwrap() +} + fn build_smarthost_sender(host: &str, port: u16) -> EmailSender { build_smarthost_sender_with_total_timeout(host, port, Duration::from_secs(10)) } @@ -101,7 +105,9 @@ async fn spawn_stub(rcpt_response: &'static [u8]) -> u16 { async fn rcpt_550_classifies_as_smtp_permanent() { let port = spawn_stub(b"550 5.1.1 user unknown\r\n").await; let sender = build_smarthost_sender("127.0.0.1", port); - let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await; + let result = sender + .send(&fixture("oys@nel.pet", "x", "x"), &to("oys@nel.pet")) + .await; match result { Err(SendError::SmtpPermanent(_)) => {} other => panic!("expected SmtpPermanent, got {other:?}"), @@ -112,7 +118,9 @@ async fn rcpt_550_classifies_as_smtp_permanent() { async fn rcpt_421_classifies_as_smtp_transient() { let port = spawn_stub(b"421 4.7.0 try again later\r\n").await; let sender = build_smarthost_sender("127.0.0.1", port); - let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await; + let result = sender + .send(&fixture("oys@nel.pet", "x", "x"), &to("oys@nel.pet")) + .await; match result { Err(SendError::SmtpTransient(_)) => {} other => panic!("expected SmtpTransient, got {other:?}"), @@ -120,10 +128,13 @@ async fn rcpt_421_classifies_as_smtp_transient() { } #[tokio::test] -async fn invalid_recipient_classifies_as_invalid_recipient() { +async fn send_rejects_mismatched_recipient_variant() { let port = spawn_stub(b"250 OK\r\n").await; let sender = build_smarthost_sender("127.0.0.1", port); - let result = sender.send(&fixture("not-an-address", "x", "x")).await; + let recipient = tranquil_types::Recipient::new(CommsChannel::Signal, "oys.01").unwrap(); + let result = sender + .send(&fixture("oys@nel.pet", "x", "x"), &recipient) + .await; match result { Err(SendError::InvalidRecipient(_)) => {} other => panic!("expected InvalidRecipient, got {other:?}"), @@ -146,7 +157,9 @@ async fn smarthost_silent_relay_hits_total_timeout() { let sender = build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500)); let start = std::time::Instant::now(); - let result = sender.send(&fixture("nel@nel.pet", "x", "x")).await; + let result = sender + .send(&fixture("oys@nel.pet", "x", "x"), &to("oys@nel.pet")) + .await; let elapsed = start.elapsed(); match result { Err(SendError::Timeout) => {} diff --git a/crates/tranquil-db-traits/src/infra.rs b/crates/tranquil-db-traits/src/infra.rs index 5407fd3..884a13d 100644 --- a/crates/tranquil-db-traits/src/infra.rs +++ b/crates/tranquil-db-traits/src/infra.rs @@ -44,60 +44,7 @@ impl InviteCodeState { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] -#[serde(rename_all = "lowercase")] -#[sqlx(type_name = "comms_channel", rename_all = "snake_case")] -pub enum CommsChannel { - Email, - Discord, - Telegram, - Signal, -} - -impl CommsChannel { - pub fn as_str(self) -> &'static str { - match self { - Self::Email => "email", - Self::Discord => "discord", - Self::Telegram => "telegram", - Self::Signal => "signal", - } - } - - pub fn display_name(self) -> &'static str { - match self { - Self::Email => "email", - Self::Discord => "Discord", - Self::Telegram => "Telegram", - Self::Signal => "Signal", - } - } -} - -impl std::str::FromStr for CommsChannel { - type Err = InvalidCommsChannel; - - fn from_str(s: &str) -> Result { - match s { - "email" => Ok(Self::Email), - "discord" => Ok(Self::Discord), - "telegram" => Ok(Self::Telegram), - "signal" => Ok(Self::Signal), - _ => Err(InvalidCommsChannel), - } - } -} - -#[derive(Debug, Clone)] -pub struct InvalidCommsChannel; - -impl std::fmt::Display for InvalidCommsChannel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("invalid comms channel") - } -} - -impl std::error::Error for InvalidCommsChannel {} +pub use tranquil_types::{CommsChannel, Recipient}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "comms_type", rename_all = "snake_case")] @@ -226,9 +173,8 @@ pub trait InfraRepository: Send + Sync { async fn enqueue_comms( &self, user_id: Option, - channel: CommsChannel, + recipient: &Recipient, comms_type: CommsType, - recipient: &str, subject: Option<&str>, body: &str, metadata: Option, diff --git a/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs index fb42511..ad39154 100644 --- a/crates/tranquil-db-traits/src/lib.rs +++ b/crates/tranquil-db-traits/src/lib.rs @@ -25,7 +25,7 @@ pub use infra::{ AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PasswordResetInfo, PlcTokenInfo, - QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, + QueuedComms, Recipient, ReservedSigningKey, ReservedSigningKeyFull, }; pub use invite_code::{InviteCodeError, ValidatedInviteCode}; pub use oauth::{ diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs index 1aa7986..1cf5a7c 100644 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -220,7 +220,11 @@ pub trait UserRepository: Send + Sync { channel: CommsChannel, ) -> Result, DbError>; - async fn admin_update_email(&self, did: &Did, email: &str) -> Result; + async fn admin_update_email( + &self, + did: &Did, + email: &tranquil_types::EmailAddress, + ) -> Result; async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result; @@ -266,13 +270,11 @@ pub trait UserRepository: Send + Sync { async fn store_telegram_chat_id( &self, - telegram_username: &str, + telegram_username: &tranquil_types::TelegramUsername, chat_id: i64, handle: Option<&Handle>, ) -> Result, DbError>; - async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result, DbError>; - async fn set_unverified_discord( &self, user_id: Uuid, @@ -281,8 +283,8 @@ pub trait UserRepository: Send + Sync { async fn store_discord_user_id( &self, - discord_username: &str, - discord_id: &str, + discord_username: &tranquil_types::DiscordUsername, + discord_id: &tranquil_types::DiscordUserId, handle: Option<&Handle>, ) -> Result, DbError>; @@ -911,6 +913,25 @@ pub struct UserResendVerification { pub channel_verification: ChannelVerificationStatus, } +macro_rules! channel_identifier { + ($name:ty) => { + impl $name { + pub fn channel_identifier(&self) -> Option<&str> { + match self.channel { + CommsChannel::Email => self.email.as_deref(), + CommsChannel::Discord => self.discord_username.as_deref(), + CommsChannel::Telegram => self.telegram_username.as_deref(), + CommsChannel::Signal => self.signal_username.as_deref(), + } + .filter(|identifier| !identifier.is_empty()) + } + } + }; +} + +channel_identifier!(UserConfirmSignup); +channel_identifier!(UserResendVerification); + #[derive(Debug, Clone)] pub struct UserResetCodeInfo { pub id: Uuid, diff --git a/crates/tranquil-db/src/postgres/infra.rs b/crates/tranquil-db/src/postgres/infra.rs index f01546d..1330459 100644 --- a/crates/tranquil-db/src/postgres/infra.rs +++ b/crates/tranquil-db/src/postgres/infra.rs @@ -5,7 +5,7 @@ use tranquil_db_traits::{ AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest, DeletionRequestWithToken, InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo, - QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode, + QueuedComms, Recipient, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode, }; use tranquil_types::{Did, InviteCode}; use uuid::Uuid; @@ -29,9 +29,8 @@ impl InfraRepository for PostgresInfraRepository { async fn enqueue_comms( &self, user_id: Option, - channel: CommsChannel, + recipient: &Recipient, comms_type: CommsType, - recipient: &str, subject: Option<&str>, body: &str, metadata: Option, @@ -42,9 +41,9 @@ impl InfraRepository for PostgresInfraRepository { VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id"#, user_id, - channel as CommsChannel, + recipient.channel() as CommsChannel, comms_type as CommsType, - recipient, + recipient.as_str(), subject, body, metadata diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs index bedb465..6238403 100644 --- a/crates/tranquil-db/src/postgres/user.rs +++ b/crates/tranquil-db/src/postgres/user.rs @@ -660,10 +660,14 @@ impl UserRepository for PostgresUserRepository { })) } - async fn admin_update_email(&self, did: &Did, email: &str) -> Result { + async fn admin_update_email( + &self, + did: &Did, + email: &tranquil_types::EmailAddress, + ) -> Result { let result = sqlx::query!( "UPDATE users SET email = $1 WHERE did = $2", - email, + email.as_str(), did.as_str() ) .execute(&self.pool) @@ -3300,10 +3304,11 @@ impl UserRepository for PostgresUserRepository { async fn store_discord_user_id( &self, - discord_username: &str, - discord_id: &str, + discord_username: &tranquil_types::DiscordUsername, + discord_id: &tranquil_types::DiscordUserId, handle: Option<&Handle>, ) -> Result, DbError> { + let (discord_username, discord_id) = (discord_username.as_str(), discord_id.as_str()); let result = match handle { Some(h) => sqlx::query_scalar!( "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND handle = $3 RETURNING id", @@ -3362,10 +3367,11 @@ impl UserRepository for PostgresUserRepository { async fn store_telegram_chat_id( &self, - telegram_username: &str, + telegram_username: &tranquil_types::TelegramUsername, chat_id: i64, handle: Option<&Handle>, ) -> Result, DbError> { + let telegram_username = telegram_username.as_str(); let result = match handle { Some(h) => sqlx::query_scalar!( "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id", @@ -3393,14 +3399,6 @@ impl UserRepository for PostgresUserRepository { Ok(result) } - async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result, DbError> { - let row = sqlx::query_scalar!("SELECT telegram_chat_id FROM users WHERE id = $1", user_id) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(row.flatten()) - } - async fn get_password_reset_info( &self, email: &str, diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs index 577d4a6..2fca8cf 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs @@ -572,20 +572,31 @@ pub async fn authorize_post( { Ok(challenge) => { let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = enqueue_2fa_code( + match enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user.id, - &challenge.code, + Notice::TwoFactorCode { + code: &challenge.code, + }, hostname, ) .await { - tracing::warn!( - did = %user.did, - error = %e, - "Failed to enqueue 2FA notification" - ); + Ok(Some(_)) => {} + Ok(None) => { + return show_login_error( + "We couldn't deliver this verification code to your notification channels. Please contact the PDS owner.", + json_response, + ); + } + Err(e) => { + tracing::warn!( + did = %user.did, + error = %e, + "Failed to enqueue 2FA notification" + ); + } } let channel_name = user.preferred_comms_channel.display_name(); if json_response { @@ -907,20 +918,32 @@ pub async fn authorize_select( { Ok(challenge) => { let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = enqueue_2fa_code( + match enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user.id, - &challenge.code, + Notice::TwoFactorCode { + code: &challenge.code, + }, hostname, ) .await { - tracing::warn!( - did = %form.did, - error = %e, - "Failed to enqueue 2FA notification" - ); + Ok(Some(_)) => {} + Ok(None) => { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "We couldn't deliver this verification code to your notification chanels. Please contact the PDS owner.", + ); + } + Err(e) => { + tracing::warn!( + did = %form.did, + error = %e, + "Failed to enqueue 2FA notification" + ); + } } let channel_name = user.preferred_comms_channel.display_name(); return Json(serde_json::json!({ diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs index 0d50292..53ad0a5 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs @@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; use tranquil_db_traits::{ScopePreference, WebauthnChallengeType}; use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier}; -use tranquil_pds::comms::comms_repo::enqueue_2fa_code; +use tranquil_pds::comms::Notice; +use tranquil_pds::comms::comms_repo::enqueue_notice; use tranquil_pds::oauth::{ AuthFlow, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent, }; diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs index d114c20..82aaa4e 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs @@ -1273,16 +1273,31 @@ pub async fn authorize_passkey_finish( .await { Ok(challenge) => { - if let Err(e) = enqueue_2fa_code( + match enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user.id, - &challenge.code, + Notice::TwoFactorCode { + code: &challenge.code, + }, pds_hostname, ) .await { - tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification"); + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "We couldn't deliver the verification code to your notification channels. Please contact the PDS owner! <3" + })), + ) + .into_response(); + } + Err(e) => { + tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification"); + } } let channel_name = user.preferred_comms_channel.display_name(); let redirect_url = format!( diff --git a/crates/tranquil-oauth-server/src/sso_endpoints.rs b/crates/tranquil-oauth-server/src/sso_endpoints.rs index 50485f2..ef5a309 100644 --- a/crates/tranquil-oauth-server/src/sso_endpoints.rs +++ b/crates/tranquil-oauth-server/src/sso_endpoints.rs @@ -916,55 +916,26 @@ pub async fn complete_registration( let verification_channel = input .verification_channel .unwrap_or(tranquil_db_traits::CommsChannel::Email); - let verification_recipient = match verification_channel { - tranquil_db_traits::CommsChannel::Email => { - let email = input - .email + let effective_email = input + .email + .clone() + .or_else(|| { + pending_preview + .provider_email .clone() - .or_else(|| { - pending_preview - .provider_email - .clone() - .map(|e| e.into_inner()) - }) - .map(|e| e.trim().to_string()) - .filter(|e| !e.is_empty()); - match email { - Some(e) if !e.is_empty() => e, - _ => return Err(ApiError::MissingEmail), - } - } - tranquil_db_traits::CommsChannel::Discord => match &input.discord_username { - Some(username) if !username.trim().is_empty() => { - let clean = username.trim().to_lowercase(); - if !tranquil_pds::api::validation::is_valid_discord_username(&clean) { - return Err(ApiError::InvalidRequest( - "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), - )); - } - clean - } - _ => return Err(ApiError::MissingDiscordId), + .map(|e| e.into_inner()) + }) + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()); + let target = tranquil_api::common::extract_verification_recipient( + verification_channel, + &tranquil_api::common::ChannelInput { + email: effective_email.as_deref(), + discord_username: input.discord_username.as_deref(), + telegram_username: input.telegram_username.as_deref(), + signal_username: input.signal_username.as_deref(), }, - tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username { - Some(username) if !username.trim().is_empty() => { - let clean = username.trim().trim_start_matches('@'); - if !tranquil_pds::api::validation::is_valid_telegram_username(clean) { - return Err(ApiError::InvalidRequest( - "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), - )); - } - clean.to_string() - } - _ => return Err(ApiError::MissingTelegramUsername), - }, - tranquil_db_traits::CommsChannel::Signal => match &input.signal_username { - Some(username) if !username.trim().is_empty() => { - username.trim().trim_start_matches('@').to_lowercase() - } - _ => return Err(ApiError::MissingSignalNumber), - }, - }; + )?; let email = input .email @@ -978,18 +949,11 @@ pub async fn complete_registration( .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()); - let email = match &email { - Some(e) => { - if e.len() > 254 { - return Err(ApiError::InvalidEmail); - } - if !tranquil_pds::api::validation::is_valid_email(e) { - return Err(ApiError::InvalidEmail); - } - Some(e.clone()) - } - None => None, - }; + if let Some(e) = &email + && tranquil_types::EmailAddress::new(e).is_err() + { + return Err(ApiError::InvalidEmail); + } let invite_registration = check_registration_invite(&state, input.invite_code.as_deref()).await?; @@ -1336,10 +1300,11 @@ pub async fn complete_registration( } let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_welcome( + if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_notice( state.repos.user.as_ref(), state.repos.infra.as_ref(), user_id.unwrap_or(uuid::Uuid::nil()), + tranquil_pds::comms::Notice::Welcome, hostname, ) .await @@ -1376,7 +1341,7 @@ pub async fn complete_registration( let verification_token = tranquil_pds::auth::verification_token::generate_signup_token( &did, verification_channel, - &verification_recipient, + &target.id, ); let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&verification_token); @@ -1384,8 +1349,7 @@ pub async fn complete_registration( state.repos.user.as_ref(), state.repos.infra.as_ref(), uid, - verification_channel, - &verification_recipient, + &target, &formatted_token, hostname, ) diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index d83fa63..0f5d7e7 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -775,6 +775,31 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(_: tranquil_types::InvalidEmailAddress) -> Self { + Self::InvalidEmail + } +} + +macro_rules! invalid_request_from { + ($($err:ty),* $(,)?) => { + $( + impl From<$err> for ApiError { + fn from(e: $err) -> Self { + Self::InvalidRequest(e.to_string()) + } + } + )* + }; +} + +invalid_request_from!( + tranquil_types::InvalidSignalUsername, + tranquil_types::InvalidTelegramUsername, + tranquil_types::InvalidDiscordUsername, + tranquil_types::InvalidRecipient, +); + impl From for ApiError { fn from(e: crate::plc::PlcError) -> Self { use crate::plc::PlcError; diff --git a/crates/tranquil-pds/src/api/validation.rs b/crates/tranquil-pds/src/api/validation.rs index 3d13b5a..9cf58e0 100644 --- a/crates/tranquil-pds/src/api/validation.rs +++ b/crates/tranquil-pds/src/api/validation.rs @@ -1,102 +1,11 @@ use crate::types::Handle; -use std::fmt; -pub const MAX_EMAIL_LENGTH: usize = 254; -pub const MAX_LOCAL_PART_LENGTH: usize = 64; -pub const MAX_DOMAIN_LENGTH: usize = 253; pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63; -const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-"; pub const MIN_HANDLE_LENGTH: usize = 3; pub const MAX_HANDLE_LENGTH: usize = 253; pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18; -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EmailValidationError { - Empty, - TooLong, - MissingAtSign, - EmptyLocalPart, - LocalPartTooLong, - InvalidLocalPart, - EmptyDomain, - DomainTooLong, - MissingDomainDot, - InvalidDomainLabel, -} - -impl fmt::Display for EmailValidationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Empty => write!(f, "Email cannot be empty"), - Self::TooLong => write!( - f, - "Email exceeds maximum length of {} characters", - MAX_EMAIL_LENGTH - ), - Self::MissingAtSign => write!(f, "Email must contain @"), - Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"), - Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"), - Self::InvalidLocalPart => write!(f, "Email local part contains invalid characters"), - Self::EmptyDomain => write!(f, "Email domain cannot be empty"), - Self::DomainTooLong => write!(f, "Email domain exceeds maximum length"), - Self::MissingDomainDot => write!(f, "Email domain must contain a dot"), - Self::InvalidDomainLabel => write!(f, "Email domain contains invalid label"), - } - } -} - -impl std::error::Error for EmailValidationError {} - -fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> { - if email.is_empty() { - return Err(EmailValidationError::Empty); - } - if email.len() > MAX_EMAIL_LENGTH { - return Err(EmailValidationError::TooLong); - } - let parts: Vec<&str> = email.rsplitn(2, '@').collect(); - if parts.len() != 2 { - return Err(EmailValidationError::MissingAtSign); - } - let domain = parts[0]; - let local = parts[1]; - if local.is_empty() { - return Err(EmailValidationError::EmptyLocalPart); - } - if local.len() > MAX_LOCAL_PART_LENGTH { - return Err(EmailValidationError::LocalPartTooLong); - } - if local.starts_with('.') || local.ends_with('.') || local.contains("..") { - return Err(EmailValidationError::InvalidLocalPart); - } - if !local - .chars() - .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) - { - return Err(EmailValidationError::InvalidLocalPart); - } - if domain.is_empty() { - return Err(EmailValidationError::EmptyDomain); - } - if domain.len() > MAX_DOMAIN_LENGTH { - return Err(EmailValidationError::DomainTooLong); - } - if !domain.contains('.') { - return Err(EmailValidationError::MissingDomainDot); - } - if !domain.split('.').all(|label| { - !label.is_empty() - && label.len() <= MAX_DOMAIN_LABEL_LENGTH - && !label.starts_with('-') - && !label.ends_with('-') - && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') - }) { - return Err(EmailValidationError::InvalidDomainLabel); - } - Ok(()) -} - #[derive(Debug, PartialEq)] pub enum HandleValidationError { Empty, @@ -304,23 +213,6 @@ pub fn validate_service_handle( Ok(handle.to_lowercase()) } -pub fn is_valid_email(email: &str) -> bool { - validate_email_detailed(email.trim()).is_ok() -} - -pub fn is_valid_telegram_username(username: &str) -> bool { - let clean = username.strip_prefix('@').unwrap_or(username); - (5..=32).contains(&clean.len()) && clean.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -pub fn is_valid_discord_username(username: &str) -> bool { - (2..=32).contains(&username.len()) - && username - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.') - && !username.contains("..") -} - #[cfg(test)] mod tests { use super::*; @@ -487,62 +379,4 @@ mod tests { Err(HandleValidationError::Reserved) ); } - - #[test] - fn test_valid_emails() { - assert!(is_valid_email("user@example.com")); - assert!(is_valid_email("user.name@example.com")); - assert!(is_valid_email("user+tag@example.com")); - assert!(is_valid_email("user@sub.example.com")); - assert!(is_valid_email("USER@EXAMPLE.COM")); - assert!(is_valid_email("user123@example123.com")); - assert!(is_valid_email("a@b.co")); - } - #[test] - fn test_invalid_emails() { - assert!(!is_valid_email("")); - assert!(!is_valid_email("user")); - assert!(!is_valid_email("user@")); - assert!(!is_valid_email("@example.com")); - assert!(!is_valid_email("user@example")); - assert!(!is_valid_email("user@@example.com")); - assert!(!is_valid_email("user@.example.com")); - assert!(!is_valid_email("user@example..com")); - assert!(!is_valid_email(".user@example.com")); - assert!(!is_valid_email("user.@example.com")); - assert!(!is_valid_email("user..name@example.com")); - assert!(!is_valid_email("user@-example.com")); - assert!(!is_valid_email("user@example-.com")); - } - #[test] - fn test_trimmed_whitespace() { - assert!(is_valid_email(" user@example.com ")); - } - - #[test] - fn test_valid_discord_usernames() { - assert!(is_valid_discord_username("ab")); - assert!(is_valid_discord_username("alice")); - assert!(is_valid_discord_username("user_name")); - assert!(is_valid_discord_username("user.name")); - assert!(is_valid_discord_username("user123")); - assert!(is_valid_discord_username("a_b.c_d")); - assert!(is_valid_discord_username( - "12345678901234567890123456789012" - )); - } - - #[test] - fn test_invalid_discord_usernames() { - assert!(!is_valid_discord_username("")); - assert!(!is_valid_discord_username("a")); - assert!(!is_valid_discord_username("Alice")); - assert!(!is_valid_discord_username("ALICE")); - assert!(!is_valid_discord_username("user-name")); - assert!(!is_valid_discord_username("user..name")); - assert!(!is_valid_discord_username("user name")); - assert!(!is_valid_discord_username( - "123456789012345678901234567890123" - )); - } } diff --git a/crates/tranquil-pds/src/comms/mod.rs b/crates/tranquil-pds/src/comms/mod.rs index 2342773..2a76e04 100644 --- a/crates/tranquil-pds/src/comms/mod.rs +++ b/crates/tranquil-pds/src/comms/mod.rs @@ -1,10 +1,9 @@ mod service; +pub use service::repo::Notice; +pub use service::{CommsService, VerificationTarget, recipient_for, repo as comms_repo}; pub use tranquil_comms::{ CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender, - NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender, - VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username, - validate_locale, + NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender, VALID_LOCALES, + format_message, get_strings, is_valid_phone_number, validate_locale, }; - -pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel}; diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index 0f0303c..d8af37e 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -7,9 +7,13 @@ use chrono::Utc; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use tranquil_comms::{ - CommsChannel, CommsSender, CommsType, NewComms, SendError, format_message, get_strings, + CommsChannel, CommsSender, CommsType, NotificationStrings, SendError, format_message, + get_strings, }; -use tranquil_db_traits::{InfraRepository, QueuedComms, UserCommsPrefs, UserRepository}; +use tranquil_db_traits::{ + DbError, InfraRepository, QueuedComms, Recipient, UserCommsPrefs, UserRepository, +}; +use tranquil_types::{DiscordUserId, EmailAddress, SignalUsername, TelegramChatId}; use uuid::Uuid; pub struct CommsService { @@ -47,23 +51,6 @@ impl CommsService { self } - pub async fn enqueue(&self, item: NewComms) -> Result { - let id = self - .infra_repo - .enqueue_comms( - Some(item.user_id), - item.channel, - item.comms_type, - &item.recipient, - item.subject.as_deref(), - &item.body, - item.metadata, - ) - .await?; - debug!(comms_id = %id, "Comms enqueued"); - Ok(id) - } - pub fn has_senders(&self) -> bool { !self.senders.is_empty() } @@ -126,8 +113,28 @@ impl CommsService { async fn process_item(&self, item: QueuedComms) { let comms_id = item.id; + + // Re-checking because there's been a trip into the DB and back, can't trust type -> string -> *maybe* type + let recipient = match tranquil_db_traits::Recipient::new(item.channel, &item.recipient) { + Ok(recipient) => recipient, + Err(e) => { + warn!( + comms_id = %comms_id, + error = %e, + "We marked comms item as permanently failed because its recipient is invalid" + ); + if let Err(db_err) = self.mark_failed_permanent(comms_id, &e.to_string()).await { + error!( + comms_id = %comms_id, + error = %db_err, + "Failed to mark comms as failed" + ); + } + return; + } + }; let result = match self.senders.get(&item.channel) { - Some(sender) => sender.send(&item).await, + Some(sender) => sender.send(&item, &recipient).await, None => { warn!( comms_id = %comms_id, @@ -189,131 +196,234 @@ impl CommsService { } } -struct ResolvedRecipient { - channel: tranquil_db_traits::CommsChannel, - recipient: String, +// Think about the situation on Telegram and Discord where the user must message a given bot *first* in order to hydrate a chat ID into our system so that we can in fact send things. +// If we can think of a better way to simply error-out later, instead of falling back to email when say Telegram is in an aborted state, let's do that. +pub struct VerificationTarget { + pub id: String, + pub recipient: Recipient, } -pub fn resolve_delivery_channel( - prefs: &UserCommsPrefs, - channel: tranquil_db_traits::CommsChannel, -) -> tranquil_db_traits::CommsChannel { - resolve_recipient(prefs, channel).channel -} - -fn resolve_recipient( - prefs: &UserCommsPrefs, - channel: tranquil_db_traits::CommsChannel, -) -> ResolvedRecipient { - let email_fallback = || ResolvedRecipient { - channel: tranquil_db_traits::CommsChannel::Email, - recipient: prefs.email.clone().unwrap_or_default(), - }; - match channel { - tranquil_db_traits::CommsChannel::Email => email_fallback(), - tranquil_db_traits::CommsChannel::Telegram => prefs - .telegram_chat_id - .map(|id| ResolvedRecipient { - channel, - recipient: id.to_string(), - }) - .unwrap_or_else(email_fallback), - tranquil_db_traits::CommsChannel::Discord => prefs - .discord_id - .as_ref() - .filter(|id| !id.is_empty()) - .map(|id| ResolvedRecipient { - channel, - recipient: id.clone(), - }) - .unwrap_or_else(email_fallback), - tranquil_db_traits::CommsChannel::Signal => prefs - .signal_username - .as_ref() - .filter(|n| !n.is_empty()) - .map(|n| ResolvedRecipient { - channel, - recipient: n.clone(), - }) - .unwrap_or_else(email_fallback), +impl VerificationTarget { + pub fn direct(recipient: Recipient) -> Self { + Self { + id: recipient.as_str().to_string(), + recipient, + } } + + pub fn resolve( + channel: CommsChannel, + id: &str, + fallback: Option<&str>, + ) -> Result { + let direct = (!channel.verifies_via_bot()) + .then(|| Recipient::new(channel, id)) + .and_then(Result::ok); + let recipient = direct.map_or_else(|| fallback_recipient(fallback), Ok)?; + Ok(Self { + id: id.to_string(), + recipient, + }) + } +} + +fn fallback_recipient(fallback: Option<&str>) -> Result { + let raw = fallback + .map(str::trim) + .filter(|email| !email.is_empty()) + .ok_or(crate::api::error::ApiError::InvalidRequest( + "Verification over this channel needs an email address. Message the bot first".into(), + ))?; + EmailAddress::new(raw) + .map(Recipient::Email) + .map_err(|_| crate::api::error::ApiError::InvalidEmail) +} + +pub fn recipient_for(prefs: &UserCommsPrefs, channel: CommsChannel) -> Option { + let fallback = || email_recipient(prefs); + match channel { + CommsChannel::Email => fallback(), + CommsChannel::Telegram => prefs + .telegram_chat_id + .and_then(TelegramChatId::from_i64) + .map(Recipient::Telegram) + .or_else(fallback), + CommsChannel::Discord => prefs + .discord_id + .as_deref() + .and_then(|id| DiscordUserId::new(id).ok()) + .map(Recipient::Discord) + .or_else(fallback), + CommsChannel::Signal => prefs + .signal_username + .as_deref() + .and_then(|name| SignalUsername::new(name).ok()) + .map(Recipient::Signal) + .or_else(fallback), + } +} + +fn email_recipient(prefs: &UserCommsPrefs) -> Option { + prefs + .email + .as_deref() + .and_then(|email| EmailAddress::new(email).ok()) + .map(Recipient::Email) } pub mod repo { use super::*; - use tranquil_db_traits::DbError; - pub async fn enqueue_welcome( - user_repo: &dyn UserRepository, - infra_repo: &dyn InfraRepository, - user_id: Uuid, - hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let body = format_message( - strings.welcome_body, - &[("hostname", hostname), ("handle", &prefs.handle)], - ); - let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]); - let resolved = resolve_recipient(&prefs, prefs.preferred_channel); - infra_repo - .enqueue_comms( - Some(user_id), - resolved.channel, - CommsType::Welcome, - &resolved.recipient, - Some(&subject), - &body, - None, - ) - .await + pub enum Notice<'a> { + Welcome, + PasswordReset { code: &'a str }, + TwoFactorCode { code: &'a str }, + AccountDeletion { code: &'a str }, + PlcOperation { token: &'a str }, + PasskeyRecovery { url: &'a str }, + ShortTokenEmail { token: &'a str }, + LegacyLoginAlert { channel: CommsChannel, ip: &'a str }, } - pub async fn enqueue_password_reset( + impl Notice<'_> { + fn comms_type(&self) -> CommsType { + match self { + Self::Welcome => CommsType::Welcome, + Self::PasswordReset { .. } => CommsType::PasswordReset, + Self::TwoFactorCode { .. } => CommsType::TwoFactorCode, + Self::AccountDeletion { .. } => CommsType::AccountDeletion, + Self::PlcOperation { .. } => CommsType::PlcOperation, + Self::PasskeyRecovery { .. } => CommsType::PasskeyRecovery, + Self::ShortTokenEmail { .. } => CommsType::EmailUpdate, + Self::LegacyLoginAlert { .. } => CommsType::LegacyLoginAlert, + } + } + + // Yes yes I know, hardcoded, non-email-based accounts will have already bailed by now, don't worry. Emails are not special. + fn channel(&self) -> Option { + match self { + Self::ShortTokenEmail { .. } => Some(CommsChannel::Email), + Self::LegacyLoginAlert { channel, .. } => Some(*channel), + _ => None, + } + } + + fn subject(&self, strings: &NotificationStrings) -> &'static str { + match self { + Self::Welcome => strings.welcome_subject, + Self::PasswordReset { .. } => strings.password_reset_subject, + Self::TwoFactorCode { .. } => strings.two_factor_code_subject, + Self::AccountDeletion { .. } => strings.account_deletion_subject, + Self::PlcOperation { .. } => strings.plc_operation_subject, + Self::PasskeyRecovery { .. } => strings.passkey_recovery_subject, + Self::ShortTokenEmail { .. } => strings.email_update_subject, + Self::LegacyLoginAlert { .. } => strings.legacy_login_subject, + } + } + + fn body(&self, strings: &NotificationStrings, handle: &str, hostname: &str) -> String { + match self { + Self::Welcome => format_message( + strings.welcome_body, + &[("hostname", hostname), ("handle", handle)], + ), + Self::PasswordReset { code } => format_message( + strings.password_reset_body, + &[("handle", handle), ("code", code)], + ), + Self::TwoFactorCode { code } => format_message( + strings.two_factor_code_body, + &[("handle", handle), ("code", code)], + ), + Self::AccountDeletion { code } => format_message( + strings.account_deletion_body, + &[("handle", handle), ("code", code)], + ), + Self::PlcOperation { token } => format_message( + strings.plc_operation_body, + &[("handle", handle), ("token", token)], + ), + Self::PasskeyRecovery { url } => format_message( + strings.passkey_recovery_body, + &[("handle", handle), ("url", url)], + ), + Self::ShortTokenEmail { token } => { + let verify_page = format!("https://{hostname}/app/settings"); + format_message( + strings.short_token_body, + &[ + ("handle", handle), + ("code", token), + ("verify_page", &verify_page), + ], + ) + } + Self::LegacyLoginAlert { ip, .. } => { + let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); + format_message( + strings.legacy_login_body, + &[ + ("handle", handle), + ("timestamp", ×tamp), + ("ip", ip), + ("hostname", hostname), + ], + ) + } + } + } + } + + pub async fn enqueue_notice( user_repo: &dyn UserRepository, infra_repo: &dyn InfraRepository, user_id: Uuid, - code: &str, + notice: Notice<'_>, hostname: &str, - ) -> Result { + ) -> Result, DbError> { let prefs = user_repo .get_comms_prefs(user_id) .await? .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let body = format_message( - strings.password_reset_body, - &[("handle", &prefs.handle), ("code", code)], - ); - let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]); - let resolved = resolve_recipient(&prefs, prefs.preferred_channel); + let channel = notice.channel().unwrap_or(prefs.preferred_channel); + let Some(recipient) = recipient_for(&prefs, channel) else { + warn!( + user_id = %user_id, + channel = ?channel, + "We skipped queuing this notice because the account doesn't have a valid recipient" + ); + return Ok(None); + }; + let strings = get_strings(locale_of(&prefs)); + let subject = format_message(notice.subject(strings), &[("hostname", hostname)]); + let body = notice.body(strings, prefs.handle.as_str(), hostname); infra_repo .enqueue_comms( Some(user_id), - resolved.channel, - CommsType::PasswordReset, - &resolved.recipient, + &recipient, + notice.comms_type(), Some(&subject), &body, None, ) .await + .map(Some) + } + + fn locale_of(prefs: &UserCommsPrefs) -> &str { + prefs.preferred_locale.as_deref().unwrap_or("en") } pub async fn enqueue_email_update( infra_repo: &dyn InfraRepository, user_id: Uuid, - new_email: &str, + new_email: &EmailAddress, handle: &crate::types::Handle, code: &str, hostname: &str, ) -> Result { let strings = get_strings("en"); - let encoded_email = urlencoding::encode(new_email); + let encoded_email = urlencoding::encode(new_email.as_str()); let encoded_token = urlencoding::encode(code); let verify_page = format!("https://{}/app/verify", hostname); let verify_link = format!( @@ -333,186 +443,8 @@ pub mod repo { infra_repo .enqueue_comms( Some(user_id), - tranquil_db_traits::CommsChannel::Email, + &Recipient::Email(new_email.clone()), CommsType::EmailUpdate, - new_email, - Some(&subject), - &body, - None, - ) - .await - } - - pub async fn enqueue_email_update_token( - user_repo: &dyn UserRepository, - infra_repo: &dyn InfraRepository, - user_id: Uuid, - raw_token: &str, - display_code: &str, - hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let current_email = prefs.email.unwrap_or_default(); - let verify_page = format!("https://{}/app/settings", hostname); - let verify_link = format!( - "https://{}/xrpc/_account.authorizeEmailUpdate?token={}", - hostname, - urlencoding::encode(raw_token) - ); - let body = format_message( - strings.email_update_body, - &[ - ("handle", &prefs.handle), - ("code", display_code), - ("verify_page", &verify_page), - ("verify_link", &verify_link), - ], - ); - let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]); - infra_repo - .enqueue_comms( - Some(user_id), - tranquil_db_traits::CommsChannel::Email, - CommsType::EmailUpdate, - ¤t_email, - Some(&subject), - &body, - None, - ) - .await - } - - pub async fn enqueue_short_token_email( - user_repo: &dyn UserRepository, - infra_repo: &dyn InfraRepository, - user_id: Uuid, - token: &str, - hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let current_email = prefs.email.clone().unwrap_or_default(); - - let subject_template = strings.email_update_subject; - let body_template = strings.short_token_body; - let comms_type = CommsType::EmailUpdate; - - let verify_page = format!("https://{}/app/settings", hostname); - let body = format_message( - body_template, - &[ - ("handle", &prefs.handle), - ("code", token), - ("verify_page", &verify_page), - ], - ); - let subject = format_message(subject_template, &[("hostname", hostname)]); - infra_repo - .enqueue_comms( - Some(user_id), - tranquil_db_traits::CommsChannel::Email, - comms_type, - ¤t_email, - Some(&subject), - &body, - None, - ) - .await - } - - pub async fn enqueue_account_deletion( - user_repo: &dyn UserRepository, - infra_repo: &dyn InfraRepository, - user_id: Uuid, - code: &str, - hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let body = format_message( - strings.account_deletion_body, - &[("handle", &prefs.handle), ("code", code)], - ); - let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]); - let resolved = resolve_recipient(&prefs, prefs.preferred_channel); - infra_repo - .enqueue_comms( - Some(user_id), - resolved.channel, - CommsType::AccountDeletion, - &resolved.recipient, - Some(&subject), - &body, - None, - ) - .await - } - - pub async fn enqueue_plc_operation( - user_repo: &dyn UserRepository, - infra_repo: &dyn InfraRepository, - user_id: Uuid, - token: &str, - hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let body = format_message( - strings.plc_operation_body, - &[("handle", &prefs.handle), ("token", token)], - ); - let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]); - let resolved = resolve_recipient(&prefs, prefs.preferred_channel); - infra_repo - .enqueue_comms( - Some(user_id), - resolved.channel, - CommsType::PlcOperation, - &resolved.recipient, - Some(&subject), - &body, - None, - ) - .await - } - - pub async fn enqueue_passkey_recovery( - user_repo: &dyn UserRepository, - infra_repo: &dyn InfraRepository, - user_id: Uuid, - recovery_url: &str, - hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let body = format_message( - strings.passkey_recovery_body, - &[("handle", &prefs.handle), ("url", recovery_url)], - ); - let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]); - let resolved = resolve_recipient(&prefs, prefs.preferred_channel); - infra_repo - .enqueue_comms( - Some(user_id), - resolved.channel, - CommsType::PasskeyRecovery, - &resolved.recipient, Some(&subject), &body, None, @@ -524,8 +456,7 @@ pub mod repo { user_repo: &dyn UserRepository, infra_repo: &dyn InfraRepository, user_id: Uuid, - channel: tranquil_db_traits::CommsChannel, - recipient: &str, + target: &VerificationTarget, token: &str, hostname: &str, ) -> Result { @@ -533,13 +464,13 @@ pub mod repo { .get_comms_prefs(user_id) .await? .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let encoded_recipient = urlencoding::encode(recipient); + let strings = get_strings(locale_of(&prefs)); + let encoded_id = urlencoding::encode(&target.id); let encoded_token = urlencoding::encode(token); let verify_page = format!("https://{}/app/verify", hostname); let verify_link = format!( "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_recipient + hostname, encoded_token, encoded_id ); let body = format_message( strings.migration_verification_body, @@ -557,9 +488,8 @@ pub mod repo { infra_repo .enqueue_comms( Some(user_id), - channel, + &target.recipient, CommsType::MigrationVerification, - recipient, Some(&subject), &body, None, @@ -571,12 +501,10 @@ pub mod repo { user_repo: &dyn UserRepository, infra_repo: &dyn InfraRepository, user_id: Uuid, - channel: tranquil_db_traits::CommsChannel, - recipient: &str, + target: &VerificationTarget, code: &str, hostname: &str, ) -> Result { - let comms_channel = channel; let prefs = match user_repo.get_comms_prefs(user_id).await { Ok(p) => p, Err(e) => { @@ -584,17 +512,14 @@ pub mod repo { None } }; - let locale = prefs - .as_ref() - .and_then(|p| p.preferred_locale.as_deref()) - .unwrap_or("en"); + let locale = prefs.as_ref().map(locale_of).unwrap_or("en"); let strings = get_strings(locale); let encoded_token = urlencoding::encode(code); - let encoded_recipient = urlencoding::encode(recipient); + let encoded_id = urlencoding::encode(&target.id); let verify_page = format!("https://{}/app/verify", hostname); let verify_link = format!( "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_recipient + hostname, encoded_token, encoded_id ); let body = format_message( strings.signup_verification_body, @@ -612,80 +537,8 @@ pub mod repo { infra_repo .enqueue_comms( Some(user_id), - comms_channel, + &target.recipient, CommsType::EmailVerification, - recipient, - Some(&subject), - &body, - None, - ) - .await - } - - pub async fn enqueue_2fa_code( - user_repo: &dyn UserRepository, - infra_repo: &dyn InfraRepository, - user_id: Uuid, - code: &str, - hostname: &str, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let body = format_message( - strings.two_factor_code_body, - &[("handle", &prefs.handle), ("code", code)], - ); - let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]); - let resolved = resolve_recipient(&prefs, prefs.preferred_channel); - infra_repo - .enqueue_comms( - Some(user_id), - resolved.channel, - CommsType::TwoFactorCode, - &resolved.recipient, - Some(&subject), - &body, - None, - ) - .await - } - - pub async fn enqueue_legacy_login( - user_repo: &dyn UserRepository, - infra_repo: &dyn InfraRepository, - user_id: Uuid, - hostname: &str, - client_ip: &str, - channel: tranquil_db_traits::CommsChannel, - ) -> Result { - let prefs = user_repo - .get_comms_prefs(user_id) - .await? - .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let timestamp = chrono::Utc::now() - .format("%Y-%m-%d %H:%M:%S UTC") - .to_string(); - let body = format_message( - strings.legacy_login_body, - &[ - ("handle", &prefs.handle), - ("timestamp", ×tamp), - ("ip", client_ip), - ("hostname", hostname), - ], - ); - let subject = format_message(strings.legacy_login_subject, &[("hostname", hostname)]); - let resolved = resolve_recipient(&prefs, channel); - infra_repo - .enqueue_comms( - Some(user_id), - resolved.channel, - CommsType::LegacyLoginAlert, - &resolved.recipient, Some(&subject), &body, None, @@ -697,20 +550,19 @@ pub mod repo { user_repo: &dyn UserRepository, infra_repo: &dyn InfraRepository, user_id: Uuid, - channel: tranquil_db_traits::CommsChannel, - recipient: &str, + recipient: &Recipient, hostname: &str, ) -> Result { let prefs = user_repo .get_comms_prefs(user_id) .await? .ok_or(DbError::NotFound)?; - let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); + let strings = get_strings(locale_of(&prefs)); let body = format_message( strings.channel_verified_body, &[ ("handle", &prefs.handle), - ("channel", channel.display_name()), + ("channel", recipient.channel().display_name()), ("hostname", hostname), ], ); @@ -718,13 +570,116 @@ pub mod repo { infra_repo .enqueue_comms( Some(user_id), - channel, - CommsType::ChannelVerified, recipient, + CommsType::ChannelVerified, Some(&subject), &body, None, ) .await } + + pub async fn try_channel_verified_notice( + user_repo: &dyn UserRepository, + infra_repo: &dyn InfraRepository, + user_id: Uuid, + recipient: &Recipient, + hostname: &str, + ) { + if let Err(e) = + enqueue_channel_verified(user_repo, infra_repo, user_id, recipient, hostname).await + { + warn!(error = %e, "Failed to enqueue channel verified notification"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bot_channel_recipients_fall_back_to_email() { + let telegram = + VerificationTarget::resolve(CommsChannel::Telegram, "123456789", Some("user@jola.dev")) + .unwrap(); + assert_eq!(telegram.recipient.channel(), CommsChannel::Email); + + let discord = VerificationTarget::resolve( + CommsChannel::Discord, + "274656283714826240", + Some("user@jola.dev"), + ) + .unwrap(); + assert_eq!(discord.recipient.channel(), CommsChannel::Email); + } + + #[test] + fn resolution_keeps_id_for_bot_channels() { + let target = + VerificationTarget::resolve(CommsChannel::Telegram, "oys_01", Some("user@jola.dev")) + .unwrap(); + assert_eq!(target.id, "oys_01"); + assert_eq!(target.recipient.channel(), CommsChannel::Email); + } + + #[test] + fn direct_channels_parse_id() { + let email = VerificationTarget::resolve(CommsChannel::Email, "user@nel.pet", None).unwrap(); + assert_eq!(email.recipient.as_str(), "user@nel.pet"); + + let signal = VerificationTarget::resolve(CommsChannel::Signal, "oys.01", None).unwrap(); + assert_eq!(signal.recipient.channel(), CommsChannel::Signal); + } + + #[test] + fn signal_falls_back_when_id_isnt_username() { + let target = + VerificationTarget::resolve(CommsChannel::Signal, "oys", Some("user@jola.dev")) + .unwrap(); + assert_eq!(target.recipient.channel(), CommsChannel::Email); + } + + #[test] + fn resolve_fails_without_fallback() { + assert!(VerificationTarget::resolve(CommsChannel::Telegram, "oys_01", None).is_err()); + assert!(VerificationTarget::resolve(CommsChannel::Signal, "oys", None).is_err()); + } +} + +#[cfg(test)] +mod recipient_for_tests { + use super::*; + + fn undeliverable_prefs() -> UserCommsPrefs { + UserCommsPrefs { + email: None, + handle: "oys.nel.pet".parse().unwrap(), + preferred_channel: CommsChannel::Telegram, + preferred_locale: None, + telegram_chat_id: None, + discord_id: None, + signal_username: None, + } + } + + #[test] + fn undeliverable_prefs_resolve_to_none_on_every_channel() { + let prefs = undeliverable_prefs(); + assert_eq!(recipient_for(&prefs, CommsChannel::Telegram), None); + assert_eq!(recipient_for(&prefs, CommsChannel::Email), None); + } + + #[test] + fn zero_chat_id_falls_back_to_email() { + let prefs = UserCommsPrefs { + telegram_chat_id: Some(0), + email: Some("oys@jola.dev".into()), + ..undeliverable_prefs() + }; + assert_eq!( + recipient_for(&prefs, CommsChannel::Telegram), + Some(Recipient::Email(EmailAddress::new("oys@jola.dev").unwrap())) + ); + } } diff --git a/crates/tranquil-pds/tests/account_notifications.rs b/crates/tranquil-pds/tests/account_notifications.rs index 7dfb562..27f4370 100644 --- a/crates/tranquil-pds/tests/account_notifications.rs +++ b/crates/tranquil-pds/tests/account_notifications.rs @@ -1,8 +1,54 @@ mod common; -use common::{base_url, client, create_account_and_login, get_test_repos}; +use common::{base_url, client, create_account_and_login, get_test_repos, user_id_of}; use serde_json::{Value, json}; use tranquil_db_traits::{CommsChannel, CommsType}; -use tranquil_types::Did; +use tranquil_types::{Did, Recipient}; + +type Repos = tranquil_db::PostgresRepositories; + +async fn set_prefs( + client: &reqwest::Client, + base: &str, + token: &str, + prefs: serde_json::Value, +) -> reqwest::Response { + client + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&prefs) + .send() + .await + .unwrap() +} + +async fn confirm_channel( + client: &reqwest::Client, + base: &str, + token: &str, + channel: &str, + id: &str, + code: &str, +) -> reqwest::Response { + client + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&json!({"channel": channel, "identifier": id, "code": code})) + .send() + .await + .unwrap() +} + +async fn latest_notices( + repos: &Repos, + user_id: uuid::Uuid, + n: i64, +) -> Vec { + repos + .infra + .get_latest_comms_for_user(user_id, CommsType::ChannelVerified, n) + .await + .expect("DB error") +} #[tokio::test] async fn test_get_notification_history() { @@ -11,21 +57,15 @@ async fn test_get_notification_history() { let repos = get_test_repos().await; let (token, did) = create_account_and_login(&client).await; - let user_id = repos - .user - .get_id_by_did(&Did::new(did).unwrap()) - .await - .expect("DB error") - .expect("User not found"); + let user_id = user_id_of(repos, &Did::new(did).unwrap()).await; for i in 0..3 { repos .infra .enqueue_comms( Some(user_id), - CommsChannel::Email, + &Recipient::new(CommsChannel::Email, "test@nel.pet").unwrap(), CommsType::Welcome, - "test@example.com", Some(&format!("Subject {}", i)), &format!("Body {}", i), None, @@ -57,16 +97,13 @@ async fn test_verify_channel_discord() { let base = base_url().await; let (token, _did) = create_account_and_login(&client).await; - let prefs = json!({ - "discordUsername": "testuser123" - }); - let resp = client - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) - .header("Authorization", format!("Bearer {}", token)) - .json(&prefs) - .send() - .await - .unwrap(); + let resp = set_prefs( + &client, + base, + &token, + json!({ "discordUsername": "testuser123" }), + ) + .await; assert_eq!(resp.status(), 200); let body: Value = resp.json().await.unwrap(); assert!( @@ -93,51 +130,112 @@ async fn test_verify_channel_invalid_code() { let base = base_url().await; let (token, _did) = create_account_and_login(&client).await; - let prefs = json!({ - "telegramUsername": "testuser" - }); - let resp = client - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) - .header("Authorization", format!("Bearer {}", token)) - .json(&prefs) - .send() - .await - .unwrap(); + let resp = set_prefs( + &client, + base, + &token, + json!({ "telegramUsername": "testuser" }), + ) + .await; assert_eq!(resp.status(), 200); - let input = json!({ - "channel": "telegram", - "identifier": "testuser", - "code": "XXXX-XXXX-XXXX-XXXX" - }); - let resp = client - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) - .header("Authorization", format!("Bearer {}", token)) - .json(&input) - .send() - .await - .unwrap(); + let resp = confirm_channel( + &client, + base, + &token, + "telegram", + "testuser", + "XXXX-XXXX-XXXX-XXXX", + ) + .await; assert_eq!(resp.status(), 400); } +#[tokio::test] +async fn test_channel_verified_notice_delivers_over_email_until_chat_id_is_stored() { + let client = client(); + let base = base_url().await; + let repos = get_test_repos().await; + let (token, did) = create_account_and_login(&client).await; + let did = Did::new(did).unwrap(); + let user_id = user_id_of(repos, &did).await; + + let id = "10987654321"; + let resp = set_prefs(&client, base, &token, json!({ "telegramUsername": id })).await; + assert_eq!(resp.status(), 200); + + let code = |did: &Did| { + tranquil_pds::auth::verification_token::generate_channel_update_token( + did, + CommsChannel::Telegram, + id, + ) + }; + let resp = confirm_channel(&client, base, &token, "telegram", id, &code(&did)).await; + assert_eq!(resp.status(), 200); + + let snapshot = |notices: &[tranquil_db_traits::QueuedComms]| { + notices + .iter() + .map(|notice| (notice.channel, notice.recipient.clone())) + .collect::>() + }; + let notices = latest_notices(repos, user_id, 5).await; + assert!( + notices + .iter() + .all(|notice| notice.channel != CommsChannel::Telegram), + "Telegram identifier entered the queue as a chat ID: {:?}", + snapshot(¬ices) + ); + assert!( + notices + .iter() + .any(|notice| notice.channel == CommsChannel::Email), + "The notice should fall back to email: {:?}", + snapshot(¬ices) + ); + + repos + .user + .store_telegram_chat_id( + &tranquil_types::TelegramUsername::new(id).unwrap(), + 10987654321, + None, + ) + .await + .expect("DB error") + .expect("The Telegram username didn't match a user"); + + let resp = confirm_channel(&client, base, &token, "telegram", id, &code(&did)).await; + assert_eq!(resp.status(), 200); + + let notices = latest_notices(repos, user_id, 10).await; + assert!( + notices + .iter() + .any(|notice| notice.channel == CommsChannel::Telegram + && notice.recipient == "10987654321"), + "A stored chat ID should receive the notice: {:?}", + snapshot(¬ices) + ); +} + #[tokio::test] async fn test_verify_channel_not_set() { let client = client(); let base = base_url().await; let (token, _did) = create_account_and_login(&client).await; - let input = json!({ - "channel": "signal", - "identifier": "123456", - "code": "XXXX-XXXX-XXXX-XXXX" - }); - let resp = client - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) - .header("Authorization", format!("Bearer {}", token)) - .json(&input) - .send() - .await - .unwrap(); + let resp = confirm_channel( + &client, + base, + &token, + "signal", + "123456", + "XXXX-XXXX-XXXX-XXXX", + ) + .await; assert_eq!(resp.status(), 400); } @@ -148,17 +246,8 @@ async fn test_update_email_via_notification_prefs() { let repos = get_test_repos().await; let (token, did) = create_account_and_login(&client).await; - let unique_email = format!("newemail_{}@example.com", uuid::Uuid::new_v4()); - let prefs = json!({ - "email": unique_email - }); - let resp = client - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) - .header("Authorization", format!("Bearer {}", token)) - .json(&prefs) - .send() - .await - .unwrap(); + let unique_email = format!("newemail_{}@jola.dev", uuid::Uuid::new_v4()); + let resp = set_prefs(&client, base, &token, json!({ "email": unique_email })).await; assert_eq!(resp.status(), 200); let body: Value = resp.json().await.unwrap(); assert!( @@ -168,12 +257,7 @@ async fn test_update_email_via_notification_prefs() { .contains(&json!("email")) ); - let user_id = repos - .user - .get_id_by_did(&Did::new(did).unwrap()) - .await - .expect("DB error") - .expect("User not found"); + let user_id = user_id_of(repos, &Did::new(did).unwrap()).await; let comms = repos .infra @@ -202,18 +286,7 @@ async fn test_update_email_via_notification_prefs() { .unwrap_or_default() }); - let input = json!({ - "channel": "email", - "identifier": unique_email, - "code": code - }); - let resp = client - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) - .header("Authorization", format!("Bearer {}", token)) - .json(&input) - .send() - .await - .unwrap(); + let resp = confirm_channel(&client, base, &token, "email", &unique_email, &code).await; assert_eq!(resp.status(), 200); let resp = client diff --git a/crates/tranquil-pds/tests/admin_email.rs b/crates/tranquil-pds/tests/admin_email.rs index 00698d7..0541cf5 100644 --- a/crates/tranquil-pds/tests/admin_email.rs +++ b/crates/tranquil-pds/tests/admin_email.rs @@ -16,7 +16,7 @@ async fn test_send_email_success() { .bearer_auth(&access_jwt) .json(&json!({ "recipientDid": did, - "senderDid": "did:plc:admin", + "senderDid": "did:plc:oystercafe", "content": "Hello, this is a test email from the admin.", "subject": "Test Admin Email" })) @@ -26,12 +26,7 @@ async fn test_send_email_success() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.expect("Invalid JSON"); assert_eq!(body["sent"], true); - let user_id = repos - .user - .get_id_by_did(&Did::new(did).unwrap()) - .await - .expect("DB error") - .expect("User not found"); + let user_id = common::user_id_of(repos, &Did::new(did).unwrap()).await; let comms = repos .infra .get_latest_comms_for_user(user_id, CommsType::AdminEmail, 1) @@ -57,7 +52,7 @@ async fn test_send_email_default_subject() { .bearer_auth(&access_jwt) .json(&json!({ "recipientDid": did, - "senderDid": "did:plc:admin", + "senderDid": "did:plc:oystercafe", "content": "Email without subject" })) .send() @@ -66,12 +61,7 @@ async fn test_send_email_default_subject() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.expect("Invalid JSON"); assert_eq!(body["sent"], true); - let user_id = repos - .user - .get_id_by_did(&Did::new(did).unwrap()) - .await - .expect("DB error") - .expect("User not found"); + let user_id = common::user_id_of(repos, &Did::new(did).unwrap()).await; let comms = repos .infra .get_latest_comms_for_user(user_id, CommsType::AdminEmail, 10) @@ -101,7 +91,7 @@ async fn test_send_email_recipient_not_found() { .bearer_auth(&access_jwt) .json(&json!({ "recipientDid": "did:plc:nonexistent", - "senderDid": "did:plc:admin", + "senderDid": "did:plc:oystercafe", "content": "Test content" })) .send() @@ -122,7 +112,7 @@ async fn test_send_email_missing_content() { .bearer_auth(&access_jwt) .json(&json!({ "recipientDid": did, - "senderDid": "did:plc:admin", + "senderDid": "did:plc:oystercafe", "content": "" })) .send() @@ -143,7 +133,7 @@ async fn test_send_email_missing_recipient() { .bearer_auth(&access_jwt) .json(&json!({ "recipientDid": "", - "senderDid": "did:plc:admin", + "senderDid": "did:plc:oystercafe", "content": "Test content" })) .send() @@ -160,7 +150,7 @@ async fn test_send_email_requires_auth() { .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) .json(&json!({ "recipientDid": "did:plc:test", - "senderDid": "did:plc:admin", + "senderDid": "did:plc:oystercafe", "content": "Test content" })) .send() @@ -168,3 +158,39 @@ async fn test_send_email_requires_auth() { .expect("Failed to send email"); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); } + +#[tokio::test] +async fn test_send_email_rejects_garbage_stored_email() { + let client = common::client(); + let base_url = common::base_url().await; + let repos = common::get_test_repos().await; + let (access_jwt, did) = common::create_admin_account_and_login(&client).await; + let user_id = common::user_id_of(repos, &Did::new(did.clone()).unwrap()).await; + repos + .user + .update_email(user_id, "not-an-email") + .await + .expect("DB error"); + + let res = client + .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) + .bearer_auth(&access_jwt) + .json(&json!({ + "recipientDid": did, + "content": "This email should never go out" + })) + .send() + .await + .expect("Failed to send email"); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + let comms = repos + .infra + .get_latest_comms_for_user(user_id, CommsType::AdminEmail, 1) + .await + .expect("DB error"); + assert!( + comms.is_empty(), + "A garbage stored email doesn't reach the queue" + ); +} diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index 3c82d3a..1ccf602 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -586,6 +586,8 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance { TEST_BLOCK_STORE.set(state.block_store.clone()).ok(); if let Some((cache, distributed_rate_limiter)) = config.cache { state = state.with_cache(cache, distributed_rate_limiter); + } else { + tranquil_pds::state::set_rate_limiting_disabled(true); } TEST_APP_STATE.set(state.clone()).ok(); tranquil_sync::listener::start_sequencer_listener(state.clone()).await; @@ -924,6 +926,19 @@ pub async fn get_test_repos() -> &'static Arc TEST_REPOS.get().expect("TEST_REPOS not initialized") } +#[allow(dead_code)] +pub async fn user_id_of( + repos: &tranquil_db::PostgresRepositories, + did: &tranquil_types::Did, +) -> uuid::Uuid { + repos + .user + .get_id_by_did(did) + .await + .expect("DB error") + .expect("User not found") +} + #[allow(dead_code)] pub async fn get_test_block_store() -> &'static tranquil_pds::repo::AnyBlockStore { base_url().await; diff --git a/crates/tranquil-pds/tests/email_update.rs b/crates/tranquil-pds/tests/email_update.rs index 16e995b..f154178 100644 --- a/crates/tranquil-pds/tests/email_update.rs +++ b/crates/tranquil-pds/tests/email_update.rs @@ -69,7 +69,7 @@ async fn test_request_email_update_returns_token_required() { let client = common::client(); let base_url = common::base_url().await; let handle = format!("er{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@nel.pet", handle); let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; let res = client @@ -92,9 +92,9 @@ async fn test_update_email_flow_success() { let base_url = common::base_url().await; let repos = common::get_test_repos().await; let handle = format!("eu{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@nel.pet", handle); let (access_jwt, did) = create_verified_account(&client, base_url, &handle, &email).await; - let new_email = format!("new_{}@example.com", handle); + let new_email = format!("new_{}@jola.dev", handle); let res = client .post(format!( @@ -139,9 +139,9 @@ async fn test_update_email_requires_token_when_verified() { let client = common::client(); let base_url = common::base_url().await; let handle = format!("ed{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@nel.pet", handle); let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; - let new_email = format!("direct_{}@example.com", handle); + let new_email = format!("direct_{}@jola.dev", handle); let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) @@ -160,7 +160,7 @@ async fn test_update_email_same_email_noop() { let client = common::client(); let base_url = common::base_url().await; let handle = format!("es{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@nel.pet", handle); let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; let res = client @@ -182,9 +182,9 @@ async fn test_update_email_invalid_token() { let client = common::client(); let base_url = common::base_url().await; let handle = format!("eb{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@nel.pet", handle); let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; - let new_email = format!("badtok_{}@example.com", handle); + let new_email = format!("badtok_{}@jola.dev", handle); let res = client .post(format!( @@ -219,7 +219,7 @@ async fn test_update_email_no_auth() { let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) - .json(&json!({ "email": "test@example.com" })) + .json(&json!({ "email": "test@jola.dev" })) .send() .await .expect("Failed to send request"); @@ -233,7 +233,7 @@ async fn test_update_email_invalid_format() { let client = common::client(); let base_url = common::base_url().await; let handle = format!("ef{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@nel.pet", handle); let (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await; let res = client @@ -252,7 +252,7 @@ async fn test_confirm_email_confirms_existing_email() { let base_url = common::base_url().await; let repos = common::get_test_repos().await; let handle = format!("ec{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@nel.pet", handle); let res = client .post(format!( @@ -327,7 +327,7 @@ async fn test_confirm_email_rejects_wrong_email() { let base_url = common::base_url().await; let repos = common::get_test_repos().await; let handle = format!("ew{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@jola.dev", handle); let res = client .post(format!( @@ -378,7 +378,7 @@ async fn test_confirm_email_rejects_wrong_email() { .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) .bearer_auth(&access_jwt) .json(&json!({ - "email": "different@example.com", + "email": "different@jola.dev", "token": code })) .send() @@ -394,7 +394,7 @@ async fn test_confirm_email_invalid_token() { let client = common::client(); let base_url = common::base_url().await; let handle = format!("ei{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@jola.dev", handle); let res = client .post(format!( @@ -437,7 +437,7 @@ async fn test_unverified_account_can_update_email_without_token() { let base_url = common::base_url().await; let repos = common::get_test_repos().await; let handle = format!("ev{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email = format!("{}@example.com", handle); + let email = format!("{}@nel.pet", handle); let res = client .post(format!( @@ -473,10 +473,10 @@ async fn test_unverified_account_can_update_email_without_token() { let body: Value = res.json().await.expect("Invalid JSON"); assert_eq!( body["tokenRequired"], false, - "Unverified account should not require token" + "An unverified account shouldn't require a token" ); - let new_email = format!("new_{}@example.com", handle); + let new_email = format!("new_{}@jola.dev", handle); let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .bearer_auth(&access_jwt) @@ -508,11 +508,11 @@ async fn test_update_email_to_same_as_another_user_allowed() { let repos = common::get_test_repos().await; let handle1 = format!("d1{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email1 = format!("{}@example.com", handle1); + let email1 = format!("{}@jola.dev", handle1); let (_, _) = create_verified_account(&client, base_url, &handle1, &email1).await; let handle2 = format!("d2{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); - let email2 = format!("{}@example.com", handle2); + let email2 = format!("{}@jola.dev", handle2); let (access_jwt2, did2) = create_verified_account(&client, base_url, &handle2, &email2).await; let res = client @@ -554,3 +554,29 @@ async fn test_update_email_to_same_as_another_user_allowed() { .email; assert_eq!(user_email, Some(email1.clone())); } + +#[tokio::test] +async fn test_check_email_in_use_distinguishes_empty_from_invalid() { + let client = common::client(); + let base = common::base_url().await; + + let resp = client + .post(format!("{}/xrpc/_account.checkEmailInUse", base)) + .json(&json!({ "email": "not-an-email" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["message"], "Invalid email address"); + + let resp = client + .post(format!("{}/xrpc/_account.checkEmailInUse", base)) + .json(&json!({ "email": " " })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["message"], "Email is required"); +} diff --git a/crates/tranquil-pds/tests/gc_after_delete.rs b/crates/tranquil-pds/tests/gc_after_delete.rs index d237f89..790d472 100644 --- a/crates/tranquil-pds/tests/gc_after_delete.rs +++ b/crates/tranquil-pds/tests/gc_after_delete.rs @@ -15,12 +15,7 @@ async fn test_delete_record_marks_blocks_obsolete() { let (did, jwt) = setup_new_user("gc-after-delete").await; let did = Did::new(did).expect("setup_new_user returned a valid DID"); - let user_id = repos - .user - .get_id_by_did(&did) - .await - .expect("DB error") - .expect("User not found"); + let user_id = user_id_of(repos, &did).await; let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID"); let rkey = Rkey::new(format!("gc_test_{}", Utc::now().timestamp_millis())).expect("valid rkey"); @@ -110,12 +105,7 @@ async fn test_update_record_marks_old_record_block_obsolete() { let (did, jwt) = setup_new_user("gc-after-update").await; let did = Did::new(did).expect("setup_new_user returned a valid DID"); - let user_id = repos - .user - .get_id_by_did(&did) - .await - .expect("DB error") - .expect("User not found"); + let user_id = user_id_of(repos, &did).await; let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID"); let rkey = diff --git a/crates/tranquil-pds/tests/legacy_2fa.rs b/crates/tranquil-pds/tests/legacy_2fa.rs index d7f2d6f..1be5ad6 100644 --- a/crates/tranquil-pds/tests/legacy_2fa.rs +++ b/crates/tranquil-pds/tests/legacy_2fa.rs @@ -1,9 +1,9 @@ mod common; -use common::{base_url, client, create_account_and_login, get_test_repos}; +use common::{base_url, client, create_account_and_login, get_test_repos, user_id_of}; use reqwest::StatusCode; use serde_json::{Value, json}; -use tranquil_db_traits::CommsType; +use tranquil_db_traits::{CommsChannel, CommsType}; use tranquil_types::Did; async fn enable_totp_for_user(did: &str) { @@ -26,13 +26,7 @@ async fn set_allow_legacy_login(did: &str, allow: bool) { async fn get_2fa_code_from_queue(did: &str) -> Option { let repos = get_test_repos().await; - let parsed_did = Did::new(did.to_string()).unwrap(); - let user_id = repos - .user - .get_id_by_did(&parsed_did) - .await - .expect("DB error") - .expect("User not found"); + let user_id = user_id_of(repos, &Did::new(did).unwrap()).await; let comms = repos .infra @@ -56,13 +50,7 @@ async fn get_2fa_code_from_queue(did: &str) -> Option { async fn clear_2fa_challenges_for_user(did: &str) { let repos = get_test_repos().await; - let parsed_did = Did::new(did.to_string()).unwrap(); - let user_id = repos - .user - .get_id_by_did(&parsed_did) - .await - .expect("DB error") - .expect("User not found"); + let user_id = user_id_of(repos, &Did::new(did).unwrap()).await; let _ = repos .infra @@ -72,13 +60,7 @@ async fn clear_2fa_challenges_for_user(did: &str) { async fn set_email_auth_factor(did: &str, enabled: bool) { let repos = get_test_repos().await; - let parsed_did = Did::new(did.to_string()).unwrap(); - let user_id = repos - .user - .get_id_by_did(&parsed_did) - .await - .expect("DB error") - .expect("User not found"); + let user_id = user_id_of(repos, &Did::new(did).unwrap()).await; repos .infra @@ -131,6 +113,55 @@ async fn test_legacy_2fa_auth_factor_required() { ); } +#[tokio::test] +async fn test_legacy_2fa_undeliverable_channel_fails_login() { + let client = client(); + let base = base_url().await; + let repos = get_test_repos().await; + let (_token, did) = create_account_and_login(&client).await; + + enable_totp_for_user(&did).await; + set_allow_legacy_login(&did, true).await; + let parsed_did = Did::new(did.clone()).unwrap(); + repos + .user + .set_channel_verified(&parsed_did, CommsChannel::Discord) + .await + .expect("DB error"); + let user_id = user_id_of(repos, &parsed_did).await; + repos + .user + .update_email(user_id, &format!("undeliverable-{}", uuid::Uuid::new_v4())) + .await + .expect("DB error"); + + let handle = get_handle(&did).await; + let resp = client + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) + .json(&json!({ + "identifier": handle, + "password": "Testpass123!" + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"], "InvalidRequest"); + assert!( + body["message"] + .as_str() + .unwrap_or("") + .contains("couldn't deliver the verification code"), + "the response should say the code couldn't be delivered: {body}" + ); + assert!( + get_2fa_code_from_queue(&did).await.is_none(), + "the comms queue should stay empty for this user" + ); +} + #[tokio::test] async fn test_legacy_2fa_valid_code_succeeds() { let client = client(); diff --git a/crates/tranquil-pds/tests/notifications.rs b/crates/tranquil-pds/tests/notifications.rs index eea6992..3a18b79 100644 --- a/crates/tranquil-pds/tests/notifications.rs +++ b/crates/tranquil-pds/tests/notifications.rs @@ -1,24 +1,18 @@ mod common; use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType}; -use tranquil_types::Did; +use tranquil_types::{Did, Recipient}; #[tokio::test] async fn test_enqueue_comms() { let repos = common::get_test_repos().await; let (_, did) = common::create_account_and_login(&common::client()).await; - let user_id = repos - .user - .get_id_by_did(&Did::new(did).unwrap()) - .await - .expect("DB error") - .expect("User not found"); + let user_id = common::user_id_of(repos, &Did::new(did).unwrap()).await; repos .infra .enqueue_comms( Some(user_id), - CommsChannel::Email, + &Recipient::new(CommsChannel::Email, "test@nel.pet").unwrap(), CommsType::Welcome, - "test@example.com", Some("Test Subject"), "Test body", None, @@ -32,7 +26,7 @@ async fn test_enqueue_comms() { .expect("DB error"); let row = comms.first().expect("Comms not found"); assert_eq!(row.user_id, Some(user_id)); - assert_eq!(row.recipient, "test@example.com"); + assert_eq!(row.recipient, "test@nel.pet"); assert_eq!(row.subject.as_deref(), Some("Test Subject")); assert_eq!(row.body, "Test body"); assert_eq!(row.channel, CommsChannel::Email); @@ -44,26 +38,20 @@ async fn test_enqueue_comms() { async fn test_comms_queue_status_index() { let repos = common::get_test_repos().await; let (_, did) = common::create_account_and_login(&common::client()).await; - let user_id = repos - .user - .get_id_by_did(&Did::new(did).unwrap()) - .await - .expect("DB error") - .expect("User not found"); + let user_id = common::user_id_of(repos, &Did::new(did).unwrap()).await; let initial_count = repos .infra .count_comms_by_type(user_id, CommsType::PasswordReset) .await .expect("Failed to count"); for i in 0..5 { - let recipient = format!("test{}@example.com", i); + let recipient = format!("test{}@jola.dev", i); repos .infra .enqueue_comms( Some(user_id), - CommsChannel::Email, + &Recipient::new(CommsChannel::Email, &recipient).unwrap(), CommsType::PasswordReset, - &recipient, Some("Test"), "Body", None, diff --git a/crates/tranquil-pds/tests/repair_leaf_loss.rs b/crates/tranquil-pds/tests/repair_leaf_loss.rs index 3d5a05a..493e86f 100644 --- a/crates/tranquil-pds/tests/repair_leaf_loss.rs +++ b/crates/tranquil-pds/tests/repair_leaf_loss.rs @@ -57,12 +57,7 @@ async fn repair_fails_loud_on_missing_leaf_block() { res.text().await ); - let user_id = repos - .user - .get_id_by_did(&Did::new(did.clone()).unwrap()) - .await - .expect("DB error") - .expect("user not found"); + let user_id = user_id_of(repos, &Did::new(did.clone()).unwrap()).await; let root_str = repos .repo diff --git a/crates/tranquil-pds/tests/repo_batch.rs b/crates/tranquil-pds/tests/repo_batch.rs index b86f98c..b003a2c 100644 --- a/crates/tranquil-pds/tests/repo_batch.rs +++ b/crates/tranquil-pds/tests/repo_batch.rs @@ -498,14 +498,7 @@ async fn test_apply_writes_create_then_delete_same_rkey() { } async fn repo_id_for_did(did: &str) -> uuid::Uuid { - let repos = get_test_repos().await; - let parsed = Did::new(did).expect("valid did"); - repos - .user - .get_id_by_did(&parsed) - .await - .expect("lookup user_id") - .expect("user exists") + user_id_of(get_test_repos().await, &Did::new(did).expect("valid DID")).await } async fn follow_uris_pointing_to(repo_id: uuid::Uuid, target_did: &str) -> Vec { diff --git a/crates/tranquil-pds/tests/security_fixes.rs b/crates/tranquil-pds/tests/security_fixes.rs index 38d2298..618c654 100644 --- a/crates/tranquil-pds/tests/security_fixes.rs +++ b/crates/tranquil-pds/tests/security_fixes.rs @@ -1,5 +1,5 @@ mod common; -use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username}; +use tranquil_pds::comms::{SendError, is_valid_phone_number}; use tranquil_pds::image::{ImageError, ImageProcessor}; #[test] @@ -46,48 +46,6 @@ fn test_phone_number_validation() { } } -#[test] -fn test_signal_username_validation() { - assert!(is_valid_signal_username("alice.01")); - assert!(is_valid_signal_username("bob_smith.99")); - assert!(is_valid_signal_username("user123.42")); - assert!(is_valid_signal_username("lu1.01")); - assert!(is_valid_signal_username("a_very_long_username_here.55")); - assert!(is_valid_signal_username("alice.123")); - assert!(is_valid_signal_username("alice.999999999")); - assert!(is_valid_signal_username("alice.18446744073709551615")); - - assert!(!is_valid_signal_username("alice")); - assert!(!is_valid_signal_username("alice.1")); - assert!(!is_valid_signal_username("alice.001")); - assert!(!is_valid_signal_username("abc.00")); - assert!(!is_valid_signal_username("alice.0")); - assert!(!is_valid_signal_username("alice.999999999999999999999")); - assert!(!is_valid_signal_username(".01")); - assert!(!is_valid_signal_username("ab.01")); - assert!(!is_valid_signal_username("")); - assert!(!is_valid_signal_username("1alice.01")); - assert!(!is_valid_signal_username("alice!.01")); - assert!(!is_valid_signal_username("alice .01")); - - assert!(!is_valid_signal_username("a".repeat(33).as_str())); - - [ - "alice.01; rm -rf /", - "bob.01 && cat /etc/passwd", - "user.01`id`", - "test.01$(whoami)", - ] - .iter() - .for_each(|malicious| { - assert!( - !is_valid_signal_username(malicious), - "Command injection '{}' should be rejected", - malicious - ); - }); -} - #[test] fn test_image_file_size_limits() { let processor = ImageProcessor::new(); diff --git a/crates/tranquil-pds/tests/sso.rs b/crates/tranquil-pds/tests/sso.rs index 011fc94..9437778 100644 --- a/crates/tranquil-pds/tests/sso.rs +++ b/crates/tranquil-pds/tests/sso.rs @@ -3,7 +3,7 @@ mod common; use common::{base_url, client, create_account_and_login, get_test_repos}; use reqwest::StatusCode; use serde_json::{Value, json}; -use tranquil_db_traits::{CommsChannel, SsoAction, SsoProviderType}; +use tranquil_db_traits::{CommsChannel, CommsType, SsoAction, SsoProviderType}; use tranquil_oauth::{ AuthorizationRequestParameters, CodeChallengeMethod, RequestData, ResponseType, }; @@ -781,6 +781,7 @@ async fn test_sso_complete_registration_multichannel_discord() { .json(&json!({ "token": token, "handle": handle_prefix, + "email": "sso_discord_reg@jola.dev", "verification_channel": "discord", "discord_username": discord_id })) @@ -810,6 +811,14 @@ async fn test_sso_complete_registration_multichannel_discord() { let user = user.unwrap(); assert_eq!(user.channel, CommsChannel::Discord); assert_eq!(user.discord_username.as_deref(), Some(discord_id)); + let quered = repos + .infra + .get_latest_comms_for_user(user.id, CommsType::EmailVerification, 1) + .await + .unwrap(); + let comms = quered.first().expect("We queued up a verification email"); + assert_eq!(comms.channel, CommsChannel::Email); + assert_eq!(comms.recipient, "sso_discord_reg@jola.dev"); } #[tokio::test] diff --git a/crates/tranquil-pds/tests/store_parity.rs b/crates/tranquil-pds/tests/store_parity.rs index c93acb0..d27f153 100644 --- a/crates/tranquil-pds/tests/store_parity.rs +++ b/crates/tranquil-pds/tests/store_parity.rs @@ -4,7 +4,7 @@ mod helpers; use std::sync::Arc; use tranquil_db::PostgresRepositories; use tranquil_db_traits::{Backlink, BacklinkPath, CommsChannel, CommsType}; -use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid}; +use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Recipient, Rkey, Tid}; use uuid::Uuid; async fn create_store_repos() -> Arc { @@ -1199,9 +1199,8 @@ async fn parity_comms_queue() { f.pg.infra .enqueue_comms( Some(pg_uid), - CommsChannel::Email, + &Recipient::new(CommsChannel::Email, "test@jola.dev").unwrap(), CommsType::Welcome, - "test@example.com", Some("Welcome"), "Welcome body", None, @@ -1214,9 +1213,8 @@ async fn parity_comms_queue() { .infra .enqueue_comms( Some(store_uid), - CommsChannel::Email, + &Recipient::new(CommsChannel::Email, "test@jola.dev").unwrap(), CommsType::Welcome, - "test@example.com", Some("Welcome"), "Welcome body", None, diff --git a/crates/tranquil-pds/tests/user_blocks_reachability.rs b/crates/tranquil-pds/tests/user_blocks_reachability.rs index b94cfd9..28175e4 100644 --- a/crates/tranquil-pds/tests/user_blocks_reachability.rs +++ b/crates/tranquil-pds/tests/user_blocks_reachability.rs @@ -109,13 +109,7 @@ async fn assert_record_gone(did: &Did, rkey: &Rkey) { } async fn user_id_for(did: &Did) -> uuid::Uuid { - get_test_repos() - .await - .user - .get_id_by_did(did) - .await - .expect("DB error looking up the user id") - .expect("User not found") + user_id_of(get_test_repos().await, did).await } #[tokio::test] diff --git a/crates/tranquil-pds/tests/validation_edge_cases.rs b/crates/tranquil-pds/tests/validation_edge_cases.rs index 2b6f696..aa80a35 100644 --- a/crates/tranquil-pds/tests/validation_edge_cases.rs +++ b/crates/tranquil-pds/tests/validation_edge_cases.rs @@ -1,7 +1,6 @@ use tranquil_lexicon::is_valid_did; use tranquil_pds::api::validation::{ - HandleValidationError, MAX_DOMAIN_LABEL_LENGTH, MAX_EMAIL_LENGTH, MAX_LOCAL_PART_LENGTH, - MAX_SERVICE_HANDLE_LOCAL_PART, is_valid_email, validate_short_handle, + HandleValidationError, MAX_SERVICE_HANDLE_LOCAL_PART, validate_short_handle, }; use tranquil_pds::validation::{validate_collection_nsid, validate_password, validate_record_key}; @@ -261,83 +260,3 @@ fn test_handle_whitespace_handling() { Err(HandleValidationError::ContainsSpaces) )); } - -#[test] -fn test_email_length_boundaries() { - let long_local = format!("{}@example.com", "a".repeat(MAX_LOCAL_PART_LENGTH)); - assert!(is_valid_email(&long_local)); - - let too_long_local = format!("{}@example.com", "a".repeat(MAX_LOCAL_PART_LENGTH + 1)); - assert!(!is_valid_email(&too_long_local)); - - let very_long_email = format!("a@{}.com", "a".repeat(240)); - if very_long_email.len() <= MAX_EMAIL_LENGTH { - assert!(is_valid_email(&very_long_email) || !is_valid_email(&very_long_email)); - } -} - -#[test] -fn test_email_local_part_special_chars() { - assert!(is_valid_email("user.name@example.com")); - assert!(is_valid_email("user+tag@example.com")); - assert!(is_valid_email("user!def@example.com")); - assert!(is_valid_email("user#abc@example.com")); - assert!(is_valid_email("user$def@example.com")); - assert!(is_valid_email("user%abc@example.com")); - assert!(is_valid_email("user&def@example.com")); - assert!(is_valid_email("user'abc@example.com")); - assert!(is_valid_email("user*def@example.com")); - assert!(is_valid_email("user=abc@example.com")); - assert!(is_valid_email("user?def@example.com")); - assert!(is_valid_email("user^abc@example.com")); - assert!(is_valid_email("user_def@example.com")); - assert!(is_valid_email("user`abc@example.com")); - assert!(is_valid_email("user{def@example.com")); - assert!(is_valid_email("user|abc@example.com")); - assert!(is_valid_email("user}def@example.com")); - assert!(is_valid_email("user~abc@example.com")); - assert!(is_valid_email("user-def@example.com")); -} - -#[test] -fn test_email_local_part_dots() { - assert!(!is_valid_email(".user@example.com")); - assert!(!is_valid_email("user.@example.com")); - assert!(!is_valid_email("user..name@example.com")); - assert!(is_valid_email("user.name@example.com")); - assert!(is_valid_email("u.s.e.r@example.com")); -} - -#[test] -fn test_email_domain_labels() { - let long_label = "a".repeat(MAX_DOMAIN_LABEL_LENGTH); - let valid_domain = format!("user@{}.com", long_label); - assert!(is_valid_email(&valid_domain)); - - let too_long_label = "a".repeat(MAX_DOMAIN_LABEL_LENGTH + 1); - let invalid_domain = format!("user@{}.com", too_long_label); - assert!(!is_valid_email(&invalid_domain)); -} - -#[test] -fn test_email_domain_hyphens() { - assert!(!is_valid_email("user@-example.com")); - assert!(!is_valid_email("user@example-.com")); - assert!(is_valid_email("user@ex-ample.com")); - assert!(is_valid_email("user@ex--ample.com")); -} - -#[test] -fn test_email_domain_must_have_dot() { - assert!(!is_valid_email("user@localhost")); - assert!(!is_valid_email("user@example")); - assert!(is_valid_email("user@a.b")); -} - -#[test] -fn test_email_invalid_chars() { - assert!(!is_valid_email("user name@example.com")); - assert!(!is_valid_email("user\t@example.com")); - assert!(!is_valid_email("user\n@example.com")); - assert!(!is_valid_email("user@exam ple.com")); -} diff --git a/crates/tranquil-signal/Cargo.toml b/crates/tranquil-signal/Cargo.toml index 3db515e..aa80846 100644 --- a/crates/tranquil-signal/Cargo.toml +++ b/crates/tranquil-signal/Cargo.toml @@ -9,6 +9,7 @@ fjall-store = ["dep:fjall"] [dependencies] presage = { workspace = true } +tranquil-types = { workspace = true } async-trait = { workspace = true } chrono = { workspace = true } fjall = { version = "3", optional = true } diff --git a/crates/tranquil-signal/src/client.rs b/crates/tranquil-signal/src/client.rs index 4c5348f..27358dc 100644 --- a/crates/tranquil-signal/src/client.rs +++ b/crates/tranquil-signal/src/client.rs @@ -14,71 +14,7 @@ use tokio_util::sync::CancellationToken; use url::Url; use crate::store::PgSignalStore; - -#[derive(Debug, Clone)] -pub struct SignalUsername(String); - -#[derive(Debug, Clone)] -pub struct InvalidSignalUsername(String); - -impl fmt::Display for InvalidSignalUsername { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "invalid signal username: {}", self.0) - } -} - -impl std::error::Error for InvalidSignalUsername {} - -impl SignalUsername { - pub fn parse(username: &str) -> Result { - let reject = || Err(InvalidSignalUsername(username.to_string())); - - let Some((base, discriminator)) = username.rsplit_once('.') else { - return reject(); - }; - - if !matches!(base.len(), 3..=32) { - return reject(); - } - - if !base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) { - return reject(); - } - - if !base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { - return reject(); - } - - if !is_valid_discriminator(discriminator) { - return reject(); - } - - Ok(Self(username.to_string())) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -fn is_valid_discriminator(s: &str) -> bool { - if !s.chars().all(|c| c.is_ascii_digit()) { - return false; - } - if !matches!(s.len(), 2..=20) { - return false; - } - if s.len() > 2 && s.starts_with('0') { - return false; - } - s.parse::().is_ok_and(|n| n != 0) -} - -impl fmt::Display for SignalUsername { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} +use tranquil_types::SignalUsername; #[derive(Debug, Clone)] pub struct DeviceName(String); diff --git a/crates/tranquil-signal/src/lib.rs b/crates/tranquil-signal/src/lib.rs index 794b856..82e1c4e 100644 --- a/crates/tranquil-signal/src/lib.rs +++ b/crates/tranquil-signal/src/lib.rs @@ -10,11 +10,12 @@ mod tests; mod tests_fjall; pub use client::{ - DeviceName, InvalidDeviceName, InvalidSignalUsername, LinkGeneration, LinkResult, MessageBody, - MessageTooLong, SignalClient, SignalError, SignalSlot, SignalUsername, + DeviceName, InvalidDeviceName, LinkGeneration, LinkResult, MessageBody, MessageTooLong, + SignalClient, SignalError, SignalSlot, }; pub use presage; pub use store::PgSignalStore; +pub use tranquil_types::{InvalidSignalUsername, SignalUsername}; #[async_trait::async_trait] pub trait SignalStoreProvider: Send + Sync { diff --git a/crates/tranquil-storage/src/lib.rs b/crates/tranquil-storage/src/lib.rs index fea57b8..16fc326 100644 --- a/crates/tranquil-storage/src/lib.rs +++ b/crates/tranquil-storage/src/lib.rs @@ -126,7 +126,9 @@ mod s3 { .clone() .expect("storage.s3_bucket (S3_BUCKET) must be set"); let client = create_s3_client().await; - let path = cfg.storage.s3_path + let path = cfg + .storage + .s3_path .trim_start_matches("/") .trim_end_matches("/") .to_string(); @@ -139,7 +141,7 @@ mod s3 { fn resolve_path(&self, key: &str) -> String { if self.path.is_empty() { - return key.to_string() + return key.to_string(); } format!("{}/{}", self.path, key) diff --git a/crates/tranquil-store/src/metastore/client.rs b/crates/tranquil-store/src/metastore/client.rs index e7172ef..f615544 100644 --- a/crates/tranquil-store/src/metastore/client.rs +++ b/crates/tranquil-store/src/metastore/client.rs @@ -13,16 +13,16 @@ use tranquil_db_traits::{ ImportBlock, ImportRecord, ImportRepoError, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, MigrationReactivationError, MigrationReactivationInput, NotificationHistoryRow, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, - PlcTokenInfo, PruneCount, QueuedComms, ReactivatedAccountInfo, RecoverPasskeyAccountInput, - RecoverPasskeyAccountResult, RepoAccountInfo, RepoIdentity, RepoInfo, RepoListItem, - RepoWithoutRev, ReservedSigningKey, ReservedSigningKeyFull, ScheduledDeletionAccount, - ScopePreference, SequenceNumber, SequencedEvent, StoredBackupCode, StoredPasskey, - TokenFamilyId, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo, UserCommsPrefs, - UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, UserForDidDoc, - UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery, - UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, - UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, - UserLoginFull, UserLoginInfo, UserNeedingRecordBlobsBackfill, UserPasswordInfo, + PlcTokenInfo, PruneCount, QueuedComms, ReactivatedAccountInfo, Recipient, + RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, RepoAccountInfo, RepoIdentity, + RepoInfo, RepoListItem, RepoWithoutRev, ReservedSigningKey, ReservedSigningKeyFull, + ScheduledDeletionAccount, ScopePreference, SequenceNumber, SequencedEvent, StoredBackupCode, + StoredPasskey, TokenFamilyId, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo, + UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, + UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, + UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, + UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, + UserLoginCheck, UserLoginFull, UserLoginInfo, UserNeedingRecordBlobsBackfill, UserPasswordInfo, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey, UserWithoutBlocks, ValidatedInviteCode, WebauthnChallengeType, @@ -1793,9 +1793,8 @@ impl tranquil_db_traits::InfraRepository for MetastoreCl async fn enqueue_comms( &self, user_id: Option, - channel: CommsChannel, + recipient: &Recipient, comms_type: CommsType, - recipient: &str, subject: Option<&str>, body: &str, metadata: Option, @@ -1804,9 +1803,8 @@ impl tranquil_db_traits::InfraRepository for MetastoreCl self.pool .send(MetastoreRequest::Infra(InfraRequest::EnqueueComms { user_id, - channel, + recipient: recipient.clone(), comms_type, - recipient: recipient.to_owned(), subject: subject.map(str::to_owned), body: body.to_owned(), metadata, @@ -3736,12 +3734,16 @@ impl tranquil_db_traits::UserRepository for MetastoreCli recv(rx).await } - async fn admin_update_email(&self, did: &Did, email: &str) -> Result { + async fn admin_update_email( + &self, + did: &Did, + email: &tranquil_types::EmailAddress, + ) -> Result { let (tx, rx) = oneshot::channel(); self.pool .send(MetastoreRequest::User(UserRequest::AdminUpdateEmail { did: did.clone(), - email: email.to_owned(), + email: email.clone(), tx, }))?; recv(rx).await @@ -3888,14 +3890,14 @@ impl tranquil_db_traits::UserRepository for MetastoreCli async fn store_telegram_chat_id( &self, - telegram_username: &str, + telegram_username: &tranquil_types::TelegramUsername, chat_id: i64, handle: Option<&Handle>, ) -> Result, DbError> { let (tx, rx) = oneshot::channel(); self.pool .send(MetastoreRequest::User(UserRequest::StoreTelegramChatId { - telegram_username: telegram_username.to_owned(), + telegram_username: telegram_username.clone(), chat_id, handle: handle.map(|h| h.to_string()), tx, @@ -3903,16 +3905,6 @@ impl tranquil_db_traits::UserRepository for MetastoreCli recv(rx).await } - async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result, DbError> { - let (tx, rx) = oneshot::channel(); - self.pool - .send(MetastoreRequest::User(UserRequest::GetTelegramChatId { - user_id, - tx, - }))?; - recv(rx).await - } - async fn set_unverified_discord( &self, user_id: Uuid, @@ -3930,15 +3922,15 @@ impl tranquil_db_traits::UserRepository for MetastoreCli async fn store_discord_user_id( &self, - discord_username: &str, - discord_id: &str, + discord_username: &tranquil_types::DiscordUsername, + discord_id: &tranquil_types::DiscordUserId, handle: Option<&Handle>, ) -> Result, DbError> { let (tx, rx) = oneshot::channel(); self.pool .send(MetastoreRequest::User(UserRequest::StoreDiscordUserId { - discord_username: discord_username.to_owned(), - discord_id: discord_id.to_owned(), + discord_username: discord_username.clone(), + discord_id: discord_id.clone(), handle: handle.map(|h| h.to_string()), tx, }))?; diff --git a/crates/tranquil-store/src/metastore/handler.rs b/crates/tranquil-store/src/metastore/handler.rs index 82d0945..20ca681 100644 --- a/crates/tranquil-store/src/metastore/handler.rs +++ b/crates/tranquil-store/src/metastore/handler.rs @@ -15,7 +15,7 @@ use tranquil_db_traits::{ InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, MigrationReactivationError, MigrationReactivationInput, NotificationHistoryRow, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, PlcTokenInfo, QueuedComms, - ReactivatedAccountInfo, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, + ReactivatedAccountInfo, Recipient, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, RefreshSessionResult, RepoIdentity, ReservedSigningKey, ReservedSigningKeyFull, ScheduledDeletionAccount, ScopePreference, SequenceNumber, SequencedEvent, SessionId, StoredBackupCode, StoredPasskey, TokenFamilyId, TotpRecord, TotpRecordState, User2faStatus, @@ -1148,7 +1148,7 @@ pub enum UserRequest { }, AdminUpdateEmail { did: Did, - email: String, + email: tranquil_types::EmailAddress, tx: Tx, }, AdminUpdateHandle { @@ -1202,23 +1202,19 @@ pub enum UserRequest { tx: Tx<()>, }, StoreTelegramChatId { - telegram_username: String, + telegram_username: tranquil_types::TelegramUsername, chat_id: i64, handle: Option, tx: Tx>, }, - GetTelegramChatId { - user_id: Uuid, - tx: Tx>, - }, SetUnverifiedDiscord { user_id: Uuid, discord_username: String, tx: Tx<()>, }, StoreDiscordUserId { - discord_username: String, - discord_id: String, + discord_username: tranquil_types::DiscordUsername, + discord_id: tranquil_types::DiscordUserId, handle: Option, tx: Tx>, }, @@ -1744,7 +1740,6 @@ impl UserRequest { | Self::ClearSignal { user_id, .. } | Self::SetUnverifiedSignal { user_id, .. } | Self::SetUnverifiedTelegram { user_id, .. } - | Self::GetTelegramChatId { user_id, .. } | Self::SetUnverifiedDiscord { user_id, .. } | Self::VerifyEmailChannel { user_id, .. } | Self::VerifyDiscordChannel { user_id, .. } @@ -1810,9 +1805,8 @@ impl UserRequest { pub enum InfraRequest { EnqueueComms { user_id: Option, - channel: CommsChannel, + recipient: Recipient, comms_type: CommsType, - recipient: String, subject: Option, body: String, metadata: Option, @@ -3870,9 +3864,8 @@ fn dispatch_infra(state: &HandlerState, req: InfraRequest) { match req { InfraRequest::EnqueueComms { user_id, - channel, - comms_type, recipient, + comms_type, subject, body, metadata, @@ -3883,9 +3876,8 @@ fn dispatch_infra(state: &HandlerState, req: InfraRequest) { .infra_ops() .enqueue_comms( user_id, - channel, - comms_type, &recipient, + comms_type, subject.as_deref(), &body, metadata, @@ -5300,9 +5292,6 @@ fn dispatch_user(state: &HandlerState, req: UserReque .map_err(metastore_to_db), ); } - UserRequest::GetTelegramChatId { user_id, tx } => { - let _ = tx.send(user.get_telegram_chat_id(user_id).map_err(metastore_to_db)); - } UserRequest::SetUnverifiedDiscord { user_id, discord_username, diff --git a/crates/tranquil-store/src/metastore/infra_ops.rs b/crates/tranquil-store/src/metastore/infra_ops.rs index 85c6bca..d5fea17 100644 --- a/crates/tranquil-store/src/metastore/infra_ops.rs +++ b/crates/tranquil-store/src/metastore/infra_ops.rs @@ -22,9 +22,9 @@ use super::user_hash::UserHashMap; use super::users::UserValue; use tranquil_db_traits::{ - AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, - DeletionRequestWithToken, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, - InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo, QueuedComms, + AdminAccountInfo, CommsStatus, CommsType, DeletionRequest, DeletionRequestWithToken, + InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, + InviteCodeUse, NotificationHistoryRow, PlcTokenInfo, QueuedComms, Recipient, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode, }; use tranquil_types::{Did, Handle, InviteCode}; @@ -145,13 +145,11 @@ impl InfraOps { }) } - #[allow(clippy::too_many_arguments)] pub fn enqueue_comms( &self, user_id: Option, - channel: CommsChannel, + recipient: &Recipient, comms_type: CommsType, - recipient: &str, subject: Option<&str>, body: &str, metadata: Option, @@ -162,9 +160,9 @@ impl InfraOps { let value = QueuedCommsValue { id, user_id, - channel: channel_to_u8(channel), + channel: channel_to_u8(recipient.channel()), comms_type: comms_type_to_u8(comms_type), - recipient: recipient.to_owned(), + recipient: recipient.as_str().to_owned(), subject: subject.map(str::to_owned), body: body.to_owned(), metadata: metadata.map(|v| serde_json::to_vec(&v).unwrap_or_default()), @@ -184,9 +182,9 @@ impl InfraOps { if let Some(uid) = user_id { let history_value = NotificationHistoryValue { id, - channel: channel_to_u8(channel), + channel: channel_to_u8(recipient.channel()), comms_type: comms_type_to_u8(comms_type), - recipient: recipient.to_owned(), + recipient: recipient.as_str().to_owned(), subject: subject.map(str::to_owned), body: body.to_owned(), status: status_to_u8(CommsStatus::Pending), diff --git a/crates/tranquil-store/src/metastore/user_ops.rs b/crates/tranquil-store/src/metastore/user_ops.rs index 7ca6b03..088e41d 100644 --- a/crates/tranquil-store/src/metastore/user_ops.rs +++ b/crates/tranquil-store/src/metastore/user_ops.rs @@ -781,7 +781,11 @@ impl UserOps { })) } - pub fn admin_update_email(&self, did: &Did, email: &str) -> Result { + pub fn admin_update_email( + &self, + did: &Did, + email: &tranquil_types::EmailAddress, + ) -> Result { let user_hash = self.resolve_hash(did.as_str()); let val = match self.load_user(user_hash)? { Some(v) => v, @@ -796,12 +800,12 @@ impl UserOps { batch.insert( &self.users, - user_by_email_key(email).as_slice(), + user_by_email_key(email.as_str()).as_slice(), user_hash.raw().to_be_bytes(), ); let mut updated = val; - updated.email = Some(email.to_owned()); + updated.email = Some(email.as_str().to_owned()); batch.insert( &self.users, @@ -1022,11 +1026,11 @@ impl UserOps { pub fn store_telegram_chat_id( &self, - telegram_username: &str, + telegram_username: &tranquil_types::TelegramUsername, chat_id: i64, handle: Option<&str>, ) -> Result, MetastoreError> { - let idx_key = telegram_lookup_key(telegram_username); + let idx_key = telegram_lookup_key(telegram_username.as_str()); let raw = match self .users .get(idx_key.as_slice()) @@ -1062,11 +1066,6 @@ impl UserOps { Ok(Some(uid)) } - pub fn get_telegram_chat_id(&self, user_id: Uuid) -> Result, MetastoreError> { - let user_hash = self.resolve_hash_from_uuid(user_id)?; - Ok(self.load_user(user_hash)?.and_then(|v| v.telegram_chat_id)) - } - pub fn set_unverified_discord( &self, user_id: Uuid, @@ -1105,11 +1104,11 @@ impl UserOps { pub fn store_discord_user_id( &self, - discord_username: &str, - discord_id: &str, + discord_username: &tranquil_types::DiscordUsername, + discord_id: &tranquil_types::DiscordUserId, handle: Option<&str>, ) -> Result, MetastoreError> { - let idx_key = discord_lookup_key(discord_username); + let idx_key = discord_lookup_key(discord_username.as_str()); let raw = match self .users .get(idx_key.as_slice()) @@ -1124,7 +1123,7 @@ impl UserOps { }; let user_hash = UserHash::from_did(&val.did); self.mutate_user(user_hash, |u| { - u.discord_id = Some(discord_id.to_owned()); + u.discord_id = Some(discord_id.as_str().to_owned()); })?; return Ok(Some(val.id)); } @@ -1140,7 +1139,7 @@ impl UserOps { }; let uid = val.id; self.mutate_user(user_hash, |u| { - u.discord_id = Some(discord_id.to_owned()); + u.discord_id = Some(discord_id.as_str().to_owned()); })?; Ok(Some(uid)) } diff --git a/crates/tranquil-types/src/lib.rs b/crates/tranquil-types/src/lib.rs index 37096ee..1b27d68 100644 --- a/crates/tranquil-types/src/lib.rs +++ b/crates/tranquil-types/src/lib.rs @@ -141,6 +141,7 @@ macro_rules! simple_string_newtype_no_sqlx { }; } +// I keep coming back to this. Is this too tricksy? Let me know. macro_rules! validated_string_newtype { ( $(#[$meta:meta])* @@ -948,6 +949,19 @@ impl CommsChannel { } } + pub fn display_name(&self) -> &'static str { + match self { + CommsChannel::Email => "email", + CommsChannel::Discord => "Discord", + CommsChannel::Telegram => "Telegram", + CommsChannel::Signal => "Signal", + } + } + + pub fn verifies_via_bot(&self) -> bool { + matches!(self, CommsChannel::Telegram | CommsChannel::Discord) + } + pub fn from_str_opt(s: &str) -> Option { match s { "email" => Some(CommsChannel::Email), @@ -959,6 +973,420 @@ impl CommsChannel { } } +impl std::str::FromStr for CommsChannel { + type Err = InvalidCommsChannel; + + fn from_str(s: &str) -> Result { + CommsChannel::from_str_opt(s).ok_or(InvalidCommsChannel) + } +} + +#[derive(Debug, Clone)] +pub struct InvalidCommsChannel; + +impl fmt::Display for InvalidCommsChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("invalid comms channel") + } +} + +impl std::error::Error for InvalidCommsChannel {} + +fn normalize_signal_username(raw: &str) -> Result { + let trimmed = raw.trim(); + let clean = trimmed.strip_prefix('@').unwrap_or(trimmed).to_lowercase(); + let shaped = clean.rsplit_once('.').is_some_and(|(base, discriminator)| { + matches!(base.len(), 3..=32) + && base.starts_with(|c: char| c.is_ascii_alphabetic()) + && base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && is_valid_discriminator(discriminator) + }); + shaped.then_some(clean).ok_or(()) +} + +fn is_valid_discriminator(s: &str) -> bool { + if !s.chars().all(|c| c.is_ascii_digit()) { + return false; + } + if !matches!(s.len(), 2..=20) { + return false; + } + if s.len() > 2 && s.starts_with('0') { + return false; + } + s.parse::().is_ok_and(|n| n != 0) +} + +validated_string_newtype! { + pub struct SignalUsername; + error = InvalidSignalUsername; + label = "Signal username. Must be 3-32 characters starting with a letter, then a full-stop, then at least two digits, like oys.01"; + validator = normalize_signal_username; +} + +fn normalize_telegram_username(raw: &str) -> Result { + let trimmed = raw.trim(); + let clean = trimmed.strip_prefix('@').unwrap_or(trimmed).to_lowercase(); + let shaped = (5..=32).contains(&clean.len()) + && clean.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + shaped.then_some(clean).ok_or(()) +} + +validated_string_newtype! { + pub struct TelegramUsername; + error = InvalidTelegramUsername; + label = "Telegram username. Must be 5-32 characters of letters, digits, or underscores"; + validator = normalize_telegram_username; +} + +fn normalize_discord_username(raw: &str) -> Result { + let clean = raw.trim().to_lowercase(); + let shaped = (2..=32).contains(&clean.len()) + && !clean.contains("..") + && clean + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.'); + shaped.then_some(clean).ok_or(()) +} + +validated_string_newtype! { + pub struct DiscordUsername; + error = InvalidDiscordUsername; + label = "Discord username. Must be 2-32 lowercase letters, digits, underscores, or full-stops"; + validator = normalize_discord_username; +} + +const MAX_EMAIL_LENGTH: usize = 254; +const MAX_EMAIL_LOCAL_PART_LENGTH: usize = 64; +const MAX_EMAIL_DOMAIN_LENGTH: usize = 253; +const MAX_EMAIL_DOMAIN_LABEL_LENGTH: usize = 63; +const EMAIL_LOCAL_FUNNY_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-"; + +validated_string_newtype! { + pub struct EmailAddress; + error = InvalidEmailAddress; + label = "email address"; + validator = |raw| { + let clean = raw.trim().to_ascii_lowercase(); + is_valid_email(&clean).then_some(clean).ok_or(()) + }; +} + +fn is_valid_email(email: &str) -> bool { + email.len() <= MAX_EMAIL_LENGTH + && email.rsplit_once('@').is_some_and(|(local, domain)| { + valid_email_local_part(local) && valid_email_domain(domain) + }) +} + +fn valid_email_local_part(local: &str) -> bool { + !local.is_empty() + && local.len() <= MAX_EMAIL_LOCAL_PART_LENGTH + && !local.starts_with('.') + && !local.ends_with('.') + && !local.contains("..") + && local + .chars() + .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_FUNNY_CHARS.contains(c)) +} + +fn valid_email_domain(domain: &str) -> bool { + !domain.is_empty() + && domain.len() <= MAX_EMAIL_DOMAIN_LENGTH + && domain.contains('.') + && domain.split('.').all(|label| { + !label.is_empty() + && label.len() <= MAX_EMAIL_DOMAIN_LABEL_LENGTH + && !label.starts_with('-') + && !label.ends_with('-') + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + }) +} + +fn valid_telegram_chat_id(raw: &str) -> Result { + let digits = raw.strip_prefix('-').unwrap_or(raw); + let accepted = !digits.is_empty() + && digits.bytes().all(|b| b.is_ascii_digit()) + && raw.len() <= 20 + && raw.parse::().is_ok_and(|id| id != 0); + accepted.then(|| raw.to_string()).ok_or(()) +} + +validated_string_newtype! { + pub struct TelegramChatId; + error = InvalidTelegramChatId; + label = "Telegram chat ID"; + validator = valid_telegram_chat_id; +} + +// Particularly from having been stored in the DB as a 0, so that the state is shown nicely within our engine. +impl TelegramChatId { + pub fn from_i64(id: i64) -> Option { + (id != 0).then(|| Self(id.to_string())) + } +} + +fn valid_discord_user_id(raw: &str) -> Result { + raw.parse::() + .is_ok_and(|id| id > 0) + .then(|| raw.to_string()) + .ok_or(()) +} + +validated_string_newtype! { + pub struct DiscordUserId; + error = InvalidDiscordUserId; + label = "Discord user ID"; + validator = valid_discord_user_id; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Recipient { + Email(EmailAddress), + Signal(SignalUsername), + Telegram(TelegramChatId), + Discord(DiscordUserId), +} + +#[derive(Debug, Clone)] +pub struct InvalidRecipient { + channel: CommsChannel, + raw: String, +} + +impl fmt::Display for InvalidRecipient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid {} recipient: {}", self.channel, self.raw) + } +} + +impl std::error::Error for InvalidRecipient {} + +impl Recipient { + pub fn new(channel: CommsChannel, raw: &str) -> Result { + let parsed = match channel { + CommsChannel::Email => EmailAddress::new(raw).ok().map(Self::Email), + CommsChannel::Signal => SignalUsername::new(raw).ok().map(Self::Signal), + CommsChannel::Telegram => TelegramChatId::new(raw).ok().map(Self::Telegram), + CommsChannel::Discord => DiscordUserId::new(raw).ok().map(Self::Discord), + }; + parsed.ok_or(InvalidRecipient { + channel, + raw: raw.to_string(), + }) + } + + pub fn channel(&self) -> CommsChannel { + match self { + Self::Email(_) => CommsChannel::Email, + Self::Signal(_) => CommsChannel::Signal, + Self::Telegram(_) => CommsChannel::Telegram, + Self::Discord(_) => CommsChannel::Discord, + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::Email(address) => address.as_str(), + Self::Signal(username) => username.as_str(), + Self::Telegram(chat_id) => chat_id.as_str(), + Self::Discord(user_id) => user_id.as_str(), + } + } +} + +impl fmt::Display for Recipient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod recipient_tests { + use super::*; + + #[test] + fn email_new_normalizes_and_validates() { + let address = EmailAddress::new(" User@Example.PET \n").unwrap(); + assert_eq!(address.as_str(), "user@example.pet"); + assert!(EmailAddress::new("").is_err()); + assert!(EmailAddress::new("no-at-sign").is_err()); + assert!(EmailAddress::new("a@-bad.label.pet").is_err()); + assert!(EmailAddress::new("dot..dot@jola.dev").is_err()); + assert!(EmailAddress::new(".user@nel.pet").is_err()); + assert!(EmailAddress::new("user.@nel.pet").is_err()); + assert!(EmailAddress::new("u.s.e.r@jola.dev").is_ok()); + assert!(EmailAddress::new("a b@nel.pet").is_err()); + assert!(EmailAddress::new("user\t@nel.pet").is_err()); + assert!(EmailAddress::new("user@exam ple.pet").is_err()); + assert!(EmailAddress::new("user@localhost").is_err()); + assert!(EmailAddress::new("user@ex-ample.pet").is_ok()); + assert!(EmailAddress::new("user@ex--ample.pet").is_ok()); + assert!(EmailAddress::new("user@example-.pet").is_err()); + assert!(EmailAddress::new("USER@JOLA.DEV").is_ok()); + } + + #[test] + fn email_accepts_every_local_part_special_char() { + for special in [ + "user.name", + "user+tag", + "user!def", + "user#abc", + "user$def", + "user%abc", + "user&def", + "user'abc", + "user*def", + "user=abc", + "user?def", + "user^abc", + "user_def", + "user`abc", + "user{def", + "user|def", + "user}def", + "user~def", + "user-def", + ] { + assert!( + EmailAddress::new(format!("{special}@jola.dev")).is_ok(), + "{special} is an allowed local part character" + ); + } + } + + #[test] + fn telegram_chat_id_accepts_groups_and_rejects_usernames() { + assert_eq!( + TelegramChatId::new("-1001234567890").unwrap().as_str(), + "-1001234567890" + ); + assert_eq!(TelegramChatId::new("42").unwrap().as_str(), "42"); + assert_eq!( + TelegramChatId::from_i64(-1001234567890).unwrap().as_str(), + "-1001234567890" + ); + assert!(TelegramChatId::from_i64(0).is_none()); + let every_minted_value_parses = [1, -1, -1001234567890, i64::MAX, i64::MIN] + .into_iter() + .filter_map(TelegramChatId::from_i64) + .all(|id| TelegramChatId::new(id.to_string()).is_ok()); + assert!(every_minted_value_parses); + assert!(TelegramChatId::new("oys_01").is_err()); + assert!(TelegramChatId::new("+42").is_err()); + assert!(TelegramChatId::new("").is_err()); + assert!(TelegramChatId::new("9999999999999999999999").is_err()); + assert!(TelegramChatId::new("0").is_err()); + assert!(TelegramChatId::new("-0").is_err()); + } + + #[test] + fn email_enforces_length_boundaries() { + let domain_189 = format!("{}.{}.{}", "a".repeat(63), "b".repeat(63), "c".repeat(61)); + let max = format!("{}@{}", "d".repeat(64), domain_189); + assert_eq!(max.len(), 254); + assert!(EmailAddress::new(&max).is_ok()); + + let domain_190 = format!("{}.{}.{}", "a".repeat(63), "b".repeat(63), "c".repeat(62)); + let over = format!("{}@{}", "d".repeat(64), domain_190); + assert_eq!(over.len(), 255); + assert!(EmailAddress::new(&over).is_err()); + + assert!(EmailAddress::new(format!("{}@nel.pet", "a".repeat(64))).is_ok()); + assert!(EmailAddress::new(format!("{}@jola.dev", "a".repeat(65))).is_err()); + assert!(EmailAddress::new(format!("a@{}.pet", "b".repeat(63))).is_ok()); + assert!(EmailAddress::new(format!("a@{}.pet", "b".repeat(64))).is_err()); + } + + #[test] + fn discord_user_id_requires_positive_snowflake() { + assert_eq!( + DiscordUserId::new("274656283714826240").unwrap().as_str(), + "274656283714826240" + ); + assert!(DiscordUserId::new("0").is_err()); + assert!(DiscordUserId::new("-1").is_err()); + assert!(DiscordUserId::new("oys").is_err()); + assert!(DiscordUserId::new("18446744073709551616").is_err()); + } + + #[test] + fn username_new_normalizes_sigil_and_case() { + assert_eq!( + TelegramUsername::new(" @Oys_01 ").unwrap().as_str(), + "oys_01" + ); + assert_eq!( + DiscordUsername::new(" Oys.Cafe ").unwrap().as_str(), + "oys.cafe" + ); + assert_eq!(SignalUsername::new("@Oys.01").unwrap().as_str(), "oys.01"); + assert!(TelegramUsername::new("oys").is_err()); + assert!(TelegramUsername::new("oys-01").is_err()); + assert!(TelegramUsername::new("123456789012345678901234567890123").is_err()); + assert!(DiscordUsername::new("a").is_err()); + assert!(DiscordUsername::new("user..name").is_err()); + assert!(DiscordUsername::new("user-name").is_err()); + } + + #[test] + fn signal_discriminators_have_exact_boundaries() { + for valid in [ + "oys.01", + "oyster_cafe.99", + "user123.42", + "lu1.01", + "a_very_long_username_here.55", + "oys.123", + "oys.999999999", + "oys.18446744073709551615", + ] { + assert!(SignalUsername::new(valid).is_ok(), "{valid}"); + } + for invalod in [ + "", + "oys", + "oys.1", + "oys.001", + "abc.00", + "oys.0", + "oys.999999999999999999999", + ".01", + "ab.01", + "1oys.01", + "oys!.01", + "oys .01", + "oys.01; rm -rf /", + "oys.01 && cat /etc/passwd", + "oys.01`id`", + "oys.01$(whoami)", + ] { + assert!(SignalUsername::new(invalod).is_err(), "{invalod}"); + } + assert!(SignalUsername::new("a".repeat(33)).is_err()); + assert!(SignalUsername::new(format!("{}.01", "a".repeat(32))).is_ok()); + } + + #[test] + fn recipient_parse_binds_channel_to_value() { + let email = Recipient::new(CommsChannel::Email, "oys@nel.pet").unwrap(); + assert_eq!(email.channel(), CommsChannel::Email); + assert_eq!(email.as_str(), "oys@nel.pet"); + let signal = Recipient::new(CommsChannel::Signal, "oys.01").unwrap(); + assert_eq!(signal.channel(), CommsChannel::Signal); + let telegram = Recipient::new(CommsChannel::Telegram, "-100").unwrap(); + assert_eq!(telegram.channel(), CommsChannel::Telegram); + let discord = Recipient::new(CommsChannel::Discord, "274656283714826240").unwrap(); + assert_eq!(discord.channel(), CommsChannel::Discord); + + let mismatch = Recipient::new(CommsChannel::Telegram, "oys_01").unwrap_err(); + assert_eq!(mismatch.to_string(), "invalid telegram recipient: oys_01"); + assert!(Recipient::new(CommsChannel::Discord, "oys#0001").is_err()); + assert!(Recipient::new(CommsChannel::Signal, "oys").is_err()); + } +} + impl fmt::Display for CommsChannel { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str())