fix: telegram comms ux improvements

This commit is contained in:
lewis
2026-02-04 19:08:44 +00:00
committed by Tangled
parent dbc81a6416
commit b104dc938a
55 changed files with 3378 additions and 300 deletions
+2 -1
View File
@@ -96,7 +96,8 @@ BACKUP_STORAGE_PATH=/var/lib/tranquil/backups
# Discord notifications (via webhook)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Telegram notifications (via bot)
# TELEGRAM_BOT_TOKEN=your-bot-token
# TELEGRAM_BOT_TOKEN=bot-token
# TELEGRAM_WEBHOOK_SECRET=random-secret
# Signal notifications (via signal-cli)
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# SIGNAL_SENDER_NUMBER=+1234567890
@@ -44,7 +44,8 @@
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
"migration_verification",
"channel_verified"
]
}
}
@@ -32,7 +32,8 @@
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
"migration_verification",
"channel_verified"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\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_number\n FROM users WHERE id = $1",
"describe": {
"columns": [
{
@@ -34,6 +34,21 @@
"ordinal": 3,
"name": "preferred_locale",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "telegram_chat_id",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "discord_id",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "signal_number",
"type_info": "Text"
}
],
"parameters": {
@@ -45,8 +60,11 @@
true,
false,
false,
true,
true,
true,
true
]
},
"hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee"
"hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67"
}
@@ -49,7 +49,8 @@
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
"migration_verification",
"channel_verified"
]
}
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW()\n WHERE id = (\n SELECT id FROM users\n WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND deactivated_at IS NULL\n LIMIT 1\n ) RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "9d17e25776c67f96022010840c1d04bdd542b5bcc511b1778bab0159a4566e9c"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT telegram_chat_id FROM users WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "telegram_chat_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "a71a76724a3a7406e30a998c03d52554efaf649bb10b35b6e5d64ac59a479023"
}
@@ -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 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_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
"describe": {
"columns": [
{
@@ -47,11 +47,16 @@
},
{
"ordinal": 6,
"name": "telegram_chat_id",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "signal_number",
"type_info": "Text"
},
{
"ordinal": 7,
"ordinal": 8,
"name": "signal_verified",
"type_info": "Bool"
}
@@ -69,8 +74,9 @@
true,
false,
true,
true,
false
]
},
"hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43"
"hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740"
}
@@ -41,7 +41,8 @@
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
"migration_verification",
"channel_verified"
]
}
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET\n telegram_username = $1,\n telegram_verified = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_verified ELSE FALSE END,\n telegram_chat_id = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_chat_id ELSE NULL END,\n updated_at = NOW()\n WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "d7f32a31b4edeebbbf54a1878dfa05f2fcb3c57fe063be4e6a78fe5e74fb9dc3"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1",
"query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, telegram_chat_id = NULL, updated_at = NOW() WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
@@ -10,5 +10,5 @@
},
"nullable": []
},
"hash": "c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d"
"hash": "e49cbb17eb279cd12874fc3e7f5cbdbf0072c42127e225b79c0fe90de78670bd"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "fc1ef8c0979206bf95cccee7c39614f7c882860c39cfe94e411cf6e1d55b63f4"
}
+14
View File
@@ -31,6 +31,8 @@ pub struct NotificationStrings {
pub legacy_login_body: &'static str,
pub migration_verification_subject: &'static str,
pub migration_verification_body: &'static str,
pub channel_verified_subject: &'static str,
pub channel_verified_body: &'static str,
}
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
@@ -66,6 +68,8 @@ static STRINGS_EN: NotificationStrings = NotificationStrings {
legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}",
migration_verification_subject: "Verify your email - {hostname}",
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}.",
};
static STRINGS_ZH: NotificationStrings = NotificationStrings {
@@ -90,6 +94,8 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings {
legacy_login_body: "您好 @{handle}\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}",
migration_verification_subject: "验证您的邮箱 - {hostname}",
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} 上的通知渠道。",
};
static STRINGS_JA: NotificationStrings = NotificationStrings {
@@ -114,6 +120,8 @@ static STRINGS_JA: NotificationStrings = NotificationStrings {
legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}",
migration_verification_subject: "メールアドレスの認証 - {hostname}",
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} の通知チャンネルとして認証されました。",
};
static STRINGS_KO: NotificationStrings = NotificationStrings {
@@ -138,6 +146,8 @@ static STRINGS_KO: NotificationStrings = NotificationStrings {
legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림",
migration_verification_subject: "이메일 인증 - {hostname}",
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}의 알림 채널로 인증되었습니다.",
};
static STRINGS_SV: NotificationStrings = NotificationStrings {
@@ -162,6 +172,8 @@ static STRINGS_SV: NotificationStrings = NotificationStrings {
legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}",
migration_verification_subject: "Verifiera din e-post - {hostname}",
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}.",
};
static STRINGS_FI: NotificationStrings = NotificationStrings {
@@ -186,6 +198,8 @@ static STRINGS_FI: NotificationStrings = NotificationStrings {
legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}",
migration_verification_subject: "Vahvista sähköpostisi - {hostname}",
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}.",
};
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+64 -3
View File
@@ -67,6 +67,12 @@ pub fn mime_encode_header(value: &str) -> String {
}
}
pub fn escape_html(text: &str) -> String {
text.replace('&', "&")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
pub fn is_valid_phone_number(number: &str) -> bool {
if number.len() < 2 || number.len() > 20 {
return false;
@@ -244,6 +250,60 @@ impl TelegramSender {
let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?;
Some(Self::new(bot_token))
}
pub async fn set_webhook(
&self,
webhook_url: &str,
secret_token: Option<&str>,
) -> Result<(), SendError> {
let url = format!("https://api.telegram.org/bot{}/setWebhook", self.bot_token);
let mut payload = json!({ "url": webhook_url });
if let Some(secret) = secret_token {
payload["secret_token"] = json!(secret);
}
let response = self
.http_client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| SendError::ExternalService(format!("setWebhook request failed: {}", e)))?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"setWebhook returned error: {}",
body
)));
}
Ok(())
}
pub async fn resolve_bot_username(&self) -> Result<String, SendError> {
let url = format!("https://api.telegram.org/bot{}/getMe", self.bot_token);
let response = self.http_client.get(&url).send().await.map_err(|e| {
SendError::ExternalService(format!("Telegram getMe request failed: {}", e))
})?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SendError::ExternalService(format!(
"Telegram getMe returned error: {}",
body
)));
}
let data: serde_json::Value = response.json().await.map_err(|e| {
SendError::ExternalService(format!("Failed to parse getMe response: {}", e))
})?;
data.get("result")
.and_then(|r| r.get("username"))
.and_then(|u| u.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
SendError::ExternalService("getMe response missing username".to_string())
})
}
}
#[async_trait]
@@ -254,13 +314,14 @@ impl CommsSender for TelegramSender {
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let chat_id = &notification.recipient;
let subject = notification.subject.as_deref().unwrap_or("Notification");
let text = format!("*{}*\n\n{}", subject, notification.body);
let subject = escape_html(notification.subject.as_deref().unwrap_or("Notification"));
let body = escape_html(&notification.body);
let text = format!("<b>{}</b>\n\n{}", subject, body);
let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
let payload = json!({
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown"
"parse_mode": "HTML"
});
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
+1
View File
@@ -78,6 +78,7 @@ pub enum CommsType {
LegacyLoginAlert,
MigrationVerification,
ChannelVerification,
ChannelVerified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
+25
View File
@@ -196,6 +196,12 @@ pub trait UserRepository: Send + Sync {
identifier: &str,
) -> Result<Option<bool>, DbError>;
async fn check_channel_verified_by_did(
&self,
did: &Did,
channel: CommsChannel,
) -> Result<Option<bool>, DbError>;
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError>;
async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>;
@@ -222,6 +228,21 @@ pub trait UserRepository: Send + Sync {
async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>;
async fn set_unverified_telegram(
&self,
user_id: Uuid,
telegram_username: &str,
) -> Result<(), DbError>;
async fn store_telegram_chat_id(
&self,
telegram_username: &str,
chat_id: i64,
handle: Option<&str>,
) -> Result<Option<Uuid>, DbError>;
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>;
async fn get_verification_info(
&self,
did: &Did,
@@ -575,6 +596,9 @@ pub struct UserCommsPrefs {
pub handle: Handle,
pub preferred_channel: CommsChannel,
pub preferred_locale: Option<String>,
pub telegram_chat_id: Option<i64>,
pub discord_id: Option<String>,
pub signal_number: Option<String>,
}
#[derive(Debug, Clone)]
@@ -635,6 +659,7 @@ pub struct NotificationPrefs {
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_verified: bool,
}
+96 -2
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
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number
FROM users WHERE id = $1"#,
user_id
)
@@ -323,6 +323,9 @@ impl UserRepository for PostgresUserRepository {
handle: Handle::from(r.handle),
preferred_channel: r.preferred_channel,
preferred_locale: r.preferred_locale,
telegram_chat_id: r.telegram_chat_id,
discord_id: r.discord_id,
signal_number: r.signal_number,
}))
}
@@ -561,6 +564,33 @@ impl UserRepository for PostgresUserRepository {
Ok(row)
}
async fn check_channel_verified_by_did(
&self,
did: &Did,
channel: CommsChannel,
) -> Result<Option<bool>, DbError> {
let row = sqlx::query!(
r#"SELECT
email_verified,
discord_verified,
telegram_verified,
signal_verified
FROM users
WHERE did = $1"#,
did.as_str()
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| match channel {
CommsChannel::Email => r.email_verified,
CommsChannel::Discord => r.discord_verified,
CommsChannel::Telegram => r.telegram_verified,
CommsChannel::Signal => r.signal_verified,
}))
}
async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError> {
let result = sqlx::query!(
"UPDATE users SET email = $1 WHERE did = $2",
@@ -609,6 +639,7 @@ impl UserRepository for PostgresUserRepository {
discord_verified,
telegram_username,
telegram_verified,
telegram_chat_id,
signal_number,
signal_verified
FROM users WHERE did = $1"#,
@@ -624,6 +655,7 @@ impl UserRepository for PostgresUserRepository {
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_verified: r.signal_verified,
}))
@@ -676,7 +708,7 @@ impl UserRepository for PostgresUserRepository {
async fn clear_telegram(&self, user_id: Uuid) -> Result<(), DbError> {
sqlx::query!(
"UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1",
"UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, telegram_chat_id = NULL, updated_at = NOW() WHERE id = $1",
user_id
)
.execute(&self.pool)
@@ -3136,4 +3168,66 @@ impl UserRepository for PostgresUserRepository {
passkeys_deleted: deleted.rows_affected(),
})
}
async fn set_unverified_telegram(
&self,
user_id: Uuid,
telegram_username: &str,
) -> Result<(), DbError> {
sqlx::query!(
r#"UPDATE users SET
telegram_username = $1,
telegram_verified = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_verified ELSE FALSE END,
telegram_chat_id = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_chat_id ELSE NULL END,
updated_at = NOW()
WHERE id = $2"#,
telegram_username,
user_id
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn store_telegram_chat_id(
&self,
telegram_username: &str,
chat_id: i64,
handle: Option<&str>,
) -> Result<Option<Uuid>, DbError> {
let result = match handle {
Some(h) => sqlx::query_scalar!(
"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id",
telegram_username,
chat_id,
h
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?,
None => sqlx::query_scalar!(
r#"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW()
WHERE id = (
SELECT id FROM users
WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND deactivated_at IS NULL
LIMIT 1
) RETURNING id"#,
telegram_username,
chat_id
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?,
};
Ok(result)
}
async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError> {
let row = sqlx::query_scalar!("SELECT telegram_chat_id FROM users WHERE id = $1", user_id)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.flatten())
}
}
@@ -208,7 +208,15 @@ pub async fn create_account(
_ => return ApiError::MissingDiscordId.into_response(),
},
"telegram" => match &input.telegram_username {
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().trim_start_matches('@');
if !crate::api::validation::is_valid_telegram_username(clean) {
return ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
).into_response();
}
clean.to_string()
}
_ => return ApiError::MissingTelegramUsername.into_response(),
},
"signal" => match &input.signal_number {
@@ -634,7 +642,7 @@ pub async fn create_account(
telegram_username: input
.telegram_username
.as_deref()
.map(|s| s.trim())
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(String::from),
signal_number: input
+1
View File
@@ -12,6 +12,7 @@ pub mod proxy_client;
pub mod repo;
pub mod responses;
pub mod server;
pub mod telegram_webhook;
pub mod temp;
pub mod validation;
pub mod verification;
@@ -165,15 +165,37 @@ pub async fn request_channel_verification(
"signal" => tranquil_db_traits::CommsChannel::Signal,
_ => return Err("Invalid channel".to_string()),
};
let hostname = pds_hostname();
let encoded_token = urlencoding::encode(&formatted_token);
let encoded_identifier = urlencoding::encode(identifier);
let verify_link = format!(
"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 recipient = match comms_channel {
tranquil_db_traits::CommsChannel::Telegram => state
.user_repo
.get_telegram_chat_id(user_id)
.await
.ok()
.flatten()
.map(|id| id.to_string())
.unwrap_or_else(|| identifier.to_string()),
_ => identifier.to_string(),
};
state
.infra_repo
.enqueue_comms(
Some(user_id),
comms_channel,
tranquil_db_traits::CommsType::ChannelVerification,
identifier,
&recipient,
Some("Verify your channel"),
&format!("Your verification code is: {}", formatted_token),
&body,
Some(json!({"code": formatted_token})),
)
.await
@@ -199,6 +221,28 @@ pub async fn update_notification_prefs(
let handle = user_row.handle;
let current_email = user_row.email;
let current_prefs = state
.user_repo
.get_notification_prefs(&auth.did)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?
.ok_or(ApiError::AccountNotFound)?;
let effective_channel = input
.preferred_channel
.as_deref()
.map(|ch| match ch {
"email" => Ok(CommsChannel::Email),
"discord" => Ok(CommsChannel::Discord),
"telegram" => Ok(CommsChannel::Telegram),
"signal" => Ok(CommsChannel::Signal),
_ => Err(ApiError::InvalidRequest(
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
)),
})
.transpose()?
.unwrap_or(current_prefs.preferred_channel);
let mut verification_required: Vec<String> = Vec::new();
if let Some(ref channel_str) = input.preferred_channel {
@@ -249,6 +293,11 @@ pub async fn update_notification_prefs(
if let Some(ref discord_id) = input.discord_id {
if discord_id.is_empty() {
if effective_channel == CommsChannel::Discord {
return Err(ApiError::InvalidRequest(
"Cannot remove Discord while it is the preferred notification channel".into(),
));
}
state
.user_repo
.clear_discord(user_id)
@@ -267,30 +316,40 @@ pub async fn update_notification_prefs(
if let Some(ref telegram) = input.telegram_username {
let telegram_clean = telegram.trim_start_matches('@');
if telegram_clean.is_empty() {
if effective_channel == CommsChannel::Telegram {
return Err(ApiError::InvalidRequest(
"Cannot remove Telegram while it is the preferred notification channel".into(),
));
}
state
.user_repo
.clear_telegram(user_id)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
info!(did = %auth.did, "Cleared Telegram username");
} else if !crate::api::validation::is_valid_telegram_username(telegram_clean) {
return Err(ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore"
.into(),
));
} else {
request_channel_verification(
&state,
user_id,
&auth.did,
"telegram",
telegram_clean,
None,
)
.await
.map_err(|e| ApiError::InternalError(Some(e)))?;
state
.user_repo
.set_unverified_telegram(user_id, telegram_clean)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
verification_required.push("telegram".to_string());
info!(did = %auth.did, "Requested Telegram verification");
info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified Telegram username");
}
}
if let Some(ref signal) = input.signal_number {
if signal.is_empty() {
if effective_channel == CommsChannel::Signal {
return Err(ApiError::InvalidRequest(
"Cannot remove Signal while it is the preferred notification channel".into(),
));
}
state
.user_repo
.clear_signal(user_id)
@@ -419,6 +419,45 @@ pub async fn check_email_verified(
}
}
#[derive(Deserialize)]
pub struct CheckChannelVerifiedInput {
pub did: String,
pub channel: String,
}
pub async fn check_channel_verified(
State(state): State<AppState>,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckChannelVerifiedInput>,
) -> Response {
let channel = match input.channel.to_lowercase().as_str() {
"email" => CommsChannel::Email,
"discord" => CommsChannel::Discord,
"telegram" => CommsChannel::Telegram,
"signal" => CommsChannel::Signal,
_ => {
return ApiError::InvalidRequest("invalid channel".into()).into_response();
}
};
let did = match crate::Did::new(input.did) {
Ok(d) => d,
Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(),
};
match state
.user_repo
.check_channel_verified_by_did(&did, channel)
.await
{
Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error checking channel verified: {:?}", e);
ApiError::InternalError(None).into_response()
}
}
}
#[derive(Deserialize)]
pub struct AuthorizeEmailUpdateQuery {
pub token: String,
+7 -3
View File
@@ -1,5 +1,5 @@
use crate::state::AppState;
use crate::util::pds_hostname;
use crate::util::{pds_hostname, telegram_bot_username};
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json;
@@ -52,7 +52,7 @@ pub async fn describe_server() -> impl IntoResponse {
if let Some(email) = contact_email {
contact.insert("email".to_string(), json!(email));
}
Json(json!({
let mut response = json!({
"availableUserDomains": domains,
"inviteCodeRequired": invite_code_required,
"did": format!("did:web:{}", pds_hostname),
@@ -61,7 +61,11 @@ pub async fn describe_server() -> impl IntoResponse {
"version": env!("CARGO_PKG_VERSION"),
"availableCommsChannels": get_available_comms_channels(),
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
}))
});
if let Some(bot_username) = telegram_bot_username() {
response["telegramBotUsername"] = json!(bot_username);
}
Json(response)
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
match state.infra_repo.health_check().await {
+1 -1
View File
@@ -23,7 +23,7 @@ pub use account_status::{
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use email::{
authorize_email_update, check_comms_channel_in_use, check_email_in_use,
authorize_email_update, check_channel_verified, check_comms_channel_in_use, check_email_in_use,
check_email_update_status, check_email_verified, confirm_email, request_email_update,
update_email,
};
@@ -172,7 +172,15 @@ pub async fn create_passkey_account(
_ => return ApiError::MissingDiscordId.into_response(),
},
"telegram" => match &input.telegram_username {
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().trim_start_matches('@');
if !crate::api::validation::is_valid_telegram_username(clean) {
return ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
).into_response();
}
clean.to_string()
}
_ => return ApiError::MissingTelegramUsername.into_response(),
},
"signal" => match &input.signal_number {
@@ -410,7 +418,7 @@ pub async fn create_passkey_account(
telegram_username: input
.telegram_username
.as_deref()
.map(|s| s.trim())
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(String::from),
signal_number: input
@@ -1,5 +1,7 @@
use crate::api::error::{ApiError, DbResultExt};
use crate::comms::comms_repo;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
@@ -161,6 +163,20 @@ async fn handle_channel_update(
info!(did = %did, channel = %channel, "Channel verified successfully");
let recipient = resolve_verified_recipient(state, user_id, channel, identifier).await;
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
channel,
&recipient,
pds_hostname(),
)
.await
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string().into(),
@@ -169,11 +185,30 @@ async fn handle_channel_update(
}))
}
async fn resolve_verified_recipient(
state: &AppState,
user_id: uuid::Uuid,
channel: &str,
identifier: &str,
) -> String {
match channel {
"telegram" => state
.user_repo
.get_telegram_chat_id(user_id)
.await
.ok()
.flatten()
.map(|id| id.to_string())
.unwrap_or_else(|| identifier.to_string()),
_ => identifier.to_string(),
}
}
async fn handle_signup_verification(
state: &AppState,
did: &str,
channel: &str,
_identifier: &str,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let did_typed: Did = did
.parse()
@@ -232,6 +267,20 @@ async fn handle_signup_verification(
info!(did = %did, channel = %channel, "Signup verified successfully");
let recipient = resolve_verified_recipient(state, user.id, channel, identifier).await;
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user.id,
channel,
&recipient,
pds_hostname(),
)
.await
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string().into(),
@@ -0,0 +1,172 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::Deserialize;
use tracing::{debug, info, warn};
use crate::comms::comms_repo;
use crate::state::AppState;
use crate::util::pds_hostname;
#[derive(Deserialize)]
struct TelegramUpdate {
message: Option<TelegramMessage>,
}
#[derive(Deserialize)]
struct TelegramMessage {
text: Option<String>,
from: Option<TelegramUser>,
}
#[derive(Deserialize)]
struct TelegramUser {
id: i64,
username: Option<String>,
}
pub async fn handle_telegram_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: String,
) -> impl IntoResponse {
let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
Ok(s) => s,
Err(_) => {
warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured");
return StatusCode::FORBIDDEN;
}
};
let provided = headers
.get("x-telegram-bot-api-secret-token")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if provided != expected_secret {
warn!("Telegram webhook received with invalid secret token");
return StatusCode::UNAUTHORIZED;
}
let update: TelegramUpdate = match serde_json::from_str(&body) {
Ok(u) => u,
Err(_) => return StatusCode::OK,
};
if let Some(message) = update.message {
let is_start = message
.text
.as_deref()
.is_some_and(|t| t.starts_with("/start"));
if is_start
&& let Some(from) = message.from
&& let Some(username) = from.username
{
let handle = parse_start_handle(message.text.as_deref());
debug!(
telegram_username = %username,
chat_id = from.id,
handle = ?handle,
"Received /start from Telegram user"
);
match state
.user_repo
.store_telegram_chat_id(&username, from.id, handle.as_deref())
.await
{
Ok(Some(user_id)) => {
info!(
telegram_username = %username,
chat_id = from.id,
"Verified Telegram user and stored chat_id"
);
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
"telegram",
&from.id.to_string(),
pds_hostname(),
)
.await
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
}
Ok(None) => {
debug!(
telegram_username = %username,
"No matching user found for Telegram username"
);
}
Err(e) => {
warn!(
telegram_username = %username,
error = %e,
"Failed to store Telegram chat_id"
);
}
}
}
}
StatusCode::OK
}
fn parse_start_handle(text: Option<&str>) -> Option<String> {
text.and_then(|t| t.strip_prefix("/start "))
.map(|payload| payload.trim())
.filter(|p| !p.is_empty())
.map(|payload| payload.replace('_', "."))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deep_link_underscores_decoded_to_dots() {
assert_eq!(
parse_start_handle(Some("/start lewis_buttercup_wizardry_systems")),
Some("lewis.buttercup.wizardry.systems".to_string()),
);
}
#[test]
fn manual_handle_with_dots_passes_through() {
assert_eq!(
parse_start_handle(Some("/start lewis.buttercup.wizardry.systems")),
Some("lewis.buttercup.wizardry.systems".to_string()),
);
}
#[test]
fn bare_start_returns_none() {
assert_eq!(parse_start_handle(Some("/start")), None);
}
#[test]
fn start_with_trailing_space_returns_none() {
assert_eq!(parse_start_handle(Some("/start ")), None);
}
#[test]
fn none_text_returns_none() {
assert_eq!(parse_start_handle(None), None);
}
#[test]
fn non_start_command_returns_none() {
assert_eq!(parse_start_handle(Some("/help")), None);
}
#[test]
fn payload_with_extra_whitespace_trimmed() {
assert_eq!(
parse_start_handle(Some("/start alice_example_com ")),
Some("alice.example.com".to_string()),
);
}
}
@@ -349,6 +349,14 @@ 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
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
+110 -21
View File
@@ -10,7 +10,7 @@ use tranquil_comms::{
CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message,
get_strings,
};
use tranquil_db_traits::{InfraRepository, QueuedComms, UserRepository};
use tranquil_db_traits::{InfraRepository, QueuedComms, UserCommsPrefs, UserRepository};
use uuid::Uuid;
pub struct CommsService {
@@ -75,6 +75,7 @@ impl CommsService {
tranquil_db_traits::CommsType::MigrationVerification
}
CommsType::ChannelVerification => tranquil_db_traits::CommsType::ChannelVerification,
CommsType::ChannelVerified => tranquil_db_traits::CommsType::ChannelVerified,
};
let id = self
.infra_repo
@@ -170,6 +171,7 @@ impl CommsService {
tranquil_db_traits::CommsType::ChannelVerification => {
CommsType::ChannelVerification
}
tranquil_db_traits::CommsType::ChannelVerified => CommsType::ChannelVerified,
},
status: match item.status {
tranquil_db_traits::CommsStatus::Pending => CommsStatus::Pending,
@@ -247,6 +249,49 @@ pub fn channel_display_name(channel: CommsChannel) -> &'static str {
}
}
struct ResolvedRecipient {
channel: tranquil_db_traits::CommsChannel,
recipient: String,
}
fn resolve_recipient(
prefs: &UserCommsPrefs,
channel: tranquil_db_traits::CommsChannel,
) -> ResolvedRecipient {
let email_fallback = || ResolvedRecipient {
channel: tranquil_db_traits::CommsChannel::Email,
recipient: prefs.email.clone().unwrap_or_default(),
};
match channel {
tranquil_db_traits::CommsChannel::Email => email_fallback(),
tranquil_db_traits::CommsChannel::Telegram => prefs
.telegram_chat_id
.map(|id| ResolvedRecipient {
channel,
recipient: id.to_string(),
})
.unwrap_or_else(email_fallback),
tranquil_db_traits::CommsChannel::Discord => prefs
.discord_id
.as_ref()
.filter(|id| !id.is_empty())
.map(|id| ResolvedRecipient {
channel,
recipient: id.clone(),
})
.unwrap_or_else(email_fallback),
tranquil_db_traits::CommsChannel::Signal => prefs
.signal_number
.as_ref()
.filter(|n| !n.is_empty())
.map(|n| ResolvedRecipient {
channel,
recipient: n.clone(),
})
.unwrap_or_else(email_fallback),
}
}
fn channel_from_str(s: &str) -> tranquil_db_traits::CommsChannel {
match s {
"discord" => tranquil_db_traits::CommsChannel::Discord,
@@ -276,13 +321,13 @@ pub mod repo {
&[("hostname", hostname), ("handle", &prefs.handle)],
);
let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]);
let channel = prefs.preferred_channel;
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
infra_repo
.enqueue_comms(
Some(user_id),
channel,
resolved.channel,
CommsType::Welcome,
&prefs.email.unwrap_or_default(),
&resolved.recipient,
Some(&subject),
&body,
None,
@@ -307,13 +352,13 @@ pub mod repo {
&[("handle", &prefs.handle), ("code", code)],
);
let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]);
let channel = prefs.preferred_channel;
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
infra_repo
.enqueue_comms(
Some(user_id),
channel,
resolved.channel,
CommsType::PasswordReset,
&prefs.email.unwrap_or_default(),
&resolved.recipient,
Some(&subject),
&body,
None,
@@ -471,13 +516,13 @@ pub mod repo {
&[("handle", &prefs.handle), ("code", code)],
);
let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]);
let channel = prefs.preferred_channel;
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
infra_repo
.enqueue_comms(
Some(user_id),
channel,
resolved.channel,
CommsType::AccountDeletion,
&prefs.email.unwrap_or_default(),
&resolved.recipient,
Some(&subject),
&body,
None,
@@ -502,13 +547,13 @@ pub mod repo {
&[("handle", &prefs.handle), ("token", token)],
);
let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]);
let channel = prefs.preferred_channel;
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
infra_repo
.enqueue_comms(
Some(user_id),
channel,
resolved.channel,
CommsType::PlcOperation,
&prefs.email.unwrap_or_default(),
&resolved.recipient,
Some(&subject),
&body,
None,
@@ -533,13 +578,13 @@ pub mod repo {
&[("handle", &prefs.handle), ("url", recovery_url)],
);
let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]);
let channel = prefs.preferred_channel;
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
infra_repo
.enqueue_comms(
Some(user_id),
channel,
resolved.channel,
CommsType::PasskeyRecovery,
&prefs.email.unwrap_or_default(),
&resolved.recipient,
Some(&subject),
&body,
None,
@@ -663,13 +708,13 @@ pub mod repo {
&[("handle", &prefs.handle), ("code", code)],
);
let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]);
let channel = prefs.preferred_channel;
let resolved = resolve_recipient(&prefs, prefs.preferred_channel);
infra_repo
.enqueue_comms(
Some(user_id),
channel,
resolved.channel,
CommsType::TwoFactorCode,
&prefs.email.unwrap_or_default(),
&resolved.recipient,
Some(&subject),
&body,
None,
@@ -703,12 +748,56 @@ pub mod repo {
],
);
let subject = format_message(strings.legacy_login_subject, &[("hostname", hostname)]);
let resolved = resolve_recipient(&prefs, channel);
infra_repo
.enqueue_comms(
Some(user_id),
channel,
resolved.channel,
CommsType::LegacyLoginAlert,
&prefs.email.unwrap_or_default(),
&resolved.recipient,
Some(&subject),
&body,
None,
)
.await
}
pub async fn enqueue_channel_verified(
user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository,
user_id: Uuid,
channel_name: &str,
recipient: &str,
hostname: &str,
) -> Result<Uuid, DbError> {
let prefs = user_repo
.get_comms_prefs(user_id)
.await?
.ok_or(DbError::NotFound)?;
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
let display_name = match channel_name {
"email" => "Email",
"discord" => "Discord",
"telegram" => "Telegram",
"signal" => "Signal",
other => other,
};
let body = format_message(
strings.channel_verified_body,
&[
("handle", &prefs.handle),
("channel", display_name),
("hostname", hostname),
],
);
let subject = format_message(strings.channel_verified_subject, &[("hostname", hostname)]);
let comms_channel = channel_from_str(channel_name);
infra_repo
.enqueue_comms(
Some(user_id),
comms_channel,
CommsType::ChannelVerified,
recipient,
Some(&subject),
&body,
None,
+8
View File
@@ -281,6 +281,10 @@ pub fn app(state: AppState) -> Router {
"/_checkEmailVerified",
post(api::server::check_email_verified),
)
.route(
"/_checkChannelVerified",
post(api::server::check_channel_verified),
)
.route(
"/com.atproto.server.confirmEmail",
post(api::server::confirm_email),
@@ -639,6 +643,10 @@ pub fn app(state: AppState) -> Router {
.route("/robots.txt", get(api::server::robots_txt))
.route("/logo", get(api::server::get_logo))
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
.route(
"/webhook/telegram",
post(api::telegram_webhook::handle_telegram_webhook),
)
.layer(DefaultBodyLimit::max(util::get_max_blob_size()))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
+27
View File
@@ -78,7 +78,34 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
}
if let Some(telegram_sender) = TelegramSender::from_env() {
let secret_token = match std::env::var("TELEGRAM_WEBHOOK_SECRET") {
Ok(s) => s,
Err(_) => {
return Err(
"TELEGRAM_BOT_TOKEN is set but TELEGRAM_WEBHOOK_SECRET is missing. Both are required for secure Telegram integration.".into()
);
}
};
info!("Telegram comms enabled");
match telegram_sender.resolve_bot_username().await {
Ok(username) => {
info!(bot_username = %username, "Resolved Telegram bot username");
tranquil_pds::util::set_telegram_bot_username(username);
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let webhook_url = format!("https://{}/webhook/telegram", hostname);
match telegram_sender
.set_webhook(&webhook_url, Some(&secret_token))
.await
{
Ok(()) => info!(url = %webhook_url, "Telegram webhook registered"),
Err(e) => warn!("Failed to register Telegram webhook: {}", e),
}
}
Err(e) => {
warn!("Failed to resolve Telegram bot username: {}", e);
}
}
comms_service = comms_service.register_sender(telegram_sender);
}
+12 -4
View File
@@ -119,8 +119,8 @@ pub async fn sso_initiate(
let auth_header = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let extracted = extract_auth_token_from_header(auth_header)
.ok_or(ApiError::SsoNotAuthenticated)?;
let extracted =
extract_auth_token_from_header(auth_header).ok_or(ApiError::SsoNotAuthenticated)?;
let auth_user = validate_bearer_token_cached(
state.user_repo.as_ref(),
state.cache.as_ref(),
@@ -899,7 +899,15 @@ pub async fn complete_registration(
_ => return Err(ApiError::MissingDiscordId),
},
"telegram" => match &input.telegram_username {
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().trim_start_matches('@');
if !crate::api::validation::is_valid_telegram_username(clean) {
return Err(ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
));
}
clean.to_string()
}
_ => return Err(ApiError::MissingTelegramUsername),
},
"signal" => match &input.signal_number {
@@ -1104,7 +1112,7 @@ pub async fn complete_registration(
telegram_username: input
.telegram_username
.clone()
.map(|s| s.trim().to_string())
.map(|s| s.trim().trim_start_matches('@').to_string())
.filter(|s| !s.is_empty()),
signal_number: input
.signal_number
+9
View File
@@ -14,6 +14,7 @@ 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 TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new();
pub fn get_max_blob_size() -> usize {
*MAX_BLOB_SIZE.get_or_init(|| {
@@ -104,6 +105,14 @@ pub fn pds_hostname_without_port() -> &'static str {
})
}
pub fn set_telegram_bot_username(username: String) {
TELEGRAM_BOT_USERNAME.set(username).ok();
}
pub fn telegram_bot_username() -> Option<&'static str> {
TELEGRAM_BOT_USERNAME.get().map(|s| s.as_str())
}
pub fn pds_public_url() -> String {
format!("https://{}", pds_hostname())
}
+228
View File
@@ -4,6 +4,87 @@ use serde_json::{Value, json};
pub use crate::common::*;
#[allow(dead_code)]
pub async fn paginate_records(
client: &reqwest::Client,
base: &str,
jwt: &str,
did: &str,
collection: &str,
limit: usize,
) -> Vec<Value> {
paginate_records_inner(client, base, jwt, did, collection, limit, None, Vec::new()).await
}
#[allow(clippy::too_many_arguments)]
async fn paginate_records_inner(
client: &reqwest::Client,
base: &str,
jwt: &str,
did: &str,
collection: &str,
limit: usize,
cursor: Option<String>,
mut acc: Vec<Value>,
) -> Vec<Value> {
let limit_str = limit.to_string();
let mut query: Vec<(&str, &str)> = vec![
("repo", did),
("collection", collection),
("limit", &limit_str),
];
if let Some(ref c) = cursor {
query.push(("cursor", c.as_str()));
}
let res = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base))
.bearer_auth(jwt)
.query(&query)
.send()
.await;
let Ok(response) = res else { return acc };
let Ok(body) = response.json::<Value>().await else {
return acc;
};
let Some(records) = body["records"].as_array() else {
return acc;
};
acc.extend(records.iter().cloned());
match body["cursor"].as_str() {
Some(next) => {
Box::pin(paginate_records_inner(
client,
base,
jwt,
did,
collection,
limit,
Some(next.to_string()),
acc,
))
.await
}
None => acc,
}
}
#[allow(dead_code)]
pub async fn count_records(
client: &reqwest::Client,
base: &str,
jwt: &str,
did: &str,
collection: &str,
) -> usize {
paginate_records(client, base, jwt, did, collection, 100)
.await
.len()
}
fn unique_id() -> String {
uuid::Uuid::new_v4().simple().to_string()[..12].to_string()
}
@@ -246,3 +327,150 @@ pub async fn set_account_deactivated(did: &str, deactivated: bool) {
.await
.expect("Failed to update deactivated_at");
}
#[allow(dead_code)]
pub fn make_cid(data: &[u8]) -> cid::Cid {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(data);
let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
cid::Cid::new_v1(0x71, multihash)
}
#[allow(dead_code)]
pub fn write_varint(buf: &mut Vec<u8>, value: u64) {
buf.extend(encode_varint_bytes(value));
}
fn encode_varint_bytes(value: u64) -> Vec<u8> {
match value < 0x80 {
true => vec![value as u8],
false => {
let mut rest = encode_varint_bytes(value >> 7);
rest.insert(0, ((value & 0x7F) as u8) | 0x80);
rest
}
}
}
#[allow(dead_code)]
pub fn encode_car_block(cid: &cid::Cid, data: &[u8]) -> Vec<u8> {
let cid_bytes = cid.to_bytes();
let mut result = Vec::new();
write_varint(&mut result, (cid_bytes.len() + data.len()) as u64);
result.extend_from_slice(&cid_bytes);
result.extend_from_slice(data);
result
}
#[allow(dead_code)]
pub fn create_test_record() -> (Vec<u8>, cid::Cid) {
use ipld_core::ipld::Ipld;
use std::collections::BTreeMap;
let record = Ipld::Map(BTreeMap::from([
(
"$type".to_string(),
Ipld::String("app.bsky.feed.post".to_string()),
),
(
"text".to_string(),
Ipld::String("Test post for verification".to_string()),
),
(
"createdAt".to_string(),
Ipld::String("2024-01-01T00:00:00Z".to_string()),
),
]));
let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap();
let cid = make_cid(&bytes);
(bytes, cid)
}
#[allow(dead_code)]
pub fn create_mst_node(entries: Vec<(String, cid::Cid)>) -> (Vec<u8>, cid::Cid) {
use ipld_core::ipld::Ipld;
use std::collections::BTreeMap;
let ipld_entries: Vec<Ipld> = entries
.into_iter()
.map(|(key, value_cid)| {
Ipld::Map(BTreeMap::from([
("k".to_string(), Ipld::Bytes(key.into_bytes())),
("v".to_string(), Ipld::Link(value_cid)),
("p".to_string(), Ipld::Integer(0)),
]))
})
.collect();
let node = Ipld::Map(BTreeMap::from([(
"e".to_string(),
Ipld::List(ipld_entries),
)]));
let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
let cid = make_cid(&bytes);
(bytes, cid)
}
#[allow(dead_code)]
pub fn create_car_signed_commit(
did: &str,
data_cid: &cid::Cid,
signing_key: &k256::ecdsa::SigningKey,
) -> (Vec<u8>, cid::Cid) {
use jacquard_common::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::commit::Commit;
let rev = Tid::now(LimitedU32::MIN);
let did = jacquard_common::types::string::Did::new(did).expect("valid DID");
let unsigned = Commit::new_unsigned(did, *data_cid, rev, None);
let signed = unsigned.sign(signing_key).expect("signing failed");
let signed_bytes = signed.to_cbor().expect("serialization failed");
let cid = make_cid(&signed_bytes);
(signed_bytes, cid)
}
#[allow(dead_code)]
pub fn build_car_with_signature(
did: &str,
signing_key: &k256::ecdsa::SigningKey,
) -> (Vec<u8>, cid::Cid) {
let (record_bytes, record_cid) = create_test_record();
let (mst_bytes, mst_cid) =
create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]);
let (commit_bytes, commit_cid) = create_car_signed_commit(did, &mst_cid, signing_key);
let header = iroh_car::CarHeader::new_v1(vec![commit_cid]);
let header_bytes = header.encode().unwrap();
let mut car = Vec::new();
write_varint(&mut car, header_bytes.len() as u64);
car.extend_from_slice(&header_bytes);
car.extend(encode_car_block(&commit_cid, &commit_bytes));
car.extend(encode_car_block(&mst_cid, &mst_bytes));
car.extend(encode_car_block(&record_cid, &record_bytes));
(car, commit_cid)
}
#[allow(dead_code)]
pub fn get_multikey_from_signing_key(signing_key: &k256::ecdsa::SigningKey) -> String {
let public_key = signing_key.verifying_key();
let compressed = public_key.to_sec1_bytes();
let buf: Vec<u8> = encode_varint_bytes(0xE7)
.into_iter()
.chain(compressed.iter().copied())
.collect();
multibase::encode(multibase::Base::Base58Btc, buf)
}
#[allow(dead_code)]
pub async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
let db_url = get_db_connection_string().await;
let pool = sqlx::PgPool::connect(&db_url).await.ok()?;
let row = sqlx::query!(
r#"
SELECT k.key_bytes, k.encryption_version
FROM user_keys k
JOIN users u ON k.user_id = u.id
WHERE u.did = $1
"#,
did
)
.fetch_optional(&pool)
.await
.ok()??;
tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok()
}
@@ -1,66 +1,13 @@
mod common;
use cid::Cid;
mod helpers;
use common::*;
use ipld_core::ipld::Ipld;
use jacquard_common::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::commit::Commit;
use helpers::*;
use k256::ecdsa::SigningKey;
use reqwest::StatusCode;
use serde_json::json;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use std::collections::BTreeMap;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_cid(data: &[u8]) -> Cid {
let mut hasher = Sha256::new();
hasher.update(data);
let hash = hasher.finalize();
let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
Cid::new_v1(0x71, multihash)
}
fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
loop {
let mut byte = (value & 0x7F) as u8;
value >>= 7;
if value != 0 {
byte |= 0x80;
}
buf.push(byte);
if value == 0 {
break;
}
}
}
fn encode_car_block(cid: &Cid, data: &[u8]) -> Vec<u8> {
let cid_bytes = cid.to_bytes();
let mut result = Vec::new();
write_varint(&mut result, (cid_bytes.len() + data.len()) as u64);
result.extend_from_slice(&cid_bytes);
result.extend_from_slice(data);
result
}
fn get_multikey_from_signing_key(signing_key: &SigningKey) -> String {
let public_key = signing_key.verifying_key();
let compressed = public_key.to_sec1_bytes();
fn encode_uvarint(mut x: u64) -> Vec<u8> {
let mut out = Vec::new();
while x >= 0x80 {
out.push(((x as u8) & 0x7F) | 0x80);
x >>= 7;
}
out.push(x as u8);
out
}
let mut buf = encode_uvarint(0xE7);
buf.extend_from_slice(&compressed);
multibase::encode(multibase::Base::Base58Btc, buf)
}
fn create_did_document(
did: &str,
handle: &str,
@@ -89,70 +36,6 @@ fn create_did_document(
})
}
fn create_signed_commit(did: &str, data_cid: &Cid, signing_key: &SigningKey) -> (Vec<u8>, Cid) {
let rev = Tid::now(LimitedU32::MIN);
let did = jacquard_common::types::string::Did::new(did).expect("valid DID");
let unsigned = Commit::new_unsigned(did, *data_cid, rev, None);
let signed = unsigned.sign(signing_key).expect("signing failed");
let signed_bytes = signed.to_cbor().expect("serialization failed");
let cid = make_cid(&signed_bytes);
(signed_bytes, cid)
}
fn create_mst_node(entries: Vec<(String, Cid)>) -> (Vec<u8>, Cid) {
let ipld_entries: Vec<Ipld> = entries
.into_iter()
.map(|(key, value_cid)| {
Ipld::Map(BTreeMap::from([
("k".to_string(), Ipld::Bytes(key.into_bytes())),
("v".to_string(), Ipld::Link(value_cid)),
("p".to_string(), Ipld::Integer(0)),
]))
})
.collect();
let node = Ipld::Map(BTreeMap::from([(
"e".to_string(),
Ipld::List(ipld_entries),
)]));
let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
let cid = make_cid(&bytes);
(bytes, cid)
}
fn create_record() -> (Vec<u8>, Cid) {
let record = Ipld::Map(BTreeMap::from([
(
"$type".to_string(),
Ipld::String("app.bsky.feed.post".to_string()),
),
(
"text".to_string(),
Ipld::String("Test post for verification".to_string()),
),
(
"createdAt".to_string(),
Ipld::String("2024-01-01T00:00:00Z".to_string()),
),
]));
let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap();
let cid = make_cid(&bytes);
(bytes, cid)
}
fn build_car_with_signature(did: &str, signing_key: &SigningKey) -> (Vec<u8>, Cid) {
let (record_bytes, record_cid) = create_record();
let (mst_bytes, mst_cid) =
create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]);
let (commit_bytes, commit_cid) = create_signed_commit(did, &mst_cid, signing_key);
let header = iroh_car::CarHeader::new_v1(vec![commit_cid]);
let header_bytes = header.encode().unwrap();
let mut car = Vec::new();
write_varint(&mut car, header_bytes.len() as u64);
car.extend_from_slice(&header_bytes);
car.extend(encode_car_block(&commit_cid, &commit_bytes));
car.extend(encode_car_block(&mst_cid, &mst_bytes));
car.extend(encode_car_block(&record_cid, &record_bytes));
(car, commit_cid)
}
async fn setup_mock_plc_directory(did: &str, did_doc: serde_json::Value) -> MockServer {
let mock_server = MockServer::start().await;
let did_encoded = urlencoding::encode(did);
@@ -164,23 +47,7 @@ async fn setup_mock_plc_directory(did: &str, did_doc: serde_json::Value) -> Mock
.await;
mock_server
}
async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
let db_url = get_db_connection_string().await;
let pool = PgPool::connect(&db_url).await.ok()?;
let row = sqlx::query!(
r#"
SELECT k.key_bytes, k.encryption_version
FROM user_keys k
JOIN users u ON k.user_id = u.id
WHERE u.did = $1
"#,
did
)
.fetch_optional(&pool)
.await
.ok()??;
tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok()
}
#[tokio::test]
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_valid_signature_and_mock_plc -- --ignored --test-threads=1"]
async fn test_import_with_valid_signature_and_mock_plc() {
File diff suppressed because it is too large Load Diff
+9
View File
@@ -92,6 +92,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /webhook/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /metrics {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
+9
View File
@@ -203,6 +203,15 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /webhook/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /metrics {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -17,6 +17,7 @@
let saving = $state(false)
let preferredChannel = $state('email')
let availableCommsChannels = $state<string[]>(['email'])
let telegramBotUsername = $state<string | undefined>(undefined)
let email = $state('')
let discordId = $state('')
let discordVerified = $state(false)
@@ -24,6 +25,9 @@
let telegramVerified = $state(false)
let signalNumber = $state('')
let signalVerified = $state(false)
let savedDiscordId = $state('')
let savedTelegramUsername = $state('')
let savedSignalNumber = $state('')
let verifyingChannel = $state<string | null>(null)
let verificationCode = $state('')
let historyLoading = $state(true)
@@ -59,7 +63,11 @@
telegramVerified = prefs.telegramVerified
signalNumber = prefs.signalNumber ?? ''
signalVerified = prefs.signalVerified
savedDiscordId = discordId
savedTelegramUsername = telegramUsername
savedSignalNumber = signalNumber
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
telegramBotUsername = serverInfo.telegramBotUsername
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
} finally {
@@ -71,15 +79,24 @@
e.preventDefault()
saving = true
try {
await api.updateNotificationPrefs(session.accessJwt, {
const result = await api.updateNotificationPrefs(session.accessJwt, {
preferredChannel,
discordId: discordId || undefined,
telegramUsername: telegramUsername || undefined,
signalNumber: signalNumber || undefined,
discordId: discordId !== savedDiscordId ? discordId : undefined,
telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined,
signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined,
})
await refreshSession()
toast.success($_('comms.preferencesSaved'))
await loadPrefs()
savedDiscordId = discordId
savedTelegramUsername = telegramUsername
savedSignalNumber = signalNumber
const channelToVerify = result.verificationRequired?.find(
(ch: string) => ch === 'discord' || ch === 'telegram' || ch === 'signal'
)
if (channelToVerify) {
verifyingChannel = channelToVerify
verificationCode = ''
}
} catch (e) {
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave'))
} finally {
@@ -218,7 +235,9 @@
<div class="config-item">
<div class="config-header">
<label for="email">{$_('register.email')}</label>
<span class="status verified">{$_('comms.primary')}</span>
<span class="status verified">
{preferredChannel === 'email' ? $_('comms.primary') : $_('comms.verified')}
</span>
</div>
<input id="email" type="email" value={email} disabled class="readonly" />
</div>
@@ -229,7 +248,7 @@
<label for="discord">{$_('register.discordId')}</label>
{#if discordId}
<span class="status" class:verified={discordVerified} class:unverified={!discordVerified}>
{discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
{preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')}
</span>
{/if}
</div>
@@ -242,7 +261,7 @@
placeholder={$_('register.discordIdPlaceholder')}
disabled={saving}
/>
{#if discordId && !discordVerified}
{#if discordId && discordId === savedDiscordId && !discordVerified}
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
{/if}
</div>
@@ -251,7 +270,7 @@
{/if}
{#if verifyingChannel === 'discord'}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
<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>
</div>
@@ -265,7 +284,7 @@
<label for="telegram">{$_('register.telegramUsername')}</label>
{#if telegramUsername}
<span class="status" class:verified={telegramVerified} class:unverified={!telegramVerified}>
{telegramVerified ? $_('comms.verified') : $_('comms.notVerified')}
{preferredChannel === 'telegram' && telegramVerified ? $_('comms.primary') : telegramVerified ? $_('comms.verified') : $_('comms.notVerified')}
</span>
{/if}
</div>
@@ -278,18 +297,15 @@
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={saving}
/>
{#if telegramUsername && !telegramVerified}
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button>
{/if}
</div>
{#if telegramInUse}
<p class="hint warning">{$_('comms.telegramInUseWarning')}</p>
{/if}
{#if verifyingChannel === 'telegram'}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
<button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button>
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
{#if telegramUsername && telegramUsername === savedTelegramUsername && !telegramVerified && telegramBotUsername}
{@const encodedHandle = session.handle.replaceAll('.', '_')}
<div class="telegram-verify-prompt">
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
<span class="manual-hint">{$_('comms.telegramStartBot', { values: { botUsername: telegramBotUsername, handle: session.handle } })}</span>
</div>
{/if}
</div>
@@ -301,7 +317,7 @@
<label for="signal">{$_('register.signalNumber')}</label>
{#if signalNumber}
<span class="status" class:verified={signalVerified} class:unverified={!signalVerified}>
{signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
{preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')}
</span>
{/if}
</div>
@@ -314,7 +330,7 @@
placeholder={$_('register.signalNumberPlaceholder')}
disabled={saving}
/>
{#if signalNumber && !signalVerified}
{#if signalNumber && signalNumber === savedSignalNumber && !signalVerified}
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
{/if}
</div>
@@ -323,7 +339,7 @@
{/if}
{#if verifyingChannel === 'signal'}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
</div>
@@ -505,6 +521,23 @@
color: var(--warning-text);
}
.telegram-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);
}
.verify-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
@@ -517,7 +550,8 @@
}
.verify-form input {
width: 120px;
flex: 1;
min-width: 0;
}
.verify-form button {
+13 -2
View File
@@ -80,6 +80,7 @@ import type {
TotpStatus,
UpdateLegacyLoginResponse,
UpdateLocaleResponse,
UpdateNotificationPrefsResponse,
UploadBlobResponse,
VerificationChannel,
VerifyMigrationEmailResponse,
@@ -479,6 +480,16 @@ export const api = {
});
},
checkChannelVerified(
did: string,
channel: string,
): Promise<{ verified: boolean }> {
return xrpc("_checkChannelVerified", {
method: "POST",
body: { did, channel },
});
},
checkEmailInUse(email: string): Promise<{ inUse: boolean }> {
return xrpc("_account.checkEmailInUse", {
method: "POST",
@@ -648,7 +659,7 @@ export const api = {
discordId?: string;
telegramUsername?: string;
signalNumber?: string;
}): Promise<SuccessResponse> {
}): Promise<UpdateNotificationPrefsResponse> {
return xrpc("_account.updateNotificationPrefs", {
method: "POST",
token,
@@ -1847,7 +1858,7 @@ export const typedApi = {
telegramUsername?: string;
signalNumber?: string;
},
): Promise<Result<SuccessResponse, ApiError>> {
): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> {
return xrpcResult("_account.updateNotificationPrefs", {
method: "POST",
token,
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import { onDestroy, onMount } from 'svelte'
import { api, ApiError } from '../api'
import { resendVerification } from '../auth.svelte'
import type { RegistrationFlow } from './flow.svelte'
@@ -13,13 +13,26 @@
let verificationCode = $state('')
let resending = $state(false)
let resendMessage = $state<string | null>(null)
let telegramBotUsername = $state<string | undefined>(undefined)
let pollingInterval: ReturnType<typeof setInterval> | null = null
const isTelegram = $derived(flow.info.verificationChannel === 'telegram')
onMount(async () => {
if (isTelegram) {
try {
const serverInfo = await api.describeServer()
telegramBotUsername = serverInfo.telegramBotUsername
} catch {
}
}
})
$effect(() => {
if (flow.state.step === 'verify' && flow.account && !verificationCode.trim()) {
if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) {
pollingInterval = setInterval(async () => {
if (verificationCode.trim()) return
if (!isTelegram && verificationCode.trim()) return
const advanced = await flow.checkAndAdvanceIfVerified()
if (advanced && pollingInterval) {
clearInterval(pollingInterval)
@@ -84,39 +97,49 @@
</script>
<div class="verification-step">
<p class="info-text">
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
Enter it below to continue.
</p>
{#if isTelegram && telegramBotUsername}
{@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`}
{@const encodedHandle = handle.replaceAll('.', '_')}
<p class="info-text">
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">Open Telegram to verify</a>,
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code> 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)}.
Enter it below to continue.
</p>
{#if resendMessage}
<div class="message success">{resendMessage}</div>
{#if resendMessage}
<div class="message success">{resendMessage}</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="field">
<label for="verification-code">Verification Code</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder="XXXX-XXXX-XXXX-XXXX"
disabled={flow.state.submitting}
required
autocomplete="one-time-code"
class="code-input"
/>
<span class="hint">Copy the entire code from your message, including dashes.</span>
</div>
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
{flow.state.submitting ? 'Verifying...' : 'Verify'}
</button>
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
{resending ? 'Resending...' : 'Resend Code'}
</button>
</form>
{/if}
<form onsubmit={handleSubmit}>
<div class="field">
<label for="verification-code">Verification Code</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder="XXXX-XXXX-XXXX-XXXX"
disabled={flow.state.submitting}
required
autocomplete="one-time-code"
class="code-input"
/>
<span class="hint">Copy the entire code from your message, including dashes.</span>
</div>
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
{flow.state.submitting ? 'Verifying...' : 'Verify'}
</button>
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
{resending ? 'Resending...' : 'Resend Code'}
</button>
</form>
</div>
<style>
@@ -131,6 +154,17 @@
margin: 0;
}
.info-text.waiting {
font-size: var(--text-sm);
}
.info-text code {
font-family: var(--font-mono, monospace);
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: var(--radius-sm);
}
.code-input {
font-family: var(--font-mono, monospace);
font-size: var(--text-base);
+4 -1
View File
@@ -421,7 +421,10 @@ export function createRegistrationFlow(
checkingVerification = true;
try {
const result = await api.checkEmailVerified(state.account.did);
const result = await api.checkChannelVerified(
state.account.did,
state.info.verificationChannel,
);
if (!result.verified) return false;
if (state.info.didType === "web-external") {
+6
View File
@@ -232,6 +232,12 @@ export interface ServerDescription {
version?: string;
availableCommsChannels?: VerificationChannel[];
selfHostedDidWebEnabled?: boolean;
telegramBotUsername?: string;
}
export interface UpdateNotificationPrefsResponse {
success: boolean;
verificationRequired: string[];
}
export interface RepoInfo {
+3 -1
View File
@@ -439,7 +439,9 @@
"noMessages": "No messages found.",
"discordInUseWarning": "This Discord ID 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 number is already associated with another account.",
"telegramStartBot": "Or send /start {handle} to @{botUsername} manually",
"telegramOpenLink": "Open Telegram to verify"
},
"repoExplorer": {
"collections": "Collections",
+2
View File
@@ -436,6 +436,8 @@
"discordInUseWarning": "Tämä Discord-tunnus 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.",
"telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti",
"telegramOpenLink": "Avaa Telegram vahvistaaksesi",
"failedToLoad": "Asetusten lataus epäonnistui",
"failedToSave": "Asetusten tallennus epäonnistui",
"failedToVerify": "Vahvistus epäonnistui",
+2
View File
@@ -436,6 +436,8 @@
"discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。",
"telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。",
"signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。",
"telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信",
"telegramOpenLink": "Telegram で確認する",
"failedToLoad": "設定の読み込みに失敗しました",
"failedToSave": "設定の保存に失敗しました",
"failedToVerify": "確認に失敗しました",
+2
View File
@@ -436,6 +436,8 @@
"discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.",
"telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.",
"signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.",
"telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요",
"telegramOpenLink": "Telegram에서 인증하기",
"failedToLoad": "설정 로딩 실패",
"failedToSave": "설정 저장 실패",
"failedToVerify": "인증 실패",
+2
View File
@@ -436,6 +436,8 @@
"discordInUseWarning": "Detta Discord-ID ä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.",
"telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt",
"telegramOpenLink": "Öppna Telegram för att verifiera",
"failedToLoad": "Kunde inte ladda inställningar",
"failedToSave": "Kunde inte spara inställningar",
"failedToVerify": "Verifiering misslyckades",
+2
View File
@@ -436,6 +436,8 @@
"discordInUseWarning": "此 Discord ID 已与另一个账户关联。",
"telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。",
"signalInUseWarning": "此 Signal 号码已与另一个账户关联。",
"telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}",
"telegramOpenLink": "打开 Telegram 验证",
"failedToLoad": "加载偏好设置失败",
"failedToSave": "保存偏好设置失败",
"failedToVerify": "验证失败",
+6 -7
View File
@@ -99,13 +99,12 @@
inviteCodeRequired: data.inviteCodeRequired ?? false,
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
}
if (data.commsChannels) {
commsChannels = {
email: data.commsChannels.email ?? true,
discord: data.commsChannels.discord ?? false,
telegram: data.commsChannels.telegram ?? false,
signal: data.commsChannels.signal ?? false,
}
const available: string[] = data.availableCommsChannels ?? ['email']
commsChannels = {
email: available.includes('email'),
discord: available.includes('discord'),
telegram: available.includes('telegram'),
signal: available.includes('signal'),
}
}
} catch {
@@ -114,13 +114,12 @@
inviteCodeRequired: data.inviteCodeRequired ?? false,
selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false,
}
if (data.commsChannels) {
commsChannels = {
email: data.commsChannels.email ?? true,
discord: data.commsChannels.discord ?? false,
telegram: data.commsChannels.telegram ?? false,
signal: data.commsChannels.signal ?? false,
}
const available: string[] = data.availableCommsChannels ?? ['email']
commsChannels = {
email: available.includes('email'),
discord: available.includes('discord'),
telegram: available.includes('telegram'),
signal: available.includes('signal'),
}
}
} catch {
+71 -26
View File
@@ -32,6 +32,7 @@
let successChannel = $state<string | null>(null)
let tokenFromUrl = $state(false)
let oauthRequestUri = $state<string | null>(null)
let telegramBotUsername = $state<string | undefined>(undefined)
const auth = $derived(getAuthState())
@@ -40,6 +41,7 @@
}
const session = $derived(getSession())
const isTelegram = $derived(pendingVerification?.channel === 'telegram')
function parseQueryParams(): Record<string, string> {
return Object.fromEntries(new URLSearchParams(window.location.search))
@@ -99,6 +101,14 @@
channel: params.channel,
}))
}
if (pendingVerification?.channel === 'telegram') {
try {
const serverInfo = await api.describeServer()
telegramBotUsername = serverInfo.telegramBotUsername
} catch {
}
}
}
})
@@ -111,13 +121,13 @@
let pollingVerification = false
$effect(() => {
if (mode === 'signup' && pendingVerification && !verificationCode.trim()) {
if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) {
const currentPending = pendingVerification
const interval = setInterval(async () => {
if (pollingVerification || verificationCode.trim()) return
if (pollingVerification || (!isTelegram && verificationCode.trim())) return
pollingVerification = true
try {
const result = await api.checkEmailVerified(currentPending.did)
const result = await api.checkChannelVerified(currentPending.did, currentPending.channel)
if (result.verified) {
clearInterval(interval)
clearPendingVerification()
@@ -435,31 +445,44 @@
<div class="message success">{resendMessage}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
<div class="field">
<label for="verification-code">{$_('verify.codeLabel')}</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder={$_('verify.codePlaceholder')}
disabled={submitting}
required
autocomplete="off"
class="token-input"
/>
<p class="field-help">{$_('verify.codeHelp')}</p>
{#if isTelegram && telegramBotUsername}
{@const encodedHandle = pendingVerification.handle.replaceAll('.', '_')}
<div class="telegram-hint">
<p>
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a>
</p>
<p class="manual-text">
{$_('comms.telegramStartBot', { values: { botUsername: telegramBotUsername, handle: pendingVerification.handle } })}
</p>
<p class="waiting-text">{$_('verify.pleaseWait')}</p>
</div>
{:else}
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
<div class="field">
<label for="verification-code">{$_('verify.codeLabel')}</label>
<input
id="verification-code"
type="text"
bind:value={verificationCode}
placeholder={$_('verify.codePlaceholder')}
disabled={submitting}
required
autocomplete="off"
class="token-input"
/>
<p class="field-help">{$_('verify.codeHelp')}</p>
</div>
<div class="form-actions">
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
</button>
<button type="submit" disabled={submitting || !verificationCode.trim()}>
{submitting ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
<div class="form-actions">
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
</button>
<button type="submit" disabled={submitting || !verificationCode.trim()}>
{submitting ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
{/if}
<p class="link-text">
<a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a>
@@ -586,4 +609,26 @@
flex: none;
padding: var(--space-4) var(--space-8);
}
.telegram-hint {
padding: var(--space-4);
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.telegram-hint p {
margin: 0;
}
.telegram-hint .manual-text {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-top: var(--space-1);
}
.telegram-hint .waiting-text {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-top: var(--space-2);
}
</style>
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN telegram_chat_id BIGINT;
@@ -0,0 +1 @@
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'channel_verified';
+9
View File
@@ -122,6 +122,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /webhook/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /metrics {
proxy_pass http://backend;
proxy_http_version 1.1;
+1
View File
@@ -18,4 +18,5 @@ sqlx migrate run --source "$PROJECT_DIR/migrations"
echo ""
echo "Running tests..."
echo ""
ulimit -n 65536
cargo nextest run "$@"