comms: Comms ought to have better-typed channel recipients

Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
This commit is contained in:
Lewis
2026-09-21 22:36:26 +03:00
parent cc9ac301ca
commit 922da48263
61 changed files with 1813 additions and 1614 deletions
Generated
+2
View File
@@ -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),
+65 -36
View File
@@ -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);
}
}
+2 -2
View File
@@ -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);
}
+19 -10
View File
@@ -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()),
);
}
}
+4 -4
View File
@@ -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,
)
+33 -59
View File
@@ -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 {}))
+54 -43
View File
@@ -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 }))
}
+12 -17
View File
@@ -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 {}))
}
+73 -72
View File
@@ -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 }))
}
+14 -11
View File
@@ -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");
+33 -28
View File
@@ -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,
+24 -13
View File
@@ -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()),
);
}
}
+1
View File
@@ -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 }
+21 -14
View File
@@ -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"));
}
+17 -4
View File
@@ -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 -3
View File
@@ -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};
+39 -15
View File
@@ -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(&notification.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 = &notification.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(&notification.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(&notification.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();
-51
View File
@@ -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,
)
}
}
+18 -5
View File
@@ -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) => {}
+2 -56
View File
@@ -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>,
+1 -1
View File
@@ -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::{
+27 -6
View File
@@ -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,
+4 -5
View File
@@ -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
+11 -13
View File
@@ -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,
)
+25
View File
@@ -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;
-166
View File
@@ -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"
));
}
}
+4 -5
View File
@@ -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};
+338 -383
View File
@@ -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", &timestamp),
("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,
&current_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,
&current_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", &timestamp),
("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(&notices)
);
assert!(
notices
.iter()
.any(|notice| notice.channel == CommsChannel::Email),
"The notice should fall back to email: {:?}",
snapshot(&notices)
);
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(&notices)
);
}
#[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
+44 -18
View File
@@ -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"
);
}
+15
View File
@@ -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;
+45 -19
View File
@@ -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");
}
+2 -12
View File
@@ -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 =
+54 -23
View File
@@ -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();
+7 -19
View File
@@ -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
+1 -8
View File
@@ -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 -43
View File
@@ -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();
+10 -1
View File
@@ -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]
+3 -5
View File
@@ -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"));
}
+1
View File
@@ -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 }
+1 -65
View File
@@ -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);
+3 -2
View File
@@ -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 {
+4 -2
View File
@@ -126,7 +126,9 @@ mod s3 {
.clone()
.expect("storage.s3_bucket (S3_BUCKET) must be set");
let client = create_s3_client().await;
let path = cfg.storage.s3_path
let path = cfg
.storage
.s3_path
.trim_start_matches("/")
.trim_end_matches("/")
.to_string();
@@ -139,7 +141,7 @@ mod s3 {
fn resolve_path(&self, key: &str) -> String {
if self.path.is_empty() {
return key.to_string()
return key.to_string();
}
format!("{}/{}", self.path, key)
+24 -32
View File
@@ -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,
}))?;
+8 -19
View File
@@ -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),
+14 -15
View File
@@ -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))
}
+428
View File
@@ -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())