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