fix: improved discord & signal comms

This commit is contained in:
lewis
2026-02-05 16:13:45 +00:00
committed by Tangled
parent b104dc938a
commit cd7400bc8b
71 changed files with 1596 additions and 514 deletions
+1 -1
View File
@@ -8,6 +8,6 @@ pub use locale::{
};
pub use sender::{
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
is_valid_phone_number, mime_encode_header, sanitize_header_value,
is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value,
};
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+14
View File
@@ -33,6 +33,8 @@ pub struct NotificationStrings {
pub migration_verification_body: &'static str,
pub channel_verified_subject: &'static str,
pub channel_verified_body: &'static str,
pub channel_verification_subject: &'static str,
pub channel_verification_body: &'static str,
}
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
@@ -70,6 +72,8 @@ static STRINGS_EN: NotificationStrings = NotificationStrings {
migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not migrate your account, please ignore this email.",
channel_verified_subject: "Channel verified - {hostname}",
channel_verified_body: "Hello {handle},\n\n{channel} has been verified as a notification channel for your account on {hostname}.",
channel_verification_subject: "Verify your channel - {hostname}",
channel_verification_body: "Your verification code is:\n{code}\n\nOr verify directly:\n{verify_link}",
};
static STRINGS_ZH: NotificationStrings = NotificationStrings {
@@ -96,6 +100,8 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings {
migration_verification_body: "欢迎来到 {hostname}\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n或者直接点击链接:\n{verify_link}\n\n如果您没有迁移账户,请忽略此邮件。",
channel_verified_subject: "通知渠道已验证 - {hostname}",
channel_verified_body: "您好 {handle}\n\n{channel} 已被验证为您在 {hostname} 上的通知渠道。",
channel_verification_subject: "验证您的渠道 - {hostname}",
channel_verification_body: "您的验证码是:\n{code}\n\n或直接验证:\n{verify_link}",
};
static STRINGS_JA: NotificationStrings = NotificationStrings {
@@ -122,6 +128,8 @@ static STRINGS_JA: NotificationStrings = NotificationStrings {
migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{verify_link}\n\nアカウントを移行していない場合は、このメールを無視してください。",
channel_verified_subject: "通知チャンネル認証完了 - {hostname}",
channel_verified_body: "{handle} 様\n\n{channel} が {hostname} の通知チャンネルとして認証されました。",
channel_verification_subject: "チャンネルを認証 - {hostname}",
channel_verification_body: "認証コードは:\n{code}\n\n直接認証:\n{verify_link}",
};
static STRINGS_KO: NotificationStrings = NotificationStrings {
@@ -148,6 +156,8 @@ static STRINGS_KO: NotificationStrings = NotificationStrings {
migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{verify_link}\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.",
channel_verified_subject: "알림 채널 인증 완료 - {hostname}",
channel_verified_body: "안녕하세요 {handle}님,\n\n{channel}이(가) {hostname}의 알림 채널로 인증되었습니다.",
channel_verification_subject: "채널 인증 - {hostname}",
channel_verification_body: "인증 코드:\n{code}\n\n직접 인증:\n{verify_link}",
};
static STRINGS_SV: NotificationStrings = NotificationStrings {
@@ -174,6 +184,8 @@ static STRINGS_SV: NotificationStrings = NotificationStrings {
migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.",
channel_verified_subject: "Aviseringskanal verifierad - {hostname}",
channel_verified_body: "Hej {handle},\n\n{channel} har verifierats som aviseringskanal för ditt konto på {hostname}.",
channel_verification_subject: "Verifiera din kanal - {hostname}",
channel_verification_body: "Din verifieringskod är:\n{code}\n\nEller verifiera direkt:\n{verify_link}",
};
static STRINGS_FI: NotificationStrings = NotificationStrings {
@@ -200,6 +212,8 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nTai jos pidät vaarallisesta elämästä:\n{verify_link}\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.",
channel_verified_subject: "Ilmoituskanava vahvistettu - {hostname}",
channel_verified_body: "Hei {handle},\n\n{channel} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {hostname}.",
channel_verification_subject: "Vahvista kanavasi - {hostname}",
channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}",
};
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+284 -30
View File
@@ -6,6 +6,7 @@ use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use super::types::{CommsChannel, QueuedComms};
@@ -85,6 +86,25 @@ pub fn is_valid_phone_number(number: &str) -> bool {
!remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit())
}
pub fn is_valid_signal_username(username: &str) -> bool {
if username.len() < 6 || username.len() > 35 {
return false;
}
let Some((base, discriminator)) = username.rsplit_once('.') else {
return false;
};
if base.len() < 3 || base.len() > 32 {
return false;
}
if !base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return false;
}
if !base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
return false;
}
discriminator.len() == 2 && discriminator.chars().all(|c| c.is_ascii_digit())
}
pub struct EmailSender {
from_address: String,
from_name: String,
@@ -154,22 +174,208 @@ impl CommsSender for EmailSender {
}
}
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
#[derive(Clone)]
pub struct DiscordSender {
webhook_url: String,
bot_token: String,
http_client: Client,
}
impl DiscordSender {
pub fn new(webhook_url: String) -> Self {
pub fn new(bot_token: String) -> Self {
Self {
webhook_url,
bot_token,
http_client: create_http_client(),
}
}
pub fn from_env() -> Option<Self> {
let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").ok()?;
Some(Self::new(webhook_url))
let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?;
Some(Self::new(bot_token))
}
fn auth_header(&self) -> String {
format!("Bot {}", self.bot_token)
}
pub async fn resolve_application_info(&self) -> Result<(String, String), SendError> {
let url = format!("{}/applications/@me", DISCORD_API_BASE);
let response = self
.http_client
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!(
"Discord application info request failed: {}",
e
))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord application info returned error: {}",
body
)));
}
let data: serde_json::Value = response.json().await.map_err(|e| {
SendError::ExternalService(format!("Failed to parse Discord application info: {}", e))
})?;
let app_id = data
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| SendError::ExternalService("Application info missing id".to_string()))?;
let verify_key = data
.get("verify_key")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
SendError::ExternalService("Application info missing verify_key".to_string())
})?;
Ok((app_id, verify_key))
}
pub async fn register_slash_command(&self, app_id: &str) -> Result<(), SendError> {
let url = format!("{}/applications/{}/commands", DISCORD_API_BASE, app_id);
let payload = serde_json::json!({
"name": "start",
"description": "Verify your PDS account",
"type": 1,
"options": [{
"name": "handle",
"description": "Your PDS handle (e.g. alice.example.com)",
"type": 3,
"required": false
}]
});
let response = self
.http_client
.post(&url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!("Register command request failed: {}", e))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Register command returned error: {}",
body
)));
}
Ok(())
}
pub async fn set_interactions_endpoint(
&self,
app_id: &str,
url: &str,
) -> Result<(), SendError> {
let patch_url = format!("{}/applications/{}", DISCORD_API_BASE, app_id);
let payload = serde_json::json!({
"interactions_endpoint_url": url
});
let response = self
.http_client
.patch(&patch_url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!("Set interactions endpoint failed: {}", e))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Set interactions endpoint returned error: {}",
body
)));
}
Ok(())
}
pub async fn resolve_bot_username(&self) -> Result<String, SendError> {
let url = format!("{}/users/@me", DISCORD_API_BASE);
let response = self
.http_client
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!("Discord getMe request failed: {}", e))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord getMe returned error: {}",
body
)));
}
let data: serde_json::Value = response.json().await.map_err(|e| {
SendError::ExternalService(format!("Failed to parse Discord getMe response: {}", e))
})?;
data.get("username")
.and_then(|u| u.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
SendError::ExternalService("Discord getMe response missing username".to_string())
})
}
async fn open_dm_channel(&self, user_id: &str) -> Result<String, SendError> {
let url = format!("{}/users/@me/channels", DISCORD_API_BASE);
let payload = json!({ "recipient_id": user_id });
let response = self
.http_client
.post(&url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await
.map_err(|e| {
SendError::ExternalService(format!("Discord DM channel request failed: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord DM channel creation returned {}: {}",
status, body
)));
}
let data: serde_json::Value = response.json().await.map_err(|e| {
SendError::ExternalService(format!(
"Failed to parse Discord DM channel response: {}",
e
))
})?;
data.get("id")
.and_then(|id| id.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
SendError::ExternalService("Discord DM channel response missing id".to_string())
})
}
}
@@ -180,17 +386,19 @@ impl CommsSender for DiscordSender {
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let channel_id = self.open_dm_channel(&notification.recipient).await?;
let subject = notification.subject.as_deref().unwrap_or("Notification");
let content = format!("**{}**\n\n{}", subject, notification.body);
let payload = json!({
"content": content,
"username": "Tranquil PDS"
});
let payload = json!({ "content": content });
let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id);
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let result = self
.http_client
.post(&self.webhook_url)
.post(&url)
.header("Authorization", self.auth_header())
.json(&payload)
.send()
.await;
@@ -201,13 +409,13 @@ impl CommsSender for DiscordSender {
}
let status = response.status();
if is_retryable_status(status) && attempt < MAX_RETRIES - 1 {
last_error = Some(format!("Discord webhook returned {}", status));
last_error = Some(format!("Discord API returned {}", status));
retry_delay(attempt).await;
continue;
}
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Discord webhook returned {}: {}",
"Discord API returned {}: {}",
status, body
)));
}
@@ -386,6 +594,19 @@ impl SignalSender {
}
}
const SIGNAL_TIMEOUT_SECS: u64 = 30;
fn is_retryable_signal_error(stderr: &str) -> bool {
let lower = stderr.to_lowercase();
lower.contains("timeout")
|| lower.contains("timed out")
|| lower.contains("connection refused")
|| lower.contains("network")
|| lower.contains("temporarily")
|| lower.contains("try again")
|| lower.contains("rate limit")
}
#[async_trait]
impl CommsSender for SignalSender {
fn channel(&self) -> CommsChannel {
@@ -394,30 +615,63 @@ impl CommsSender for SignalSender {
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let recipient = &notification.recipient;
if !is_valid_phone_number(recipient) {
if !is_valid_signal_username(recipient) {
return Err(SendError::InvalidRecipient(format!(
"Invalid phone number format: {}",
"Invalid Signal username format: {}",
recipient
)));
}
let subject = notification.subject.as_deref().unwrap_or("Notification");
let message = format!("{}\n\n{}", subject, notification.body);
let output = Command::new(&self.signal_cli_path)
.arg("-u")
.arg(&self.sender_number)
.arg("send")
.arg("-m")
.arg(&message)
.arg(recipient)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SendError::ExternalService(format!(
"signal-cli failed: {}",
stderr
)));
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let cmd_future = Command::new(&self.signal_cli_path)
.arg("-u")
.arg(&self.sender_number)
.arg("send")
.arg("--username")
.arg(recipient)
.arg("-m")
.arg(&message)
.output();
let result = timeout(Duration::from_secs(SIGNAL_TIMEOUT_SECS), cmd_future).await;
match result {
Ok(Ok(output)) if output.status.success() => return Ok(()),
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if is_retryable_signal_error(&stderr) && attempt < MAX_RETRIES - 1 {
last_error = Some(format!("signal-cli failed: {}", stderr));
retry_delay(attempt).await;
continue;
}
return Err(SendError::ExternalService(format!(
"signal-cli failed: {}",
stderr
)));
}
Ok(Err(e)) => {
if attempt < MAX_RETRIES - 1 {
last_error = Some(format!("signal-cli spawn failed: {}", e));
retry_delay(attempt).await;
continue;
}
return Err(SendError::ProcessSpawn(e));
}
Err(_) => {
if attempt < MAX_RETRIES - 1 {
last_error = Some("signal-cli timed out".to_string());
retry_delay(attempt).await;
continue;
}
return Err(SendError::Timeout);
}
}
}
Ok(())
Err(SendError::MaxRetriesExceeded(
last_error.unwrap_or_else(|| "Unknown error".to_string()),
))
}
}
+6
View File
@@ -20,6 +20,12 @@ pub enum DbError {
#[error("Serialization error: {0}")]
Serialization(String),
#[error("Ambiguous: {0}")]
Ambiguous(String),
#[error("Resource busy, try again")]
LockContention,
#[error("Other database error: {0}")]
Other(String),
}
+1
View File
@@ -488,6 +488,7 @@ pub trait RepoRepository: Send + Sync {
user_id: Uuid,
blocks: &[ImportBlock],
records: &[ImportRecord],
expected_root_cid: Option<&CidLink>,
) -> Result<(), ImportRepoError>;
async fn apply_commit(
+33 -13
View File
@@ -228,6 +228,12 @@ pub trait UserRepository: Send + Sync {
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>;
async fn set_unverified_signal(
&self,
user_id: Uuid,
signal_username: &str,
) -> Result<(), DbError>;
async fn set_unverified_telegram(
&self,
user_id: Uuid,
@@ -243,6 +249,19 @@ pub trait UserRepository: Send + Sync {
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
async fn set_unverified_discord(
&self,
user_id: Uuid,
discord_username: &str,
) -> Result<(), DbError>;
async fn store_discord_user_id(
&self,
discord_username: &str,
discord_id: &str,
handle: Option<&str>,
) -> Result<Option<Uuid>, DbError>;
async fn get_verification_info(
&self,
did: &Did,
@@ -261,7 +280,7 @@ pub trait UserRepository: Send + Sync {
async fn verify_signal_channel(
&self,
user_id: Uuid,
signal_number: &str,
signal_username: &str,
) -> Result<(), DbError>;
async fn set_email_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>;
@@ -598,7 +617,7 @@ pub struct UserCommsPrefs {
pub preferred_locale: Option<String>,
pub telegram_chat_id: Option<i64>,
pub discord_id: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
}
#[derive(Debug, Clone)]
@@ -656,11 +675,12 @@ pub struct NotificationPrefs {
pub email: String,
pub preferred_channel: CommsChannel,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub discord_verified: bool,
pub telegram_username: Option<String>,
pub telegram_verified: bool,
pub telegram_chat_id: Option<i64>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
pub signal_verified: bool,
}
@@ -829,9 +849,9 @@ pub struct UserConfirmSignup {
pub handle: Handle,
pub email: Option<String>,
pub channel: CommsChannel,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
pub key_bytes: Vec<u8>,
pub encryption_version: Option<i32>,
}
@@ -842,9 +862,9 @@ pub struct UserResendVerification {
pub handle: Handle,
pub email: Option<String>,
pub channel: CommsChannel,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
pub channel_verification: ChannelVerificationStatus,
}
@@ -932,9 +952,9 @@ pub struct CreatePasswordAccountInput {
pub did: Did,
pub password_hash: String,
pub preferred_comms_channel: CommsChannel,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
pub deactivated_at: Option<DateTime<Utc>>,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
@@ -982,9 +1002,9 @@ pub struct CreatePasskeyAccountInput {
pub email: String,
pub did: Did,
pub preferred_comms_channel: CommsChannel,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
pub setup_token_hash: String,
pub setup_expires_at: DateTime<Utc>,
pub deactivated_at: Option<DateTime<Utc>>,
@@ -1004,9 +1024,9 @@ pub struct CreateSsoAccountInput {
pub email: Option<String>,
pub did: Did,
pub preferred_comms_channel: CommsChannel,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub commit_cid: String,
+10 -2
View File
@@ -1131,6 +1131,7 @@ impl RepoRepository for PostgresRepoRepository {
user_id: Uuid,
blocks: &[ImportBlock],
records: &[ImportRecord],
expected_root_cid: Option<&CidLink>,
) -> Result<(), ImportRepoError> {
let mut tx = self
.pool
@@ -1153,8 +1154,15 @@ impl RepoRepository for PostgresRepoRepository {
ImportRepoError::Database(e.to_string())
})?;
if repo.is_none() {
return Err(ImportRepoError::RepoNotFound);
let repo = match repo {
Some(r) => r,
None => return Err(ImportRepoError::RepoNotFound),
};
if let Some(expected) = expected_root_cid {
if repo.repo_root_cid.as_str() != expected.as_str() {
return Err(ImportRepoError::ConcurrentModification);
}
}
let block_chunks: Vec<Vec<&ImportBlock>> = blocks
+131 -26
View File
@@ -311,7 +311,7 @@ impl UserRepository for PostgresUserRepository {
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
let row = sqlx::query!(
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_username
FROM users WHERE id = $1"#,
user_id
)
@@ -325,7 +325,7 @@ impl UserRepository for PostgresUserRepository {
preferred_locale: r.preferred_locale,
telegram_chat_id: r.telegram_chat_id,
discord_id: r.discord_id,
signal_number: r.signal_number,
signal_username: r.signal_username,
}))
}
@@ -636,11 +636,12 @@ impl UserRepository for PostgresUserRepository {
email,
preferred_comms_channel as "preferred_channel!: CommsChannel",
discord_id,
discord_username,
discord_verified,
telegram_username,
telegram_verified,
telegram_chat_id,
signal_number,
signal_username,
signal_verified
FROM users WHERE did = $1"#,
did.as_str()
@@ -652,11 +653,12 @@ impl UserRepository for PostgresUserRepository {
email: r.email.unwrap_or_default(),
preferred_channel: r.preferred_channel,
discord_id: r.discord_id,
discord_username: r.discord_username,
discord_verified: r.discord_verified,
telegram_username: r.telegram_username,
telegram_verified: r.telegram_verified,
telegram_chat_id: r.telegram_chat_id,
signal_number: r.signal_number,
signal_username: r.signal_username,
signal_verified: r.signal_verified,
}))
}
@@ -697,7 +699,7 @@ impl UserRepository for PostgresUserRepository {
async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError> {
sqlx::query!(
"UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
"UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
user_id
)
.execute(&self.pool)
@@ -719,7 +721,7 @@ impl UserRepository for PostgresUserRepository {
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError> {
sqlx::query!(
"UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
"UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
user_id
)
.execute(&self.pool)
@@ -796,11 +798,11 @@ impl UserRepository for PostgresUserRepository {
async fn verify_signal_channel(
&self,
user_id: Uuid,
signal_number: &str,
signal_username: &str,
) -> Result<(), DbError> {
sqlx::query!(
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
signal_number,
"UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
signal_username,
user_id
)
.execute(&self.pool)
@@ -1548,7 +1550,7 @@ impl UserRepository for PostgresUserRepository {
r#"SELECT
u.id, u.did, u.handle, u.email,
u.preferred_comms_channel as "channel: CommsChannel",
u.discord_id, u.telegram_username, u.signal_number,
u.discord_username, u.telegram_username, u.signal_username,
k.key_bytes, k.encryption_version
FROM users u
JOIN user_keys k ON u.id = k.user_id
@@ -1565,9 +1567,9 @@ impl UserRepository for PostgresUserRepository {
handle: Handle::from(row.handle),
email: row.email,
channel: row.channel,
discord_id: row.discord_id,
discord_username: row.discord_username,
telegram_username: row.telegram_username,
signal_number: row.signal_number,
signal_username: row.signal_username,
key_bytes: row.key_bytes,
encryption_version: row.encryption_version,
})
@@ -1582,7 +1584,7 @@ impl UserRepository for PostgresUserRepository {
r#"SELECT
id, handle, email,
preferred_comms_channel as "channel: CommsChannel",
discord_id, telegram_username, signal_number,
discord_username, telegram_username, signal_username,
email_verified, discord_verified, telegram_verified, signal_verified
FROM users
WHERE did = $1"#,
@@ -1597,9 +1599,9 @@ impl UserRepository for PostgresUserRepository {
handle: Handle::from(row.handle),
email: row.email,
channel: row.channel,
discord_id: row.discord_id,
discord_username: row.discord_username,
telegram_username: row.telegram_username,
signal_number: row.signal_number,
signal_username: row.signal_username,
channel_verification: ChannelVerificationStatus::new(
row.email_verified,
row.discord_verified,
@@ -1662,13 +1664,13 @@ impl UserRepository for PostgresUserRepository {
"SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Discord => {
"SELECT COUNT(*) FROM users WHERE discord_id = $1 AND deactivated_at IS NULL"
"SELECT COUNT(*) FROM users WHERE LOWER(discord_username) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Telegram => {
"SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL"
}
CommsChannel::Signal => {
"SELECT COUNT(*) FROM users WHERE signal_number = $1 AND deactivated_at IS NULL"
"SELECT COUNT(*) FROM users WHERE signal_username = $1 AND deactivated_at IS NULL"
}
};
sqlx::query_scalar(query)
@@ -2385,7 +2387,7 @@ impl UserRepository for PostgresUserRepository {
r#"INSERT INTO users (
handle, email, did, password_hash,
preferred_comms_channel,
discord_id, telegram_username, signal_number,
discord_username, telegram_username, signal_username,
is_admin, deactivated_at, email_verified
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#,
)
@@ -2394,9 +2396,9 @@ impl UserRepository for PostgresUserRepository {
.bind(input.did.as_str())
.bind(&input.password_hash)
.bind(input.preferred_comms_channel)
.bind(&input.discord_id)
.bind(&input.discord_username)
.bind(&input.telegram_username)
.bind(&input.signal_number)
.bind(&input.signal_username)
.bind(is_first_user)
.bind(input.deactivated_at)
.fetch_one(&mut *tx)
@@ -2652,7 +2654,7 @@ impl UserRepository for PostgresUserRepository {
r#"INSERT INTO users (
handle, email, did, password_hash, password_required,
preferred_comms_channel,
discord_id, telegram_username, signal_number,
discord_username, telegram_username, signal_username,
recovery_token, recovery_token_expires_at,
is_admin, deactivated_at
) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#,
@@ -2661,9 +2663,9 @@ impl UserRepository for PostgresUserRepository {
.bind(&input.email)
.bind(input.did.as_str())
.bind(input.preferred_comms_channel)
.bind(&input.discord_id)
.bind(&input.discord_username)
.bind(&input.telegram_username)
.bind(&input.signal_number)
.bind(&input.signal_username)
.bind(&input.setup_token_hash)
.bind(input.setup_expires_at)
.bind(is_first_user)
@@ -2816,7 +2818,7 @@ impl UserRepository for PostgresUserRepository {
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
r#"INSERT INTO users (
handle, email, did, password_hash, password_required,
preferred_comms_channel, discord_id, telegram_username, signal_number,
preferred_comms_channel, discord_username, telegram_username, signal_username,
is_admin
) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#,
)
@@ -2824,9 +2826,9 @@ impl UserRepository for PostgresUserRepository {
.bind(&input.email)
.bind(input.did.as_str())
.bind(input.preferred_comms_channel)
.bind(&input.discord_id)
.bind(&input.discord_username)
.bind(&input.telegram_username)
.bind(&input.signal_number)
.bind(&input.signal_username)
.bind(is_first_user)
.fetch_one(&mut *tx)
.await;
@@ -3190,6 +3192,109 @@ impl UserRepository for PostgresUserRepository {
Ok(())
}
async fn set_unverified_signal(
&self,
user_id: Uuid,
signal_username: &str,
) -> Result<(), DbError> {
sqlx::query!(
r#"UPDATE users SET
signal_username = $1,
signal_verified = CASE WHEN LOWER(signal_username) = LOWER($1) THEN signal_verified ELSE FALSE END,
updated_at = NOW()
WHERE id = $2"#,
signal_username,
user_id
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn set_unverified_discord(
&self,
user_id: Uuid,
discord_username: &str,
) -> Result<(), DbError> {
sqlx::query!(
r#"UPDATE users SET
discord_username = $1,
discord_verified = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_verified ELSE FALSE END,
discord_id = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_id ELSE NULL END,
updated_at = NOW()
WHERE id = $2"#,
discord_username,
user_id
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn store_discord_user_id(
&self,
discord_username: &str,
discord_id: &str,
handle: Option<&str>,
) -> Result<Option<Uuid>, DbError> {
let result = match handle {
Some(h) => sqlx::query_scalar!(
"UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND handle = $3 RETURNING id",
discord_username,
discord_id,
h
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?,
None => {
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
let matching: Vec<uuid::Uuid> = match sqlx::query_scalar!(
"SELECT id FROM users WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND deactivated_at IS NULL FOR UPDATE NOWAIT",
discord_username
)
.fetch_all(&mut *tx)
.await
{
Ok(ids) => ids,
Err(sqlx::Error::Database(ref db_err))
if db_err.code().as_deref() == Some("55P03") =>
{
return Err(DbError::LockContention);
}
Err(e) => return Err(map_sqlx_error(e)),
};
let result = match matching.len() {
0 => None,
1 => {
sqlx::query_scalar!(
"UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id",
matching[0],
discord_id
)
.fetch_optional(&mut *tx)
.await
.map_err(map_sqlx_error)?
}
_ => {
tx.rollback().await.ok();
return Err(DbError::Ambiguous(
"Multiple accounts use this Discord username. Type: /start your-handle.example.com".to_string(),
));
}
};
tx.commit().await.map_err(map_sqlx_error)?;
result
}
};
Ok(result)
}
async fn store_telegram_chat_id(
&self,
telegram_username: &str,
+2
View File
@@ -32,7 +32,9 @@ bytes = { workspace = true }
chrono = { workspace = true }
cid = { workspace = true }
dotenvy = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
hex = { workspace = true }
futures-util = { workspace = true }
governor = { workspace = true }
hickory-resolver = { workspace = true }
@@ -0,0 +1,301 @@
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::Deserialize;
use serde_json::json;
use tracing::{debug, info, warn};
use tranquil_types::Handle;
use crate::comms::comms_repo;
use crate::state::AppState;
use crate::util::{discord_public_key, pds_hostname};
#[derive(Deserialize)]
struct Interaction {
#[serde(rename = "type")]
interaction_type: u8,
data: Option<InteractionData>,
member: Option<InteractionMember>,
user: Option<InteractionUser>,
}
#[derive(Deserialize)]
struct InteractionData {
name: Option<String>,
options: Option<Vec<InteractionOption>>,
}
#[derive(Deserialize)]
struct InteractionOption {
name: String,
value: serde_json::Value,
}
#[derive(Deserialize)]
struct InteractionMember {
user: Option<InteractionUser>,
}
#[derive(Deserialize)]
struct InteractionUser {
id: String,
username: Option<String>,
}
fn verify_signature(
public_key: &VerifyingKey,
timestamp: &str,
body: &str,
signature: &str,
) -> bool {
let sig_bytes = match hex::decode(signature) {
Ok(b) => b,
Err(_) => return false,
};
let signature = match Signature::from_slice(&sig_bytes) {
Ok(s) => s,
Err(_) => return false,
};
let message = format!("{}{}", timestamp, body);
public_key.verify(message.as_bytes(), &signature).is_ok()
}
fn parse_start_handle(options: Option<&[InteractionOption]>) -> Option<String> {
options
.and_then(|opts| opts.iter().find(|o| o.name == "handle"))
.and_then(|o| o.value.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
pub async fn handle_discord_webhook(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
body: String,
) -> Response {
let public_key = match discord_public_key() {
Some(pk) => pk,
None => {
warn!("Discord webhook called but public key is not configured");
return StatusCode::FORBIDDEN.into_response();
}
};
let signature = headers
.get("x-signature-ed25519")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let timestamp = headers
.get("x-signature-timestamp")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if !verify_signature(public_key, timestamp, &body, signature) {
return StatusCode::UNAUTHORIZED.into_response();
}
let interaction: Interaction = match serde_json::from_str(&body) {
Ok(i) => i,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
match interaction.interaction_type {
1 => Json(json!({"type": 1})).into_response(),
2 => handle_command(state, interaction).await,
other => {
debug!(interaction_type = other, "Received unknown Discord interaction type");
StatusCode::OK.into_response()
}
}
}
async fn handle_command(state: AppState, interaction: Interaction) -> Response {
let command_name = interaction
.data
.as_ref()
.and_then(|d| d.name.as_deref())
.unwrap_or_default();
if command_name != "start" {
return Json(json!({
"type": 4,
"data": {"content": "Unknown command", "flags": 64}
}))
.into_response();
}
let user = interaction
.member
.as_ref()
.and_then(|m| m.user.as_ref())
.or(interaction.user.as_ref());
let (discord_user_id, discord_username) = match user {
Some(u) => (u.id.clone(), u.username.clone().unwrap_or_default()),
None => {
return Json(json!({
"type": 4,
"data": {"content": "Could not identify user", "flags": 64}
}))
.into_response();
}
};
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
if let Some(ref h) = handle {
if Handle::new(h).is_err() {
return Json(json!({
"type": 4,
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
}))
.into_response();
}
}
debug!(
discord_username = %discord_username,
discord_user_id = %discord_user_id,
handle = ?handle,
"Received /start from Discord user"
);
match state
.user_repo
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
.await
{
Ok(Some(user_id)) => {
info!(
discord_username = %discord_username,
discord_user_id = %discord_user_id,
"Verified Discord user and stored user ID"
);
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
"discord",
&discord_user_id,
pds_hostname(),
)
.await
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
Json(json!({
"type": 4,
"data": {"content": "Verified", "flags": 64}
}))
.into_response()
}
Ok(None) => {
debug!(
discord_username = %discord_username,
"No matching user found for Discord username"
);
Json(json!({
"type": 4,
"data": {"content": "No account found with this Discord username. Set your Discord username in your PDS settings first.", "flags": 64}
}))
.into_response()
}
Err(tranquil_db_traits::DbError::Ambiguous(msg)) => {
debug!(
discord_username = %discord_username,
"Ambiguous Discord username match"
);
Json(json!({
"type": 4,
"data": {"content": msg, "flags": 64}
}))
.into_response()
}
Err(tranquil_db_traits::DbError::LockContention) => {
debug!(
discord_username = %discord_username,
"Lock contention during Discord verification"
);
Json(json!({
"type": 4,
"data": {"content": "Server busy, please try again in a moment.", "flags": 64}
}))
.into_response()
}
Err(e) => {
warn!(
discord_username = %discord_username,
error = %e,
"Failed to store Discord user ID"
);
Json(json!({
"type": 4,
"data": {"content": "Verification failed. Try again later.", "flags": 64}
}))
.into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_handle_from_options() {
let options = vec![InteractionOption {
name: "handle".to_string(),
value: serde_json::json!("lewis.buttercup.wizardry.systems"),
}];
assert_eq!(
parse_start_handle(Some(&options)),
Some("lewis.buttercup.wizardry.systems".to_string()),
);
}
#[test]
fn parse_handle_no_options() {
assert_eq!(parse_start_handle(None), None);
}
#[test]
fn parse_handle_empty_options() {
let options: Vec<InteractionOption> = vec![];
assert_eq!(parse_start_handle(Some(&options)), None);
}
#[test]
fn parse_handle_wrong_option_name() {
let options = vec![InteractionOption {
name: "other".to_string(),
value: serde_json::json!("test"),
}];
assert_eq!(parse_start_handle(Some(&options)), None);
}
#[test]
fn parse_handle_empty_string() {
let options = vec![InteractionOption {
name: "handle".to_string(),
value: serde_json::json!(""),
}];
assert_eq!(parse_start_handle(Some(&options)), None);
}
#[test]
fn parse_handle_whitespace_trimmed() {
let options = vec![InteractionOption {
name: "handle".to_string(),
value: serde_json::json!(" alice.example.com "),
}];
assert_eq!(
parse_start_handle(Some(&options)),
Some("alice.example.com".to_string()),
);
}
}
+1 -1
View File
@@ -409,7 +409,7 @@ impl ApiError {
Some("Telegram username is required when using Telegram verification".to_string())
}
Self::MissingSignalNumber => {
Some("Signal phone number is required when using Signal verification".to_string())
Some("Signal username is required when using Signal verification".to_string())
}
Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()),
Self::SelfHostedDidWebDisabled => {
+25 -15
View File
@@ -35,9 +35,9 @@ pub struct CreateAccountInput {
pub did_type: Option<String>,
pub signing_key: Option<String>,
pub verification_channel: Option<String>,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
}
#[derive(Serialize)]
@@ -203,8 +203,16 @@ pub async fn create_account(
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
_ => return ApiError::MissingEmail.into_response(),
},
"discord" => match &input.discord_id {
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
"discord" => match &input.discord_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().to_lowercase();
if !crate::api::validation::is_valid_discord_username(&clean) {
return ApiError::InvalidRequest(
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
).into_response();
}
clean
}
_ => return ApiError::MissingDiscordId.into_response(),
},
"telegram" => match &input.telegram_username {
@@ -219,8 +227,10 @@ pub async fn create_account(
}
_ => return ApiError::MissingTelegramUsername.into_response(),
},
"signal" => match &input.signal_number {
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
"signal" => match &input.signal_username {
Some(username) if !username.trim().is_empty() => {
username.trim().trim_start_matches('@').to_lowercase()
}
_ => return ApiError::MissingSignalNumber.into_response(),
},
_ => return ApiError::InvalidVerificationChannel.into_response(),
@@ -633,24 +643,23 @@ pub async fn create_account(
did: unsafe { Did::new_unchecked(&did) },
password_hash,
preferred_comms_channel,
discord_id: input
.discord_id
discord_username: input
.discord_username
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from),
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
telegram_username: input
.telegram_username
.as_deref()
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(String::from),
signal_number: input
.signal_number
signal_username: input
.signal_username
.as_deref()
.map(|s| s.trim())
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(String::from),
.map(|s| s.to_lowercase()),
deactivated_at,
encrypted_key_bytes,
encryption_version: crate::config::ENCRYPTION_VERSION,
@@ -750,6 +759,7 @@ pub async fn create_account(
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
verification_channel,
+1
View File
@@ -3,6 +3,7 @@ pub mod admin;
pub mod age_assurance;
pub mod backup;
pub mod delegation;
pub mod discord_webhook;
pub mod error;
pub mod identity;
pub mod moderation;
@@ -17,11 +17,11 @@ use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
pub struct NotificationPrefsResponse {
pub preferred_channel: CommsChannel,
pub email: String,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub discord_verified: bool,
pub telegram_username: Option<String>,
pub telegram_verified: bool,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
pub signal_verified: bool,
}
@@ -38,11 +38,11 @@ pub async fn get_notification_prefs(
Ok(Json(NotificationPrefsResponse {
preferred_channel: prefs.preferred_channel,
email: prefs.email,
discord_id: prefs.discord_id,
discord_username: prefs.discord_username,
discord_verified: prefs.discord_verified,
telegram_username: prefs.telegram_username,
telegram_verified: prefs.telegram_verified,
signal_number: prefs.signal_number,
signal_username: prefs.signal_username,
signal_verified: prefs.signal_verified,
})
.into_response())
@@ -120,9 +120,9 @@ pub async fn get_notification_history(
pub struct UpdateNotificationPrefsInput {
pub preferred_channel: Option<String>,
pub email: Option<String>,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
}
#[derive(Serialize)]
@@ -172,9 +172,24 @@ pub async fn request_channel_verification(
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_identifier
);
let body = format!(
"Your verification code is: {}\n\nOr verify directly:\n{}",
formatted_token, verify_link
let prefs = state
.user_repo
.get_comms_prefs(user_id)
.await
.ok()
.flatten();
let locale = prefs
.as_ref()
.and_then(|p| p.preferred_locale.as_deref())
.unwrap_or("en");
let strings = crate::comms::get_strings(locale);
let body = crate::comms::format_message(
strings.channel_verification_body,
&[("code", &formatted_token), ("verify_link", &verify_link)],
);
let subject = crate::comms::format_message(
strings.channel_verification_subject,
&[("hostname", hostname)],
);
let recipient = match comms_channel {
tranquil_db_traits::CommsChannel::Telegram => state
@@ -194,7 +209,7 @@ pub async fn request_channel_verification(
comms_channel,
tranquil_db_traits::CommsType::ChannelVerification,
&recipient,
Some("Verify your channel"),
Some(&subject),
&body,
Some(json!({"code": formatted_token})),
)
@@ -291,8 +306,9 @@ pub async fn update_notification_prefs(
}
}
if let Some(ref discord_id) = input.discord_id {
if discord_id.is_empty() {
if let Some(ref discord_username) = input.discord_username {
let discord_clean = discord_username.trim().to_lowercase();
if discord_clean.is_empty() {
if effective_channel == CommsChannel::Discord {
return Err(ApiError::InvalidRequest(
"Cannot remove Discord while it is the preferred notification channel".into(),
@@ -303,13 +319,20 @@ pub async fn update_notification_prefs(
.clear_discord(user_id)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
info!(did = %auth.did, "Cleared Discord ID");
info!(did = %auth.did, "Cleared Discord");
} else if !crate::api::validation::is_valid_discord_username(&discord_clean) {
return Err(ApiError::InvalidRequest(
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)"
.into(),
));
} else {
request_channel_verification(&state, user_id, &auth.did, "discord", discord_id, None)
state
.user_repo
.set_unverified_discord(user_id, &discord_clean)
.await
.map_err(|e| ApiError::InternalError(Some(e)))?;
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
verification_required.push("discord".to_string());
info!(did = %auth.did, "Requested Discord verification");
info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username");
}
}
@@ -343,8 +366,9 @@ pub async fn update_notification_prefs(
}
}
if let Some(ref signal) = input.signal_number {
if signal.is_empty() {
if let Some(ref signal) = input.signal_username {
let signal_clean = signal.trim().trim_start_matches('@').to_lowercase();
if signal_clean.is_empty() {
if effective_channel == CommsChannel::Signal {
return Err(ApiError::InvalidRequest(
"Cannot remove Signal while it is the preferred notification channel".into(),
@@ -355,13 +379,23 @@ pub async fn update_notification_prefs(
.clear_signal(user_id)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
info!(did = %auth.did, "Cleared Signal number");
info!(did = %auth.did, "Cleared Signal username");
} else if !crate::comms::is_valid_signal_username(&signal_clean) {
return Err(ApiError::InvalidRequest(
"Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)"
.into(),
));
} else {
request_channel_verification(&state, user_id, &auth.did, "signal", signal, None)
state
.user_repo
.set_unverified_signal(user_id, &signal_clean)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
request_channel_verification(&state, user_id, &auth.did, "signal", &signal_clean, None)
.await
.map_err(|e| ApiError::InternalError(Some(e)))?;
verification_required.push("signal".to_string());
info!(did = %auth.did, "Requested Signal verification");
info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username");
}
}
+18 -1
View File
@@ -55,6 +55,14 @@ pub async fn import_repo(
return Err(ApiError::AccountTakedown);
}
let user_id = user.id;
let expected_root_cid = state
.repo_repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| {
error!("DB error fetching repo root: {:?}", e);
ApiError::InternalError(None)
})?;
let (root, blocks) = match parse_car(&body).await {
Ok((r, b)) => (r, b),
Err(ImportError::InvalidRootCount) => {
@@ -191,7 +199,16 @@ pub async fn import_repo(
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_BLOCKS);
let _write_lock = state.repo_write_locks.lock(user_id).await;
match apply_import(&state.repo_repo, user_id, root, blocks.clone(), max_blocks).await {
match apply_import(
&state.repo_repo,
user_id,
root,
blocks.clone(),
max_blocks,
expected_root_cid.as_ref(),
)
.await
{
Ok(import_result) => {
info!(
"Successfully imported {} records for user {}",
@@ -367,6 +367,7 @@ pub async fn update_email(
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
"email",
+8 -2
View File
@@ -1,11 +1,11 @@
use crate::state::AppState;
use crate::util::{pds_hostname, telegram_bot_username};
use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json;
fn get_available_comms_channels() -> Vec<&'static str> {
let mut channels = vec!["email"];
if std::env::var("DISCORD_WEBHOOK_URL").is_ok() {
if std::env::var("DISCORD_BOT_TOKEN").is_ok() {
channels.push("discord");
}
if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() {
@@ -62,6 +62,12 @@ pub async fn describe_server() -> impl IntoResponse {
"availableCommsChannels": get_available_comms_channels(),
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
});
if let Some(bot_username) = discord_bot_username() {
response["discordBotUsername"] = json!(bot_username);
}
if let Some(app_id) = discord_app_id() {
response["discordAppId"] = json!(app_id);
}
if let Some(bot_username) = telegram_bot_username() {
response["telegramBotUsername"] = json!(bot_username);
}
@@ -50,9 +50,9 @@ pub struct CreatePasskeyAccountInput {
pub did_type: Option<String>,
pub signing_key: Option<String>,
pub verification_channel: Option<String>,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
}
#[derive(Serialize)]
@@ -167,8 +167,16 @@ pub async fn create_passkey_account(
Some(e) if !e.is_empty() => e.clone(),
_ => return ApiError::MissingEmail.into_response(),
},
"discord" => match &input.discord_id {
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
"discord" => match &input.discord_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().to_lowercase();
if !crate::api::validation::is_valid_discord_username(&clean) {
return ApiError::InvalidRequest(
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
).into_response();
}
clean
}
_ => return ApiError::MissingDiscordId.into_response(),
},
"telegram" => match &input.telegram_username {
@@ -183,8 +191,10 @@ pub async fn create_passkey_account(
}
_ => return ApiError::MissingTelegramUsername.into_response(),
},
"signal" => match &input.signal_number {
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
"signal" => match &input.signal_username {
Some(username) if !username.trim().is_empty() => {
username.trim().trim_start_matches('@').to_lowercase()
}
_ => return ApiError::MissingSignalNumber.into_response(),
},
_ => return ApiError::InvalidVerificationChannel.into_response(),
@@ -409,24 +419,23 @@ pub async fn create_passkey_account(
email: email.clone().unwrap_or_default(),
did: did_typed.clone(),
preferred_comms_channel,
discord_id: input
.discord_id
discord_username: input
.discord_username
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from),
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
telegram_username: input
.telegram_username
.as_deref()
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(String::from),
signal_number: input
.signal_number
signal_username: input
.signal_username
.as_deref()
.map(|s| s.trim())
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(String::from),
.map(|s| s.to_lowercase()),
setup_token_hash,
setup_expires_at,
deactivated_at,
@@ -501,6 +510,7 @@ pub async fn create_passkey_account(
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
verification_channel,
@@ -634,14 +634,14 @@ pub async fn confirm_signup(
let (channel_str, identifier) = match row.channel {
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
tranquil_db_traits::CommsChannel::Discord => {
("discord", row.discord_id.clone().unwrap_or_default())
("discord", row.discord_username.clone().unwrap_or_default())
}
tranquil_db_traits::CommsChannel::Telegram => (
"telegram",
row.telegram_username.clone().unwrap_or_default(),
),
tranquil_db_traits::CommsChannel::Signal => {
("signal", row.signal_number.clone().unwrap_or_default())
("signal", row.signal_username.clone().unwrap_or_default())
}
};
@@ -786,14 +786,14 @@ pub async fn resend_verification(
let (channel_str, recipient) = match row.channel {
tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
tranquil_db_traits::CommsChannel::Discord => {
("discord", row.discord_id.clone().unwrap_or_default())
("discord", row.discord_username.clone().unwrap_or_default())
}
tranquil_db_traits::CommsChannel::Telegram => (
"telegram",
row.telegram_username.clone().unwrap_or_default(),
),
tranquil_db_traits::CommsChannel::Signal => {
("signal", row.signal_number.clone().unwrap_or_default())
("signal", row.signal_username.clone().unwrap_or_default())
}
};
@@ -804,6 +804,7 @@ pub async fn resend_verification(
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
row.id,
channel_str,
+35 -3
View File
@@ -351,10 +351,17 @@ pub fn is_valid_email(email: &str) -> bool {
pub fn is_valid_telegram_username(username: &str) -> bool {
let clean = username.strip_prefix('@').unwrap_or(username);
(5..=32).contains(&clean.len())
&& clean
(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_alphanumeric() || c == '_')
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.')
&& !username.starts_with('.')
&& !username.ends_with('.')
&& !username.contains("..")
}
#[cfg(test)]
@@ -534,4 +541,29 @@ mod tests {
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(".username"));
assert!(!is_valid_discord_username("username."));
assert!(!is_valid_discord_username("user..name"));
assert!(!is_valid_discord_username("user name"));
assert!(!is_valid_discord_username("123456789012345678901234567890123"));
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ mod service;
pub use tranquil_comms::{
CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender,
NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender,
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, mime_encode_header,
sanitize_header_value, validate_locale,
VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username,
mime_encode_header, sanitize_header_value, validate_locale,
};
pub use service::{CommsService, channel_display_name, repo as comms_repo};
+20 -24
View File
@@ -281,7 +281,7 @@ fn resolve_recipient(
})
.unwrap_or_else(email_fallback),
tranquil_db_traits::CommsChannel::Signal => prefs
.signal_number
.signal_username
.as_ref()
.filter(|n| !n.is_empty())
.map(|n| ResolvedRecipient {
@@ -639,6 +639,7 @@ pub mod repo {
}
pub async fn enqueue_signup_verification(
user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository,
user_id: Uuid,
channel: &str,
@@ -647,21 +648,19 @@ pub mod repo {
hostname: &str,
) -> Result<Uuid, DbError> {
let comms_channel = channel_from_str(channel);
let strings = get_strings("en");
let (verify_page, verify_link) = match comms_channel {
tranquil_db_traits::CommsChannel::Email => {
let encoded_email = urlencoding::encode(recipient);
let encoded_token = urlencoding::encode(code);
(
format!("https://{}/app/verify", hostname),
format!(
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_email
),
)
}
_ => (String::new(), String::new()),
};
let prefs = user_repo.get_comms_prefs(user_id).await.ok().flatten();
let locale = prefs
.as_ref()
.and_then(|p| p.preferred_locale.as_deref())
.unwrap_or("en");
let strings = get_strings(locale);
let encoded_token = urlencoding::encode(code);
let encoded_recipient = urlencoding::encode(recipient);
let verify_page = format!("https://{}/app/verify", hostname);
let verify_link = format!(
"https://{}/app/verify?token={}&identifier={}",
hostname, encoded_token, encoded_recipient
);
let body = format_message(
strings.signup_verification_body,
&[
@@ -671,20 +670,17 @@ pub mod repo {
("verify_link", &verify_link),
],
);
let subject = match comms_channel {
tranquil_db_traits::CommsChannel::Email => Some(format_message(
strings.signup_verification_subject,
&[("hostname", hostname)],
)),
_ => None,
};
let subject = format_message(
strings.signup_verification_subject,
&[("hostname", hostname)],
);
infra_repo
.enqueue_comms(
Some(user_id),
comms_channel,
CommsType::EmailVerification,
recipient,
subject.as_deref(),
Some(&subject),
&body,
None,
)
+7 -1
View File
@@ -645,7 +645,13 @@ pub fn app(state: AppState) -> Router {
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
.route(
"/webhook/telegram",
post(api::telegram_webhook::handle_telegram_webhook),
post(api::telegram_webhook::handle_telegram_webhook)
.layer(DefaultBodyLimit::max(64 * 1024)),
)
.route(
"/webhook/discord",
post(api::discord_webhook::handle_discord_webhook)
.layer(DefaultBodyLimit::max(64 * 1024)),
)
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
.layer(middleware::from_fn(metrics::metrics_middleware))
+62 -3
View File
@@ -64,6 +64,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
});
let mut comms_service = CommsService::new(state.infra_repo.clone());
let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None;
if let Some(email_sender) = EmailSender::from_env() {
info!("Email comms enabled");
@@ -74,6 +75,46 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
if let Some(discord_sender) = DiscordSender::from_env() {
info!("Discord comms enabled");
match discord_sender.resolve_bot_username().await {
Ok(username) => {
info!(bot_username = %username, "Resolved Discord bot username");
tranquil_pds::util::set_discord_bot_username(username);
}
Err(e) => {
warn!("Failed to resolve Discord bot username: {}", e);
}
}
match discord_sender.resolve_application_info().await {
Ok((app_id, verify_key)) => {
info!(app_id = %app_id, "Resolved Discord application info");
tranquil_pds::util::set_discord_app_id(app_id.clone());
match hex::decode(&verify_key)
.ok()
.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok())
.and_then(|bytes| ed25519_dalek::VerifyingKey::from_bytes(&bytes).ok())
{
Some(public_key) => {
tranquil_pds::util::set_discord_public_key(public_key);
info!("Discord Ed25519 public key loaded");
let hostname = std::env::var("PDS_HOSTNAME")
.unwrap_or_else(|_| "localhost".to_string());
let webhook_url = format!("https://{}/webhook/discord", hostname);
match discord_sender.register_slash_command(&app_id).await {
Ok(()) => info!("Discord /start slash command registered"),
Err(e) => warn!("Failed to register Discord slash command: {}", e),
}
deferred_discord_endpoint =
Some((discord_sender.clone(), app_id, webhook_url));
}
None => {
warn!("Failed to parse Discord verify_key as Ed25519 public key");
}
}
}
Err(e) => {
warn!("Failed to resolve Discord application info: {}", e);
}
}
comms_service = comms_service.register_sender(discord_sender);
}
@@ -172,9 +213,27 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.await
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
let server_result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
.await;
let server_handle = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
.await
});
if let Some((sender, app_id, webhook_url)) = deferred_discord_endpoint {
tokio::spawn(async move {
match sender
.set_interactions_endpoint(&app_id, &webhook_url)
.await
{
Ok(()) => info!(url = %webhook_url, "Discord interactions endpoint registered"),
Err(e) => warn!("Failed to set Discord interactions endpoint: {}", e),
}
});
}
let server_result = server_handle
.await
.map_err(|e| format!("Server task panicked: {}", e))?;
comms_handle.await.ok();
+23 -12
View File
@@ -817,9 +817,9 @@ pub struct CompleteRegistrationInput {
pub email: Option<String>,
pub invite_code: Option<String>,
pub verification_channel: Option<String>,
pub discord_id: Option<String>,
pub discord_username: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub signal_username: Option<String>,
pub did_type: Option<String>,
pub did: Option<String>,
}
@@ -894,8 +894,16 @@ pub async fn complete_registration(
_ => return Err(ApiError::MissingEmail),
}
}
"discord" => match &input.discord_id {
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
"discord" => match &input.discord_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().to_lowercase();
if !crate::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),
},
"telegram" => match &input.telegram_username {
@@ -910,8 +918,10 @@ pub async fn complete_registration(
}
_ => return Err(ApiError::MissingTelegramUsername),
},
"signal" => match &input.signal_number {
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
"signal" => match &input.signal_username {
Some(username) if !username.trim().is_empty() => {
username.trim().trim_start_matches('@').to_lowercase()
}
_ => return Err(ApiError::MissingSignalNumber),
},
_ => return Err(ApiError::InvalidVerificationChannel),
@@ -1104,20 +1114,20 @@ pub async fn complete_registration(
email: email.clone(),
did: did_typed.clone(),
preferred_comms_channel,
discord_id: input
.discord_id
discord_username: input
.discord_username
.clone()
.map(|s| s.trim().to_string())
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
telegram_username: input
.telegram_username
.clone()
.map(|s| s.trim().trim_start_matches('@').to_string())
.filter(|s| !s.is_empty()),
signal_number: input
.signal_number
signal_username: input
.signal_username
.clone()
.map(|s| s.trim().to_string())
.map(|s| s.trim().trim_start_matches('@').to_lowercase())
.filter(|s| !s.is_empty()),
encrypted_key_bytes: encrypted_key_bytes.clone(),
encryption_version: crate::config::ENCRYPTION_VERSION,
@@ -1354,6 +1364,7 @@ pub async fn complete_registration(
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
uid,
verification_channel,
+3 -1
View File
@@ -9,6 +9,7 @@ use std::sync::Arc;
use thiserror::Error;
use tracing::debug;
use tranquil_db::{ImportBlock, ImportRecord, ImportRepoError, RepoRepository};
use tranquil_types::CidLink;
use uuid::Uuid;
#[derive(Error, Debug)]
@@ -323,6 +324,7 @@ pub async fn apply_import(
root: Cid,
blocks: HashMap<Cid, Bytes>,
max_blocks: usize,
expected_root_cid: Option<&CidLink>,
) -> Result<ImportResult, ImportError> {
if blocks.len() > max_blocks {
return Err(ImportError::SizeLimitExceeded);
@@ -364,7 +366,7 @@ pub async fn apply_import(
.collect();
repo_repo
.import_repo_data(user_id, &import_blocks, &import_records)
.import_repo_data(user_id, &import_blocks, &import_records, expected_root_cid)
.await?;
debug!(
+27
View File
@@ -14,6 +14,9 @@ const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024;
static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new();
static PDS_HOSTNAME: OnceLock<String> = OnceLock::new();
static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new();
static DISCORD_BOT_USERNAME: OnceLock<String> = OnceLock::new();
static DISCORD_PUBLIC_KEY: OnceLock<ed25519_dalek::VerifyingKey> = OnceLock::new();
static DISCORD_APP_ID: OnceLock<String> = OnceLock::new();
static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new();
pub fn get_max_blob_size() -> usize {
@@ -105,6 +108,30 @@ pub fn pds_hostname_without_port() -> &'static str {
})
}
pub fn set_discord_bot_username(username: String) {
DISCORD_BOT_USERNAME.set(username).ok();
}
pub fn discord_bot_username() -> Option<&'static str> {
DISCORD_BOT_USERNAME.get().map(|s| s.as_str())
}
pub fn set_discord_public_key(key: ed25519_dalek::VerifyingKey) {
DISCORD_PUBLIC_KEY.set(key).ok();
}
pub fn discord_public_key() -> Option<&'static ed25519_dalek::VerifyingKey> {
DISCORD_PUBLIC_KEY.get()
}
pub fn set_discord_app_id(app_id: String) {
DISCORD_APP_ID.set(app_id).ok();
}
pub fn discord_app_id() -> Option<&'static str> {
DISCORD_APP_ID.get().map(|s| s.as_str())
}
pub fn set_telegram_bot_username(username: String) {
TELEGRAM_BOT_USERNAME.set(username).ok();
}
@@ -1,7 +1,6 @@
mod common;
use common::{base_url, client, create_account_and_login, get_test_db_pool};
use serde_json::{Value, json};
use sqlx::Row;
#[tokio::test]
async fn test_get_notification_history() {
@@ -51,10 +50,10 @@ async fn test_get_notification_history() {
async fn test_verify_channel_discord() {
let client = client();
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!({
"discordId": "123456789"
"discordUsername": "testuser123"
});
let resp = client
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
@@ -72,42 +71,6 @@ async fn test_verify_channel_discord() {
.contains(&json!("discord"))
);
let pool = get_test_db_pool().await;
let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE did = $1")
.bind(&did)
.fetch_one(pool)
.await
.expect("User not found");
let row = sqlx::query(
"SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1",
)
.bind(user_id)
.fetch_one(pool)
.await
.expect("Verification code not found");
let metadata: Option<serde_json::Value> = row.get("metadata");
let code = metadata
.as_ref()
.and_then(|m| m.get("code"))
.and_then(|c| c.as_str())
.expect("No code in metadata");
let input = json!({
"channel": "discord",
"identifier": "123456789",
"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);
let resp = client
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
.header("Authorization", format!("Bearer {}", token))
@@ -115,8 +78,8 @@ async fn test_verify_channel_discord() {
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(body["discordVerified"], true);
assert_eq!(body["discordId"], "123456789");
assert_eq!(body["discordVerified"], false);
assert_eq!(body["discordUsername"], "testuser123");
}
#[tokio::test]
+33 -1
View File
@@ -1,5 +1,5 @@
mod common;
use tranquil_pds::comms::{SendError, is_valid_phone_number, sanitize_header_value};
use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value};
use tranquil_pds::image::{ImageError, ImageProcessor};
#[test]
@@ -80,6 +80,38 @@ 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("abc.00"));
assert!(is_valid_signal_username("a_very_long_username_here.55"));
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(".01"));
assert!(!is_valid_signal_username("ab.01"));
assert!(!is_valid_signal_username(""));
assert!(!is_valid_signal_username("1alice.01"));
assert!(!is_valid_signal_username("alice!.01"));
assert!(!is_valid_signal_username("alice .01"));
assert!(!is_valid_signal_username("a".repeat(33).as_str()));
["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"]
.iter()
.for_each(|malicious| {
assert!(
!is_valid_signal_username(malicious),
"Command injection '{}' should be rejected",
malicious
);
});
}
#[test]
fn test_image_file_size_limits() {
let processor = ImageProcessor::new();
+3 -3
View File
@@ -1035,7 +1035,7 @@ async fn test_sso_complete_registration_multichannel_discord() {
"token": token,
"handle": handle_prefix,
"verification_channel": "discord",
"discord_id": discord_id
"discord_username": discord_id
}))
.send()
.await
@@ -1054,7 +1054,7 @@ async fn test_sso_complete_registration_multichannel_discord() {
let did_str = body["did"].as_str().unwrap();
let user = sqlx::query!(
r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_id FROM users WHERE did = $1"#,
r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_username FROM users WHERE did = $1"#,
did_str,
)
.fetch_one(pool)
@@ -1062,7 +1062,7 @@ async fn test_sso_complete_registration_multichannel_discord() {
.unwrap();
assert_eq!(user.preferred_comms_channel, "discord");
assert_eq!(user.discord_id, Some(discord_id.to_string()));
assert_eq!(user.discord_username, Some(discord_id.to_string()));
}
#[tokio::test]
+17 -4
View File
@@ -1948,7 +1948,14 @@ async fn test_concurrent_import_and_writes() {
.expect("Import request failed");
let status = res.status();
let body: Value = res.json().await.unwrap_or_default();
assert_eq!(status, StatusCode::OK, "Import should succeed: {:?}", body);
let is_concurrent_modification = status == StatusCode::BAD_REQUEST
&& body.get("error").and_then(|e| e.as_str()) == Some("InvalidSwap");
assert!(
status == StatusCode::OK || is_concurrent_modification,
"Import should succeed or fail with InvalidSwap due to concurrent writes: {:?}",
body
);
status == StatusCode::OK
}
};
@@ -1987,7 +1994,7 @@ async fn test_concurrent_import_and_writes() {
})
.collect();
tokio::join!(import_future, join_all(write_futures));
let (import_succeeded, _) = tokio::join!(import_future, join_all(write_futures));
let final_posts = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
@@ -2003,11 +2010,17 @@ async fn test_concurrent_import_and_writes() {
let final_body: Value = final_posts.json().await.unwrap();
let record_count = final_body["records"].as_array().unwrap().len();
let min_expected = write_count;
let min_expected = if import_succeeded {
write_count + 1
} else {
write_count
};
assert!(
record_count >= min_expected,
"Expected at least {} records (from writes), got {} (import may also contribute records)",
"Expected at least {} records (writes={}, import_succeeded={}), got {}",
min_expected,
write_count,
import_succeeded,
record_count
);
}