fix: improved discord & signal comms

This commit is contained in:
lewis
2026-02-05 17:31:52 +02:00
committed by Tangled
parent b104dc938a
commit cd7400bc8b
71 changed files with 1596 additions and 514 deletions
+2 -2
View File
@@ -93,8 +93,8 @@ BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
# MAIL_FROM_ADDRESS=noreply@example.com
# MAIL_FROM_NAME=My PDS
# SENDMAIL_PATH=/usr/sbin/sendmail
# Discord notifications (via webhook)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Discord notifications (via bot DM)
# DISCORD_BOT_TOKEN=bot-token
# Telegram notifications (via bot)
# TELEGRAM_BOT_TOKEN=bot-token
# TELEGRAM_WEBHOOK_SECRET=random-secret
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET\n signal_username = $1,\n signal_verified = CASE WHEN LOWER(signal_username) = LOWER($1) THEN signal_verified ELSE FALSE END,\n updated_at = NOW()\n WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "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",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM users WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND deactivated_at IS NULL FOR UPDATE NOWAIT",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
"query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_username,\n discord_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_username,\n signal_verified\n FROM users WHERE did = $1",
"describe": {
"columns": [
{
@@ -32,31 +32,36 @@
},
{
"ordinal": 3,
"name": "discord_username",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "discord_verified",
"type_info": "Bool"
},
{
"ordinal": 4,
"ordinal": 5,
"name": "telegram_username",
"type_info": "Text"
},
{
"ordinal": 5,
"ordinal": 6,
"name": "telegram_verified",
"type_info": "Bool"
},
{
"ordinal": 6,
"ordinal": 7,
"name": "telegram_chat_id",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "signal_number",
"ordinal": 8,
"name": "signal_username",
"type_info": "Text"
},
{
"ordinal": 8,
"ordinal": 9,
"name": "signal_verified",
"type_info": "Bool"
}
@@ -70,6 +75,7 @@
true,
false,
true,
true,
false,
true,
false,
@@ -78,5 +84,5 @@
false
]
},
"hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740"
"hash": "5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET\n discord_username = $1,\n discord_verified = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_verified ELSE FALSE END,\n discord_id = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_id ELSE NULL END,\n updated_at = NOW()\n WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1",
"query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_username FROM users WHERE did = $1",
"describe": {
"columns": [
{
@@ -22,7 +22,7 @@
},
{
"ordinal": 1,
"name": "discord_id",
"name": "discord_username",
"type_info": "Text"
}
],
@@ -36,5 +36,5 @@
true
]
},
"hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722"
"hash": "a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id, handle, email,\n preferred_comms_channel as \"channel: CommsChannel\",\n discord_id, telegram_username, signal_number,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
"query": "SELECT\n id, handle, email,\n preferred_comms_channel as \"channel: CommsChannel\",\n discord_username, telegram_username, signal_username,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
"describe": {
"columns": [
{
@@ -37,7 +37,7 @@
},
{
"ordinal": 4,
"name": "discord_id",
"name": "discord_username",
"type_info": "Text"
},
{
@@ -47,7 +47,7 @@
},
{
"ordinal": 6,
"name": "signal_number",
"name": "signal_username",
"type_info": "Text"
},
{
@@ -90,5 +90,5 @@
false
]
},
"hash": "a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c"
"hash": "b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale, telegram_chat_id, discord_id, signal_number\n FROM users WHERE id = $1",
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale, telegram_chat_id, discord_id, signal_username\n FROM users WHERE id = $1",
"describe": {
"columns": [
{
@@ -47,7 +47,7 @@
},
{
"ordinal": 6,
"name": "signal_number",
"name": "signal_username",
"type_info": "Text"
}
],
@@ -66,5 +66,5 @@
true
]
},
"hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67"
"hash": "b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: CommsChannel\",\n u.discord_id, u.telegram_username, u.signal_number,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: CommsChannel\",\n u.discord_username, u.telegram_username, u.signal_username,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
"describe": {
"columns": [
{
@@ -42,7 +42,7 @@
},
{
"ordinal": 5,
"name": "discord_id",
"name": "discord_username",
"type_info": "Text"
},
{
@@ -52,7 +52,7 @@
},
{
"ordinal": 7,
"name": "signal_number",
"name": "signal_username",
"type_info": "Text"
},
{
@@ -84,5 +84,5 @@
true
]
},
"hash": "671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910"
"hash": "c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3"
}
Generated
+2
View File
@@ -6078,9 +6078,11 @@ dependencies = [
"cid",
"ctor",
"dotenvy",
"ed25519-dalek",
"futures",
"futures-util",
"governor",
"hex",
"hickory-resolver",
"hkdf",
"hmac",
+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
);
}
@@ -18,16 +18,18 @@
let preferredChannel = $state('email')
let availableCommsChannels = $state<string[]>(['email'])
let telegramBotUsername = $state<string | undefined>(undefined)
let discordBotUsername = $state<string | undefined>(undefined)
let discordAppId = $state<string | undefined>(undefined)
let email = $state('')
let discordId = $state('')
let discordUsername = $state('')
let discordVerified = $state(false)
let telegramUsername = $state('')
let telegramVerified = $state(false)
let signalNumber = $state('')
let signalUsername = $state('')
let signalVerified = $state(false)
let savedDiscordId = $state('')
let savedDiscordUsername = $state('')
let savedTelegramUsername = $state('')
let savedSignalNumber = $state('')
let savedSignalUsername = $state('')
let verifyingChannel = $state<string | null>(null)
let verificationCode = $state('')
let historyLoading = $state(true)
@@ -57,17 +59,19 @@
])
preferredChannel = prefs.preferredChannel
email = prefs.email
discordId = prefs.discordId ?? ''
discordUsername = prefs.discordUsername ?? ''
discordVerified = prefs.discordVerified
telegramUsername = prefs.telegramUsername ?? ''
telegramVerified = prefs.telegramVerified
signalNumber = prefs.signalNumber ?? ''
signalUsername = prefs.signalUsername ?? ''
signalVerified = prefs.signalVerified
savedDiscordId = discordId
savedDiscordUsername = discordUsername
savedTelegramUsername = telegramUsername
savedSignalNumber = signalNumber
savedSignalUsername = signalUsername
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
telegramBotUsername = serverInfo.telegramBotUsername
discordBotUsername = serverInfo.discordBotUsername
discordAppId = serverInfo.discordAppId
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
} finally {
@@ -81,15 +85,15 @@
try {
const result = await api.updateNotificationPrefs(session.accessJwt, {
preferredChannel,
discordId: discordId !== savedDiscordId ? discordId : undefined,
discordUsername: discordUsername !== savedDiscordUsername ? discordUsername : undefined,
telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined,
signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined,
signalUsername: signalUsername !== savedSignalUsername ? signalUsername : undefined,
})
await refreshSession()
toast.success($_('comms.preferencesSaved'))
savedDiscordId = discordId
savedDiscordUsername = discordUsername
savedTelegramUsername = telegramUsername
savedSignalNumber = signalNumber
savedSignalUsername = signalUsername
const channelToVerify = result.verificationRequired?.find(
(ch: string) => ch === 'discord' || ch === 'telegram' || ch === 'signal'
)
@@ -108,9 +112,9 @@
if (!verificationCode) return
const identifierMap: Record<string, string> = {
discord: discordId,
discord: discordUsername,
telegram: telegramUsername,
signal: signalNumber
signal: signalUsername
}
const identifier = identifierMap[channel]
if (!identifier) return
@@ -190,9 +194,9 @@
if (!isChannelAvailableOnServer(channelId)) return false
if (channelId === 'email') return true
const hasIdentifier: Record<string, boolean> = {
discord: !!discordId,
discord: !!discordUsername,
telegram: !!telegramUsername,
signal: !!signalNumber
signal: !!signalUsername
}
return hasIdentifier[channelId] ?? false
}
@@ -245,8 +249,8 @@
{#if isChannelAvailableOnServer('discord')}
<div class="config-item">
<div class="config-header">
<label for="discord">{$_('register.discordId')}</label>
{#if discordId}
<label for="discord">{$_('register.discordUsername')}</label>
{#if discordUsername}
<span class="status" class:verified={discordVerified} class:unverified={!discordVerified}>
{preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
</span>
@@ -256,23 +260,22 @@
<input
id="discord"
type="text"
bind:value={discordId}
onblur={() => checkChannelInUse('discord', discordId)}
placeholder={$_('register.discordIdPlaceholder')}
bind:value={discordUsername}
onblur={() => checkChannelInUse('discord', discordUsername)}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={saving}
/>
{#if discordId && discordId === savedDiscordId && !discordVerified}
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
{/if}
</div>
{#if discordInUse}
<p class="hint warning">{$_('comms.discordInUseWarning')}</p>
{/if}
{#if verifyingChannel === 'discord'}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
<button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
{#if discordUsername && discordUsername === savedDiscordUsername && !discordVerified && discordBotUsername}
{@const encodedHandle = session.handle.replaceAll('.', '_')}
<div class="discord-verify-prompt">
{#if discordAppId}
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('comms.discordOpenLink')}</a>
{/if}
<span class="manual-hint">{$_('comms.discordStartBot', { values: { botUsername: discordBotUsername, handle: session.handle } })}</span>
</div>
{/if}
</div>
@@ -314,8 +317,8 @@
{#if isChannelAvailableOnServer('signal')}
<div class="config-item">
<div class="config-header">
<label for="signal">{$_('register.signalNumber')}</label>
{#if signalNumber}
<label for="signal">{$_('register.signalUsername')}</label>
{#if signalUsername}
<span class="status" class:verified={signalVerified} class:unverified={!signalVerified}>
{preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
</span>
@@ -324,13 +327,13 @@
<div class="config-input">
<input
id="signal"
type="tel"
bind:value={signalNumber}
onblur={() => checkChannelInUse('signal', signalNumber)}
placeholder={$_('register.signalNumberPlaceholder')}
type="text"
bind:value={signalUsername}
onblur={() => checkChannelInUse('signal', signalUsername)}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={saving}
/>
{#if signalNumber && signalNumber === savedSignalNumber && !signalVerified}
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
{/if}
</div>
@@ -533,6 +536,18 @@
color: var(--text-primary);
}
.discord-verify-prompt {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--accent-bg, var(--bg-card));
border: 1px solid var(--accent, var(--border-color));
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-primary);
}
.manual-hint {
font-size: var(--text-xs);
color: var(--text-secondary);
+8 -8
View File
@@ -404,9 +404,9 @@ export const api = {
did: params.did,
signingKey: params.signingKey,
verificationChannel: params.verificationChannel,
discordId: params.discordId,
discordUsername: params.discordUsername,
telegramUsername: params.telegramUsername,
signalNumber: params.signalNumber,
signalUsername: params.signalUsername,
}),
});
const data = await response.json();
@@ -656,9 +656,9 @@ export const api = {
updateNotificationPrefs(token: AccessToken, prefs: {
preferredChannel?: string;
discordId?: string;
discordUsername?: string;
telegramUsername?: string;
signalNumber?: string;
signalUsername?: string;
}): Promise<UpdateNotificationPrefsResponse> {
return xrpc("_account.updateNotificationPrefs", {
method: "POST",
@@ -1133,9 +1133,9 @@ export const api = {
did?: Did;
signingKey?: string;
verificationChannel?: VerificationChannel;
discordId?: string;
discordUsername?: string;
telegramUsername?: string;
signalNumber?: string;
signalUsername?: string;
}, byodToken?: string): Promise<PasskeyAccountCreateResponse> {
const url = `${API_BASE}/_account.createPasskeyAccount`;
const headers: Record<string, string> = {
@@ -1854,9 +1854,9 @@ export const typedApi = {
token: AccessToken,
prefs: {
preferredChannel?: string;
discordId?: string;
discordUsername?: string;
telegramUsername?: string;
signalNumber?: string;
signalUsername?: string;
},
): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> {
return xrpcResult("_account.updateNotificationPrefs", {
@@ -14,25 +14,31 @@
let resending = $state(false)
let resendMessage = $state<string | null>(null)
let telegramBotUsername = $state<string | undefined>(undefined)
let discordBotUsername = $state<string | undefined>(undefined)
let discordAppId = $state<string | undefined>(undefined)
let pollingInterval: ReturnType<typeof setInterval> | null = null
const isTelegram = $derived(flow.info.verificationChannel === 'telegram')
const isDiscord = $derived(flow.info.verificationChannel === 'discord')
const isBotVerified = $derived(isTelegram || isDiscord)
onMount(async () => {
if (isTelegram) {
if (isTelegram || isDiscord) {
try {
const serverInfo = await api.describeServer()
telegramBotUsername = serverInfo.telegramBotUsername
discordBotUsername = serverInfo.discordBotUsername
discordAppId = serverInfo.discordAppId
} catch {
}
}
})
$effect(() => {
if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) {
if (flow.state.step === 'verify' && flow.account && (isBotVerified || !verificationCode.trim())) {
pollingInterval = setInterval(async () => {
if (!isTelegram && verificationCode.trim()) return
if (!isBotVerified && verificationCode.trim()) return
const advanced = await flow.checkAndAdvanceIfVerified()
if (advanced && pollingInterval) {
clearInterval(pollingInterval)
@@ -105,6 +111,13 @@
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code> manually.
</p>
<p class="info-text waiting">Waiting for verification...</p>
{:else if isDiscord && discordAppId}
{@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`}
<p class="info-text">
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">Open Discord to verify</a>,
or send <code>/start {handle}</code> to <strong>{discordBotUsername ?? 'the bot'}</strong> manually.
</p>
<p class="info-text waiting">Waiting for verification...</p>
{:else}
<p class="info-text">
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
+6 -6
View File
@@ -56,9 +56,9 @@ export function createRegistrationFlow(
didType: "plc",
externalDid: "",
verificationChannel: "email",
discordId: "",
discordUsername: "",
telegramUsername: "",
signalNumber: "",
signalUsername: "",
},
externalDidWeb: {
keyMode: "reserved",
@@ -252,9 +252,9 @@ export function createRegistrationFlow(
? state.externalDidWeb.reservedSigningKey
: undefined,
verificationChannel: state.info.verificationChannel,
discordId: state.info.discordId?.trim() || undefined,
discordUsername: state.info.discordUsername?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalNumber: state.info.signalNumber?.trim() || undefined,
signalUsername: state.info.signalUsername?.trim() || undefined,
}, byodToken);
state.account = {
@@ -305,9 +305,9 @@ export function createRegistrationFlow(
? state.externalDidWeb.reservedSigningKey
: undefined,
verificationChannel: state.info.verificationChannel,
discordId: state.info.discordId?.trim() || undefined,
discordUsername: state.info.discordUsername?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalNumber: state.info.signalNumber?.trim() || undefined,
signalUsername: state.info.signalUsername?.trim() || undefined,
}, byodToken);
state.account = {
+2 -2
View File
@@ -28,9 +28,9 @@ export interface RegistrationInfo {
didType: DidType;
externalDid?: string;
verificationChannel: VerificationChannel;
discordId?: string;
discordUsername?: string;
telegramUsername?: string;
signalNumber?: string;
signalUsername?: string;
}
export interface ExternalDidWebState {
+4 -4
View File
@@ -185,9 +185,9 @@ export interface CreateAccountParams {
did?: string;
signingKey?: string;
verificationChannel?: VerificationChannel;
discordId?: string;
discordUsername?: string;
telegramUsername?: string;
signalNumber?: string;
signalUsername?: string;
}
export interface CreateAccountResult {
@@ -254,11 +254,11 @@ export interface ListReposResponse {
export interface NotificationPrefs {
preferredChannel: VerificationChannel;
email: EmailAddress;
discordId: string | null;
discordUsername: string | null;
discordVerified: boolean;
telegramUsername: string | null;
telegramVerified: boolean;
signalNumber: string | null;
signalUsername: string | null;
signalVerified: boolean;
}
+2 -2
View File
@@ -173,11 +173,11 @@ export const reauthResponseSchema = z.object({
export const notificationPrefsSchema = z.object({
preferredChannel: verificationChannel,
email: email,
discordId: z.string().nullable(),
discordUsername: z.string().nullable(),
discordVerified: z.boolean(),
telegramUsername: z.string().nullable(),
telegramVerified: z.boolean(),
signalNumber: z.string().nullable(),
signalUsername: z.string().nullable(),
signalVerified: z.boolean(),
});
+15 -15
View File
@@ -84,19 +84,17 @@
"emailAddress": "Email Address",
"emailPlaceholder": "you@example.com",
"discord": "Discord",
"discordId": "Discord User ID",
"discordIdPlaceholder": "123456789012345678",
"discordIdHint": "Enable Developer Mode to find your ID",
"discordInUseWarning": "Discord ID in use by another account",
"discordUsername": "Discord Username",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "Discord username in use by another account",
"telegram": "Telegram",
"telegramUsername": "Telegram Username",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "Telegram username in use by another account",
"signal": "Signal",
"signalNumber": "Signal Phone Number",
"signalNumberPlaceholder": "+1234567890",
"signalNumberHint": "Include country code",
"signalInUseWarning": "Signal number in use by another account",
"signalUsername": "Signal Username",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "Signal username in use by another account",
"notConfigured": "not configured",
"inviteCode": "Invite Code",
"inviteCodePlaceholder": "Enter your invite code",
@@ -118,9 +116,9 @@
"externalDidRequired": "External did:web is required",
"externalDidFormat": "External DID must start with did:web:",
"emailRequired": "Email is required for email verification",
"discordIdRequired": "Discord ID is required for Discord verification",
"discordUsernameRequired": "Discord username is required for Discord verification",
"telegramRequired": "Telegram username is required for Telegram verification",
"signalRequired": "Phone number is required for Signal verification"
"signalRequired": "Signal username is required for Signal verification"
}
},
"dashboard": {
@@ -437,11 +435,13 @@
"verifiedSuccess": "{channel} verified successfully",
"messageHistory": "Message History",
"noMessages": "No messages found.",
"discordInUseWarning": "This Discord ID is already associated with another account.",
"discordInUseWarning": "This Discord username is already associated with another account.",
"telegramInUseWarning": "This Telegram username is already associated with another account.",
"signalInUseWarning": "This Signal number is already associated with another account.",
"signalInUseWarning": "This Signal username is already associated with another account.",
"telegramStartBot": "Or send /start {handle} to @{botUsername} manually",
"telegramOpenLink": "Open Telegram to verify"
"telegramOpenLink": "Open Telegram to verify",
"discordStartBot": "DM @{botUsername} on Discord and send /start {handle}",
"discordOpenLink": "Open Discord to verify"
},
"repoExplorer": {
"collections": "Collections",
@@ -779,9 +779,9 @@
"externalDidRequired": "External did:web is required",
"externalDidFormat": "External DID must start with did:web:",
"emailRequired": "Email is required for email verification",
"discordRequired": "Discord ID is required for Discord verification",
"discordRequired": "Discord username is required for Discord verification",
"telegramRequired": "Telegram username is required for Telegram verification",
"signalRequired": "Phone number required",
"signalRequired": "Signal username required",
"passkeysNotSupported": "Passkeys not supported in this browser",
"passkeyCancelled": "Cancelled",
"passkeyFailed": "Passkey registration failed"
+14 -14
View File
@@ -84,19 +84,17 @@
"emailAddress": "Sähköpostiosoite",
"emailPlaceholder": "sinä@esimerkki.fi",
"discord": "Discord",
"discordId": "Discord-käyttäjätunnus",
"discordIdPlaceholder": "Discord-käyttäjätunnuksesi",
"discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)",
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
"discordUsername": "Discord-käyttäjänimi",
"discordUsernamePlaceholder": "käyttäjänimesi",
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"telegram": "Telegram",
"telegramUsername": "Telegram-käyttäjänimi",
"telegramUsernamePlaceholder": "@käyttäjänimesi",
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"signal": "Signal",
"signalNumber": "Signal-puhelinnumero",
"signalNumberPlaceholder": "+358401234567",
"signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)",
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
"signalUsername": "Signal-käyttäjänimi",
"signalUsernamePlaceholder": "käyttäjänimi.01",
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"notConfigured": "ei määritetty",
"inviteCode": "Kutsukoodi",
"inviteCodePlaceholder": "Syötä kutsukoodisi",
@@ -118,9 +116,9 @@
"externalDidRequired": "Ulkoinen did:web vaaditaan",
"externalDidFormat": "Ulkoisen DID:n on alettava did:web:",
"emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen",
"discordIdRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen",
"discordUsernameRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen",
"telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen",
"signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen"
"signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen"
}
},
"dashboard": {
@@ -433,11 +431,13 @@
"verifiedSuccess": "{channel} vahvistettu",
"messageHistory": "Viestihistoria",
"noMessages": "Viestejä ei löytynyt.",
"discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.",
"discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.",
"signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.",
"telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti",
"telegramOpenLink": "Avaa Telegram vahvistaaksesi",
"discordStartBot": "Lähetä @{botUsername}-botille viesti /start {handle} Discordissa",
"discordOpenLink": "Avaa Discord vahvistaaksesi",
"failedToLoad": "Asetusten lataus epäonnistui",
"failedToSave": "Asetusten tallennus epäonnistui",
"failedToVerify": "Vahvistus epäonnistui",
@@ -751,13 +751,13 @@
"passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.",
"passkeyCancelled": "Pääsyavaimen luominen peruutettu",
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui",
"signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen",
"signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen",
"inviteRequired": "Kutsukoodi vaaditaan",
"externalDidRequired": "Ulkoinen did:web vaaditaan",
"emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen",
"telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen",
"externalDidFormat": "Ulkoisen DID:n on alettava did:web:",
"discordRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen"
"discordRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen"
},
"identityType": "Identiteettityyppi",
"identityTypeHint": "Valitse, miten hajautettua identiteettiäsi hallitaan.",
+14 -14
View File
@@ -84,19 +84,17 @@
"emailAddress": "メールアドレス",
"emailPlaceholder": "you@example.com",
"discord": "Discord",
"discordId": "Discord ユーザー ID",
"discordIdPlaceholder": "Discord ユーザー ID",
"discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)",
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
"discordUsername": "Discord ユーザー",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": "の Discord ユーザー名は既に別のアカウントに関連付けられています。",
"telegram": "Telegram",
"telegramUsername": "Telegram ユーザー名",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signal": "Signal",
"signalNumber": "Signal 電話番号",
"signalNumberPlaceholder": "+81XXXXXXXXXX",
"signalNumberHint": "国番号を含めてください(例: 日本は +81)",
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
"signalUsername": "Signal ユーザー名",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
"notConfigured": "未設定",
"inviteCode": "招待コード",
"inviteCodePlaceholder": "招待コードを入力",
@@ -118,9 +116,9 @@
"externalDidRequired": "外部 did:web は必須です",
"externalDidFormat": "外部 DID は did:web: で始まる必要があります",
"emailRequired": "メール認証にはメールアドレスが必要です",
"discordIdRequired": "Discord 認証には Discord ID が必要です",
"discordUsernameRequired": "Discord 認証には Discord ユーザー名が必要です",
"telegramRequired": "Telegram 認証には Telegram ユーザー名が必要です",
"signalRequired": "Signal 認証には電話番号が必要です"
"signalRequired": "Signal 認証にはユーザー名が必要です"
}
},
"dashboard": {
@@ -433,11 +431,13 @@
"verifiedSuccess": "{channel} を確認しました",
"messageHistory": "メッセージ履歴",
"noMessages": "メッセージが見つかりません。",
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
"discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
"signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。",
"telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信",
"telegramOpenLink": "Telegram で確認する",
"discordStartBot": "Discordで @{botUsername} にDMして /start {handle} を送信",
"discordOpenLink": "Discordで認証",
"failedToLoad": "設定の読み込みに失敗しました",
"failedToSave": "設定の保存に失敗しました",
"failedToVerify": "確認に失敗しました",
@@ -751,13 +751,13 @@
"passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。",
"passkeyCancelled": "パスキーの作成がキャンセルされました",
"passkeyFailed": "パスキーの登録に失敗しました",
"signalRequired": "Signal認証には電話番号が必要です",
"signalRequired": "Signal認証にはユーザー名が必要です",
"inviteRequired": "招待コードが必要です",
"externalDidRequired": "外部did:webが必要です",
"emailRequired": "メール認証にはメールアドレスが必要です",
"telegramRequired": "Telegram認証にはTelegramユーザー名が必要です",
"externalDidFormat": "外部DIDはdid:web:で始まる必要があります",
"discordRequired": "Discord認証にはDiscord IDが必要です"
"discordRequired": "Discord認証にはDiscordユーザー名が必要です"
},
"identityType": "アイデンティティタイプ",
"identityTypeHint": "分散型アイデンティティの管理方法を選択してください。",
+14 -14
View File
@@ -84,19 +84,17 @@
"emailAddress": "이메일 주소",
"emailPlaceholder": "you@example.com",
"discord": "Discord",
"discordId": "Discord 사용자 ID",
"discordIdPlaceholder": "Discord 사용자 ID",
"discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)",
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
"discordUsername": "Discord 사용자",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": " Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
"telegram": "Telegram",
"telegramUsername": "Telegram 사용자 이름",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signal": "Signal",
"signalNumber": "Signal 전화번호",
"signalNumberPlaceholder": "+821012345678",
"signalNumberHint": "국가 코드 포함 (예: 한국 +82)",
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
"signalUsername": "Signal 사용자명",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
"notConfigured": "구성되지 않음",
"inviteCode": "초대 코드",
"inviteCodePlaceholder": "초대 코드 입력",
@@ -118,9 +116,9 @@
"externalDidRequired": "외부 did:web은 필수입니다",
"externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다",
"emailRequired": "이메일 인증에는 이메일이 필요합니다",
"discordIdRequired": "Discord 인증에는 Discord ID가 필요합니다",
"discordUsernameRequired": "Discord 인증에는 Discord 사용자명이 필요합니다",
"telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다",
"signalRequired": "Signal 인증에는 전화번호가 필요합니다"
"signalRequired": "Signal 인증에는 사용자명이 필요합니다"
}
},
"dashboard": {
@@ -433,11 +431,13 @@
"verifiedSuccess": "{channel} 인증 완료",
"messageHistory": "메시지 기록",
"noMessages": "메시지가 없습니다.",
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
"discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
"signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.",
"telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요",
"telegramOpenLink": "Telegram에서 인증하기",
"discordStartBot": "Discord에서 @{botUsername}에게 DM으로 /start {handle} 보내기",
"discordOpenLink": "Discord에서 인증",
"failedToLoad": "설정 로딩 실패",
"failedToSave": "설정 저장 실패",
"failedToVerify": "인증 실패",
@@ -751,13 +751,13 @@
"passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.",
"passkeyCancelled": "패스키 생성이 취소되었습니다",
"passkeyFailed": "패스키 등록에 실패했습니다",
"signalRequired": "Signal 인증에는 전화번호가 필요합니다",
"signalRequired": "Signal 인증에는 사용자명이 필요합니다",
"inviteRequired": "초대 코드가 필요합니다",
"externalDidRequired": "외부 did:web이 필요합니다",
"emailRequired": "이메일 인증에는 이메일이 필요합니다",
"telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다",
"externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다",
"discordRequired": "Discord 인증에는 Discord ID가 필요합니다"
"discordRequired": "Discord 인증에는 Discord 사용자명이 필요합니다"
},
"identityType": "아이덴티티 유형",
"identityTypeHint": "분산 아이덴티티 관리 방법을 선택하세요.",
+14 -14
View File
@@ -84,19 +84,17 @@
"emailAddress": "E-postadress",
"emailPlaceholder": "du@exempel.se",
"discord": "Discord",
"discordId": "Discord användar-ID",
"discordIdPlaceholder": "Ditt Discord användar-ID",
"discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)",
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
"discordUsername": "Discord-användarnamn",
"discordUsernamePlaceholder": "dittanvändarnamn",
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
"telegram": "Telegram",
"telegramUsername": "Telegram-användarnamn",
"telegramUsernamePlaceholder": "@dittanvändarnamn",
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
"signal": "Signal",
"signalNumber": "Signal-telefonnummer",
"signalNumberPlaceholder": "+46701234567",
"signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)",
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
"signalUsername": "Signal-användarnamn",
"signalUsernamePlaceholder": "användarnamn.01",
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
"notConfigured": "ej konfigurerad",
"inviteCode": "Inbjudningskod",
"inviteCodePlaceholder": "Ange din inbjudningskod",
@@ -118,9 +116,9 @@
"externalDidRequired": "Extern did:web krävs",
"externalDidFormat": "Extern DID måste börja med did:web:",
"emailRequired": "E-post krävs för e-postverifiering",
"discordIdRequired": "Discord-ID krävs för Discord-verifiering",
"discordUsernameRequired": "Discord-användarnamn krävs för Discord-verifiering",
"telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering",
"signalRequired": "Telefonnummer krävs för Signal-verifiering"
"signalRequired": "Signal-användarnamn krävs för Signal-verifiering"
}
},
"dashboard": {
@@ -433,11 +431,13 @@
"verifiedSuccess": "{channel} verifierad",
"messageHistory": "Meddelandehistorik",
"noMessages": "Inga meddelanden hittades.",
"discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.",
"discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.",
"telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.",
"signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.",
"signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.",
"telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt",
"telegramOpenLink": "Öppna Telegram för att verifiera",
"discordStartBot": "DM:a @{botUsername} på Discord och skicka /start {handle}",
"discordOpenLink": "Öppna Discord för att verifiera",
"failedToLoad": "Kunde inte ladda inställningar",
"failedToSave": "Kunde inte spara inställningar",
"failedToVerify": "Verifiering misslyckades",
@@ -751,13 +751,13 @@
"passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.",
"passkeyCancelled": "Nyckelskapande avbröts",
"passkeyFailed": "Nyckelregistrering misslyckades",
"signalRequired": "Telefonnummer krävs för Signal-verifiering",
"signalRequired": "Signal-användarnamn krävs för Signal-verifiering",
"inviteRequired": "Inbjudningskod krävs",
"externalDidRequired": "Extern did:web krävs",
"emailRequired": "E-post krävs för e-postverifiering",
"telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering",
"externalDidFormat": "Extern DID måste börja med did:web:",
"discordRequired": "Discord-ID krävs för Discord-verifiering"
"discordRequired": "Discord-användarnamn krävs för Discord-verifiering"
},
"identityType": "Identitetstyp",
"identityTypeHint": "Välj hur din decentraliserade identitet ska hanteras.",
+14 -14
View File
@@ -84,19 +84,17 @@
"emailAddress": "电子邮件地址",
"emailPlaceholder": "you@example.com",
"discord": "Discord",
"discordId": "Discord 用户 ID",
"discordIdPlaceholder": "您的 Discord 用户 ID",
"discordIdHint": "您的 Discord 数字用户 ID(开启开发者模式后可以复制)",
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
"discordUsername": "Discord 用户",
"discordUsernamePlaceholder": "yourusername",
"discordInUseWarning": " Discord 用户名已与另一个账户关联。",
"telegram": "Telegram",
"telegramUsername": "Telegram 用户名",
"telegramUsernamePlaceholder": "@yourusername",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signal": "Signal",
"signalNumber": "Signal 电话号码",
"signalNumberPlaceholder": "+1234567890",
"signalNumberHint": "包含国家代码(例如中国为 +86",
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
"signalUsername": "Signal 用户名",
"signalUsernamePlaceholder": "username.01",
"signalInUseWarning": "此 Signal 用户名已被其他账户使用。",
"notConfigured": "未配置",
"inviteCode": "邀请码",
"inviteCodePlaceholder": "输入您的邀请码",
@@ -118,9 +116,9 @@
"externalDidRequired": "请输入您的 did:web",
"externalDidFormat": "DID 必须以 did:web: 开头",
"emailRequired": "使用邮箱验证需要填写邮箱地址",
"discordIdRequired": "使用 Discord 验证需要填写 Discord ID",
"discordUsernameRequired": "使用 Discord 验证需要填写 Discord 用户名",
"telegramRequired": "使用 Telegram 验证需要填写用户名",
"signalRequired": "使用 Signal 验证需要填写电话号码"
"signalRequired": "使用 Signal 验证需要填写用户名"
}
},
"dashboard": {
@@ -433,11 +431,13 @@
"verifiedSuccess": "{channel} 验证成功",
"messageHistory": "消息历史",
"noMessages": "暂无消息记录",
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
"discordInUseWarning": "此 Discord 用户名已与另一个账户关联。",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
"signalInUseWarning": "此 Signal 用户名已与另一个账户关联。",
"telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}",
"telegramOpenLink": "打开 Telegram 验证",
"discordStartBot": "在 Discord 上私信 @{botUsername} 并发送 /start {handle}",
"discordOpenLink": "打开 Discord 验证",
"failedToLoad": "加载偏好设置失败",
"failedToSave": "保存偏好设置失败",
"failedToVerify": "验证失败",
@@ -774,9 +774,9 @@
"externalDidRequired": "请输入您的 did:web",
"externalDidFormat": "DID 必须以 did:web: 开头",
"emailRequired": "使用邮箱验证需要填写邮箱地址",
"discordRequired": "使用 Discord 验证需要填写 Discord ID",
"discordRequired": "使用 Discord 验证需要填写 Discord 用户名",
"telegramRequired": "使用 Telegram 验证需要填写用户名",
"signalRequired": "使用 Signal 验证需要填写电话号码",
"signalRequired": "使用 Signal 验证需要填写用户名",
"passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。",
"passkeyCancelled": "通行密钥创建已取消",
"passkeyFailed": "通行密钥注册失败"
+10 -11
View File
@@ -123,13 +123,13 @@
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
break
case 'discord':
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
break
case 'telegram':
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
break
case 'signal':
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired')
break
}
return null
@@ -389,16 +389,15 @@
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-id"
id="discord-username"
type="text"
bind:value={flow.info.discordId}
placeholder={$_('register.discordIdPlaceholder')}
bind:value={flow.info.discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
@@ -414,16 +413,16 @@
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalNumber}
placeholder={$_('register.signalNumberPlaceholder')}
bind:value={flow.info.signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalNumberHint')}</p>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
</div>
+14 -15
View File
@@ -30,9 +30,9 @@
let providerEmailOriginal = $state<string | null>(null)
let inviteCode = $state('')
let verificationChannel = $state('email')
let discordId = $state('')
let discordUsername = $state('')
let telegramUsername = $state('')
let signalNumber = $state('')
let signalUsername = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
@@ -189,11 +189,11 @@
case 'email':
return !!email.trim()
case 'discord':
return !!discordId.trim()
return !!discordUsername.trim()
case 'telegram':
return !!telegramUsername.trim()
case 'signal':
return !!signalNumber.trim()
return !!signalUsername.trim()
default:
return false
}
@@ -234,9 +234,9 @@
email: email || null,
invite_code: inviteCode || null,
verification_channel: verificationChannel,
discord_id: discordId || null,
discord_username: discordUsername || null,
telegram_username: telegramUsername || null,
signal_number: signalNumber || null,
signal_username: signalUsername || null,
did_type: didType,
did: didType === 'web-external' ? externalDid.trim() : null,
}),
@@ -379,16 +379,15 @@
</div>
{:else if verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-id"
id="discord-username"
type="text"
bind:value={discordId}
placeholder={$_('register.discordIdPlaceholder')}
bind:value={discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={submitting}
required
/>
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if verificationChannel === 'telegram'}
<div class="field">
@@ -404,16 +403,16 @@
</div>
{:else if verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={signalNumber}
placeholder={$_('register.signalNumberPlaceholder')}
bind:value={signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={submitting}
required
/>
<p class="hint">{$_('register.signalNumberHint')}</p>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
</div>
+10 -11
View File
@@ -136,13 +136,13 @@
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
break
case 'discord':
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired')
break
case 'telegram':
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
break
case 'signal':
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired')
break
}
return null
@@ -409,16 +409,15 @@
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-id"
id="discord-username"
type="text"
bind:value={flow.info.discordId}
placeholder={$_('register.discordIdPlaceholder')}
bind:value={flow.info.discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
@@ -434,16 +433,16 @@
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalNumber}
placeholder={$_('register.signalNumberPlaceholder')}
bind:value={flow.info.signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalNumberHint')}</p>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
+12 -13
View File
@@ -133,13 +133,13 @@
if (!info.email.trim()) return $_('register.validation.emailRequired')
break
case 'discord':
if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
if (!info.discordUsername?.trim()) return $_('register.validation.discordUsernameRequired')
break
case 'telegram':
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
break
case 'signal':
if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
if (!info.signalUsername?.trim()) return $_('register.validation.signalRequired')
break
}
return null
@@ -381,17 +381,16 @@
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-id"
id="discord-username"
type="text"
bind:value={flow.info.discordId}
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')}
placeholder={$_('register.discordIdPlaceholder')}
bind:value={flow.info.discordUsername}
onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordUsername ?? '')}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.discordIdHint')}</p>
{#if flow.state.discordInUse}
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
{/if}
@@ -414,17 +413,17 @@
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={flow.info.signalNumber}
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')}
placeholder={$_('register.signalNumberPlaceholder')}
bind:value={flow.info.signalUsername}
onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalUsername ?? '')}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={flow.state.submitting}
required
/>
<p class="hint">{$_('register.signalNumberHint')}</p>
<p class="hint">{$_('register.signalUsernameHint')}</p>
{#if flow.state.signalInUse}
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
{/if}
+14 -15
View File
@@ -40,9 +40,9 @@
let providerEmailOriginal = $state<string | null>(null)
let inviteCode = $state('')
let verificationChannel = $state('email')
let discordId = $state('')
let discordUsername = $state('')
let telegramUsername = $state('')
let signalNumber = $state('')
let signalUsername = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
@@ -204,11 +204,11 @@
case 'email':
return !!email.trim()
case 'discord':
return !!discordId.trim()
return !!discordUsername.trim()
case 'telegram':
return !!telegramUsername.trim()
case 'signal':
return !!signalNumber.trim()
return !!signalUsername.trim()
default:
return false
}
@@ -281,9 +281,9 @@
email: email || null,
invite_code: inviteCode || null,
verification_channel: verificationChannel,
discord_id: discordId || null,
discord_username: discordUsername || null,
telegram_username: telegramUsername || null,
signal_number: signalNumber || null,
signal_username: signalUsername || null,
did_type: didType,
did: didType === 'web-external' ? externalDid.trim() : null,
}),
@@ -452,16 +452,15 @@
</div>
{:else if verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<label for="discord-username">{$_('register.discordUsername')}</label>
<input
id="discord-id"
id="discord-username"
type="text"
bind:value={discordId}
placeholder={$_('register.discordIdPlaceholder')}
bind:value={discordUsername}
placeholder={$_('register.discordUsernamePlaceholder')}
disabled={submitting}
required
/>
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if verificationChannel === 'telegram'}
<div class="field">
@@ -477,16 +476,16 @@
</div>
{:else if verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<label for="signal-number">{$_('register.signalUsername')}</label>
<input
id="signal-number"
type="tel"
bind:value={signalNumber}
placeholder={$_('register.signalNumberPlaceholder')}
bind:value={signalUsername}
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={submitting}
required
/>
<p class="hint">{$_('register.signalNumberHint')}</p>
<p class="hint">{$_('register.signalUsernameHint')}</p>
</div>
{/if}
</div>
+24 -8
View File
@@ -33,6 +33,8 @@
let tokenFromUrl = $state(false)
let oauthRequestUri = $state<string | null>(null)
let telegramBotUsername = $state<string | undefined>(undefined)
let discordBotUsername = $state<string | undefined>(undefined)
let discordAppId = $state<string | undefined>(undefined)
const auth = $derived(getAuthState())
@@ -42,6 +44,8 @@
const session = $derived(getSession())
const isTelegram = $derived(pendingVerification?.channel === 'telegram')
const isDiscord = $derived(pendingVerification?.channel === 'discord')
const isBotVerified = $derived(isTelegram || isDiscord)
function parseQueryParams(): Record<string, string> {
return Object.fromEntries(new URLSearchParams(window.location.search))
@@ -102,10 +106,12 @@
}))
}
if (pendingVerification?.channel === 'telegram') {
if (pendingVerification?.channel === 'telegram' || pendingVerification?.channel === 'discord') {
try {
const serverInfo = await api.describeServer()
telegramBotUsername = serverInfo.telegramBotUsername
discordBotUsername = serverInfo.discordBotUsername
discordAppId = serverInfo.discordAppId
} catch {
}
}
@@ -121,10 +127,10 @@
let pollingVerification = false
$effect(() => {
if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) {
if (mode === 'signup' && pendingVerification && (isBotVerified || !verificationCode.trim())) {
const currentPending = pendingVerification
const interval = setInterval(async () => {
if (pollingVerification || (!isTelegram && verificationCode.trim())) return
if (pollingVerification || (!isBotVerified && verificationCode.trim())) return
pollingVerification = true
try {
const result = await api.checkChannelVerified(currentPending.did, currentPending.channel)
@@ -447,7 +453,7 @@
{#if isTelegram && telegramBotUsername}
{@const encodedHandle = pendingVerification.handle.replaceAll('.', '_')}
<div class="telegram-hint">
<div class="bot-hint">
<p>
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
</p>
@@ -456,6 +462,16 @@
</p>
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
</div>
{:else if isDiscord && discordAppId}
<div class="bot-hint">
<p>
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('comms.discordOpenLink')}</a>
</p>
<p class="manual-text">
{$_('comms.discordStartBot', { values: { botUsername: discordBotUsername ?? 'the bot', handle: pendingVerification.handle } })}
</p>
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
</div>
{:else}
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
<div class="field">
@@ -610,23 +626,23 @@
padding: var(--space-4) var(--space-8);
}
.telegram-hint {
.bot-hint {
padding: var(--space-4);
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.telegram-hint p {
.bot-hint p {
margin: 0;
}
.telegram-hint .manual-text {
.bot-hint .manual-text {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-top: var(--space-1);
}
.telegram-hint .waiting-text {
.bot-hint .waiting-text {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-top: var(--space-2);
+2 -2
View File
@@ -254,11 +254,11 @@ export const mockData = {
notificationPrefs: (overrides?: Record<string, unknown>) => ({
preferredChannel: "email",
email: "test@example.com",
discordId: null,
discordUsername: null,
discordVerified: false,
telegramUsername: null,
telegramVerified: false,
signalNumber: null,
signalUsername: null,
signalVerified: false,
...overrides,
}),
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE users ADD COLUMN discord_username TEXT;
UPDATE users SET discord_id = NULL, discord_verified = FALSE WHERE discord_id IS NOT NULL;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users RENAME COLUMN signal_number TO signal_username;