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

fix: concurrent write improvements & telegram verification streamlining #14

merged opened by lewis.moe targeting main from fix/concurrent-perf-improvements
  1. Using a little locking on writing so that we can queue things up instead of immediately just erroring out on NOWAIT
  2. telegram UX improvement for verification since replying to /start means that it's guaranteed verified already, no token needed
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mdxvnhulzv22
+3736 -453
Diff #0
+2 -1
.env.example
··· 96 96 # Discord notifications (via webhook) 97 97 # DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... 98 98 # Telegram notifications (via bot) 99 - # TELEGRAM_BOT_TOKEN=your-bot-token 99 + # TELEGRAM_BOT_TOKEN=bot-token 100 + # TELEGRAM_WEBHOOK_SECRET=random-secret 100 101 # Signal notifications (via signal-cli) 101 102 # SIGNAL_CLI_PATH=/usr/local/bin/signal-cli 102 103 # SIGNAL_SENDER_NUMBER=+1234567890
-76
.sqlx/query-247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43.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 signal_number,\n signal_verified\n FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "preferred_channel!: CommsChannel", 14 - "type_info": { 15 - "Custom": { 16 - "name": "comms_channel", 17 - "kind": { 18 - "Enum": [ 19 - "email", 20 - "discord", 21 - "telegram", 22 - "signal" 23 - ] 24 - } 25 - } 26 - } 27 - }, 28 - { 29 - "ordinal": 2, 30 - "name": "discord_id", 31 - "type_info": "Text" 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": "signal_number", 51 - "type_info": "Text" 52 - }, 53 - { 54 - "ordinal": 7, 55 - "name": "signal_verified", 56 - "type_info": "Bool" 57 - } 58 - ], 59 - "parameters": { 60 - "Left": [ 61 - "Text" 62 - ] 63 - }, 64 - "nullable": [ 65 - true, 66 - false, 67 - true, 68 - false, 69 - true, 70 - false, 71 - true, 72 - false 73 - ] 74 - }, 75 - "hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43" 76 - }
+2 -1
.sqlx/query-25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86.json
··· 44 44 "channel_verification", 45 45 "passkey_recovery", 46 46 "legacy_login_alert", 47 - "migration_verification" 47 + "migration_verification", 48 + "channel_verified" 48 49 ] 49 50 } 50 51 }
+2 -1
.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json
··· 32 32 "channel_verification", 33 33 "passkey_recovery", 34 34 "legacy_login_alert", 35 - "migration_verification" 35 + "migration_verification", 36 + "channel_verified" 36 37 ] 37 38 } 38 39 }
+70
.sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.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 + { 7 + "ordinal": 0, 8 + "name": "email", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "preferred_channel!: CommsChannel", 19 + "type_info": { 20 + "Custom": { 21 + "name": "comms_channel", 22 + "kind": { 23 + "Enum": [ 24 + "email", 25 + "discord", 26 + "telegram", 27 + "signal" 28 + ] 29 + } 30 + } 31 + } 32 + }, 33 + { 34 + "ordinal": 3, 35 + "name": "preferred_locale", 36 + "type_info": "Varchar" 37 + }, 38 + { 39 + "ordinal": 4, 40 + "name": "telegram_chat_id", 41 + "type_info": "Int8" 42 + }, 43 + { 44 + "ordinal": 5, 45 + "name": "discord_id", 46 + "type_info": "Text" 47 + }, 48 + { 49 + "ordinal": 6, 50 + "name": "signal_number", 51 + "type_info": "Text" 52 + } 53 + ], 54 + "parameters": { 55 + "Left": [ 56 + "Uuid" 57 + ] 58 + }, 59 + "nullable": [ 60 + true, 61 + false, 62 + false, 63 + true, 64 + true, 65 + true, 66 + true 67 + ] 68 + }, 69 + "hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67" 70 + }
+2 -1
.sqlx/query-8047fda41bd94f819213decb8b3e0aba49a8dbdb10217eefd77e3567f8c9694a.json
··· 49 49 "channel_verification", 50 50 "passkey_recovery", 51 51 "legacy_login_alert", 52 - "migration_verification" 52 + "migration_verification", 53 + "channel_verified" 53 54 ] 54 55 } 55 56 }
+23
.sqlx/query-9d17e25776c67f96022010840c1d04bdd542b5bcc511b1778bab0159a4566e9c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW()\n WHERE id = (\n SELECT id FROM users\n WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND deactivated_at IS NULL\n LIMIT 1\n ) RETURNING id", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Int8" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "9d17e25776c67f96022010840c1d04bdd542b5bcc511b1778bab0159a4566e9c" 23 + }
+22
.sqlx/query-a71a76724a3a7406e30a998c03d52554efaf649bb10b35b6e5d64ac59a479023.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT telegram_chat_id FROM users WHERE id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "telegram_chat_id", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Uuid" 15 + ] 16 + }, 17 + "nullable": [ 18 + true 19 + ] 20 + }, 21 + "hash": "a71a76724a3a7406e30a998c03d52554efaf649bb10b35b6e5d64ac59a479023" 22 + }
+82
.sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.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 + { 7 + "ordinal": 0, 8 + "name": "email", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "preferred_channel!: CommsChannel", 14 + "type_info": { 15 + "Custom": { 16 + "name": "comms_channel", 17 + "kind": { 18 + "Enum": [ 19 + "email", 20 + "discord", 21 + "telegram", 22 + "signal" 23 + ] 24 + } 25 + } 26 + } 27 + }, 28 + { 29 + "ordinal": 2, 30 + "name": "discord_id", 31 + "type_info": "Text" 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 + } 63 + ], 64 + "parameters": { 65 + "Left": [ 66 + "Text" 67 + ] 68 + }, 69 + "nullable": [ 70 + true, 71 + false, 72 + true, 73 + false, 74 + true, 75 + false, 76 + true, 77 + true, 78 + false 79 + ] 80 + }, 81 + "hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740" 82 + }
-14
.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET telegram_username = NULL, telegram_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": "c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d" 14 - }
+2 -1
.sqlx/query-d54660032ca184c20d9cf4563d9a34934ff717796d4f564f1384c1c31fd76594.json
··· 41 41 "channel_verification", 42 42 "passkey_recovery", 43 43 "legacy_login_alert", 44 - "migration_verification" 44 + "migration_verification", 45 + "channel_verified" 45 46 ] 46 47 } 47 48 }
+15
.sqlx/query-d7f32a31b4edeebbbf54a1878dfa05f2fcb3c57fe063be4e6a78fe5e74fb9dc3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET\n telegram_username = $1,\n telegram_verified = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_verified ELSE FALSE END,\n telegram_chat_id = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_chat_id ELSE NULL END,\n updated_at = NOW()\n WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "d7f32a31b4edeebbbf54a1878dfa05f2fcb3c57fe063be4e6a78fe5e74fb9dc3" 15 + }
-52
.sqlx/query-d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\n FROM users WHERE id = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "handle", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "preferred_channel!: CommsChannel", 19 - "type_info": { 20 - "Custom": { 21 - "name": "comms_channel", 22 - "kind": { 23 - "Enum": [ 24 - "email", 25 - "discord", 26 - "telegram", 27 - "signal" 28 - ] 29 - } 30 - } 31 - } 32 - }, 33 - { 34 - "ordinal": 3, 35 - "name": "preferred_locale", 36 - "type_info": "Varchar" 37 - } 38 - ], 39 - "parameters": { 40 - "Left": [ 41 - "Uuid" 42 - ] 43 - }, 44 - "nullable": [ 45 - true, 46 - false, 47 - false, 48 - true 49 - ] 50 - }, 51 - "hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee" 52 - }
+14
.sqlx/query-e49cbb17eb279cd12874fc3e7f5cbdbf0072c42127e225b79c0fe90de78670bd.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, telegram_chat_id = NULL, updated_at = NOW() WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "e49cbb17eb279cd12874fc3e7f5cbdbf0072c42127e225b79c0fe90de78670bd" 14 + }
+24
.sqlx/query-fc1ef8c0979206bf95cccee7c39614f7c882860c39cfe94e411cf6e1d55b63f4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Int8", 16 + "Text" 17 + ] 18 + }, 19 + "nullable": [ 20 + false 21 + ] 22 + }, 23 + "hash": "fc1ef8c0979206bf95cccee7c39614f7c882860c39cfe94e411cf6e1d55b63f4" 24 + }
+14
crates/tranquil-comms/src/locale.rs
··· 31 31 pub legacy_login_body: &'static str, 32 32 pub migration_verification_subject: &'static str, 33 33 pub migration_verification_body: &'static str, 34 + pub channel_verified_subject: &'static str, 35 + pub channel_verified_body: &'static str, 34 36 } 35 37 36 38 pub fn get_strings(locale: &str) -> &'static NotificationStrings { ··· 66 68 legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}", 67 69 migration_verification_subject: "Verify your email - {hostname}", 68 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}.", 69 73 }; 70 74 71 75 static STRINGS_ZH: NotificationStrings = NotificationStrings { ··· 90 94 legacy_login_body: "您好 @{handle},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}", 91 95 migration_verification_subject: "验证您的邮箱 - {hostname}", 92 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} 上的通知渠道。", 93 99 }; 94 100 95 101 static STRINGS_JA: NotificationStrings = NotificationStrings { ··· 114 120 legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}", 115 121 migration_verification_subject: "メールアドレスの認証 - {hostname}", 116 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} の通知チャンネルとして認証されました。", 117 125 }; 118 126 119 127 static STRINGS_KO: NotificationStrings = NotificationStrings { ··· 138 146 legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림", 139 147 migration_verification_subject: "이메일 인증 - {hostname}", 140 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}의 알림 채널로 인증되었습니다.", 141 151 }; 142 152 143 153 static STRINGS_SV: NotificationStrings = NotificationStrings { ··· 162 172 legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}", 163 173 migration_verification_subject: "Verifiera din e-post - {hostname}", 164 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}.", 165 177 }; 166 178 167 179 static STRINGS_FI: NotificationStrings = NotificationStrings { ··· 186 198 legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}", 187 199 migration_verification_subject: "Vahvista sähköpostisi - {hostname}", 188 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}.", 189 203 }; 190 204 191 205 pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+64 -3
crates/tranquil-comms/src/sender.rs
··· 67 67 } 68 68 } 69 69 70 + pub fn escape_html(text: &str) -> String { 71 + text.replace('&', "&amp;") 72 + .replace('<', "&lt;") 73 + .replace('>', "&gt;") 74 + } 75 + 70 76 pub fn is_valid_phone_number(number: &str) -> bool { 71 77 if number.len() < 2 || number.len() > 20 { 72 78 return false; ··· 244 250 let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok()?; 245 251 Some(Self::new(bot_token)) 246 252 } 253 + 254 + pub async fn set_webhook( 255 + &self, 256 + webhook_url: &str, 257 + secret_token: Option<&str>, 258 + ) -> Result<(), SendError> { 259 + let url = format!("https://api.telegram.org/bot{}/setWebhook", self.bot_token); 260 + let mut payload = json!({ "url": webhook_url }); 261 + if let Some(secret) = secret_token { 262 + payload["secret_token"] = json!(secret); 263 + } 264 + let response = self 265 + .http_client 266 + .post(&url) 267 + .json(&payload) 268 + .send() 269 + .await 270 + .map_err(|e| SendError::ExternalService(format!("setWebhook request failed: {}", e)))?; 271 + if !response.status().is_success() { 272 + let body = response.text().await.unwrap_or_default(); 273 + return Err(SendError::ExternalService(format!( 274 + "setWebhook returned error: {}", 275 + body 276 + ))); 277 + } 278 + Ok(()) 279 + } 280 + 281 + pub async fn resolve_bot_username(&self) -> Result<String, SendError> { 282 + let url = format!("https://api.telegram.org/bot{}/getMe", self.bot_token); 283 + let response = self.http_client.get(&url).send().await.map_err(|e| { 284 + SendError::ExternalService(format!("Telegram getMe request failed: {}", e)) 285 + })?; 286 + 287 + if !response.status().is_success() { 288 + let body = response.text().await.unwrap_or_default(); 289 + return Err(SendError::ExternalService(format!( 290 + "Telegram getMe returned error: {}", 291 + body 292 + ))); 293 + } 294 + 295 + let data: serde_json::Value = response.json().await.map_err(|e| { 296 + SendError::ExternalService(format!("Failed to parse getMe response: {}", e)) 297 + })?; 298 + 299 + data.get("result") 300 + .and_then(|r| r.get("username")) 301 + .and_then(|u| u.as_str()) 302 + .map(|s| s.to_string()) 303 + .ok_or_else(|| { 304 + SendError::ExternalService("getMe response missing username".to_string()) 305 + }) 306 + } 247 307 } 248 308 249 309 #[async_trait] ··· 254 314 255 315 async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { 256 316 let chat_id = &notification.recipient; 257 - let subject = notification.subject.as_deref().unwrap_or("Notification"); 258 - let text = format!("*{}*\n\n{}", subject, notification.body); 317 + let subject = escape_html(notification.subject.as_deref().unwrap_or("Notification")); 318 + let body = escape_html(&notification.body); 319 + let text = format!("<b>{}</b>\n\n{}", subject, body); 259 320 let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token); 260 321 let payload = json!({ 261 322 "chat_id": chat_id, 262 323 "text": text, 263 - "parse_mode": "Markdown" 324 + "parse_mode": "HTML" 264 325 }); 265 326 let mut last_error = None; 266 327 for attempt in 0..MAX_RETRIES {
+96 -2
crates/tranquil-db/src/postgres/user.rs
··· 311 311 312 312 async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> { 313 313 let row = sqlx::query!( 314 - r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale 314 + r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number 315 315 FROM users WHERE id = $1"#, 316 316 user_id 317 317 ) ··· 323 323 handle: Handle::from(r.handle), 324 324 preferred_channel: r.preferred_channel, 325 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, 326 329 })) 327 330 } 328 331 ··· 561 564 Ok(row) 562 565 } 563 566 567 + async fn check_channel_verified_by_did( 568 + &self, 569 + did: &Did, 570 + channel: CommsChannel, 571 + ) -> Result<Option<bool>, DbError> { 572 + let row = sqlx::query!( 573 + r#"SELECT 574 + email_verified, 575 + discord_verified, 576 + telegram_verified, 577 + signal_verified 578 + FROM users 579 + WHERE did = $1"#, 580 + did.as_str() 581 + ) 582 + .fetch_optional(&self.pool) 583 + .await 584 + .map_err(map_sqlx_error)?; 585 + 586 + Ok(row.map(|r| match channel { 587 + CommsChannel::Email => r.email_verified, 588 + CommsChannel::Discord => r.discord_verified, 589 + CommsChannel::Telegram => r.telegram_verified, 590 + CommsChannel::Signal => r.signal_verified, 591 + })) 592 + } 593 + 564 594 async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError> { 565 595 let result = sqlx::query!( 566 596 "UPDATE users SET email = $1 WHERE did = $2", ··· 609 639 discord_verified, 610 640 telegram_username, 611 641 telegram_verified, 642 + telegram_chat_id, 612 643 signal_number, 613 644 signal_verified 614 645 FROM users WHERE did = $1"#, ··· 624 655 discord_verified: r.discord_verified, 625 656 telegram_username: r.telegram_username, 626 657 telegram_verified: r.telegram_verified, 658 + telegram_chat_id: r.telegram_chat_id, 627 659 signal_number: r.signal_number, 628 660 signal_verified: r.signal_verified, 629 661 })) ··· 676 708 677 709 async fn clear_telegram(&self, user_id: Uuid) -> Result<(), DbError> { 678 710 sqlx::query!( 679 - "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1", 711 + "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, telegram_chat_id = NULL, updated_at = NOW() WHERE id = $1", 680 712 user_id 681 713 ) 682 714 .execute(&self.pool) ··· 3135 3167 Ok(tranquil_db_traits::RecoverPasskeyAccountResult { 3136 3168 passkeys_deleted: deleted.rows_affected(), 3137 3169 }) 3170 + } 3171 + 3172 + async fn set_unverified_telegram( 3173 + &self, 3174 + user_id: Uuid, 3175 + telegram_username: &str, 3176 + ) -> Result<(), DbError> { 3177 + sqlx::query!( 3178 + r#"UPDATE users SET 3179 + telegram_username = $1, 3180 + telegram_verified = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_verified ELSE FALSE END, 3181 + telegram_chat_id = CASE WHEN LOWER(telegram_username) = LOWER($1) THEN telegram_chat_id ELSE NULL END, 3182 + updated_at = NOW() 3183 + WHERE id = $2"#, 3184 + telegram_username, 3185 + user_id 3186 + ) 3187 + .execute(&self.pool) 3188 + .await 3189 + .map_err(map_sqlx_error)?; 3190 + Ok(()) 3191 + } 3192 + 3193 + async fn store_telegram_chat_id( 3194 + &self, 3195 + telegram_username: &str, 3196 + chat_id: i64, 3197 + handle: Option<&str>, 3198 + ) -> Result<Option<Uuid>, DbError> { 3199 + let result = match handle { 3200 + Some(h) => sqlx::query_scalar!( 3201 + "UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND handle = $3 RETURNING id", 3202 + telegram_username, 3203 + chat_id, 3204 + h 3205 + ) 3206 + .fetch_optional(&self.pool) 3207 + .await 3208 + .map_err(map_sqlx_error)?, 3209 + None => sqlx::query_scalar!( 3210 + r#"UPDATE users SET telegram_chat_id = $2, telegram_verified = TRUE, updated_at = NOW() 3211 + WHERE id = ( 3212 + SELECT id FROM users 3213 + WHERE LOWER(telegram_username) = LOWER($1) AND telegram_username IS NOT NULL AND deactivated_at IS NULL 3214 + LIMIT 1 3215 + ) RETURNING id"#, 3216 + telegram_username, 3217 + chat_id 3218 + ) 3219 + .fetch_optional(&self.pool) 3220 + .await 3221 + .map_err(map_sqlx_error)?, 3222 + }; 3223 + Ok(result) 3224 + } 3225 + 3226 + async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError> { 3227 + let row = sqlx::query_scalar!("SELECT telegram_chat_id FROM users WHERE id = $1", user_id) 3228 + .fetch_optional(&self.pool) 3229 + .await 3230 + .map_err(map_sqlx_error)?; 3231 + Ok(row.flatten()) 3138 3232 } 3139 3233 }
+1
crates/tranquil-db-traits/src/infra.rs
··· 78 78 LegacyLoginAlert, 79 79 MigrationVerification, 80 80 ChannelVerification, 81 + ChannelVerified, 81 82 } 82 83 83 84 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
+25
crates/tranquil-db-traits/src/user.rs
··· 196 196 identifier: &str, 197 197 ) -> Result<Option<bool>, DbError>; 198 198 199 + async fn check_channel_verified_by_did( 200 + &self, 201 + did: &Did, 202 + channel: CommsChannel, 203 + ) -> Result<Option<bool>, DbError>; 204 + 199 205 async fn admin_update_email(&self, did: &Did, email: &str) -> Result<u64, DbError>; 200 206 201 207 async fn admin_update_handle(&self, did: &Did, handle: &Handle) -> Result<u64, DbError>; ··· 221 227 async fn clear_telegram(&self, user_id: Uuid) -> Result<(), DbError>; 222 228 223 229 async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>; 230 + 231 + async fn set_unverified_telegram( 232 + &self, 233 + user_id: Uuid, 234 + telegram_username: &str, 235 + ) -> Result<(), DbError>; 236 + 237 + async fn store_telegram_chat_id( 238 + &self, 239 + telegram_username: &str, 240 + chat_id: i64, 241 + handle: Option<&str>, 242 + ) -> Result<Option<Uuid>, DbError>; 243 + 244 + async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result<Option<i64>, DbError>; 224 245 225 246 async fn get_verification_info( 226 247 &self, ··· 575 596 pub handle: Handle, 576 597 pub preferred_channel: CommsChannel, 577 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>, 578 602 } 579 603 580 604 #[derive(Debug, Clone)] ··· 635 659 pub discord_verified: bool, 636 660 pub telegram_username: Option<String>, 637 661 pub telegram_verified: bool, 662 + pub telegram_chat_id: Option<i64>, 638 663 pub signal_number: Option<String>, 639 664 pub signal_verified: bool, 640 665 }
+10 -2
crates/tranquil-pds/src/api/identity/account.rs
··· 208 208 _ => return ApiError::MissingDiscordId.into_response(), 209 209 }, 210 210 "telegram" => match &input.telegram_username { 211 - Some(username) if !username.trim().is_empty() => username.trim().to_string(), 211 + Some(username) if !username.trim().is_empty() => { 212 + let clean = username.trim().trim_start_matches('@'); 213 + if !crate::api::validation::is_valid_telegram_username(clean) { 214 + return ApiError::InvalidRequest( 215 + "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), 216 + ).into_response(); 217 + } 218 + clean.to_string() 219 + } 212 220 _ => return ApiError::MissingTelegramUsername.into_response(), 213 221 }, 214 222 "signal" => match &input.signal_number { ··· 634 642 telegram_username: input 635 643 .telegram_username 636 644 .as_deref() 637 - .map(|s| s.trim()) 645 + .map(|s| s.trim().trim_start_matches('@')) 638 646 .filter(|s| !s.is_empty()) 639 647 .map(String::from), 640 648 signal_number: input
+1
crates/tranquil-pds/src/api/mod.rs
··· 12 12 pub mod repo; 13 13 pub mod responses; 14 14 pub mod server; 15 + pub mod telegram_webhook; 15 16 pub mod temp; 16 17 pub mod validation; 17 18 pub mod verification;
+72 -13
crates/tranquil-pds/src/api/notification_prefs.rs
··· 165 165 "signal" => tranquil_db_traits::CommsChannel::Signal, 166 166 _ => return Err("Invalid channel".to_string()), 167 167 }; 168 + let hostname = pds_hostname(); 169 + let encoded_token = urlencoding::encode(&formatted_token); 170 + let encoded_identifier = urlencoding::encode(identifier); 171 + let verify_link = format!( 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 181 + .user_repo 182 + .get_telegram_chat_id(user_id) 183 + .await 184 + .ok() 185 + .flatten() 186 + .map(|id| id.to_string()) 187 + .unwrap_or_else(|| identifier.to_string()), 188 + _ => identifier.to_string(), 189 + }; 168 190 state 169 191 .infra_repo 170 192 .enqueue_comms( 171 193 Some(user_id), 172 194 comms_channel, 173 195 tranquil_db_traits::CommsType::ChannelVerification, 174 - identifier, 196 + &recipient, 175 197 Some("Verify your channel"), 176 - &format!("Your verification code is: {}", formatted_token), 198 + &body, 177 199 Some(json!({"code": formatted_token})), 178 200 ) 179 201 .await ··· 199 221 let handle = user_row.handle; 200 222 let current_email = user_row.email; 201 223 224 + let current_prefs = state 225 + .user_repo 226 + .get_notification_prefs(&auth.did) 227 + .await 228 + .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))? 229 + .ok_or(ApiError::AccountNotFound)?; 230 + 231 + let effective_channel = input 232 + .preferred_channel 233 + .as_deref() 234 + .map(|ch| match ch { 235 + "email" => Ok(CommsChannel::Email), 236 + "discord" => Ok(CommsChannel::Discord), 237 + "telegram" => Ok(CommsChannel::Telegram), 238 + "signal" => Ok(CommsChannel::Signal), 239 + _ => Err(ApiError::InvalidRequest( 240 + "Invalid channel. Must be one of: email, discord, telegram, signal".into(), 241 + )), 242 + }) 243 + .transpose()? 244 + .unwrap_or(current_prefs.preferred_channel); 245 + 202 246 let mut verification_required: Vec<String> = Vec::new(); 203 247 204 248 if let Some(ref channel_str) = input.preferred_channel { ··· 249 293 250 294 if let Some(ref discord_id) = input.discord_id { 251 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(), 299 + )); 300 + } 252 301 state 253 302 .user_repo 254 303 .clear_discord(user_id) ··· 267 316 if let Some(ref telegram) = input.telegram_username { 268 317 let telegram_clean = telegram.trim_start_matches('@'); 269 318 if telegram_clean.is_empty() { 319 + if effective_channel == CommsChannel::Telegram { 320 + return Err(ApiError::InvalidRequest( 321 + "Cannot remove Telegram while it is the preferred notification channel".into(), 322 + )); 323 + } 270 324 state 271 325 .user_repo 272 326 .clear_telegram(user_id) 273 327 .await 274 328 .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 275 329 info!(did = %auth.did, "Cleared Telegram username"); 330 + } else if !crate::api::validation::is_valid_telegram_username(telegram_clean) { 331 + return Err(ApiError::InvalidRequest( 332 + "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore" 333 + .into(), 334 + )); 276 335 } else { 277 - request_channel_verification( 278 - &state, 279 - user_id, 280 - &auth.did, 281 - "telegram", 282 - telegram_clean, 283 - None, 284 - ) 285 - .await 286 - .map_err(|e| ApiError::InternalError(Some(e)))?; 336 + state 337 + .user_repo 338 + .set_unverified_telegram(user_id, telegram_clean) 339 + .await 340 + .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; 287 341 verification_required.push("telegram".to_string()); 288 - info!(did = %auth.did, "Requested Telegram verification"); 342 + info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified Telegram username"); 289 343 } 290 344 } 291 345 292 346 if let Some(ref signal) = input.signal_number { 293 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(), 351 + )); 352 + } 294 353 state 295 354 .user_repo 296 355 .clear_signal(user_id)
+1
crates/tranquil-pds/src/api/repo/import.rs
··· 190 190 .ok() 191 191 .and_then(|s| s.parse().ok()) 192 192 .unwrap_or(DEFAULT_MAX_BLOCKS); 193 + let _write_lock = state.repo_write_locks.lock(user_id).await; 193 194 match apply_import(&state.repo_repo, user_id, root, blocks.clone(), max_blocks).await { 194 195 Ok(import_result) => { 195 196 info!(
+3
crates/tranquil-pds/src/api/repo/record/batch.rs
··· 326 326 .ok() 327 327 .flatten() 328 328 .ok_or_else(|| ApiError::InternalError(Some("User not found".into())))?; 329 + 330 + let _write_lock = state.repo_write_locks.lock(user_id).await; 331 + 329 332 let root_cid_str = state 330 333 .repo_repo 331 334 .get_repo_root_cid_by_user_id(user_id)
+8 -2
crates/tranquil-pds/src/api/repo/record/delete.rs
··· 1 1 use crate::api::error::ApiError; 2 - use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 2 + use crate::api::repo::record::utils::{ 3 + CommitParams, RecordOp, commit_and_log, get_current_root_cid, 4 + }; 3 5 use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; 4 6 use crate::auth::{Active, Auth, VerifyScope}; 5 7 use crate::cid_types::CommitCid; ··· 56 58 57 59 let did = repo_auth.did; 58 60 let user_id = repo_auth.user_id; 59 - let current_root_cid = repo_auth.current_root_cid; 60 61 let controller_did = repo_auth.controller_did; 62 + 63 + let _write_lock = state.repo_write_locks.lock(user_id).await; 64 + let current_root_cid = get_current_root_cid(&state, user_id).await?; 61 65 62 66 if let Some(swap_commit) = &input.swap_commit 63 67 && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) ··· 238 242 collection: &Nsid, 239 243 rkey: &Rkey, 240 244 ) -> Result<(), String> { 245 + let _write_lock = state.repo_write_locks.lock(user_id).await; 246 + 241 247 let root_cid_str = state 242 248 .repo_repo 243 249 .get_repo_root_cid_by_user_id(user_id)
+20
crates/tranquil-pds/src/api/repo/record/utils.rs
··· 1 + use crate::api::error::ApiError; 2 + use crate::cid_types::CommitCid; 1 3 use crate::state::AppState; 2 4 use crate::types::{Did, Handle, Nsid, Rkey}; 3 5 use bytes::Bytes; ··· 8 10 use k256::ecdsa::SigningKey; 9 11 use serde_json::{Value, json}; 10 12 use std::str::FromStr; 13 + use tracing::error; 11 14 use tranquil_db_traits::SequenceNumber; 12 15 use uuid::Uuid; 16 + 17 + pub async fn get_current_root_cid(state: &AppState, user_id: Uuid) -> Result<CommitCid, ApiError> { 18 + let root_cid_str = state 19 + .repo_repo 20 + .get_repo_root_cid_by_user_id(user_id) 21 + .await 22 + .map_err(|e| { 23 + error!("DB error fetching repo root: {}", e); 24 + ApiError::InternalError(None) 25 + })? 26 + .ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?; 27 + CommitCid::from_str(&root_cid_str) 28 + .map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into()))) 29 + } 13 30 14 31 pub fn extract_blob_cids(record: &Value) -> Vec<String> { 15 32 let mut blobs = Vec::new(); ··· 328 345 .await 329 346 .map_err(|e| format!("DB error: {}", e))? 330 347 .ok_or_else(|| "User not found".to_string())?; 348 + 349 + let _write_lock = state.repo_write_locks.lock(user_id).await; 350 + 331 351 let root_cid_link = state 332 352 .repo_repo 333 353 .get_repo_root_cid_by_user_id(user_id)
+8 -18
crates/tranquil-pds/src/api/repo/record/write.rs
··· 3 3 use crate::api::error::ApiError; 4 4 use crate::api::repo::record::utils::{ 5 5 CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids, 6 + get_current_root_cid, 6 7 }; 7 8 use crate::auth::{ 8 9 Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated, ··· 31 32 pub struct RepoWriteAuth { 32 33 pub did: Did, 33 34 pub user_id: Uuid, 34 - pub current_root_cid: CommitCid, 35 35 pub is_oauth: bool, 36 36 pub scope: Option<String>, 37 37 pub controller_did: Option<Did>, ··· 62 62 ApiError::InternalError(None).into_response() 63 63 })? 64 64 .ok_or_else(|| ApiError::InternalError(Some("User not found".into())).into_response())?; 65 - let root_cid_str = state 66 - .repo_repo 67 - .get_repo_root_cid_by_user_id(user_id) 68 - .await 69 - .map_err(|e| { 70 - error!("DB error fetching repo root: {}", e); 71 - ApiError::InternalError(None).into_response() 72 - })? 73 - .ok_or_else(|| { 74 - ApiError::InternalError(Some("Repo root not found".into())).into_response() 75 - })?; 76 - let current_root_cid = CommitCid::from_str(&root_cid_str).map_err(|_| { 77 - ApiError::InternalError(Some("Invalid repo root CID".into())).into_response() 78 - })?; 65 + 79 66 Ok(RepoWriteAuth { 80 67 did: principal_did.into_did(), 81 68 user_id, 82 - current_root_cid, 83 69 is_oauth: user.is_oauth(), 84 70 scope: user.scope.clone(), 85 71 controller_did: scope_proof.controller_did().map(|c| c.into_did()), ··· 130 116 131 117 let did = repo_auth.did; 132 118 let user_id = repo_auth.user_id; 133 - let current_root_cid = repo_auth.current_root_cid; 134 119 let controller_did = repo_auth.controller_did; 120 + 121 + let _write_lock = state.repo_write_locks.lock(user_id).await; 122 + let current_root_cid = get_current_root_cid(&state, user_id).await?; 135 123 136 124 if let Some(swap_commit) = &input.swap_commit 137 125 && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid) ··· 433 421 434 422 let did = repo_auth.did; 435 423 let user_id = repo_auth.user_id; 436 - let current_root_cid = repo_auth.current_root_cid; 437 424 let controller_did = repo_auth.controller_did; 425 + 426 + let _write_lock = state.repo_write_locks.lock(user_id).await; 427 + let current_root_cid = get_current_root_cid(&state, user_id).await?; 438 428 439 429 if let Some(swap_commit) = &input.swap_commit 440 430 && CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid)
+39
crates/tranquil-pds/src/api/server/email.rs
··· 420 420 } 421 421 422 422 #[derive(Deserialize)] 423 + pub struct CheckChannelVerifiedInput { 424 + pub did: String, 425 + pub channel: String, 426 + } 427 + 428 + pub async fn check_channel_verified( 429 + State(state): State<AppState>, 430 + _rate_limit: RateLimited<VerificationCheckLimit>, 431 + Json(input): Json<CheckChannelVerifiedInput>, 432 + ) -> Response { 433 + let channel = match input.channel.to_lowercase().as_str() { 434 + "email" => CommsChannel::Email, 435 + "discord" => CommsChannel::Discord, 436 + "telegram" => CommsChannel::Telegram, 437 + "signal" => CommsChannel::Signal, 438 + _ => { 439 + return ApiError::InvalidRequest("invalid channel".into()).into_response(); 440 + } 441 + }; 442 + 443 + let did = match crate::Did::new(input.did) { 444 + Ok(d) => d, 445 + Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), 446 + }; 447 + match state 448 + .user_repo 449 + .check_channel_verified_by_did(&did, channel) 450 + .await 451 + { 452 + Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(), 453 + Ok(None) => ApiError::AccountNotFound.into_response(), 454 + Err(e) => { 455 + error!("DB error checking channel verified: {:?}", e); 456 + ApiError::InternalError(None).into_response() 457 + } 458 + } 459 + } 460 + 461 + #[derive(Deserialize)] 423 462 pub struct AuthorizeEmailUpdateQuery { 424 463 pub token: String, 425 464 }
+7 -3
crates/tranquil-pds/src/api/server/meta.rs
··· 1 1 use crate::state::AppState; 2 - use crate::util::pds_hostname; 2 + use crate::util::{pds_hostname, telegram_bot_username}; 3 3 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 4 4 use serde_json::json; 5 5 ··· 52 52 if let Some(email) = contact_email { 53 53 contact.insert("email".to_string(), json!(email)); 54 54 } 55 - Json(json!({ 55 + let mut response = json!({ 56 56 "availableUserDomains": domains, 57 57 "inviteCodeRequired": invite_code_required, 58 58 "did": format!("did:web:{}", pds_hostname), ··· 61 61 "version": env!("CARGO_PKG_VERSION"), 62 62 "availableCommsChannels": get_available_comms_channels(), 63 63 "selfHostedDidWebEnabled": is_self_hosted_did_web_enabled() 64 - })) 64 + }); 65 + if let Some(bot_username) = telegram_bot_username() { 66 + response["telegramBotUsername"] = json!(bot_username); 67 + } 68 + Json(response) 65 69 } 66 70 pub async fn health(State(state): State<AppState>) -> impl IntoResponse { 67 71 match state.infra_repo.health_check().await {
+1 -1
crates/tranquil-pds/src/api/server/mod.rs
··· 23 23 }; 24 24 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 25 25 pub use email::{ 26 - authorize_email_update, check_comms_channel_in_use, check_email_in_use, 26 + authorize_email_update, check_channel_verified, check_comms_channel_in_use, check_email_in_use, 27 27 check_email_update_status, check_email_verified, confirm_email, request_email_update, 28 28 update_email, 29 29 };
+10 -2
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 172 172 _ => return ApiError::MissingDiscordId.into_response(), 173 173 }, 174 174 "telegram" => match &input.telegram_username { 175 - Some(username) if !username.trim().is_empty() => username.trim().to_string(), 175 + Some(username) if !username.trim().is_empty() => { 176 + let clean = username.trim().trim_start_matches('@'); 177 + if !crate::api::validation::is_valid_telegram_username(clean) { 178 + return ApiError::InvalidRequest( 179 + "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), 180 + ).into_response(); 181 + } 182 + clean.to_string() 183 + } 176 184 _ => return ApiError::MissingTelegramUsername.into_response(), 177 185 }, 178 186 "signal" => match &input.signal_number { ··· 410 418 telegram_username: input 411 419 .telegram_username 412 420 .as_deref() 413 - .map(|s| s.trim()) 421 + .map(|s| s.trim().trim_start_matches('@')) 414 422 .filter(|s| !s.is_empty()) 415 423 .map(String::from), 416 424 signal_number: input
+50 -1
crates/tranquil-pds/src/api/server/verify_token.rs
··· 1 1 use crate::api::error::{ApiError, DbResultExt}; 2 + use crate::comms::comms_repo; 2 3 use crate::types::Did; 4 + use crate::util::pds_hostname; 3 5 use axum::{Json, extract::State}; 4 6 use serde::{Deserialize, Serialize}; 5 7 use tracing::{info, warn}; ··· 161 163 162 164 info!(did = %did, channel = %channel, "Channel verified successfully"); 163 165 166 + let recipient = resolve_verified_recipient(state, user_id, channel, identifier).await; 167 + if let Err(e) = comms_repo::enqueue_channel_verified( 168 + state.user_repo.as_ref(), 169 + state.infra_repo.as_ref(), 170 + user_id, 171 + channel, 172 + &recipient, 173 + pds_hostname(), 174 + ) 175 + .await 176 + { 177 + warn!(error = %e, "Failed to enqueue channel verified notification"); 178 + } 179 + 164 180 Ok(Json(VerifyTokenOutput { 165 181 success: true, 166 182 did: did.to_string().into(), ··· 169 185 })) 170 186 } 171 187 188 + async fn resolve_verified_recipient( 189 + state: &AppState, 190 + user_id: uuid::Uuid, 191 + channel: &str, 192 + identifier: &str, 193 + ) -> String { 194 + match channel { 195 + "telegram" => state 196 + .user_repo 197 + .get_telegram_chat_id(user_id) 198 + .await 199 + .ok() 200 + .flatten() 201 + .map(|id| id.to_string()) 202 + .unwrap_or_else(|| identifier.to_string()), 203 + _ => identifier.to_string(), 204 + } 205 + } 206 + 172 207 async fn handle_signup_verification( 173 208 state: &AppState, 174 209 did: &str, 175 210 channel: &str, 176 - _identifier: &str, 211 + identifier: &str, 177 212 ) -> Result<Json<VerifyTokenOutput>, ApiError> { 178 213 let did_typed: Did = did 179 214 .parse() ··· 231 266 }; 232 267 233 268 info!(did = %did, channel = %channel, "Signup verified successfully"); 269 + 270 + let recipient = resolve_verified_recipient(state, user.id, channel, identifier).await; 271 + if let Err(e) = comms_repo::enqueue_channel_verified( 272 + state.user_repo.as_ref(), 273 + state.infra_repo.as_ref(), 274 + user.id, 275 + channel, 276 + &recipient, 277 + pds_hostname(), 278 + ) 279 + .await 280 + { 281 + warn!(error = %e, "Failed to enqueue channel verified notification"); 282 + } 234 283 235 284 Ok(Json(VerifyTokenOutput { 236 285 success: true,
+172
crates/tranquil-pds/src/api/telegram_webhook.rs
··· 1 + use axum::{ 2 + extract::State, 3 + http::{HeaderMap, StatusCode}, 4 + response::IntoResponse, 5 + }; 6 + use serde::Deserialize; 7 + use tracing::{debug, info, warn}; 8 + 9 + use crate::comms::comms_repo; 10 + use crate::state::AppState; 11 + use crate::util::pds_hostname; 12 + 13 + #[derive(Deserialize)] 14 + struct TelegramUpdate { 15 + message: Option<TelegramMessage>, 16 + } 17 + 18 + #[derive(Deserialize)] 19 + struct TelegramMessage { 20 + text: Option<String>, 21 + from: Option<TelegramUser>, 22 + } 23 + 24 + #[derive(Deserialize)] 25 + struct TelegramUser { 26 + id: i64, 27 + username: Option<String>, 28 + } 29 + 30 + pub async fn handle_telegram_webhook( 31 + State(state): State<AppState>, 32 + headers: HeaderMap, 33 + body: String, 34 + ) -> impl IntoResponse { 35 + let expected_secret = match std::env::var("TELEGRAM_WEBHOOK_SECRET") { 36 + Ok(s) => s, 37 + Err(_) => { 38 + warn!("Telegram webhook called but TELEGRAM_WEBHOOK_SECRET is not configured"); 39 + return StatusCode::FORBIDDEN; 40 + } 41 + }; 42 + let provided = headers 43 + .get("x-telegram-bot-api-secret-token") 44 + .and_then(|v| v.to_str().ok()) 45 + .unwrap_or_default(); 46 + if provided != expected_secret { 47 + warn!("Telegram webhook received with invalid secret token"); 48 + return StatusCode::UNAUTHORIZED; 49 + } 50 + 51 + let update: TelegramUpdate = match serde_json::from_str(&body) { 52 + Ok(u) => u, 53 + Err(_) => return StatusCode::OK, 54 + }; 55 + 56 + if let Some(message) = update.message { 57 + let is_start = message 58 + .text 59 + .as_deref() 60 + .is_some_and(|t| t.starts_with("/start")); 61 + 62 + if is_start 63 + && let Some(from) = message.from 64 + && let Some(username) = from.username 65 + { 66 + let handle = parse_start_handle(message.text.as_deref()); 67 + 68 + debug!( 69 + telegram_username = %username, 70 + chat_id = from.id, 71 + handle = ?handle, 72 + "Received /start from Telegram user" 73 + ); 74 + match state 75 + .user_repo 76 + .store_telegram_chat_id(&username, from.id, handle.as_deref()) 77 + .await 78 + { 79 + Ok(Some(user_id)) => { 80 + info!( 81 + telegram_username = %username, 82 + chat_id = from.id, 83 + "Verified Telegram user and stored chat_id" 84 + ); 85 + if let Err(e) = comms_repo::enqueue_channel_verified( 86 + state.user_repo.as_ref(), 87 + state.infra_repo.as_ref(), 88 + user_id, 89 + "telegram", 90 + &from.id.to_string(), 91 + pds_hostname(), 92 + ) 93 + .await 94 + { 95 + warn!(error = %e, "Failed to enqueue channel verified notification"); 96 + } 97 + } 98 + Ok(None) => { 99 + debug!( 100 + telegram_username = %username, 101 + "No matching user found for Telegram username" 102 + ); 103 + } 104 + Err(e) => { 105 + warn!( 106 + telegram_username = %username, 107 + error = %e, 108 + "Failed to store Telegram chat_id" 109 + ); 110 + } 111 + } 112 + } 113 + } 114 + 115 + StatusCode::OK 116 + } 117 + 118 + fn parse_start_handle(text: Option<&str>) -> Option<String> { 119 + text.and_then(|t| t.strip_prefix("/start ")) 120 + .map(|payload| payload.trim()) 121 + .filter(|p| !p.is_empty()) 122 + .map(|payload| payload.replace('_', ".")) 123 + } 124 + 125 + #[cfg(test)] 126 + mod tests { 127 + use super::*; 128 + 129 + #[test] 130 + fn deep_link_underscores_decoded_to_dots() { 131 + assert_eq!( 132 + parse_start_handle(Some("/start lewis_buttercup_wizardry_systems")), 133 + Some("lewis.buttercup.wizardry.systems".to_string()), 134 + ); 135 + } 136 + 137 + #[test] 138 + fn manual_handle_with_dots_passes_through() { 139 + assert_eq!( 140 + parse_start_handle(Some("/start lewis.buttercup.wizardry.systems")), 141 + Some("lewis.buttercup.wizardry.systems".to_string()), 142 + ); 143 + } 144 + 145 + #[test] 146 + fn bare_start_returns_none() { 147 + assert_eq!(parse_start_handle(Some("/start")), None); 148 + } 149 + 150 + #[test] 151 + fn start_with_trailing_space_returns_none() { 152 + assert_eq!(parse_start_handle(Some("/start ")), None); 153 + } 154 + 155 + #[test] 156 + fn none_text_returns_none() { 157 + assert_eq!(parse_start_handle(None), None); 158 + } 159 + 160 + #[test] 161 + fn non_start_command_returns_none() { 162 + assert_eq!(parse_start_handle(Some("/help")), None); 163 + } 164 + 165 + #[test] 166 + fn payload_with_extra_whitespace_trimmed() { 167 + assert_eq!( 168 + parse_start_handle(Some("/start alice_example_com ")), 169 + Some("alice.example.com".to_string()), 170 + ); 171 + } 172 + }
+8
crates/tranquil-pds/src/api/validation.rs
··· 349 349 }) 350 350 } 351 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 + 352 360 #[cfg(test)] 353 361 mod tests { 354 362 use super::*;
+110 -21
crates/tranquil-pds/src/comms/service.rs
··· 10 10 CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message, 11 11 get_strings, 12 12 }; 13 - use tranquil_db_traits::{InfraRepository, QueuedComms, UserRepository}; 13 + use tranquil_db_traits::{InfraRepository, QueuedComms, UserCommsPrefs, UserRepository}; 14 14 use uuid::Uuid; 15 15 16 16 pub struct CommsService { ··· 75 75 tranquil_db_traits::CommsType::MigrationVerification 76 76 } 77 77 CommsType::ChannelVerification => tranquil_db_traits::CommsType::ChannelVerification, 78 + CommsType::ChannelVerified => tranquil_db_traits::CommsType::ChannelVerified, 78 79 }; 79 80 let id = self 80 81 .infra_repo ··· 170 171 tranquil_db_traits::CommsType::ChannelVerification => { 171 172 CommsType::ChannelVerification 172 173 } 174 + tranquil_db_traits::CommsType::ChannelVerified => CommsType::ChannelVerified, 173 175 }, 174 176 status: match item.status { 175 177 tranquil_db_traits::CommsStatus::Pending => CommsStatus::Pending, ··· 247 249 } 248 250 } 249 251 252 + struct ResolvedRecipient { 253 + channel: tranquil_db_traits::CommsChannel, 254 + recipient: String, 255 + } 256 + 257 + fn resolve_recipient( 258 + prefs: &UserCommsPrefs, 259 + channel: tranquil_db_traits::CommsChannel, 260 + ) -> ResolvedRecipient { 261 + let email_fallback = || ResolvedRecipient { 262 + channel: tranquil_db_traits::CommsChannel::Email, 263 + recipient: prefs.email.clone().unwrap_or_default(), 264 + }; 265 + match channel { 266 + tranquil_db_traits::CommsChannel::Email => email_fallback(), 267 + tranquil_db_traits::CommsChannel::Telegram => prefs 268 + .telegram_chat_id 269 + .map(|id| ResolvedRecipient { 270 + channel, 271 + recipient: id.to_string(), 272 + }) 273 + .unwrap_or_else(email_fallback), 274 + tranquil_db_traits::CommsChannel::Discord => prefs 275 + .discord_id 276 + .as_ref() 277 + .filter(|id| !id.is_empty()) 278 + .map(|id| ResolvedRecipient { 279 + channel, 280 + recipient: id.clone(), 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 { 288 + channel, 289 + recipient: n.clone(), 290 + }) 291 + .unwrap_or_else(email_fallback), 292 + } 293 + } 294 + 250 295 fn channel_from_str(s: &str) -> tranquil_db_traits::CommsChannel { 251 296 match s { 252 297 "discord" => tranquil_db_traits::CommsChannel::Discord, ··· 276 321 &[("hostname", hostname), ("handle", &prefs.handle)], 277 322 ); 278 323 let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]); 279 - let channel = prefs.preferred_channel; 324 + let resolved = resolve_recipient(&prefs, prefs.preferred_channel); 280 325 infra_repo 281 326 .enqueue_comms( 282 327 Some(user_id), 283 - channel, 328 + resolved.channel, 284 329 CommsType::Welcome, 285 - &prefs.email.unwrap_or_default(), 330 + &resolved.recipient, 286 331 Some(&subject), 287 332 &body, 288 333 None, ··· 307 352 &[("handle", &prefs.handle), ("code", code)], 308 353 ); 309 354 let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]); 310 - let channel = prefs.preferred_channel; 355 + let resolved = resolve_recipient(&prefs, prefs.preferred_channel); 311 356 infra_repo 312 357 .enqueue_comms( 313 358 Some(user_id), 314 - channel, 359 + resolved.channel, 315 360 CommsType::PasswordReset, 316 - &prefs.email.unwrap_or_default(), 361 + &resolved.recipient, 317 362 Some(&subject), 318 363 &body, 319 364 None, ··· 471 516 &[("handle", &prefs.handle), ("code", code)], 472 517 ); 473 518 let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]); 474 - let channel = prefs.preferred_channel; 519 + let resolved = resolve_recipient(&prefs, prefs.preferred_channel); 475 520 infra_repo 476 521 .enqueue_comms( 477 522 Some(user_id), 478 - channel, 523 + resolved.channel, 479 524 CommsType::AccountDeletion, 480 - &prefs.email.unwrap_or_default(), 525 + &resolved.recipient, 481 526 Some(&subject), 482 527 &body, 483 528 None, ··· 502 547 &[("handle", &prefs.handle), ("token", token)], 503 548 ); 504 549 let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]); 505 - let channel = prefs.preferred_channel; 550 + let resolved = resolve_recipient(&prefs, prefs.preferred_channel); 506 551 infra_repo 507 552 .enqueue_comms( 508 553 Some(user_id), 509 - channel, 554 + resolved.channel, 510 555 CommsType::PlcOperation, 511 - &prefs.email.unwrap_or_default(), 556 + &resolved.recipient, 512 557 Some(&subject), 513 558 &body, 514 559 None, ··· 533 578 &[("handle", &prefs.handle), ("url", recovery_url)], 534 579 ); 535 580 let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]); 536 - let channel = prefs.preferred_channel; 581 + let resolved = resolve_recipient(&prefs, prefs.preferred_channel); 537 582 infra_repo 538 583 .enqueue_comms( 539 584 Some(user_id), 540 - channel, 585 + resolved.channel, 541 586 CommsType::PasskeyRecovery, 542 - &prefs.email.unwrap_or_default(), 587 + &resolved.recipient, 543 588 Some(&subject), 544 589 &body, 545 590 None, ··· 663 708 &[("handle", &prefs.handle), ("code", code)], 664 709 ); 665 710 let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]); 666 - let channel = prefs.preferred_channel; 711 + let resolved = resolve_recipient(&prefs, prefs.preferred_channel); 667 712 infra_repo 668 713 .enqueue_comms( 669 714 Some(user_id), 670 - channel, 715 + resolved.channel, 671 716 CommsType::TwoFactorCode, 672 - &prefs.email.unwrap_or_default(), 717 + &resolved.recipient, 673 718 Some(&subject), 674 719 &body, 675 720 None, ··· 703 748 ], 704 749 ); 705 750 let subject = format_message(strings.legacy_login_subject, &[("hostname", hostname)]); 751 + let resolved = resolve_recipient(&prefs, channel); 706 752 infra_repo 707 753 .enqueue_comms( 708 754 Some(user_id), 709 - channel, 755 + resolved.channel, 710 756 CommsType::LegacyLoginAlert, 711 - &prefs.email.unwrap_or_default(), 757 + &resolved.recipient, 758 + Some(&subject), 759 + &body, 760 + None, 761 + ) 762 + .await 763 + } 764 + 765 + pub async fn enqueue_channel_verified( 766 + user_repo: &dyn UserRepository, 767 + infra_repo: &dyn InfraRepository, 768 + user_id: Uuid, 769 + channel_name: &str, 770 + recipient: &str, 771 + hostname: &str, 772 + ) -> Result<Uuid, DbError> { 773 + let prefs = user_repo 774 + .get_comms_prefs(user_id) 775 + .await? 776 + .ok_or(DbError::NotFound)?; 777 + let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); 778 + let display_name = match channel_name { 779 + "email" => "Email", 780 + "discord" => "Discord", 781 + "telegram" => "Telegram", 782 + "signal" => "Signal", 783 + other => other, 784 + }; 785 + let body = format_message( 786 + strings.channel_verified_body, 787 + &[ 788 + ("handle", &prefs.handle), 789 + ("channel", display_name), 790 + ("hostname", hostname), 791 + ], 792 + ); 793 + let subject = format_message(strings.channel_verified_subject, &[("hostname", hostname)]); 794 + let comms_channel = channel_from_str(channel_name); 795 + infra_repo 796 + .enqueue_comms( 797 + Some(user_id), 798 + comms_channel, 799 + CommsType::ChannelVerified, 800 + recipient, 712 801 Some(&subject), 713 802 &body, 714 803 None,
+9
crates/tranquil-pds/src/lib.rs
··· 16 16 pub mod plc; 17 17 pub mod rate_limit; 18 18 pub mod repo; 19 + pub mod repo_write_lock; 19 20 pub mod scheduled; 20 21 pub mod sso; 21 22 pub mod state; ··· 279 280 .route( 280 281 "/_checkEmailVerified", 281 282 post(api::server::check_email_verified), 283 + ) 284 + .route( 285 + "/_checkChannelVerified", 286 + post(api::server::check_channel_verified), 282 287 ) 283 288 .route( 284 289 "/com.atproto.server.confirmEmail", ··· 638 643 .route("/robots.txt", get(api::server::robots_txt)) 639 644 .route("/logo", get(api::server::get_logo)) 640 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 + ) 641 650 .layer(DefaultBodyLimit::max(util::get_max_blob_size())) 642 651 .layer(middleware::from_fn(metrics::metrics_middleware)) 643 652 .layer(
+27
crates/tranquil-pds/src/main.rs
··· 78 78 } 79 79 80 80 if let Some(telegram_sender) = TelegramSender::from_env() { 81 + let secret_token = match std::env::var("TELEGRAM_WEBHOOK_SECRET") { 82 + Ok(s) => s, 83 + Err(_) => { 84 + return Err( 85 + "TELEGRAM_BOT_TOKEN is set but TELEGRAM_WEBHOOK_SECRET is missing. Both are required for secure Telegram integration.".into() 86 + ); 87 + } 88 + }; 81 89 info!("Telegram comms enabled"); 90 + match telegram_sender.resolve_bot_username().await { 91 + Ok(username) => { 92 + info!(bot_username = %username, "Resolved Telegram bot username"); 93 + tranquil_pds::util::set_telegram_bot_username(username); 94 + let hostname = 95 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 96 + let webhook_url = format!("https://{}/webhook/telegram", hostname); 97 + match telegram_sender 98 + .set_webhook(&webhook_url, Some(&secret_token)) 99 + .await 100 + { 101 + Ok(()) => info!(url = %webhook_url, "Telegram webhook registered"), 102 + Err(e) => warn!("Failed to register Telegram webhook: {}", e), 103 + } 104 + } 105 + Err(e) => { 106 + warn!("Failed to resolve Telegram bot username: {}", e); 107 + } 108 + } 82 109 comms_service = comms_service.register_sender(telegram_sender); 83 110 } 84 111
+180
crates/tranquil-pds/src/repo_write_lock.rs
··· 1 + use std::collections::HashMap; 2 + use std::sync::Arc; 3 + use std::time::Duration; 4 + use tokio::sync::{Mutex, OwnedMutexGuard, RwLock}; 5 + use uuid::Uuid; 6 + 7 + const SWEEP_INTERVAL: Duration = Duration::from_secs(300); 8 + 9 + pub struct RepoWriteLocks { 10 + locks: Arc<RwLock<HashMap<Uuid, Arc<Mutex<()>>>>>, 11 + } 12 + 13 + impl Default for RepoWriteLocks { 14 + fn default() -> Self { 15 + Self::new() 16 + } 17 + } 18 + 19 + impl RepoWriteLocks { 20 + pub fn new() -> Self { 21 + let locks = Arc::new(RwLock::new(HashMap::new())); 22 + let sweep_locks = Arc::clone(&locks); 23 + tokio::spawn(async move { 24 + sweep_loop(sweep_locks).await; 25 + }); 26 + Self { locks } 27 + } 28 + 29 + pub async fn lock(&self, user_id: Uuid) -> OwnedMutexGuard<()> { 30 + let mutex = { 31 + let read_guard = self.locks.read().await; 32 + read_guard.get(&user_id).cloned() 33 + }; 34 + 35 + match mutex { 36 + Some(m) => m.lock_owned().await, 37 + None => { 38 + let mut write_guard = self.locks.write().await; 39 + let mutex = write_guard 40 + .entry(user_id) 41 + .or_insert_with(|| Arc::new(Mutex::new(()))) 42 + .clone(); 43 + drop(write_guard); 44 + mutex.lock_owned().await 45 + } 46 + } 47 + } 48 + } 49 + 50 + async fn sweep_loop(locks: Arc<RwLock<HashMap<Uuid, Arc<Mutex<()>>>>>) { 51 + tokio::time::sleep(SWEEP_INTERVAL).await; 52 + let mut write_guard = locks.write().await; 53 + let before = write_guard.len(); 54 + write_guard.retain(|_, mutex| Arc::strong_count(mutex) > 1); 55 + let evicted = before - write_guard.len(); 56 + if evicted > 0 { 57 + tracing::debug!( 58 + evicted, 59 + remaining = write_guard.len(), 60 + "repo write lock sweep" 61 + ); 62 + } 63 + drop(write_guard); 64 + Box::pin(sweep_loop(locks)).await; 65 + } 66 + 67 + #[cfg(test)] 68 + mod tests { 69 + use super::*; 70 + use std::sync::atomic::{AtomicU32, Ordering}; 71 + use std::time::Duration; 72 + 73 + #[tokio::test] 74 + async fn test_locks_serialize_same_user() { 75 + let locks = Arc::new(RepoWriteLocks::new()); 76 + let user_id = Uuid::new_v4(); 77 + let counter = Arc::new(AtomicU32::new(0)); 78 + let max_concurrent = Arc::new(AtomicU32::new(0)); 79 + 80 + let handles: Vec<_> = (0..10) 81 + .map(|_| { 82 + let locks = locks.clone(); 83 + let counter = counter.clone(); 84 + let max_concurrent = max_concurrent.clone(); 85 + 86 + tokio::spawn(async move { 87 + let _guard = locks.lock(user_id).await; 88 + let current = counter.fetch_add(1, Ordering::SeqCst) + 1; 89 + max_concurrent.fetch_max(current, Ordering::SeqCst); 90 + tokio::time::sleep(Duration::from_millis(1)).await; 91 + counter.fetch_sub(1, Ordering::SeqCst); 92 + }) 93 + }) 94 + .collect(); 95 + 96 + futures::future::join_all(handles).await; 97 + 98 + assert_eq!( 99 + max_concurrent.load(Ordering::SeqCst), 100 + 1, 101 + "Only one task should hold the lock at a time for same user" 102 + ); 103 + } 104 + 105 + #[tokio::test] 106 + async fn test_different_users_can_run_concurrently() { 107 + let locks = Arc::new(RepoWriteLocks::new()); 108 + let user1 = Uuid::new_v4(); 109 + let user2 = Uuid::new_v4(); 110 + let concurrent_count = Arc::new(AtomicU32::new(0)); 111 + let max_concurrent = Arc::new(AtomicU32::new(0)); 112 + 113 + let locks1 = locks.clone(); 114 + let count1 = concurrent_count.clone(); 115 + let max1 = max_concurrent.clone(); 116 + let handle1 = tokio::spawn(async move { 117 + let _guard = locks1.lock(user1).await; 118 + let current = count1.fetch_add(1, Ordering::SeqCst) + 1; 119 + max1.fetch_max(current, Ordering::SeqCst); 120 + tokio::time::sleep(Duration::from_millis(50)).await; 121 + count1.fetch_sub(1, Ordering::SeqCst); 122 + }); 123 + 124 + tokio::time::sleep(Duration::from_millis(10)).await; 125 + 126 + let locks2 = locks.clone(); 127 + let count2 = concurrent_count.clone(); 128 + let max2 = max_concurrent.clone(); 129 + let handle2 = tokio::spawn(async move { 130 + let _guard = locks2.lock(user2).await; 131 + let current = count2.fetch_add(1, Ordering::SeqCst) + 1; 132 + max2.fetch_max(current, Ordering::SeqCst); 133 + tokio::time::sleep(Duration::from_millis(50)).await; 134 + count2.fetch_sub(1, Ordering::SeqCst); 135 + }); 136 + 137 + handle1.await.unwrap(); 138 + handle2.await.unwrap(); 139 + 140 + assert_eq!( 141 + max_concurrent.load(Ordering::SeqCst), 142 + 2, 143 + "Different users should be able to run concurrently" 144 + ); 145 + } 146 + 147 + #[tokio::test] 148 + async fn test_sweep_evicts_idle_entries() { 149 + let locks = Arc::new(RwLock::new(HashMap::new())); 150 + let user_id = Uuid::new_v4(); 151 + 152 + { 153 + let mut write_guard = locks.write().await; 154 + write_guard.insert(user_id, Arc::new(Mutex::new(()))); 155 + } 156 + 157 + assert_eq!(locks.read().await.len(), 1); 158 + 159 + let mut write_guard = locks.write().await; 160 + write_guard.retain(|_, mutex| Arc::strong_count(mutex) > 1); 161 + assert_eq!(write_guard.len(), 0, "Idle entry should be evicted"); 162 + } 163 + 164 + #[tokio::test] 165 + async fn test_sweep_preserves_active_entries() { 166 + let locks = Arc::new(RwLock::new(HashMap::new())); 167 + let user_id = Uuid::new_v4(); 168 + let active_mutex = Arc::new(Mutex::new(())); 169 + let _held_ref = active_mutex.clone(); 170 + 171 + { 172 + let mut write_guard = locks.write().await; 173 + write_guard.insert(user_id, active_mutex); 174 + } 175 + 176 + let mut write_guard = locks.write().await; 177 + write_guard.retain(|_, mutex| Arc::strong_count(mutex) > 1); 178 + assert_eq!(write_guard.len(), 1, "Active entry should be preserved"); 179 + } 180 + }
+12 -4
crates/tranquil-pds/src/sso/endpoints.rs
··· 119 119 let auth_header = headers 120 120 .get(axum::http::header::AUTHORIZATION) 121 121 .and_then(|v| v.to_str().ok()); 122 - let extracted = extract_auth_token_from_header(auth_header) 123 - .ok_or(ApiError::SsoNotAuthenticated)?; 122 + let extracted = 123 + extract_auth_token_from_header(auth_header).ok_or(ApiError::SsoNotAuthenticated)?; 124 124 let auth_user = validate_bearer_token_cached( 125 125 state.user_repo.as_ref(), 126 126 state.cache.as_ref(), ··· 899 899 _ => return Err(ApiError::MissingDiscordId), 900 900 }, 901 901 "telegram" => match &input.telegram_username { 902 - Some(username) if !username.trim().is_empty() => username.trim().to_string(), 902 + Some(username) if !username.trim().is_empty() => { 903 + let clean = username.trim().trim_start_matches('@'); 904 + if !crate::api::validation::is_valid_telegram_username(clean) { 905 + return Err(ApiError::InvalidRequest( 906 + "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), 907 + )); 908 + } 909 + clean.to_string() 910 + } 903 911 _ => return Err(ApiError::MissingTelegramUsername), 904 912 }, 905 913 "signal" => match &input.signal_number { ··· 1104 1112 telegram_username: input 1105 1113 .telegram_username 1106 1114 .clone() 1107 - .map(|s| s.trim().to_string()) 1115 + .map(|s| s.trim().trim_start_matches('@').to_string()) 1108 1116 .filter(|s| !s.is_empty()), 1109 1117 signal_number: input 1110 1118 .signal_number
+4
crates/tranquil-pds/src/state.rs
··· 5 5 use crate::config::AuthConfig; 6 6 use crate::rate_limit::RateLimiters; 7 7 use crate::repo::PostgresBlockStore; 8 + use crate::repo_write_lock::RepoWriteLocks; 8 9 use crate::sso::{SsoConfig, SsoManager}; 9 10 use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage}; 10 11 use crate::sync::firehose::SequencedEvent; ··· 38 39 pub backup_storage: Option<Arc<dyn BackupStorage>>, 39 40 pub firehose_tx: broadcast::Sender<SequencedEvent>, 40 41 pub rate_limiters: Arc<RateLimiters>, 42 + pub repo_write_locks: Arc<RepoWriteLocks>, 41 43 pub circuit_breakers: Arc<CircuitBreakers>, 42 44 pub cache: Arc<dyn Cache>, 43 45 pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>, ··· 181 183 182 184 let (firehose_tx, _) = broadcast::channel(firehose_buffer_size); 183 185 let rate_limiters = Arc::new(RateLimiters::new()); 186 + let repo_write_locks = Arc::new(RepoWriteLocks::new()); 184 187 let circuit_breakers = Arc::new(CircuitBreakers::new()); 185 188 let (cache, distributed_rate_limiter) = create_cache().await; 186 189 let did_resolver = Arc::new(DidResolver::new()); ··· 209 212 backup_storage, 210 213 firehose_tx, 211 214 rate_limiters, 215 + repo_write_locks, 212 216 circuit_breakers, 213 217 cache, 214 218 distributed_rate_limiter,
+9
crates/tranquil-pds/src/util.rs
··· 14 14 static MAX_BLOB_SIZE: OnceLock<usize> = OnceLock::new(); 15 15 static PDS_HOSTNAME: OnceLock<String> = OnceLock::new(); 16 16 static PDS_HOSTNAME_WITHOUT_PORT: OnceLock<String> = OnceLock::new(); 17 + static TELEGRAM_BOT_USERNAME: OnceLock<String> = OnceLock::new(); 17 18 18 19 pub fn get_max_blob_size() -> usize { 19 20 *MAX_BLOB_SIZE.get_or_init(|| { ··· 102 103 let hostname = pds_hostname(); 103 104 hostname.split(':').next().unwrap_or(hostname).to_string() 104 105 }) 106 + } 107 + 108 + pub fn set_telegram_bot_username(username: String) { 109 + TELEGRAM_BOT_USERNAME.set(username).ok(); 110 + } 111 + 112 + pub fn telegram_bot_username() -> Option<&'static str> { 113 + TELEGRAM_BOT_USERNAME.get().map(|s| s.as_str()) 105 114 } 106 115 107 116 pub fn pds_public_url() -> String {
+228
crates/tranquil-pds/tests/helpers/mod.rs
··· 4 4 5 5 pub use crate::common::*; 6 6 7 + #[allow(dead_code)] 8 + pub async fn paginate_records( 9 + client: &reqwest::Client, 10 + base: &str, 11 + jwt: &str, 12 + did: &str, 13 + collection: &str, 14 + limit: usize, 15 + ) -> Vec<Value> { 16 + paginate_records_inner(client, base, jwt, did, collection, limit, None, Vec::new()).await 17 + } 18 + 19 + #[allow(clippy::too_many_arguments)] 20 + async fn paginate_records_inner( 21 + client: &reqwest::Client, 22 + base: &str, 23 + jwt: &str, 24 + did: &str, 25 + collection: &str, 26 + limit: usize, 27 + cursor: Option<String>, 28 + mut acc: Vec<Value>, 29 + ) -> Vec<Value> { 30 + let limit_str = limit.to_string(); 31 + let mut query: Vec<(&str, &str)> = vec![ 32 + ("repo", did), 33 + ("collection", collection), 34 + ("limit", &limit_str), 35 + ]; 36 + if let Some(ref c) = cursor { 37 + query.push(("cursor", c.as_str())); 38 + } 39 + 40 + let res = client 41 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 42 + .bearer_auth(jwt) 43 + .query(&query) 44 + .send() 45 + .await; 46 + 47 + let Ok(response) = res else { return acc }; 48 + let Ok(body) = response.json::<Value>().await else { 49 + return acc; 50 + }; 51 + 52 + let Some(records) = body["records"].as_array() else { 53 + return acc; 54 + }; 55 + acc.extend(records.iter().cloned()); 56 + 57 + match body["cursor"].as_str() { 58 + Some(next) => { 59 + Box::pin(paginate_records_inner( 60 + client, 61 + base, 62 + jwt, 63 + did, 64 + collection, 65 + limit, 66 + Some(next.to_string()), 67 + acc, 68 + )) 69 + .await 70 + } 71 + None => acc, 72 + } 73 + } 74 + 75 + #[allow(dead_code)] 76 + pub async fn count_records( 77 + client: &reqwest::Client, 78 + base: &str, 79 + jwt: &str, 80 + did: &str, 81 + collection: &str, 82 + ) -> usize { 83 + paginate_records(client, base, jwt, did, collection, 100) 84 + .await 85 + .len() 86 + } 87 + 7 88 fn unique_id() -> String { 8 89 uuid::Uuid::new_v4().simple().to_string()[..12].to_string() 9 90 } ··· 246 327 .await 247 328 .expect("Failed to update deactivated_at"); 248 329 } 330 + 331 + #[allow(dead_code)] 332 + pub fn make_cid(data: &[u8]) -> cid::Cid { 333 + use sha2::{Digest, Sha256}; 334 + let hash = Sha256::digest(data); 335 + let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap(); 336 + cid::Cid::new_v1(0x71, multihash) 337 + } 338 + 339 + #[allow(dead_code)] 340 + pub fn write_varint(buf: &mut Vec<u8>, value: u64) { 341 + buf.extend(encode_varint_bytes(value)); 342 + } 343 + 344 + fn encode_varint_bytes(value: u64) -> Vec<u8> { 345 + match value < 0x80 { 346 + true => vec![value as u8], 347 + false => { 348 + let mut rest = encode_varint_bytes(value >> 7); 349 + rest.insert(0, ((value & 0x7F) as u8) | 0x80); 350 + rest 351 + } 352 + } 353 + } 354 + 355 + #[allow(dead_code)] 356 + pub fn encode_car_block(cid: &cid::Cid, data: &[u8]) -> Vec<u8> { 357 + let cid_bytes = cid.to_bytes(); 358 + let mut result = Vec::new(); 359 + write_varint(&mut result, (cid_bytes.len() + data.len()) as u64); 360 + result.extend_from_slice(&cid_bytes); 361 + result.extend_from_slice(data); 362 + result 363 + } 364 + 365 + #[allow(dead_code)] 366 + pub fn create_test_record() -> (Vec<u8>, cid::Cid) { 367 + use ipld_core::ipld::Ipld; 368 + use std::collections::BTreeMap; 369 + let record = Ipld::Map(BTreeMap::from([ 370 + ( 371 + "$type".to_string(), 372 + Ipld::String("app.bsky.feed.post".to_string()), 373 + ), 374 + ( 375 + "text".to_string(), 376 + Ipld::String("Test post for verification".to_string()), 377 + ), 378 + ( 379 + "createdAt".to_string(), 380 + Ipld::String("2024-01-01T00:00:00Z".to_string()), 381 + ), 382 + ])); 383 + let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap(); 384 + let cid = make_cid(&bytes); 385 + (bytes, cid) 386 + } 387 + 388 + #[allow(dead_code)] 389 + pub fn create_mst_node(entries: Vec<(String, cid::Cid)>) -> (Vec<u8>, cid::Cid) { 390 + use ipld_core::ipld::Ipld; 391 + use std::collections::BTreeMap; 392 + let ipld_entries: Vec<Ipld> = entries 393 + .into_iter() 394 + .map(|(key, value_cid)| { 395 + Ipld::Map(BTreeMap::from([ 396 + ("k".to_string(), Ipld::Bytes(key.into_bytes())), 397 + ("v".to_string(), Ipld::Link(value_cid)), 398 + ("p".to_string(), Ipld::Integer(0)), 399 + ])) 400 + }) 401 + .collect(); 402 + let node = Ipld::Map(BTreeMap::from([( 403 + "e".to_string(), 404 + Ipld::List(ipld_entries), 405 + )])); 406 + let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); 407 + let cid = make_cid(&bytes); 408 + (bytes, cid) 409 + } 410 + 411 + #[allow(dead_code)] 412 + pub fn create_car_signed_commit( 413 + did: &str, 414 + data_cid: &cid::Cid, 415 + signing_key: &k256::ecdsa::SigningKey, 416 + ) -> (Vec<u8>, cid::Cid) { 417 + use jacquard_common::types::{integer::LimitedU32, string::Tid}; 418 + use jacquard_repo::commit::Commit; 419 + let rev = Tid::now(LimitedU32::MIN); 420 + let did = jacquard_common::types::string::Did::new(did).expect("valid DID"); 421 + let unsigned = Commit::new_unsigned(did, *data_cid, rev, None); 422 + let signed = unsigned.sign(signing_key).expect("signing failed"); 423 + let signed_bytes = signed.to_cbor().expect("serialization failed"); 424 + let cid = make_cid(&signed_bytes); 425 + (signed_bytes, cid) 426 + } 427 + 428 + #[allow(dead_code)] 429 + pub fn build_car_with_signature( 430 + did: &str, 431 + signing_key: &k256::ecdsa::SigningKey, 432 + ) -> (Vec<u8>, cid::Cid) { 433 + let (record_bytes, record_cid) = create_test_record(); 434 + let (mst_bytes, mst_cid) = 435 + create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]); 436 + let (commit_bytes, commit_cid) = create_car_signed_commit(did, &mst_cid, signing_key); 437 + let header = iroh_car::CarHeader::new_v1(vec![commit_cid]); 438 + let header_bytes = header.encode().unwrap(); 439 + let mut car = Vec::new(); 440 + write_varint(&mut car, header_bytes.len() as u64); 441 + car.extend_from_slice(&header_bytes); 442 + car.extend(encode_car_block(&commit_cid, &commit_bytes)); 443 + car.extend(encode_car_block(&mst_cid, &mst_bytes)); 444 + car.extend(encode_car_block(&record_cid, &record_bytes)); 445 + (car, commit_cid) 446 + } 447 + 448 + #[allow(dead_code)] 449 + pub fn get_multikey_from_signing_key(signing_key: &k256::ecdsa::SigningKey) -> String { 450 + let public_key = signing_key.verifying_key(); 451 + let compressed = public_key.to_sec1_bytes(); 452 + let buf: Vec<u8> = encode_varint_bytes(0xE7) 453 + .into_iter() 454 + .chain(compressed.iter().copied()) 455 + .collect(); 456 + multibase::encode(multibase::Base::Base58Btc, buf) 457 + } 458 + 459 + #[allow(dead_code)] 460 + pub async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> { 461 + let db_url = get_db_connection_string().await; 462 + let pool = sqlx::PgPool::connect(&db_url).await.ok()?; 463 + let row = sqlx::query!( 464 + r#" 465 + SELECT k.key_bytes, k.encryption_version 466 + FROM user_keys k 467 + JOIN users u ON k.user_id = u.id 468 + WHERE u.did = $1 469 + "#, 470 + did 471 + ) 472 + .fetch_optional(&pool) 473 + .await 474 + .ok()??; 475 + tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok() 476 + }
+3 -136
crates/tranquil-pds/tests/import_with_verification.rs
··· 1 1 mod common; 2 - use cid::Cid; 2 + mod helpers; 3 3 use common::*; 4 - use ipld_core::ipld::Ipld; 5 - use jacquard_common::types::{integer::LimitedU32, string::Tid}; 6 - use jacquard_repo::commit::Commit; 4 + use helpers::*; 7 5 use k256::ecdsa::SigningKey; 8 6 use reqwest::StatusCode; 9 7 use serde_json::json; 10 - use sha2::{Digest, Sha256}; 11 - use sqlx::PgPool; 12 - use std::collections::BTreeMap; 13 8 use wiremock::matchers::{method, path}; 14 9 use wiremock::{Mock, MockServer, ResponseTemplate}; 15 10 16 - fn make_cid(data: &[u8]) -> Cid { 17 - let mut hasher = Sha256::new(); 18 - hasher.update(data); 19 - let hash = hasher.finalize(); 20 - let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap(); 21 - Cid::new_v1(0x71, multihash) 22 - } 23 - 24 - fn write_varint(buf: &mut Vec<u8>, mut value: u64) { 25 - loop { 26 - let mut byte = (value & 0x7F) as u8; 27 - value >>= 7; 28 - if value != 0 { 29 - byte |= 0x80; 30 - } 31 - buf.push(byte); 32 - if value == 0 { 33 - break; 34 - } 35 - } 36 - } 37 - 38 - fn encode_car_block(cid: &Cid, data: &[u8]) -> Vec<u8> { 39 - let cid_bytes = cid.to_bytes(); 40 - let mut result = Vec::new(); 41 - write_varint(&mut result, (cid_bytes.len() + data.len()) as u64); 42 - result.extend_from_slice(&cid_bytes); 43 - result.extend_from_slice(data); 44 - result 45 - } 46 - 47 - fn get_multikey_from_signing_key(signing_key: &SigningKey) -> String { 48 - let public_key = signing_key.verifying_key(); 49 - let compressed = public_key.to_sec1_bytes(); 50 - fn encode_uvarint(mut x: u64) -> Vec<u8> { 51 - let mut out = Vec::new(); 52 - while x >= 0x80 { 53 - out.push(((x as u8) & 0x7F) | 0x80); 54 - x >>= 7; 55 - } 56 - out.push(x as u8); 57 - out 58 - } 59 - let mut buf = encode_uvarint(0xE7); 60 - buf.extend_from_slice(&compressed); 61 - multibase::encode(multibase::Base::Base58Btc, buf) 62 - } 63 - 64 11 fn create_did_document( 65 12 did: &str, 66 13 handle: &str, ··· 89 36 }) 90 37 } 91 38 92 - fn create_signed_commit(did: &str, data_cid: &Cid, signing_key: &SigningKey) -> (Vec<u8>, Cid) { 93 - let rev = Tid::now(LimitedU32::MIN); 94 - let did = jacquard_common::types::string::Did::new(did).expect("valid DID"); 95 - let unsigned = Commit::new_unsigned(did, *data_cid, rev, None); 96 - let signed = unsigned.sign(signing_key).expect("signing failed"); 97 - let signed_bytes = signed.to_cbor().expect("serialization failed"); 98 - let cid = make_cid(&signed_bytes); 99 - (signed_bytes, cid) 100 - } 101 - 102 - fn create_mst_node(entries: Vec<(String, Cid)>) -> (Vec<u8>, Cid) { 103 - let ipld_entries: Vec<Ipld> = entries 104 - .into_iter() 105 - .map(|(key, value_cid)| { 106 - Ipld::Map(BTreeMap::from([ 107 - ("k".to_string(), Ipld::Bytes(key.into_bytes())), 108 - ("v".to_string(), Ipld::Link(value_cid)), 109 - ("p".to_string(), Ipld::Integer(0)), 110 - ])) 111 - }) 112 - .collect(); 113 - let node = Ipld::Map(BTreeMap::from([( 114 - "e".to_string(), 115 - Ipld::List(ipld_entries), 116 - )])); 117 - let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); 118 - let cid = make_cid(&bytes); 119 - (bytes, cid) 120 - } 121 - 122 - fn create_record() -> (Vec<u8>, Cid) { 123 - let record = Ipld::Map(BTreeMap::from([ 124 - ( 125 - "$type".to_string(), 126 - Ipld::String("app.bsky.feed.post".to_string()), 127 - ), 128 - ( 129 - "text".to_string(), 130 - Ipld::String("Test post for verification".to_string()), 131 - ), 132 - ( 133 - "createdAt".to_string(), 134 - Ipld::String("2024-01-01T00:00:00Z".to_string()), 135 - ), 136 - ])); 137 - let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap(); 138 - let cid = make_cid(&bytes); 139 - (bytes, cid) 140 - } 141 - fn build_car_with_signature(did: &str, signing_key: &SigningKey) -> (Vec<u8>, Cid) { 142 - let (record_bytes, record_cid) = create_record(); 143 - let (mst_bytes, mst_cid) = 144 - create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]); 145 - let (commit_bytes, commit_cid) = create_signed_commit(did, &mst_cid, signing_key); 146 - let header = iroh_car::CarHeader::new_v1(vec![commit_cid]); 147 - let header_bytes = header.encode().unwrap(); 148 - let mut car = Vec::new(); 149 - write_varint(&mut car, header_bytes.len() as u64); 150 - car.extend_from_slice(&header_bytes); 151 - car.extend(encode_car_block(&commit_cid, &commit_bytes)); 152 - car.extend(encode_car_block(&mst_cid, &mst_bytes)); 153 - car.extend(encode_car_block(&record_cid, &record_bytes)); 154 - (car, commit_cid) 155 - } 156 39 async fn setup_mock_plc_directory(did: &str, did_doc: serde_json::Value) -> MockServer { 157 40 let mock_server = MockServer::start().await; 158 41 let did_encoded = urlencoding::encode(did); ··· 164 47 .await; 165 48 mock_server 166 49 } 167 - async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> { 168 - let db_url = get_db_connection_string().await; 169 - let pool = PgPool::connect(&db_url).await.ok()?; 170 - let row = sqlx::query!( 171 - r#" 172 - SELECT k.key_bytes, k.encryption_version 173 - FROM user_keys k 174 - JOIN users u ON k.user_id = u.id 175 - WHERE u.did = $1 176 - "#, 177 - did 178 - ) 179 - .fetch_optional(&pool) 180 - .await 181 - .ok()??; 182 - tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok() 183 - } 50 + 184 51 #[tokio::test] 185 52 #[ignore = "requires exclusive env var access; run with: cargo test test_import_with_valid_signature_and_mock_plc -- --ignored --test-threads=1"] 186 53 async fn test_import_with_valid_signature_and_mock_plc() {
+2013
crates/tranquil-pds/tests/whole_story.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use chrono::Utc; 5 + use common::*; 6 + use futures::{StreamExt, future::join_all}; 7 + use helpers::*; 8 + use k256::ecdsa::SigningKey; 9 + use reqwest::{StatusCode, header}; 10 + use serde_json::{Value, json}; 11 + 12 + #[tokio::test] 13 + async fn test_complete_user_journey_signup_to_deletion() { 14 + let client = client(); 15 + let base = base_url().await; 16 + let uid = uuid::Uuid::new_v4().simple().to_string(); 17 + let handle = format!("journey{}", &uid[..8]); 18 + let email = format!("journey{}@test.com", &uid[..8]); 19 + let password = "JourneyPass123!"; 20 + 21 + let create_res = client 22 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 23 + .json(&json!({ 24 + "handle": handle, 25 + "email": email, 26 + "password": password 27 + })) 28 + .send() 29 + .await 30 + .expect("Account creation failed"); 31 + assert_eq!(create_res.status(), StatusCode::OK); 32 + let account: Value = create_res.json().await.unwrap(); 33 + let did = account["did"].as_str().unwrap().to_string(); 34 + 35 + let jwt = verify_new_account(&client, &did).await; 36 + 37 + let blob_data = b"This is my avatar image data for the complete journey test"; 38 + let upload_res = client 39 + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base)) 40 + .header(header::CONTENT_TYPE, "image/png") 41 + .bearer_auth(&jwt) 42 + .body(blob_data.to_vec()) 43 + .send() 44 + .await 45 + .expect("Blob upload failed"); 46 + assert_eq!(upload_res.status(), StatusCode::OK); 47 + let upload_body: Value = upload_res.json().await.unwrap(); 48 + let avatar_blob = upload_body["blob"].clone(); 49 + 50 + let profile_res = client 51 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 52 + .bearer_auth(&jwt) 53 + .json(&json!({ 54 + "repo": did, 55 + "collection": "app.bsky.actor.profile", 56 + "rkey": "self", 57 + "record": { 58 + "$type": "app.bsky.actor.profile", 59 + "displayName": "Journey Test User", 60 + "description": "Testing the complete user journey", 61 + "avatar": avatar_blob 62 + } 63 + })) 64 + .send() 65 + .await 66 + .expect("Profile creation failed"); 67 + assert_eq!(profile_res.status(), StatusCode::OK); 68 + 69 + let post1_res = client 70 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 71 + .bearer_auth(&jwt) 72 + .json(&json!({ 73 + "repo": did, 74 + "collection": "app.bsky.feed.post", 75 + "record": { 76 + "$type": "app.bsky.feed.post", 77 + "text": "My first post on this journey!", 78 + "createdAt": Utc::now().to_rfc3339() 79 + } 80 + })) 81 + .send() 82 + .await 83 + .expect("First post failed"); 84 + assert_eq!(post1_res.status(), StatusCode::OK); 85 + let post1_body: Value = post1_res.json().await.unwrap(); 86 + let post1_uri = post1_body["uri"].as_str().unwrap(); 87 + let post1_cid = post1_body["cid"].as_str().unwrap(); 88 + 89 + let post2_res = client 90 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 91 + .bearer_auth(&jwt) 92 + .json(&json!({ 93 + "repo": did, 94 + "collection": "app.bsky.feed.post", 95 + "record": { 96 + "$type": "app.bsky.feed.post", 97 + "text": "Second post in my journey", 98 + "createdAt": Utc::now().to_rfc3339() 99 + } 100 + })) 101 + .send() 102 + .await 103 + .expect("Second post failed"); 104 + assert_eq!(post2_res.status(), StatusCode::OK); 105 + 106 + let list_res = client 107 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 108 + .bearer_auth(&jwt) 109 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 110 + .send() 111 + .await 112 + .expect("List records failed"); 113 + assert_eq!(list_res.status(), StatusCode::OK); 114 + let list_body: Value = list_res.json().await.unwrap(); 115 + assert_eq!(list_body["records"].as_array().unwrap().len(), 2); 116 + 117 + let post1_rkey = post1_uri.split('/').next_back().unwrap(); 118 + let edit_res = client 119 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 120 + .bearer_auth(&jwt) 121 + .json(&json!({ 122 + "repo": did, 123 + "collection": "app.bsky.feed.post", 124 + "rkey": post1_rkey, 125 + "record": { 126 + "$type": "app.bsky.feed.post", 127 + "text": "My first post on this journey! (edited)", 128 + "createdAt": Utc::now().to_rfc3339() 129 + }, 130 + "swapRecord": post1_cid 131 + })) 132 + .send() 133 + .await 134 + .expect("Edit post failed"); 135 + assert_eq!(edit_res.status(), StatusCode::OK); 136 + 137 + let backup_res = client 138 + .post(format!("{}/xrpc/_backup.createBackup", base)) 139 + .bearer_auth(&jwt) 140 + .send() 141 + .await 142 + .expect("Backup creation failed"); 143 + assert_eq!(backup_res.status(), StatusCode::OK); 144 + let backup_body: Value = backup_res.json().await.unwrap(); 145 + let backup_id = backup_body["id"].as_str().unwrap(); 146 + 147 + let download_res = client 148 + .get(format!("{}/xrpc/_backup.getBackup?id={}", base, backup_id)) 149 + .bearer_auth(&jwt) 150 + .send() 151 + .await 152 + .expect("Backup download failed"); 153 + assert_eq!(download_res.status(), StatusCode::OK); 154 + let backup_bytes = download_res.bytes().await.unwrap(); 155 + assert!(backup_bytes.len() > 100, "Backup should have content"); 156 + 157 + let delete_res = client 158 + .post(format!("{}/xrpc/com.atproto.server.deleteSession", base)) 159 + .bearer_auth(&jwt) 160 + .send() 161 + .await 162 + .expect("Logout failed"); 163 + assert!(delete_res.status() == StatusCode::OK || delete_res.status() == StatusCode::NO_CONTENT); 164 + 165 + let login_res = client 166 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 167 + .json(&json!({ 168 + "identifier": handle, 169 + "password": password 170 + })) 171 + .send() 172 + .await 173 + .expect("Re-login failed"); 174 + assert_eq!(login_res.status(), StatusCode::OK); 175 + let login_body: Value = login_res.json().await.unwrap(); 176 + let new_jwt = login_body["accessJwt"].as_str().unwrap(); 177 + 178 + let verify_res = client 179 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 180 + .bearer_auth(new_jwt) 181 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 182 + .send() 183 + .await 184 + .expect("Verify after re-login failed"); 185 + assert_eq!(verify_res.status(), StatusCode::OK); 186 + let verify_body: Value = verify_res.json().await.unwrap(); 187 + assert_eq!(verify_body["records"].as_array().unwrap().len(), 2); 188 + 189 + let request_delete_res = client 190 + .post(format!( 191 + "{}/xrpc/com.atproto.server.requestAccountDelete", 192 + base 193 + )) 194 + .bearer_auth(new_jwt) 195 + .send() 196 + .await 197 + .expect("Request delete failed"); 198 + assert_eq!(request_delete_res.status(), StatusCode::OK); 199 + 200 + let pool = get_test_db_pool().await; 201 + let row = sqlx::query!( 202 + "SELECT token FROM account_deletion_requests WHERE did = $1", 203 + did 204 + ) 205 + .fetch_one(pool) 206 + .await 207 + .expect("Failed to get deletion token"); 208 + 209 + let final_delete_res = client 210 + .post(format!("{}/xrpc/com.atproto.server.deleteAccount", base)) 211 + .json(&json!({ 212 + "did": did, 213 + "password": password, 214 + "token": row.token 215 + })) 216 + .send() 217 + .await 218 + .expect("Final delete failed"); 219 + assert_eq!(final_delete_res.status(), StatusCode::OK); 220 + 221 + let user_gone = sqlx::query!("SELECT id FROM users WHERE did = $1", did) 222 + .fetch_optional(pool) 223 + .await 224 + .expect("Failed to check user"); 225 + assert!(user_gone.is_none(), "User should be deleted"); 226 + } 227 + 228 + #[tokio::test] 229 + async fn test_multi_user_social_graph_lifecycle() { 230 + let client = client(); 231 + let base = base_url().await; 232 + 233 + let (alice_did, alice_jwt) = setup_new_user("alice-social").await; 234 + let (bob_did, bob_jwt) = setup_new_user("bob-social").await; 235 + let (carol_did, carol_jwt) = setup_new_user("carol-social").await; 236 + 237 + let (alice_post_uri, alice_post_cid) = 238 + create_post(&client, &alice_did, &alice_jwt, "Hello from Alice!").await; 239 + 240 + let (bob_post_uri, bob_post_cid) = 241 + create_post(&client, &bob_did, &bob_jwt, "Hello from Bob!").await; 242 + 243 + let (_bob_follows_alice_uri, _) = create_follow(&client, &bob_did, &bob_jwt, &alice_did).await; 244 + let (_carol_follows_alice_uri, _) = 245 + create_follow(&client, &carol_did, &carol_jwt, &alice_did).await; 246 + let (_carol_follows_bob_uri, _) = 247 + create_follow(&client, &carol_did, &carol_jwt, &bob_did).await; 248 + 249 + let (_bob_likes_alice_uri, _) = create_like( 250 + &client, 251 + &bob_did, 252 + &bob_jwt, 253 + &alice_post_uri, 254 + &alice_post_cid, 255 + ) 256 + .await; 257 + let (_carol_likes_alice_uri, _) = create_like( 258 + &client, 259 + &carol_did, 260 + &carol_jwt, 261 + &alice_post_uri, 262 + &alice_post_cid, 263 + ) 264 + .await; 265 + 266 + let (_bob_reposts_alice_uri, _) = create_repost( 267 + &client, 268 + &bob_did, 269 + &bob_jwt, 270 + &alice_post_uri, 271 + &alice_post_cid, 272 + ) 273 + .await; 274 + 275 + let reply_res = client 276 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 277 + .bearer_auth(&carol_jwt) 278 + .json(&json!({ 279 + "repo": carol_did, 280 + "collection": "app.bsky.feed.post", 281 + "record": { 282 + "$type": "app.bsky.feed.post", 283 + "text": "Great post Alice!", 284 + "createdAt": Utc::now().to_rfc3339(), 285 + "reply": { 286 + "root": { "uri": alice_post_uri, "cid": alice_post_cid }, 287 + "parent": { "uri": alice_post_uri, "cid": alice_post_cid } 288 + } 289 + } 290 + })) 291 + .send() 292 + .await 293 + .expect("Reply failed"); 294 + assert_eq!(reply_res.status(), StatusCode::OK); 295 + let reply_body: Value = reply_res.json().await.unwrap(); 296 + let carol_reply_uri = reply_body["uri"].as_str().unwrap(); 297 + let carol_reply_cid = reply_body["cid"].as_str().unwrap(); 298 + 299 + let alice_reply_res = client 300 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 301 + .bearer_auth(&alice_jwt) 302 + .json(&json!({ 303 + "repo": alice_did, 304 + "collection": "app.bsky.feed.post", 305 + "record": { 306 + "$type": "app.bsky.feed.post", 307 + "text": "Thanks Carol!", 308 + "createdAt": Utc::now().to_rfc3339(), 309 + "reply": { 310 + "root": { "uri": alice_post_uri, "cid": alice_post_cid }, 311 + "parent": { "uri": carol_reply_uri, "cid": carol_reply_cid } 312 + } 313 + } 314 + })) 315 + .send() 316 + .await 317 + .expect("Alice reply failed"); 318 + assert_eq!(alice_reply_res.status(), StatusCode::OK); 319 + 320 + let alice_follows_res = client 321 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 322 + .query(&[ 323 + ("repo", alice_did.as_str()), 324 + ("collection", "app.bsky.graph.follow"), 325 + ]) 326 + .send() 327 + .await 328 + .unwrap(); 329 + let alice_follows: Value = alice_follows_res.json().await.unwrap(); 330 + assert_eq!( 331 + alice_follows["records"].as_array().unwrap().len(), 332 + 0, 333 + "Alice follows nobody" 334 + ); 335 + 336 + let bob_follows_res = client 337 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 338 + .query(&[ 339 + ("repo", bob_did.as_str()), 340 + ("collection", "app.bsky.graph.follow"), 341 + ]) 342 + .send() 343 + .await 344 + .unwrap(); 345 + let bob_follows: Value = bob_follows_res.json().await.unwrap(); 346 + assert_eq!( 347 + bob_follows["records"].as_array().unwrap().len(), 348 + 1, 349 + "Bob follows 1 person" 350 + ); 351 + 352 + let carol_follows_res = client 353 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 354 + .query(&[ 355 + ("repo", carol_did.as_str()), 356 + ("collection", "app.bsky.graph.follow"), 357 + ]) 358 + .send() 359 + .await 360 + .unwrap(); 361 + let carol_follows: Value = carol_follows_res.json().await.unwrap(); 362 + assert_eq!( 363 + carol_follows["records"].as_array().unwrap().len(), 364 + 2, 365 + "Carol follows 2 people" 366 + ); 367 + 368 + let bob_likes_res = client 369 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 370 + .query(&[ 371 + ("repo", bob_did.as_str()), 372 + ("collection", "app.bsky.feed.like"), 373 + ]) 374 + .send() 375 + .await 376 + .unwrap(); 377 + let bob_likes: Value = bob_likes_res.json().await.unwrap(); 378 + assert_eq!(bob_likes["records"].as_array().unwrap().len(), 1); 379 + 380 + let alice_posts_res = client 381 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 382 + .query(&[ 383 + ("repo", alice_did.as_str()), 384 + ("collection", "app.bsky.feed.post"), 385 + ]) 386 + .send() 387 + .await 388 + .unwrap(); 389 + let alice_posts: Value = alice_posts_res.json().await.unwrap(); 390 + assert_eq!( 391 + alice_posts["records"].as_array().unwrap().len(), 392 + 2, 393 + "Alice has 2 posts (original + reply)" 394 + ); 395 + 396 + let bob_likes_rkey = bob_likes["records"][0]["uri"] 397 + .as_str() 398 + .unwrap() 399 + .split('/') 400 + .next_back() 401 + .unwrap(); 402 + let unlike_res = client 403 + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) 404 + .bearer_auth(&bob_jwt) 405 + .json(&json!({ 406 + "repo": bob_did, 407 + "collection": "app.bsky.feed.like", 408 + "rkey": bob_likes_rkey 409 + })) 410 + .send() 411 + .await 412 + .expect("Unlike failed"); 413 + assert_eq!(unlike_res.status(), StatusCode::OK); 414 + 415 + let bob_likes_after = client 416 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 417 + .query(&[ 418 + ("repo", bob_did.as_str()), 419 + ("collection", "app.bsky.feed.like"), 420 + ]) 421 + .send() 422 + .await 423 + .unwrap(); 424 + let bob_likes_after_body: Value = bob_likes_after.json().await.unwrap(); 425 + assert_eq!(bob_likes_after_body["records"].as_array().unwrap().len(), 0); 426 + 427 + let relike_res = client 428 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 429 + .bearer_auth(&bob_jwt) 430 + .json(&json!({ 431 + "repo": bob_did, 432 + "collection": "app.bsky.feed.like", 433 + "record": { 434 + "$type": "app.bsky.feed.like", 435 + "subject": { "uri": alice_post_uri, "cid": alice_post_cid }, 436 + "createdAt": Utc::now().to_rfc3339() 437 + } 438 + })) 439 + .send() 440 + .await 441 + .expect("Relike failed"); 442 + assert_eq!(relike_res.status(), StatusCode::OK); 443 + 444 + let (_alice_likes_bob_uri, _) = create_like( 445 + &client, 446 + &alice_did, 447 + &alice_jwt, 448 + &bob_post_uri, 449 + &bob_post_cid, 450 + ) 451 + .await; 452 + 453 + let mutual_follow_res = client 454 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 455 + .bearer_auth(&alice_jwt) 456 + .json(&json!({ 457 + "repo": alice_did, 458 + "collection": "app.bsky.graph.follow", 459 + "record": { 460 + "$type": "app.bsky.graph.follow", 461 + "subject": bob_did, 462 + "createdAt": Utc::now().to_rfc3339() 463 + } 464 + })) 465 + .send() 466 + .await 467 + .expect("Mutual follow failed"); 468 + assert_eq!(mutual_follow_res.status(), StatusCode::OK); 469 + 470 + let alice_final_follows = client 471 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 472 + .query(&[ 473 + ("repo", alice_did.as_str()), 474 + ("collection", "app.bsky.graph.follow"), 475 + ]) 476 + .send() 477 + .await 478 + .unwrap(); 479 + let alice_final: Value = alice_final_follows.json().await.unwrap(); 480 + assert_eq!(alice_final["records"].as_array().unwrap().len(), 1); 481 + } 482 + 483 + #[tokio::test] 484 + async fn test_blob_lifecycle_upload_use_remove() { 485 + let client = client(); 486 + let base = base_url().await; 487 + let (did, jwt) = setup_new_user("blob-lifecycle").await; 488 + 489 + let blob1_data = b"First blob for testing lifecycle"; 490 + let upload1_res = client 491 + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base)) 492 + .header(header::CONTENT_TYPE, "text/plain") 493 + .bearer_auth(&jwt) 494 + .body(blob1_data.to_vec()) 495 + .send() 496 + .await 497 + .expect("Upload 1 failed"); 498 + assert_eq!(upload1_res.status(), StatusCode::OK); 499 + let upload1_body: Value = upload1_res.json().await.unwrap(); 500 + let blob1 = upload1_body["blob"].clone(); 501 + let blob1_cid = blob1["ref"]["$link"].as_str().unwrap(); 502 + 503 + let blob2_data = b"Second blob for testing lifecycle"; 504 + let upload2_res = client 505 + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base)) 506 + .header(header::CONTENT_TYPE, "text/plain") 507 + .bearer_auth(&jwt) 508 + .body(blob2_data.to_vec()) 509 + .send() 510 + .await 511 + .expect("Upload 2 failed"); 512 + assert_eq!(upload2_res.status(), StatusCode::OK); 513 + let upload2_body: Value = upload2_res.json().await.unwrap(); 514 + let blob2 = upload2_body["blob"].clone(); 515 + let _blob2_cid = blob2["ref"]["$link"].as_str().unwrap(); 516 + 517 + let post_res = client 518 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 519 + .bearer_auth(&jwt) 520 + .json(&json!({ 521 + "repo": did, 522 + "collection": "app.bsky.feed.post", 523 + "record": { 524 + "$type": "app.bsky.feed.post", 525 + "text": "Post with images", 526 + "createdAt": Utc::now().to_rfc3339(), 527 + "embed": { 528 + "$type": "app.bsky.embed.images", 529 + "images": [ 530 + { "alt": "First image", "image": blob1 }, 531 + { "alt": "Second image", "image": blob2 } 532 + ] 533 + } 534 + } 535 + })) 536 + .send() 537 + .await 538 + .expect("Post with blobs failed"); 539 + assert_eq!(post_res.status(), StatusCode::OK); 540 + let post_body: Value = post_res.json().await.unwrap(); 541 + let post_uri = post_body["uri"].as_str().unwrap(); 542 + let post_cid = post_body["cid"].as_str().unwrap(); 543 + let post_rkey = post_uri.split('/').next_back().unwrap(); 544 + 545 + let get_blob1 = client 546 + .get(format!("{}/xrpc/com.atproto.sync.getBlob", base)) 547 + .query(&[("did", did.as_str()), ("cid", blob1_cid)]) 548 + .send() 549 + .await 550 + .expect("Get blob 1 failed"); 551 + assert_eq!(get_blob1.status(), StatusCode::OK); 552 + let blob1_content = get_blob1.bytes().await.unwrap(); 553 + assert_eq!(blob1_content.as_ref(), blob1_data); 554 + 555 + let get_record = client 556 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base)) 557 + .query(&[ 558 + ("repo", did.as_str()), 559 + ("collection", "app.bsky.feed.post"), 560 + ("rkey", post_rkey), 561 + ]) 562 + .send() 563 + .await 564 + .expect("Get record failed"); 565 + let record_body: Value = get_record.json().await.unwrap(); 566 + let images = record_body["value"]["embed"]["images"].as_array().unwrap(); 567 + assert_eq!(images.len(), 2); 568 + 569 + let edit_res = client 570 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 571 + .bearer_auth(&jwt) 572 + .json(&json!({ 573 + "repo": did, 574 + "collection": "app.bsky.feed.post", 575 + "rkey": post_rkey, 576 + "record": { 577 + "$type": "app.bsky.feed.post", 578 + "text": "Post with single image now", 579 + "createdAt": Utc::now().to_rfc3339(), 580 + "embed": { 581 + "$type": "app.bsky.embed.images", 582 + "images": [ 583 + { "alt": "Only first image", "image": blob1 } 584 + ] 585 + } 586 + }, 587 + "swapRecord": post_cid 588 + })) 589 + .send() 590 + .await 591 + .expect("Edit failed"); 592 + assert_eq!(edit_res.status(), StatusCode::OK); 593 + 594 + let get_edited = client 595 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base)) 596 + .query(&[ 597 + ("repo", did.as_str()), 598 + ("collection", "app.bsky.feed.post"), 599 + ("rkey", post_rkey), 600 + ]) 601 + .send() 602 + .await 603 + .unwrap(); 604 + let edited_body: Value = get_edited.json().await.unwrap(); 605 + let edited_images = edited_body["value"]["embed"]["images"].as_array().unwrap(); 606 + assert_eq!(edited_images.len(), 1); 607 + 608 + let profile_res = client 609 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 610 + .bearer_auth(&jwt) 611 + .json(&json!({ 612 + "repo": did, 613 + "collection": "app.bsky.actor.profile", 614 + "rkey": "self", 615 + "record": { 616 + "$type": "app.bsky.actor.profile", 617 + "displayName": "Blob Test User", 618 + "avatar": blob1 619 + } 620 + })) 621 + .send() 622 + .await 623 + .expect("Profile failed"); 624 + assert_eq!(profile_res.status(), StatusCode::OK); 625 + 626 + let list_blobs = client 627 + .get(format!("{}/xrpc/com.atproto.sync.listBlobs", base)) 628 + .query(&[("did", did.as_str())]) 629 + .send() 630 + .await 631 + .expect("List blobs failed"); 632 + assert_eq!(list_blobs.status(), StatusCode::OK); 633 + let blobs_body: Value = list_blobs.json().await.unwrap(); 634 + let cids = blobs_body["cids"].as_array().unwrap(); 635 + assert!( 636 + cids.iter().any(|c| c.as_str() == Some(blob1_cid)), 637 + "blob1 should still exist (referenced by profile and post)" 638 + ); 639 + } 640 + 641 + #[tokio::test] 642 + async fn test_session_and_record_interaction() { 643 + let client = client(); 644 + let base = base_url().await; 645 + let uid = uuid::Uuid::new_v4().simple().to_string(); 646 + let handle = format!("sess{}", &uid[..8]); 647 + let email = format!("sess{}@test.com", &uid[..8]); 648 + let password = "SessionTest123!"; 649 + 650 + let create_res = client 651 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 652 + .json(&json!({ 653 + "handle": handle, 654 + "email": email, 655 + "password": password 656 + })) 657 + .send() 658 + .await 659 + .expect("Account creation failed"); 660 + assert_eq!(create_res.status(), StatusCode::OK); 661 + let account: Value = create_res.json().await.unwrap(); 662 + let did = account["did"].as_str().unwrap().to_string(); 663 + let jwt = verify_new_account(&client, &did).await; 664 + 665 + let (post1_uri, _) = create_post(&client, &did, &jwt, "Post from session 1").await; 666 + 667 + let session2_res = client 668 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 669 + .json(&json!({ 670 + "identifier": handle, 671 + "password": password 672 + })) 673 + .send() 674 + .await 675 + .expect("Session 2 creation failed"); 676 + assert_eq!(session2_res.status(), StatusCode::OK); 677 + let session2: Value = session2_res.json().await.unwrap(); 678 + let jwt2 = session2["accessJwt"].as_str().unwrap(); 679 + let refresh2 = session2["refreshJwt"].as_str().unwrap(); 680 + 681 + let (post2_uri, _) = create_post(&client, &did, jwt2, "Post from session 2").await; 682 + 683 + let list_res = client 684 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 685 + .bearer_auth(&jwt) 686 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 687 + .send() 688 + .await 689 + .unwrap(); 690 + let list_body: Value = list_res.json().await.unwrap(); 691 + assert_eq!( 692 + list_body["records"].as_array().unwrap().len(), 693 + 2, 694 + "Both posts visible from session 1" 695 + ); 696 + 697 + let refresh_res = client 698 + .post(format!("{}/xrpc/com.atproto.server.refreshSession", base)) 699 + .bearer_auth(refresh2) 700 + .send() 701 + .await 702 + .expect("Refresh failed"); 703 + assert_eq!(refresh_res.status(), StatusCode::OK); 704 + let refresh_body: Value = refresh_res.json().await.unwrap(); 705 + let new_jwt2 = refresh_body["accessJwt"].as_str().unwrap(); 706 + 707 + let verify_posts = client 708 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 709 + .bearer_auth(new_jwt2) 710 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 711 + .send() 712 + .await 713 + .unwrap(); 714 + let verify_body: Value = verify_posts.json().await.unwrap(); 715 + assert_eq!( 716 + verify_body["records"].as_array().unwrap().len(), 717 + 2, 718 + "Posts still visible after token refresh" 719 + ); 720 + 721 + let post1_rkey = post1_uri.split('/').next_back().unwrap(); 722 + let delete_res = client 723 + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) 724 + .bearer_auth(new_jwt2) 725 + .json(&json!({ 726 + "repo": did, 727 + "collection": "app.bsky.feed.post", 728 + "rkey": post1_rkey 729 + })) 730 + .send() 731 + .await 732 + .expect("Delete from session 2 failed"); 733 + assert_eq!(delete_res.status(), StatusCode::OK); 734 + 735 + let final_list = client 736 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 737 + .bearer_auth(&jwt) 738 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 739 + .send() 740 + .await 741 + .unwrap(); 742 + let final_body: Value = final_list.json().await.unwrap(); 743 + let remaining_posts = final_body["records"].as_array().unwrap(); 744 + assert_eq!(remaining_posts.len(), 1); 745 + assert!( 746 + remaining_posts[0]["uri"] 747 + .as_str() 748 + .unwrap() 749 + .contains(post2_uri.split('/').next_back().unwrap()) 750 + ); 751 + } 752 + 753 + #[tokio::test] 754 + async fn test_app_password_record_lifecycle() { 755 + let client = client(); 756 + let base = base_url().await; 757 + let uid = uuid::Uuid::new_v4().simple().to_string(); 758 + let handle = format!("apprec{}", &uid[..8]); 759 + let email = format!("apprec{}@test.com", &uid[..8]); 760 + let password = "AppRecTest123!"; 761 + 762 + let create_res = client 763 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 764 + .json(&json!({ 765 + "handle": handle, 766 + "email": email, 767 + "password": password 768 + })) 769 + .send() 770 + .await 771 + .expect("Account creation failed"); 772 + assert_eq!(create_res.status(), StatusCode::OK); 773 + let account: Value = create_res.json().await.unwrap(); 774 + let did = account["did"].as_str().unwrap().to_string(); 775 + let main_jwt = verify_new_account(&client, &did).await; 776 + 777 + let create_app_pass = client 778 + .post(format!( 779 + "{}/xrpc/com.atproto.server.createAppPassword", 780 + base 781 + )) 782 + .bearer_auth(&main_jwt) 783 + .json(&json!({ "name": "Test App" })) 784 + .send() 785 + .await 786 + .expect("App password creation failed"); 787 + assert_eq!(create_app_pass.status(), StatusCode::OK); 788 + let app_pass_body: Value = create_app_pass.json().await.unwrap(); 789 + let app_password = app_pass_body["password"].as_str().unwrap(); 790 + 791 + let app_login = client 792 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 793 + .json(&json!({ 794 + "identifier": handle, 795 + "password": app_password 796 + })) 797 + .send() 798 + .await 799 + .expect("App password login failed"); 800 + assert_eq!(app_login.status(), StatusCode::OK); 801 + let app_session: Value = app_login.json().await.unwrap(); 802 + let app_jwt = app_session["accessJwt"].as_str().unwrap(); 803 + 804 + create_post(&client, &did, app_jwt, "Post from app password session").await; 805 + 806 + let verify_main = client 807 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 808 + .bearer_auth(&main_jwt) 809 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 810 + .send() 811 + .await 812 + .unwrap(); 813 + let verify_body: Value = verify_main.json().await.unwrap(); 814 + assert_eq!( 815 + verify_body["records"].as_array().unwrap().len(), 816 + 1, 817 + "Post visible from main session" 818 + ); 819 + 820 + let revoke_res = client 821 + .post(format!( 822 + "{}/xrpc/com.atproto.server.revokeAppPassword", 823 + base 824 + )) 825 + .bearer_auth(&main_jwt) 826 + .json(&json!({ "name": "Test App" })) 827 + .send() 828 + .await 829 + .expect("Revoke failed"); 830 + assert_eq!(revoke_res.status(), StatusCode::OK); 831 + 832 + let post_after_revoke = client 833 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 834 + .bearer_auth(app_jwt) 835 + .json(&json!({ 836 + "repo": did, 837 + "collection": "app.bsky.feed.post", 838 + "record": { 839 + "$type": "app.bsky.feed.post", 840 + "text": "Should fail", 841 + "createdAt": Utc::now().to_rfc3339() 842 + } 843 + })) 844 + .send() 845 + .await 846 + .expect("Post attempt failed"); 847 + assert!( 848 + post_after_revoke.status() == StatusCode::UNAUTHORIZED 849 + || post_after_revoke.status() == StatusCode::BAD_REQUEST, 850 + "Revoked app password should not create posts" 851 + ); 852 + 853 + let final_list = client 854 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 855 + .bearer_auth(&main_jwt) 856 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 857 + .send() 858 + .await 859 + .unwrap(); 860 + let final_body: Value = final_list.json().await.unwrap(); 861 + assert_eq!( 862 + final_body["records"].as_array().unwrap().len(), 863 + 1, 864 + "Only the valid post should exist" 865 + ); 866 + } 867 + 868 + #[tokio::test] 869 + async fn test_handle_change_with_existing_content() { 870 + let client = client(); 871 + let base = base_url().await; 872 + let (did, jwt) = setup_new_user("handlechange").await; 873 + 874 + let (post_uri, post_cid) = create_post(&client, &did, &jwt, "Post before handle change").await; 875 + 876 + let profile_res = client 877 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 878 + .bearer_auth(&jwt) 879 + .json(&json!({ 880 + "repo": did, 881 + "collection": "app.bsky.actor.profile", 882 + "rkey": "self", 883 + "record": { 884 + "$type": "app.bsky.actor.profile", 885 + "displayName": "Original Handle User" 886 + } 887 + })) 888 + .send() 889 + .await 890 + .expect("Profile creation failed"); 891 + assert_eq!(profile_res.status(), StatusCode::OK); 892 + 893 + let (other_did, other_jwt) = setup_new_user("other-user").await; 894 + let (_like_uri, _) = create_like(&client, &other_did, &other_jwt, &post_uri, &post_cid).await; 895 + let (_follow_uri, _) = create_follow(&client, &other_did, &other_jwt, &did).await; 896 + 897 + let new_handle = format!("newh{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); 898 + let update_res = client 899 + .post(format!("{}/xrpc/com.atproto.identity.updateHandle", base)) 900 + .bearer_auth(&jwt) 901 + .json(&json!({ "handle": new_handle })) 902 + .send() 903 + .await 904 + .expect("Handle update failed"); 905 + assert_eq!(update_res.status(), StatusCode::OK); 906 + 907 + let session_res = client 908 + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 909 + .bearer_auth(&jwt) 910 + .send() 911 + .await 912 + .expect("Get session failed"); 913 + let session_body: Value = session_res.json().await.unwrap(); 914 + assert!( 915 + session_body["handle"] 916 + .as_str() 917 + .unwrap() 918 + .starts_with(&new_handle), 919 + "Handle should be updated" 920 + ); 921 + 922 + let posts_res = client 923 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 924 + .bearer_auth(&jwt) 925 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 926 + .send() 927 + .await 928 + .unwrap(); 929 + let posts_body: Value = posts_res.json().await.unwrap(); 930 + assert_eq!( 931 + posts_body["records"].as_array().unwrap().len(), 932 + 1, 933 + "Post should still exist after handle change" 934 + ); 935 + 936 + let profile_check = client 937 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base)) 938 + .query(&[ 939 + ("repo", did.as_str()), 940 + ("collection", "app.bsky.actor.profile"), 941 + ("rkey", "self"), 942 + ]) 943 + .send() 944 + .await 945 + .unwrap(); 946 + let profile_body: Value = profile_check.json().await.unwrap(); 947 + assert_eq!( 948 + profile_body["value"]["displayName"], "Original Handle User", 949 + "Profile should be intact" 950 + ); 951 + 952 + let other_follows = client 953 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 954 + .query(&[ 955 + ("repo", other_did.as_str()), 956 + ("collection", "app.bsky.graph.follow"), 957 + ]) 958 + .send() 959 + .await 960 + .unwrap(); 961 + let follows_body: Value = other_follows.json().await.unwrap(); 962 + let follows = follows_body["records"].as_array().unwrap(); 963 + assert_eq!(follows.len(), 1); 964 + assert_eq!( 965 + follows[0]["value"]["subject"], did, 966 + "Follow should still point to the DID" 967 + ); 968 + 969 + let resolve_res = client 970 + .get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base)) 971 + .query(&[("handle", session_body["handle"].as_str().unwrap())]) 972 + .send() 973 + .await 974 + .expect("Resolve failed"); 975 + assert_eq!(resolve_res.status(), StatusCode::OK); 976 + let resolve_body: Value = resolve_res.json().await.unwrap(); 977 + assert_eq!( 978 + resolve_body["did"], did, 979 + "New handle should resolve to same DID" 980 + ); 981 + } 982 + 983 + #[tokio::test] 984 + async fn test_deactivation_preserves_data() { 985 + let client = client(); 986 + let base = base_url().await; 987 + let (did, jwt) = setup_new_user("deactivate-data").await; 988 + 989 + create_post(&client, &did, &jwt, "Post 1 before deactivation").await; 990 + create_post(&client, &did, &jwt, "Post 2 before deactivation").await; 991 + 992 + let profile_res = client 993 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 994 + .bearer_auth(&jwt) 995 + .json(&json!({ 996 + "repo": did, 997 + "collection": "app.bsky.actor.profile", 998 + "rkey": "self", 999 + "record": { 1000 + "$type": "app.bsky.actor.profile", 1001 + "displayName": "Deactivation Test User" 1002 + } 1003 + })) 1004 + .send() 1005 + .await 1006 + .unwrap(); 1007 + assert_eq!(profile_res.status(), StatusCode::OK); 1008 + 1009 + let deactivate_res = client 1010 + .post(format!( 1011 + "{}/xrpc/com.atproto.server.deactivateAccount", 1012 + base 1013 + )) 1014 + .bearer_auth(&jwt) 1015 + .json(&json!({})) 1016 + .send() 1017 + .await 1018 + .expect("Deactivation failed"); 1019 + assert_eq!(deactivate_res.status(), StatusCode::OK); 1020 + 1021 + let posts_while_deactivated = client 1022 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 1023 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 1024 + .send() 1025 + .await 1026 + .unwrap(); 1027 + assert_eq!(posts_while_deactivated.status(), StatusCode::OK); 1028 + let posts_body: Value = posts_while_deactivated.json().await.unwrap(); 1029 + assert_eq!( 1030 + posts_body["records"].as_array().unwrap().len(), 1031 + 2, 1032 + "Posts should still be readable while deactivated" 1033 + ); 1034 + 1035 + let activate_res = client 1036 + .post(format!("{}/xrpc/com.atproto.server.activateAccount", base)) 1037 + .bearer_auth(&jwt) 1038 + .json(&json!({})) 1039 + .send() 1040 + .await 1041 + .expect("Activation failed"); 1042 + assert_eq!(activate_res.status(), StatusCode::OK); 1043 + 1044 + create_post(&client, &did, &jwt, "Post 3 after reactivation").await; 1045 + 1046 + let final_posts = client 1047 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 1048 + .bearer_auth(&jwt) 1049 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 1050 + .send() 1051 + .await 1052 + .unwrap(); 1053 + let final_body: Value = final_posts.json().await.unwrap(); 1054 + assert_eq!( 1055 + final_body["records"].as_array().unwrap().len(), 1056 + 3, 1057 + "All three posts should exist" 1058 + ); 1059 + } 1060 + 1061 + #[tokio::test] 1062 + async fn test_password_change_session_behavior() { 1063 + let client = client(); 1064 + let base = base_url().await; 1065 + let uid = uuid::Uuid::new_v4().simple().to_string(); 1066 + let handle = format!("pwch{}", &uid[..8]); 1067 + let email = format!("pwch{}@test.com", &uid[..8]); 1068 + let old_password = "OldPassword123!"; 1069 + let new_password = "NewPassword456!"; 1070 + 1071 + let create_res = client 1072 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 1073 + .json(&json!({ 1074 + "handle": handle, 1075 + "email": email, 1076 + "password": old_password 1077 + })) 1078 + .send() 1079 + .await 1080 + .expect("Account creation failed"); 1081 + assert_eq!(create_res.status(), StatusCode::OK); 1082 + let account: Value = create_res.json().await.unwrap(); 1083 + let did = account["did"].as_str().unwrap().to_string(); 1084 + let jwt1 = verify_new_account(&client, &did).await; 1085 + 1086 + create_post(&client, &did, &jwt1, "Post before password change").await; 1087 + 1088 + let change_pw_res = client 1089 + .post(format!("{}/xrpc/_account.changePassword", base)) 1090 + .bearer_auth(&jwt1) 1091 + .json(&json!({ 1092 + "currentPassword": old_password, 1093 + "newPassword": new_password 1094 + })) 1095 + .send() 1096 + .await 1097 + .expect("Password change failed"); 1098 + assert_eq!(change_pw_res.status(), StatusCode::OK); 1099 + 1100 + let old_pw_login = client 1101 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 1102 + .json(&json!({ 1103 + "identifier": handle, 1104 + "password": old_password 1105 + })) 1106 + .send() 1107 + .await 1108 + .unwrap(); 1109 + assert!( 1110 + old_pw_login.status() == StatusCode::UNAUTHORIZED 1111 + || old_pw_login.status() == StatusCode::BAD_REQUEST, 1112 + "Old password should not work" 1113 + ); 1114 + 1115 + let new_pw_login = client 1116 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 1117 + .json(&json!({ 1118 + "identifier": handle, 1119 + "password": new_password 1120 + })) 1121 + .send() 1122 + .await 1123 + .expect("New password login failed"); 1124 + assert_eq!(new_pw_login.status(), StatusCode::OK); 1125 + let new_session: Value = new_pw_login.json().await.unwrap(); 1126 + let new_jwt = new_session["accessJwt"].as_str().unwrap(); 1127 + 1128 + let posts_res = client 1129 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 1130 + .bearer_auth(new_jwt) 1131 + .query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")]) 1132 + .send() 1133 + .await 1134 + .unwrap(); 1135 + let posts_body: Value = posts_res.json().await.unwrap(); 1136 + assert_eq!( 1137 + posts_body["records"].as_array().unwrap().len(), 1138 + 1, 1139 + "Post should still exist after password change" 1140 + ); 1141 + } 1142 + 1143 + #[tokio::test] 1144 + async fn test_backup_restore_workflow() { 1145 + let client = client(); 1146 + let base = base_url().await; 1147 + let (did, jwt) = setup_new_user("backup-restore").await; 1148 + 1149 + futures::future::join_all((0..3).map(|i| { 1150 + let client = client.clone(); 1151 + let did = did.clone(); 1152 + let jwt = jwt.clone(); 1153 + async move { 1154 + create_post(&client, &did, &jwt, &format!("Post {} for backup test", i)).await; 1155 + } 1156 + })) 1157 + .await; 1158 + 1159 + let blob_data = b"Blob data for backup test"; 1160 + let upload_res = client 1161 + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base)) 1162 + .header(header::CONTENT_TYPE, "text/plain") 1163 + .bearer_auth(&jwt) 1164 + .body(blob_data.to_vec()) 1165 + .send() 1166 + .await 1167 + .expect("Blob upload failed"); 1168 + assert_eq!(upload_res.status(), StatusCode::OK); 1169 + let upload_body: Value = upload_res.json().await.unwrap(); 1170 + let blob = upload_body["blob"].clone(); 1171 + 1172 + let profile_res = client 1173 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 1174 + .bearer_auth(&jwt) 1175 + .json(&json!({ 1176 + "repo": did, 1177 + "collection": "app.bsky.actor.profile", 1178 + "rkey": "self", 1179 + "record": { 1180 + "$type": "app.bsky.actor.profile", 1181 + "displayName": "Backup Test User", 1182 + "avatar": blob 1183 + } 1184 + })) 1185 + .send() 1186 + .await 1187 + .unwrap(); 1188 + assert_eq!(profile_res.status(), StatusCode::OK); 1189 + 1190 + let backup1_res = client 1191 + .post(format!("{}/xrpc/_backup.createBackup", base)) 1192 + .bearer_auth(&jwt) 1193 + .send() 1194 + .await 1195 + .expect("Backup 1 failed"); 1196 + assert_eq!(backup1_res.status(), StatusCode::OK); 1197 + let backup1: Value = backup1_res.json().await.unwrap(); 1198 + let backup1_id = backup1["id"].as_str().unwrap(); 1199 + let backup1_rev = backup1["repoRev"].as_str().unwrap(); 1200 + 1201 + create_post(&client, &did, &jwt, "Post 4 after first backup").await; 1202 + create_post(&client, &did, &jwt, "Post 5 after first backup").await; 1203 + 1204 + let backup2_res = client 1205 + .post(format!("{}/xrpc/_backup.createBackup", base)) 1206 + .bearer_auth(&jwt) 1207 + .send() 1208 + .await 1209 + .expect("Backup 2 failed"); 1210 + assert_eq!(backup2_res.status(), StatusCode::OK); 1211 + let backup2: Value = backup2_res.json().await.unwrap(); 1212 + let backup2_id = backup2["id"].as_str().unwrap(); 1213 + let backup2_rev = backup2["repoRev"].as_str().unwrap(); 1214 + 1215 + assert_ne!( 1216 + backup1_rev, backup2_rev, 1217 + "Backups should have different revs" 1218 + ); 1219 + 1220 + let list_res = client 1221 + .get(format!("{}/xrpc/_backup.listBackups", base)) 1222 + .bearer_auth(&jwt) 1223 + .send() 1224 + .await 1225 + .expect("List backups failed"); 1226 + let list_body: Value = list_res.json().await.unwrap(); 1227 + let backups = list_body["backups"].as_array().unwrap(); 1228 + assert_eq!(backups.len(), 2, "Should have 2 backups"); 1229 + 1230 + let download1 = client 1231 + .get(format!("{}/xrpc/_backup.getBackup?id={}", base, backup1_id)) 1232 + .bearer_auth(&jwt) 1233 + .send() 1234 + .await 1235 + .expect("Download backup 1 failed"); 1236 + assert_eq!(download1.status(), StatusCode::OK); 1237 + let backup1_bytes = download1.bytes().await.unwrap(); 1238 + 1239 + let download2 = client 1240 + .get(format!("{}/xrpc/_backup.getBackup?id={}", base, backup2_id)) 1241 + .bearer_auth(&jwt) 1242 + .send() 1243 + .await 1244 + .expect("Download backup 2 failed"); 1245 + assert_eq!(download2.status(), StatusCode::OK); 1246 + let backup2_bytes = download2.bytes().await.unwrap(); 1247 + 1248 + assert!( 1249 + backup2_bytes.len() > backup1_bytes.len(), 1250 + "Second backup should be larger (more posts)" 1251 + ); 1252 + 1253 + let delete_old = client 1254 + .post(format!( 1255 + "{}/xrpc/_backup.deleteBackup?id={}", 1256 + base, backup1_id 1257 + )) 1258 + .bearer_auth(&jwt) 1259 + .send() 1260 + .await 1261 + .expect("Delete backup failed"); 1262 + assert_eq!(delete_old.status(), StatusCode::OK); 1263 + 1264 + let final_list = client 1265 + .get(format!("{}/xrpc/_backup.listBackups", base)) 1266 + .bearer_auth(&jwt) 1267 + .send() 1268 + .await 1269 + .unwrap(); 1270 + let final_body: Value = final_list.json().await.unwrap(); 1271 + assert_eq!(final_body["backups"].as_array().unwrap().len(), 1); 1272 + } 1273 + 1274 + #[tokio::test] 1275 + async fn test_scale_100_posts_with_pagination() { 1276 + let client = client(); 1277 + let base = base_url().await; 1278 + let (did, jwt) = setup_new_user("scale-posts").await; 1279 + 1280 + let post_count = 1000; 1281 + let post_futures: Vec<_> = (0..post_count) 1282 + .map(|i| { 1283 + let client = client.clone(); 1284 + let base = base.to_string(); 1285 + let did = did.clone(); 1286 + let jwt = jwt.clone(); 1287 + async move { 1288 + let res = client 1289 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 1290 + .bearer_auth(&jwt) 1291 + .json(&json!({ 1292 + "repo": did, 1293 + "collection": "app.bsky.feed.post", 1294 + "record": { 1295 + "$type": "app.bsky.feed.post", 1296 + "text": format!("Scale test post number {}", i), 1297 + "createdAt": Utc::now().to_rfc3339() 1298 + } 1299 + })) 1300 + .send() 1301 + .await 1302 + .expect("Post creation failed"); 1303 + let status = res.status(); 1304 + let body: Value = res.json().await.unwrap_or_default(); 1305 + assert_eq!( 1306 + status, 1307 + StatusCode::OK, 1308 + "Failed to create post {}: {:?}", 1309 + i, 1310 + body 1311 + ); 1312 + } 1313 + }) 1314 + .collect(); 1315 + 1316 + join_all(post_futures).await; 1317 + 1318 + let count_res = client 1319 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 1320 + .bearer_auth(&jwt) 1321 + .query(&[ 1322 + ("repo", did.as_str()), 1323 + ("collection", "app.bsky.feed.post"), 1324 + ("limit", "1"), 1325 + ]) 1326 + .send() 1327 + .await 1328 + .unwrap(); 1329 + assert_eq!(count_res.status(), StatusCode::OK); 1330 + 1331 + let all_uris: Vec<String> = 1332 + paginate_records(&client, base, &jwt, &did, "app.bsky.feed.post", 25) 1333 + .await 1334 + .iter() 1335 + .filter_map(|r| r["uri"].as_str().map(String::from)) 1336 + .collect(); 1337 + 1338 + assert_eq!( 1339 + all_uris.len(), 1340 + post_count, 1341 + "Should have paginated through all {} posts", 1342 + post_count 1343 + ); 1344 + 1345 + let unique_uris: std::collections::HashSet<_> = all_uris.iter().collect(); 1346 + assert_eq!( 1347 + unique_uris.len(), 1348 + post_count, 1349 + "All posts should have unique URIs" 1350 + ); 1351 + 1352 + let delete_futures: Vec<_> = all_uris 1353 + .iter() 1354 + .take(500) 1355 + .map(|uri| { 1356 + let client = client.clone(); 1357 + let base = base.to_string(); 1358 + let did = did.clone(); 1359 + let jwt = jwt.clone(); 1360 + let rkey = uri.split('/').next_back().unwrap().to_string(); 1361 + async move { 1362 + let res = client 1363 + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) 1364 + .bearer_auth(&jwt) 1365 + .json(&json!({ 1366 + "repo": did, 1367 + "collection": "app.bsky.feed.post", 1368 + "rkey": rkey 1369 + })) 1370 + .send() 1371 + .await 1372 + .expect("Delete failed"); 1373 + assert_eq!(res.status(), StatusCode::OK); 1374 + } 1375 + }) 1376 + .collect(); 1377 + 1378 + join_all(delete_futures).await; 1379 + 1380 + let final_count = count_records(&client, base, &jwt, &did, "app.bsky.feed.post").await; 1381 + assert_eq!( 1382 + final_count, 500, 1383 + "Should have 500 posts remaining after deleting 500" 1384 + ); 1385 + } 1386 + 1387 + #[tokio::test] 1388 + async fn test_scale_many_users_social_graph() { 1389 + let client = client(); 1390 + let base = base_url().await; 1391 + 1392 + let user_count = 50; 1393 + let user_futures: Vec<_> = (0..user_count) 1394 + .map(|i| async move { setup_new_user(&format!("graph{}", i)).await }) 1395 + .collect(); 1396 + 1397 + let users: Vec<(String, String)> = join_all(user_futures).await; 1398 + 1399 + let follow_futures: Vec<_> = users 1400 + .iter() 1401 + .enumerate() 1402 + .flat_map(|(i, (follower_did, follower_jwt))| { 1403 + let client = client.clone(); 1404 + let base = base.to_string(); 1405 + users.iter().enumerate().filter(move |(j, _)| *j != i).map({ 1406 + let client = client.clone(); 1407 + let base = base.clone(); 1408 + let follower_did = follower_did.clone(); 1409 + let follower_jwt = follower_jwt.clone(); 1410 + move |(_, (followee_did, _))| { 1411 + let client = client.clone(); 1412 + let base = base.clone(); 1413 + let follower_did = follower_did.clone(); 1414 + let follower_jwt = follower_jwt.clone(); 1415 + let followee_did = followee_did.clone(); 1416 + async move { 1417 + let rkey = format!( 1418 + "follow_{}", 1419 + &uuid::Uuid::new_v4().simple().to_string()[..12] 1420 + ); 1421 + let res = client 1422 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 1423 + .bearer_auth(&follower_jwt) 1424 + .json(&json!({ 1425 + "repo": follower_did, 1426 + "collection": "app.bsky.graph.follow", 1427 + "rkey": rkey, 1428 + "record": { 1429 + "$type": "app.bsky.graph.follow", 1430 + "subject": followee_did, 1431 + "createdAt": Utc::now().to_rfc3339() 1432 + } 1433 + })) 1434 + .send() 1435 + .await 1436 + .expect("Follow failed"); 1437 + let status = res.status(); 1438 + let body: Value = res.json().await.unwrap_or_default(); 1439 + assert_eq!(status, StatusCode::OK, "Follow failed: {:?}", body); 1440 + } 1441 + } 1442 + }) 1443 + }) 1444 + .collect(); 1445 + 1446 + join_all(follow_futures).await; 1447 + 1448 + let expected_follows_per_user = user_count - 1; 1449 + let verify_futures: Vec<_> = users 1450 + .iter() 1451 + .map(|(did, jwt)| { 1452 + let client = client.clone(); 1453 + let base = base.to_string(); 1454 + let did = did.clone(); 1455 + let jwt = jwt.clone(); 1456 + async move { 1457 + let res = client 1458 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 1459 + .bearer_auth(&jwt) 1460 + .query(&[ 1461 + ("repo", did.as_str()), 1462 + ("collection", "app.bsky.graph.follow"), 1463 + ("limit", "100"), 1464 + ]) 1465 + .send() 1466 + .await 1467 + .unwrap(); 1468 + let body: Value = res.json().await.unwrap(); 1469 + body["records"].as_array().unwrap().len() 1470 + } 1471 + }) 1472 + .collect(); 1473 + 1474 + let follow_counts: Vec<usize> = join_all(verify_futures).await; 1475 + let total_follows: usize = follow_counts.iter().sum(); 1476 + 1477 + assert_eq!( 1478 + total_follows, 1479 + user_count * expected_follows_per_user, 1480 + "Each of {} users should follow {} others = {} total follows", 1481 + user_count, 1482 + expected_follows_per_user, 1483 + user_count * expected_follows_per_user 1484 + ); 1485 + 1486 + let (poster_did, poster_jwt) = &users[0]; 1487 + let (post_uri, post_cid) = create_post(&client, poster_did, poster_jwt, "Popular post").await; 1488 + 1489 + let like_futures: Vec<_> = users 1490 + .iter() 1491 + .skip(1) 1492 + .map(|(liker_did, liker_jwt)| { 1493 + let client = client.clone(); 1494 + let base = base.to_string(); 1495 + let liker_did = liker_did.clone(); 1496 + let liker_jwt = liker_jwt.clone(); 1497 + let post_uri = post_uri.clone(); 1498 + let post_cid = post_cid.clone(); 1499 + async move { 1500 + let rkey = format!("like_{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 1501 + let res = client 1502 + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 1503 + .bearer_auth(&liker_jwt) 1504 + .json(&json!({ 1505 + "repo": liker_did, 1506 + "collection": "app.bsky.feed.like", 1507 + "rkey": rkey, 1508 + "record": { 1509 + "$type": "app.bsky.feed.like", 1510 + "subject": { "uri": post_uri, "cid": post_cid }, 1511 + "createdAt": Utc::now().to_rfc3339() 1512 + } 1513 + })) 1514 + .send() 1515 + .await 1516 + .expect("Like failed"); 1517 + assert_eq!(res.status(), StatusCode::OK); 1518 + } 1519 + }) 1520 + .collect(); 1521 + 1522 + join_all(like_futures).await; 1523 + } 1524 + 1525 + #[tokio::test] 1526 + async fn test_scale_many_blobs_in_repo() { 1527 + let client = client(); 1528 + let base = base_url().await; 1529 + let (did, jwt) = setup_new_user("scale-blobs").await; 1530 + 1531 + let blob_count = 300; 1532 + let blob_futures: Vec<_> = (0..blob_count) 1533 + .map(|i| { 1534 + let client = client.clone(); 1535 + let base = base.to_string(); 1536 + let jwt = jwt.clone(); 1537 + async move { 1538 + let blob_data = format!("Blob data number {} with some padding to make it realistic size for testing purposes", i); 1539 + let res = client 1540 + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base)) 1541 + .header(header::CONTENT_TYPE, "text/plain") 1542 + .bearer_auth(&jwt) 1543 + .body(blob_data) 1544 + .send() 1545 + .await 1546 + .expect("Blob upload failed"); 1547 + assert_eq!(res.status(), StatusCode::OK, "Failed to upload blob {}", i); 1548 + let body: Value = res.json().await.unwrap(); 1549 + body["blob"].clone() 1550 + } 1551 + }) 1552 + .collect(); 1553 + 1554 + let blobs: Vec<Value> = join_all(blob_futures).await; 1555 + 1556 + let post_futures: Vec<_> = blobs 1557 + .chunks(3) 1558 + .enumerate() 1559 + .map(|(i, blob_chunk)| { 1560 + let client = client.clone(); 1561 + let base = base.to_string(); 1562 + let did = did.clone(); 1563 + let jwt = jwt.clone(); 1564 + let images: Vec<Value> = blob_chunk 1565 + .iter() 1566 + .enumerate() 1567 + .map(|(j, blob)| { 1568 + json!({ 1569 + "alt": format!("Image {} in post {}", j, i), 1570 + "image": blob 1571 + }) 1572 + }) 1573 + .collect(); 1574 + async move { 1575 + let res = client 1576 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 1577 + .bearer_auth(&jwt) 1578 + .json(&json!({ 1579 + "repo": did, 1580 + "collection": "app.bsky.feed.post", 1581 + "record": { 1582 + "$type": "app.bsky.feed.post", 1583 + "text": format!("Post {} with {} images", i, images.len()), 1584 + "createdAt": Utc::now().to_rfc3339(), 1585 + "embed": { 1586 + "$type": "app.bsky.embed.images", 1587 + "images": images 1588 + } 1589 + } 1590 + })) 1591 + .send() 1592 + .await 1593 + .expect("Post with blobs failed"); 1594 + let status = res.status(); 1595 + let body: Value = res.json().await.unwrap_or_default(); 1596 + assert_eq!(status, StatusCode::OK, "Post with blobs failed: {:?}", body); 1597 + } 1598 + }) 1599 + .collect(); 1600 + 1601 + join_all(post_futures).await; 1602 + 1603 + let list_blobs_res = client 1604 + .get(format!("{}/xrpc/com.atproto.sync.listBlobs", base)) 1605 + .query(&[("did", did.as_str())]) 1606 + .send() 1607 + .await 1608 + .expect("List blobs failed"); 1609 + assert_eq!(list_blobs_res.status(), StatusCode::OK); 1610 + let blobs_body: Value = list_blobs_res.json().await.unwrap(); 1611 + let cids = blobs_body["cids"].as_array().unwrap(); 1612 + assert_eq!( 1613 + cids.len(), 1614 + blob_count, 1615 + "Should have {} blobs in repo", 1616 + blob_count 1617 + ); 1618 + 1619 + let verify_futures: Vec<_> = cids 1620 + .iter() 1621 + .take(10) 1622 + .map(|cid| { 1623 + let client = client.clone(); 1624 + let base = base.to_string(); 1625 + let did = did.clone(); 1626 + let cid_str = cid.as_str().unwrap().to_string(); 1627 + async move { 1628 + let res = client 1629 + .get(format!("{}/xrpc/com.atproto.sync.getBlob", base)) 1630 + .query(&[("did", did.as_str()), ("cid", cid_str.as_str())]) 1631 + .send() 1632 + .await 1633 + .expect("Get blob failed"); 1634 + assert_eq!( 1635 + res.status(), 1636 + StatusCode::OK, 1637 + "Failed to get blob {}", 1638 + cid_str 1639 + ); 1640 + let bytes = res.bytes().await.unwrap(); 1641 + assert!(bytes.len() > 50, "Blob should have content"); 1642 + } 1643 + }) 1644 + .collect(); 1645 + 1646 + join_all(verify_futures).await; 1647 + } 1648 + 1649 + #[tokio::test] 1650 + async fn test_scale_batch_operations() { 1651 + let client = client(); 1652 + let base = base_url().await; 1653 + let (did, jwt) = setup_new_user("scale-batch").await; 1654 + 1655 + let batch_size = 200; 1656 + let writes: Vec<Value> = (0..batch_size) 1657 + .map(|i| { 1658 + json!({ 1659 + "$type": "com.atproto.repo.applyWrites#create", 1660 + "collection": "app.bsky.feed.post", 1661 + "rkey": format!("batch_{:03}", i), 1662 + "value": { 1663 + "$type": "app.bsky.feed.post", 1664 + "text": format!("Batch created post {}", i), 1665 + "createdAt": Utc::now().to_rfc3339() 1666 + } 1667 + }) 1668 + }) 1669 + .collect(); 1670 + 1671 + let apply_res = client 1672 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base)) 1673 + .bearer_auth(&jwt) 1674 + .json(&json!({ 1675 + "repo": did, 1676 + "writes": writes 1677 + })) 1678 + .send() 1679 + .await 1680 + .expect("Batch create failed"); 1681 + assert_eq!( 1682 + apply_res.status(), 1683 + StatusCode::OK, 1684 + "Batch create of {} posts should succeed", 1685 + batch_size 1686 + ); 1687 + 1688 + let batch_count = count_records(&client, base, &jwt, &did, "app.bsky.feed.post").await; 1689 + assert_eq!( 1690 + batch_count, batch_size, 1691 + "Should have {} posts after batch create", 1692 + batch_size 1693 + ); 1694 + 1695 + let update_writes: Vec<Value> = (0..batch_size) 1696 + .map(|i| { 1697 + json!({ 1698 + "$type": "com.atproto.repo.applyWrites#update", 1699 + "collection": "app.bsky.feed.post", 1700 + "rkey": format!("batch_{:03}", i), 1701 + "value": { 1702 + "$type": "app.bsky.feed.post", 1703 + "text": format!("UPDATED batch post {}", i), 1704 + "createdAt": Utc::now().to_rfc3339() 1705 + } 1706 + }) 1707 + }) 1708 + .collect(); 1709 + 1710 + let update_res = client 1711 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base)) 1712 + .bearer_auth(&jwt) 1713 + .json(&json!({ 1714 + "repo": did, 1715 + "writes": update_writes 1716 + })) 1717 + .send() 1718 + .await 1719 + .expect("Batch update failed"); 1720 + assert_eq!( 1721 + update_res.status(), 1722 + StatusCode::OK, 1723 + "Batch update of {} posts should succeed", 1724 + batch_size 1725 + ); 1726 + 1727 + let verify_res = client 1728 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base)) 1729 + .query(&[ 1730 + ("repo", did.as_str()), 1731 + ("collection", "app.bsky.feed.post"), 1732 + ("rkey", "batch_000"), 1733 + ]) 1734 + .send() 1735 + .await 1736 + .unwrap(); 1737 + let verify_body: Value = verify_res.json().await.unwrap(); 1738 + assert!( 1739 + verify_body["value"]["text"] 1740 + .as_str() 1741 + .unwrap() 1742 + .starts_with("UPDATED"), 1743 + "Post should be updated" 1744 + ); 1745 + 1746 + let delete_writes: Vec<Value> = (0..batch_size) 1747 + .map(|i| { 1748 + json!({ 1749 + "$type": "com.atproto.repo.applyWrites#delete", 1750 + "collection": "app.bsky.feed.post", 1751 + "rkey": format!("batch_{:03}", i) 1752 + }) 1753 + }) 1754 + .collect(); 1755 + 1756 + let delete_res = client 1757 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base)) 1758 + .bearer_auth(&jwt) 1759 + .json(&json!({ 1760 + "repo": did, 1761 + "writes": delete_writes 1762 + })) 1763 + .send() 1764 + .await 1765 + .expect("Batch delete failed"); 1766 + assert_eq!( 1767 + delete_res.status(), 1768 + StatusCode::OK, 1769 + "Batch delete of {} posts should succeed", 1770 + batch_size 1771 + ); 1772 + 1773 + let final_res = client 1774 + .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) 1775 + .bearer_auth(&jwt) 1776 + .query(&[ 1777 + ("repo", did.as_str()), 1778 + ("collection", "app.bsky.feed.post"), 1779 + ("limit", "100"), 1780 + ]) 1781 + .send() 1782 + .await 1783 + .unwrap(); 1784 + let final_body: Value = final_res.json().await.unwrap(); 1785 + assert_eq!( 1786 + final_body["records"].as_array().unwrap().len(), 1787 + 0, 1788 + "Should have 0 posts after batch delete" 1789 + ); 1790 + } 1791 + 1792 + #[tokio::test] 1793 + async fn test_scale_reply_thread_depth() { 1794 + let client = client(); 1795 + let base = base_url().await; 1796 + let (did, jwt) = setup_new_user("deep-thread").await; 1797 + 1798 + let root_res = client 1799 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 1800 + .bearer_auth(&jwt) 1801 + .json(&json!({ 1802 + "repo": did, 1803 + "collection": "app.bsky.feed.post", 1804 + "record": { 1805 + "$type": "app.bsky.feed.post", 1806 + "text": "Root post of deep thread", 1807 + "createdAt": Utc::now().to_rfc3339() 1808 + } 1809 + })) 1810 + .send() 1811 + .await 1812 + .expect("Root post failed"); 1813 + assert_eq!(root_res.status(), StatusCode::OK); 1814 + let root_body: Value = root_res.json().await.unwrap(); 1815 + let root_uri = root_body["uri"].as_str().unwrap().to_string(); 1816 + let root_cid = root_body["cid"].as_str().unwrap().to_string(); 1817 + 1818 + let thread_depth = 500; 1819 + 1820 + struct ReplyState { 1821 + parent_uri: String, 1822 + parent_cid: String, 1823 + depth: usize, 1824 + } 1825 + 1826 + let initial_state = ReplyState { 1827 + parent_uri: root_uri.clone(), 1828 + parent_cid: root_cid.clone(), 1829 + depth: 1, 1830 + }; 1831 + 1832 + let (parent_uri, reply_count) = futures::stream::unfold(initial_state, |state| { 1833 + let client = client.clone(); 1834 + let base = base.to_string(); 1835 + let did = did.clone(); 1836 + let jwt = jwt.clone(); 1837 + let root_uri = root_uri.clone(); 1838 + let root_cid = root_cid.clone(); 1839 + async move { 1840 + if state.depth > thread_depth { 1841 + return None; 1842 + } 1843 + 1844 + let res = client 1845 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 1846 + .bearer_auth(&jwt) 1847 + .json(&json!({ 1848 + "repo": did, 1849 + "collection": "app.bsky.feed.post", 1850 + "record": { 1851 + "$type": "app.bsky.feed.post", 1852 + "text": format!("Reply at depth {}", state.depth), 1853 + "createdAt": Utc::now().to_rfc3339(), 1854 + "reply": { 1855 + "root": { "uri": root_uri, "cid": root_cid }, 1856 + "parent": { "uri": &state.parent_uri, "cid": &state.parent_cid } 1857 + } 1858 + } 1859 + })) 1860 + .send() 1861 + .await 1862 + .expect("Reply failed"); 1863 + assert_eq!( 1864 + res.status(), 1865 + StatusCode::OK, 1866 + "Reply at depth {} failed", 1867 + state.depth 1868 + ); 1869 + let body: Value = res.json().await.unwrap(); 1870 + let new_uri = body["uri"].as_str().unwrap().to_string(); 1871 + let new_cid = body["cid"].as_str().unwrap().to_string(); 1872 + 1873 + let next_state = ReplyState { 1874 + parent_uri: new_uri.clone(), 1875 + parent_cid: new_cid, 1876 + depth: state.depth + 1, 1877 + }; 1878 + 1879 + Some((new_uri, next_state)) 1880 + } 1881 + }) 1882 + .fold((String::new(), 0usize), |(_, count), uri| async move { 1883 + (uri, count + 1) 1884 + }) 1885 + .await; 1886 + 1887 + assert_eq!( 1888 + reply_count, thread_depth, 1889 + "Should have created {} replies", 1890 + thread_depth 1891 + ); 1892 + 1893 + let thread_count = count_records(&client, base, &jwt, &did, "app.bsky.feed.post").await; 1894 + assert_eq!( 1895 + thread_count, 1896 + thread_depth + 1, 1897 + "Should have root + {} replies", 1898 + thread_depth 1899 + ); 1900 + 1901 + let deepest_rkey = parent_uri.split('/').next_back().unwrap(); 1902 + let deep_res = client 1903 + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base)) 1904 + .query(&[ 1905 + ("repo", did.as_str()), 1906 + ("collection", "app.bsky.feed.post"), 1907 + ("rkey", deepest_rkey), 1908 + ]) 1909 + .send() 1910 + .await 1911 + .unwrap(); 1912 + assert_eq!(deep_res.status(), StatusCode::OK); 1913 + let deep_body: Value = deep_res.json().await.unwrap(); 1914 + assert_eq!( 1915 + deep_body["value"]["reply"]["root"]["uri"], root_uri, 1916 + "Deepest reply should reference root" 1917 + ); 1918 + } 1919 + 1920 + #[tokio::test] 1921 + async fn test_concurrent_import_and_writes() { 1922 + let client = client(); 1923 + let base = base_url().await; 1924 + let (did, jwt) = setup_new_user("import-conc").await; 1925 + 1926 + let key_bytes = get_user_signing_key(&did) 1927 + .await 1928 + .expect("Failed to get user signing key"); 1929 + let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key"); 1930 + 1931 + let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key); 1932 + 1933 + let write_count = 10; 1934 + 1935 + let import_future = { 1936 + let client = client.clone(); 1937 + let base = base.to_string(); 1938 + let jwt = jwt.clone(); 1939 + let car_bytes = car_bytes.clone(); 1940 + async move { 1941 + let res = client 1942 + .post(format!("{}/xrpc/com.atproto.repo.importRepo", base)) 1943 + .bearer_auth(&jwt) 1944 + .header("Content-Type", "application/vnd.ipld.car") 1945 + .body(car_bytes) 1946 + .send() 1947 + .await 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 + 1955 + let write_futures: Vec<_> = (0..write_count) 1956 + .map(|i| { 1957 + let client = client.clone(); 1958 + let base = base.to_string(); 1959 + let did = did.clone(); 1960 + let jwt = jwt.clone(); 1961 + async move { 1962 + let res = client 1963 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 1964 + .bearer_auth(&jwt) 1965 + .json(&json!({ 1966 + "repo": did, 1967 + "collection": "app.bsky.feed.post", 1968 + "record": { 1969 + "$type": "app.bsky.feed.post", 1970 + "text": format!("Concurrent post {}", i), 1971 + "createdAt": Utc::now().to_rfc3339() 1972 + } 1973 + })) 1974 + .send() 1975 + .await 1976 + .expect("Write request failed"); 1977 + let status = res.status(); 1978 + let body: Value = res.json().await.unwrap_or_default(); 1979 + assert_eq!( 1980 + status, 1981 + StatusCode::OK, 1982 + "Write {} should succeed: {:?}", 1983 + i, 1984 + body 1985 + ); 1986 + } 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)) 1994 + .bearer_auth(&jwt) 1995 + .query(&[ 1996 + ("repo", did.as_str()), 1997 + ("collection", "app.bsky.feed.post"), 1998 + ("limit", "100"), 1999 + ]) 2000 + .send() 2001 + .await 2002 + .unwrap(); 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 + }
+9
deploy/nginx/nginx-quadlet.conf
··· 92 92 proxy_set_header X-Forwarded-Proto $scheme; 93 93 } 94 94 95 + location /webhook/ { 96 + proxy_pass http://127.0.0.1:3000; 97 + proxy_http_version 1.1; 98 + proxy_set_header Host $host; 99 + proxy_set_header X-Real-IP $remote_addr; 100 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 101 + proxy_set_header X-Forwarded-Proto $scheme; 102 + } 103 + 95 104 location = /metrics { 96 105 proxy_pass http://127.0.0.1:3000; 97 106 proxy_http_version 1.1;
+9
docs/install-debian.md
··· 203 203 proxy_set_header X-Forwarded-Proto $scheme; 204 204 } 205 205 206 + location /webhook/ { 207 + proxy_pass http://127.0.0.1:3000; 208 + proxy_http_version 1.1; 209 + proxy_set_header Host $host; 210 + proxy_set_header X-Real-IP $remote_addr; 211 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 212 + proxy_set_header X-Forwarded-Proto $scheme; 213 + } 214 + 206 215 location = /metrics { 207 216 proxy_pass http://127.0.0.1:3000; 208 217 proxy_http_version 1.1;
+56 -22
frontend/src/components/dashboard/CommsContent.svelte
··· 17 17 let saving = $state(false) 18 18 let preferredChannel = $state('email') 19 19 let availableCommsChannels = $state<string[]>(['email']) 20 + let telegramBotUsername = $state<string | undefined>(undefined) 20 21 let email = $state('') 21 22 let discordId = $state('') 22 23 let discordVerified = $state(false) ··· 24 25 let telegramVerified = $state(false) 25 26 let signalNumber = $state('') 26 27 let signalVerified = $state(false) 28 + let savedDiscordId = $state('') 29 + let savedTelegramUsername = $state('') 30 + let savedSignalNumber = $state('') 27 31 let verifyingChannel = $state<string | null>(null) 28 32 let verificationCode = $state('') 29 33 let historyLoading = $state(true) ··· 59 63 telegramVerified = prefs.telegramVerified 60 64 signalNumber = prefs.signalNumber ?? '' 61 65 signalVerified = prefs.signalVerified 66 + savedDiscordId = discordId 67 + savedTelegramUsername = telegramUsername 68 + savedSignalNumber = signalNumber 62 69 availableCommsChannels = serverInfo.availableCommsChannels ?? ['email'] 70 + telegramBotUsername = serverInfo.telegramBotUsername 63 71 } catch (e) { 64 72 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad')) 65 73 } finally { ··· 71 79 e.preventDefault() 72 80 saving = true 73 81 try { 74 - await api.updateNotificationPrefs(session.accessJwt, { 82 + const result = await api.updateNotificationPrefs(session.accessJwt, { 75 83 preferredChannel, 76 - discordId: discordId || undefined, 77 - telegramUsername: telegramUsername || undefined, 78 - signalNumber: signalNumber || undefined, 84 + discordId: discordId !== savedDiscordId ? discordId : undefined, 85 + telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined, 86 + signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined, 79 87 }) 80 88 await refreshSession() 81 89 toast.success($_('comms.preferencesSaved')) 82 - await loadPrefs() 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 + ) 96 + if (channelToVerify) { 97 + verifyingChannel = channelToVerify 98 + verificationCode = '' 99 + } 83 100 } catch (e) { 84 101 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave')) 85 102 } finally { ··· 218 235 <div class="config-item"> 219 236 <div class="config-header"> 220 237 <label for="email">{$_('register.email')}</label> 221 - <span class="status verified">{$_('comms.primary')}</span> 238 + <span class="status verified"> 239 + {preferredChannel === 'email' ? $_('comms.primary') : $_('comms.verified')} 240 + </span> 222 241 </div> 223 242 <input id="email" type="email" value={email} disabled class="readonly" /> 224 243 </div> ··· 229 248 <label for="discord">{$_('register.discordId')}</label> 230 249 {#if discordId} 231 250 <span class="status" class:verified={discordVerified} class:unverified={!discordVerified}> 232 - {discordVerified ? $_('comms.verified') : $_('comms.notVerified')} 251 + {preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')} 233 252 </span> 234 253 {/if} 235 254 </div> ··· 242 261 placeholder={$_('register.discordIdPlaceholder')} 243 262 disabled={saving} 244 263 /> 245 - {#if discordId && !discordVerified} 264 + {#if discordId && discordId === savedDiscordId && !discordVerified} 246 265 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button> 247 266 {/if} 248 267 </div> ··· 251 270 {/if} 252 271 {#if verifyingChannel === 'discord'} 253 272 <div class="verify-form"> 254 - <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 273 + <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" /> 255 274 <button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button> 256 275 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 257 276 </div> ··· 265 284 <label for="telegram">{$_('register.telegramUsername')}</label> 266 285 {#if telegramUsername} 267 286 <span class="status" class:verified={telegramVerified} class:unverified={!telegramVerified}> 268 - {telegramVerified ? $_('comms.verified') : $_('comms.notVerified')} 287 + {preferredChannel === 'telegram' && telegramVerified ? $_('comms.primary') : telegramVerified ? $_('comms.verified') : $_('comms.notVerified')} 269 288 </span> 270 289 {/if} 271 290 </div> ··· 278 297 placeholder={$_('register.telegramUsernamePlaceholder')} 279 298 disabled={saving} 280 299 /> 281 - {#if telegramUsername && !telegramVerified} 282 - <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button> 283 - {/if} 284 300 </div> 285 301 {#if telegramInUse} 286 302 <p class="hint warning">{$_('comms.telegramInUseWarning')}</p> 287 303 {/if} 288 - {#if verifyingChannel === 'telegram'} 289 - <div class="verify-form"> 290 - <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 291 - <button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button> 292 - <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 304 + {#if telegramUsername && telegramUsername === savedTelegramUsername && !telegramVerified && telegramBotUsername} 305 + {@const encodedHandle = session.handle.replaceAll('.', '_')} 306 + <div class="telegram-verify-prompt"> 307 + <a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('comms.telegramOpenLink')}</a> 308 + <span class="manual-hint">{$_('comms.telegramStartBot', { values: { botUsername: telegramBotUsername, handle: session.handle } })}</span> 293 309 </div> 294 310 {/if} 295 311 </div> ··· 301 317 <label for="signal">{$_('register.signalNumber')}</label> 302 318 {#if signalNumber} 303 319 <span class="status" class:verified={signalVerified} class:unverified={!signalVerified}> 304 - {signalVerified ? $_('comms.verified') : $_('comms.notVerified')} 320 + {preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')} 305 321 </span> 306 322 {/if} 307 323 </div> ··· 314 330 placeholder={$_('register.signalNumberPlaceholder')} 315 331 disabled={saving} 316 332 /> 317 - {#if signalNumber && !signalVerified} 333 + {#if signalNumber && signalNumber === savedSignalNumber && !signalVerified} 318 334 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button> 319 335 {/if} 320 336 </div> ··· 323 339 {/if} 324 340 {#if verifyingChannel === 'signal'} 325 341 <div class="verify-form"> 326 - <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 342 + <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" /> 327 343 <button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button> 328 344 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 329 345 </div> ··· 505 521 color: var(--warning-text); 506 522 } 507 523 524 + .telegram-verify-prompt { 525 + display: flex; 526 + flex-direction: column; 527 + gap: var(--space-2); 528 + padding: var(--space-3) var(--space-4); 529 + background: var(--accent-bg, var(--bg-card)); 530 + border: 1px solid var(--accent, var(--border-color)); 531 + border-radius: var(--radius-md); 532 + font-size: var(--text-sm); 533 + color: var(--text-primary); 534 + } 535 + 536 + .manual-hint { 537 + font-size: var(--text-xs); 538 + color: var(--text-secondary); 539 + } 540 + 508 541 .verify-btn { 509 542 padding: var(--space-2) var(--space-3); 510 543 font-size: var(--text-sm); ··· 517 550 } 518 551 519 552 .verify-form input { 520 - width: 120px; 553 + flex: 1; 554 + min-width: 0; 521 555 } 522 556 523 557 .verify-form button {
+13 -2
frontend/src/lib/api.ts
··· 80 80 TotpStatus, 81 81 UpdateLegacyLoginResponse, 82 82 UpdateLocaleResponse, 83 + UpdateNotificationPrefsResponse, 83 84 UploadBlobResponse, 84 85 VerificationChannel, 85 86 VerifyMigrationEmailResponse, ··· 479 480 }); 480 481 }, 481 482 483 + checkChannelVerified( 484 + did: string, 485 + channel: string, 486 + ): Promise<{ verified: boolean }> { 487 + return xrpc("_checkChannelVerified", { 488 + method: "POST", 489 + body: { did, channel }, 490 + }); 491 + }, 492 + 482 493 checkEmailInUse(email: string): Promise<{ inUse: boolean }> { 483 494 return xrpc("_account.checkEmailInUse", { 484 495 method: "POST", ··· 648 659 discordId?: string; 649 660 telegramUsername?: string; 650 661 signalNumber?: string; 651 - }): Promise<SuccessResponse> { 662 + }): Promise<UpdateNotificationPrefsResponse> { 652 663 return xrpc("_account.updateNotificationPrefs", { 653 664 method: "POST", 654 665 token, ··· 1847 1858 telegramUsername?: string; 1848 1859 signalNumber?: string; 1849 1860 }, 1850 - ): Promise<Result<SuccessResponse, ApiError>> { 1861 + ): Promise<Result<UpdateNotificationPrefsResponse, ApiError>> { 1851 1862 return xrpcResult("_account.updateNotificationPrefs", { 1852 1863 method: "POST", 1853 1864 token,
+66 -32
frontend/src/lib/registration/VerificationStep.svelte
··· 1 1 <script lang="ts"> 2 - import { onDestroy } from 'svelte' 2 + import { onDestroy, onMount } from 'svelte' 3 3 import { api, ApiError } from '../api' 4 4 import { resendVerification } from '../auth.svelte' 5 5 import type { RegistrationFlow } from './flow.svelte' ··· 13 13 let verificationCode = $state('') 14 14 let resending = $state(false) 15 15 let resendMessage = $state<string | null>(null) 16 + let telegramBotUsername = $state<string | undefined>(undefined) 16 17 17 18 let pollingInterval: ReturnType<typeof setInterval> | null = null 18 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 + 19 32 $effect(() => { 20 - if (flow.state.step === 'verify' && flow.account && !verificationCode.trim()) { 33 + if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) { 21 34 pollingInterval = setInterval(async () => { 22 - if (verificationCode.trim()) return 35 + if (!isTelegram && verificationCode.trim()) return 23 36 const advanced = await flow.checkAndAdvanceIfVerified() 24 37 if (advanced && pollingInterval) { 25 38 clearInterval(pollingInterval) ··· 84 97 </script> 85 98 86 99 <div class="verification-step"> 87 - <p class="info-text"> 88 - We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}. 89 - Enter it below to continue. 90 - </p> 100 + {#if isTelegram && telegramBotUsername} 101 + {@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`} 102 + {@const encodedHandle = handle.replaceAll('.', '_')} 103 + <p class="info-text"> 104 + <a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">Open Telegram to verify</a>, 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)}. 111 + Enter it below to continue. 112 + </p> 91 113 92 - {#if resendMessage} 93 - <div class="message success">{resendMessage}</div> 94 - {/if} 114 + {#if resendMessage} 115 + <div class="message success">{resendMessage}</div> 116 + {/if} 95 117 96 - <form onsubmit={handleSubmit}> 97 - <div class="field"> 98 - <label for="verification-code">Verification Code</label> 99 - <input 100 - id="verification-code" 101 - type="text" 102 - bind:value={verificationCode} 103 - placeholder="XXXX-XXXX-XXXX-XXXX" 104 - disabled={flow.state.submitting} 105 - required 106 - autocomplete="one-time-code" 107 - class="code-input" 108 - /> 109 - <span class="hint">Copy the entire code from your message, including dashes.</span> 110 - </div> 118 + <form onsubmit={handleSubmit}> 119 + <div class="field"> 120 + <label for="verification-code">Verification Code</label> 121 + <input 122 + id="verification-code" 123 + type="text" 124 + bind:value={verificationCode} 125 + placeholder="XXXX-XXXX-XXXX-XXXX" 126 + disabled={flow.state.submitting} 127 + required 128 + autocomplete="one-time-code" 129 + class="code-input" 130 + /> 131 + <span class="hint">Copy the entire code from your message, including dashes.</span> 132 + </div> 111 133 112 - <button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}> 113 - {flow.state.submitting ? 'Verifying...' : 'Verify'} 114 - </button> 134 + <button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}> 135 + {flow.state.submitting ? 'Verifying...' : 'Verify'} 136 + </button> 115 137 116 - <button type="button" class="secondary" onclick={handleResend} disabled={resending}> 117 - {resending ? 'Resending...' : 'Resend Code'} 118 - </button> 119 - </form> 138 + <button type="button" class="secondary" onclick={handleResend} disabled={resending}> 139 + {resending ? 'Resending...' : 'Resend Code'} 140 + </button> 141 + </form> 142 + {/if} 120 143 </div> 121 144 122 145 <style> ··· 129 152 .info-text { 130 153 color: var(--text-secondary); 131 154 margin: 0; 155 + } 156 + 157 + .info-text.waiting { 158 + font-size: var(--text-sm); 159 + } 160 + 161 + .info-text code { 162 + font-family: var(--font-mono, monospace); 163 + background: var(--bg-secondary); 164 + padding: 0.1em 0.3em; 165 + border-radius: var(--radius-sm); 132 166 } 133 167 134 168 .code-input {
+4 -1
frontend/src/lib/registration/flow.svelte.ts
··· 421 421 422 422 checkingVerification = true; 423 423 try { 424 - const result = await api.checkEmailVerified(state.account.did); 424 + const result = await api.checkChannelVerified( 425 + state.account.did, 426 + state.info.verificationChannel, 427 + ); 425 428 if (!result.verified) return false; 426 429 427 430 if (state.info.didType === "web-external") {
+6
frontend/src/lib/types/api.ts
··· 232 232 version?: string; 233 233 availableCommsChannels?: VerificationChannel[]; 234 234 selfHostedDidWebEnabled?: boolean; 235 + telegramBotUsername?: string; 236 + } 237 + 238 + export interface UpdateNotificationPrefsResponse { 239 + success: boolean; 240 + verificationRequired: string[]; 235 241 } 236 242 237 243 export interface RepoInfo {
+3 -1
frontend/src/locales/en.json
··· 439 439 "noMessages": "No messages found.", 440 440 "discordInUseWarning": "This Discord ID is already associated with another account.", 441 441 "telegramInUseWarning": "This Telegram username is already associated with another account.", 442 - "signalInUseWarning": "This Signal number 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" 443 445 }, 444 446 "repoExplorer": { 445 447 "collections": "Collections",
+2
frontend/src/locales/fi.json
··· 436 436 "discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.", 437 437 "telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.", 438 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", 439 441 "failedToLoad": "Asetusten lataus epäonnistui", 440 442 "failedToSave": "Asetusten tallennus epäonnistui", 441 443 "failedToVerify": "Vahvistus epäonnistui",
+2
frontend/src/locales/ja.json
··· 436 436 "discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。", 437 437 "telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。", 438 438 "signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。", 439 + "telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信", 440 + "telegramOpenLink": "Telegram で確認する", 439 441 "failedToLoad": "設定の読み込みに失敗しました", 440 442 "failedToSave": "設定の保存に失敗しました", 441 443 "failedToVerify": "確認に失敗しました",
+2
frontend/src/locales/ko.json
··· 436 436 "discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.", 437 437 "telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.", 438 438 "signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.", 439 + "telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요", 440 + "telegramOpenLink": "Telegram에서 인증하기", 439 441 "failedToLoad": "설정 로딩 실패", 440 442 "failedToSave": "설정 저장 실패", 441 443 "failedToVerify": "인증 실패",
+2
frontend/src/locales/sv.json
··· 436 436 "discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.", 437 437 "telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.", 438 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", 439 441 "failedToLoad": "Kunde inte ladda inställningar", 440 442 "failedToSave": "Kunde inte spara inställningar", 441 443 "failedToVerify": "Verifiering misslyckades",
+2
frontend/src/locales/zh.json
··· 436 436 "discordInUseWarning": "此 Discord ID 已与另一个账户关联。", 437 437 "telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。", 438 438 "signalInUseWarning": "此 Signal 号码已与另一个账户关联。", 439 + "telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}", 440 + "telegramOpenLink": "打开 Telegram 验证", 439 441 "failedToLoad": "加载偏好设置失败", 440 442 "failedToSave": "保存偏好设置失败", 441 443 "failedToVerify": "验证失败",
+6 -7
frontend/src/routes/OAuthSsoRegister.svelte
··· 99 99 inviteCodeRequired: data.inviteCodeRequired ?? false, 100 100 selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false, 101 101 } 102 - if (data.commsChannels) { 103 - commsChannels = { 104 - email: data.commsChannels.email ?? true, 105 - discord: data.commsChannels.discord ?? false, 106 - telegram: data.commsChannels.telegram ?? false, 107 - signal: data.commsChannels.signal ?? false, 108 - } 102 + const available: string[] = data.availableCommsChannels ?? ['email'] 103 + commsChannels = { 104 + email: available.includes('email'), 105 + discord: available.includes('discord'), 106 + telegram: available.includes('telegram'), 107 + signal: available.includes('signal'), 109 108 } 110 109 } 111 110 } catch {
+6 -7
frontend/src/routes/SsoRegisterComplete.svelte
··· 114 114 inviteCodeRequired: data.inviteCodeRequired ?? false, 115 115 selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false, 116 116 } 117 - if (data.commsChannels) { 118 - commsChannels = { 119 - email: data.commsChannels.email ?? true, 120 - discord: data.commsChannels.discord ?? false, 121 - telegram: data.commsChannels.telegram ?? false, 122 - signal: data.commsChannels.signal ?? false, 123 - } 117 + const available: string[] = data.availableCommsChannels ?? ['email'] 118 + commsChannels = { 119 + email: available.includes('email'), 120 + discord: available.includes('discord'), 121 + telegram: available.includes('telegram'), 122 + signal: available.includes('signal'), 124 123 } 125 124 } 126 125 } catch {
+71 -26
frontend/src/routes/Verify.svelte
··· 32 32 let successChannel = $state<string | null>(null) 33 33 let tokenFromUrl = $state(false) 34 34 let oauthRequestUri = $state<string | null>(null) 35 + let telegramBotUsername = $state<string | undefined>(undefined) 35 36 36 37 const auth = $derived(getAuthState()) 37 38 ··· 40 41 } 41 42 42 43 const session = $derived(getSession()) 44 + const isTelegram = $derived(pendingVerification?.channel === 'telegram') 43 45 44 46 function parseQueryParams(): Record<string, string> { 45 47 return Object.fromEntries(new URLSearchParams(window.location.search)) ··· 99 101 channel: params.channel, 100 102 })) 101 103 } 104 + 105 + if (pendingVerification?.channel === 'telegram') { 106 + try { 107 + const serverInfo = await api.describeServer() 108 + telegramBotUsername = serverInfo.telegramBotUsername 109 + } catch { 110 + } 111 + } 102 112 } 103 113 }) 104 114 ··· 111 121 112 122 let pollingVerification = false 113 123 $effect(() => { 114 - if (mode === 'signup' && pendingVerification && !verificationCode.trim()) { 124 + if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) { 115 125 const currentPending = pendingVerification 116 126 const interval = setInterval(async () => { 117 - if (pollingVerification || verificationCode.trim()) return 127 + if (pollingVerification || (!isTelegram && verificationCode.trim())) return 118 128 pollingVerification = true 119 129 try { 120 - const result = await api.checkEmailVerified(currentPending.did) 130 + const result = await api.checkChannelVerified(currentPending.did, currentPending.channel) 121 131 if (result.verified) { 122 132 clearInterval(interval) 123 133 clearPendingVerification() ··· 435 445 <div class="message success">{resendMessage}</div> 436 446 {/if} 437 447 438 - <form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}> 439 - <div class="field"> 440 - <label for="verification-code">{$_('verify.codeLabel')}</label> 441 - <input 442 - id="verification-code" 443 - type="text" 444 - bind:value={verificationCode} 445 - placeholder={$_('verify.codePlaceholder')} 446 - disabled={submitting} 447 - required 448 - autocomplete="off" 449 - class="token-input" 450 - /> 451 - <p class="field-help">{$_('verify.codeHelp')}</p> 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> 454 + <p class="manual-text"> 455 + {$_('comms.telegramStartBot', { values: { botUsername: telegramBotUsername, handle: pendingVerification.handle } })} 456 + </p> 457 + <p class="waiting-text">{$_('verify.pleaseWait')}</p> 452 458 </div> 459 + {:else} 460 + <form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}> 461 + <div class="field"> 462 + <label for="verification-code">{$_('verify.codeLabel')}</label> 463 + <input 464 + id="verification-code" 465 + type="text" 466 + bind:value={verificationCode} 467 + placeholder={$_('verify.codePlaceholder')} 468 + disabled={submitting} 469 + required 470 + autocomplete="off" 471 + class="token-input" 472 + /> 473 + <p class="field-help">{$_('verify.codeHelp')}</p> 474 + </div> 453 475 454 - <div class="form-actions"> 455 - <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 456 - {resendingCode ? $_('common.sending') : $_('common.resendCode')} 457 - </button> 458 - <button type="submit" disabled={submitting || !verificationCode.trim()}> 459 - {submitting ? $_('common.verifying') : $_('common.verify')} 460 - </button> 461 - </div> 462 - </form> 476 + <div class="form-actions"> 477 + <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 478 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 479 + </button> 480 + <button type="submit" disabled={submitting || !verificationCode.trim()}> 481 + {submitting ? $_('common.verifying') : $_('common.verify')} 482 + </button> 483 + </div> 484 + </form> 485 + {/if} 463 486 464 487 <p class="link-text"> 465 488 <a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a> ··· 585 608 .success-container .btn { 586 609 flex: none; 587 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); 588 633 } 589 634 </style>
+1
migrations/20260203_telegram_chat_id.sql
··· 1 + ALTER TABLE users ADD COLUMN telegram_chat_id BIGINT;
+1
migrations/20260204_channel_verified_comms_type.sql
··· 1 + ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'channel_verified';
+9
nginx.frontend.conf
··· 122 122 proxy_set_header X-Forwarded-Proto $scheme; 123 123 } 124 124 125 + location /webhook/ { 126 + proxy_pass http://backend; 127 + proxy_http_version 1.1; 128 + proxy_set_header Host $host; 129 + proxy_set_header X-Real-IP $remote_addr; 130 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 131 + proxy_set_header X-Forwarded-Proto $scheme; 132 + } 133 + 125 134 location = /metrics { 126 135 proxy_pass http://backend; 127 136 proxy_http_version 1.1;
+1
scripts/run-tests.sh
··· 18 18 echo "" 19 19 echo "Running tests..." 20 20 echo "" 21 + ulimit -n 65536 21 22 cargo nextest run "$@"

History

1 round 0 comments
sign up or login to add to the discussion
lewis.moe submitted #0
2 commits
expand
fix: concurrent perf improvement
fix: telegram comms ux improvements
expand 0 comments
pull request successfully merged