Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

fix: improved discord & signal comms #15

merged opened by lewis.moe targeting main from fix/discord-bot-comms
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3me4pkkfyvm22
+1596 -514
Diff #0
+2 -2
.env.example
··· 93 93 # MAIL_FROM_ADDRESS=noreply@example.com 94 94 # MAIL_FROM_NAME=My PDS 95 95 # SENDMAIL_PATH=/usr/sbin/sendmail 96 - # Discord notifications (via webhook) 97 - # DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... 96 + # Discord notifications (via bot DM) 97 + # DISCORD_BOT_TOKEN=bot-token 98 98 # Telegram notifications (via bot) 99 99 # TELEGRAM_BOT_TOKEN=bot-token 100 100 # TELEGRAM_WEBHOOK_SECRET=random-secret
+15
.sqlx/query-0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76" 15 + }
+24
.sqlx/query-0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text", 16 + "Text" 17 + ] 18 + }, 19 + "nullable": [ 20 + false 21 + ] 22 + }, 23 + "hash": "0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70" 24 + }
+22
.sqlx/query-15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693" 22 + }
+14
.sqlx/query-3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07" 14 + }
+14 -8
.sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json .sqlx/query-5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 32 32 }, 33 33 { 34 34 "ordinal": 3, 35 + "name": "discord_username", 36 + "type_info": "Text" 37 + }, 38 + { 39 + "ordinal": 4, 35 40 "name": "discord_verified", 36 41 "type_info": "Bool" 37 42 }, 38 43 { 39 - "ordinal": 4, 44 + "ordinal": 5, 40 45 "name": "telegram_username", 41 46 "type_info": "Text" 42 47 }, 43 48 { 44 - "ordinal": 5, 49 + "ordinal": 6, 45 50 "name": "telegram_verified", 46 51 "type_info": "Bool" 47 52 }, 48 53 { 49 - "ordinal": 6, 54 + "ordinal": 7, 50 55 "name": "telegram_chat_id", 51 56 "type_info": "Int8" 52 57 }, 53 58 { 54 - "ordinal": 7, 55 - "name": "signal_number", 59 + "ordinal": 8, 60 + "name": "signal_username", 56 61 "type_info": "Text" 57 62 }, 58 63 { 59 - "ordinal": 8, 64 + "ordinal": 9, 60 65 "name": "signal_verified", 61 66 "type_info": "Bool" 62 67 } ··· 70 75 true, 71 76 false, 72 77 true, 78 + true, 73 79 false, 74 80 true, 75 81 false, ··· 78 84 false 79 85 ] 80 86 }, 81 - "hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740" 87 + "hash": "5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2" 82 88 }
-14
.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1" 14 - }
+14
.sqlx/query-7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21" 14 + }
+15
.sqlx/query-91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a" 15 + }
+3 -3
.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json .sqlx/query-a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1", 3 + "query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_username FROM users WHERE did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 22 22 }, 23 23 { 24 24 "ordinal": 1, 25 - "name": "discord_id", 25 + "name": "discord_username", 26 26 "type_info": "Text" 27 27 } 28 28 ], ··· 36 36 true 37 37 ] 38 38 }, 39 - "hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722" 39 + "hash": "a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f" 40 40 }
+4 -4
.sqlx/query-a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c.json .sqlx/query-b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 37 37 }, 38 38 { 39 39 "ordinal": 4, 40 - "name": "discord_id", 40 + "name": "discord_username", 41 41 "type_info": "Text" 42 42 }, 43 43 { ··· 47 47 }, 48 48 { 49 49 "ordinal": 6, 50 - "name": "signal_number", 50 + "name": "signal_username", 51 51 "type_info": "Text" 52 52 }, 53 53 { ··· 90 90 false 91 91 ] 92 92 }, 93 - "hash": "a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c" 93 + "hash": "b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27" 94 94 }
+15
.sqlx/query-b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824" 15 + }
+3 -3
.sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json .sqlx/query-b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 47 47 }, 48 48 { 49 49 "ordinal": 6, 50 - "name": "signal_number", 50 + "name": "signal_username", 51 51 "type_info": "Text" 52 52 } 53 53 ], ··· 66 66 true 67 67 ] 68 68 }, 69 - "hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67" 69 + "hash": "b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020" 70 70 }
+4 -4
.sqlx/query-671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910.json .sqlx/query-c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 42 42 }, 43 43 { 44 44 "ordinal": 5, 45 - "name": "discord_id", 45 + "name": "discord_username", 46 46 "type_info": "Text" 47 47 }, 48 48 { ··· 52 52 }, 53 53 { 54 54 "ordinal": 7, 55 - "name": "signal_number", 55 + "name": "signal_username", 56 56 "type_info": "Text" 57 57 }, 58 58 { ··· 84 84 true 85 85 ] 86 86 }, 87 - "hash": "671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910" 87 + "hash": "c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911" 88 88 }
-14
.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37" 14 - }
+23
.sqlx/query-eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Uuid", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c" 23 + }
-15
.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Uuid" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3" 15 - }
+2
Cargo.lock
··· 6078 6078 "cid", 6079 6079 "ctor", 6080 6080 "dotenvy", 6081 + "ed25519-dalek", 6081 6082 "futures", 6082 6083 "futures-util", 6083 6084 "governor", 6085 + "hex", 6084 6086 "hickory-resolver", 6085 6087 "hkdf", 6086 6088 "hmac",
+1 -1
crates/tranquil-comms/src/lib.rs
··· 8 8 }; 9 9 pub use sender::{ 10 10 CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender, 11 - is_valid_phone_number, mime_encode_header, sanitize_header_value, 11 + is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value, 12 12 }; 13 13 pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+14
crates/tranquil-comms/src/locale.rs
··· 33 33 pub migration_verification_body: &'static str, 34 34 pub channel_verified_subject: &'static str, 35 35 pub channel_verified_body: &'static str, 36 + pub channel_verification_subject: &'static str, 37 + pub channel_verification_body: &'static str, 36 38 } 37 39 38 40 pub fn get_strings(locale: &str) -> &'static NotificationStrings { ··· 70 72 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.", 71 73 channel_verified_subject: "Channel verified - {hostname}", 72 74 channel_verified_body: "Hello {handle},\n\n{channel} has been verified as a notification channel for your account on {hostname}.", 75 + channel_verification_subject: "Verify your channel - {hostname}", 76 + channel_verification_body: "Your verification code is:\n{code}\n\nOr verify directly:\n{verify_link}", 73 77 }; 74 78 75 79 static STRINGS_ZH: NotificationStrings = NotificationStrings { ··· 96 100 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如果您没有迁移账户,请忽略此邮件。", 97 101 channel_verified_subject: "通知渠道已验证 - {hostname}", 98 102 channel_verified_body: "您好 {handle},\n\n{channel} 已被验证为您在 {hostname} 上的通知渠道。", 103 + channel_verification_subject: "验证您的渠道 - {hostname}", 104 + channel_verification_body: "您的验证码是:\n{code}\n\n或直接验证:\n{verify_link}", 99 105 }; 100 106 101 107 static STRINGS_JA: NotificationStrings = NotificationStrings { ··· 122 128 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アカウントを移行していない場合は、このメールを無視してください。", 123 129 channel_verified_subject: "通知チャンネル認証完了 - {hostname}", 124 130 channel_verified_body: "{handle} 様\n\n{channel} が {hostname} の通知チャンネルとして認証されました。", 131 + channel_verification_subject: "チャンネルを認証 - {hostname}", 132 + channel_verification_body: "認証コードは:\n{code}\n\n直接認証:\n{verify_link}", 125 133 }; 126 134 127 135 static STRINGS_KO: NotificationStrings = NotificationStrings { ··· 148 156 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계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.", 149 157 channel_verified_subject: "알림 채널 인증 완료 - {hostname}", 150 158 channel_verified_body: "안녕하세요 {handle}님,\n\n{channel}이(가) {hostname}의 알림 채널로 인증되었습니다.", 159 + channel_verification_subject: "채널 인증 - {hostname}", 160 + channel_verification_body: "인증 코드:\n{code}\n\n직접 인증:\n{verify_link}", 151 161 }; 152 162 153 163 static STRINGS_SV: NotificationStrings = NotificationStrings { ··· 174 184 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.", 175 185 channel_verified_subject: "Aviseringskanal verifierad - {hostname}", 176 186 channel_verified_body: "Hej {handle},\n\n{channel} har verifierats som aviseringskanal för ditt konto på {hostname}.", 187 + channel_verification_subject: "Verifiera din kanal - {hostname}", 188 + channel_verification_body: "Din verifieringskod är:\n{code}\n\nEller verifiera direkt:\n{verify_link}", 177 189 }; 178 190 179 191 static STRINGS_FI: NotificationStrings = NotificationStrings { ··· 200 212 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.", 201 213 channel_verified_subject: "Ilmoituskanava vahvistettu - {hostname}", 202 214 channel_verified_body: "Hei {handle},\n\n{channel} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {hostname}.", 215 + channel_verification_subject: "Vahvista kanavasi - {hostname}", 216 + channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}", 203 217 }; 204 218 205 219 pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+284 -30
crates/tranquil-comms/src/sender.rs
··· 6 6 use std::time::Duration; 7 7 use tokio::io::AsyncWriteExt; 8 8 use tokio::process::Command; 9 + use tokio::time::timeout; 9 10 10 11 use super::types::{CommsChannel, QueuedComms}; 11 12 ··· 85 86 !remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit()) 86 87 } 87 88 89 + pub fn is_valid_signal_username(username: &str) -> bool { 90 + if username.len() < 6 || username.len() > 35 { 91 + return false; 92 + } 93 + let Some((base, discriminator)) = username.rsplit_once('.') else { 94 + return false; 95 + }; 96 + if base.len() < 3 || base.len() > 32 { 97 + return false; 98 + } 99 + if !base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { 100 + return false; 101 + } 102 + if !base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) { 103 + return false; 104 + } 105 + discriminator.len() == 2 && discriminator.chars().all(|c| c.is_ascii_digit()) 106 + } 107 + 88 108 pub struct EmailSender { 89 109 from_address: String, 90 110 from_name: String, ··· 154 174 } 155 175 } 156 176 177 + const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; 178 + 179 + #[derive(Clone)] 157 180 pub struct DiscordSender { 158 - webhook_url: String, 181 + bot_token: String, 159 182 http_client: Client, 160 183 } 161 184 162 185 impl DiscordSender { 163 - pub fn new(webhook_url: String) -> Self { 186 + pub fn new(bot_token: String) -> Self { 164 187 Self { 165 - webhook_url, 188 + bot_token, 166 189 http_client: create_http_client(), 167 190 } 168 191 } 169 192 170 193 pub fn from_env() -> Option<Self> { 171 - let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").ok()?; 172 - Some(Self::new(webhook_url)) 194 + let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?; 195 + Some(Self::new(bot_token)) 196 + } 197 + 198 + fn auth_header(&self) -> String { 199 + format!("Bot {}", self.bot_token) 200 + } 201 + 202 + pub async fn resolve_application_info(&self) -> Result<(String, String), SendError> { 203 + let url = format!("{}/applications/@me", DISCORD_API_BASE); 204 + let response = self 205 + .http_client 206 + .get(&url) 207 + .header("Authorization", self.auth_header()) 208 + .send() 209 + .await 210 + .map_err(|e| { 211 + SendError::ExternalService(format!( 212 + "Discord application info request failed: {}", 213 + e 214 + )) 215 + })?; 216 + 217 + if !response.status().is_success() { 218 + let body = response.text().await.unwrap_or_default(); 219 + return Err(SendError::ExternalService(format!( 220 + "Discord application info returned error: {}", 221 + body 222 + ))); 223 + } 224 + 225 + let data: serde_json::Value = response.json().await.map_err(|e| { 226 + SendError::ExternalService(format!("Failed to parse Discord application info: {}", e)) 227 + })?; 228 + 229 + let app_id = data 230 + .get("id") 231 + .and_then(|v| v.as_str()) 232 + .map(|s| s.to_string()) 233 + .ok_or_else(|| SendError::ExternalService("Application info missing id".to_string()))?; 234 + 235 + let verify_key = data 236 + .get("verify_key") 237 + .and_then(|v| v.as_str()) 238 + .map(|s| s.to_string()) 239 + .ok_or_else(|| { 240 + SendError::ExternalService("Application info missing verify_key".to_string()) 241 + })?; 242 + 243 + Ok((app_id, verify_key)) 244 + } 245 + 246 + pub async fn register_slash_command(&self, app_id: &str) -> Result<(), SendError> { 247 + let url = format!("{}/applications/{}/commands", DISCORD_API_BASE, app_id); 248 + let payload = serde_json::json!({ 249 + "name": "start", 250 + "description": "Verify your PDS account", 251 + "type": 1, 252 + "options": [{ 253 + "name": "handle", 254 + "description": "Your PDS handle (e.g. alice.example.com)", 255 + "type": 3, 256 + "required": false 257 + }] 258 + }); 259 + let response = self 260 + .http_client 261 + .post(&url) 262 + .header("Authorization", self.auth_header()) 263 + .json(&payload) 264 + .send() 265 + .await 266 + .map_err(|e| { 267 + SendError::ExternalService(format!("Register command request failed: {}", e)) 268 + })?; 269 + 270 + if !response.status().is_success() { 271 + let body = response.text().await.unwrap_or_default(); 272 + return Err(SendError::ExternalService(format!( 273 + "Register command returned error: {}", 274 + body 275 + ))); 276 + } 277 + Ok(()) 278 + } 279 + 280 + pub async fn set_interactions_endpoint( 281 + &self, 282 + app_id: &str, 283 + url: &str, 284 + ) -> Result<(), SendError> { 285 + let patch_url = format!("{}/applications/{}", DISCORD_API_BASE, app_id); 286 + let payload = serde_json::json!({ 287 + "interactions_endpoint_url": url 288 + }); 289 + let response = self 290 + .http_client 291 + .patch(&patch_url) 292 + .header("Authorization", self.auth_header()) 293 + .json(&payload) 294 + .send() 295 + .await 296 + .map_err(|e| { 297 + SendError::ExternalService(format!("Set interactions endpoint failed: {}", e)) 298 + })?; 299 + 300 + if !response.status().is_success() { 301 + let body = response.text().await.unwrap_or_default(); 302 + return Err(SendError::ExternalService(format!( 303 + "Set interactions endpoint returned error: {}", 304 + body 305 + ))); 306 + } 307 + Ok(()) 308 + } 309 + 310 + pub async fn resolve_bot_username(&self) -> Result<String, SendError> { 311 + let url = format!("{}/users/@me", DISCORD_API_BASE); 312 + let response = self 313 + .http_client 314 + .get(&url) 315 + .header("Authorization", self.auth_header()) 316 + .send() 317 + .await 318 + .map_err(|e| { 319 + SendError::ExternalService(format!("Discord getMe request failed: {}", e)) 320 + })?; 321 + 322 + if !response.status().is_success() { 323 + let body = response.text().await.unwrap_or_default(); 324 + return Err(SendError::ExternalService(format!( 325 + "Discord getMe returned error: {}", 326 + body 327 + ))); 328 + } 329 + 330 + let data: serde_json::Value = response.json().await.map_err(|e| { 331 + SendError::ExternalService(format!("Failed to parse Discord getMe response: {}", e)) 332 + })?; 333 + 334 + data.get("username") 335 + .and_then(|u| u.as_str()) 336 + .map(|s| s.to_string()) 337 + .ok_or_else(|| { 338 + SendError::ExternalService("Discord getMe response missing username".to_string()) 339 + }) 340 + } 341 + 342 + async fn open_dm_channel(&self, user_id: &str) -> Result<String, SendError> { 343 + let url = format!("{}/users/@me/channels", DISCORD_API_BASE); 344 + let payload = json!({ "recipient_id": user_id }); 345 + 346 + let response = self 347 + .http_client 348 + .post(&url) 349 + .header("Authorization", self.auth_header()) 350 + .json(&payload) 351 + .send() 352 + .await 353 + .map_err(|e| { 354 + SendError::ExternalService(format!("Discord DM channel request failed: {}", e)) 355 + })?; 356 + 357 + if !response.status().is_success() { 358 + let status = response.status(); 359 + let body = response.text().await.unwrap_or_default(); 360 + return Err(SendError::ExternalService(format!( 361 + "Discord DM channel creation returned {}: {}", 362 + status, body 363 + ))); 364 + } 365 + 366 + let data: serde_json::Value = response.json().await.map_err(|e| { 367 + SendError::ExternalService(format!( 368 + "Failed to parse Discord DM channel response: {}", 369 + e 370 + )) 371 + })?; 372 + 373 + data.get("id") 374 + .and_then(|id| id.as_str()) 375 + .map(|s| s.to_string()) 376 + .ok_or_else(|| { 377 + SendError::ExternalService("Discord DM channel response missing id".to_string()) 378 + }) 173 379 } 174 380 } 175 381 ··· 180 386 } 181 387 182 388 async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { 389 + let channel_id = self.open_dm_channel(&notification.recipient).await?; 390 + 183 391 let subject = notification.subject.as_deref().unwrap_or("Notification"); 184 392 let content = format!("**{}**\n\n{}", subject, notification.body); 185 - let payload = json!({ 186 - "content": content, 187 - "username": "Tranquil PDS" 188 - }); 393 + let payload = json!({ "content": content }); 394 + let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id); 395 + 189 396 let mut last_error = None; 190 397 for attempt in 0..MAX_RETRIES { 191 398 let result = self 192 399 .http_client 193 - .post(&self.webhook_url) 400 + .post(&url) 401 + .header("Authorization", self.auth_header()) 194 402 .json(&payload) 195 403 .send() 196 404 .await; ··· 201 409 } 202 410 let status = response.status(); 203 411 if is_retryable_status(status) && attempt < MAX_RETRIES - 1 { 204 - last_error = Some(format!("Discord webhook returned {}", status)); 412 + last_error = Some(format!("Discord API returned {}", status)); 205 413 retry_delay(attempt).await; 206 414 continue; 207 415 } 208 416 let body = response.text().await.unwrap_or_default(); 209 417 return Err(SendError::ExternalService(format!( 210 - "Discord webhook returned {}: {}", 418 + "Discord API returned {}: {}", 211 419 status, body 212 420 ))); 213 421 } ··· 386 594 } 387 595 } 388 596 597 + const SIGNAL_TIMEOUT_SECS: u64 = 30; 598 + 599 + fn is_retryable_signal_error(stderr: &str) -> bool { 600 + let lower = stderr.to_lowercase(); 601 + lower.contains("timeout") 602 + || lower.contains("timed out") 603 + || lower.contains("connection refused") 604 + || lower.contains("network") 605 + || lower.contains("temporarily") 606 + || lower.contains("try again") 607 + || lower.contains("rate limit") 608 + } 609 + 389 610 #[async_trait] 390 611 impl CommsSender for SignalSender { 391 612 fn channel(&self) -> CommsChannel { ··· 394 615 395 616 async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { 396 617 let recipient = &notification.recipient; 397 - if !is_valid_phone_number(recipient) { 618 + if !is_valid_signal_username(recipient) { 398 619 return Err(SendError::InvalidRecipient(format!( 399 - "Invalid phone number format: {}", 620 + "Invalid Signal username format: {}", 400 621 recipient 401 622 ))); 402 623 } 403 624 let subject = notification.subject.as_deref().unwrap_or("Notification"); 404 625 let message = format!("{}\n\n{}", subject, notification.body); 405 - let output = Command::new(&self.signal_cli_path) 406 - .arg("-u") 407 - .arg(&self.sender_number) 408 - .arg("send") 409 - .arg("-m") 410 - .arg(&message) 411 - .arg(recipient) 412 - .output() 413 - .await?; 414 - if !output.status.success() { 415 - let stderr = String::from_utf8_lossy(&output.stderr); 416 - return Err(SendError::ExternalService(format!( 417 - "signal-cli failed: {}", 418 - stderr 419 - ))); 626 + 627 + let mut last_error = None; 628 + for attempt in 0..MAX_RETRIES { 629 + let cmd_future = Command::new(&self.signal_cli_path) 630 + .arg("-u") 631 + .arg(&self.sender_number) 632 + .arg("send") 633 + .arg("--username") 634 + .arg(recipient) 635 + .arg("-m") 636 + .arg(&message) 637 + .output(); 638 + 639 + let result = timeout(Duration::from_secs(SIGNAL_TIMEOUT_SECS), cmd_future).await; 640 + 641 + match result { 642 + Ok(Ok(output)) if output.status.success() => return Ok(()), 643 + Ok(Ok(output)) => { 644 + let stderr = String::from_utf8_lossy(&output.stderr); 645 + if is_retryable_signal_error(&stderr) && attempt < MAX_RETRIES - 1 { 646 + last_error = Some(format!("signal-cli failed: {}", stderr)); 647 + retry_delay(attempt).await; 648 + continue; 649 + } 650 + return Err(SendError::ExternalService(format!( 651 + "signal-cli failed: {}", 652 + stderr 653 + ))); 654 + } 655 + Ok(Err(e)) => { 656 + if attempt < MAX_RETRIES - 1 { 657 + last_error = Some(format!("signal-cli spawn failed: {}", e)); 658 + retry_delay(attempt).await; 659 + continue; 660 + } 661 + return Err(SendError::ProcessSpawn(e)); 662 + } 663 + Err(_) => { 664 + if attempt < MAX_RETRIES - 1 { 665 + last_error = Some("signal-cli timed out".to_string()); 666 + retry_delay(attempt).await; 667 + continue; 668 + } 669 + return Err(SendError::Timeout); 670 + } 671 + } 420 672 } 421 - Ok(()) 673 + Err(SendError::MaxRetriesExceeded( 674 + last_error.unwrap_or_else(|| "Unknown error".to_string()), 675 + )) 422 676 } 423 677 }
+6
crates/tranquil-db-traits/src/error.rs
··· 20 20 #[error("Serialization error: {0}")] 21 21 Serialization(String), 22 22 23 + #[error("Ambiguous: {0}")] 24 + Ambiguous(String), 25 + 26 + #[error("Resource busy, try again")] 27 + LockContention, 28 + 23 29 #[error("Other database error: {0}")] 24 30 Other(String), 25 31 }
+1
crates/tranquil-db-traits/src/repo.rs
··· 488 488 user_id: Uuid, 489 489 blocks: &[ImportBlock], 490 490 records: &[ImportRecord], 491 + expected_root_cid: Option<&CidLink>, 491 492 ) -> Result<(), ImportRepoError>; 492 493 493 494 async fn apply_commit(
+33 -13
crates/tranquil-db-traits/src/user.rs
··· 228 228 229 229 async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>; 230 230 231 + async fn set_unverified_signal( 232 + &self, 233 + user_id: Uuid, 234 + signal_username: &str, 235 + ) -> Result<(), DbError>; 236 + 231 237 async fn set_unverified_telegram( 232 238 &self, 233 239 user_id: Uuid, ··· 243 249 244 250 async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>; 245 251 252 + async fn set_unverified_discord( 253 + &self, 254 + user_id: Uuid, 255 + discord_username: &str, 256 + ) -> Result<(), DbError>; 257 + 258 + async fn store_discord_user_id( 259 + &self, 260 + discord_username: &str, 261 + discord_id: &str, 262 + handle: Option<&str>, 263 + ) -> Result<Option<Uuid>, DbError>; 264 + 246 265 async fn get_verification_info( 247 266 &self, 248 267 did: &Did, ··· 261 280 async fn verify_signal_channel( 262 281 &self, 263 282 user_id: Uuid, 264 - signal_number: &str, 283 + signal_username: &str, 265 284 ) -> Result<(), DbError>; 266 285 267 286 async fn set_email_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>; ··· 598 617 pub preferred_locale: Option<String>, 599 618 pub telegram_chat_id: Option<i64>, 600 619 pub discord_id: Option<String>, 601 - pub signal_number: Option<String>, 620 + pub signal_username: Option<String>, 602 621 } 603 622 604 623 #[derive(Debug, Clone)] ··· 656 675 pub email: String, 657 676 pub preferred_channel: CommsChannel, 658 677 pub discord_id: Option<String>, 678 + pub discord_username: Option<String>, 659 679 pub discord_verified: bool, 660 680 pub telegram_username: Option<String>, 661 681 pub telegram_verified: bool, 662 682 pub telegram_chat_id: Option<i64>, 663 - pub signal_number: Option<String>, 683 + pub signal_username: Option<String>, 664 684 pub signal_verified: bool, 665 685 } 666 686 ··· 829 849 pub handle: Handle, 830 850 pub email: Option<String>, 831 851 pub channel: CommsChannel, 832 - pub discord_id: Option<String>, 852 + pub discord_username: Option<String>, 833 853 pub telegram_username: Option<String>, 834 - pub signal_number: Option<String>, 854 + pub signal_username: Option<String>, 835 855 pub key_bytes: Vec<u8>, 836 856 pub encryption_version: Option<i32>, 837 857 } ··· 842 862 pub handle: Handle, 843 863 pub email: Option<String>, 844 864 pub channel: CommsChannel, 845 - pub discord_id: Option<String>, 865 + pub discord_username: Option<String>, 846 866 pub telegram_username: Option<String>, 847 - pub signal_number: Option<String>, 867 + pub signal_username: Option<String>, 848 868 pub channel_verification: ChannelVerificationStatus, 849 869 } 850 870 ··· 932 952 pub did: Did, 933 953 pub password_hash: String, 934 954 pub preferred_comms_channel: CommsChannel, 935 - pub discord_id: Option<String>, 955 + pub discord_username: Option<String>, 936 956 pub telegram_username: Option<String>, 937 - pub signal_number: Option<String>, 957 + pub signal_username: Option<String>, 938 958 pub deactivated_at: Option<DateTime<Utc>>, 939 959 pub encrypted_key_bytes: Vec<u8>, 940 960 pub encryption_version: i32, ··· 982 1002 pub email: String, 983 1003 pub did: Did, 984 1004 pub preferred_comms_channel: CommsChannel, 985 - pub discord_id: Option<String>, 1005 + pub discord_username: Option<String>, 986 1006 pub telegram_username: Option<String>, 987 - pub signal_number: Option<String>, 1007 + pub signal_username: Option<String>, 988 1008 pub setup_token_hash: String, 989 1009 pub setup_expires_at: DateTime<Utc>, 990 1010 pub deactivated_at: Option<DateTime<Utc>>, ··· 1004 1024 pub email: Option<String>, 1005 1025 pub did: Did, 1006 1026 pub preferred_comms_channel: CommsChannel, 1007 - pub discord_id: Option<String>, 1027 + pub discord_username: Option<String>, 1008 1028 pub telegram_username: Option<String>, 1009 - pub signal_number: Option<String>, 1029 + pub signal_username: Option<String>, 1010 1030 pub encrypted_key_bytes: Vec<u8>, 1011 1031 pub encryption_version: i32, 1012 1032 pub commit_cid: String,
+10 -2
crates/tranquil-db/src/postgres/repo.rs
··· 1131 1131 user_id: Uuid, 1132 1132 blocks: &[ImportBlock], 1133 1133 records: &[ImportRecord], 1134 + expected_root_cid: Option<&CidLink>, 1134 1135 ) -> Result<(), ImportRepoError> { 1135 1136 let mut tx = self 1136 1137 .pool ··· 1153 1154 ImportRepoError::Database(e.to_string()) 1154 1155 })?; 1155 1156 1156 - if repo.is_none() { 1157 - return Err(ImportRepoError::RepoNotFound); 1157 + let repo = match repo { 1158 + Some(r) => r, 1159 + None => return Err(ImportRepoError::RepoNotFound), 1160 + }; 1161 + 1162 + if let Some(expected) = expected_root_cid { 1163 + if repo.repo_root_cid.as_str() != expected.as_str() { 1164 + return Err(ImportRepoError::ConcurrentModification); 1165 + } 1158 1166 } 1159 1167 1160 1168 let block_chunks: Vec<Vec<&ImportBlock>> = blocks
+131 -26
crates/tranquil-db/src/postgres/user.rs
··· 311 311 312 312 async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> { 313 313 let row = sqlx::query!( 314 - r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number 314 + r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_username 315 315 FROM users WHERE id = $1"#, 316 316 user_id 317 317 ) ··· 325 325 preferred_locale: r.preferred_locale, 326 326 telegram_chat_id: r.telegram_chat_id, 327 327 discord_id: r.discord_id, 328 - signal_number: r.signal_number, 328 + signal_username: r.signal_username, 329 329 })) 330 330 } 331 331 ··· 636 636 email, 637 637 preferred_comms_channel as "preferred_channel!: CommsChannel", 638 638 discord_id, 639 + discord_username, 639 640 discord_verified, 640 641 telegram_username, 641 642 telegram_verified, 642 643 telegram_chat_id, 643 - signal_number, 644 + signal_username, 644 645 signal_verified 645 646 FROM users WHERE did = $1"#, 646 647 did.as_str() ··· 652 653 email: r.email.unwrap_or_default(), 653 654 preferred_channel: r.preferred_channel, 654 655 discord_id: r.discord_id, 656 + discord_username: r.discord_username, 655 657 discord_verified: r.discord_verified, 656 658 telegram_username: r.telegram_username, 657 659 telegram_verified: r.telegram_verified, 658 660 telegram_chat_id: r.telegram_chat_id, 659 - signal_number: r.signal_number, 661 + signal_username: r.signal_username, 660 662 signal_verified: r.signal_verified, 661 663 })) 662 664 } ··· 697 699 698 700 async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError> { 699 701 sqlx::query!( 700 - "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", 702 + "UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", 701 703 user_id 702 704 ) 703 705 .execute(&self.pool) ··· 719 721 720 722 async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError> { 721 723 sqlx::query!( 722 - "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", 724 + "UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", 723 725 user_id 724 726 ) 725 727 .execute(&self.pool) ··· 796 798 async fn verify_signal_channel( 797 799 &self, 798 800 user_id: Uuid, 799 - signal_number: &str, 801 + signal_username: &str, 800 802 ) -> Result<(), DbError> { 801 803 sqlx::query!( 802 - "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", 803 - signal_number, 804 + "UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", 805 + signal_username, 804 806 user_id 805 807 ) 806 808 .execute(&self.pool) ··· 1548 1550 r#"SELECT 1549 1551 u.id, u.did, u.handle, u.email, 1550 1552 u.preferred_comms_channel as "channel: CommsChannel", 1551 - u.discord_id, u.telegram_username, u.signal_number, 1553 + u.discord_username, u.telegram_username, u.signal_username, 1552 1554 k.key_bytes, k.encryption_version 1553 1555 FROM users u 1554 1556 JOIN user_keys k ON u.id = k.user_id ··· 1565 1567 handle: Handle::from(row.handle), 1566 1568 email: row.email, 1567 1569 channel: row.channel, 1568 - discord_id: row.discord_id, 1570 + discord_username: row.discord_username, 1569 1571 telegram_username: row.telegram_username, 1570 - signal_number: row.signal_number, 1572 + signal_username: row.signal_username, 1571 1573 key_bytes: row.key_bytes, 1572 1574 encryption_version: row.encryption_version, 1573 1575 }) ··· 1582 1584 r#"SELECT 1583 1585 id, handle, email, 1584 1586 preferred_comms_channel as "channel: CommsChannel", 1585 - discord_id, telegram_username, signal_number, 1587 + discord_username, telegram_username, signal_username, 1586 1588 email_verified, discord_verified, telegram_verified, signal_verified 1587 1589 FROM users 1588 1590 WHERE did = $1"#, ··· 1597 1599 handle: Handle::from(row.handle), 1598 1600 email: row.email, 1599 1601 channel: row.channel, 1600 - discord_id: row.discord_id, 1602 + discord_username: row.discord_username, 1601 1603 telegram_username: row.telegram_username, 1602 - signal_number: row.signal_number, 1604 + signal_username: row.signal_username, 1603 1605 channel_verification: ChannelVerificationStatus::new( 1604 1606 row.email_verified, 1605 1607 row.discord_verified, ··· 1662 1664 "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL" 1663 1665 } 1664 1666 CommsChannel::Discord => { 1665 - "SELECT COUNT(*) FROM users WHERE discord_id = $1 AND deactivated_at IS NULL" 1667 + "SELECT COUNT(*) FROM users WHERE LOWER(discord_username) = LOWER($1) AND deactivated_at IS NULL" 1666 1668 } 1667 1669 CommsChannel::Telegram => { 1668 1670 "SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL" 1669 1671 } 1670 1672 CommsChannel::Signal => { 1671 - "SELECT COUNT(*) FROM users WHERE signal_number = $1 AND deactivated_at IS NULL" 1673 + "SELECT COUNT(*) FROM users WHERE signal_username = $1 AND deactivated_at IS NULL" 1672 1674 } 1673 1675 }; 1674 1676 sqlx::query_scalar(query) ··· 2385 2387 r#"INSERT INTO users ( 2386 2388 handle, email, did, password_hash, 2387 2389 preferred_comms_channel, 2388 - discord_id, telegram_username, signal_number, 2390 + discord_username, telegram_username, signal_username, 2389 2391 is_admin, deactivated_at, email_verified 2390 2392 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#, 2391 2393 ) ··· 2394 2396 .bind(input.did.as_str()) 2395 2397 .bind(&input.password_hash) 2396 2398 .bind(input.preferred_comms_channel) 2397 - .bind(&input.discord_id) 2399 + .bind(&input.discord_username) 2398 2400 .bind(&input.telegram_username) 2399 - .bind(&input.signal_number) 2401 + .bind(&input.signal_username) 2400 2402 .bind(is_first_user) 2401 2403 .bind(input.deactivated_at) 2402 2404 .fetch_one(&mut *tx) ··· 2652 2654 r#"INSERT INTO users ( 2653 2655 handle, email, did, password_hash, password_required, 2654 2656 preferred_comms_channel, 2655 - discord_id, telegram_username, signal_number, 2657 + discord_username, telegram_username, signal_username, 2656 2658 recovery_token, recovery_token_expires_at, 2657 2659 is_admin, deactivated_at 2658 2660 ) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#, ··· 2661 2663 .bind(&input.email) 2662 2664 .bind(input.did.as_str()) 2663 2665 .bind(input.preferred_comms_channel) 2664 - .bind(&input.discord_id) 2666 + .bind(&input.discord_username) 2665 2667 .bind(&input.telegram_username) 2666 - .bind(&input.signal_number) 2668 + .bind(&input.signal_username) 2667 2669 .bind(&input.setup_token_hash) 2668 2670 .bind(input.setup_expires_at) 2669 2671 .bind(is_first_user) ··· 2816 2818 let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 2817 2819 r#"INSERT INTO users ( 2818 2820 handle, email, did, password_hash, password_required, 2819 - preferred_comms_channel, discord_id, telegram_username, signal_number, 2821 + preferred_comms_channel, discord_username, telegram_username, signal_username, 2820 2822 is_admin 2821 2823 ) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#, 2822 2824 ) ··· 2824 2826 .bind(&input.email) 2825 2827 .bind(input.did.as_str()) 2826 2828 .bind(input.preferred_comms_channel) 2827 - .bind(&input.discord_id) 2829 + .bind(&input.discord_username) 2828 2830 .bind(&input.telegram_username) 2829 - .bind(&input.signal_number) 2831 + .bind(&input.signal_username) 2830 2832 .bind(is_first_user) 2831 2833 .fetch_one(&mut *tx) 2832 2834 .await; ··· 3190 3192 Ok(()) 3191 3193 } 3192 3194 3195 + async fn set_unverified_signal( 3196 + &self, 3197 + user_id: Uuid, 3198 + signal_username: &str, 3199 + ) -> Result<(), DbError> { 3200 + sqlx::query!( 3201 + r#"UPDATE users SET 3202 + signal_username = $1, 3203 + signal_verified = CASE WHEN LOWER(signal_username) = LOWER($1) THEN signal_verified ELSE FALSE END, 3204 + updated_at = NOW() 3205 + WHERE id = $2"#, 3206 + signal_username, 3207 + user_id 3208 + ) 3209 + .execute(&self.pool) 3210 + .await 3211 + .map_err(map_sqlx_error)?; 3212 + Ok(()) 3213 + } 3214 + 3215 + async fn set_unverified_discord( 3216 + &self, 3217 + user_id: Uuid, 3218 + discord_username: &str, 3219 + ) -> Result<(), DbError> { 3220 + sqlx::query!( 3221 + r#"UPDATE users SET 3222 + discord_username = $1, 3223 + discord_verified = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_verified ELSE FALSE END, 3224 + discord_id = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_id ELSE NULL END, 3225 + updated_at = NOW() 3226 + WHERE id = $2"#, 3227 + discord_username, 3228 + user_id 3229 + ) 3230 + .execute(&self.pool) 3231 + .await 3232 + .map_err(map_sqlx_error)?; 3233 + Ok(()) 3234 + } 3235 + 3236 + async fn store_discord_user_id( 3237 + &self, 3238 + discord_username: &str, 3239 + discord_id: &str, 3240 + handle: Option<&str>, 3241 + ) -> Result<Option<Uuid>, DbError> { 3242 + let result = match handle { 3243 + Some(h) => sqlx::query_scalar!( 3244 + "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", 3245 + discord_username, 3246 + discord_id, 3247 + h 3248 + ) 3249 + .fetch_optional(&self.pool) 3250 + .await 3251 + .map_err(map_sqlx_error)?, 3252 + None => { 3253 + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; 3254 + 3255 + let matching: Vec<uuid::Uuid> = match sqlx::query_scalar!( 3256 + "SELECT id FROM users WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND deactivated_at IS NULL FOR UPDATE NOWAIT", 3257 + discord_username 3258 + ) 3259 + .fetch_all(&mut *tx) 3260 + .await 3261 + { 3262 + Ok(ids) => ids, 3263 + Err(sqlx::Error::Database(ref db_err)) 3264 + if db_err.code().as_deref() == Some("55P03") => 3265 + { 3266 + return Err(DbError::LockContention); 3267 + } 3268 + Err(e) => return Err(map_sqlx_error(e)), 3269 + }; 3270 + 3271 + let result = match matching.len() { 3272 + 0 => None, 3273 + 1 => { 3274 + sqlx::query_scalar!( 3275 + "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id", 3276 + matching[0], 3277 + discord_id 3278 + ) 3279 + .fetch_optional(&mut *tx) 3280 + .await 3281 + .map_err(map_sqlx_error)? 3282 + } 3283 + _ => { 3284 + tx.rollback().await.ok(); 3285 + return Err(DbError::Ambiguous( 3286 + "Multiple accounts use this Discord username. Type: /start your-handle.example.com".to_string(), 3287 + )); 3288 + } 3289 + }; 3290 + 3291 + tx.commit().await.map_err(map_sqlx_error)?; 3292 + result 3293 + } 3294 + }; 3295 + Ok(result) 3296 + } 3297 + 3193 3298 async fn store_telegram_chat_id( 3194 3299 &self, 3195 3300 telegram_username: &str,
+2
crates/tranquil-pds/Cargo.toml
··· 32 32 chrono = { workspace = true } 33 33 cid = { workspace = true } 34 34 dotenvy = { workspace = true } 35 + ed25519-dalek = { workspace = true } 35 36 futures = { workspace = true } 37 + hex = { workspace = true } 36 38 futures-util = { workspace = true } 37 39 governor = { workspace = true } 38 40 hickory-resolver = { workspace = true }
+301
crates/tranquil-pds/src/api/discord_webhook.rs
··· 1 + use axum::{ 2 + Json, 3 + extract::State, 4 + http::StatusCode, 5 + response::{IntoResponse, Response}, 6 + }; 7 + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; 8 + use serde::Deserialize; 9 + use serde_json::json; 10 + use tracing::{debug, info, warn}; 11 + use tranquil_types::Handle; 12 + 13 + use crate::comms::comms_repo; 14 + use crate::state::AppState; 15 + use crate::util::{discord_public_key, pds_hostname}; 16 + 17 + #[derive(Deserialize)] 18 + struct Interaction { 19 + #[serde(rename = "type")] 20 + interaction_type: u8, 21 + data: Option<InteractionData>, 22 + member: Option<InteractionMember>, 23 + user: Option<InteractionUser>, 24 + } 25 + 26 + #[derive(Deserialize)] 27 + struct InteractionData { 28 + name: Option<String>, 29 + options: Option<Vec<InteractionOption>>, 30 + } 31 + 32 + #[derive(Deserialize)] 33 + struct InteractionOption { 34 + name: String, 35 + value: serde_json::Value, 36 + } 37 + 38 + #[derive(Deserialize)] 39 + struct InteractionMember { 40 + user: Option<InteractionUser>, 41 + } 42 + 43 + #[derive(Deserialize)] 44 + struct InteractionUser { 45 + id: String, 46 + username: Option<String>, 47 + } 48 + 49 + fn verify_signature( 50 + public_key: &VerifyingKey, 51 + timestamp: &str, 52 + body: &str, 53 + signature: &str, 54 + ) -> bool { 55 + let sig_bytes = match hex::decode(signature) { 56 + Ok(b) => b, 57 + Err(_) => return false, 58 + }; 59 + let signature = match Signature::from_slice(&sig_bytes) { 60 + Ok(s) => s, 61 + Err(_) => return false, 62 + }; 63 + let message = format!("{}{}", timestamp, body); 64 + public_key.verify(message.as_bytes(), &signature).is_ok() 65 + } 66 + 67 + fn parse_start_handle(options: Option<&[InteractionOption]>) -> Option<String> { 68 + options 69 + .and_then(|opts| opts.iter().find(|o| o.name == "handle")) 70 + .and_then(|o| o.value.as_str()) 71 + .map(|s| s.trim()) 72 + .filter(|s| !s.is_empty()) 73 + .map(|s| s.to_string()) 74 + } 75 + 76 + pub async fn handle_discord_webhook( 77 + State(state): State<AppState>, 78 + headers: axum::http::HeaderMap, 79 + body: String, 80 + ) -> Response { 81 + let public_key = match discord_public_key() { 82 + Some(pk) => pk, 83 + None => { 84 + warn!("Discord webhook called but public key is not configured"); 85 + return StatusCode::FORBIDDEN.into_response(); 86 + } 87 + }; 88 + 89 + let signature = headers 90 + .get("x-signature-ed25519") 91 + .and_then(|v| v.to_str().ok()) 92 + .unwrap_or_default(); 93 + let timestamp = headers 94 + .get("x-signature-timestamp") 95 + .and_then(|v| v.to_str().ok()) 96 + .unwrap_or_default(); 97 + 98 + if !verify_signature(public_key, timestamp, &body, signature) { 99 + return StatusCode::UNAUTHORIZED.into_response(); 100 + } 101 + 102 + let interaction: Interaction = match serde_json::from_str(&body) { 103 + Ok(i) => i, 104 + Err(_) => return StatusCode::BAD_REQUEST.into_response(), 105 + }; 106 + 107 + match interaction.interaction_type { 108 + 1 => Json(json!({"type": 1})).into_response(), 109 + 2 => handle_command(state, interaction).await, 110 + other => { 111 + debug!(interaction_type = other, "Received unknown Discord interaction type"); 112 + StatusCode::OK.into_response() 113 + } 114 + } 115 + } 116 + 117 + async fn handle_command(state: AppState, interaction: Interaction) -> Response { 118 + let command_name = interaction 119 + .data 120 + .as_ref() 121 + .and_then(|d| d.name.as_deref()) 122 + .unwrap_or_default(); 123 + 124 + if command_name != "start" { 125 + return Json(json!({ 126 + "type": 4, 127 + "data": {"content": "Unknown command", "flags": 64} 128 + })) 129 + .into_response(); 130 + } 131 + 132 + let user = interaction 133 + .member 134 + .as_ref() 135 + .and_then(|m| m.user.as_ref()) 136 + .or(interaction.user.as_ref()); 137 + 138 + let (discord_user_id, discord_username) = match user { 139 + Some(u) => (u.id.clone(), u.username.clone().unwrap_or_default()), 140 + None => { 141 + return Json(json!({ 142 + "type": 4, 143 + "data": {"content": "Could not identify user", "flags": 64} 144 + })) 145 + .into_response(); 146 + } 147 + }; 148 + 149 + let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref())); 150 + 151 + if let Some(ref h) = handle { 152 + if Handle::new(h).is_err() { 153 + return Json(json!({ 154 + "type": 4, 155 + "data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64} 156 + })) 157 + .into_response(); 158 + } 159 + } 160 + 161 + debug!( 162 + discord_username = %discord_username, 163 + discord_user_id = %discord_user_id, 164 + handle = ?handle, 165 + "Received /start from Discord user" 166 + ); 167 + 168 + match state 169 + .user_repo 170 + .store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref()) 171 + .await 172 + { 173 + Ok(Some(user_id)) => { 174 + info!( 175 + discord_username = %discord_username, 176 + discord_user_id = %discord_user_id, 177 + "Verified Discord user and stored user ID" 178 + ); 179 + if let Err(e) = comms_repo::enqueue_channel_verified( 180 + state.user_repo.as_ref(), 181 + state.infra_repo.as_ref(), 182 + user_id, 183 + "discord", 184 + &discord_user_id, 185 + pds_hostname(), 186 + ) 187 + .await 188 + { 189 + warn!(error = %e, "Failed to enqueue channel verified notification"); 190 + } 191 + Json(json!({ 192 + "type": 4, 193 + "data": {"content": "Verified", "flags": 64} 194 + })) 195 + .into_response() 196 + } 197 + Ok(None) => { 198 + debug!( 199 + discord_username = %discord_username, 200 + "No matching user found for Discord username" 201 + ); 202 + Json(json!({ 203 + "type": 4, 204 + "data": {"content": "No account found with this Discord username. Set your Discord username in your PDS settings first.", "flags": 64} 205 + })) 206 + .into_response() 207 + } 208 + Err(tranquil_db_traits::DbError::Ambiguous(msg)) => { 209 + debug!( 210 + discord_username = %discord_username, 211 + "Ambiguous Discord username match" 212 + ); 213 + Json(json!({ 214 + "type": 4, 215 + "data": {"content": msg, "flags": 64} 216 + })) 217 + .into_response() 218 + } 219 + Err(tranquil_db_traits::DbError::LockContention) => { 220 + debug!( 221 + discord_username = %discord_username, 222 + "Lock contention during Discord verification" 223 + ); 224 + Json(json!({ 225 + "type": 4, 226 + "data": {"content": "Server busy, please try again in a moment.", "flags": 64} 227 + })) 228 + .into_response() 229 + } 230 + Err(e) => { 231 + warn!( 232 + discord_username = %discord_username, 233 + error = %e, 234 + "Failed to store Discord user ID" 235 + ); 236 + Json(json!({ 237 + "type": 4, 238 + "data": {"content": "Verification failed. Try again later.", "flags": 64} 239 + })) 240 + .into_response() 241 + } 242 + } 243 + } 244 + 245 + #[cfg(test)] 246 + mod tests { 247 + use super::*; 248 + 249 + #[test] 250 + fn parse_handle_from_options() { 251 + let options = vec![InteractionOption { 252 + name: "handle".to_string(), 253 + value: serde_json::json!("lewis.buttercup.wizardry.systems"), 254 + }]; 255 + assert_eq!( 256 + parse_start_handle(Some(&options)), 257 + Some("lewis.buttercup.wizardry.systems".to_string()), 258 + ); 259 + } 260 + 261 + #[test] 262 + fn parse_handle_no_options() { 263 + assert_eq!(parse_start_handle(None), None); 264 + } 265 + 266 + #[test] 267 + fn parse_handle_empty_options() { 268 + let options: Vec<InteractionOption> = vec![]; 269 + assert_eq!(parse_start_handle(Some(&options)), None); 270 + } 271 + 272 + #[test] 273 + fn parse_handle_wrong_option_name() { 274 + let options = vec![InteractionOption { 275 + name: "other".to_string(), 276 + value: serde_json::json!("test"), 277 + }]; 278 + assert_eq!(parse_start_handle(Some(&options)), None); 279 + } 280 + 281 + #[test] 282 + fn parse_handle_empty_string() { 283 + let options = vec![InteractionOption { 284 + name: "handle".to_string(), 285 + value: serde_json::json!(""), 286 + }]; 287 + assert_eq!(parse_start_handle(Some(&options)), None); 288 + } 289 + 290 + #[test] 291 + fn parse_handle_whitespace_trimmed() { 292 + let options = vec![InteractionOption { 293 + name: "handle".to_string(), 294 + value: serde_json::json!(" alice.example.com "), 295 + }]; 296 + assert_eq!( 297 + parse_start_handle(Some(&options)), 298 + Some("alice.example.com".to_string()), 299 + ); 300 + } 301 + }
+1 -1
crates/tranquil-pds/src/api/error.rs
··· 409 409 Some("Telegram username is required when using Telegram verification".to_string()) 410 410 } 411 411 Self::MissingSignalNumber => { 412 - Some("Signal phone number is required when using Signal verification".to_string()) 412 + Some("Signal username is required when using Signal verification".to_string()) 413 413 } 414 414 Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()), 415 415 Self::SelfHostedDidWebDisabled => {
+25 -15
crates/tranquil-pds/src/api/identity/account.rs
··· 35 35 pub did_type: Option<String>, 36 36 pub signing_key: Option<String>, 37 37 pub verification_channel: Option<String>, 38 - pub discord_id: Option<String>, 38 + pub discord_username: Option<String>, 39 39 pub telegram_username: Option<String>, 40 - pub signal_number: Option<String>, 40 + pub signal_username: Option<String>, 41 41 } 42 42 43 43 #[derive(Serialize)] ··· 203 203 Some(email) if !email.trim().is_empty() => email.trim().to_string(), 204 204 _ => return ApiError::MissingEmail.into_response(), 205 205 }, 206 - "discord" => match &input.discord_id { 207 - Some(id) if !id.trim().is_empty() => id.trim().to_string(), 206 + "discord" => match &input.discord_username { 207 + Some(username) if !username.trim().is_empty() => { 208 + let clean = username.trim().to_lowercase(); 209 + if !crate::api::validation::is_valid_discord_username(&clean) { 210 + return ApiError::InvalidRequest( 211 + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), 212 + ).into_response(); 213 + } 214 + clean 215 + } 208 216 _ => return ApiError::MissingDiscordId.into_response(), 209 217 }, 210 218 "telegram" => match &input.telegram_username { ··· 219 227 } 220 228 _ => return ApiError::MissingTelegramUsername.into_response(), 221 229 }, 222 - "signal" => match &input.signal_number { 223 - Some(number) if !number.trim().is_empty() => number.trim().to_string(), 230 + "signal" => match &input.signal_username { 231 + Some(username) if !username.trim().is_empty() => { 232 + username.trim().trim_start_matches('@').to_lowercase() 233 + } 224 234 _ => return ApiError::MissingSignalNumber.into_response(), 225 235 }, 226 236 _ => return ApiError::InvalidVerificationChannel.into_response(), ··· 633 643 did: unsafe { Did::new_unchecked(&did) }, 634 644 password_hash, 635 645 preferred_comms_channel, 636 - discord_id: input 637 - .discord_id 646 + discord_username: input 647 + .discord_username 638 648 .as_deref() 639 - .map(|s| s.trim()) 640 - .filter(|s| !s.is_empty()) 641 - .map(String::from), 649 + .map(|s| s.trim().to_lowercase()) 650 + .filter(|s| !s.is_empty()), 642 651 telegram_username: input 643 652 .telegram_username 644 653 .as_deref() 645 654 .map(|s| s.trim().trim_start_matches('@')) 646 655 .filter(|s| !s.is_empty()) 647 656 .map(String::from), 648 - signal_number: input 649 - .signal_number 657 + signal_username: input 658 + .signal_username 650 659 .as_deref() 651 - .map(|s| s.trim()) 660 + .map(|s| s.trim().trim_start_matches('@')) 652 661 .filter(|s| !s.is_empty()) 653 - .map(String::from), 662 + .map(|s| s.to_lowercase()), 654 663 deactivated_at, 655 664 encrypted_key_bytes, 656 665 encryption_version: crate::config::ENCRYPTION_VERSION, ··· 750 759 let formatted_token = 751 760 crate::auth::verification_token::format_token_for_display(&verification_token); 752 761 if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 762 + state.user_repo.as_ref(), 753 763 state.infra_repo.as_ref(), 754 764 user_id, 755 765 verification_channel,
+1
crates/tranquil-pds/src/api/mod.rs
··· 3 3 pub mod age_assurance; 4 4 pub mod backup; 5 5 pub mod delegation; 6 + pub mod discord_webhook; 6 7 pub mod error; 7 8 pub mod identity; 8 9 pub mod moderation;
+55 -21
crates/tranquil-pds/src/api/notification_prefs.rs
··· 17 17 pub struct NotificationPrefsResponse { 18 18 pub preferred_channel: CommsChannel, 19 19 pub email: String, 20 - pub discord_id: Option<String>, 20 + pub discord_username: Option<String>, 21 21 pub discord_verified: bool, 22 22 pub telegram_username: Option<String>, 23 23 pub telegram_verified: bool, 24 - pub signal_number: Option<String>, 24 + pub signal_username: Option<String>, 25 25 pub signal_verified: bool, 26 26 } 27 27 ··· 38 38 Ok(Json(NotificationPrefsResponse { 39 39 preferred_channel: prefs.preferred_channel, 40 40 email: prefs.email, 41 - discord_id: prefs.discord_id, 41 + discord_username: prefs.discord_username, 42 42 discord_verified: prefs.discord_verified, 43 43 telegram_username: prefs.telegram_username, 44 44 telegram_verified: prefs.telegram_verified, 45 - signal_number: prefs.signal_number, 45 + signal_username: prefs.signal_username, 46 46 signal_verified: prefs.signal_verified, 47 47 }) 48 48 .into_response()) ··· 120 120 pub struct UpdateNotificationPrefsInput { 121 121 pub preferred_channel: Option<String>, 122 122 pub email: Option<String>, 123 - pub discord_id: Option<String>, 123 + pub discord_username: Option<String>, 124 124 pub telegram_username: Option<String>, 125 - pub signal_number: Option<String>, 125 + pub signal_username: Option<String>, 126 126 } 127 127 128 128 #[derive(Serialize)] ··· 172 172 "https://{}/app/verify?token={}&identifier={}", 173 173 hostname, encoded_token, encoded_identifier 174 174 ); 175 - let body = format!( 176 - "Your verification code is: {}\n\nOr verify directly:\n{}", 177 - formatted_token, verify_link 175 + let prefs = state 176 + .user_repo 177 + .get_comms_prefs(user_id) 178 + .await 179 + .ok() 180 + .flatten(); 181 + let locale = prefs 182 + .as_ref() 183 + .and_then(|p| p.preferred_locale.as_deref()) 184 + .unwrap_or("en"); 185 + let strings = crate::comms::get_strings(locale); 186 + let body = crate::comms::format_message( 187 + strings.channel_verification_body, 188 + &[("code", &formatted_token), ("verify_link", &verify_link)], 189 + ); 190 + let subject = crate::comms::format_message( 191 + strings.channel_verification_subject, 192 + &[("hostname", hostname)], 178 193 ); 179 194 let recipient = match comms_channel { 180 195 tranquil_db_traits::CommsChannel::Telegram => state ··· 194 209 comms_channel, 195 210 tranquil_db_traits::CommsType::ChannelVerification, 196 211 &recipient, 197 - Some("Verify your channel"), 212 + Some(&subject), 198 213 &body, 199 214 Some(json!({"code": formatted_token})), 200 215 ) ··· 291 306 } 292 307 } 293 308 294 - if let Some(ref discord_id) = input.discord_id { 295 - if discord_id.is_empty() { 309 + if let Some(ref discord_username) = input.discord_username { 310 + let discord_clean = discord_username.trim().to_lowercase(); 311 + if discord_clean.is_empty() { 296 312 if effective_channel == CommsChannel::Discord { 297 313 return Err(ApiError::InvalidRequest( 298 314 "Cannot remove Discord while it is the preferred notification channel".into(), ··· 303 319 .clear_discord(user_id) 304 320 .await 305 321 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 306 - info!(did = %auth.did, "Cleared Discord ID"); 322 + info!(did = %auth.did, "Cleared Discord"); 323 + } else if !crate::api::validation::is_valid_discord_username(&discord_clean) { 324 + return Err(ApiError::InvalidRequest( 325 + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)" 326 + .into(), 327 + )); 307 328 } else { 308 - request_channel_verification(&state, user_id, &auth.did, "discord", discord_id, None) 329 + state 330 + .user_repo 331 + .set_unverified_discord(user_id, &discord_clean) 309 332 .await 310 - .map_err(|e| ApiError::InternalError(Some(e)))?; 333 + .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 311 334 verification_required.push("discord".to_string()); 312 - info!(did = %auth.did, "Requested Discord verification"); 335 + info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username"); 313 336 } 314 337 } 315 338 ··· 343 366 } 344 367 } 345 368 346 - if let Some(ref signal) = input.signal_number { 347 - if signal.is_empty() { 369 + if let Some(ref signal) = input.signal_username { 370 + let signal_clean = signal.trim().trim_start_matches('@').to_lowercase(); 371 + if signal_clean.is_empty() { 348 372 if effective_channel == CommsChannel::Signal { 349 373 return Err(ApiError::InvalidRequest( 350 374 "Cannot remove Signal while it is the preferred notification channel".into(), ··· 355 379 .clear_signal(user_id) 356 380 .await 357 381 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 358 - info!(did = %auth.did, "Cleared Signal number"); 382 + info!(did = %auth.did, "Cleared Signal username"); 383 + } else if !crate::comms::is_valid_signal_username(&signal_clean) { 384 + return Err(ApiError::InvalidRequest( 385 + "Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)" 386 + .into(), 387 + )); 359 388 } else { 360 - request_channel_verification(&state, user_id, &auth.did, "signal", signal, None) 389 + state 390 + .user_repo 391 + .set_unverified_signal(user_id, &signal_clean) 392 + .await 393 + .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 394 + request_channel_verification(&state, user_id, &auth.did, "signal", &signal_clean, None) 361 395 .await 362 396 .map_err(|e| ApiError::InternalError(Some(e)))?; 363 397 verification_required.push("signal".to_string()); 364 - info!(did = %auth.did, "Requested Signal verification"); 398 + info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username"); 365 399 } 366 400 } 367 401
+18 -1
crates/tranquil-pds/src/api/repo/import.rs
··· 55 55 return Err(ApiError::AccountTakedown); 56 56 } 57 57 let user_id = user.id; 58 + let expected_root_cid = state 59 + .repo_repo 60 + .get_repo_root_cid_by_user_id(user_id) 61 + .await 62 + .map_err(|e| { 63 + error!("DB error fetching repo root: {:?}", e); 64 + ApiError::InternalError(None) 65 + })?; 58 66 let (root, blocks) = match parse_car(&body).await { 59 67 Ok((r, b)) => (r, b), 60 68 Err(ImportError::InvalidRootCount) => { ··· 191 199 .and_then(|s| s.parse().ok()) 192 200 .unwrap_or(DEFAULT_MAX_BLOCKS); 193 201 let _write_lock = state.repo_write_locks.lock(user_id).await; 194 - match apply_import(&state.repo_repo, user_id, root, blocks.clone(), max_blocks).await { 202 + match apply_import( 203 + &state.repo_repo, 204 + user_id, 205 + root, 206 + blocks.clone(), 207 + max_blocks, 208 + expected_root_cid.as_ref(), 209 + ) 210 + .await 211 + { 195 212 Ok(import_result) => { 196 213 info!( 197 214 "Successfully imported {} records for user {}",
+1
crates/tranquil-pds/src/api/server/email.rs
··· 367 367 crate::auth::verification_token::format_token_for_display(&verification_token); 368 368 let hostname = pds_hostname(); 369 369 if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 370 + state.user_repo.as_ref(), 370 371 state.infra_repo.as_ref(), 371 372 user_id, 372 373 "email",
+8 -2
crates/tranquil-pds/src/api/server/meta.rs
··· 1 1 use crate::state::AppState; 2 - use crate::util::{pds_hostname, telegram_bot_username}; 2 + use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username}; 3 3 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 4 4 use serde_json::json; 5 5 6 6 fn get_available_comms_channels() -> Vec<&'static str> { 7 7 let mut channels = vec!["email"]; 8 - if std::env::var("DISCORD_WEBHOOK_URL").is_ok() { 8 + if std::env::var("DISCORD_BOT_TOKEN").is_ok() { 9 9 channels.push("discord"); 10 10 } 11 11 if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { ··· 62 62 "availableCommsChannels": get_available_comms_channels(), 63 63 "selfHostedDidWebEnabled": is_self_hosted_did_web_enabled() 64 64 }); 65 + if let Some(bot_username) = discord_bot_username() { 66 + response["discordBotUsername"] = json!(bot_username); 67 + } 68 + if let Some(app_id) = discord_app_id() { 69 + response["discordAppId"] = json!(app_id); 70 + } 65 71 if let Some(bot_username) = telegram_bot_username() { 66 72 response["telegramBotUsername"] = json!(bot_username); 67 73 }
+25 -15
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 50 50 pub did_type: Option<String>, 51 51 pub signing_key: Option<String>, 52 52 pub verification_channel: Option<String>, 53 - pub discord_id: Option<String>, 53 + pub discord_username: Option<String>, 54 54 pub telegram_username: Option<String>, 55 - pub signal_number: Option<String>, 55 + pub signal_username: Option<String>, 56 56 } 57 57 58 58 #[derive(Serialize)] ··· 167 167 Some(e) if !e.is_empty() => e.clone(), 168 168 _ => return ApiError::MissingEmail.into_response(), 169 169 }, 170 - "discord" => match &input.discord_id { 171 - Some(id) if !id.trim().is_empty() => id.trim().to_string(), 170 + "discord" => match &input.discord_username { 171 + Some(username) if !username.trim().is_empty() => { 172 + let clean = username.trim().to_lowercase(); 173 + if !crate::api::validation::is_valid_discord_username(&clean) { 174 + return ApiError::InvalidRequest( 175 + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), 176 + ).into_response(); 177 + } 178 + clean 179 + } 172 180 _ => return ApiError::MissingDiscordId.into_response(), 173 181 }, 174 182 "telegram" => match &input.telegram_username { ··· 183 191 } 184 192 _ => return ApiError::MissingTelegramUsername.into_response(), 185 193 }, 186 - "signal" => match &input.signal_number { 187 - Some(number) if !number.trim().is_empty() => number.trim().to_string(), 194 + "signal" => match &input.signal_username { 195 + Some(username) if !username.trim().is_empty() => { 196 + username.trim().trim_start_matches('@').to_lowercase() 197 + } 188 198 _ => return ApiError::MissingSignalNumber.into_response(), 189 199 }, 190 200 _ => return ApiError::InvalidVerificationChannel.into_response(), ··· 409 419 email: email.clone().unwrap_or_default(), 410 420 did: did_typed.clone(), 411 421 preferred_comms_channel, 412 - discord_id: input 413 - .discord_id 422 + discord_username: input 423 + .discord_username 414 424 .as_deref() 415 - .map(|s| s.trim()) 416 - .filter(|s| !s.is_empty()) 417 - .map(String::from), 425 + .map(|s| s.trim().to_lowercase()) 426 + .filter(|s| !s.is_empty()), 418 427 telegram_username: input 419 428 .telegram_username 420 429 .as_deref() 421 430 .map(|s| s.trim().trim_start_matches('@')) 422 431 .filter(|s| !s.is_empty()) 423 432 .map(String::from), 424 - signal_number: input 425 - .signal_number 433 + signal_username: input 434 + .signal_username 426 435 .as_deref() 427 - .map(|s| s.trim()) 436 + .map(|s| s.trim().trim_start_matches('@')) 428 437 .filter(|s| !s.is_empty()) 429 - .map(String::from), 438 + .map(|s| s.to_lowercase()), 430 439 setup_token_hash, 431 440 setup_expires_at, 432 441 deactivated_at, ··· 501 510 let formatted_token = 502 511 crate::auth::verification_token::format_token_for_display(&verification_token); 503 512 if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 513 + state.user_repo.as_ref(), 504 514 state.infra_repo.as_ref(), 505 515 user_id, 506 516 verification_channel,
+5 -4
crates/tranquil-pds/src/api/server/session.rs
··· 634 634 let (channel_str, identifier) = match row.channel { 635 635 tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), 636 636 tranquil_db_traits::CommsChannel::Discord => { 637 - ("discord", row.discord_id.clone().unwrap_or_default()) 637 + ("discord", row.discord_username.clone().unwrap_or_default()) 638 638 } 639 639 tranquil_db_traits::CommsChannel::Telegram => ( 640 640 "telegram", 641 641 row.telegram_username.clone().unwrap_or_default(), 642 642 ), 643 643 tranquil_db_traits::CommsChannel::Signal => { 644 - ("signal", row.signal_number.clone().unwrap_or_default()) 644 + ("signal", row.signal_username.clone().unwrap_or_default()) 645 645 } 646 646 }; 647 647 ··· 786 786 let (channel_str, recipient) = match row.channel { 787 787 tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), 788 788 tranquil_db_traits::CommsChannel::Discord => { 789 - ("discord", row.discord_id.clone().unwrap_or_default()) 789 + ("discord", row.discord_username.clone().unwrap_or_default()) 790 790 } 791 791 tranquil_db_traits::CommsChannel::Telegram => ( 792 792 "telegram", 793 793 row.telegram_username.clone().unwrap_or_default(), 794 794 ), 795 795 tranquil_db_traits::CommsChannel::Signal => { 796 - ("signal", row.signal_number.clone().unwrap_or_default()) 796 + ("signal", row.signal_username.clone().unwrap_or_default()) 797 797 } 798 798 }; 799 799 ··· 804 804 805 805 let hostname = pds_hostname(); 806 806 if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 807 + state.user_repo.as_ref(), 807 808 state.infra_repo.as_ref(), 808 809 row.id, 809 810 channel_str,
+35 -3
crates/tranquil-pds/src/api/validation.rs
··· 351 351 352 352 pub fn is_valid_telegram_username(username: &str) -> bool { 353 353 let clean = username.strip_prefix('@').unwrap_or(username); 354 - (5..=32).contains(&clean.len()) 355 - && clean 354 + (5..=32).contains(&clean.len()) && clean.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') 355 + } 356 + 357 + pub fn is_valid_discord_username(username: &str) -> bool { 358 + (2..=32).contains(&username.len()) 359 + && username 356 360 .chars() 357 - .all(|c| c.is_ascii_alphanumeric() || c == '_') 361 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.') 362 + && !username.starts_with('.') 363 + && !username.ends_with('.') 364 + && !username.contains("..") 358 365 } 359 366 360 367 #[cfg(test)] ··· 534 541 fn test_trimmed_whitespace() { 535 542 assert!(is_valid_email(" user@example.com ")); 536 543 } 544 + 545 + #[test] 546 + fn test_valid_discord_usernames() { 547 + assert!(is_valid_discord_username("ab")); 548 + assert!(is_valid_discord_username("alice")); 549 + assert!(is_valid_discord_username("user_name")); 550 + assert!(is_valid_discord_username("user.name")); 551 + assert!(is_valid_discord_username("user123")); 552 + assert!(is_valid_discord_username("a_b.c_d")); 553 + assert!(is_valid_discord_username("12345678901234567890123456789012")); 554 + } 555 + 556 + #[test] 557 + fn test_invalid_discord_usernames() { 558 + assert!(!is_valid_discord_username("")); 559 + assert!(!is_valid_discord_username("a")); 560 + assert!(!is_valid_discord_username("Alice")); 561 + assert!(!is_valid_discord_username("ALICE")); 562 + assert!(!is_valid_discord_username("user-name")); 563 + assert!(!is_valid_discord_username(".username")); 564 + assert!(!is_valid_discord_username("username.")); 565 + assert!(!is_valid_discord_username("user..name")); 566 + assert!(!is_valid_discord_username("user name")); 567 + assert!(!is_valid_discord_username("123456789012345678901234567890123")); 568 + } 537 569 }
+2 -2
crates/tranquil-pds/src/comms/mod.rs
··· 3 3 pub use tranquil_comms::{ 4 4 CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender, 5 5 NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender, 6 - VALID_LOCALES, format_message, get_strings, is_valid_phone_number, mime_encode_header, 7 - sanitize_header_value, validate_locale, 6 + VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username, 7 + mime_encode_header, sanitize_header_value, validate_locale, 8 8 }; 9 9 10 10 pub use service::{CommsService, channel_display_name, repo as comms_repo};
+20 -24
crates/tranquil-pds/src/comms/service.rs
··· 281 281 }) 282 282 .unwrap_or_else(email_fallback), 283 283 tranquil_db_traits::CommsChannel::Signal => prefs 284 - .signal_number 284 + .signal_username 285 285 .as_ref() 286 286 .filter(|n| !n.is_empty()) 287 287 .map(|n| ResolvedRecipient { ··· 639 639 } 640 640 641 641 pub async fn enqueue_signup_verification( 642 + user_repo: &dyn UserRepository, 642 643 infra_repo: &dyn InfraRepository, 643 644 user_id: Uuid, 644 645 channel: &str, ··· 647 648 hostname: &str, 648 649 ) -> Result<Uuid, DbError> { 649 650 let comms_channel = channel_from_str(channel); 650 - let strings = get_strings("en"); 651 - let (verify_page, verify_link) = match comms_channel { 652 - tranquil_db_traits::CommsChannel::Email => { 653 - let encoded_email = urlencoding::encode(recipient); 654 - let encoded_token = urlencoding::encode(code); 655 - ( 656 - format!("https://{}/app/verify", hostname), 657 - format!( 658 - "https://{}/app/verify?token={}&identifier={}", 659 - hostname, encoded_token, encoded_email 660 - ), 661 - ) 662 - } 663 - _ => (String::new(), String::new()), 664 - }; 651 + let prefs = user_repo.get_comms_prefs(user_id).await.ok().flatten(); 652 + let locale = prefs 653 + .as_ref() 654 + .and_then(|p| p.preferred_locale.as_deref()) 655 + .unwrap_or("en"); 656 + let strings = get_strings(locale); 657 + let encoded_token = urlencoding::encode(code); 658 + let encoded_recipient = urlencoding::encode(recipient); 659 + let verify_page = format!("https://{}/app/verify", hostname); 660 + let verify_link = format!( 661 + "https://{}/app/verify?token={}&identifier={}", 662 + hostname, encoded_token, encoded_recipient 663 + ); 665 664 let body = format_message( 666 665 strings.signup_verification_body, 667 666 &[ ··· 671 670 ("verify_link", &verify_link), 672 671 ], 673 672 ); 674 - let subject = match comms_channel { 675 - tranquil_db_traits::CommsChannel::Email => Some(format_message( 676 - strings.signup_verification_subject, 677 - &[("hostname", hostname)], 678 - )), 679 - _ => None, 680 - }; 673 + let subject = format_message( 674 + strings.signup_verification_subject, 675 + &[("hostname", hostname)], 676 + ); 681 677 infra_repo 682 678 .enqueue_comms( 683 679 Some(user_id), 684 680 comms_channel, 685 681 CommsType::EmailVerification, 686 682 recipient, 687 - subject.as_deref(), 683 + Some(&subject), 688 684 &body, 689 685 None, 690 686 )
+7 -1
crates/tranquil-pds/src/lib.rs
··· 645 645 .route("/u/{handle}/did.json", get(api::identity::user_did_doc)) 646 646 .route( 647 647 "/webhook/telegram", 648 - post(api::telegram_webhook::handle_telegram_webhook), 648 + post(api::telegram_webhook::handle_telegram_webhook) 649 + .layer(DefaultBodyLimit::max(64 * 1024)), 650 + ) 651 + .route( 652 + "/webhook/discord", 653 + post(api::discord_webhook::handle_discord_webhook) 654 + .layer(DefaultBodyLimit::max(64 * 1024)), 649 655 ) 650 656 .layer(DefaultBodyLimit::max(util::get_max_blob_size())) 651 657 .layer(middleware::from_fn(metrics::metrics_middleware))
+62 -3
crates/tranquil-pds/src/main.rs
··· 64 64 }); 65 65 66 66 let mut comms_service = CommsService::new(state.infra_repo.clone()); 67 + let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None; 67 68 68 69 if let Some(email_sender) = EmailSender::from_env() { 69 70 info!("Email comms enabled"); ··· 74 75 75 76 if let Some(discord_sender) = DiscordSender::from_env() { 76 77 info!("Discord comms enabled"); 78 + match discord_sender.resolve_bot_username().await { 79 + Ok(username) => { 80 + info!(bot_username = %username, "Resolved Discord bot username"); 81 + tranquil_pds::util::set_discord_bot_username(username); 82 + } 83 + Err(e) => { 84 + warn!("Failed to resolve Discord bot username: {}", e); 85 + } 86 + } 87 + match discord_sender.resolve_application_info().await { 88 + Ok((app_id, verify_key)) => { 89 + info!(app_id = %app_id, "Resolved Discord application info"); 90 + tranquil_pds::util::set_discord_app_id(app_id.clone()); 91 + match hex::decode(&verify_key) 92 + .ok() 93 + .and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()) 94 + .and_then(|bytes| ed25519_dalek::VerifyingKey::from_bytes(&bytes).ok()) 95 + { 96 + Some(public_key) => { 97 + tranquil_pds::util::set_discord_public_key(public_key); 98 + info!("Discord Ed25519 public key loaded"); 99 + let hostname = std::env::var("PDS_HOSTNAME") 100 + .unwrap_or_else(|_| "localhost".to_string()); 101 + let webhook_url = format!("https://{}/webhook/discord", hostname); 102 + match discord_sender.register_slash_command(&app_id).await { 103 + Ok(()) => info!("Discord /start slash command registered"), 104 + Err(e) => warn!("Failed to register Discord slash command: {}", e), 105 + } 106 + deferred_discord_endpoint = 107 + Some((discord_sender.clone(), app_id, webhook_url)); 108 + } 109 + None => { 110 + warn!("Failed to parse Discord verify_key as Ed25519 public key"); 111 + } 112 + } 113 + } 114 + Err(e) => { 115 + warn!("Failed to resolve Discord application info: {}", e); 116 + } 117 + } 77 118 comms_service = comms_service.register_sender(discord_sender); 78 119 } 79 120 ··· 172 213 .await 173 214 .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?; 174 215 175 - let server_result = axum::serve(listener, app) 176 - .with_graceful_shutdown(shutdown.clone().cancelled_owned()) 177 - .await; 216 + let server_handle = tokio::spawn(async move { 217 + axum::serve(listener, app) 218 + .with_graceful_shutdown(shutdown.clone().cancelled_owned()) 219 + .await 220 + }); 221 + 222 + if let Some((sender, app_id, webhook_url)) = deferred_discord_endpoint { 223 + tokio::spawn(async move { 224 + match sender 225 + .set_interactions_endpoint(&app_id, &webhook_url) 226 + .await 227 + { 228 + Ok(()) => info!(url = %webhook_url, "Discord interactions endpoint registered"), 229 + Err(e) => warn!("Failed to set Discord interactions endpoint: {}", e), 230 + } 231 + }); 232 + } 233 + 234 + let server_result = server_handle 235 + .await 236 + .map_err(|e| format!("Server task panicked: {}", e))?; 178 237 179 238 comms_handle.await.ok(); 180 239
+23 -12
crates/tranquil-pds/src/sso/endpoints.rs
··· 817 817 pub email: Option<String>, 818 818 pub invite_code: Option<String>, 819 819 pub verification_channel: Option<String>, 820 - pub discord_id: Option<String>, 820 + pub discord_username: Option<String>, 821 821 pub telegram_username: Option<String>, 822 - pub signal_number: Option<String>, 822 + pub signal_username: Option<String>, 823 823 pub did_type: Option<String>, 824 824 pub did: Option<String>, 825 825 } ··· 894 894 _ => return Err(ApiError::MissingEmail), 895 895 } 896 896 } 897 - "discord" => match &input.discord_id { 898 - Some(id) if !id.trim().is_empty() => id.trim().to_string(), 897 + "discord" => match &input.discord_username { 898 + Some(username) if !username.trim().is_empty() => { 899 + let clean = username.trim().to_lowercase(); 900 + if !crate::api::validation::is_valid_discord_username(&clean) { 901 + return Err(ApiError::InvalidRequest( 902 + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), 903 + )); 904 + } 905 + clean 906 + } 899 907 _ => return Err(ApiError::MissingDiscordId), 900 908 }, 901 909 "telegram" => match &input.telegram_username { ··· 910 918 } 911 919 _ => return Err(ApiError::MissingTelegramUsername), 912 920 }, 913 - "signal" => match &input.signal_number { 914 - Some(number) if !number.trim().is_empty() => number.trim().to_string(), 921 + "signal" => match &input.signal_username { 922 + Some(username) if !username.trim().is_empty() => { 923 + username.trim().trim_start_matches('@').to_lowercase() 924 + } 915 925 _ => return Err(ApiError::MissingSignalNumber), 916 926 }, 917 927 _ => return Err(ApiError::InvalidVerificationChannel), ··· 1104 1114 email: email.clone(), 1105 1115 did: did_typed.clone(), 1106 1116 preferred_comms_channel, 1107 - discord_id: input 1108 - .discord_id 1117 + discord_username: input 1118 + .discord_username 1109 1119 .clone() 1110 - .map(|s| s.trim().to_string()) 1120 + .map(|s| s.trim().to_lowercase()) 1111 1121 .filter(|s| !s.is_empty()), 1112 1122 telegram_username: input 1113 1123 .telegram_username 1114 1124 .clone() 1115 1125 .map(|s| s.trim().trim_start_matches('@').to_string()) 1116 1126 .filter(|s| !s.is_empty()), 1117 - signal_number: input 1118 - .signal_number 1127 + signal_username: input 1128 + .signal_username 1119 1129 .clone() 1120 - .map(|s| s.trim().to_string()) 1130 + .map(|s| s.trim().trim_start_matches('@').to_lowercase()) 1121 1131 .filter(|s| !s.is_empty()), 1122 1132 encrypted_key_bytes: encrypted_key_bytes.clone(), 1123 1133 encryption_version: crate::config::ENCRYPTION_VERSION, ··· 1354 1364 let formatted_token = 1355 1365 crate::auth::verification_token::format_token_for_display(&verification_token); 1356 1366 if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( 1367 + state.user_repo.as_ref(), 1357 1368 state.infra_repo.as_ref(), 1358 1369 uid, 1359 1370 verification_channel,
+3 -1
crates/tranquil-pds/src/sync/import.rs
··· 9 9 use thiserror::Error; 10 10 use tracing::debug; 11 11 use tranquil_db::{ImportBlock, ImportRecord, ImportRepoError, RepoRepository}; 12 + use tranquil_types::CidLink; 12 13 use uuid::Uuid; 13 14 14 15 #[derive(Error, Debug)] ··· 323 324 root: Cid, 324 325 blocks: HashMap<Cid, Bytes>, 325 326 max_blocks: usize, 327 + expected_root_cid: Option<&CidLink>, 326 328 ) -> Result<ImportResult, ImportError> { 327 329 if blocks.len() > max_blocks { 328 330 return Err(ImportError::SizeLimitExceeded); ··· 364 366 .collect(); 365 367 366 368 repo_repo 367 - .import_repo_data(user_id, &import_blocks, &import_records) 369 + .import_repo_data(user_id, &import_blocks, &import_records, expected_root_cid) 368 370 .await?; 369 371 370 372 debug!(
+27
crates/tranquil-pds/src/util.rs
··· 14 14 static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new(); 15 15 static PDS_HOSTNAME: OnceLock<String> = OnceLock::new(); 16 16 static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new(); 17 + static DISCORD_BOT_USERNAME: OnceLock<String> = OnceLock::new(); 18 + static DISCORD_PUBLIC_KEY: OnceLock<ed25519_dalek::VerifyingKey> = OnceLock::new(); 19 + static DISCORD_APP_ID: OnceLock<String> = OnceLock::new(); 17 20 static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new(); 18 21 19 22 pub fn get_max_blob_size() -> usize { ··· 105 108 }) 106 109 } 107 110 111 + pub fn set_discord_bot_username(username: String) { 112 + DISCORD_BOT_USERNAME.set(username).ok(); 113 + } 114 + 115 + pub fn discord_bot_username() -> Option<&'static str> { 116 + DISCORD_BOT_USERNAME.get().map(|s| s.as_str()) 117 + } 118 + 119 + pub fn set_discord_public_key(key: ed25519_dalek::VerifyingKey) { 120 + DISCORD_PUBLIC_KEY.set(key).ok(); 121 + } 122 + 123 + pub fn discord_public_key() -> Option<&'static ed25519_dalek::VerifyingKey> { 124 + DISCORD_PUBLIC_KEY.get() 125 + } 126 + 127 + pub fn set_discord_app_id(app_id: String) { 128 + DISCORD_APP_ID.set(app_id).ok(); 129 + } 130 + 131 + pub fn discord_app_id() -> Option<&'static str> { 132 + DISCORD_APP_ID.get().map(|s| s.as_str()) 133 + } 134 + 108 135 pub fn set_telegram_bot_username(username: String) { 109 136 TELEGRAM_BOT_USERNAME.set(username).ok(); 110 137 }
+4 -41
crates/tranquil-pds/tests/account_notifications.rs
··· 1 1 mod common; 2 2 use common::{base_url, client, create_account_and_login, get_test_db_pool}; 3 3 use serde_json::{Value, json}; 4 - use sqlx::Row; 5 4 6 5 #[tokio::test] 7 6 async fn test_get_notification_history() { ··· 51 50 async fn test_verify_channel_discord() { 52 51 let client = client(); 53 52 let base = base_url().await; 54 - let (token, did) = create_account_and_login(&client).await; 53 + let (token, _did) = create_account_and_login(&client).await; 55 54 56 55 let prefs = json!({ 57 - "discordId": "123456789" 56 + "discordUsername": "testuser123" 58 57 }); 59 58 let resp = client 60 59 .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) ··· 72 71 .contains(&json!("discord")) 73 72 ); 74 73 75 - let pool = get_test_db_pool().await; 76 - let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE did = $1") 77 - .bind(&did) 78 - .fetch_one(pool) 79 - .await 80 - .expect("User not found"); 81 - 82 - let row = sqlx::query( 83 - "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1", 84 - ) 85 - .bind(user_id) 86 - .fetch_one(pool) 87 - .await 88 - .expect("Verification code not found"); 89 - 90 - let metadata: Option<serde_json::Value> = row.get("metadata"); 91 - let code = metadata 92 - .as_ref() 93 - .and_then(|m| m.get("code")) 94 - .and_then(|c| c.as_str()) 95 - .expect("No code in metadata"); 96 - 97 - let input = json!({ 98 - "channel": "discord", 99 - "identifier": "123456789", 100 - "code": code 101 - }); 102 - let resp = client 103 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 104 - .header("Authorization", format!("Bearer {}", token)) 105 - .json(&input) 106 - .send() 107 - .await 108 - .unwrap(); 109 - assert_eq!(resp.status(), 200); 110 - 111 74 let resp = client 112 75 .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 113 76 .header("Authorization", format!("Bearer {}", token)) ··· 115 78 .await 116 79 .unwrap(); 117 80 let body: Value = resp.json().await.unwrap(); 118 - assert_eq!(body["discordVerified"], true); 119 - assert_eq!(body["discordId"], "123456789"); 81 + assert_eq!(body["discordVerified"], false); 82 + assert_eq!(body["discordUsername"], "testuser123"); 120 83 } 121 84 122 85 #[tokio::test]
+33 -1
crates/tranquil-pds/tests/security_fixes.rs
··· 1 1 mod common; 2 - use tranquil_pds::comms::{SendError, is_valid_phone_number, sanitize_header_value}; 2 + use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value}; 3 3 use tranquil_pds::image::{ImageError, ImageProcessor}; 4 4 5 5 #[test] ··· 80 80 } 81 81 } 82 82 83 + #[test] 84 + fn test_signal_username_validation() { 85 + assert!(is_valid_signal_username("alice.01")); 86 + assert!(is_valid_signal_username("bob_smith.99")); 87 + assert!(is_valid_signal_username("user123.42")); 88 + assert!(is_valid_signal_username("lu1.01")); 89 + assert!(is_valid_signal_username("abc.00")); 90 + assert!(is_valid_signal_username("a_very_long_username_here.55")); 91 + 92 + assert!(!is_valid_signal_username("alice")); 93 + assert!(!is_valid_signal_username("alice.1")); 94 + assert!(!is_valid_signal_username("alice.001")); 95 + assert!(!is_valid_signal_username(".01")); 96 + assert!(!is_valid_signal_username("ab.01")); 97 + assert!(!is_valid_signal_username("")); 98 + assert!(!is_valid_signal_username("1alice.01")); 99 + assert!(!is_valid_signal_username("alice!.01")); 100 + assert!(!is_valid_signal_username("alice .01")); 101 + 102 + assert!(!is_valid_signal_username("a".repeat(33).as_str())); 103 + 104 + ["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"] 105 + .iter() 106 + .for_each(|malicious| { 107 + assert!( 108 + !is_valid_signal_username(malicious), 109 + "Command injection '{}' should be rejected", 110 + malicious 111 + ); 112 + }); 113 + } 114 + 83 115 #[test] 84 116 fn test_image_file_size_limits() { 85 117 let processor = ImageProcessor::new();
+3 -3
crates/tranquil-pds/tests/sso.rs
··· 1035 1035 "token": token, 1036 1036 "handle": handle_prefix, 1037 1037 "verification_channel": "discord", 1038 - "discord_id": discord_id 1038 + "discord_username": discord_id 1039 1039 })) 1040 1040 .send() 1041 1041 .await ··· 1054 1054 1055 1055 let did_str = body["did"].as_str().unwrap(); 1056 1056 let user = sqlx::query!( 1057 - r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_id FROM users WHERE did = $1"#, 1057 + r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_username FROM users WHERE did = $1"#, 1058 1058 did_str, 1059 1059 ) 1060 1060 .fetch_one(pool) ··· 1062 1062 .unwrap(); 1063 1063 1064 1064 assert_eq!(user.preferred_comms_channel, "discord"); 1065 - assert_eq!(user.discord_id, Some(discord_id.to_string())); 1065 + assert_eq!(user.discord_username, Some(discord_id.to_string())); 1066 1066 } 1067 1067 1068 1068 #[tokio::test]
+17 -4
crates/tranquil-pds/tests/whole_story.rs
··· 1948 1948 .expect("Import request failed"); 1949 1949 let status = res.status(); 1950 1950 let body: Value = res.json().await.unwrap_or_default(); 1951 - assert_eq!(status, StatusCode::OK, "Import should succeed: {:?}", body); 1951 + let is_concurrent_modification = status == StatusCode::BAD_REQUEST 1952 + && body.get("error").and_then(|e| e.as_str()) == Some("InvalidSwap"); 1953 + assert!( 1954 + status == StatusCode::OK || is_concurrent_modification, 1955 + "Import should succeed or fail with InvalidSwap due to concurrent writes: {:?}", 1956 + body 1957 + ); 1958 + status == StatusCode::OK 1952 1959 } 1953 1960 }; 1954 1961 ··· 1987 1994 }) 1988 1995 .collect(); 1989 1996 1990 - tokio::join!(import_future, join_all(write_futures)); 1997 + let (import_succeeded, _) = tokio::join!(import_future, join_all(write_futures)); 1991 1998 1992 1999 let final_posts = client 1993 2000 .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) ··· 2003 2010 let final_body: Value = final_posts.json().await.unwrap(); 2004 2011 let record_count = final_body["records"].as_array().unwrap().len(); 2005 2012 2006 - let min_expected = write_count; 2013 + let min_expected = if import_succeeded { 2014 + write_count + 1 2015 + } else { 2016 + write_count 2017 + }; 2007 2018 assert!( 2008 2019 record_count >= min_expected, 2009 - "Expected at least {} records (from writes), got {} (import may also contribute records)", 2020 + "Expected at least {} records (writes={}, import_succeeded={}), got {}", 2010 2021 min_expected, 2022 + write_count, 2023 + import_succeeded, 2011 2024 record_count 2012 2025 ); 2013 2026 }
+51 -36
frontend/src/components/dashboard/CommsContent.svelte
··· 18 18 let preferredChannel = $state('email') 19 19 let availableCommsChannels = $state<string[]>(['email']) 20 20 let telegramBotUsername = $state<string | undefined>(undefined) 21 + let discordBotUsername = $state<string | undefined>(undefined) 22 + let discordAppId = $state<string | undefined>(undefined) 21 23 let email = $state('') 22 - let discordId = $state('') 24 + let discordUsername = $state('') 23 25 let discordVerified = $state(false) 24 26 let telegramUsername = $state('') 25 27 let telegramVerified = $state(false) 26 - let signalNumber = $state('') 28 + let signalUsername = $state('') 27 29 let signalVerified = $state(false) 28 - let savedDiscordId = $state('') 30 + let savedDiscordUsername = $state('') 29 31 let savedTelegramUsername = $state('') 30 - let savedSignalNumber = $state('') 32 + let savedSignalUsername = $state('') 31 33 let verifyingChannel = $state<string | null>(null) 32 34 let verificationCode = $state('') 33 35 let historyLoading = $state(true) ··· 57 59 ]) 58 60 preferredChannel = prefs.preferredChannel 59 61 email = prefs.email 60 - discordId = prefs.discordId ?? '' 62 + discordUsername = prefs.discordUsername ?? '' 61 63 discordVerified = prefs.discordVerified 62 64 telegramUsername = prefs.telegramUsername ?? '' 63 65 telegramVerified = prefs.telegramVerified 64 - signalNumber = prefs.signalNumber ?? '' 66 + signalUsername = prefs.signalUsername ?? '' 65 67 signalVerified = prefs.signalVerified 66 - savedDiscordId = discordId 68 + savedDiscordUsername = discordUsername 67 69 savedTelegramUsername = telegramUsername 68 - savedSignalNumber = signalNumber 70 + savedSignalUsername = signalUsername 69 71 availableCommsChannels = serverInfo.availableCommsChannels ?? ['email'] 70 72 telegramBotUsername = serverInfo.telegramBotUsername 73 + discordBotUsername = serverInfo.discordBotUsername 74 + discordAppId = serverInfo.discordAppId 71 75 } catch (e) { 72 76 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad')) 73 77 } finally { ··· 81 85 try { 82 86 const result = await api.updateNotificationPrefs(session.accessJwt, { 83 87 preferredChannel, 84 - discordId: discordId !== savedDiscordId ? discordId : undefined, 88 + discordUsername: discordUsername !== savedDiscordUsername ? discordUsername : undefined, 85 89 telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined, 86 - signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined, 90 + signalUsername: signalUsername !== savedSignalUsername ? signalUsername : undefined, 87 91 }) 88 92 await refreshSession() 89 93 toast.success($_('comms.preferencesSaved')) 90 - savedDiscordId = discordId 94 + savedDiscordUsername = discordUsername 91 95 savedTelegramUsername = telegramUsername 92 - savedSignalNumber = signalNumber 96 + savedSignalUsername = signalUsername 93 97 const channelToVerify = result.verificationRequired?.find( 94 98 (ch: string) => ch === 'discord' || ch === 'telegram' || ch === 'signal' 95 99 ) ··· 108 112 if (!verificationCode) return 109 113 110 114 const identifierMap: Record<string, string> = { 111 - discord: discordId, 115 + discord: discordUsername, 112 116 telegram: telegramUsername, 113 - signal: signalNumber 117 + signal: signalUsername 114 118 } 115 119 const identifier = identifierMap[channel] 116 120 if (!identifier) return ··· 190 194 if (!isChannelAvailableOnServer(channelId)) return false 191 195 if (channelId === 'email') return true 192 196 const hasIdentifier: Record<string, boolean> = { 193 - discord: !!discordId, 197 + discord: !!discordUsername, 194 198 telegram: !!telegramUsername, 195 - signal: !!signalNumber 199 + signal: !!signalUsername 196 200 } 197 201 return hasIdentifier[channelId] ?? false 198 202 } ··· 245 249 {#if isChannelAvailableOnServer('discord')} 246 250 <div class="config-item"> 247 251 <div class="config-header"> 248 - <label for="discord">{$_('register.discordId')}</label> 249 - {#if discordId} 252 + <label for="discord">{$_('register.discordUsername')}</label> 253 + {#if discordUsername} 250 254 <span class="status" class:verified={discordVerified} class:unverified={!discordVerified}> 251 255 {preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')} 252 256 </span> ··· 256 260 <input 257 261 id="discord" 258 262 type="text" 259 - bind:value={discordId} 260 - onblur={() => checkChannelInUse('discord', discordId)} 261 - placeholder={$_('register.discordIdPlaceholder')} 263 + bind:value={discordUsername} 264 + onblur={() => checkChannelInUse('discord', discordUsername)} 265 + placeholder={$_('register.discordUsernamePlaceholder')} 262 266 disabled={saving} 263 267 /> 264 - {#if discordId && discordId === savedDiscordId && !discordVerified} 265 - <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button> 266 - {/if} 267 268 </div> 268 269 {#if discordInUse} 269 270 <p class="hint warning">{$_('comms.discordInUseWarning')}</p> 270 271 {/if} 271 - {#if verifyingChannel === 'discord'} 272 - <div class="verify-form"> 273 - <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" /> 274 - <button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button> 275 - <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 272 + {#if discordUsername && discordUsername === savedDiscordUsername && !discordVerified && discordBotUsername} 273 + {@const encodedHandle = session.handle.replaceAll('.', '_')} 274 + <div class="discord-verify-prompt"> 275 + {#if discordAppId} 276 + <a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('comms.discordOpenLink')}</a> 277 + {/if} 278 + <span class="manual-hint">{$_('comms.discordStartBot', { values: { botUsername: discordBotUsername, handle: session.handle } })}</span> 276 279 </div> 277 280 {/if} 278 281 </div> ··· 314 317 {#if isChannelAvailableOnServer('signal')} 315 318 <div class="config-item"> 316 319 <div class="config-header"> 317 - <label for="signal">{$_('register.signalNumber')}</label> 318 - {#if signalNumber} 320 + <label for="signal">{$_('register.signalUsername')}</label> 321 + {#if signalUsername} 319 322 <span class="status" class:verified={signalVerified} class:unverified={!signalVerified}> 320 323 {preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')} 321 324 </span> ··· 324 327 <div class="config-input"> 325 328 <input 326 329 id="signal" 327 - type="tel" 328 - bind:value={signalNumber} 329 - onblur={() => checkChannelInUse('signal', signalNumber)} 330 - placeholder={$_('register.signalNumberPlaceholder')} 330 + type="text" 331 + bind:value={signalUsername} 332 + onblur={() => checkChannelInUse('signal', signalUsername)} 333 + placeholder={$_('register.signalUsernamePlaceholder')} 331 334 disabled={saving} 332 335 /> 333 - {#if signalNumber && signalNumber === savedSignalNumber && !signalVerified} 336 + {#if signalUsername && signalUsername === savedSignalUsername && !signalVerified} 334 337 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button> 335 338 {/if} 336 339 </div> ··· 533 536 color: var(--text-primary); 534 537 } 535 538 539 + .discord-verify-prompt { 540 + display: flex; 541 + flex-direction: column; 542 + gap: var(--space-2); 543 + padding: var(--space-3) var(--space-4); 544 + background: var(--accent-bg, var(--bg-card)); 545 + border: 1px solid var(--accent, var(--border-color)); 546 + border-radius: var(--radius-md); 547 + font-size: var(--text-sm); 548 + color: var(--text-primary); 549 + } 550 + 536 551 .manual-hint { 537 552 font-size: var(--text-xs); 538 553 color: var(--text-secondary);
+8 -8
frontend/src/lib/api.ts
··· 404 404 did: params.did, 405 405 signingKey: params.signingKey, 406 406 verificationChannel: params.verificationChannel, 407 - discordId: params.discordId, 407 + discordUsername: params.discordUsername, 408 408 telegramUsername: params.telegramUsername, 409 - signalNumber: params.signalNumber, 409 + signalUsername: params.signalUsername, 410 410 }), 411 411 }); 412 412 const data = await response.json(); ··· 656 656 657 657 updateNotificationPrefs(token: AccessToken, prefs: { 658 658 preferredChannel?: string; 659 - discordId?: string; 659 + discordUsername?: string; 660 660 telegramUsername?: string; 661 - signalNumber?: string; 661 + signalUsername?: string; 662 662 }): Promise<UpdateNotificationPrefsResponse> { 663 663 return xrpc("_account.updateNotificationPrefs", { 664 664 method: "POST", ··· 1133 1133 did?: Did; 1134 1134 signingKey?: string; 1135 1135 verificationChannel?: VerificationChannel; 1136 - discordId?: string; 1136 + discordUsername?: string; 1137 1137 telegramUsername?: string; 1138 - signalNumber?: string; 1138 + signalUsername?: string; 1139 1139 }, byodToken?: string): Promise<PasskeyAccountCreateResponse> { 1140 1140 const url = `${API_BASE}/_account.createPasskeyAccount`; 1141 1141 const headers: Record<string, string> = { ··· 1854 1854 token: AccessToken, 1855 1855 prefs: { 1856 1856 preferredChannel?: string; 1857 - discordId?: string; 1857 + discordUsername?: string; 1858 1858 telegramUsername?: string; 1859 - signalNumber?: string; 1859 + signalUsername?: string; 1860 1860 }, 1861 1861 ): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> { 1862 1862 return xrpcResult("_account.updateNotificationPrefs", {
+16 -3
frontend/src/lib/registration/VerificationStep.svelte
··· 14 14 let resending = $state(false) 15 15 let resendMessage = $state<string | null>(null) 16 16 let telegramBotUsername = $state<string | undefined>(undefined) 17 + let discordBotUsername = $state<string | undefined>(undefined) 18 + let discordAppId = $state<string | undefined>(undefined) 17 19 18 20 let pollingInterval: ReturnType<typeof setInterval> | null = null 19 21 20 22 const isTelegram = $derived(flow.info.verificationChannel === 'telegram') 23 + const isDiscord = $derived(flow.info.verificationChannel === 'discord') 24 + const isBotVerified = $derived(isTelegram || isDiscord) 21 25 22 26 onMount(async () => { 23 - if (isTelegram) { 27 + if (isTelegram || isDiscord) { 24 28 try { 25 29 const serverInfo = await api.describeServer() 26 30 telegramBotUsername = serverInfo.telegramBotUsername 31 + discordBotUsername = serverInfo.discordBotUsername 32 + discordAppId = serverInfo.discordAppId 27 33 } catch { 28 34 } 29 35 } 30 36 }) 31 37 32 38 $effect(() => { 33 - if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) { 39 + if (flow.state.step === 'verify' && flow.account && (isBotVerified || !verificationCode.trim())) { 34 40 pollingInterval = setInterval(async () => { 35 - if (!isTelegram && verificationCode.trim()) return 41 + if (!isBotVerified && verificationCode.trim()) return 36 42 const advanced = await flow.checkAndAdvanceIfVerified() 37 43 if (advanced && pollingInterval) { 38 44 clearInterval(pollingInterval) ··· 105 111 or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code> manually. 106 112 </p> 107 113 <p class="info-text waiting">Waiting for verification...</p> 114 + {:else if isDiscord && discordAppId} 115 + {@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`} 116 + <p class="info-text"> 117 + <a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">Open Discord to verify</a>, 118 + or send <code>/start {handle}</code> to <strong>{discordBotUsername ?? 'the bot'}</strong> manually. 119 + </p> 120 + <p class="info-text waiting">Waiting for verification...</p> 108 121 {:else} 109 122 <p class="info-text"> 110 123 We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
+6 -6
frontend/src/lib/registration/flow.svelte.ts
··· 56 56 didType: "plc", 57 57 externalDid: "", 58 58 verificationChannel: "email", 59 - discordId: "", 59 + discordUsername: "", 60 60 telegramUsername: "", 61 - signalNumber: "", 61 + signalUsername: "", 62 62 }, 63 63 externalDidWeb: { 64 64 keyMode: "reserved", ··· 252 252 ? state.externalDidWeb.reservedSigningKey 253 253 : undefined, 254 254 verificationChannel: state.info.verificationChannel, 255 - discordId: state.info.discordId?.trim() || undefined, 255 + discordUsername: state.info.discordUsername?.trim() || undefined, 256 256 telegramUsername: state.info.telegramUsername?.trim() || undefined, 257 - signalNumber: state.info.signalNumber?.trim() || undefined, 257 + signalUsername: state.info.signalUsername?.trim() || undefined, 258 258 }, byodToken); 259 259 260 260 state.account = { ··· 305 305 ? state.externalDidWeb.reservedSigningKey 306 306 : undefined, 307 307 verificationChannel: state.info.verificationChannel, 308 - discordId: state.info.discordId?.trim() || undefined, 308 + discordUsername: state.info.discordUsername?.trim() || undefined, 309 309 telegramUsername: state.info.telegramUsername?.trim() || undefined, 310 - signalNumber: state.info.signalNumber?.trim() || undefined, 310 + signalUsername: state.info.signalUsername?.trim() || undefined, 311 311 }, byodToken); 312 312 313 313 state.account = {
+2 -2
frontend/src/lib/registration/types.ts
··· 28 28 didType: DidType; 29 29 externalDid?: string; 30 30 verificationChannel: VerificationChannel; 31 - discordId?: string; 31 + discordUsername?: string; 32 32 telegramUsername?: string; 33 - signalNumber?: string; 33 + signalUsername?: string; 34 34 } 35 35 36 36 export interface ExternalDidWebState {
+4 -4
frontend/src/lib/types/api.ts
··· 185 185 did?: string; 186 186 signingKey?: string; 187 187 verificationChannel?: VerificationChannel; 188 - discordId?: string; 188 + discordUsername?: string; 189 189 telegramUsername?: string; 190 - signalNumber?: string; 190 + signalUsername?: string; 191 191 } 192 192 193 193 export interface CreateAccountResult { ··· 254 254 export interface NotificationPrefs { 255 255 preferredChannel: VerificationChannel; 256 256 email: EmailAddress; 257 - discordId: string | null; 257 + discordUsername: string | null; 258 258 discordVerified: boolean; 259 259 telegramUsername: string | null; 260 260 telegramVerified: boolean; 261 - signalNumber: string | null; 261 + signalUsername: string | null; 262 262 signalVerified: boolean; 263 263 } 264 264
+2 -2
frontend/src/lib/types/schemas.ts
··· 173 173 export const notificationPrefsSchema = z.object({ 174 174 preferredChannel: verificationChannel, 175 175 email: email, 176 - discordId: z.string().nullable(), 176 + discordUsername: z.string().nullable(), 177 177 discordVerified: z.boolean(), 178 178 telegramUsername: z.string().nullable(), 179 179 telegramVerified: z.boolean(), 180 - signalNumber: z.string().nullable(), 180 + signalUsername: z.string().nullable(), 181 181 signalVerified: z.boolean(), 182 182 }); 183 183
+15 -15
frontend/src/locales/en.json
··· 84 84 "emailAddress": "Email Address", 85 85 "emailPlaceholder": "you@example.com", 86 86 "discord": "Discord", 87 - "discordId": "Discord User ID", 88 - "discordIdPlaceholder": "123456789012345678", 89 - "discordIdHint": "Enable Developer Mode to find your ID", 90 - "discordInUseWarning": "Discord ID in use by another account", 87 + "discordUsername": "Discord Username", 88 + "discordUsernamePlaceholder": "yourusername", 89 + "discordInUseWarning": "Discord username in use by another account", 91 90 "telegram": "Telegram", 92 91 "telegramUsername": "Telegram Username", 93 92 "telegramUsernamePlaceholder": "@yourusername", 94 93 "telegramInUseWarning": "Telegram username in use by another account", 95 94 "signal": "Signal", 96 - "signalNumber": "Signal Phone Number", 97 - "signalNumberPlaceholder": "+1234567890", 98 - "signalNumberHint": "Include country code", 99 - "signalInUseWarning": "Signal number in use by another account", 95 + "signalUsername": "Signal Username", 96 + "signalUsernamePlaceholder": "username.01", 97 + "signalInUseWarning": "Signal username in use by another account", 100 98 "notConfigured": "not configured", 101 99 "inviteCode": "Invite Code", 102 100 "inviteCodePlaceholder": "Enter your invite code", ··· 118 116 "externalDidRequired": "External did:web is required", 119 117 "externalDidFormat": "External DID must start with did:web:", 120 118 "emailRequired": "Email is required for email verification", 121 - "discordIdRequired": "Discord ID is required for Discord verification", 119 + "discordUsernameRequired": "Discord username is required for Discord verification", 122 120 "telegramRequired": "Telegram username is required for Telegram verification", 123 - "signalRequired": "Phone number is required for Signal verification" 121 + "signalRequired": "Signal username is required for Signal verification" 124 122 } 125 123 }, 126 124 "dashboard": { ··· 437 435 "verifiedSuccess": "{channel} verified successfully", 438 436 "messageHistory": "Message History", 439 437 "noMessages": "No messages found.", 440 - "discordInUseWarning": "This Discord ID is already associated with another account.", 438 + "discordInUseWarning": "This Discord username is already associated with another account.", 441 439 "telegramInUseWarning": "This Telegram username is already associated with another account.", 442 - "signalInUseWarning": "This Signal number is already associated with another account.", 440 + "signalInUseWarning": "This Signal username is already associated with another account.", 443 441 "telegramStartBot": "Or send /start {handle} to @{botUsername} manually", 444 - "telegramOpenLink": "Open Telegram to verify" 442 + "telegramOpenLink": "Open Telegram to verify", 443 + "discordStartBot": "DM @{botUsername} on Discord and send /start {handle}", 444 + "discordOpenLink": "Open Discord to verify" 445 445 }, 446 446 "repoExplorer": { 447 447 "collections": "Collections", ··· 779 779 "externalDidRequired": "External did:web is required", 780 780 "externalDidFormat": "External DID must start with did:web:", 781 781 "emailRequired": "Email is required for email verification", 782 - "discordRequired": "Discord ID is required for Discord verification", 782 + "discordRequired": "Discord username is required for Discord verification", 783 783 "telegramRequired": "Telegram username is required for Telegram verification", 784 - "signalRequired": "Phone number required", 784 + "signalRequired": "Signal username required", 785 785 "passkeysNotSupported": "Passkeys not supported in this browser", 786 786 "passkeyCancelled": "Cancelled", 787 787 "passkeyFailed": "Passkey registration failed"
+14 -14
frontend/src/locales/fi.json
··· 84 84 "emailAddress": "Sähköpostiosoite", 85 85 "emailPlaceholder": "sinä@esimerkki.fi", 86 86 "discord": "Discord", 87 - "discordId": "Discord-käyttäjätunnus", 88 - "discordIdPlaceholder": "Discord-käyttäjätunnuksesi", 89 - "discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)", 90 - "discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.", 87 + "discordUsername": "Discord-käyttäjänimi", 88 + "discordUsernamePlaceholder": "käyttäjänimesi", 89 + "discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.", 91 90 "telegram": "Telegram", 92 91 "telegramUsername": "Telegram-käyttäjänimi", 93 92 "telegramUsernamePlaceholder": "@käyttäjänimesi", 94 93 "telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.", 95 94 "signal": "Signal", 96 - "signalNumber": "Signal-puhelinnumero", 97 - "signalNumberPlaceholder": "+358401234567", 98 - "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", 99 - "signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.", 95 + "signalUsername": "Signal-käyttäjänimi", 96 + "signalUsernamePlaceholder": "käyttäjänimi.01", 97 + "signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.", 100 98 "notConfigured": "ei määritetty", 101 99 "inviteCode": "Kutsukoodi", 102 100 "inviteCodePlaceholder": "Syötä kutsukoodisi", ··· 118 116 "externalDidRequired": "Ulkoinen did:web vaaditaan", 119 117 "externalDidFormat": "Ulkoisen DID:n on alettava did:web:", 120 118 "emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen", 121 - "discordIdRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen", 119 + "discordUsernameRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen", 122 120 "telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen", 123 - "signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen" 121 + "signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen" 124 122 } 125 123 }, 126 124 "dashboard": { ··· 433 431 "verifiedSuccess": "{channel} vahvistettu", 434 432 "messageHistory": "Viestihistoria", 435 433 "noMessages": "Viestejä ei löytynyt.", 436 - "discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.", 434 + "discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.", 437 435 "telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.", 438 - "signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.", 436 + "signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.", 439 437 "telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti", 440 438 "telegramOpenLink": "Avaa Telegram vahvistaaksesi", 439 + "discordStartBot": "Lähetä @{botUsername}-botille viesti /start {handle} Discordissa", 440 + "discordOpenLink": "Avaa Discord vahvistaaksesi", 441 441 "failedToLoad": "Asetusten lataus epäonnistui", 442 442 "failedToSave": "Asetusten tallennus epäonnistui", 443 443 "failedToVerify": "Vahvistus epäonnistui", ··· 751 751 "passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.", 752 752 "passkeyCancelled": "Pääsyavaimen luominen peruutettu", 753 753 "passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui", 754 - "signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen", 754 + "signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen", 755 755 "inviteRequired": "Kutsukoodi vaaditaan", 756 756 "externalDidRequired": "Ulkoinen did:web vaaditaan", 757 757 "emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen", 758 758 "telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen", 759 759 "externalDidFormat": "Ulkoisen DID:n on alettava did:web:", 760 - "discordRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen" 760 + "discordRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen" 761 761 }, 762 762 "identityType": "Identiteettityyppi", 763 763 "identityTypeHint": "Valitse, miten hajautettua identiteettiäsi hallitaan.",
+14 -14
frontend/src/locales/ja.json
··· 84 84 "emailAddress": "メールアドレス", 85 85 "emailPlaceholder": "you@example.com", 86 86 "discord": "Discord", 87 - "discordId": "Discord ユーザー ID", 88 - "discordIdPlaceholder": "Discord ユーザー ID", 89 - "discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)", 90 - "discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。", 87 + "discordUsername": "Discord ユーザー名", 88 + "discordUsernamePlaceholder": "yourusername", 89 + "discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。", 91 90 "telegram": "Telegram", 92 91 "telegramUsername": "Telegram ユーザー名", 93 92 "telegramUsernamePlaceholder": "@yourusername", 94 93 "telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。", 95 94 "signal": "Signal", 96 - "signalNumber": "Signal 電話番号", 97 - "signalNumberPlaceholder": "+81XXXXXXXXXX", 98 - "signalNumberHint": "国番号を含めてください(例: 日本は +81)", 99 - "signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。", 95 + "signalUsername": "Signal ユーザー名", 96 + "signalUsernamePlaceholder": "username.01", 97 + "signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。", 100 98 "notConfigured": "未設定", 101 99 "inviteCode": "招待コード", 102 100 "inviteCodePlaceholder": "招待コードを入力", ··· 118 116 "externalDidRequired": "外部 did:web は必須です", 119 117 "externalDidFormat": "外部 DID は did:web: で始まる必要があります", 120 118 "emailRequired": "メール認証にはメールアドレスが必要です", 121 - "discordIdRequired": "Discord 認証には Discord ID が必要です", 119 + "discordUsernameRequired": "Discord 認証には Discord ユーザー名が必要です", 122 120 "telegramRequired": "Telegram 認証には Telegram ユーザー名が必要です", 123 - "signalRequired": "Signal 認証には電話番号が必要です" 121 + "signalRequired": "Signal 認証にはユーザー名が必要です" 124 122 } 125 123 }, 126 124 "dashboard": { ··· 433 431 "verifiedSuccess": "{channel} を確認しました", 434 432 "messageHistory": "メッセージ履歴", 435 433 "noMessages": "メッセージが見つかりません。", 436 - "discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。", 434 + "discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。", 437 435 "telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。", 438 - "signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。", 436 + "signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。", 439 437 "telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信", 440 438 "telegramOpenLink": "Telegram で確認する", 439 + "discordStartBot": "Discordで @{botUsername} にDMして /start {handle} を送信", 440 + "discordOpenLink": "Discordで認証", 441 441 "failedToLoad": "設定の読み込みに失敗しました", 442 442 "failedToSave": "設定の保存に失敗しました", 443 443 "failedToVerify": "確認に失敗しました", ··· 751 751 "passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。", 752 752 "passkeyCancelled": "パスキーの作成がキャンセルされました", 753 753 "passkeyFailed": "パスキーの登録に失敗しました", 754 - "signalRequired": "Signal認証には電話番号が必要です", 754 + "signalRequired": "Signal認証にはユーザー名が必要です", 755 755 "inviteRequired": "招待コードが必要です", 756 756 "externalDidRequired": "外部did:webが必要です", 757 757 "emailRequired": "メール認証にはメールアドレスが必要です", 758 758 "telegramRequired": "Telegram認証にはTelegramユーザー名が必要です", 759 759 "externalDidFormat": "外部DIDはdid:web:で始まる必要があります", 760 - "discordRequired": "Discord認証にはDiscord IDが必要です" 760 + "discordRequired": "Discord認証にはDiscordユーザー名が必要です" 761 761 }, 762 762 "identityType": "アイデンティティタイプ", 763 763 "identityTypeHint": "分散型アイデンティティの管理方法を選択してください。",
+14 -14
frontend/src/locales/ko.json
··· 84 84 "emailAddress": "이메일 주소", 85 85 "emailPlaceholder": "you@example.com", 86 86 "discord": "Discord", 87 - "discordId": "Discord 사용자 ID", 88 - "discordIdPlaceholder": "Discord 사용자 ID", 89 - "discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)", 90 - "discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.", 87 + "discordUsername": "Discord 사용자명", 88 + "discordUsernamePlaceholder": "yourusername", 89 + "discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.", 91 90 "telegram": "Telegram", 92 91 "telegramUsername": "Telegram 사용자 이름", 93 92 "telegramUsernamePlaceholder": "@yourusername", 94 93 "telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.", 95 94 "signal": "Signal", 96 - "signalNumber": "Signal 전화번호", 97 - "signalNumberPlaceholder": "+821012345678", 98 - "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", 99 - "signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.", 95 + "signalUsername": "Signal 사용자명", 96 + "signalUsernamePlaceholder": "username.01", 97 + "signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.", 100 98 "notConfigured": "구성되지 않음", 101 99 "inviteCode": "초대 코드", 102 100 "inviteCodePlaceholder": "초대 코드 입력", ··· 118 116 "externalDidRequired": "외부 did:web은 필수입니다", 119 117 "externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다", 120 118 "emailRequired": "이메일 인증에는 이메일이 필요합니다", 121 - "discordIdRequired": "Discord 인증에는 Discord ID가 필요합니다", 119 + "discordUsernameRequired": "Discord 인증에는 Discord 사용자명이 필요합니다", 122 120 "telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다", 123 - "signalRequired": "Signal 인증에는 전화번호가 필요합니다" 121 + "signalRequired": "Signal 인증에는 사용자명이 필요합니다" 124 122 } 125 123 }, 126 124 "dashboard": { ··· 433 431 "verifiedSuccess": "{channel} 인증 완료", 434 432 "messageHistory": "메시지 기록", 435 433 "noMessages": "메시지가 없습니다.", 436 - "discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.", 434 + "discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.", 437 435 "telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.", 438 - "signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.", 436 + "signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.", 439 437 "telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요", 440 438 "telegramOpenLink": "Telegram에서 인증하기", 439 + "discordStartBot": "Discord에서 @{botUsername}에게 DM으로 /start {handle} 보내기", 440 + "discordOpenLink": "Discord에서 인증", 441 441 "failedToLoad": "설정 로딩 실패", 442 442 "failedToSave": "설정 저장 실패", 443 443 "failedToVerify": "인증 실패", ··· 751 751 "passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.", 752 752 "passkeyCancelled": "패스키 생성이 취소되었습니다", 753 753 "passkeyFailed": "패스키 등록에 실패했습니다", 754 - "signalRequired": "Signal 인증에는 전화번호가 필요합니다", 754 + "signalRequired": "Signal 인증에는 사용자명이 필요합니다", 755 755 "inviteRequired": "초대 코드가 필요합니다", 756 756 "externalDidRequired": "외부 did:web이 필요합니다", 757 757 "emailRequired": "이메일 인증에는 이메일이 필요합니다", 758 758 "telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다", 759 759 "externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다", 760 - "discordRequired": "Discord 인증에는 Discord ID가 필요합니다" 760 + "discordRequired": "Discord 인증에는 Discord 사용자명이 필요합니다" 761 761 }, 762 762 "identityType": "아이덴티티 유형", 763 763 "identityTypeHint": "분산 아이덴티티 관리 방법을 선택하세요.",
+14 -14
frontend/src/locales/sv.json
··· 84 84 "emailAddress": "E-postadress", 85 85 "emailPlaceholder": "du@exempel.se", 86 86 "discord": "Discord", 87 - "discordId": "Discord användar-ID", 88 - "discordIdPlaceholder": "Ditt Discord användar-ID", 89 - "discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)", 90 - "discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.", 87 + "discordUsername": "Discord-användarnamn", 88 + "discordUsernamePlaceholder": "dittanvändarnamn", 89 + "discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.", 91 90 "telegram": "Telegram", 92 91 "telegramUsername": "Telegram-användarnamn", 93 92 "telegramUsernamePlaceholder": "@dittanvändarnamn", 94 93 "telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.", 95 94 "signal": "Signal", 96 - "signalNumber": "Signal-telefonnummer", 97 - "signalNumberPlaceholder": "+46701234567", 98 - "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", 99 - "signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.", 95 + "signalUsername": "Signal-användarnamn", 96 + "signalUsernamePlaceholder": "användarnamn.01", 97 + "signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.", 100 98 "notConfigured": "ej konfigurerad", 101 99 "inviteCode": "Inbjudningskod", 102 100 "inviteCodePlaceholder": "Ange din inbjudningskod", ··· 118 116 "externalDidRequired": "Extern did:web krävs", 119 117 "externalDidFormat": "Extern DID måste börja med did:web:", 120 118 "emailRequired": "E-post krävs för e-postverifiering", 121 - "discordIdRequired": "Discord-ID krävs för Discord-verifiering", 119 + "discordUsernameRequired": "Discord-användarnamn krävs för Discord-verifiering", 122 120 "telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering", 123 - "signalRequired": "Telefonnummer krävs för Signal-verifiering" 121 + "signalRequired": "Signal-användarnamn krävs för Signal-verifiering" 124 122 } 125 123 }, 126 124 "dashboard": { ··· 433 431 "verifiedSuccess": "{channel} verifierad", 434 432 "messageHistory": "Meddelandehistorik", 435 433 "noMessages": "Inga meddelanden hittades.", 436 - "discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.", 434 + "discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.", 437 435 "telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.", 438 - "signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.", 436 + "signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.", 439 437 "telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt", 440 438 "telegramOpenLink": "Öppna Telegram för att verifiera", 439 + "discordStartBot": "DM:a @{botUsername} på Discord och skicka /start {handle}", 440 + "discordOpenLink": "Öppna Discord för att verifiera", 441 441 "failedToLoad": "Kunde inte ladda inställningar", 442 442 "failedToSave": "Kunde inte spara inställningar", 443 443 "failedToVerify": "Verifiering misslyckades", ··· 751 751 "passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.", 752 752 "passkeyCancelled": "Nyckelskapande avbröts", 753 753 "passkeyFailed": "Nyckelregistrering misslyckades", 754 - "signalRequired": "Telefonnummer krävs för Signal-verifiering", 754 + "signalRequired": "Signal-användarnamn krävs för Signal-verifiering", 755 755 "inviteRequired": "Inbjudningskod krävs", 756 756 "externalDidRequired": "Extern did:web krävs", 757 757 "emailRequired": "E-post krävs för e-postverifiering", 758 758 "telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering", 759 759 "externalDidFormat": "Extern DID måste börja med did:web:", 760 - "discordRequired": "Discord-ID krävs för Discord-verifiering" 760 + "discordRequired": "Discord-användarnamn krävs för Discord-verifiering" 761 761 }, 762 762 "identityType": "Identitetstyp", 763 763 "identityTypeHint": "Välj hur din decentraliserade identitet ska hanteras.",
+14 -14
frontend/src/locales/zh.json
··· 84 84 "emailAddress": "电子邮件地址", 85 85 "emailPlaceholder": "you@example.com", 86 86 "discord": "Discord", 87 - "discordId": "Discord 用户 ID", 88 - "discordIdPlaceholder": "您的 Discord 用户 ID", 89 - "discordIdHint": "您的 Discord 数字用户 ID(开启开发者模式后可以复制)", 90 - "discordInUseWarning": "此 Discord ID 已与另一个账户关联。", 87 + "discordUsername": "Discord 用户名", 88 + "discordUsernamePlaceholder": "yourusername", 89 + "discordInUseWarning": "此 Discord 用户名已与另一个账户关联。", 91 90 "telegram": "Telegram", 92 91 "telegramUsername": "Telegram 用户名", 93 92 "telegramUsernamePlaceholder": "@yourusername", 94 93 "telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。", 95 94 "signal": "Signal", 96 - "signalNumber": "Signal 电话号码", 97 - "signalNumberPlaceholder": "+1234567890", 98 - "signalNumberHint": "包含国家代码(例如中国为 +86)", 99 - "signalInUseWarning": "此 Signal 号码已与另一个账户关联。", 95 + "signalUsername": "Signal 用户名", 96 + "signalUsernamePlaceholder": "username.01", 97 + "signalInUseWarning": "此 Signal 用户名已被其他账户使用。", 100 98 "notConfigured": "未配置", 101 99 "inviteCode": "邀请码", 102 100 "inviteCodePlaceholder": "输入您的邀请码", ··· 118 116 "externalDidRequired": "请输入您的 did:web", 119 117 "externalDidFormat": "DID 必须以 did:web: 开头", 120 118 "emailRequired": "使用邮箱验证需要填写邮箱地址", 121 - "discordIdRequired": "使用 Discord 验证需要填写 Discord ID", 119 + "discordUsernameRequired": "使用 Discord 验证需要填写 Discord 用户名", 122 120 "telegramRequired": "使用 Telegram 验证需要填写用户名", 123 - "signalRequired": "使用 Signal 验证需要填写电话号码" 121 + "signalRequired": "使用 Signal 验证需要填写用户名" 124 122 } 125 123 }, 126 124 "dashboard": { ··· 433 431 "verifiedSuccess": "{channel} 验证成功", 434 432 "messageHistory": "消息历史", 435 433 "noMessages": "暂无消息记录", 436 - "discordInUseWarning": "此 Discord ID 已与另一个账户关联。", 434 + "discordInUseWarning": "此 Discord 用户名已与另一个账户关联。", 437 435 "telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。", 438 - "signalInUseWarning": "此 Signal 号码已与另一个账户关联。", 436 + "signalInUseWarning": "此 Signal 用户名已与另一个账户关联。", 439 437 "telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}", 440 438 "telegramOpenLink": "打开 Telegram 验证", 439 + "discordStartBot": "在 Discord 上私信 @{botUsername} 并发送 /start {handle}", 440 + "discordOpenLink": "打开 Discord 验证", 441 441 "failedToLoad": "加载偏好设置失败", 442 442 "failedToSave": "保存偏好设置失败", 443 443 "failedToVerify": "验证失败", ··· 774 774 "externalDidRequired": "请输入您的 did:web", 775 775 "externalDidFormat": "DID 必须以 did:web: 开头", 776 776 "emailRequired": "使用邮箱验证需要填写邮箱地址", 777 - "discordRequired": "使用 Discord 验证需要填写 Discord ID", 777 + "discordRequired": "使用 Discord 验证需要填写 Discord 用户名", 778 778 "telegramRequired": "使用 Telegram 验证需要填写用户名", 779 - "signalRequired": "使用 Signal 验证需要填写电话号码", 779 + "signalRequired": "使用 Signal 验证需要填写用户名", 780 780 "passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。", 781 781 "passkeyCancelled": "通行密钥创建已取消", 782 782 "passkeyFailed": "通行密钥注册失败"
+10 -11
frontend/src/routes/OAuthRegister.svelte
··· 123 123 if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired') 124 124 break 125 125 case 'discord': 126 - if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired') 126 + if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired') 127 127 break 128 128 case 'telegram': 129 129 if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired') 130 130 break 131 131 case 'signal': 132 - if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired') 132 + if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired') 133 133 break 134 134 } 135 135 return null ··· 389 389 </div> 390 390 {:else if flow.info.verificationChannel === 'discord'} 391 391 <div class="field"> 392 - <label for="discord-id">{$_('register.discordId')}</label> 392 + <label for="discord-username">{$_('register.discordUsername')}</label> 393 393 <input 394 - id="discord-id" 394 + id="discord-username" 395 395 type="text" 396 - bind:value={flow.info.discordId} 397 - placeholder={$_('register.discordIdPlaceholder')} 396 + bind:value={flow.info.discordUsername} 397 + placeholder={$_('register.discordUsernamePlaceholder')} 398 398 disabled={flow.state.submitting} 399 399 required 400 400 /> 401 - <p class="hint">{$_('register.discordIdHint')}</p> 402 401 </div> 403 402 {:else if flow.info.verificationChannel === 'telegram'} 404 403 <div class="field"> ··· 414 413 </div> 415 414 {:else if flow.info.verificationChannel === 'signal'} 416 415 <div class="field"> 417 - <label for="signal-number">{$_('register.signalNumber')}</label> 416 + <label for="signal-number">{$_('register.signalUsername')}</label> 418 417 <input 419 418 id="signal-number" 420 419 type="tel" 421 - bind:value={flow.info.signalNumber} 422 - placeholder={$_('register.signalNumberPlaceholder')} 420 + bind:value={flow.info.signalUsername} 421 + placeholder={$_('register.signalUsernamePlaceholder')} 423 422 disabled={flow.state.submitting} 424 423 required 425 424 /> 426 - <p class="hint">{$_('register.signalNumberHint')}</p> 425 + <p class="hint">{$_('register.signalUsernameHint')}</p> 427 426 </div> 428 427 {/if} 429 428 </div>
+14 -15
frontend/src/routes/OAuthSsoRegister.svelte
··· 30 30 let providerEmailOriginal = $state<string | null>(null) 31 31 let inviteCode = $state('') 32 32 let verificationChannel = $state('email') 33 - let discordId = $state('') 33 + let discordUsername = $state('') 34 34 let telegramUsername = $state('') 35 - let signalNumber = $state('') 35 + let signalUsername = $state('') 36 36 37 37 let handleAvailable = $state<boolean | null>(null) 38 38 let checkingHandle = $state(false) ··· 189 189 case 'email': 190 190 return !!email.trim() 191 191 case 'discord': 192 - return !!discordId.trim() 192 + return !!discordUsername.trim() 193 193 case 'telegram': 194 194 return !!telegramUsername.trim() 195 195 case 'signal': 196 - return !!signalNumber.trim() 196 + return !!signalUsername.trim() 197 197 default: 198 198 return false 199 199 } ··· 234 234 email: email || null, 235 235 invite_code: inviteCode || null, 236 236 verification_channel: verificationChannel, 237 - discord_id: discordId || null, 237 + discord_username: discordUsername || null, 238 238 telegram_username: telegramUsername || null, 239 - signal_number: signalNumber || null, 239 + signal_username: signalUsername || null, 240 240 did_type: didType, 241 241 did: didType === 'web-external' ? externalDid.trim() : null, 242 242 }), ··· 379 379 </div> 380 380 {:else if verificationChannel === 'discord'} 381 381 <div class="field"> 382 - <label for="discord-id">{$_('register.discordId')}</label> 382 + <label for="discord-username">{$_('register.discordUsername')}</label> 383 383 <input 384 - id="discord-id" 384 + id="discord-username" 385 385 type="text" 386 - bind:value={discordId} 387 - placeholder={$_('register.discordIdPlaceholder')} 386 + bind:value={discordUsername} 387 + placeholder={$_('register.discordUsernamePlaceholder')} 388 388 disabled={submitting} 389 389 required 390 390 /> 391 - <p class="hint">{$_('register.discordIdHint')}</p> 392 391 </div> 393 392 {:else if verificationChannel === 'telegram'} 394 393 <div class="field"> ··· 404 403 </div> 405 404 {:else if verificationChannel === 'signal'} 406 405 <div class="field"> 407 - <label for="signal-number">{$_('register.signalNumber')}</label> 406 + <label for="signal-number">{$_('register.signalUsername')}</label> 408 407 <input 409 408 id="signal-number" 410 409 type="tel" 411 - bind:value={signalNumber} 412 - placeholder={$_('register.signalNumberPlaceholder')} 410 + bind:value={signalUsername} 411 + placeholder={$_('register.signalUsernamePlaceholder')} 413 412 disabled={submitting} 414 413 required 415 414 /> 416 - <p class="hint">{$_('register.signalNumberHint')}</p> 415 + <p class="hint">{$_('register.signalUsernameHint')}</p> 417 416 </div> 418 417 {/if} 419 418 </div>
+10 -11
frontend/src/routes/Register.svelte
··· 136 136 if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired') 137 137 break 138 138 case 'discord': 139 - if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired') 139 + if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired') 140 140 break 141 141 case 'telegram': 142 142 if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired') 143 143 break 144 144 case 'signal': 145 - if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired') 145 + if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired') 146 146 break 147 147 } 148 148 return null ··· 409 409 </div> 410 410 {:else if flow.info.verificationChannel === 'discord'} 411 411 <div class="field"> 412 - <label for="discord-id">{$_('register.discordId')}</label> 412 + <label for="discord-username">{$_('register.discordUsername')}</label> 413 413 <input 414 - id="discord-id" 414 + id="discord-username" 415 415 type="text" 416 - bind:value={flow.info.discordId} 417 - placeholder={$_('register.discordIdPlaceholder')} 416 + bind:value={flow.info.discordUsername} 417 + placeholder={$_('register.discordUsernamePlaceholder')} 418 418 disabled={flow.state.submitting} 419 419 required 420 420 /> 421 - <p class="hint">{$_('register.discordIdHint')}</p> 422 421 </div> 423 422 {:else if flow.info.verificationChannel === 'telegram'} 424 423 <div class="field"> ··· 434 433 </div> 435 434 {:else if flow.info.verificationChannel === 'signal'} 436 435 <div class="field"> 437 - <label for="signal-number">{$_('register.signalNumber')}</label> 436 + <label for="signal-number">{$_('register.signalUsername')}</label> 438 437 <input 439 438 id="signal-number" 440 439 type="tel" 441 - bind:value={flow.info.signalNumber} 442 - placeholder={$_('register.signalNumberPlaceholder')} 440 + bind:value={flow.info.signalUsername} 441 + placeholder={$_('register.signalUsernamePlaceholder')} 443 442 disabled={flow.state.submitting} 444 443 required 445 444 /> 446 - <p class="hint">{$_('register.signalNumberHint')}</p> 445 + <p class="hint">{$_('register.signalUsernameHint')}</p> 447 446 </div> 448 447 {/if} 449 448
+12 -13
frontend/src/routes/RegisterPassword.svelte
··· 133 133 if (!info.email.trim()) return $_('register.validation.emailRequired') 134 134 break 135 135 case 'discord': 136 - if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') 136 + if (!info.discordUsername?.trim()) return $_('register.validation.discordUsernameRequired') 137 137 break 138 138 case 'telegram': 139 139 if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') 140 140 break 141 141 case 'signal': 142 - if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') 142 + if (!info.signalUsername?.trim()) return $_('register.validation.signalRequired') 143 143 break 144 144 } 145 145 return null ··· 381 381 </div> 382 382 {:else if flow.info.verificationChannel === 'discord'} 383 383 <div class="field"> 384 - <label for="discord-id">{$_('register.discordId')}</label> 384 + <label for="discord-username">{$_('register.discordUsername')}</label> 385 385 <input 386 - id="discord-id" 386 + id="discord-username" 387 387 type="text" 388 - bind:value={flow.info.discordId} 389 - onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')} 390 - placeholder={$_('register.discordIdPlaceholder')} 388 + bind:value={flow.info.discordUsername} 389 + onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordUsername ?? '')} 390 + placeholder={$_('register.discordUsernamePlaceholder')} 391 391 disabled={flow.state.submitting} 392 392 required 393 393 /> 394 - <p class="hint">{$_('register.discordIdHint')}</p> 395 394 {#if flow.state.discordInUse} 396 395 <p class="hint warning">{$_('register.discordInUseWarning')}</p> 397 396 {/if} ··· 414 413 </div> 415 414 {:else if flow.info.verificationChannel === 'signal'} 416 415 <div class="field"> 417 - <label for="signal-number">{$_('register.signalNumber')}</label> 416 + <label for="signal-number">{$_('register.signalUsername')}</label> 418 417 <input 419 418 id="signal-number" 420 419 type="tel" 421 - bind:value={flow.info.signalNumber} 422 - onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')} 423 - placeholder={$_('register.signalNumberPlaceholder')} 420 + bind:value={flow.info.signalUsername} 421 + onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalUsername ?? '')} 422 + placeholder={$_('register.signalUsernamePlaceholder')} 424 423 disabled={flow.state.submitting} 425 424 required 426 425 /> 427 - <p class="hint">{$_('register.signalNumberHint')}</p> 426 + <p class="hint">{$_('register.signalUsernameHint')}</p> 428 427 {#if flow.state.signalInUse} 429 428 <p class="hint warning">{$_('register.signalInUseWarning')}</p> 430 429 {/if}
+14 -15
frontend/src/routes/SsoRegisterComplete.svelte
··· 40 40 let providerEmailOriginal = $state<string | null>(null) 41 41 let inviteCode = $state('') 42 42 let verificationChannel = $state('email') 43 - let discordId = $state('') 43 + let discordUsername = $state('') 44 44 let telegramUsername = $state('') 45 - let signalNumber = $state('') 45 + let signalUsername = $state('') 46 46 47 47 let handleAvailable = $state<boolean | null>(null) 48 48 let checkingHandle = $state(false) ··· 204 204 case 'email': 205 205 return !!email.trim() 206 206 case 'discord': 207 - return !!discordId.trim() 207 + return !!discordUsername.trim() 208 208 case 'telegram': 209 209 return !!telegramUsername.trim() 210 210 case 'signal': 211 - return !!signalNumber.trim() 211 + return !!signalUsername.trim() 212 212 default: 213 213 return false 214 214 } ··· 281 281 email: email || null, 282 282 invite_code: inviteCode || null, 283 283 verification_channel: verificationChannel, 284 - discord_id: discordId || null, 284 + discord_username: discordUsername || null, 285 285 telegram_username: telegramUsername || null, 286 - signal_number: signalNumber || null, 286 + signal_username: signalUsername || null, 287 287 did_type: didType, 288 288 did: didType === 'web-external' ? externalDid.trim() : null, 289 289 }), ··· 452 452 </div> 453 453 {:else if verificationChannel === 'discord'} 454 454 <div class="field"> 455 - <label for="discord-id">{$_('register.discordId')}</label> 455 + <label for="discord-username">{$_('register.discordUsername')}</label> 456 456 <input 457 - id="discord-id" 457 + id="discord-username" 458 458 type="text" 459 - bind:value={discordId} 460 - placeholder={$_('register.discordIdPlaceholder')} 459 + bind:value={discordUsername} 460 + placeholder={$_('register.discordUsernamePlaceholder')} 461 461 disabled={submitting} 462 462 required 463 463 /> 464 - <p class="hint">{$_('register.discordIdHint')}</p> 465 464 </div> 466 465 {:else if verificationChannel === 'telegram'} 467 466 <div class="field"> ··· 477 476 </div> 478 477 {:else if verificationChannel === 'signal'} 479 478 <div class="field"> 480 - <label for="signal-number">{$_('register.signalNumber')}</label> 479 + <label for="signal-number">{$_('register.signalUsername')}</label> 481 480 <input 482 481 id="signal-number" 483 482 type="tel" 484 - bind:value={signalNumber} 485 - placeholder={$_('register.signalNumberPlaceholder')} 483 + bind:value={signalUsername} 484 + placeholder={$_('register.signalUsernamePlaceholder')} 486 485 disabled={submitting} 487 486 required 488 487 /> 489 - <p class="hint">{$_('register.signalNumberHint')}</p> 488 + <p class="hint">{$_('register.signalUsernameHint')}</p> 490 489 </div> 491 490 {/if} 492 491 </div>
+24 -8
frontend/src/routes/Verify.svelte
··· 33 33 let tokenFromUrl = $state(false) 34 34 let oauthRequestUri = $state<string | null>(null) 35 35 let telegramBotUsername = $state<string | undefined>(undefined) 36 + let discordBotUsername = $state<string | undefined>(undefined) 37 + let discordAppId = $state<string | undefined>(undefined) 36 38 37 39 const auth = $derived(getAuthState()) 38 40 ··· 42 44 43 45 const session = $derived(getSession()) 44 46 const isTelegram = $derived(pendingVerification?.channel === 'telegram') 47 + const isDiscord = $derived(pendingVerification?.channel === 'discord') 48 + const isBotVerified = $derived(isTelegram || isDiscord) 45 49 46 50 function parseQueryParams(): Record<string, string> { 47 51 return Object.fromEntries(new URLSearchParams(window.location.search)) ··· 102 106 })) 103 107 } 104 108 105 - if (pendingVerification?.channel === 'telegram') { 109 + if (pendingVerification?.channel === 'telegram' || pendingVerification?.channel === 'discord') { 106 110 try { 107 111 const serverInfo = await api.describeServer() 108 112 telegramBotUsername = serverInfo.telegramBotUsername 113 + discordBotUsername = serverInfo.discordBotUsername 114 + discordAppId = serverInfo.discordAppId 109 115 } catch { 110 116 } 111 117 } ··· 121 127 122 128 let pollingVerification = false 123 129 $effect(() => { 124 - if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) { 130 + if (mode === 'signup' && pendingVerification && (isBotVerified || !verificationCode.trim())) { 125 131 const currentPending = pendingVerification 126 132 const interval = setInterval(async () => { 127 - if (pollingVerification || (!isTelegram && verificationCode.trim())) return 133 + if (pollingVerification || (!isBotVerified && verificationCode.trim())) return 128 134 pollingVerification = true 129 135 try { 130 136 const result = await api.checkChannelVerified(currentPending.did, currentPending.channel) ··· 447 453 448 454 {#if isTelegram && telegramBotUsername} 449 455 {@const encodedHandle = pendingVerification.handle.replaceAll('.', '_')} 450 - <div class="telegram-hint"> 456 + <div class="bot-hint"> 451 457 <p> 452 458 <a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a> 453 459 </p> ··· 456 462 </p> 457 463 <p class="waiting-text">{$_('verify.pleaseWait')}</p> 458 464 </div> 465 + {:else if isDiscord && discordAppId} 466 + <div class="bot-hint"> 467 + <p> 468 + <a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('comms.discordOpenLink')}</a> 469 + </p> 470 + <p class="manual-text"> 471 + {$_('comms.discordStartBot', { values: { botUsername: discordBotUsername ?? 'the bot', handle: pendingVerification.handle } })} 472 + </p> 473 + <p class="waiting-text">{$_('verify.pleaseWait')}</p> 474 + </div> 459 475 {:else} 460 476 <form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}> 461 477 <div class="field"> ··· 610 626 padding: var(--space-4) var(--space-8); 611 627 } 612 628 613 - .telegram-hint { 629 + .bot-hint { 614 630 padding: var(--space-4); 615 631 background: var(--bg-secondary); 616 632 border-radius: var(--radius-md); 617 633 } 618 634 619 - .telegram-hint p { 635 + .bot-hint p { 620 636 margin: 0; 621 637 } 622 638 623 - .telegram-hint .manual-text { 639 + .bot-hint .manual-text { 624 640 font-size: var(--text-sm); 625 641 color: var(--text-secondary); 626 642 margin-top: var(--space-1); 627 643 } 628 644 629 - .telegram-hint .waiting-text { 645 + .bot-hint .waiting-text { 630 646 font-size: var(--text-sm); 631 647 color: var(--text-secondary); 632 648 margin-top: var(--space-2);
+2 -2
frontend/src/tests/mocks.ts
··· 254 254 notificationPrefs: (overrides?: Record<string, unknown>) => ({ 255 255 preferredChannel: "email", 256 256 email: "test@example.com", 257 - discordId: null, 257 + discordUsername: null, 258 258 discordVerified: false, 259 259 telegramUsername: null, 260 260 telegramVerified: false, 261 - signalNumber: null, 261 + signalUsername: null, 262 262 signalVerified: false, 263 263 ...overrides, 264 264 }),
+3
migrations/20260205_discord_username.sql
··· 1 + ALTER TABLE users ADD COLUMN discord_username TEXT; 2 + 3 + UPDATE users SET discord_id = NULL, discord_verified = FALSE WHERE discord_id IS NOT NULL;
+1
migrations/20260206_signal_username.sql
··· 1 + ALTER TABLE users RENAME COLUMN signal_number TO signal_username;

History

1 round 0 comments
sign up or login to add to the discussion
lewis.moe submitted #0
1 commit
expand
fix: improved discord & signal comms
expand 0 comments
pull request successfully merged