mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-23 02:34:16 +00:00
comms: Comms ought to have better-typed channel recipients
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
This commit is contained in:
Generated
+2
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,10 +19,10 @@ pub async fn update_account_email(
|
||||
Json(input): Json<UpdateAccountEmailInput>,
|
||||
) -> Result<Json<EmptyResponse>, 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),
|
||||
|
||||
@@ -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<String, ApiError> {
|
||||
) -> Result<tranquil_pds::comms::VerificationTarget, ApiError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ async fn try_reactivate_migration(
|
||||
handle: &Handle,
|
||||
email: &Option<String>,
|
||||
verification_channel: tranquil_db_traits::CommsChannel,
|
||||
verification_recipient: Option<&str>,
|
||||
verification_recipient: Option<&tranquil_pds::comms::VerificationTarget>,
|
||||
) -> Option<Response> {
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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 {}))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<String, ApiError> {
|
||||
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<CommsChannel>,
|
||||
) -> 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?;
|
||||
|
||||
@@ -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 {}))
|
||||
|
||||
@@ -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::<PendingEmailUpdate>(&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<VerificationCheckLimit>,
|
||||
Json(input): Json<CheckEmailInUseInput>,
|
||||
) -> Result<Json<InUseOutput>, 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);
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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 {}))
|
||||
}
|
||||
|
||||
@@ -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<Aut
|
||||
handle: row.handle.clone(),
|
||||
channel: row.channel,
|
||||
};
|
||||
let is_bot_channel = matches!(
|
||||
row.channel,
|
||||
tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord
|
||||
);
|
||||
if is_bot_channel || debounced {
|
||||
if row.channel.verifies_via_bot() || debounced {
|
||||
return Some(result);
|
||||
}
|
||||
let recipient = match row.channel {
|
||||
tranquil_db_traits::CommsChannel::Email => 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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<Json<VerifyTokenOutput>, 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,
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Message, SendError> {
|
||||
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<AtmosCategory> {
|
||||
#[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 <noreply@nel.pet>".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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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!("<b>{}</b>\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();
|
||||
|
||||
@@ -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<String>,
|
||||
pub body: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl NewComms {
|
||||
pub fn new(
|
||||
user_id: Uuid,
|
||||
channel: CommsChannel,
|
||||
comms_type: CommsType,
|
||||
recipient: String,
|
||||
subject: Option<String>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) => {}
|
||||
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<Uuid>,
|
||||
channel: CommsChannel,
|
||||
recipient: &Recipient,
|
||||
comms_type: CommsType,
|
||||
recipient: &str,
|
||||
subject: Option<&str>,
|
||||
body: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -220,7 +220,11 @@ pub trait UserRepository: Send + Sync {
|
||||
channel: CommsChannel,
|
||||
) -> Result<Option<bool>, DbError>;
|
||||
|
||||
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError>;
|
||||
async fn admin_update_email(
|
||||
&self,
|
||||
did: &Did,
|
||||
email: &tranquil_types::EmailAddress,
|
||||
) -> Result<u64, DbError>;
|
||||
|
||||
async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>;
|
||||
|
||||
@@ -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<Option<Uuid>, DbError>;
|
||||
|
||||
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, 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<Option<Uuid>, 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,
|
||||
|
||||
@@ -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<Uuid>,
|
||||
channel: CommsChannel,
|
||||
recipient: &Recipient,
|
||||
comms_type: CommsType,
|
||||
recipient: &str,
|
||||
subject: Option<&str>,
|
||||
body: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
@@ -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
|
||||
|
||||
@@ -660,10 +660,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError> {
|
||||
async fn admin_update_email(
|
||||
&self,
|
||||
did: &Did,
|
||||
email: &tranquil_types::EmailAddress,
|
||||
) -> Result<u64, DbError> {
|
||||
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<Option<Uuid>, 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<Option<Uuid>, 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<Option<i64>, 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,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -775,6 +775,31 @@ impl From<jacquard_common::types::string::AtStrError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tranquil_types::InvalidEmailAddress> 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<crate::plc::PlcError> for ApiError {
|
||||
fn from(e: crate::plc::PlcError) -> Self {
|
||||
use crate::plc::PlcError;
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Uuid, tranquil_db_traits::DbError> {
|
||||
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<Self, crate::api::error::ApiError> {
|
||||
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<Recipient, crate::api::error::ApiError> {
|
||||
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<Recipient> {
|
||||
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<Recipient> {
|
||||
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<Uuid, 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.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<CommsChannel> {
|
||||
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<Uuid, DbError> {
|
||||
) -> Result<Option<Uuid>, 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<Uuid, DbError> {
|
||||
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<Uuid, 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 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<Uuid, 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 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<Uuid, 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.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<Uuid, 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.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<Uuid, 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.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<Uuid, DbError> {
|
||||
@@ -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<Uuid, DbError> {
|
||||
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<Uuid, 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.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<Uuid, 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 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<Uuid, 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 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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<tranquil_db_traits::QueuedComms> {
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<tranquil_db::PostgresRepositories>
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<PostgresRepositories> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Self, InvalidSignalUsername> {
|
||||
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::<u64>().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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<S: StorageIO + 'static> tranquil_db_traits::InfraRepository for MetastoreCl
|
||||
async fn enqueue_comms(
|
||||
&self,
|
||||
user_id: Option<Uuid>,
|
||||
channel: CommsChannel,
|
||||
recipient: &Recipient,
|
||||
comms_type: CommsType,
|
||||
recipient: &str,
|
||||
subject: Option<&str>,
|
||||
body: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
@@ -1804,9 +1803,8 @@ impl<S: StorageIO + 'static> 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<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError> {
|
||||
async fn admin_update_email(
|
||||
&self,
|
||||
did: &Did,
|
||||
email: &tranquil_types::EmailAddress,
|
||||
) -> Result<u64, DbError> {
|
||||
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<S: StorageIO + 'static> 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<Option<Uuid>, 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<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, 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<S: StorageIO + 'static> 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<Option<Uuid>, 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,
|
||||
}))?;
|
||||
|
||||
@@ -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<u64>,
|
||||
},
|
||||
AdminUpdateHandle {
|
||||
@@ -1202,23 +1202,19 @@ pub enum UserRequest {
|
||||
tx: Tx<()>,
|
||||
},
|
||||
StoreTelegramChatId {
|
||||
telegram_username: String,
|
||||
telegram_username: tranquil_types::TelegramUsername,
|
||||
chat_id: i64,
|
||||
handle: Option<String>,
|
||||
tx: Tx<Option<Uuid>>,
|
||||
},
|
||||
GetTelegramChatId {
|
||||
user_id: Uuid,
|
||||
tx: Tx<Option<i64>>,
|
||||
},
|
||||
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<String>,
|
||||
tx: Tx<Option<Uuid>>,
|
||||
},
|
||||
@@ -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<Uuid>,
|
||||
channel: CommsChannel,
|
||||
recipient: Recipient,
|
||||
comms_type: CommsType,
|
||||
recipient: String,
|
||||
subject: Option<String>,
|
||||
body: String,
|
||||
metadata: Option<serde_json::Value>,
|
||||
@@ -3870,9 +3864,8 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, req: InfraRequest) {
|
||||
match req {
|
||||
InfraRequest::EnqueueComms {
|
||||
user_id,
|
||||
channel,
|
||||
comms_type,
|
||||
recipient,
|
||||
comms_type,
|
||||
subject,
|
||||
body,
|
||||
metadata,
|
||||
@@ -3883,9 +3876,8 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO + 'static>(state: &HandlerState<S>, 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,
|
||||
|
||||
@@ -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<Uuid>,
|
||||
channel: CommsChannel,
|
||||
recipient: &Recipient,
|
||||
comms_type: CommsType,
|
||||
recipient: &str,
|
||||
subject: Option<&str>,
|
||||
body: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
@@ -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),
|
||||
|
||||
@@ -781,7 +781,11 @@ impl UserOps {
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, MetastoreError> {
|
||||
pub fn admin_update_email(
|
||||
&self,
|
||||
did: &Did,
|
||||
email: &tranquil_types::EmailAddress,
|
||||
) -> Result<u64, MetastoreError> {
|
||||
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<Option<Uuid>, 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<Option<i64>, 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<Option<Uuid>, 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))
|
||||
}
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Self, Self::Err> {
|
||||
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<String, ()> {
|
||||
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::<u64>().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<String, ()> {
|
||||
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<String, ()> {
|
||||
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<String, ()> {
|
||||
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::<i64>().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<Self> {
|
||||
(id != 0).then(|| Self(id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_discord_user_id(raw: &str) -> Result<String, ()> {
|
||||
raw.parse::<u64>()
|
||||
.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<Self, InvalidRecipient> {
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user