Compare commits

...
Author SHA1 Message Date
Lewis 17b3338b31 tranquil-store: make it seem a little less 'experimental'
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-09-22 21:10:36 +03:00
LewisandTangled 91114c491a comms: Comms ought to have better-typed channel recipients
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-09-22 17:05:06 +00:00
juneandTangled 3d0376c476 feat(docs): alpine linux installation guide
meow meow no container, using the upstream package by yours truly :3
2026-09-22 14:25:40 +00:00
Louis EscherandTangled f1d963988e fix: actually pass the scope's aud with the service fragment 2026-09-22 13:19:21 +00:00
Johanna LarssonandTangled cc9ac301ca Little tweaks for multi-arch image building
Updates the default runner debian image sha to the multi-arch image instead of amd64-only. Also symlinks .containerignore -> .dockerignore since docker refuses to read .containerignore.

With that one can build and push multi-arch, like so:

```
docker buildx create --name tranquil-multiarch --driver docker-container --bootstrap

docker buildx build \
  --builder tranquil-multiarch \
  --platform linux/amd64,linux/arm64 \
  -t atcr.io/tranquil.farm/tranquil-pds:0.6.7 \
  --push \
  .

docker buildx imagetools create \
  -t atcr.io/tranquil.farm/tranquil-pds:latest \
  atcr.io/tranquil.farm/tranquil-pds:0.6.7
```

if one were so inclined, that is.
2026-09-21 14:41:54 +00:00
73 changed files with 2014 additions and 1637 deletions
+1
View File
@@ -0,0 +1 @@
.containerignore
Generated
+2
View File
@@ -7772,6 +7772,7 @@ dependencies = [
"tranquil-config", "tranquil-config",
"tranquil-db-traits", "tranquil-db-traits",
"tranquil-signal", "tranquil-signal",
"tranquil-types",
"uuid", "uuid",
] ]
@@ -8129,6 +8130,7 @@ dependencies = [
"tokio", "tokio",
"tokio-util", "tokio-util",
"tracing", "tracing",
"tranquil-types",
"url", "url",
"uuid", "uuid",
] ]
+1 -1
View File
@@ -1,4 +1,4 @@
ARG DISTROLESS_IMAGE=gcr.io/distroless/cc-debian13:latest@sha256:1e3c6d9c255be500eb680cdea0ad07554f52ae92dfcbdf07043a2a435b4c1fe3 ARG DISTROLESS_IMAGE=gcr.io/distroless/cc-debian13:latest@sha256:a017e74bd2a12d98342dbecd33d121d2b160415ed777573dc1808969e989d94d
FROM node:24-trixie-slim AS frontend FROM node:24-trixie-slim AS frontend
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
+1
View File
@@ -67,6 +67,7 @@ podman-compose -f docker-compose.prod.yaml up -d
### Installation Guides ### Installation Guides
- [Nix](docs/2_INSTALL_NIX.md) - [Nix](docs/2_INSTALL_NIX.md)
- [Alpine](docs/2_INSTALL_ALPINE.md)
- [Containers](docs/2_INSTALL_CONTAINERS.md) - [Containers](docs/2_INSTALL_CONTAINERS.md)
## Community ## Community
@@ -38,7 +38,8 @@ pub async fn send_email(
.log_db_err("in send_email")? .log_db_err("in send_email")?
.ok_or(ApiError::AccountNotFound)?; .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 (user_id, handle) = (user.id, user.handle);
let hostname = &tranquil_config::get().server.hostname; let hostname = &tranquil_config::get().server.hostname;
let subject = input let subject = input
@@ -50,9 +51,8 @@ pub async fn send_email(
.infra .infra
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
tranquil_db_traits::CommsChannel::Email, &tranquil_types::Recipient::Email(email),
tranquil_db_traits::CommsType::AdminEmail, tranquil_db_traits::CommsType::AdminEmail,
&email,
Some(&subject), Some(&subject),
content, content,
None, None,
@@ -19,10 +19,10 @@ pub async fn update_account_email(
Json(input): Json<UpdateAccountEmailInput>, Json(input): Json<UpdateAccountEmailInput>,
) -> Result<Json<EmptyResponse>, ApiError> { ) -> Result<Json<EmptyResponse>, ApiError> {
let account = input.account.trim(); let account = input.account.trim();
let email = input.email.trim(); let email = tranquil_types::EmailAddress::new(&input.email)?;
if account.is_empty() || email.is_empty() { if account.is_empty() {
return Err(ApiError::InvalidRequest( return Err(ApiError::InvalidRequest(
"account and email are required".into(), "Account is required, silly!".into(),
)); ));
} }
let account_did: Did = account let account_did: Did = account
@@ -32,7 +32,7 @@ pub async fn update_account_email(
match state match state
.repos .repos
.user .user
.admin_update_email(&account_did, email) .admin_update_email(&account_did, &email)
.await .await
{ {
Ok(0) => Err(ApiError::AccountNotFound), Ok(0) => Err(ApiError::AccountNotFound),
+65 -36
View File
@@ -1,11 +1,12 @@
use bcrypt::{DEFAULT_COST, hash}; use bcrypt::{DEFAULT_COST, hash};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use std::collections::HashMap; use std::collections::HashMap;
use tracing::error; use tracing::{error, warn};
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository}; use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
use tranquil_pds::api::error::ApiError; use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::error::DbResultExt; use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::types::{AtIdentifier, Did, Handle, PasswordHash}; use tranquil_pds::types::{AtIdentifier, Did, Handle, PasswordHash};
use tranquil_types::{DiscordUsername, SignalUsername, TelegramUsername};
pub struct ResolvedRepo { pub struct ResolvedRepo {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
@@ -156,42 +157,70 @@ pub struct ChannelInput<'a> {
pub fn extract_verification_recipient( pub fn extract_verification_recipient(
channel: CommsChannel, channel: CommsChannel,
input: &ChannelInput<'_>, input: &ChannelInput<'_>,
) -> Result<String, ApiError> { ) -> Result<tranquil_pds::comms::VerificationTarget, ApiError> {
match channel { match channel {
CommsChannel::Email => match input.email { CommsChannel::Email => {
Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()), let raw = trimmed(input.email).ok_or(ApiError::MissingEmail)?;
_ => Err(ApiError::MissingEmail), let address = tranquil_types::EmailAddress::new(raw)?;
}, Ok(tranquil_pds::comms::VerificationTarget::direct(
CommsChannel::Discord => match input.discord_username { tranquil_db_traits::Recipient::Email(address),
Some(username) if !username.trim().is_empty() => { ))
let clean = username.trim().to_lowercase(); }
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) { CommsChannel::Signal => {
return Err(ApiError::InvalidRequest( let raw = trimmed(input.signal_username).ok_or(ApiError::MissingSignalNumber)?;
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), let username = SignalUsername::new(raw)?;
)); Ok(tranquil_pds::comms::VerificationTarget::direct(
} tranquil_db_traits::Recipient::Signal(username),
Ok(clean) ))
} }
_ => Err(ApiError::MissingDiscordId), CommsChannel::Telegram => {
}, let raw = trimmed(input.telegram_username).ok_or(ApiError::MissingTelegramUsername)?;
CommsChannel::Telegram => match input.telegram_username { let username = TelegramUsername::new(raw)?;
Some(username) if !username.trim().is_empty() => { tranquil_pds::comms::VerificationTarget::resolve(
let clean = username.trim().trim_start_matches('@'); channel,
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) { username.as_str(),
return Err(ApiError::InvalidRequest( input.email,
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), )
)); }
} CommsChannel::Discord => {
Ok(clean.to_string()) let raw = trimmed(input.discord_username).ok_or(ApiError::MissingDiscordId)?;
} let username = DiscordUsername::new(raw)?;
_ => Err(ApiError::MissingTelegramUsername), tranquil_pds::comms::VerificationTarget::resolve(
}, channel,
CommsChannel::Signal => match input.signal_username { username.as_str(),
Some(username) if !username.trim().is_empty() => { input.email,
Ok(username.trim().trim_start_matches('@').to_lowercase()) )
} }
_ => Err(ApiError::MissingSignalNumber), }
}, }
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() .as_ref()
.map(|e| e.trim().to_string()) .map(|e| e.trim().to_string())
.filter(|e| !e.is_empty()); .filter(|e| !e.is_empty());
if let Some(ref email) = email if let Some(email) = &email
&& !tranquil_pds::api::validation::is_valid_email(email) && tranquil_types::EmailAddress::new(email).is_err()
{ {
return Err(ApiError::InvalidEmail); return Err(ApiError::InvalidEmail);
} }
+19 -10
View File
@@ -143,7 +143,20 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
None => { None => {
return Json(json!({ return Json(json!({
"type": 4, "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(); .into_response();
} }
@@ -184,18 +197,14 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
discord_user_id = %discord_user_id, discord_user_id = %discord_user_id,
"Verified Discord user and stored 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
tranquil_db_traits::CommsChannel::Discord, &tranquil_types::Recipient::Discord(discord_user_id),
&discord_user_id,
&tranquil_config::get().server.hostname, &tranquil_config::get().server.hostname,
) )
.await .await;
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
Json(json!({ Json(json!({
"type": 4, "type": 4,
"data": {"content": "Verified", "flags": 64} "data": {"content": "Verified", "flags": 64}
@@ -299,11 +308,11 @@ mod tests {
fn parse_handle_whitespace_trimmed() { fn parse_handle_whitespace_trimmed() {
let options = vec![InteractionOption { let options = vec![InteractionOption {
name: "handle".to_string(), name: "handle".to_string(),
value: serde_json::json!(" alice.example.com "), value: serde_json::json!(" oystercafe.jola.dev "),
}]; }];
assert_eq!( assert_eq!(
parse_start_handle(Some(&options)), 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, handle: &Handle,
email: &Option<String>, email: &Option<String>,
verification_channel: tranquil_db_traits::CommsChannel, verification_channel: tranquil_db_traits::CommsChannel,
verification_recipient: Option<&str>, verification_recipient: Option<&tranquil_pds::comms::VerificationTarget>,
) -> Option<Response> { ) -> Option<Response> {
let reactivate_input = tranquil_db_traits::MigrationReactivationInput { let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
did: did.clone(), did: did.clone(),
@@ -271,8 +271,8 @@ pub async fn create_account(
.as_ref() .as_ref()
.map(|e| e.trim().to_string()) .map(|e| e.trim().to_string())
.filter(|e| !e.is_empty()); .filter(|e| !e.is_empty());
if let Some(ref email) = email if let Some(email) = &email
&& !tranquil_pds::api::validation::is_valid_email(email) && tranquil_types::EmailAddress::new(email).is_err()
{ {
return ApiError::InvalidEmail.into_response(); return ApiError::InvalidEmail.into_response();
} }
@@ -393,7 +393,7 @@ pub async fn create_account(
&handle, &handle,
&email, &email,
verification_channel, verification_channel,
verification_recipient.as_deref(), verification_recipient.as_ref(),
) )
.await .await
{ {
@@ -35,16 +35,27 @@ pub async fn request_plc_operation_signature(
.log_db_err("creating PLC token")?; .log_db_err("creating PLC token")?;
let hostname = &tranquil_config::get().server.hostname; 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
&display_token, tranquil_pds::comms::Notice::PlcOperation {
token: &display_token,
},
hostname, hostname,
) )
.await .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); info!("PLC operation signature requested for user {}", auth.did);
Ok(Json(EmptyResponse {})) Ok(Json(EmptyResponse {}))
@@ -315,18 +315,17 @@ pub async fn enqueue_signup_verification(
user_id: uuid::Uuid, user_id: uuid::Uuid,
did: &Did, did: &Did,
channel: CommsChannel, channel: CommsChannel,
recipient: &str, target: &tranquil_pds::comms::VerificationTarget,
) { ) {
let token = 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 formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
let hostname = &tranquil_config::get().server.hostname; let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification( if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
state.repos.user.as_ref(), state.repos.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
channel, target,
recipient,
&formatted, &formatted,
hostname, hostname,
) )
@@ -341,18 +340,17 @@ pub async fn enqueue_migration_verification(
user_id: uuid::Uuid, user_id: uuid::Uuid,
did: &Did, did: &Did,
channel: CommsChannel, channel: CommsChannel,
recipient: &str, target: &tranquil_pds::comms::VerificationTarget,
) { ) {
let token = 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 formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
let hostname = &tranquil_config::get().server.hostname; let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification( if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
state.repos.user.as_ref(), state.repos.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
channel, target,
recipient,
&formatted, &formatted,
hostname, hostname,
) )
+33 -59
View File
@@ -159,12 +159,16 @@ pub async fn request_channel_verification(
user_id: uuid::Uuid, user_id: uuid::Uuid,
did: &Did, did: &Did,
channel: CommsChannel, channel: CommsChannel,
identifier: &str, id: &str,
handle: Option<&Handle>, handle: Option<&Handle>,
) -> Result<String, ApiError> { ) -> Result<String, ApiError> {
let token = tranquil_pds::auth::verification_token::generate_channel_update_token( if channel.verifies_via_bot() {
did, channel, identifier, 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); let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token);
match channel { match channel {
@@ -173,10 +177,11 @@ pub async fn request_channel_verification(
let handle = handle.ok_or_else(|| { let handle = handle.ok_or_else(|| {
ApiError::InternalError(Some("Email verification requires a handle".into())) 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( tranquil_pds::comms::comms_repo::enqueue_email_update(
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
identifier, &new_email,
handle, handle,
&formatted_token, &formatted_token,
hostname, hostname,
@@ -187,10 +192,10 @@ pub async fn request_channel_verification(
_ => { _ => {
let hostname = &tranquil_config::get().server.hostname; let hostname = &tranquil_config::get().server.hostname;
let encoded_token = urlencoding::encode(&formatted_token); let encoded_token = urlencoding::encode(&formatted_token);
let encoded_identifier = urlencoding::encode(identifier); let encoded_id = urlencoding::encode(id);
let verify_link = format!( let verify_link = format!(
"https://{}/app/verify?token={}&identifier={}", "https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_identifier hostname, encoded_token, encoded_id
); );
let prefs = state let prefs = state
.repos .repos
@@ -212,26 +217,14 @@ pub async fn request_channel_verification(
strings.channel_verification_subject, strings.channel_verification_subject,
&[("hostname", hostname)], &[("hostname", hostname)],
); );
let recipient = match channel { let recipient = tranquil_db_traits::Recipient::new(channel, id)?;
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(),
};
state state
.repos .repos
.infra .infra
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
channel,
tranquil_db_traits::CommsType::ChannelVerification,
&recipient, &recipient,
tranquil_db_traits::CommsType::ChannelVerification,
Some(&subject), Some(&subject),
&body, &body,
Some(json!({"code": formatted_token})), Some(json!({"code": formatted_token})),
@@ -253,14 +246,7 @@ async fn process_messaging_channel_update(
effective_channel: CommsChannel, effective_channel: CommsChannel,
verification_required: &mut Vec<CommsChannel>, verification_required: &mut Vec<CommsChannel>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let clean = match channel { if raw_value.trim().is_empty() {
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 effective_channel == channel { if effective_channel == channel {
return Err(ApiError::InvalidRequest(format!( return Err(ApiError::InvalidRequest(format!(
"Cannot remove {:?} while it is the preferred notification channel", "Cannot remove {:?} while it is the preferred notification channel",
@@ -292,26 +278,12 @@ async fn process_messaging_channel_update(
return Ok(()); return Ok(());
} }
let valid = match channel { let clean = match channel {
CommsChannel::Discord => tranquil_pds::api::validation::is_valid_discord_username(&clean), CommsChannel::Discord => tranquil_types::DiscordUsername::new(raw_value)?.to_string(),
CommsChannel::Telegram => tranquil_pds::api::validation::is_valid_telegram_username(&clean), CommsChannel::Telegram => tranquil_types::TelegramUsername::new(raw_value)?.to_string(),
CommsChannel::Signal => tranquil_pds::comms::is_valid_signal_username(&clean), CommsChannel::Signal => tranquil_types::SignalUsername::new(raw_value)?.to_string(),
CommsChannel::Email => tranquil_pds::api::validation::is_valid_email(&clean), 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 { match channel {
CommsChannel::Discord => state CommsChannel::Discord => state
@@ -394,23 +366,25 @@ pub async fn update_notification_prefs(
info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel"); info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel");
} }
if let Some(ref new_email) = input.email { if let Some(new_email) = &input.email {
let email_clean = new_email.trim().to_lowercase(); let email = tranquil_types::EmailAddress::new(new_email).map_err(|_| {
if email_clean.is_empty() { if new_email.trim().is_empty() {
return Err(ApiError::InvalidRequest("Email cannot be empty".into())); ApiError::InvalidRequest("Email can't be empty".into())
} } else {
ApiError::InvalidEmail
}
})?;
if !tranquil_pds::api::validation::is_valid_email(&email_clean) { if !current_email
return Err(ApiError::InvalidEmail); .as_deref()
} .is_some_and(|e| e.eq_ignore_ascii_case(email.as_str()))
{
if current_email.as_ref().map(|e| e.to_lowercase()) != Some(email_clean.clone()) {
request_channel_verification( request_channel_verification(
&state, &state,
user_id, user_id,
&auth.did, &auth.did,
CommsChannel::Email, CommsChannel::Email,
&email_clean, email.as_str(),
Some(&handle), Some(&handle),
) )
.await?; .await?;
@@ -586,16 +586,27 @@ pub async fn request_account_delete(
.await .await
.log_db_err("creating deletion token")?; .log_db_err("creating deletion token")?;
let hostname = &tranquil_config::get().server.hostname; 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
&confirmation_token, tranquil_pds::comms::Notice::AccountDeletion {
code: &confirmation_token,
},
hostname, hostname,
) )
.await .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()); info!("Account deletion requested for user {}", session_mfa.did());
Ok(Json(EmptyResponse {})) Ok(Json(EmptyResponse {}))
+54 -43
View File
@@ -71,7 +71,7 @@ pub async fn request_email_update(
let Some(_current_email) = user.email else { let Some(_current_email) = user.email else {
return Err(ApiError::InvalidRequest( 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())) ApiError::InternalError(Some("Failed to generate verification code".into()))
})?; })?;
if let Some(Json(ref inp)) = input if let Some(Json(inp)) = &input
&& let Some(ref new_email) = inp.new_email && 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(); let pending = PendingEmailUpdate {
if !new_email.is_empty() && tranquil_pds::api::validation::is_valid_email(&new_email) { new_email: address.as_str().to_string(),
let pending = PendingEmailUpdate { token_hash: hash_token(&token),
new_email, authorized: false,
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)
if let Ok(json) = serde_json::to_string(&pending) { && let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await
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);
warn!("Failed to cache pending email update: {:?}", e);
}
}
} }
} }
let hostname = &tranquil_config::get().server.hostname; 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user.id, user.id,
&token, tranquil_pds::comms::Notice::ShortTokenEmail { token: &token },
hostname, hostname,
) )
.await .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")? .log_db_err("getting email info")?
.ok_or(ApiError::AccountNotFound)?; .ok_or(ApiError::AccountNotFound)?;
let Some(ref email) = user.email else { let Some(email) = &user.email else {
return Err(ApiError::InvalidEmail); return Err(ApiError::InvalidEmail);
}; };
let current_email = email.to_lowercase(); let provided_email = tranquil_types::EmailAddress::new(input.email.trim())?;
if provided_email.as_str() != email.to_lowercase() {
let provided_email = input.email.trim().to_lowercase();
if provided_email != current_email {
return Err(ApiError::InvalidEmail); return Err(ApiError::InvalidEmail);
} }
@@ -170,7 +175,7 @@ pub async fn confirm_email(
let verified = tranquil_pds::auth::verification_token::verify_signup_token( let verified = tranquil_pds::auth::verification_token::verify_signup_token(
&confirmation_code, &confirmation_code,
CommsChannel::Email, CommsChannel::Email,
&provided_email, provided_email.as_str(),
); );
match verified { match verified {
@@ -226,17 +231,14 @@ pub async fn update_email(
let user_id = user.id; let user_id = user.id;
let current_email = user.email.clone(); let current_email = user.email.clone();
let email_verified = user.email_verified; let email_verified = user.email_verified;
let new_email = input.email.trim().to_lowercase(); let new_email = tranquil_types::EmailAddress::new(input.email.trim()).map_err(|_| {
ApiError::InvalidRequest(
if !tranquil_pds::api::validation::is_valid_email(&new_email) {
return Err(ApiError::InvalidRequest(
"This email address is not supported, please use a different email.".into(), "This email address is not supported, please use a different email.".into(),
)); )
} })?;
let email_unchanged = current_email let email_unchanged = current_email
.as_ref() .as_ref()
.map(|c| new_email == c.to_lowercase()) .map(|c| new_email.as_str() == c.to_lowercase())
.unwrap_or(false); .unwrap_or(false);
if email_unchanged { if email_unchanged {
@@ -283,7 +285,7 @@ pub async fn update_email(
if let Some(pending_json) = state.cache.get(&cache_key).await if let Some(pending_json) = state.cache.get(&cache_key).await
&& let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json) && let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json)
&& pending.authorized && pending.authorized
&& pending.new_email == new_email && pending.new_email == new_email.as_str()
{ {
authorized_via_link = true; authorized_via_link = true;
let _ = state.cache.delete(&cache_key).await; let _ = state.cache.delete(&cache_key).await;
@@ -350,24 +352,26 @@ pub async fn update_email(
state state
.repos .repos
.user .user
.update_email(user_id, &new_email) .update_email(user_id, new_email.as_str())
.await .await
.log_db_err("updating email")?; .log_db_err("updating email")?;
let verification_token = tranquil_pds::auth::verification_token::generate_signup_token( let verification_token = tranquil_pds::auth::verification_token::generate_signup_token(
did, did,
CommsChannel::Email, CommsChannel::Email,
&new_email, new_email.as_str(),
); );
let formatted_token = let formatted_token =
tranquil_pds::auth::verification_token::format_token_for_display(&verification_token); tranquil_pds::auth::verification_token::format_token_for_display(&verification_token);
let hostname = &tranquil_config::get().server.hostname; 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( if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
state.repos.user.as_ref(), state.repos.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
tranquil_db_traits::CommsChannel::Email, &target,
&new_email,
&formatted_token, &formatted_token,
hostname, hostname,
) )
@@ -565,15 +569,22 @@ pub async fn check_email_in_use(
_rate_limit: RateLimited<VerificationCheckLimit>, _rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckEmailInUseInput>, Json(input): Json<CheckEmailInUseInput>,
) -> Result<Json<InUseOutput>, ApiError> { ) -> Result<Json<InUseOutput>, ApiError> {
let email = input.email.trim().to_lowercase(); let raw = input.email.trim();
if email.is_empty() { let email = tranquil_types::EmailAddress::new(raw).map_err(|_| {
return Err(ApiError::InvalidRequest("email is required".into())); ApiError::InvalidRequest(
} if raw.is_empty() {
"Email is required"
} else {
"Invalid email address"
}
.into(),
)
})?;
let count = state let count = state
.repos .repos
.user .user
.count_accounts_by_email(&email) .count_accounts_by_email(email.as_str())
.await .await
.map_err(|e| { .map_err(|e| {
error!("DB error checking email usage: {:?}", e); error!("DB error checking email usage: {:?}", e);
@@ -116,8 +116,8 @@ pub async fn create_passkey_account(
.as_ref() .as_ref()
.map(|e| e.trim().to_string()) .map(|e| e.trim().to_string())
.filter(|e| !e.is_empty()); .filter(|e| !e.is_empty());
if let Some(ref email) = email if let Some(email) = &email
&& !tranquil_pds::api::validation::is_valid_email(email) && tranquil_types::EmailAddress::new(email).is_err()
{ {
return Err(ApiError::InvalidEmail); return Err(ApiError::InvalidEmail);
} }
@@ -703,11 +703,11 @@ pub async fn request_passkey_recovery(
urlencoding::encode(&recovery_token) 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user.id, user.id,
&recovery_url, tranquil_pds::comms::Notice::PasskeyRecovery { url: &recovery_url },
hostname, hostname,
) )
.await; .await;
@@ -776,21 +776,14 @@ pub async fn recover_passkey_account(
if result.passkeys_deleted > 0 { if result.passkeys_deleted > 0 {
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery"); 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 { crate::common::implicitly_verify_channel(
let actual_channel = state.repos.user.as_ref(),
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); &input.did,
if let Err(e) = state user.id,
.repos user.preferred_comms_channel,
.user "passkey recovery",
.set_channel_verified(&input.did, actual_channel) )
.await .await;
{
warn!(
"Failed to implicitly verify channel on passkey recovery: {:?}",
e
);
}
}
info!(did = %input.did, "Passkey-only account recovered with temporary password"); info!(did = %input.did, "Passkey-only account recovered with temporary password");
Ok(Json(SuccessResponse { success: true })) Ok(Json(SuccessResponse { success: true }))
} }
+12 -17
View File
@@ -90,11 +90,13 @@ pub async fn request_password_reset(
return Err(ApiError::InternalError(None)); return Err(ApiError::InternalError(None));
} }
let hostname = &tranquil_config::get().server.hostname; 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
&display_code, tranquil_pds::comms::Notice::PasswordReset {
code: &display_code,
},
hostname, hostname,
) )
.await .await
@@ -193,21 +195,14 @@ pub async fn reset_password(
} }
})) }))
.await; .await;
if let Ok(Some(prefs)) = state.repos.user.get_comms_prefs(user_id).await { crate::common::implicitly_verify_channel(
let actual_channel = state.repos.user.as_ref(),
tranquil_pds::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel); &user.did,
if let Err(e) = state user_id,
.repos user.preferred_comms_channel,
.user "password reset",
.set_channel_verified(&user.did, actual_channel) )
.await .await;
{
warn!(
"Failed to implicitly verify channel on password reset: {:?}",
e
);
}
}
info!("Password reset completed for user {}", user_id); info!("Password reset completed for user {}", user_id);
Ok(Json(EmptyResponse {})) 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)) => { Ok(tranquil_pds::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => {
let hostname = &tranquil_config::get().server.hostname; 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
row.id, row.id,
code.as_str(), tranquil_pds::comms::Notice::TwoFactorCode {
code: code.as_str(),
},
hostname, hostname,
) )
.await .await
{ {
error!("Failed to send 2FA code: {:?}", e); Ok(Some(_)) => {}
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did) Ok(None) => {
.await; tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did)
return Err(ApiError::InternalError(Some( .await;
"Failed to send verification code. Please try again.".into(), 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); return Err(ApiError::AuthFactorTokenRequired);
} }
@@ -336,13 +349,15 @@ pub async fn create_session(
"Legacy login on TOTP-enabled account - sending notification" "Legacy login on TOTP-enabled account - sending notification"
); );
let hostname = &tranquil_config::get().server.hostname; 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
row.id, row.id,
tranquil_pds::comms::Notice::LegacyLoginAlert {
channel: row.preferred_comms_channel,
ip: client_ip,
},
hostname, hostname,
client_ip,
row.preferred_comms_channel,
) )
.await .await
{ {
@@ -868,15 +883,13 @@ pub async fn confirm_signup(
} }
}; };
let identifier = match row.channel { let Some(id) = row.channel_identifier() else {
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(), warn!(
tranquil_db_traits::CommsChannel::Discord => { did = %input.did,
row.discord_username.clone().unwrap_or_default() channel = ?row.channel,
} "We can't confirm signup because the account doesn't have an identifier on file"
tranquil_db_traits::CommsChannel::Telegram => { );
row.telegram_username.clone().unwrap_or_default() return Err(ApiError::InvalidRequest("Invalid verification code".into()));
}
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
}; };
let normalized_token = let normalized_token =
@@ -884,7 +897,7 @@ pub async fn confirm_signup(
match tranquil_pds::auth::verification_token::verify_signup_token( match tranquil_pds::auth::verification_token::verify_signup_token(
&normalized_token, &normalized_token,
row.channel, row.channel,
&identifier, id,
) { ) {
Ok(token_data) => { Ok(token_data) => {
if token_data.did != input.did { if token_data.did != input.did {
@@ -940,10 +953,11 @@ pub async fn confirm_signup(
}; };
let hostname = &tranquil_config::get().server.hostname; 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
row.id, row.id,
tranquil_pds::comms::Notice::Welcome,
hostname, hostname,
) )
.await .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); const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120);
pub struct AutoResendResult { pub struct AutoResendResult {
@@ -990,37 +1033,15 @@ pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<Aut
handle: row.handle.clone(), handle: row.handle.clone(),
channel: row.channel, channel: row.channel,
}; };
let is_bot_channel = matches!( if row.channel.verifies_via_bot() || debounced {
row.channel,
tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord
);
if is_bot_channel || debounced {
return Some(result); return Some(result);
} }
let recipient = match row.channel { if resend_signup_verification(state, &row, did, "auto-resend verification").await {
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(), let _ = state
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(), .cache
_ => return Some(result), .set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE)
}; .await;
if recipient.is_empty() {
warn!(
"No recipient configured for auto-resend verification: {}",
did
);
return Some(result);
} }
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) Some(result)
} }
@@ -1050,32 +1071,12 @@ pub async fn resend_verification(
return Err(ApiError::InternalError(None)); return Err(ApiError::InternalError(None));
} }
}; };
let is_verified = row.channel_verification.has_any_verified(); if row.channel_verification.has_any_verified() {
if is_verified {
return Err(ApiError::InvalidRequest( return Err(ApiError::InvalidRequest(
"Account is already verified".into(), "Account is already verified".into(),
)); ));
} }
resend_signup_verification(&state, &row, &input.did, "resend verification").await;
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;
Ok(Json(SuccessResponse { success: true })) Ok(Json(SuccessResponse { success: true }))
} }
+14 -11
View File
@@ -57,9 +57,9 @@ pub async fn resend_migration_verification(
let channel = input let channel = input
.channel .channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email); .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(Some(u)) => u,
Ok(None) => { Ok(None) => {
return Ok(Json(ResendMigrationVerificationOutput { sent: true })); return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
@@ -73,15 +73,18 @@ pub async fn resend_migration_verification(
if user.email_verified { if user.email_verified {
return Ok(Json(ResendMigrationVerificationOutput { sent: true })); return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
} }
let target = tranquil_pds::comms::VerificationTarget::resolve(channel, &id, Some(&id)).ok();
crate::identity::provision::enqueue_migration_verification( if let Some(target) = target {
&state, crate::identity::provision::enqueue_migration_verification(
user.id, &state, user.id, &user.did, channel, &target,
&user.did, )
channel, .await;
&identifier, } else {
) warn!(
.await; 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"); 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 handle_channel_update(state, &token_data.did, token_data.channel, &identifier).await
} }
VerificationPurpose::Signup => { VerificationPurpose::Signup => {
handle_signup_verification(state, &token_data.did, token_data.channel, &identifier) handle_signup_verification(state, &token_data.did, token_data.channel).await
.await
} }
} }
} }
@@ -167,7 +166,7 @@ async fn handle_channel_update(
info!(did = %did, channel = ?channel, "Channel verified successfully"); 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 { Ok(Json(VerifyTokenOutput {
success: true, success: true,
@@ -177,43 +176,49 @@ async fn handle_channel_update(
})) }))
} }
async fn notify_channel_verified( async fn notify_channel_verified(state: &AppState, user_id: uuid::Uuid, channel: CommsChannel) {
state: &AppState, let prefs = match state.repos.user.get_comms_prefs(user_id).await {
user_id: uuid::Uuid, Ok(Some(prefs)) => prefs,
channel: CommsChannel, Ok(None) => {
identifier: &str, warn!(
) { user_id = %user_id,
let recipient = match channel { channel = ?channel,
CommsChannel::Telegram => state "We skipped channel-verified notice because the account doesn't have comms preferences"
.repos );
.user return;
.get_telegram_chat_id(user_id) }
.await Err(e) => {
.ok() warn!(
.flatten() user_id = %user_id,
.map(|id| id.to_string()) channel = ?channel,
.unwrap_or_else(|| identifier.to_string()), error = ?e,
_ => identifier.to_string(), "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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id, user_id,
channel,
&recipient, &recipient,
&tranquil_config::get().server.hostname, &tranquil_config::get().server.hostname,
) )
.await .await;
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
} }
async fn handle_signup_verification( async fn handle_signup_verification(
state: &AppState, state: &AppState,
did: &Did, did: &Did,
channel: CommsChannel, channel: CommsChannel,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> { ) -> Result<Json<VerifyTokenOutput>, ApiError> {
let user = state let user = state
.repos .repos
@@ -238,7 +243,7 @@ async fn handle_signup_verification(
info!(did = %did, channel = ?channel, "Signup verified successfully"); 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 { Ok(Json(VerifyTokenOutput {
success: true, success: true,
+24 -13
View File
@@ -76,6 +76,13 @@ pub async fn handle_telegram_webhook(
return StatusCode::OK; 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!( debug!(
telegram_username = %username, telegram_username = %username,
@@ -95,17 +102,21 @@ pub async fn handle_telegram_webhook(
chat_id = from.id, chat_id = from.id,
"Verified Telegram user and stored chat_id" "Verified Telegram user and stored chat_id"
); );
if let Err(e) = comms_repo::enqueue_channel_verified( match tranquil_types::TelegramChatId::from_i64(from.id) {
state.repos.user.as_ref(), Some(chat_id) => {
state.repos.infra.as_ref(), comms_repo::try_channel_verified_notice(
user_id, state.repos.user.as_ref(),
tranquil_db_traits::CommsChannel::Telegram, state.repos.infra.as_ref(),
&from.id.to_string(), user_id,
&tranquil_config::get().server.hostname, &tranquil_types::Recipient::Telegram(chat_id),
) &tranquil_config::get().server.hostname,
.await )
{ .await;
warn!(error = %e, "Failed to enqueue channel verified notification"); }
None => warn!(
chat_id = from.id,
"We skipped verified notice because the Telegram chat ID can't be 0"
),
} }
} }
Ok(None) => { Ok(None) => {
@@ -178,8 +189,8 @@ mod tests {
#[test] #[test]
fn payload_with_extra_whitespace_trimmed() { fn payload_with_extra_whitespace_trimmed() {
assert_eq!( assert_eq!(
parse_start_handle(Some("/start alice_example_com ")), parse_start_handle(Some("/start oys_nel_pet ")),
Some("alice.example.com".to_string()), Some("oys.nel.pet".to_string()),
); );
} }
} }
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies] [dependencies]
tranquil-config = { workspace = true } tranquil-config = { workspace = true }
tranquil-signal = { workspace = true } tranquil-signal = { workspace = true }
tranquil-types = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
+21 -14
View File
@@ -6,15 +6,16 @@ use uuid::Uuid;
use super::types::EmailDomain; use super::types::EmailDomain;
use crate::sender::SendError; use crate::sender::SendError;
use crate::types::{CommsType, QueuedComms}; use crate::{CommsType, QueuedComms};
pub(super) fn build( pub(super) fn build(
from: &Mailbox, from: &Mailbox,
qc: &QueuedComms, qc: &QueuedComms,
to: &tranquil_types::EmailAddress,
apply_atmos_categories: bool, apply_atmos_categories: bool,
) -> Result<Message, SendError> { ) -> Result<Message, SendError> {
let to: Mailbox = qc let to: Mailbox = to
.recipient .as_str()
.parse() .parse()
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?; .map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
let subject = qc.subject.as_deref().unwrap_or("Notification"); let subject = qc.subject.as_deref().unwrap_or("Notification");
@@ -101,7 +102,7 @@ fn atmos_category(comms_type: CommsType) -> Option<AtmosCategory> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::types::{CommsChannel, CommsStatus}; use crate::{CommsChannel, CommsStatus};
use chrono::Utc; use chrono::Utc;
use uuid::Uuid; use uuid::Uuid;
@@ -109,6 +110,10 @@ mod tests {
"Test Sender <noreply@nel.pet>".parse().unwrap() "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 { fn fixture(recipient: &str, subject: Option<&str>, body: &str) -> QueuedComms {
QueuedComms { QueuedComms {
id: Uuid::new_v4(), id: Uuid::new_v4(),
@@ -135,6 +140,7 @@ mod tests {
let msg = build( let msg = build(
&from_mailbox(), &from_mailbox(),
&fixture("user@nel.pet", Some("Welcome"), "Hello world."), &fixture("user@nel.pet", Some("Welcome"), "Hello world."),
&to("user@nel.pet"),
false, false,
) )
.unwrap(); .unwrap();
@@ -153,6 +159,7 @@ mod tests {
let msg = build( let msg = build(
&from_mailbox(), &from_mailbox(),
&fixture("user@nel.pet", Some("héllo wörld"), "Body"), &fixture("user@nel.pet", Some("héllo wörld"), "Body"),
&to("user@jola.dev"),
false, false,
) )
.unwrap(); .unwrap();
@@ -163,12 +170,8 @@ mod tests {
#[test] #[test]
fn header_injection_rejected() { fn header_injection_rejected() {
let result = build( let result = tranquil_types::EmailAddress::new("x@jola.dev\r\nBcc: evil@x");
&from_mailbox(), assert!(result.is_err());
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
false,
);
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
} }
#[test] #[test]
@@ -176,13 +179,14 @@ mod tests {
let msg = build( let msg = build(
&from_mailbox(), &from_mailbox(),
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"), &fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
&to("user@jola.dev"),
false, false,
) )
.expect("subject CRLF should be encoded, not rejected"); .expect("subject CRLF should be encoded, not rejected");
let raw = String::from_utf8(msg.formatted()).unwrap(); let raw = String::from_utf8(msg.formatted()).unwrap();
assert!( assert!(
!raw.contains("Bcc:"), !raw.contains("Bcc:"),
"CRLF in subject must not produce a Bcc header: {raw}" "CRLF in subject mustn't produce a Bcc header: {raw}"
); );
assert!( assert!(
raw.contains("Subject: ="), raw.contains("Subject: ="),
@@ -195,6 +199,7 @@ mod tests {
let msg = build( let msg = build(
&from_mailbox(), &from_mailbox(),
&fixture("user@nel.pet", Some("s"), "b"), &fixture("user@nel.pet", Some("s"), "b"),
&to("user@jola.dev"),
false, false,
) )
.unwrap(); .unwrap();
@@ -214,6 +219,7 @@ mod tests {
let msg = build( let msg = build(
&from_mailbox(), &from_mailbox(),
&fixture("user@nel.pet", None, "Body"), &fixture("user@nel.pet", None, "Body"),
&to("user@nel.pet"),
false, false,
) )
.unwrap(); .unwrap();
@@ -226,6 +232,7 @@ mod tests {
let msg = build( let msg = build(
&from_mailbox(), &from_mailbox(),
&fixture("user@Nel.PET", Some("s"), "b"), &fixture("user@Nel.PET", Some("s"), "b"),
&to("user@nel.pet"),
false, false,
) )
.unwrap(); .unwrap();
@@ -239,7 +246,7 @@ mod tests {
comms_type: CommsType::PasswordReset, comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b") ..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(); let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("X-Atmos-Category: password-reset")); assert!(raw.contains("X-Atmos-Category: password-reset"));
} }
@@ -250,7 +257,7 @@ mod tests {
comms_type: CommsType::PasswordReset, comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b") ..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(); let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category")); assert!(!raw.contains("X-Atmos-Category"));
} }
@@ -261,7 +268,7 @@ mod tests {
comms_type: CommsType::AdminEmail, comms_type: CommsType::AdminEmail,
..fixture("user@nel.pet", Some("s"), "b") ..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(); let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category")); assert!(!raw.contains("X-Atmos-Category"));
} }
+17 -4
View File
@@ -25,7 +25,7 @@ use self::types::{
SmtpUsername, TlsMode, SmtpUsername, TlsMode,
}; };
use crate::sender::{CommsSender, SendError}; use crate::sender::{CommsSender, SendError};
use crate::types::{CommsChannel, QueuedComms}; use crate::{CommsChannel, QueuedComms};
pub struct EmailSender { pub struct EmailSender {
from: Mailbox, from: Mailbox,
@@ -193,9 +193,22 @@ impl CommsSender for EmailSender {
CommsChannel::Email CommsChannel::Email
} }
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { async fn send(
let mut message = &self,
message::build(&self.from, notification, wants_atmos_categories(&self.mode))?; 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 { if let Some(signer) = &self.dkim {
signer.sign(&mut message); signer.sign(&mut message);
} }
+1 -3
View File
@@ -1,7 +1,6 @@
pub mod email; pub mod email;
mod locale; mod locale;
mod sender; mod sender;
mod types;
pub use email::EmailSender; pub use email::EmailSender;
pub use locale::{ pub use locale::{
@@ -10,6 +9,5 @@ pub use locale::{
}; };
pub use sender::{ pub use sender::{
CommsSender, DiscordSender, SendError, SignalSender, TelegramSender, is_valid_phone_number, 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 serde_json::json;
use std::time::Duration; use std::time::Duration;
use super::types::{CommsChannel, QueuedComms}; use tranquil_db_traits::{CommsChannel, QueuedComms};
const HTTP_TIMEOUT_SECS: u64 = 30; const HTTP_TIMEOUT_SECS: u64 = 30;
const MAX_RETRIES: u32 = 3; const MAX_RETRIES: u32 = 3;
@@ -12,7 +12,11 @@ const INITIAL_RETRY_DELAY_MS: u64 = 500;
#[async_trait] #[async_trait]
pub trait CommsSender: Send + Sync { pub trait CommsSender: Send + Sync {
fn channel(&self) -> CommsChannel; 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)] #[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()) !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"; const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
#[derive(Clone)] #[derive(Clone)]
@@ -355,8 +355,17 @@ impl CommsSender for DiscordSender {
CommsChannel::Discord CommsChannel::Discord
} }
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { async fn send(
let channel_id = self.open_dm_channel(&notification.recipient).await?; &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 subject = notification.subject.as_deref().unwrap_or("Notification");
let content = format!("**{}**\n\n{}", subject, notification.body); let content = format!("**{}**\n\n{}", subject, notification.body);
@@ -453,14 +462,22 @@ impl CommsSender for TelegramSender {
CommsChannel::Telegram CommsChannel::Telegram
} }
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { async fn send(
let chat_id = &notification.recipient; &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 subject = escape_html(notification.subject.as_deref().unwrap_or("Notification"));
let body = escape_html(&notification.body); let body = escape_html(&notification.body);
let text = format!("<b>{}</b>\n\n{}", subject, body); let text = format!("<b>{}</b>\n\n{}", subject, body);
let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token); let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
let payload = json!({ let payload = json!({
"chat_id": chat_id, "chat_id": chat_id.as_str(),
"text": text, "text": text,
"parse_mode": "HTML" "parse_mode": "HTML"
}); });
@@ -488,9 +505,16 @@ impl CommsSender for SignalSender {
CommsChannel::Signal CommsChannel::Signal
} }
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { async fn send(
let username = tranquil_signal::SignalUsername::parse(&notification.recipient) &self,
.map_err(|e| SendError::InvalidRecipient(e.to_string()))?; 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 let client = self
.slot .slot
@@ -505,7 +529,7 @@ impl CommsSender for SignalSender {
let mut last_error = None; let mut last_error = None;
for attempt in 0..MAX_RETRIES { for attempt in 0..MAX_RETRIES {
match client.send(&username, message.clone()).await { match client.send(username, message.clone()).await {
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(e) => { Err(e) => {
let err_str = e.to_string(); 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 { fn build_smarthost_sender(host: &str, port: u16) -> EmailSender {
build_smarthost_sender_with_total_timeout(host, port, Duration::from_secs(10)) 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() { async fn rcpt_550_classifies_as_smtp_permanent() {
let port = spawn_stub(b"550 5.1.1 user unknown\r\n").await; 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 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 { match result {
Err(SendError::SmtpPermanent(_)) => {} Err(SendError::SmtpPermanent(_)) => {}
other => panic!("expected SmtpPermanent, got {other:?}"), 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() { async fn rcpt_421_classifies_as_smtp_transient() {
let port = spawn_stub(b"421 4.7.0 try again later\r\n").await; 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 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 { match result {
Err(SendError::SmtpTransient(_)) => {} Err(SendError::SmtpTransient(_)) => {}
other => panic!("expected SmtpTransient, got {other:?}"), other => panic!("expected SmtpTransient, got {other:?}"),
@@ -120,10 +128,13 @@ async fn rcpt_421_classifies_as_smtp_transient() {
} }
#[tokio::test] #[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 port = spawn_stub(b"250 OK\r\n").await;
let sender = build_smarthost_sender("127.0.0.1", port); 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 { match result {
Err(SendError::InvalidRecipient(_)) => {} Err(SendError::InvalidRecipient(_)) => {}
other => panic!("expected InvalidRecipient, got {other:?}"), other => panic!("expected InvalidRecipient, got {other:?}"),
@@ -146,7 +157,9 @@ async fn smarthost_silent_relay_hits_total_timeout() {
let sender = let sender =
build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500)); build_smarthost_sender_with_total_timeout("127.0.0.1", port, Duration::from_millis(500));
let start = std::time::Instant::now(); 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(); let elapsed = start.elapsed();
match result { match result {
Err(SendError::Timeout) => {} Err(SendError::Timeout) => {}
+1 -1
View File
@@ -775,7 +775,7 @@ pub struct StorageConfig {
pub s3_path: String, pub s3_path: String,
/// Repository backend: `postgres` by default, or `tranquil-store`, our embedded db. /// Repository backend: `postgres` by default, or `tranquil-store`, our embedded db.
/// tranquil-store is EXPERIMENTAL!!!! RISK OF TOTAL DATA LOSS. /// `tranquil-store` is our own solution, not a tried-and-tested out-of-box database. If you're not feeling brave, don't choose this.
#[config(env = "REPO_BACKEND", default = "postgres")] #[config(env = "REPO_BACKEND", default = "postgres")]
pub repo_backend: String, pub repo_backend: String,
} }
+2 -56
View File
@@ -44,60 +44,7 @@ impl InviteCodeState {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] pub use tranquil_types::{CommsChannel, Recipient};
#[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 {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "comms_type", rename_all = "snake_case")] #[sqlx(type_name = "comms_type", rename_all = "snake_case")]
@@ -226,9 +173,8 @@ pub trait InfraRepository: Send + Sync {
async fn enqueue_comms( async fn enqueue_comms(
&self, &self,
user_id: Option<Uuid>, user_id: Option<Uuid>,
channel: CommsChannel, recipient: &Recipient,
comms_type: CommsType, comms_type: CommsType,
recipient: &str,
subject: Option<&str>, subject: Option<&str>,
body: &str, body: &str,
metadata: Option<serde_json::Value>, metadata: Option<serde_json::Value>,
+1 -1
View File
@@ -25,7 +25,7 @@ pub use infra::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest,
DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, DeletionRequestWithToken, InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
InviteCodeState, InviteCodeUse, NotificationHistoryRow, PasswordResetInfo, PlcTokenInfo, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PasswordResetInfo, PlcTokenInfo,
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, QueuedComms, Recipient, ReservedSigningKey, ReservedSigningKeyFull,
}; };
pub use invite_code::{InviteCodeError, ValidatedInviteCode}; pub use invite_code::{InviteCodeError, ValidatedInviteCode};
pub use oauth::{ pub use oauth::{
+27 -6
View File
@@ -220,7 +220,11 @@ pub trait UserRepository: Send + Sync {
channel: CommsChannel, channel: CommsChannel,
) -> Result<Option<bool>, DbError>; ) -> 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>; 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( async fn store_telegram_chat_id(
&self, &self,
telegram_username: &str, telegram_username: &tranquil_types::TelegramUsername,
chat_id: i64, chat_id: i64,
handle: Option<&Handle>, handle: Option<&Handle>,
) -> Result<Option<Uuid>, DbError>; ) -> Result<Option<Uuid>, DbError>;
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
async fn set_unverified_discord( async fn set_unverified_discord(
&self, &self,
user_id: Uuid, user_id: Uuid,
@@ -281,8 +283,8 @@ pub trait UserRepository: Send + Sync {
async fn store_discord_user_id( async fn store_discord_user_id(
&self, &self,
discord_username: &str, discord_username: &tranquil_types::DiscordUsername,
discord_id: &str, discord_id: &tranquil_types::DiscordUserId,
handle: Option<&Handle>, handle: Option<&Handle>,
) -> Result<Option<Uuid>, DbError>; ) -> Result<Option<Uuid>, DbError>;
@@ -911,6 +913,25 @@ pub struct UserResendVerification {
pub channel_verification: ChannelVerificationStatus, 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)] #[derive(Debug, Clone)]
pub struct UserResetCodeInfo { pub struct UserResetCodeInfo {
pub id: Uuid, pub id: Uuid,
+4 -5
View File
@@ -5,7 +5,7 @@ use tranquil_db_traits::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest, AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest,
DeletionRequestWithToken, InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow, DeletionRequestWithToken, InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow,
InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo, InviteCodeSortOrder, InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo,
QueuedComms, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode, QueuedComms, Recipient, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
}; };
use tranquil_types::{Did, InviteCode}; use tranquil_types::{Did, InviteCode};
use uuid::Uuid; use uuid::Uuid;
@@ -29,9 +29,8 @@ impl InfraRepository for PostgresInfraRepository {
async fn enqueue_comms( async fn enqueue_comms(
&self, &self,
user_id: Option<Uuid>, user_id: Option<Uuid>,
channel: CommsChannel, recipient: &Recipient,
comms_type: CommsType, comms_type: CommsType,
recipient: &str,
subject: Option<&str>, subject: Option<&str>,
body: &str, body: &str,
metadata: Option<serde_json::Value>, metadata: Option<serde_json::Value>,
@@ -42,9 +41,9 @@ impl InfraRepository for PostgresInfraRepository {
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id"#, RETURNING id"#,
user_id, user_id,
channel as CommsChannel, recipient.channel() as CommsChannel,
comms_type as CommsType, comms_type as CommsType,
recipient, recipient.as_str(),
subject, subject,
body, body,
metadata 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!( let result = sqlx::query!(
"UPDATE users SET email = $1 WHERE did = $2", "UPDATE users SET email = $1 WHERE did = $2",
email, email.as_str(),
did.as_str() did.as_str()
) )
.execute(&self.pool) .execute(&self.pool)
@@ -3300,10 +3304,11 @@ impl UserRepository for PostgresUserRepository {
async fn store_discord_user_id( async fn store_discord_user_id(
&self, &self,
discord_username: &str, discord_username: &tranquil_types::DiscordUsername,
discord_id: &str, discord_id: &tranquil_types::DiscordUserId,
handle: Option<&Handle>, handle: Option<&Handle>,
) -> Result<Option<Uuid>, DbError> { ) -> Result<Option<Uuid>, DbError> {
let (discord_username, discord_id) = (discord_username.as_str(), discord_id.as_str());
let result = match handle { let result = match handle {
Some(h) => sqlx::query_scalar!( 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", "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( async fn store_telegram_chat_id(
&self, &self,
telegram_username: &str, telegram_username: &tranquil_types::TelegramUsername,
chat_id: i64, chat_id: i64,
handle: Option<&Handle>, handle: Option<&Handle>,
) -> Result<Option<Uuid>, DbError> { ) -> Result<Option<Uuid>, DbError> {
let telegram_username = telegram_username.as_str();
let result = match handle { let result = match handle {
Some(h) => sqlx::query_scalar!( 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", "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) 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( async fn get_password_reset_info(
&self, &self,
email: &str, email: &str,
@@ -572,20 +572,31 @@ pub async fn authorize_post(
{ {
Ok(challenge) => { Ok(challenge) => {
let hostname = &tranquil_config::get().server.hostname; let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code( match enqueue_notice(
state.repos.user.as_ref(), state.repos.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user.id, user.id,
&challenge.code, Notice::TwoFactorCode {
code: &challenge.code,
},
hostname, hostname,
) )
.await .await
{ {
tracing::warn!( Ok(Some(_)) => {}
did = %user.did, Ok(None) => {
error = %e, return show_login_error(
"Failed to enqueue 2FA notification" "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(); let channel_name = user.preferred_comms_channel.display_name();
if json_response { if json_response {
@@ -907,20 +918,32 @@ pub async fn authorize_select(
{ {
Ok(challenge) => { Ok(challenge) => {
let hostname = &tranquil_config::get().server.hostname; let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = enqueue_2fa_code( match enqueue_notice(
state.repos.user.as_ref(), state.repos.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user.id, user.id,
&challenge.code, Notice::TwoFactorCode {
code: &challenge.code,
},
hostname, hostname,
) )
.await .await
{ {
tracing::warn!( Ok(Some(_)) => {}
did = %form.did, Ok(None) => {
error = %e, return json_error(
"Failed to enqueue 2FA notification" 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(); let channel_name = user.preferred_comms_channel.display_name();
return Json(serde_json::json!({ return Json(serde_json::json!({
@@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use tranquil_db_traits::{ScopePreference, WebauthnChallengeType}; use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier}; 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::{ use tranquil_pds::oauth::{
AuthFlow, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent, AuthFlow, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent,
}; };
@@ -1273,16 +1273,31 @@ pub async fn authorize_passkey_finish(
.await .await
{ {
Ok(challenge) => { Ok(challenge) => {
if let Err(e) = enqueue_2fa_code( match enqueue_notice(
state.repos.user.as_ref(), state.repos.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user.id, user.id,
&challenge.code, Notice::TwoFactorCode {
code: &challenge.code,
},
pds_hostname, pds_hostname,
) )
.await .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 channel_name = user.preferred_comms_channel.display_name();
let redirect_url = format!( let redirect_url = format!(
@@ -916,55 +916,26 @@ pub async fn complete_registration(
let verification_channel = input let verification_channel = input
.verification_channel .verification_channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email); .unwrap_or(tranquil_db_traits::CommsChannel::Email);
let verification_recipient = match verification_channel { let effective_email = input
tranquil_db_traits::CommsChannel::Email => { .email
let email = input .clone()
.email .or_else(|| {
pending_preview
.provider_email
.clone() .clone()
.or_else(|| { .map(|e| e.into_inner())
pending_preview })
.provider_email .map(|e| e.trim().to_string())
.clone() .filter(|e| !e.is_empty());
.map(|e| e.into_inner()) let target = tranquil_api::common::extract_verification_recipient(
}) verification_channel,
.map(|e| e.trim().to_string()) &tranquil_api::common::ChannelInput {
.filter(|e| !e.is_empty()); email: effective_email.as_deref(),
match email { discord_username: input.discord_username.as_deref(),
Some(e) if !e.is_empty() => e, telegram_username: input.telegram_username.as_deref(),
_ => return Err(ApiError::MissingEmail), signal_username: input.signal_username.as_deref(),
}
}
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),
}, },
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 let email = input
.email .email
@@ -978,18 +949,11 @@ pub async fn complete_registration(
.map(|e| e.trim().to_string()) .map(|e| e.trim().to_string())
.filter(|e| !e.is_empty()); .filter(|e| !e.is_empty());
let email = match &email { if let Some(e) = &email
Some(e) => { && tranquil_types::EmailAddress::new(e).is_err()
if e.len() > 254 { {
return Err(ApiError::InvalidEmail); return Err(ApiError::InvalidEmail);
} }
if !tranquil_pds::api::validation::is_valid_email(e) {
return Err(ApiError::InvalidEmail);
}
Some(e.clone())
}
None => None,
};
let invite_registration = let invite_registration =
check_registration_invite(&state, input.invite_code.as_deref()).await?; 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; 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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
user_id.unwrap_or(uuid::Uuid::nil()), user_id.unwrap_or(uuid::Uuid::nil()),
tranquil_pds::comms::Notice::Welcome,
hostname, hostname,
) )
.await .await
@@ -1376,7 +1341,7 @@ pub async fn complete_registration(
let verification_token = tranquil_pds::auth::verification_token::generate_signup_token( let verification_token = tranquil_pds::auth::verification_token::generate_signup_token(
&did, &did,
verification_channel, verification_channel,
&verification_recipient, &target.id,
); );
let formatted_token = let formatted_token =
tranquil_pds::auth::verification_token::format_token_for_display(&verification_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.user.as_ref(),
state.repos.infra.as_ref(), state.repos.infra.as_ref(),
uid, uid,
verification_channel, &target,
&verification_recipient,
&formatted_token, &formatted_token,
hostname, 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 { impl From<crate::plc::PlcError> for ApiError {
fn from(e: crate::plc::PlcError) -> Self { fn from(e: crate::plc::PlcError) -> Self {
use crate::plc::PlcError; use crate::plc::PlcError;
+2 -1
View File
@@ -293,10 +293,11 @@ async fn proxy_handler(
return ApiError::InvalidRequest(format!("Invalid XRPC method: {}", method)) return ApiError::InvalidRequest(format!("Invalid XRPC method: {}", method))
.into_response(); .into_response();
}; };
let scope_aud = format!("{}#{}", resolved.did, service_id);
if let Err(e) = crate::auth::scope_check::check_rpc_scope( if let Err(e) = crate::auth::scope_check::check_rpc_scope(
&auth_user.auth_source, &auth_user.auth_source,
auth_user.scope.as_deref(), auth_user.scope.as_deref(),
&resolved.did, &scope_aud,
&method_nsid, &method_nsid,
) { ) {
return e.into_response(); return e.into_response();
-166
View File
@@ -1,102 +1,11 @@
use crate::types::Handle; 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; pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63;
const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-";
pub const MIN_HANDLE_LENGTH: usize = 3; pub const MIN_HANDLE_LENGTH: usize = 3;
pub const MAX_HANDLE_LENGTH: usize = 253; pub const MAX_HANDLE_LENGTH: usize = 253;
pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18; 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)] #[derive(Debug, PartialEq)]
pub enum HandleValidationError { pub enum HandleValidationError {
Empty, Empty,
@@ -304,23 +213,6 @@ pub fn validate_service_handle(
Ok(handle.to_lowercase()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -487,62 +379,4 @@ mod tests {
Err(HandleValidationError::Reserved) 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; mod service;
pub use service::repo::Notice;
pub use service::{CommsService, VerificationTarget, recipient_for, repo as comms_repo};
pub use tranquil_comms::{ pub use tranquil_comms::{
CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender, CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender,
NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender, VALID_LOCALES,
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username, format_message, get_strings, is_valid_phone_number, validate_locale,
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 tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use tranquil_comms::{ 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; use uuid::Uuid;
pub struct CommsService { pub struct CommsService {
@@ -47,23 +51,6 @@ impl CommsService {
self 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 { pub fn has_senders(&self) -> bool {
!self.senders.is_empty() !self.senders.is_empty()
} }
@@ -126,8 +113,28 @@ impl CommsService {
async fn process_item(&self, item: QueuedComms) { async fn process_item(&self, item: QueuedComms) {
let comms_id = item.id; 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) { let result = match self.senders.get(&item.channel) {
Some(sender) => sender.send(&item).await, Some(sender) => sender.send(&item, &recipient).await,
None => { None => {
warn!( warn!(
comms_id = %comms_id, comms_id = %comms_id,
@@ -189,131 +196,234 @@ impl CommsService {
} }
} }
struct ResolvedRecipient { // 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.
channel: tranquil_db_traits::CommsChannel, // 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.
recipient: String, pub struct VerificationTarget {
pub id: String,
pub recipient: Recipient,
} }
pub fn resolve_delivery_channel( impl VerificationTarget {
prefs: &UserCommsPrefs, pub fn direct(recipient: Recipient) -> Self {
channel: tranquil_db_traits::CommsChannel, Self {
) -> tranquil_db_traits::CommsChannel { id: recipient.as_str().to_string(),
resolve_recipient(prefs, channel).channel recipient,
} }
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),
} }
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 { pub mod repo {
use super::*; use super::*;
use tranquil_db_traits::DbError;
pub async fn enqueue_welcome( pub enum Notice<'a> {
user_repo: &dyn UserRepository, Welcome,
infra_repo: &dyn InfraRepository, PasswordReset { code: &'a str },
user_id: Uuid, TwoFactorCode { code: &'a str },
hostname: &str, AccountDeletion { code: &'a str },
) -> Result<Uuid, DbError> { PlcOperation { token: &'a str },
let prefs = user_repo PasskeyRecovery { url: &'a str },
.get_comms_prefs(user_id) ShortTokenEmail { token: &'a str },
.await? LegacyLoginAlert { channel: CommsChannel, ip: &'a str },
.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 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, user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository, infra_repo: &dyn InfraRepository,
user_id: Uuid, user_id: Uuid,
code: &str, notice: Notice<'_>,
hostname: &str, hostname: &str,
) -> Result<Uuid, DbError> { ) -> Result<Option<Uuid>, DbError> {
let prefs = user_repo let prefs = user_repo
.get_comms_prefs(user_id) .get_comms_prefs(user_id)
.await? .await?
.ok_or(DbError::NotFound)?; .ok_or(DbError::NotFound)?;
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); let channel = notice.channel().unwrap_or(prefs.preferred_channel);
let body = format_message( let Some(recipient) = recipient_for(&prefs, channel) else {
strings.password_reset_body, warn!(
&[("handle", &prefs.handle), ("code", code)], user_id = %user_id,
); channel = ?channel,
let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]); "We skipped queuing this notice because the account doesn't have a valid recipient"
let resolved = resolve_recipient(&prefs, prefs.preferred_channel); );
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 infra_repo
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
resolved.channel, &recipient,
CommsType::PasswordReset, notice.comms_type(),
&resolved.recipient,
Some(&subject), Some(&subject),
&body, &body,
None, None,
) )
.await .await
.map(Some)
}
fn locale_of(prefs: &UserCommsPrefs) -> &str {
prefs.preferred_locale.as_deref().unwrap_or("en")
} }
pub async fn enqueue_email_update( pub async fn enqueue_email_update(
infra_repo: &dyn InfraRepository, infra_repo: &dyn InfraRepository,
user_id: Uuid, user_id: Uuid,
new_email: &str, new_email: &EmailAddress,
handle: &crate::types::Handle, handle: &crate::types::Handle,
code: &str, code: &str,
hostname: &str, hostname: &str,
) -> Result<Uuid, DbError> { ) -> Result<Uuid, DbError> {
let strings = get_strings("en"); 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 encoded_token = urlencoding::encode(code);
let verify_page = format!("https://{}/app/verify", hostname); let verify_page = format!("https://{}/app/verify", hostname);
let verify_link = format!( let verify_link = format!(
@@ -333,186 +443,8 @@ pub mod repo {
infra_repo infra_repo
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
tranquil_db_traits::CommsChannel::Email, &Recipient::Email(new_email.clone()),
CommsType::EmailUpdate, 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), Some(&subject),
&body, &body,
None, None,
@@ -524,8 +456,7 @@ pub mod repo {
user_repo: &dyn UserRepository, user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository, infra_repo: &dyn InfraRepository,
user_id: Uuid, user_id: Uuid,
channel: tranquil_db_traits::CommsChannel, target: &VerificationTarget,
recipient: &str,
token: &str, token: &str,
hostname: &str, hostname: &str,
) -> Result<Uuid, DbError> { ) -> Result<Uuid, DbError> {
@@ -533,13 +464,13 @@ pub mod repo {
.get_comms_prefs(user_id) .get_comms_prefs(user_id)
.await? .await?
.ok_or(DbError::NotFound)?; .ok_or(DbError::NotFound)?;
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); let strings = get_strings(locale_of(&prefs));
let encoded_recipient = urlencoding::encode(recipient); let encoded_id = urlencoding::encode(&target.id);
let encoded_token = urlencoding::encode(token); let encoded_token = urlencoding::encode(token);
let verify_page = format!("https://{}/app/verify", hostname); let verify_page = format!("https://{}/app/verify", hostname);
let verify_link = format!( let verify_link = format!(
"https://{}/app/verify?token={}&identifier={}", "https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_recipient hostname, encoded_token, encoded_id
); );
let body = format_message( let body = format_message(
strings.migration_verification_body, strings.migration_verification_body,
@@ -557,9 +488,8 @@ pub mod repo {
infra_repo infra_repo
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
channel, &target.recipient,
CommsType::MigrationVerification, CommsType::MigrationVerification,
recipient,
Some(&subject), Some(&subject),
&body, &body,
None, None,
@@ -571,12 +501,10 @@ pub mod repo {
user_repo: &dyn UserRepository, user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository, infra_repo: &dyn InfraRepository,
user_id: Uuid, user_id: Uuid,
channel: tranquil_db_traits::CommsChannel, target: &VerificationTarget,
recipient: &str,
code: &str, code: &str,
hostname: &str, hostname: &str,
) -> Result<Uuid, DbError> { ) -> Result<Uuid, DbError> {
let comms_channel = channel;
let prefs = match user_repo.get_comms_prefs(user_id).await { let prefs = match user_repo.get_comms_prefs(user_id).await {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
@@ -584,17 +512,14 @@ pub mod repo {
None None
} }
}; };
let locale = prefs let locale = prefs.as_ref().map(locale_of).unwrap_or("en");
.as_ref()
.and_then(|p| p.preferred_locale.as_deref())
.unwrap_or("en");
let strings = get_strings(locale); let strings = get_strings(locale);
let encoded_token = urlencoding::encode(code); 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_page = format!("https://{}/app/verify", hostname);
let verify_link = format!( let verify_link = format!(
"https://{}/app/verify?token={}&identifier={}", "https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_recipient hostname, encoded_token, encoded_id
); );
let body = format_message( let body = format_message(
strings.signup_verification_body, strings.signup_verification_body,
@@ -612,80 +537,8 @@ pub mod repo {
infra_repo infra_repo
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
comms_channel, &target.recipient,
CommsType::EmailVerification, 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), Some(&subject),
&body, &body,
None, None,
@@ -697,20 +550,19 @@ pub mod repo {
user_repo: &dyn UserRepository, user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository, infra_repo: &dyn InfraRepository,
user_id: Uuid, user_id: Uuid,
channel: tranquil_db_traits::CommsChannel, recipient: &Recipient,
recipient: &str,
hostname: &str, hostname: &str,
) -> Result<Uuid, DbError> { ) -> Result<Uuid, DbError> {
let prefs = user_repo let prefs = user_repo
.get_comms_prefs(user_id) .get_comms_prefs(user_id)
.await? .await?
.ok_or(DbError::NotFound)?; .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( let body = format_message(
strings.channel_verified_body, strings.channel_verified_body,
&[ &[
("handle", &prefs.handle), ("handle", &prefs.handle),
("channel", channel.display_name()), ("channel", recipient.channel().display_name()),
("hostname", hostname), ("hostname", hostname),
], ],
); );
@@ -718,13 +570,116 @@ pub mod repo {
infra_repo infra_repo
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
channel,
CommsType::ChannelVerified,
recipient, recipient,
CommsType::ChannelVerified,
Some(&subject), Some(&subject),
&body, &body,
None, None,
) )
.await .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 -1
View File
@@ -251,7 +251,7 @@ impl AppState {
let mut state = match cfg.storage.repo_backend() { let mut state = match cfg.storage.repo_backend() {
tranquil_config::RepoBackend::TranquilStore => { tranquil_config::RepoBackend::TranquilStore => {
tracing::info!("tranquil-store repo backend active. EXPERIMENTAL!"); tracing::info!("tranquil-store repo backend active. Thank you so much for believing in us!");
Self::from_store(shutdown).await Self::from_store(shutdown).await
} }
tranquil_config::RepoBackend::Postgres => { tranquil_config::RepoBackend::Postgres => {
@@ -1,8 +1,54 @@
mod common; 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 serde_json::{Value, json};
use tranquil_db_traits::{CommsChannel, CommsType}; 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] #[tokio::test]
async fn test_get_notification_history() { async fn test_get_notification_history() {
@@ -11,21 +57,15 @@ async fn test_get_notification_history() {
let repos = get_test_repos().await; let repos = get_test_repos().await;
let (token, did) = create_account_and_login(&client).await; let (token, did) = create_account_and_login(&client).await;
let user_id = repos let user_id = user_id_of(repos, &Did::new(did).unwrap()).await;
.user
.get_id_by_did(&Did::new(did).unwrap())
.await
.expect("DB error")
.expect("User not found");
for i in 0..3 { for i in 0..3 {
repos repos
.infra .infra
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
CommsChannel::Email, &Recipient::new(CommsChannel::Email, "test@nel.pet").unwrap(),
CommsType::Welcome, CommsType::Welcome,
"test@example.com",
Some(&format!("Subject {}", i)), Some(&format!("Subject {}", i)),
&format!("Body {}", i), &format!("Body {}", i),
None, None,
@@ -57,16 +97,13 @@ async fn test_verify_channel_discord() {
let base = base_url().await; let base = base_url().await;
let (token, _did) = create_account_and_login(&client).await; let (token, _did) = create_account_and_login(&client).await;
let prefs = json!({ let resp = set_prefs(
"discordUsername": "testuser123" &client,
}); base,
let resp = client &token,
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) json!({ "discordUsername": "testuser123" }),
.header("Authorization", format!("Bearer {}", token)) )
.json(&prefs) .await;
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
assert!( assert!(
@@ -93,51 +130,112 @@ async fn test_verify_channel_invalid_code() {
let base = base_url().await; let base = base_url().await;
let (token, _did) = create_account_and_login(&client).await; let (token, _did) = create_account_and_login(&client).await;
let prefs = json!({ let resp = set_prefs(
"telegramUsername": "testuser" &client,
}); base,
let resp = client &token,
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) json!({ "telegramUsername": "testuser" }),
.header("Authorization", format!("Bearer {}", token)) )
.json(&prefs) .await;
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let input = json!({ let resp = confirm_channel(
"channel": "telegram", &client,
"identifier": "testuser", base,
"code": "XXXX-XXXX-XXXX-XXXX" &token,
}); "telegram",
let resp = client "testuser",
.post(format!("{}/xrpc/_account.confirmChannelVerification", base)) "XXXX-XXXX-XXXX-XXXX",
.header("Authorization", format!("Bearer {}", token)) )
.json(&input) .await;
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400); 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] #[tokio::test]
async fn test_verify_channel_not_set() { async fn test_verify_channel_not_set() {
let client = client(); let client = client();
let base = base_url().await; let base = base_url().await;
let (token, _did) = create_account_and_login(&client).await; let (token, _did) = create_account_and_login(&client).await;
let input = json!({ let resp = confirm_channel(
"channel": "signal", &client,
"identifier": "123456", base,
"code": "XXXX-XXXX-XXXX-XXXX" &token,
}); "signal",
let resp = client "123456",
.post(format!("{}/xrpc/_account.confirmChannelVerification", base)) "XXXX-XXXX-XXXX-XXXX",
.header("Authorization", format!("Bearer {}", token)) )
.json(&input) .await;
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400); assert_eq!(resp.status(), 400);
} }
@@ -148,17 +246,8 @@ async fn test_update_email_via_notification_prefs() {
let repos = get_test_repos().await; let repos = get_test_repos().await;
let (token, did) = create_account_and_login(&client).await; let (token, did) = create_account_and_login(&client).await;
let unique_email = format!("newemail_{}@example.com", uuid::Uuid::new_v4()); let unique_email = format!("newemail_{}@jola.dev", uuid::Uuid::new_v4());
let prefs = json!({ let resp = set_prefs(&client, base, &token, json!({ "email": unique_email })).await;
"email": unique_email
});
let resp = client
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
.header("Authorization", format!("Bearer {}", token))
.json(&prefs)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
assert!( assert!(
@@ -168,12 +257,7 @@ async fn test_update_email_via_notification_prefs() {
.contains(&json!("email")) .contains(&json!("email"))
); );
let user_id = repos let user_id = user_id_of(repos, &Did::new(did).unwrap()).await;
.user
.get_id_by_did(&Did::new(did).unwrap())
.await
.expect("DB error")
.expect("User not found");
let comms = repos let comms = repos
.infra .infra
@@ -202,18 +286,7 @@ async fn test_update_email_via_notification_prefs() {
.unwrap_or_default() .unwrap_or_default()
}); });
let input = json!({ let resp = confirm_channel(&client, base, &token, "email", &unique_email, &code).await;
"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();
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
let resp = client let resp = client
+44 -18
View File
@@ -16,7 +16,7 @@ async fn test_send_email_success() {
.bearer_auth(&access_jwt) .bearer_auth(&access_jwt)
.json(&json!({ .json(&json!({
"recipientDid": did, "recipientDid": did,
"senderDid": "did:plc:admin", "senderDid": "did:plc:oystercafe",
"content": "Hello, this is a test email from the admin.", "content": "Hello, this is a test email from the admin.",
"subject": "Test Admin Email" "subject": "Test Admin Email"
})) }))
@@ -26,12 +26,7 @@ async fn test_send_email_success() {
assert_eq!(res.status(), StatusCode::OK); assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON"); let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["sent"], true); assert_eq!(body["sent"], true);
let user_id = repos let user_id = common::user_id_of(repos, &Did::new(did).unwrap()).await;
.user
.get_id_by_did(&Did::new(did).unwrap())
.await
.expect("DB error")
.expect("User not found");
let comms = repos let comms = repos
.infra .infra
.get_latest_comms_for_user(user_id, CommsType::AdminEmail, 1) .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) .bearer_auth(&access_jwt)
.json(&json!({ .json(&json!({
"recipientDid": did, "recipientDid": did,
"senderDid": "did:plc:admin", "senderDid": "did:plc:oystercafe",
"content": "Email without subject" "content": "Email without subject"
})) }))
.send() .send()
@@ -66,12 +61,7 @@ async fn test_send_email_default_subject() {
assert_eq!(res.status(), StatusCode::OK); assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Invalid JSON"); let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!(body["sent"], true); assert_eq!(body["sent"], true);
let user_id = repos let user_id = common::user_id_of(repos, &Did::new(did).unwrap()).await;
.user
.get_id_by_did(&Did::new(did).unwrap())
.await
.expect("DB error")
.expect("User not found");
let comms = repos let comms = repos
.infra .infra
.get_latest_comms_for_user(user_id, CommsType::AdminEmail, 10) .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) .bearer_auth(&access_jwt)
.json(&json!({ .json(&json!({
"recipientDid": "did:plc:nonexistent", "recipientDid": "did:plc:nonexistent",
"senderDid": "did:plc:admin", "senderDid": "did:plc:oystercafe",
"content": "Test content" "content": "Test content"
})) }))
.send() .send()
@@ -122,7 +112,7 @@ async fn test_send_email_missing_content() {
.bearer_auth(&access_jwt) .bearer_auth(&access_jwt)
.json(&json!({ .json(&json!({
"recipientDid": did, "recipientDid": did,
"senderDid": "did:plc:admin", "senderDid": "did:plc:oystercafe",
"content": "" "content": ""
})) }))
.send() .send()
@@ -143,7 +133,7 @@ async fn test_send_email_missing_recipient() {
.bearer_auth(&access_jwt) .bearer_auth(&access_jwt)
.json(&json!({ .json(&json!({
"recipientDid": "", "recipientDid": "",
"senderDid": "did:plc:admin", "senderDid": "did:plc:oystercafe",
"content": "Test content" "content": "Test content"
})) }))
.send() .send()
@@ -160,7 +150,7 @@ async fn test_send_email_requires_auth() {
.post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url))
.json(&json!({ .json(&json!({
"recipientDid": "did:plc:test", "recipientDid": "did:plc:test",
"senderDid": "did:plc:admin", "senderDid": "did:plc:oystercafe",
"content": "Test content" "content": "Test content"
})) }))
.send() .send()
@@ -168,3 +158,39 @@ async fn test_send_email_requires_auth() {
.expect("Failed to send email"); .expect("Failed to send email");
assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 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(); TEST_BLOCK_STORE.set(state.block_store.clone()).ok();
if let Some((cache, distributed_rate_limiter)) = config.cache { if let Some((cache, distributed_rate_limiter)) = config.cache {
state = state.with_cache(cache, distributed_rate_limiter); state = state.with_cache(cache, distributed_rate_limiter);
} else {
tranquil_pds::state::set_rate_limiting_disabled(true);
} }
TEST_APP_STATE.set(state.clone()).ok(); TEST_APP_STATE.set(state.clone()).ok();
tranquil_sync::listener::start_sequencer_listener(state.clone()).await; 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") 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)] #[allow(dead_code)]
pub async fn get_test_block_store() -> &'static tranquil_pds::repo::AnyBlockStore { pub async fn get_test_block_store() -> &'static tranquil_pds::repo::AnyBlockStore {
base_url().await; 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 client = common::client();
let base_url = common::base_url().await; let base_url = common::base_url().await;
let handle = format!("er{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await;
let res = client let res = client
@@ -92,9 +92,9 @@ async fn test_update_email_flow_success() {
let base_url = common::base_url().await; let base_url = common::base_url().await;
let repos = common::get_test_repos().await; let repos = common::get_test_repos().await;
let handle = format!("eu{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 (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 let res = client
.post(format!( .post(format!(
@@ -139,9 +139,9 @@ async fn test_update_email_requires_token_when_verified() {
let client = common::client(); let client = common::client();
let base_url = common::base_url().await; let base_url = common::base_url().await;
let handle = format!("ed{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 (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 let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .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 client = common::client();
let base_url = common::base_url().await; let base_url = common::base_url().await;
let handle = format!("es{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await;
let res = client let res = client
@@ -182,9 +182,9 @@ async fn test_update_email_invalid_token() {
let client = common::client(); let client = common::client();
let base_url = common::base_url().await; let base_url = common::base_url().await;
let handle = format!("eb{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 (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 let res = client
.post(format!( .post(format!(
@@ -219,7 +219,7 @@ async fn test_update_email_no_auth() {
let res = client let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.json(&json!({ "email": "test@example.com" })) .json(&json!({ "email": "test@jola.dev" }))
.send() .send()
.await .await
.expect("Failed to send request"); .expect("Failed to send request");
@@ -233,7 +233,7 @@ async fn test_update_email_invalid_format() {
let client = common::client(); let client = common::client();
let base_url = common::base_url().await; let base_url = common::base_url().await;
let handle = format!("ef{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 (access_jwt, _) = create_verified_account(&client, base_url, &handle, &email).await;
let res = client let res = client
@@ -252,7 +252,7 @@ async fn test_confirm_email_confirms_existing_email() {
let base_url = common::base_url().await; let base_url = common::base_url().await;
let repos = common::get_test_repos().await; let repos = common::get_test_repos().await;
let handle = format!("ec{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 let res = client
.post(format!( .post(format!(
@@ -327,7 +327,7 @@ async fn test_confirm_email_rejects_wrong_email() {
let base_url = common::base_url().await; let base_url = common::base_url().await;
let repos = common::get_test_repos().await; let repos = common::get_test_repos().await;
let handle = format!("ew{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 let res = client
.post(format!( .post(format!(
@@ -378,7 +378,7 @@ async fn test_confirm_email_rejects_wrong_email() {
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
.bearer_auth(&access_jwt) .bearer_auth(&access_jwt)
.json(&json!({ .json(&json!({
"email": "different@example.com", "email": "different@jola.dev",
"token": code "token": code
})) }))
.send() .send()
@@ -394,7 +394,7 @@ async fn test_confirm_email_invalid_token() {
let client = common::client(); let client = common::client();
let base_url = common::base_url().await; let base_url = common::base_url().await;
let handle = format!("ei{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 let res = client
.post(format!( .post(format!(
@@ -437,7 +437,7 @@ async fn test_unverified_account_can_update_email_without_token() {
let base_url = common::base_url().await; let base_url = common::base_url().await;
let repos = common::get_test_repos().await; let repos = common::get_test_repos().await;
let handle = format!("ev{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 let res = client
.post(format!( .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"); let body: Value = res.json().await.expect("Invalid JSON");
assert_eq!( assert_eq!(
body["tokenRequired"], false, 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 let res = client
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
.bearer_auth(&access_jwt) .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 repos = common::get_test_repos().await;
let handle1 = format!("d1{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 (_, _) = create_verified_account(&client, base_url, &handle1, &email1).await;
let handle2 = format!("d2{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 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 (access_jwt2, did2) = create_verified_account(&client, base_url, &handle2, &email2).await;
let res = client let res = client
@@ -554,3 +554,29 @@ async fn test_update_email_to_same_as_another_user_allowed() {
.email; .email;
assert_eq!(user_email, Some(email1.clone())); 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, jwt) = setup_new_user("gc-after-delete").await;
let did = Did::new(did).expect("setup_new_user returned a valid DID"); let did = Did::new(did).expect("setup_new_user returned a valid DID");
let user_id = repos let user_id = user_id_of(repos, &did).await;
.user
.get_id_by_did(&did)
.await
.expect("DB error")
.expect("User not found");
let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID"); 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"); 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, jwt) = setup_new_user("gc-after-update").await;
let did = Did::new(did).expect("setup_new_user returned a valid DID"); let did = Did::new(did).expect("setup_new_user returned a valid DID");
let user_id = repos let user_id = user_id_of(repos, &did).await;
.user
.get_id_by_did(&did)
.await
.expect("DB error")
.expect("User not found");
let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID"); let collection = Nsid::new("app.bsky.feed.post".to_string()).expect("valid NSID");
let rkey = let rkey =
+54 -23
View File
@@ -1,9 +1,9 @@
mod common; 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 reqwest::StatusCode;
use serde_json::{Value, json}; use serde_json::{Value, json};
use tranquil_db_traits::CommsType; use tranquil_db_traits::{CommsChannel, CommsType};
use tranquil_types::Did; use tranquil_types::Did;
async fn enable_totp_for_user(did: &str) { 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> { async fn get_2fa_code_from_queue(did: &str) -> Option<String> {
let repos = get_test_repos().await; let repos = get_test_repos().await;
let parsed_did = Did::new(did.to_string()).unwrap(); let user_id = user_id_of(repos, &Did::new(did).unwrap()).await;
let user_id = repos
.user
.get_id_by_did(&parsed_did)
.await
.expect("DB error")
.expect("User not found");
let comms = repos let comms = repos
.infra .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) { async fn clear_2fa_challenges_for_user(did: &str) {
let repos = get_test_repos().await; let repos = get_test_repos().await;
let parsed_did = Did::new(did.to_string()).unwrap(); let user_id = user_id_of(repos, &Did::new(did).unwrap()).await;
let user_id = repos
.user
.get_id_by_did(&parsed_did)
.await
.expect("DB error")
.expect("User not found");
let _ = repos let _ = repos
.infra .infra
@@ -72,13 +60,7 @@ async fn clear_2fa_challenges_for_user(did: &str) {
async fn set_email_auth_factor(did: &str, enabled: bool) { async fn set_email_auth_factor(did: &str, enabled: bool) {
let repos = get_test_repos().await; let repos = get_test_repos().await;
let parsed_did = Did::new(did.to_string()).unwrap(); let user_id = user_id_of(repos, &Did::new(did).unwrap()).await;
let user_id = repos
.user
.get_id_by_did(&parsed_did)
.await
.expect("DB error")
.expect("User not found");
repos repos
.infra .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] #[tokio::test]
async fn test_legacy_2fa_valid_code_succeeds() { async fn test_legacy_2fa_valid_code_succeeds() {
let client = client(); let client = client();
+7 -19
View File
@@ -1,24 +1,18 @@
mod common; mod common;
use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType}; use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
use tranquil_types::Did; use tranquil_types::{Did, Recipient};
#[tokio::test] #[tokio::test]
async fn test_enqueue_comms() { async fn test_enqueue_comms() {
let repos = common::get_test_repos().await; let repos = common::get_test_repos().await;
let (_, did) = common::create_account_and_login(&common::client()).await; let (_, did) = common::create_account_and_login(&common::client()).await;
let user_id = repos let user_id = common::user_id_of(repos, &Did::new(did).unwrap()).await;
.user
.get_id_by_did(&Did::new(did).unwrap())
.await
.expect("DB error")
.expect("User not found");
repos repos
.infra .infra
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
CommsChannel::Email, &Recipient::new(CommsChannel::Email, "test@nel.pet").unwrap(),
CommsType::Welcome, CommsType::Welcome,
"test@example.com",
Some("Test Subject"), Some("Test Subject"),
"Test body", "Test body",
None, None,
@@ -32,7 +26,7 @@ async fn test_enqueue_comms() {
.expect("DB error"); .expect("DB error");
let row = comms.first().expect("Comms not found"); let row = comms.first().expect("Comms not found");
assert_eq!(row.user_id, Some(user_id)); 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.subject.as_deref(), Some("Test Subject"));
assert_eq!(row.body, "Test body"); assert_eq!(row.body, "Test body");
assert_eq!(row.channel, CommsChannel::Email); assert_eq!(row.channel, CommsChannel::Email);
@@ -44,26 +38,20 @@ async fn test_enqueue_comms() {
async fn test_comms_queue_status_index() { async fn test_comms_queue_status_index() {
let repos = common::get_test_repos().await; let repos = common::get_test_repos().await;
let (_, did) = common::create_account_and_login(&common::client()).await; let (_, did) = common::create_account_and_login(&common::client()).await;
let user_id = repos let user_id = common::user_id_of(repos, &Did::new(did).unwrap()).await;
.user
.get_id_by_did(&Did::new(did).unwrap())
.await
.expect("DB error")
.expect("User not found");
let initial_count = repos let initial_count = repos
.infra .infra
.count_comms_by_type(user_id, CommsType::PasswordReset) .count_comms_by_type(user_id, CommsType::PasswordReset)
.await .await
.expect("Failed to count"); .expect("Failed to count");
for i in 0..5 { for i in 0..5 {
let recipient = format!("test{}@example.com", i); let recipient = format!("test{}@jola.dev", i);
repos repos
.infra .infra
.enqueue_comms( .enqueue_comms(
Some(user_id), Some(user_id),
CommsChannel::Email, &Recipient::new(CommsChannel::Email, &recipient).unwrap(),
CommsType::PasswordReset, CommsType::PasswordReset,
&recipient,
Some("Test"), Some("Test"),
"Body", "Body",
None, None,
+87
View File
@@ -1114,3 +1114,90 @@ async fn test_remembered_scope_later_unregistered_never_reaches_a_token() {
refreshed["scope"] refreshed["scope"]
); );
} }
const PROXY_LXM: &str = "io.atcr.getManifest";
const PROXY_SERVICE_ID: &str = "test_service";
async fn setup_mock_proxy_service() -> (MockServer, String) {
let server = MockServer::start().await;
let uri = server.uri();
let host = uri
.strip_prefix("http://")
.expect("mock uri should be http");
let did = format!("did:web:{host}");
Mock::given(method("GET"))
.and(path("/.well-known/did.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": did,
"service": [{
"id": format!("#{PROXY_SERVICE_ID}"),
"type": "TestService",
"serviceEndpoint": uri,
}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/xrpc/{PROXY_LXM}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "manifest": [] })))
.mount(&server)
.await;
(server, did)
}
async fn proxied_call(access_token: &str, service_did: &str) -> reqwest::Response {
client()
.get(format!("{}/xrpc/{}", base_url().await, PROXY_LXM))
.bearer_auth(access_token)
.header("atproto-proxy", format!("{service_did}#{PROXY_SERVICE_ID}"))
.send()
.await
.expect("Proxied request failed")
}
#[tokio::test]
async fn test_rpc_scope_with_service_fragment_allows_proxied_call() {
let (_service, service_did) = setup_mock_proxy_service().await;
let scope = format!("atproto rpc:{PROXY_LXM}?aud={service_did}#{PROXY_SERVICE_ID}");
let (session, _mock) = create_user_and_oauth_session_with_scope(
"rpcfrag",
"https://example.com/rpc-fragment-callback",
&scope,
)
.await;
let res = proxied_call(&session.access_token, &service_did).await;
let status = res.status();
assert_eq!(
status,
StatusCode::OK,
"a scope audienced to did#serviceId must cover a call proxied to that service, got {} {:?}",
status,
res.text().await
);
}
#[tokio::test]
async fn test_rpc_scope_with_bare_did_is_refused_for_proxied_call() {
let (_service, service_did) = setup_mock_proxy_service().await;
let scope = format!("atproto rpc:{PROXY_LXM}?aud={service_did}");
let (session, _mock) = create_user_and_oauth_session_with_scope(
"rpcbare",
"https://example.com/rpc-bare-callback",
&scope,
)
.await;
let res = proxied_call(&session.access_token, &service_did).await;
let status = res.status();
assert_eq!(
status,
StatusCode::FORBIDDEN,
"a bare DID audience must not cover a call proxied to a service on that DID, got {} {:?}",
status,
res.text().await
);
}
@@ -57,12 +57,7 @@ async fn repair_fails_loud_on_missing_leaf_block() {
res.text().await res.text().await
); );
let user_id = repos let user_id = user_id_of(repos, &Did::new(did.clone()).unwrap()).await;
.user
.get_id_by_did(&Did::new(did.clone()).unwrap())
.await
.expect("DB error")
.expect("user not found");
let root_str = repos let root_str = repos
.repo .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 { async fn repo_id_for_did(did: &str) -> uuid::Uuid {
let repos = get_test_repos().await; user_id_of(get_test_repos().await, &Did::new(did).expect("valid DID")).await
let parsed = Did::new(did).expect("valid did");
repos
.user
.get_id_by_did(&parsed)
.await
.expect("lookup user_id")
.expect("user exists")
} }
async fn follow_uris_pointing_to(repo_id: uuid::Uuid, target_did: &str) -> Vec<String> { 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; 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}; use tranquil_pds::image::{ImageError, ImageProcessor};
#[test] #[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] #[test]
fn test_image_file_size_limits() { fn test_image_file_size_limits() {
let processor = ImageProcessor::new(); 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 common::{base_url, client, create_account_and_login, get_test_repos};
use reqwest::StatusCode; use reqwest::StatusCode;
use serde_json::{Value, json}; use serde_json::{Value, json};
use tranquil_db_traits::{CommsChannel, SsoAction, SsoProviderType}; use tranquil_db_traits::{CommsChannel, CommsType, SsoAction, SsoProviderType};
use tranquil_oauth::{ use tranquil_oauth::{
AuthorizationRequestParameters, CodeChallengeMethod, RequestData, ResponseType, AuthorizationRequestParameters, CodeChallengeMethod, RequestData, ResponseType,
}; };
@@ -781,6 +781,7 @@ async fn test_sso_complete_registration_multichannel_discord() {
.json(&json!({ .json(&json!({
"token": token, "token": token,
"handle": handle_prefix, "handle": handle_prefix,
"email": "sso_discord_reg@jola.dev",
"verification_channel": "discord", "verification_channel": "discord",
"discord_username": discord_id "discord_username": discord_id
})) }))
@@ -810,6 +811,14 @@ async fn test_sso_complete_registration_multichannel_discord() {
let user = user.unwrap(); let user = user.unwrap();
assert_eq!(user.channel, CommsChannel::Discord); assert_eq!(user.channel, CommsChannel::Discord);
assert_eq!(user.discord_username.as_deref(), Some(discord_id)); 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] #[tokio::test]
+3 -5
View File
@@ -4,7 +4,7 @@ mod helpers;
use std::sync::Arc; use std::sync::Arc;
use tranquil_db::PostgresRepositories; use tranquil_db::PostgresRepositories;
use tranquil_db_traits::{Backlink, BacklinkPath, CommsChannel, CommsType}; 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; use uuid::Uuid;
async fn create_store_repos() -> Arc<PostgresRepositories> { async fn create_store_repos() -> Arc<PostgresRepositories> {
@@ -1199,9 +1199,8 @@ async fn parity_comms_queue() {
f.pg.infra f.pg.infra
.enqueue_comms( .enqueue_comms(
Some(pg_uid), Some(pg_uid),
CommsChannel::Email, &Recipient::new(CommsChannel::Email, "test@jola.dev").unwrap(),
CommsType::Welcome, CommsType::Welcome,
"test@example.com",
Some("Welcome"), Some("Welcome"),
"Welcome body", "Welcome body",
None, None,
@@ -1214,9 +1213,8 @@ async fn parity_comms_queue() {
.infra .infra
.enqueue_comms( .enqueue_comms(
Some(store_uid), Some(store_uid),
CommsChannel::Email, &Recipient::new(CommsChannel::Email, "test@jola.dev").unwrap(),
CommsType::Welcome, CommsType::Welcome,
"test@example.com",
Some("Welcome"), Some("Welcome"),
"Welcome body", "Welcome body",
None, None,
@@ -109,13 +109,7 @@ async fn assert_record_gone(did: &Did, rkey: &Rkey) {
} }
async fn user_id_for(did: &Did) -> uuid::Uuid { async fn user_id_for(did: &Did) -> uuid::Uuid {
get_test_repos() user_id_of(get_test_repos().await, did).await
.await
.user
.get_id_by_did(did)
.await
.expect("DB error looking up the user id")
.expect("User not found")
} }
#[tokio::test] #[tokio::test]
@@ -1,7 +1,6 @@
use tranquil_lexicon::is_valid_did; use tranquil_lexicon::is_valid_did;
use tranquil_pds::api::validation::{ use tranquil_pds::api::validation::{
HandleValidationError, MAX_DOMAIN_LABEL_LENGTH, MAX_EMAIL_LENGTH, MAX_LOCAL_PART_LENGTH, HandleValidationError, MAX_SERVICE_HANDLE_LOCAL_PART, validate_short_handle,
MAX_SERVICE_HANDLE_LOCAL_PART, is_valid_email, validate_short_handle,
}; };
use tranquil_pds::validation::{validate_collection_nsid, validate_password, validate_record_key}; use tranquil_pds::validation::{validate_collection_nsid, validate_password, validate_record_key};
@@ -261,83 +260,3 @@ fn test_handle_whitespace_handling() {
Err(HandleValidationError::ContainsSpaces) 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] [dependencies]
presage = { workspace = true } presage = { workspace = true }
tranquil-types = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
fjall = { version = "3", optional = true } fjall = { version = "3", optional = true }
+1 -65
View File
@@ -14,71 +14,7 @@ use tokio_util::sync::CancellationToken;
use url::Url; use url::Url;
use crate::store::PgSignalStore; use crate::store::PgSignalStore;
use tranquil_types::SignalUsername;
#[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)
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DeviceName(String); pub struct DeviceName(String);
+3 -2
View File
@@ -10,11 +10,12 @@ mod tests;
mod tests_fjall; mod tests_fjall;
pub use client::{ pub use client::{
DeviceName, InvalidDeviceName, InvalidSignalUsername, LinkGeneration, LinkResult, MessageBody, DeviceName, InvalidDeviceName, LinkGeneration, LinkResult, MessageBody, MessageTooLong,
MessageTooLong, SignalClient, SignalError, SignalSlot, SignalUsername, SignalClient, SignalError, SignalSlot,
}; };
pub use presage; pub use presage;
pub use store::PgSignalStore; pub use store::PgSignalStore;
pub use tranquil_types::{InvalidSignalUsername, SignalUsername};
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait SignalStoreProvider: Send + Sync { pub trait SignalStoreProvider: Send + Sync {
+4 -2
View File
@@ -126,7 +126,9 @@ mod s3 {
.clone() .clone()
.expect("storage.s3_bucket (S3_BUCKET) must be set"); .expect("storage.s3_bucket (S3_BUCKET) must be set");
let client = create_s3_client().await; let client = create_s3_client().await;
let path = cfg.storage.s3_path let path = cfg
.storage
.s3_path
.trim_start_matches("/") .trim_start_matches("/")
.trim_end_matches("/") .trim_end_matches("/")
.to_string(); .to_string();
@@ -139,7 +141,7 @@ mod s3 {
fn resolve_path(&self, key: &str) -> String { fn resolve_path(&self, key: &str) -> String {
if self.path.is_empty() { if self.path.is_empty() {
return key.to_string() return key.to_string();
} }
format!("{}/{}", self.path, key) format!("{}/{}", self.path, key)
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tranquil-store" name = "tranquil-store"
description = "Embedded storage engine for tranquil-pds (experimental)" description = "Embedded storage engine for tranquil-pds"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
+24 -32
View File
@@ -13,16 +13,16 @@ use tranquil_db_traits::{
ImportBlock, ImportRecord, ImportRepoError, InviteCodeError, InviteCodeInfo, InviteCodeRow, ImportBlock, ImportRecord, ImportRepoError, InviteCodeError, InviteCodeInfo, InviteCodeRow,
InviteCodeSortOrder, InviteCodeUse, MigrationReactivationError, MigrationReactivationInput, InviteCodeSortOrder, InviteCodeUse, MigrationReactivationError, MigrationReactivationInput,
NotificationHistoryRow, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, NotificationHistoryRow, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult,
PlcTokenInfo, PruneCount, QueuedComms, ReactivatedAccountInfo, RecoverPasskeyAccountInput, PlcTokenInfo, PruneCount, QueuedComms, ReactivatedAccountInfo, Recipient,
RecoverPasskeyAccountResult, RepoAccountInfo, RepoIdentity, RepoInfo, RepoListItem, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, RepoAccountInfo, RepoIdentity,
RepoWithoutRev, ReservedSigningKey, ReservedSigningKeyFull, ScheduledDeletionAccount, RepoInfo, RepoListItem, RepoWithoutRev, ReservedSigningKey, ReservedSigningKeyFull,
ScopePreference, SequenceNumber, SequencedEvent, StoredBackupCode, StoredPasskey, ScheduledDeletionAccount, ScopePreference, SequenceNumber, SequencedEvent, StoredBackupCode,
TokenFamilyId, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo, UserCommsPrefs, StoredPasskey, TokenFamilyId, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo,
UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, UserForDidDoc, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion,
UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup,
UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash,
UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref,
UserLoginFull, UserLoginInfo, UserNeedingRecordBlobsBackfill, UserPasswordInfo, UserLoginCheck, UserLoginFull, UserLoginInfo, UserNeedingRecordBlobsBackfill, UserPasswordInfo,
UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus,
UserVerificationInfo, UserWithKey, UserWithoutBlocks, ValidatedInviteCode, UserVerificationInfo, UserWithKey, UserWithoutBlocks, ValidatedInviteCode,
WebauthnChallengeType, WebauthnChallengeType,
@@ -1793,9 +1793,8 @@ impl<S: StorageIO + 'static> tranquil_db_traits::InfraRepository for MetastoreCl
async fn enqueue_comms( async fn enqueue_comms(
&self, &self,
user_id: Option<Uuid>, user_id: Option<Uuid>,
channel: CommsChannel, recipient: &Recipient,
comms_type: CommsType, comms_type: CommsType,
recipient: &str,
subject: Option<&str>, subject: Option<&str>,
body: &str, body: &str,
metadata: Option<serde_json::Value>, metadata: Option<serde_json::Value>,
@@ -1804,9 +1803,8 @@ impl<S: StorageIO + 'static> tranquil_db_traits::InfraRepository for MetastoreCl
self.pool self.pool
.send(MetastoreRequest::Infra(InfraRequest::EnqueueComms { .send(MetastoreRequest::Infra(InfraRequest::EnqueueComms {
user_id, user_id,
channel, recipient: recipient.clone(),
comms_type, comms_type,
recipient: recipient.to_owned(),
subject: subject.map(str::to_owned), subject: subject.map(str::to_owned),
body: body.to_owned(), body: body.to_owned(),
metadata, metadata,
@@ -3736,12 +3734,16 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
recv(rx).await 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(); let (tx, rx) = oneshot::channel();
self.pool self.pool
.send(MetastoreRequest::User(UserRequest::AdminUpdateEmail { .send(MetastoreRequest::User(UserRequest::AdminUpdateEmail {
did: did.clone(), did: did.clone(),
email: email.to_owned(), email: email.clone(),
tx, tx,
}))?; }))?;
recv(rx).await recv(rx).await
@@ -3888,14 +3890,14 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
async fn store_telegram_chat_id( async fn store_telegram_chat_id(
&self, &self,
telegram_username: &str, telegram_username: &tranquil_types::TelegramUsername,
chat_id: i64, chat_id: i64,
handle: Option<&Handle>, handle: Option<&Handle>,
) -> Result<Option<Uuid>, DbError> { ) -> Result<Option<Uuid>, DbError> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
self.pool self.pool
.send(MetastoreRequest::User(UserRequest::StoreTelegramChatId { .send(MetastoreRequest::User(UserRequest::StoreTelegramChatId {
telegram_username: telegram_username.to_owned(), telegram_username: telegram_username.clone(),
chat_id, chat_id,
handle: handle.map(|h| h.to_string()), handle: handle.map(|h| h.to_string()),
tx, tx,
@@ -3903,16 +3905,6 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
recv(rx).await 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( async fn set_unverified_discord(
&self, &self,
user_id: Uuid, user_id: Uuid,
@@ -3930,15 +3922,15 @@ impl<S: StorageIO + 'static> tranquil_db_traits::UserRepository for MetastoreCli
async fn store_discord_user_id( async fn store_discord_user_id(
&self, &self,
discord_username: &str, discord_username: &tranquil_types::DiscordUsername,
discord_id: &str, discord_id: &tranquil_types::DiscordUserId,
handle: Option<&Handle>, handle: Option<&Handle>,
) -> Result<Option<Uuid>, DbError> { ) -> Result<Option<Uuid>, DbError> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
self.pool self.pool
.send(MetastoreRequest::User(UserRequest::StoreDiscordUserId { .send(MetastoreRequest::User(UserRequest::StoreDiscordUserId {
discord_username: discord_username.to_owned(), discord_username: discord_username.clone(),
discord_id: discord_id.to_owned(), discord_id: discord_id.clone(),
handle: handle.map(|h| h.to_string()), handle: handle.map(|h| h.to_string()),
tx, tx,
}))?; }))?;
+8 -19
View File
@@ -15,7 +15,7 @@ use tranquil_db_traits::{
InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse,
MigrationReactivationError, MigrationReactivationInput, NotificationHistoryRow, MigrationReactivationError, MigrationReactivationInput, NotificationHistoryRow,
NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, PlcTokenInfo, QueuedComms, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, PlcTokenInfo, QueuedComms,
ReactivatedAccountInfo, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, ReactivatedAccountInfo, Recipient, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult,
RefreshSessionResult, RepoIdentity, ReservedSigningKey, ReservedSigningKeyFull, RefreshSessionResult, RepoIdentity, ReservedSigningKey, ReservedSigningKeyFull,
ScheduledDeletionAccount, ScopePreference, SequenceNumber, SequencedEvent, SessionId, ScheduledDeletionAccount, ScopePreference, SequenceNumber, SequencedEvent, SessionId,
StoredBackupCode, StoredPasskey, TokenFamilyId, TotpRecord, TotpRecordState, User2faStatus, StoredBackupCode, StoredPasskey, TokenFamilyId, TotpRecord, TotpRecordState, User2faStatus,
@@ -1148,7 +1148,7 @@ pub enum UserRequest {
}, },
AdminUpdateEmail { AdminUpdateEmail {
did: Did, did: Did,
email: String, email: tranquil_types::EmailAddress,
tx: Tx<u64>, tx: Tx<u64>,
}, },
AdminUpdateHandle { AdminUpdateHandle {
@@ -1202,23 +1202,19 @@ pub enum UserRequest {
tx: Tx<()>, tx: Tx<()>,
}, },
StoreTelegramChatId { StoreTelegramChatId {
telegram_username: String, telegram_username: tranquil_types::TelegramUsername,
chat_id: i64, chat_id: i64,
handle: Option<String>, handle: Option<String>,
tx: Tx<Option<Uuid>>, tx: Tx<Option<Uuid>>,
}, },
GetTelegramChatId {
user_id: Uuid,
tx: Tx<Option<i64>>,
},
SetUnverifiedDiscord { SetUnverifiedDiscord {
user_id: Uuid, user_id: Uuid,
discord_username: String, discord_username: String,
tx: Tx<()>, tx: Tx<()>,
}, },
StoreDiscordUserId { StoreDiscordUserId {
discord_username: String, discord_username: tranquil_types::DiscordUsername,
discord_id: String, discord_id: tranquil_types::DiscordUserId,
handle: Option<String>, handle: Option<String>,
tx: Tx<Option<Uuid>>, tx: Tx<Option<Uuid>>,
}, },
@@ -1744,7 +1740,6 @@ impl UserRequest {
| Self::ClearSignal { user_id, .. } | Self::ClearSignal { user_id, .. }
| Self::SetUnverifiedSignal { user_id, .. } | Self::SetUnverifiedSignal { user_id, .. }
| Self::SetUnverifiedTelegram { user_id, .. } | Self::SetUnverifiedTelegram { user_id, .. }
| Self::GetTelegramChatId { user_id, .. }
| Self::SetUnverifiedDiscord { user_id, .. } | Self::SetUnverifiedDiscord { user_id, .. }
| Self::VerifyEmailChannel { user_id, .. } | Self::VerifyEmailChannel { user_id, .. }
| Self::VerifyDiscordChannel { user_id, .. } | Self::VerifyDiscordChannel { user_id, .. }
@@ -1810,9 +1805,8 @@ impl UserRequest {
pub enum InfraRequest { pub enum InfraRequest {
EnqueueComms { EnqueueComms {
user_id: Option<Uuid>, user_id: Option<Uuid>,
channel: CommsChannel, recipient: Recipient,
comms_type: CommsType, comms_type: CommsType,
recipient: String,
subject: Option<String>, subject: Option<String>,
body: String, body: String,
metadata: Option<serde_json::Value>, metadata: Option<serde_json::Value>,
@@ -3870,9 +3864,8 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, req: InfraRequest) {
match req { match req {
InfraRequest::EnqueueComms { InfraRequest::EnqueueComms {
user_id, user_id,
channel,
comms_type,
recipient, recipient,
comms_type,
subject, subject,
body, body,
metadata, metadata,
@@ -3883,9 +3876,8 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, req: InfraRequest) {
.infra_ops() .infra_ops()
.enqueue_comms( .enqueue_comms(
user_id, user_id,
channel,
comms_type,
&recipient, &recipient,
comms_type,
subject.as_deref(), subject.as_deref(),
&body, &body,
metadata, metadata,
@@ -5300,9 +5292,6 @@ fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserReque
.map_err(metastore_to_db), .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 { UserRequest::SetUnverifiedDiscord {
user_id, user_id,
discord_username, discord_username,
@@ -22,9 +22,9 @@ use super::user_hash::UserHashMap;
use super::users::UserValue; use super::users::UserValue;
use tranquil_db_traits::{ use tranquil_db_traits::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, AdminAccountInfo, CommsStatus, CommsType, DeletionRequest, DeletionRequestWithToken,
DeletionRequestWithToken, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState,
InviteCodeState, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo, QueuedComms, InviteCodeUse, NotificationHistoryRow, PlcTokenInfo, QueuedComms, Recipient,
ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode, ReservedSigningKey, ReservedSigningKeyFull, ValidatedInviteCode,
}; };
use tranquil_types::{Did, Handle, InviteCode}; use tranquil_types::{Did, Handle, InviteCode};
@@ -145,13 +145,11 @@ impl InfraOps {
}) })
} }
#[allow(clippy::too_many_arguments)]
pub fn enqueue_comms( pub fn enqueue_comms(
&self, &self,
user_id: Option<Uuid>, user_id: Option<Uuid>,
channel: CommsChannel, recipient: &Recipient,
comms_type: CommsType, comms_type: CommsType,
recipient: &str,
subject: Option<&str>, subject: Option<&str>,
body: &str, body: &str,
metadata: Option<serde_json::Value>, metadata: Option<serde_json::Value>,
@@ -162,9 +160,9 @@ impl InfraOps {
let value = QueuedCommsValue { let value = QueuedCommsValue {
id, id,
user_id, user_id,
channel: channel_to_u8(channel), channel: channel_to_u8(recipient.channel()),
comms_type: comms_type_to_u8(comms_type), comms_type: comms_type_to_u8(comms_type),
recipient: recipient.to_owned(), recipient: recipient.as_str().to_owned(),
subject: subject.map(str::to_owned), subject: subject.map(str::to_owned),
body: body.to_owned(), body: body.to_owned(),
metadata: metadata.map(|v| serde_json::to_vec(&v).unwrap_or_default()), metadata: metadata.map(|v| serde_json::to_vec(&v).unwrap_or_default()),
@@ -184,9 +182,9 @@ impl InfraOps {
if let Some(uid) = user_id { if let Some(uid) = user_id {
let history_value = NotificationHistoryValue { let history_value = NotificationHistoryValue {
id, id,
channel: channel_to_u8(channel), channel: channel_to_u8(recipient.channel()),
comms_type: comms_type_to_u8(comms_type), comms_type: comms_type_to_u8(comms_type),
recipient: recipient.to_owned(), recipient: recipient.as_str().to_owned(),
subject: subject.map(str::to_owned), subject: subject.map(str::to_owned),
body: body.to_owned(), body: body.to_owned(),
status: status_to_u8(CommsStatus::Pending), 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 user_hash = self.resolve_hash(did.as_str());
let val = match self.load_user(user_hash)? { let val = match self.load_user(user_hash)? {
Some(v) => v, Some(v) => v,
@@ -796,12 +800,12 @@ impl UserOps {
batch.insert( batch.insert(
&self.users, &self.users,
user_by_email_key(email).as_slice(), user_by_email_key(email.as_str()).as_slice(),
user_hash.raw().to_be_bytes(), user_hash.raw().to_be_bytes(),
); );
let mut updated = val; let mut updated = val;
updated.email = Some(email.to_owned()); updated.email = Some(email.as_str().to_owned());
batch.insert( batch.insert(
&self.users, &self.users,
@@ -1022,11 +1026,11 @@ impl UserOps {
pub fn store_telegram_chat_id( pub fn store_telegram_chat_id(
&self, &self,
telegram_username: &str, telegram_username: &tranquil_types::TelegramUsername,
chat_id: i64, chat_id: i64,
handle: Option<&str>, handle: Option<&str>,
) -> Result<Option<Uuid>, MetastoreError> { ) -> 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 let raw = match self
.users .users
.get(idx_key.as_slice()) .get(idx_key.as_slice())
@@ -1062,11 +1066,6 @@ impl UserOps {
Ok(Some(uid)) 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( pub fn set_unverified_discord(
&self, &self,
user_id: Uuid, user_id: Uuid,
@@ -1105,11 +1104,11 @@ impl UserOps {
pub fn store_discord_user_id( pub fn store_discord_user_id(
&self, &self,
discord_username: &str, discord_username: &tranquil_types::DiscordUsername,
discord_id: &str, discord_id: &tranquil_types::DiscordUserId,
handle: Option<&str>, handle: Option<&str>,
) -> Result<Option<Uuid>, MetastoreError> { ) -> 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 let raw = match self
.users .users
.get(idx_key.as_slice()) .get(idx_key.as_slice())
@@ -1124,7 +1123,7 @@ impl UserOps {
}; };
let user_hash = UserHash::from_did(&val.did); let user_hash = UserHash::from_did(&val.did);
self.mutate_user(user_hash, |u| { 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)); return Ok(Some(val.id));
} }
@@ -1140,7 +1139,7 @@ impl UserOps {
}; };
let uid = val.id; let uid = val.id;
self.mutate_user(user_hash, |u| { 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)) 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 { macro_rules! validated_string_newtype {
( (
$(#[$meta:meta])* $(#[$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> { pub fn from_str_opt(s: &str) -> Option<Self> {
match s { match s {
"email" => Some(CommsChannel::Email), "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 { impl fmt::Display for CommsChannel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str()) write!(f, "{}", self.as_str())
+1 -1
View File
@@ -7,7 +7,7 @@ I will assume from here on out that you know what a [PDS](https://atproto.com/gu
# Tranquil & the world # Tranquil & the world
A PDS is an extremely important aspect of atproto in general, dare I say the bedrock of the whole thing. Storing data reliably and giving it out at the right time is its bread and butter, and if it fails that even once then it breaks its contract with you. A PDS is an extremely important aspect of atproto in general, dare I say the bedrock of the whole thing. Storing data reliably and giving it out at the right time is its bread and butter, and if it fails that even once then it breaks its contract with you.
The reference ("ref") PDS uses SQLite for its storage backend, and Tranquil opted to go with PostgreSQL initially (and we have an experimental embedded-db that we sorta made ourselves). We'd also like to implement a SQLite backend too - to that effect we have made a database trait with both postgres and our embedded db implementing the correct functions! All it takes to add a SQLite backend is implementing those same functions - hell wouldn't it be fun to have some fun storage backends too? The reference ("ref") PDS uses SQLite for its storage backend, and Tranquil opted to go with PostgreSQL initially (and we have an embedded-db that we sorta made ourselves). We'd also like to implement a SQLite backend too - to that effect we have made a database trait with both postgres and our embedded db implementing the correct functions! All it takes to add a SQLite backend is implementing those same functions - hell wouldn't it be fun to have some fun storage backends too?
Each storage backend has its trade-offs - so at the heart of Tranquil we always want to give users choice and put them in the driving seat - why should we choose your storage backend for you if you have a hankering for MongoDB or something? Go ahead and implement the database functions, let's have it. If I sound sarcastic I'm sorry, I'm actually serious. Each storage backend has its trade-offs - so at the heart of Tranquil we always want to give users choice and put them in the driving seat - why should we choose your storage backend for you if you have a hankering for MongoDB or something? Go ahead and implement the database functions, let's have it. If I sound sarcastic I'm sorry, I'm actually serious.
Which database should you choose? At time of writing the answer is most definitely postgres - if you choose our embedded db right now please don't complain if you have data loss, the one thing a PDS is not supposed to have - though if you do choose it we'd love the live user testing by fire. Which database should you choose? At time of writing the answer is most definitely postgres - if you choose our embedded db right now please don't complain if you have data loss, the one thing a PDS is not supposed to have - though if you do choose it we'd love the live user testing by fire.
+87
View File
@@ -0,0 +1,87 @@
# Tranquil PDS production deployment on Alpine Linux
This guide covers installing Tranquil PDS on Alpine Linux with OpenRC via the upstream package.
## Prerequisites
- A server :p
- Disk space for blobs, around* 1GB per active user as a baseline
- A domain name pointing to your server's IP
- A **wildcard TLS certificate** for `*.pds.example.com`, since user handles are served as subdomains
> 🦪 Lewis**
>
> * "around" here meaning "at absolute least!"
>
> > 🍂 June
> >
> > ** hi Lewis !
## Installation
First, ensure you have the [testing repository](https://wiki.alpinelinux.org/wiki/Repositories#Using_testing_repository) enabled in your `/etc/apk/repositories`, with:
```
@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing
```
> 🍂 June
>
> There's a MR open to move the package to the community repository ([!108614](https://gitlab.alpinelinux.org/alpine/aports/-/merge_requests/108614))
Install the upstream package with:
```sh
apk add tranquil-pds@testing
```
This, by default, will:
- Install the server binary, as well as the frontend, default config, and OpenRC service files;
- Create and configure the `tranquil-pds` system user.
## Database Configuration
PostgreSQL is not a direct package dependency, so it needs to be installed and configured separately.
Install and start it with:
```sh
apk add postgresql
grep 'port=' /etc/conf.d/postgresql # check/configure the port
/etc/init.d/postgresql start
```
Create the PDS role and database (as the `postgres` user) with:
```sh
psql \
-c 'CREATE ROLE "tranquil-pds" WITH LOGIN;' \
-c 'CREATE DATABASE "tranquil-pds" OWNER "tranquil-pds";'
```
Set the following options in your config file (`/etc/tranquil-pds/config.toml`):
```toml
[server]
hostname = "pds.example.com"
[database]
url = "postgresql:///tranquil-pds?host=/run/postgresql"
[storage]
repo_backend = "postgres"
# as well as other required options in [secrets]
```
## Server Configuration
Optionally review the service options in `/etc/conf.d/tranquil-pds`.
Start the service with:
```sh
/etc/init.d/tranquil-pds start
```
The logs will be saved to `/var/log/tranquil-pds.log`.
## Reverse Proxy Configuration
You will probably also want to run a reverse proxy like [Nginx](https://wiki.alpinelinux.org/wiki/Nginx) or [Caddy](https://wiki.alpinelinux.org/wiki/Caddy).
Refer to their respective Alpine Wiki pages for instructions.
An example Nginx configuration for Tranquil can be found in [nginx.conf](https://tangled.org/tranquil.farm/tranquil-pds/blob/main/nginx.conf).
+17 -16
View File
@@ -3,53 +3,54 @@
Welcome, brave one. Welcome, brave one.
So you're interested in leaving relational databases behind? Raw performance? Or... perhaps you simply want to run less services on your machine? So you're interested in leaving relational databases behind? Raw performance? Or... perhaps you simply want to run less services on your machine?
Tranquil's embedded DB is experimental. Tranquil's embedded DB is not necessarily experimental now that Mr Oyster has been running his just fine for about half a year, but still, just a *slight* risk of total data loss. Back up your data and we'd be able to nurse borked data back to health with some spit and polish.
Risk of total data loss.
## What's the difference? ## What's the difference?
tranquil-store replaces the entire repository layer. When it is selected the server opens no postgres connection at all. The postgres service, its password secret, and the `database.url` value are all unused. Blob storage is however untouched: filesystem or S3 applies exactly as in the base guide. `tranquil-store` is an alternative repository storage layer. When it's selected in the config, the server won't use postgres at all. However, blob storage won't be affected by this db-level choice, in that filesystem vs S3 will continue as a separate concern exactly as in the base guide.
2 settings select and place the store: Here are our settings that set up the store:
- `repo_backend` under `[storage]`, environment variable `REPO_BACKEND`. Set it to `"tranquil-store"`. The default is `"postgres"`. - `repo_backend` under `[storage]`, environment variable `REPO_BACKEND`. Set it to `"tranquil-store"`. (Default being `"postgres"`.)
- `data_dir` under `[tranquil_store]`, environment variable `TRANQUIL_STORE_DATA_DIR`. This is optional. It defaults to `/var/lib/tranquil-pds/store`. - `data_dir` under `[tranquil_store]`, environment variable `TRANQUIL_STORE_DATA_DIR`. Optional if the above is picked! It defaults to `/var/lib/tranquil-pds/store`.
So the minimum config delta is one line: Thus the minimum config is as follows:
```toml ```toml
[storage] [storage]
repo_backend = "tranquil-store" repo_backend = "tranquil-store"
``` ```
and voila!
## That being said, here are the facts: ## That being said, here are the facts:
- At time of writing, there's no way to transfer an existing Tranquil instance from PG-backed to embedded or vice-versa. If you have an instance and you want to move to embedded, you'll have to spin it up as a new instance and migrate as you would normally. - At time of writing, there's no way to transfer an existing Tranquil instance from PG-backed to embedded or vice-versa. If you have an instance and you want to move to embedded, you'll have to spin it up as a new instance and migrate as you would normally.
- You will absolutely want to take backups of all users' CAR files daily of not more frequently. As usual, you *really* should have rotation keys separately stored aside somewhere in case the DB explodes in an unrecoverable way. - You will absolutely want to take backups of all users' CAR files daily of not more frequently. As usual, you *really* should have rotation keys separately stored aside somewhere in case the DB explodes in an unrecoverable way.
## Installing: a patch on the existing guides ## Installing: slight differences to existing guides
The procedure is the one in [2_INSTALL_CONTAINERS.md](2_INSTALL_CONTAINERS.md) or [2_INSTALL_NIX.md](2_INSTALL_NIX.md). Follow your chosen guide top to bottom and apply the deltas below, otherwise exactly the same! Choose-your-own-adventure based on your poison as per [2_INSTALL_CONTAINERS.md](2_INSTALL_CONTAINERS.md), [2_INSTALL_NIX.md](2_INSTALL_NIX.md), or [2_INSTALL_ALPINE.md](2_INSTALL_ALPINE.md). Follow your chosen guide top-to-bottom and apply the below changes, otherwise exactly the same!
### Containers ### Containers
Both base guides assume postgres of course, and the units couple the app to it. Dropping the database means uncoupling that out too. Both base guides assume postgres of course, and the units tie the app to pg running. Therefore, dropping the database means un-coupling those too.
Shared, regardless of init system: Shared, regardless of init system:
1. In `config.toml`, leave `database.url` unset and add the `[storage]` block shown above. 1. In `config.toml`, leave `database.url` unset and add the `[storage]` block shown above.
2. Skip the database secret. No need to create `tranquil-pds-db-password`. 2. Skip the database secret! No need to create `tranquil-pds-db-password`.
3. The app unit already mounts the `store` directory, so `data_dir` needs no extra setup. The `postgres` directory in the guide's `mkdir` goes unused. 3. The app unit already mounts the `store` directory, so `data_dir` doesn't need any extra setup. The `postgres` dir in the guide's `mkdir` won't be used.
4. Backup section: `pg_dump` does not apply. Back up the `data_dir` instead, which holds the metastore, eventlog, and blockstore. CAR files and rotation keys still belong in your own backup as mentioned above. 4. Backup section: `pg_dump` doesn't apply. Back up the `data_dir` instead, which contains the metastore, eventlog, and blockstore. CAR files and rotation keys still belong in your own separate high-level backup as mentioned above.
**Debian (quadlets):** Do not copy `tranquil-pds-db.container`. Drop `tranquil-pds-db` from the `systemctl start` and `systemctl enable` commands. The `After=tranquil-pds-db.service` line in `tranquil-pds-app.container` becomes a no-op with the database gone. Remove it if you want. **Debian (quadlets):** Don't bother copying `tranquil-pds-db.container`. Drop `tranquil-pds-db` from the `systemctl start` and `systemctl enable` commands. The `After=tranquil-pds-db.service` line in `tranquil-pds-app.container` will be a no-op with the database gone. Remove it if you want!
**Alpine (OpenRC):** Do not copy the `tranquil-pds-db` init script. The app script hard-depends on it via `need tranquil-pds-db`, so edit `tranquil-pds-app`'s `depend()` to read `need tranquil-pds-pod` instead. Without this the app needs a service that no longer exists and refuses to start. Drop `tranquil-pds-db` from the `rc-update add` command too. **Alpine (OpenRC):** Don't bother copying the `tranquil-pds-db` init script. The app script hard-depends on it via `need tranquil-pds-db`, so edit `tranquil-pds-app`'s `depend()` to grab `need tranquil-pds-pod` instead. Without this, the app will need a service that doesn't actually exist anymore, and would abort at start. Leave out `tranquil-pds-db` from the `rc-update add` command too.
### Nix ### Nix
1. Set `services.tranquil-pds.settings.storage.repo_backend = "tranquil-store";`. 1. Set `services.tranquil-pds.settings.storage.repo_backend = "tranquil-store";`.
2. Make sure all the postgres related options are left as their default. Otherwise you might end up with a useless running instance of postgres. 2. Make sure all the postgres related options are left as their default. Otherwise you might end up with an unnecessarily-running instance of postgres.
That's it!! That's it!!
+1 -1
View File
@@ -272,7 +272,7 @@
#s3_path = "" #s3_path = ""
# Repository backend: `postgres` by default, or `tranquil-store`, our embedded db. # Repository backend: `postgres` by default, or `tranquil-store`, our embedded db.
# tranquil-store is EXPERIMENTAL!!!! RISK OF TOTAL DATA LOSS. # `tranquil-store` is our own solution, not a tried-and-tested out-of-box database. If you're not feeling brave, don't choose this.
# #
# Can also be specified via environment variable `REPO_BACKEND`. # Can also be specified via environment variable `REPO_BACKEND`.
# #