From cd7400bc8bc55c6383bc1b95b349abfa08d1d0da Mon Sep 17 00:00:00 2001 From: lewis Date: Thu, 5 Feb 2026 17:31:52 +0200 Subject: [PATCH] fix: improved discord & signal comms --- .env.example | 4 +- ...a921017a2dc02fe73513dc1243e219ebc3b76.json | 15 + ...62ecd3b34e50c3604d7b48664a9f85058eb70.json | 24 ++ ...2e345ee8d30ab4b4aa71656d0e47d0dc36693.json | 22 ++ ...309d361518bb30890f688088cfd114ae3ce07.json | 14 + ...505bd35383ba5c6453e72e819f80bacc19b2.json} | 22 +- ...0230bad1d85da784c826df7fe4f037e1cb7f1.json | 14 - ...aaede516e0452219f14d006c43af6f959db21.json | 14 + ...c3256ece8a0c8dda4baefeadb2b9627214d2a.json | 15 + ...534b80234759c8437434e467ca33ea10fd1f.json} | 6 +- ...98a9a7775d81a7e3e9e8bd976f134d260b27.json} | 8 +- ...730fa0ca9f5c722357f90f36e3b47e825f824.json | 15 + ...ea1087f284b66fc814e7c4742c0a54f78020.json} | 6 +- ...e460d84ea1de18f620ee4ece2623ad153911.json} | 8 +- ...14000c6f0d53743007046d820a76497a2fa37.json | 14 - ...63d2f4b4a1464ca6184b5275d769c7930e96c.json | 23 ++ ...13d1f79c2541a5e992b2b2aa7c0ae006364c3.json | 15 - Cargo.lock | 2 + crates/tranquil-comms/src/lib.rs | 2 +- crates/tranquil-comms/src/locale.rs | 14 + crates/tranquil-comms/src/sender.rs | 314 ++++++++++++++++-- crates/tranquil-db-traits/src/error.rs | 6 + crates/tranquil-db-traits/src/repo.rs | 1 + crates/tranquil-db-traits/src/user.rs | 46 ++- crates/tranquil-db/src/postgres/repo.rs | 12 +- crates/tranquil-db/src/postgres/user.rs | 157 +++++++-- crates/tranquil-pds/Cargo.toml | 2 + .../tranquil-pds/src/api/discord_webhook.rs | 301 +++++++++++++++++ crates/tranquil-pds/src/api/error.rs | 2 +- .../tranquil-pds/src/api/identity/account.rs | 40 ++- crates/tranquil-pds/src/api/mod.rs | 1 + .../src/api/notification_prefs.rs | 76 +++-- crates/tranquil-pds/src/api/repo/import.rs | 19 +- crates/tranquil-pds/src/api/server/email.rs | 1 + crates/tranquil-pds/src/api/server/meta.rs | 10 +- .../src/api/server/passkey_account.rs | 40 ++- crates/tranquil-pds/src/api/server/session.rs | 9 +- crates/tranquil-pds/src/api/validation.rs | 38 ++- crates/tranquil-pds/src/comms/mod.rs | 4 +- crates/tranquil-pds/src/comms/service.rs | 44 ++- crates/tranquil-pds/src/lib.rs | 8 +- crates/tranquil-pds/src/main.rs | 65 +++- crates/tranquil-pds/src/sso/endpoints.rs | 35 +- crates/tranquil-pds/src/sync/import.rs | 4 +- crates/tranquil-pds/src/util.rs | 27 ++ .../tests/account_notifications.rs | 45 +-- crates/tranquil-pds/tests/security_fixes.rs | 34 +- crates/tranquil-pds/tests/sso.rs | 6 +- crates/tranquil-pds/tests/whole_story.rs | 21 +- .../components/dashboard/CommsContent.svelte | 87 +++-- frontend/src/lib/api.ts | 16 +- .../lib/registration/VerificationStep.svelte | 19 +- frontend/src/lib/registration/flow.svelte.ts | 12 +- frontend/src/lib/registration/types.ts | 4 +- frontend/src/lib/types/api.ts | 8 +- frontend/src/lib/types/schemas.ts | 4 +- frontend/src/locales/en.json | 30 +- frontend/src/locales/fi.json | 28 +- frontend/src/locales/ja.json | 28 +- frontend/src/locales/ko.json | 28 +- frontend/src/locales/sv.json | 28 +- frontend/src/locales/zh.json | 28 +- frontend/src/routes/OAuthRegister.svelte | 21 +- frontend/src/routes/OAuthSsoRegister.svelte | 29 +- frontend/src/routes/Register.svelte | 21 +- frontend/src/routes/RegisterPassword.svelte | 25 +- .../src/routes/SsoRegisterComplete.svelte | 29 +- frontend/src/routes/Verify.svelte | 32 +- frontend/src/tests/mocks.ts | 4 +- migrations/20260205_discord_username.sql | 3 + migrations/20260206_signal_username.sql | 1 + 71 files changed, 1596 insertions(+), 514 deletions(-) create mode 100644 .sqlx/query-0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76.json create mode 100644 .sqlx/query-0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70.json create mode 100644 .sqlx/query-15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693.json create mode 100644 .sqlx/query-3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07.json rename .sqlx/{query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json => query-5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2.json} (74%) delete mode 100644 .sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json create mode 100644 .sqlx/query-7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21.json create mode 100644 .sqlx/query-91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a.json rename .sqlx/{query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json => query-a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f.json} (79%) rename .sqlx/{query-a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c.json => query-b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27.json} (82%) create mode 100644 .sqlx/query-b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824.json rename .sqlx/{query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json => query-b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020.json} (87%) rename .sqlx/{query-671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910.json => query-c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911.json} (79%) delete mode 100644 .sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json create mode 100644 .sqlx/query-eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c.json delete mode 100644 .sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json create mode 100644 crates/tranquil-pds/src/api/discord_webhook.rs create mode 100644 migrations/20260205_discord_username.sql create mode 100644 migrations/20260206_signal_username.sql diff --git a/.env.example b/.env.example index dfb1a60..e7cbd21 100644 --- a/.env.example +++ b/.env.example @@ -93,8 +93,8 @@ BACKUP_STORAGE_PATH=/var/lib/tranquil/backups # MAIL_FROM_ADDRESS=noreply@example.com # MAIL_FROM_NAME=My PDS # SENDMAIL_PATH=/usr/sbin/sendmail -# Discord notifications (via webhook) -# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... +# Discord notifications (via bot DM) +# DISCORD_BOT_TOKEN=bot-token # Telegram notifications (via bot) # TELEGRAM_BOT_TOKEN=bot-token # TELEGRAM_WEBHOOK_SECRET=random-secret diff --git a/.sqlx/query-0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76.json b/.sqlx/query-0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76.json new file mode 100644 index 0000000..7e93046 --- /dev/null +++ b/.sqlx/query-0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET\n signal_username = $1,\n signal_verified = CASE WHEN LOWER(signal_username) = LOWER($1) THEN signal_verified ELSE FALSE END,\n updated_at = NOW()\n WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "0350df76ef1a934f5262ae05856a921017a2dc02fe73513dc1243e219ebc3b76" +} diff --git a/.sqlx/query-0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70.json b/.sqlx/query-0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70.json new file mode 100644 index 0000000..6607226 --- /dev/null +++ b/.sqlx/query-0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND handle = $3 RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0f80177bbdffb0186895e7f000462ecd3b34e50c3604d7b48664a9f85058eb70" +} diff --git a/.sqlx/query-15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693.json b/.sqlx/query-15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693.json new file mode 100644 index 0000000..0d6e8b5 --- /dev/null +++ b/.sqlx/query-15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND deactivated_at IS NULL FOR UPDATE NOWAIT", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "15e75af384c9d948b655a4ec2442e345ee8d30ab4b4aa71656d0e47d0dc36693" +} diff --git a/.sqlx/query-3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07.json b/.sqlx/query-3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07.json new file mode 100644 index 0000000..80f85e2 --- /dev/null +++ b/.sqlx/query-3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3c2dde5b27aeaf0dfe5a5c00d4b309d361518bb30890f688088cfd114ae3ce07" +} diff --git a/.sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json b/.sqlx/query-5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2.json similarity index 74% rename from .sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json rename to .sqlx/query-5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2.json index 9d34348..a896f5d 100644 --- a/.sqlx/query-c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740.json +++ b/.sqlx/query-5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_number,\n signal_verified\n FROM users WHERE did = $1", + "query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_username,\n discord_verified,\n telegram_username,\n telegram_verified,\n telegram_chat_id,\n signal_username,\n signal_verified\n FROM users WHERE did = $1", "describe": { "columns": [ { @@ -32,31 +32,36 @@ }, { "ordinal": 3, + "name": "discord_username", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "discord_verified", "type_info": "Bool" }, { - "ordinal": 4, + "ordinal": 5, "name": "telegram_username", "type_info": "Text" }, { - "ordinal": 5, + "ordinal": 6, "name": "telegram_verified", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "telegram_chat_id", "type_info": "Int8" }, { - "ordinal": 7, - "name": "signal_number", + "ordinal": 8, + "name": "signal_username", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, "name": "signal_verified", "type_info": "Bool" } @@ -70,6 +75,7 @@ true, false, true, + true, false, true, false, @@ -78,5 +84,5 @@ false ] }, - "hash": "c48c9af71d8dab70ea2f11df30d2cc4976732a47430bd471f5aa47afc16e5740" + "hash": "5b43c61801fb580a9cc08094a611505bd35383ba5c6453e72e819f80bacc19b2" } diff --git a/.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json b/.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json deleted file mode 100644 index 6d7e2e1..0000000 --- a/.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1" -} diff --git a/.sqlx/query-7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21.json b/.sqlx/query-7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21.json new file mode 100644 index 0000000..b636361 --- /dev/null +++ b/.sqlx/query-7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "7ff9f138ecb4974169490a998bcaaede516e0452219f14d006c43af6f959db21" +} diff --git a/.sqlx/query-91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a.json b/.sqlx/query-91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a.json new file mode 100644 index 0000000..6c196ca --- /dev/null +++ b/.sqlx/query-91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET\n discord_username = $1,\n discord_verified = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_verified ELSE FALSE END,\n discord_id = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_id ELSE NULL END,\n updated_at = NOW()\n WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "91376af625ed07da85a7a091997c3256ece8a0c8dda4baefeadb2b9627214d2a" +} diff --git a/.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json b/.sqlx/query-a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f.json similarity index 79% rename from .sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json rename to .sqlx/query-a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f.json index b6e8c16..9cfbdba 100644 --- a/.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json +++ b/.sqlx/query-a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1", + "query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_username FROM users WHERE did = $1", "describe": { "columns": [ { @@ -22,7 +22,7 @@ }, { "ordinal": 1, - "name": "discord_id", + "name": "discord_username", "type_info": "Text" } ], @@ -36,5 +36,5 @@ true ] }, - "hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722" + "hash": "a844774d8dd3c50c5faf3de5d43f534b80234759c8437434e467ca33ea10fd1f" } diff --git a/.sqlx/query-a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c.json b/.sqlx/query-b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27.json similarity index 82% rename from .sqlx/query-a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c.json rename to .sqlx/query-b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27.json index dc16429..ab55a8f 100644 --- a/.sqlx/query-a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c.json +++ b/.sqlx/query-b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n id, handle, email,\n preferred_comms_channel as \"channel: CommsChannel\",\n discord_id, telegram_username, signal_number,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1", + "query": "SELECT\n id, handle, email,\n preferred_comms_channel as \"channel: CommsChannel\",\n discord_username, telegram_username, signal_username,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1", "describe": { "columns": [ { @@ -37,7 +37,7 @@ }, { "ordinal": 4, - "name": "discord_id", + "name": "discord_username", "type_info": "Text" }, { @@ -47,7 +47,7 @@ }, { "ordinal": 6, - "name": "signal_number", + "name": "signal_username", "type_info": "Text" }, { @@ -90,5 +90,5 @@ false ] }, - "hash": "a01d6b316fc64250992bccb3b4b2cea9205d8828fac58c84ec929c8a0465588c" + "hash": "b542a4235ceb6285cad12eed162098a9a7775d81a7e3e9e8bd976f134d260b27" } diff --git a/.sqlx/query-b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824.json b/.sqlx/query-b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824.json new file mode 100644 index 0000000..e48dfa5 --- /dev/null +++ b/.sqlx/query-b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b863a9d1feb2f6c76c7c1c9bd09730fa0ca9f5c722357f90f36e3b47e825f824" +} diff --git a/.sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json b/.sqlx/query-b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020.json similarity index 87% rename from .sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json rename to .sqlx/query-b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020.json index 035bbb8..59c7f5f 100644 --- a/.sqlx/query-63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67.json +++ b/.sqlx/query-b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale, telegram_chat_id, discord_id, signal_number\n FROM users WHERE id = $1", + "query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale, telegram_chat_id, discord_id, signal_username\n FROM users WHERE id = $1", "describe": { "columns": [ { @@ -47,7 +47,7 @@ }, { "ordinal": 6, - "name": "signal_number", + "name": "signal_username", "type_info": "Text" } ], @@ -66,5 +66,5 @@ true ] }, - "hash": "63c2a9079c147be6d04bf02c63ea7a3d0b0db3f35438380c0fe7e4c60b420c67" + "hash": "b8829a4375f55ff7f357ceab94d1ea1087f284b66fc814e7c4742c0a54f78020" } diff --git a/.sqlx/query-671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910.json b/.sqlx/query-c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911.json similarity index 79% rename from .sqlx/query-671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910.json rename to .sqlx/query-c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911.json index 31cc2c9..02ae610 100644 --- a/.sqlx/query-671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910.json +++ b/.sqlx/query-c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: CommsChannel\",\n u.discord_id, u.telegram_username, u.signal_number,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", + "query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: CommsChannel\",\n u.discord_username, u.telegram_username, u.signal_username,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", "describe": { "columns": [ { @@ -42,7 +42,7 @@ }, { "ordinal": 5, - "name": "discord_id", + "name": "discord_username", "type_info": "Text" }, { @@ -52,7 +52,7 @@ }, { "ordinal": 7, - "name": "signal_number", + "name": "signal_username", "type_info": "Text" }, { @@ -84,5 +84,5 @@ true ] }, - "hash": "671fb43192b9f37862f9eae2c5cec206ae1664ab5d595df4058c44abd257c910" + "hash": "c8657f67fdd3cc0fb40dfaabd5cde460d84ea1de18f620ee4ece2623ad153911" } diff --git a/.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json b/.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json deleted file mode 100644 index 02204fe..0000000 --- a/.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37" -} diff --git a/.sqlx/query-eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c.json b/.sqlx/query-eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c.json new file mode 100644 index 0000000..844206c --- /dev/null +++ b/.sqlx/query-eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "eab6c3df913b420839e9752cf1163d2f4b4a1464ca6184b5275d769c7930e96c" +} diff --git a/.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json b/.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json deleted file mode 100644 index 76674da..0000000 --- a/.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3" -} diff --git a/Cargo.lock b/Cargo.lock index a760f82..956b45c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6078,9 +6078,11 @@ dependencies = [ "cid", "ctor", "dotenvy", + "ed25519-dalek", "futures", "futures-util", "governor", + "hex", "hickory-resolver", "hkdf", "hmac", diff --git a/crates/tranquil-comms/src/lib.rs b/crates/tranquil-comms/src/lib.rs index b06ea35..5c5824e 100644 --- a/crates/tranquil-comms/src/lib.rs +++ b/crates/tranquil-comms/src/lib.rs @@ -8,6 +8,6 @@ pub use locale::{ }; pub use sender::{ CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender, - is_valid_phone_number, mime_encode_header, sanitize_header_value, + is_valid_phone_number, is_valid_signal_username, mime_encode_header, sanitize_header_value, }; pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms}; diff --git a/crates/tranquil-comms/src/locale.rs b/crates/tranquil-comms/src/locale.rs index d34dfa9..0f64049 100644 --- a/crates/tranquil-comms/src/locale.rs +++ b/crates/tranquil-comms/src/locale.rs @@ -33,6 +33,8 @@ pub struct NotificationStrings { pub migration_verification_body: &'static str, pub channel_verified_subject: &'static str, pub channel_verified_body: &'static str, + pub channel_verification_subject: &'static str, + pub channel_verification_body: &'static str, } pub fn get_strings(locale: &str) -> &'static NotificationStrings { @@ -70,6 +72,8 @@ static STRINGS_EN: NotificationStrings = NotificationStrings { migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not migrate your account, please ignore this email.", channel_verified_subject: "Channel verified - {hostname}", channel_verified_body: "Hello {handle},\n\n{channel} has been verified as a notification channel for your account on {hostname}.", + channel_verification_subject: "Verify your channel - {hostname}", + channel_verification_body: "Your verification code is:\n{code}\n\nOr verify directly:\n{verify_link}", }; static STRINGS_ZH: NotificationStrings = NotificationStrings { @@ -96,6 +100,8 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings { migration_verification_body: "欢迎来到 {hostname}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n或者直接点击链接:\n{verify_link}\n\n如果您没有迁移账户,请忽略此邮件。", channel_verified_subject: "通知渠道已验证 - {hostname}", channel_verified_body: "您好 {handle},\n\n{channel} 已被验证为您在 {hostname} 上的通知渠道。", + channel_verification_subject: "验证您的渠道 - {hostname}", + channel_verification_body: "您的验证码是:\n{code}\n\n或直接验证:\n{verify_link}", }; static STRINGS_JA: NotificationStrings = NotificationStrings { @@ -122,6 +128,8 @@ static STRINGS_JA: NotificationStrings = NotificationStrings { migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\n自己責任でワンクリック認証:\n{verify_link}\n\nアカウントを移行していない場合は、このメールを無視してください。", channel_verified_subject: "通知チャンネル認証完了 - {hostname}", channel_verified_body: "{handle} 様\n\n{channel} が {hostname} の通知チャンネルとして認証されました。", + channel_verification_subject: "チャンネルを認証 - {hostname}", + channel_verification_body: "認証コードは:\n{code}\n\n直接認証:\n{verify_link}", }; static STRINGS_KO: NotificationStrings = NotificationStrings { @@ -148,6 +156,8 @@ static STRINGS_KO: NotificationStrings = NotificationStrings { migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n위험을 감수하고 원클릭 인증:\n{verify_link}\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.", channel_verified_subject: "알림 채널 인증 완료 - {hostname}", channel_verified_body: "안녕하세요 {handle}님,\n\n{channel}이(가) {hostname}의 알림 채널로 인증되었습니다.", + channel_verification_subject: "채널 인증 - {hostname}", + channel_verification_body: "인증 코드:\n{code}\n\n직접 인증:\n{verify_link}", }; static STRINGS_SV: NotificationStrings = NotificationStrings { @@ -174,6 +184,8 @@ static STRINGS_SV: NotificationStrings = NotificationStrings { migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.", channel_verified_subject: "Aviseringskanal verifierad - {hostname}", channel_verified_body: "Hej {handle},\n\n{channel} har verifierats som aviseringskanal för ditt konto på {hostname}.", + channel_verification_subject: "Verifiera din kanal - {hostname}", + channel_verification_body: "Din verifieringskod är:\n{code}\n\nEller verifiera direkt:\n{verify_link}", }; static STRINGS_FI: NotificationStrings = NotificationStrings { @@ -200,6 +212,8 @@ static STRINGS_FI: NotificationStrings = NotificationStrings { migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nTai jos pidät vaarallisesta elämästä:\n{verify_link}\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.", channel_verified_subject: "Ilmoituskanava vahvistettu - {hostname}", channel_verified_body: "Hei {handle},\n\n{channel} on vahvistettu ilmoituskanavaksi tilillesi palvelussa {hostname}.", + channel_verification_subject: "Vahvista kanavasi - {hostname}", + channel_verification_body: "Vahvistuskoodisi on:\n{code}\n\nTai vahvista suoraan:\n{verify_link}", }; pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String { diff --git a/crates/tranquil-comms/src/sender.rs b/crates/tranquil-comms/src/sender.rs index bd0dab8..d2d1453 100644 --- a/crates/tranquil-comms/src/sender.rs +++ b/crates/tranquil-comms/src/sender.rs @@ -6,6 +6,7 @@ use std::process::Stdio; use std::time::Duration; use tokio::io::AsyncWriteExt; use tokio::process::Command; +use tokio::time::timeout; use super::types::{CommsChannel, QueuedComms}; @@ -85,6 +86,25 @@ pub fn is_valid_phone_number(number: &str) -> bool { !remaining.is_empty() && remaining.chars().all(|c| c.is_ascii_digit()) } +pub fn is_valid_signal_username(username: &str) -> bool { + if username.len() < 6 || username.len() > 35 { + return false; + } + let Some((base, discriminator)) = username.rsplit_once('.') else { + return false; + }; + if base.len() < 3 || base.len() > 32 { + return false; + } + if !base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return false; + } + if !base.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) { + return false; + } + discriminator.len() == 2 && discriminator.chars().all(|c| c.is_ascii_digit()) +} + pub struct EmailSender { from_address: String, from_name: String, @@ -154,22 +174,208 @@ impl CommsSender for EmailSender { } } +const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; + +#[derive(Clone)] pub struct DiscordSender { - webhook_url: String, + bot_token: String, http_client: Client, } impl DiscordSender { - pub fn new(webhook_url: String) -> Self { + pub fn new(bot_token: String) -> Self { Self { - webhook_url, + bot_token, http_client: create_http_client(), } } pub fn from_env() -> Option { - let webhook_url = std::env::var("DISCORD_WEBHOOK_URL").ok()?; - Some(Self::new(webhook_url)) + let bot_token = std::env::var("DISCORD_BOT_TOKEN").ok()?; + Some(Self::new(bot_token)) + } + + fn auth_header(&self) -> String { + format!("Bot {}", self.bot_token) + } + + pub async fn resolve_application_info(&self) -> Result<(String, String), SendError> { + let url = format!("{}/applications/@me", DISCORD_API_BASE); + let response = self + .http_client + .get(&url) + .header("Authorization", self.auth_header()) + .send() + .await + .map_err(|e| { + SendError::ExternalService(format!( + "Discord application info request failed: {}", + e + )) + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(SendError::ExternalService(format!( + "Discord application info returned error: {}", + body + ))); + } + + let data: serde_json::Value = response.json().await.map_err(|e| { + SendError::ExternalService(format!("Failed to parse Discord application info: {}", e)) + })?; + + let app_id = data + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| SendError::ExternalService("Application info missing id".to_string()))?; + + let verify_key = data + .get("verify_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + SendError::ExternalService("Application info missing verify_key".to_string()) + })?; + + Ok((app_id, verify_key)) + } + + pub async fn register_slash_command(&self, app_id: &str) -> Result<(), SendError> { + let url = format!("{}/applications/{}/commands", DISCORD_API_BASE, app_id); + let payload = serde_json::json!({ + "name": "start", + "description": "Verify your PDS account", + "type": 1, + "options": [{ + "name": "handle", + "description": "Your PDS handle (e.g. alice.example.com)", + "type": 3, + "required": false + }] + }); + let response = self + .http_client + .post(&url) + .header("Authorization", self.auth_header()) + .json(&payload) + .send() + .await + .map_err(|e| { + SendError::ExternalService(format!("Register command request failed: {}", e)) + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(SendError::ExternalService(format!( + "Register command returned error: {}", + body + ))); + } + Ok(()) + } + + pub async fn set_interactions_endpoint( + &self, + app_id: &str, + url: &str, + ) -> Result<(), SendError> { + let patch_url = format!("{}/applications/{}", DISCORD_API_BASE, app_id); + let payload = serde_json::json!({ + "interactions_endpoint_url": url + }); + let response = self + .http_client + .patch(&patch_url) + .header("Authorization", self.auth_header()) + .json(&payload) + .send() + .await + .map_err(|e| { + SendError::ExternalService(format!("Set interactions endpoint failed: {}", e)) + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(SendError::ExternalService(format!( + "Set interactions endpoint returned error: {}", + body + ))); + } + Ok(()) + } + + pub async fn resolve_bot_username(&self) -> Result { + let url = format!("{}/users/@me", DISCORD_API_BASE); + let response = self + .http_client + .get(&url) + .header("Authorization", self.auth_header()) + .send() + .await + .map_err(|e| { + SendError::ExternalService(format!("Discord getMe request failed: {}", e)) + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(SendError::ExternalService(format!( + "Discord getMe returned error: {}", + body + ))); + } + + let data: serde_json::Value = response.json().await.map_err(|e| { + SendError::ExternalService(format!("Failed to parse Discord getMe response: {}", e)) + })?; + + data.get("username") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + SendError::ExternalService("Discord getMe response missing username".to_string()) + }) + } + + async fn open_dm_channel(&self, user_id: &str) -> Result { + let url = format!("{}/users/@me/channels", DISCORD_API_BASE); + let payload = json!({ "recipient_id": user_id }); + + let response = self + .http_client + .post(&url) + .header("Authorization", self.auth_header()) + .json(&payload) + .send() + .await + .map_err(|e| { + SendError::ExternalService(format!("Discord DM channel request failed: {}", e)) + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SendError::ExternalService(format!( + "Discord DM channel creation returned {}: {}", + status, body + ))); + } + + let data: serde_json::Value = response.json().await.map_err(|e| { + SendError::ExternalService(format!( + "Failed to parse Discord DM channel response: {}", + e + )) + })?; + + data.get("id") + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + SendError::ExternalService("Discord DM channel response missing id".to_string()) + }) } } @@ -180,17 +386,19 @@ impl CommsSender for DiscordSender { } async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { + let channel_id = self.open_dm_channel(¬ification.recipient).await?; + let subject = notification.subject.as_deref().unwrap_or("Notification"); let content = format!("**{}**\n\n{}", subject, notification.body); - let payload = json!({ - "content": content, - "username": "Tranquil PDS" - }); + let payload = json!({ "content": content }); + let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id); + let mut last_error = None; for attempt in 0..MAX_RETRIES { let result = self .http_client - .post(&self.webhook_url) + .post(&url) + .header("Authorization", self.auth_header()) .json(&payload) .send() .await; @@ -201,13 +409,13 @@ impl CommsSender for DiscordSender { } let status = response.status(); if is_retryable_status(status) && attempt < MAX_RETRIES - 1 { - last_error = Some(format!("Discord webhook returned {}", status)); + last_error = Some(format!("Discord API returned {}", status)); retry_delay(attempt).await; continue; } let body = response.text().await.unwrap_or_default(); return Err(SendError::ExternalService(format!( - "Discord webhook returned {}: {}", + "Discord API returned {}: {}", status, body ))); } @@ -386,6 +594,19 @@ impl SignalSender { } } +const SIGNAL_TIMEOUT_SECS: u64 = 30; + +fn is_retryable_signal_error(stderr: &str) -> bool { + let lower = stderr.to_lowercase(); + lower.contains("timeout") + || lower.contains("timed out") + || lower.contains("connection refused") + || lower.contains("network") + || lower.contains("temporarily") + || lower.contains("try again") + || lower.contains("rate limit") +} + #[async_trait] impl CommsSender for SignalSender { fn channel(&self) -> CommsChannel { @@ -394,30 +615,63 @@ impl CommsSender for SignalSender { async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { let recipient = ¬ification.recipient; - if !is_valid_phone_number(recipient) { + if !is_valid_signal_username(recipient) { return Err(SendError::InvalidRecipient(format!( - "Invalid phone number format: {}", + "Invalid Signal username format: {}", recipient ))); } let subject = notification.subject.as_deref().unwrap_or("Notification"); let message = format!("{}\n\n{}", subject, notification.body); - let output = Command::new(&self.signal_cli_path) - .arg("-u") - .arg(&self.sender_number) - .arg("send") - .arg("-m") - .arg(&message) - .arg(recipient) - .output() - .await?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(SendError::ExternalService(format!( - "signal-cli failed: {}", - stderr - ))); + + let mut last_error = None; + for attempt in 0..MAX_RETRIES { + let cmd_future = Command::new(&self.signal_cli_path) + .arg("-u") + .arg(&self.sender_number) + .arg("send") + .arg("--username") + .arg(recipient) + .arg("-m") + .arg(&message) + .output(); + + let result = timeout(Duration::from_secs(SIGNAL_TIMEOUT_SECS), cmd_future).await; + + match result { + Ok(Ok(output)) if output.status.success() => return Ok(()), + Ok(Ok(output)) => { + let stderr = String::from_utf8_lossy(&output.stderr); + if is_retryable_signal_error(&stderr) && attempt < MAX_RETRIES - 1 { + last_error = Some(format!("signal-cli failed: {}", stderr)); + retry_delay(attempt).await; + continue; + } + return Err(SendError::ExternalService(format!( + "signal-cli failed: {}", + stderr + ))); + } + Ok(Err(e)) => { + if attempt < MAX_RETRIES - 1 { + last_error = Some(format!("signal-cli spawn failed: {}", e)); + retry_delay(attempt).await; + continue; + } + return Err(SendError::ProcessSpawn(e)); + } + Err(_) => { + if attempt < MAX_RETRIES - 1 { + last_error = Some("signal-cli timed out".to_string()); + retry_delay(attempt).await; + continue; + } + return Err(SendError::Timeout); + } + } } - Ok(()) + Err(SendError::MaxRetriesExceeded( + last_error.unwrap_or_else(|| "Unknown error".to_string()), + )) } } diff --git a/crates/tranquil-db-traits/src/error.rs b/crates/tranquil-db-traits/src/error.rs index 8cdc29c..4bcc053 100644 --- a/crates/tranquil-db-traits/src/error.rs +++ b/crates/tranquil-db-traits/src/error.rs @@ -20,6 +20,12 @@ pub enum DbError { #[error("Serialization error: {0}")] Serialization(String), + #[error("Ambiguous: {0}")] + Ambiguous(String), + + #[error("Resource busy, try again")] + LockContention, + #[error("Other database error: {0}")] Other(String), } diff --git a/crates/tranquil-db-traits/src/repo.rs b/crates/tranquil-db-traits/src/repo.rs index 26dae2c..4189631 100644 --- a/crates/tranquil-db-traits/src/repo.rs +++ b/crates/tranquil-db-traits/src/repo.rs @@ -488,6 +488,7 @@ pub trait RepoRepository: Send + Sync { user_id: Uuid, blocks: &[ImportBlock], records: &[ImportRecord], + expected_root_cid: Option<&CidLink>, ) -> Result<(), ImportRepoError>; async fn apply_commit( diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs index c34c150..7192673 100644 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -228,6 +228,12 @@ pub trait UserRepository: Send + Sync { async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError>; + async fn set_unverified_signal( + &self, + user_id: Uuid, + signal_username: &str, + ) -> Result<(), DbError>; + async fn set_unverified_telegram( &self, user_id: Uuid, @@ -243,6 +249,19 @@ pub trait UserRepository: Send + Sync { async fn get_telegram_chat_id(&self, user_id: Uuid) -> Result, DbError>; + async fn set_unverified_discord( + &self, + user_id: Uuid, + discord_username: &str, + ) -> Result<(), DbError>; + + async fn store_discord_user_id( + &self, + discord_username: &str, + discord_id: &str, + handle: Option<&str>, + ) -> Result, DbError>; + async fn get_verification_info( &self, did: &Did, @@ -261,7 +280,7 @@ pub trait UserRepository: Send + Sync { async fn verify_signal_channel( &self, user_id: Uuid, - signal_number: &str, + signal_username: &str, ) -> Result<(), DbError>; async fn set_email_verified_flag(&self, user_id: Uuid) -> Result<(), DbError>; @@ -598,7 +617,7 @@ pub struct UserCommsPrefs { pub preferred_locale: Option, pub telegram_chat_id: Option, pub discord_id: Option, - pub signal_number: Option, + pub signal_username: Option, } #[derive(Debug, Clone)] @@ -656,11 +675,12 @@ pub struct NotificationPrefs { pub email: String, pub preferred_channel: CommsChannel, pub discord_id: Option, + pub discord_username: Option, pub discord_verified: bool, pub telegram_username: Option, pub telegram_verified: bool, pub telegram_chat_id: Option, - pub signal_number: Option, + pub signal_username: Option, pub signal_verified: bool, } @@ -829,9 +849,9 @@ pub struct UserConfirmSignup { pub handle: Handle, pub email: Option, pub channel: CommsChannel, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, pub key_bytes: Vec, pub encryption_version: Option, } @@ -842,9 +862,9 @@ pub struct UserResendVerification { pub handle: Handle, pub email: Option, pub channel: CommsChannel, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, pub channel_verification: ChannelVerificationStatus, } @@ -932,9 +952,9 @@ pub struct CreatePasswordAccountInput { pub did: Did, pub password_hash: String, pub preferred_comms_channel: CommsChannel, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, pub deactivated_at: Option>, pub encrypted_key_bytes: Vec, pub encryption_version: i32, @@ -982,9 +1002,9 @@ pub struct CreatePasskeyAccountInput { pub email: String, pub did: Did, pub preferred_comms_channel: CommsChannel, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, pub setup_token_hash: String, pub setup_expires_at: DateTime, pub deactivated_at: Option>, @@ -1004,9 +1024,9 @@ pub struct CreateSsoAccountInput { pub email: Option, pub did: Did, pub preferred_comms_channel: CommsChannel, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, pub encrypted_key_bytes: Vec, pub encryption_version: i32, pub commit_cid: String, diff --git a/crates/tranquil-db/src/postgres/repo.rs b/crates/tranquil-db/src/postgres/repo.rs index 14d6644..a86ceeb 100644 --- a/crates/tranquil-db/src/postgres/repo.rs +++ b/crates/tranquil-db/src/postgres/repo.rs @@ -1131,6 +1131,7 @@ impl RepoRepository for PostgresRepoRepository { user_id: Uuid, blocks: &[ImportBlock], records: &[ImportRecord], + expected_root_cid: Option<&CidLink>, ) -> Result<(), ImportRepoError> { let mut tx = self .pool @@ -1153,8 +1154,15 @@ impl RepoRepository for PostgresRepoRepository { ImportRepoError::Database(e.to_string()) })?; - if repo.is_none() { - return Err(ImportRepoError::RepoNotFound); + let repo = match repo { + Some(r) => r, + None => return Err(ImportRepoError::RepoNotFound), + }; + + if let Some(expected) = expected_root_cid { + if repo.repo_root_cid.as_str() != expected.as_str() { + return Err(ImportRepoError::ConcurrentModification); + } } let block_chunks: Vec> = blocks diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs index 644ad15..ef33421 100644 --- a/crates/tranquil-db/src/postgres/user.rs +++ b/crates/tranquil-db/src/postgres/user.rs @@ -311,7 +311,7 @@ impl UserRepository for PostgresUserRepository { async fn get_comms_prefs(&self, user_id: Uuid) -> Result, DbError> { let row = sqlx::query!( - r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_number + r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale, telegram_chat_id, discord_id, signal_username FROM users WHERE id = $1"#, user_id ) @@ -325,7 +325,7 @@ impl UserRepository for PostgresUserRepository { preferred_locale: r.preferred_locale, telegram_chat_id: r.telegram_chat_id, discord_id: r.discord_id, - signal_number: r.signal_number, + signal_username: r.signal_username, })) } @@ -636,11 +636,12 @@ impl UserRepository for PostgresUserRepository { email, preferred_comms_channel as "preferred_channel!: CommsChannel", discord_id, + discord_username, discord_verified, telegram_username, telegram_verified, telegram_chat_id, - signal_number, + signal_username, signal_verified FROM users WHERE did = $1"#, did.as_str() @@ -652,11 +653,12 @@ impl UserRepository for PostgresUserRepository { email: r.email.unwrap_or_default(), preferred_channel: r.preferred_channel, discord_id: r.discord_id, + discord_username: r.discord_username, discord_verified: r.discord_verified, telegram_username: r.telegram_username, telegram_verified: r.telegram_verified, telegram_chat_id: r.telegram_chat_id, - signal_number: r.signal_number, + signal_username: r.signal_username, signal_verified: r.signal_verified, })) } @@ -697,7 +699,7 @@ impl UserRepository for PostgresUserRepository { async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError> { sqlx::query!( - "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", + "UPDATE users SET discord_id = NULL, discord_username = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", user_id ) .execute(&self.pool) @@ -719,7 +721,7 @@ impl UserRepository for PostgresUserRepository { async fn clear_signal(&self, user_id: Uuid) -> Result<(), DbError> { sqlx::query!( - "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", + "UPDATE users SET signal_username = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", user_id ) .execute(&self.pool) @@ -796,11 +798,11 @@ impl UserRepository for PostgresUserRepository { async fn verify_signal_channel( &self, user_id: Uuid, - signal_number: &str, + signal_username: &str, ) -> Result<(), DbError> { sqlx::query!( - "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", - signal_number, + "UPDATE users SET signal_username = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", + signal_username, user_id ) .execute(&self.pool) @@ -1548,7 +1550,7 @@ impl UserRepository for PostgresUserRepository { r#"SELECT u.id, u.did, u.handle, u.email, u.preferred_comms_channel as "channel: CommsChannel", - u.discord_id, u.telegram_username, u.signal_number, + u.discord_username, u.telegram_username, u.signal_username, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id @@ -1565,9 +1567,9 @@ impl UserRepository for PostgresUserRepository { handle: Handle::from(row.handle), email: row.email, channel: row.channel, - discord_id: row.discord_id, + discord_username: row.discord_username, telegram_username: row.telegram_username, - signal_number: row.signal_number, + signal_username: row.signal_username, key_bytes: row.key_bytes, encryption_version: row.encryption_version, }) @@ -1582,7 +1584,7 @@ impl UserRepository for PostgresUserRepository { r#"SELECT id, handle, email, preferred_comms_channel as "channel: CommsChannel", - discord_id, telegram_username, signal_number, + discord_username, telegram_username, signal_username, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1"#, @@ -1597,9 +1599,9 @@ impl UserRepository for PostgresUserRepository { handle: Handle::from(row.handle), email: row.email, channel: row.channel, - discord_id: row.discord_id, + discord_username: row.discord_username, telegram_username: row.telegram_username, - signal_number: row.signal_number, + signal_username: row.signal_username, channel_verification: ChannelVerificationStatus::new( row.email_verified, row.discord_verified, @@ -1662,13 +1664,13 @@ impl UserRepository for PostgresUserRepository { "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL" } CommsChannel::Discord => { - "SELECT COUNT(*) FROM users WHERE discord_id = $1 AND deactivated_at IS NULL" + "SELECT COUNT(*) FROM users WHERE LOWER(discord_username) = LOWER($1) AND deactivated_at IS NULL" } CommsChannel::Telegram => { "SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL" } CommsChannel::Signal => { - "SELECT COUNT(*) FROM users WHERE signal_number = $1 AND deactivated_at IS NULL" + "SELECT COUNT(*) FROM users WHERE signal_username = $1 AND deactivated_at IS NULL" } }; sqlx::query_scalar(query) @@ -2385,7 +2387,7 @@ impl UserRepository for PostgresUserRepository { r#"INSERT INTO users ( handle, email, did, password_hash, preferred_comms_channel, - discord_id, telegram_username, signal_number, + discord_username, telegram_username, signal_username, is_admin, deactivated_at, email_verified ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, FALSE) RETURNING id"#, ) @@ -2394,9 +2396,9 @@ impl UserRepository for PostgresUserRepository { .bind(input.did.as_str()) .bind(&input.password_hash) .bind(input.preferred_comms_channel) - .bind(&input.discord_id) + .bind(&input.discord_username) .bind(&input.telegram_username) - .bind(&input.signal_number) + .bind(&input.signal_username) .bind(is_first_user) .bind(input.deactivated_at) .fetch_one(&mut *tx) @@ -2652,7 +2654,7 @@ impl UserRepository for PostgresUserRepository { r#"INSERT INTO users ( handle, email, did, password_hash, password_required, preferred_comms_channel, - discord_id, telegram_username, signal_number, + discord_username, telegram_username, signal_username, recovery_token, recovery_token_expires_at, is_admin, deactivated_at ) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#, @@ -2661,9 +2663,9 @@ impl UserRepository for PostgresUserRepository { .bind(&input.email) .bind(input.did.as_str()) .bind(input.preferred_comms_channel) - .bind(&input.discord_id) + .bind(&input.discord_username) .bind(&input.telegram_username) - .bind(&input.signal_number) + .bind(&input.signal_username) .bind(&input.setup_token_hash) .bind(input.setup_expires_at) .bind(is_first_user) @@ -2816,7 +2818,7 @@ impl UserRepository for PostgresUserRepository { let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( r#"INSERT INTO users ( handle, email, did, password_hash, password_required, - preferred_comms_channel, discord_id, telegram_username, signal_number, + preferred_comms_channel, discord_username, telegram_username, signal_username, is_admin ) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#, ) @@ -2824,9 +2826,9 @@ impl UserRepository for PostgresUserRepository { .bind(&input.email) .bind(input.did.as_str()) .bind(input.preferred_comms_channel) - .bind(&input.discord_id) + .bind(&input.discord_username) .bind(&input.telegram_username) - .bind(&input.signal_number) + .bind(&input.signal_username) .bind(is_first_user) .fetch_one(&mut *tx) .await; @@ -3190,6 +3192,109 @@ impl UserRepository for PostgresUserRepository { Ok(()) } + async fn set_unverified_signal( + &self, + user_id: Uuid, + signal_username: &str, + ) -> Result<(), DbError> { + sqlx::query!( + r#"UPDATE users SET + signal_username = $1, + signal_verified = CASE WHEN LOWER(signal_username) = LOWER($1) THEN signal_verified ELSE FALSE END, + updated_at = NOW() + WHERE id = $2"#, + signal_username, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn set_unverified_discord( + &self, + user_id: Uuid, + discord_username: &str, + ) -> Result<(), DbError> { + sqlx::query!( + r#"UPDATE users SET + discord_username = $1, + discord_verified = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_verified ELSE FALSE END, + discord_id = CASE WHEN LOWER(discord_username) = LOWER($1) THEN discord_id ELSE NULL END, + updated_at = NOW() + WHERE id = $2"#, + discord_username, + user_id + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + Ok(()) + } + + async fn store_discord_user_id( + &self, + discord_username: &str, + discord_id: &str, + handle: Option<&str>, + ) -> Result, DbError> { + let result = match handle { + Some(h) => sqlx::query_scalar!( + "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND handle = $3 RETURNING id", + discord_username, + discord_id, + h + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?, + None => { + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; + + let matching: Vec = match sqlx::query_scalar!( + "SELECT id FROM users WHERE LOWER(discord_username) = LOWER($1) AND discord_username IS NOT NULL AND deactivated_at IS NULL FOR UPDATE NOWAIT", + discord_username + ) + .fetch_all(&mut *tx) + .await + { + Ok(ids) => ids, + Err(sqlx::Error::Database(ref db_err)) + if db_err.code().as_deref() == Some("55P03") => + { + return Err(DbError::LockContention); + } + Err(e) => return Err(map_sqlx_error(e)), + }; + + let result = match matching.len() { + 0 => None, + 1 => { + sqlx::query_scalar!( + "UPDATE users SET discord_id = $2, discord_verified = TRUE, updated_at = NOW() WHERE id = $1 RETURNING id", + matching[0], + discord_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(map_sqlx_error)? + } + _ => { + tx.rollback().await.ok(); + return Err(DbError::Ambiguous( + "Multiple accounts use this Discord username. Type: /start your-handle.example.com".to_string(), + )); + } + }; + + tx.commit().await.map_err(map_sqlx_error)?; + result + } + }; + Ok(result) + } + async fn store_telegram_chat_id( &self, telegram_username: &str, diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index d07436e..d0d5488 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -32,7 +32,9 @@ bytes = { workspace = true } chrono = { workspace = true } cid = { workspace = true } dotenvy = { workspace = true } +ed25519-dalek = { workspace = true } futures = { workspace = true } +hex = { workspace = true } futures-util = { workspace = true } governor = { workspace = true } hickory-resolver = { workspace = true } diff --git a/crates/tranquil-pds/src/api/discord_webhook.rs b/crates/tranquil-pds/src/api/discord_webhook.rs new file mode 100644 index 0000000..8fecb99 --- /dev/null +++ b/crates/tranquil-pds/src/api/discord_webhook.rs @@ -0,0 +1,301 @@ +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::Deserialize; +use serde_json::json; +use tracing::{debug, info, warn}; +use tranquil_types::Handle; + +use crate::comms::comms_repo; +use crate::state::AppState; +use crate::util::{discord_public_key, pds_hostname}; + +#[derive(Deserialize)] +struct Interaction { + #[serde(rename = "type")] + interaction_type: u8, + data: Option, + member: Option, + user: Option, +} + +#[derive(Deserialize)] +struct InteractionData { + name: Option, + options: Option>, +} + +#[derive(Deserialize)] +struct InteractionOption { + name: String, + value: serde_json::Value, +} + +#[derive(Deserialize)] +struct InteractionMember { + user: Option, +} + +#[derive(Deserialize)] +struct InteractionUser { + id: String, + username: Option, +} + +fn verify_signature( + public_key: &VerifyingKey, + timestamp: &str, + body: &str, + signature: &str, +) -> bool { + let sig_bytes = match hex::decode(signature) { + Ok(b) => b, + Err(_) => return false, + }; + let signature = match Signature::from_slice(&sig_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + let message = format!("{}{}", timestamp, body); + public_key.verify(message.as_bytes(), &signature).is_ok() +} + +fn parse_start_handle(options: Option<&[InteractionOption]>) -> Option { + options + .and_then(|opts| opts.iter().find(|o| o.name == "handle")) + .and_then(|o| o.value.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +pub async fn handle_discord_webhook( + State(state): State, + headers: axum::http::HeaderMap, + body: String, +) -> Response { + let public_key = match discord_public_key() { + Some(pk) => pk, + None => { + warn!("Discord webhook called but public key is not configured"); + return StatusCode::FORBIDDEN.into_response(); + } + }; + + let signature = headers + .get("x-signature-ed25519") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + let timestamp = headers + .get("x-signature-timestamp") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + if !verify_signature(public_key, timestamp, &body, signature) { + return StatusCode::UNAUTHORIZED.into_response(); + } + + let interaction: Interaction = match serde_json::from_str(&body) { + Ok(i) => i, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + + match interaction.interaction_type { + 1 => Json(json!({"type": 1})).into_response(), + 2 => handle_command(state, interaction).await, + other => { + debug!(interaction_type = other, "Received unknown Discord interaction type"); + StatusCode::OK.into_response() + } + } +} + +async fn handle_command(state: AppState, interaction: Interaction) -> Response { + let command_name = interaction + .data + .as_ref() + .and_then(|d| d.name.as_deref()) + .unwrap_or_default(); + + if command_name != "start" { + return Json(json!({ + "type": 4, + "data": {"content": "Unknown command", "flags": 64} + })) + .into_response(); + } + + let user = interaction + .member + .as_ref() + .and_then(|m| m.user.as_ref()) + .or(interaction.user.as_ref()); + + let (discord_user_id, discord_username) = match user { + Some(u) => (u.id.clone(), u.username.clone().unwrap_or_default()), + None => { + return Json(json!({ + "type": 4, + "data": {"content": "Could not identify user", "flags": 64} + })) + .into_response(); + } + }; + + let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref())); + + if let Some(ref h) = handle { + if Handle::new(h).is_err() { + return Json(json!({ + "type": 4, + "data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64} + })) + .into_response(); + } + } + + debug!( + discord_username = %discord_username, + discord_user_id = %discord_user_id, + handle = ?handle, + "Received /start from Discord user" + ); + + match state + .user_repo + .store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref()) + .await + { + Ok(Some(user_id)) => { + info!( + discord_username = %discord_username, + discord_user_id = %discord_user_id, + "Verified Discord user and stored user ID" + ); + if let Err(e) = comms_repo::enqueue_channel_verified( + state.user_repo.as_ref(), + state.infra_repo.as_ref(), + user_id, + "discord", + &discord_user_id, + pds_hostname(), + ) + .await + { + warn!(error = %e, "Failed to enqueue channel verified notification"); + } + Json(json!({ + "type": 4, + "data": {"content": "Verified", "flags": 64} + })) + .into_response() + } + Ok(None) => { + debug!( + discord_username = %discord_username, + "No matching user found for Discord username" + ); + Json(json!({ + "type": 4, + "data": {"content": "No account found with this Discord username. Set your Discord username in your PDS settings first.", "flags": 64} + })) + .into_response() + } + Err(tranquil_db_traits::DbError::Ambiguous(msg)) => { + debug!( + discord_username = %discord_username, + "Ambiguous Discord username match" + ); + Json(json!({ + "type": 4, + "data": {"content": msg, "flags": 64} + })) + .into_response() + } + Err(tranquil_db_traits::DbError::LockContention) => { + debug!( + discord_username = %discord_username, + "Lock contention during Discord verification" + ); + Json(json!({ + "type": 4, + "data": {"content": "Server busy, please try again in a moment.", "flags": 64} + })) + .into_response() + } + Err(e) => { + warn!( + discord_username = %discord_username, + error = %e, + "Failed to store Discord user ID" + ); + Json(json!({ + "type": 4, + "data": {"content": "Verification failed. Try again later.", "flags": 64} + })) + .into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_handle_from_options() { + let options = vec![InteractionOption { + name: "handle".to_string(), + value: serde_json::json!("lewis.buttercup.wizardry.systems"), + }]; + assert_eq!( + parse_start_handle(Some(&options)), + Some("lewis.buttercup.wizardry.systems".to_string()), + ); + } + + #[test] + fn parse_handle_no_options() { + assert_eq!(parse_start_handle(None), None); + } + + #[test] + fn parse_handle_empty_options() { + let options: Vec = vec![]; + assert_eq!(parse_start_handle(Some(&options)), None); + } + + #[test] + fn parse_handle_wrong_option_name() { + let options = vec![InteractionOption { + name: "other".to_string(), + value: serde_json::json!("test"), + }]; + assert_eq!(parse_start_handle(Some(&options)), None); + } + + #[test] + fn parse_handle_empty_string() { + let options = vec![InteractionOption { + name: "handle".to_string(), + value: serde_json::json!(""), + }]; + assert_eq!(parse_start_handle(Some(&options)), None); + } + + #[test] + fn parse_handle_whitespace_trimmed() { + let options = vec![InteractionOption { + name: "handle".to_string(), + value: serde_json::json!(" alice.example.com "), + }]; + assert_eq!( + parse_start_handle(Some(&options)), + Some("alice.example.com".to_string()), + ); + } +} diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index a1d0196..e1e19ba 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -409,7 +409,7 @@ impl ApiError { Some("Telegram username is required when using Telegram verification".to_string()) } Self::MissingSignalNumber => { - Some("Signal phone number is required when using Signal verification".to_string()) + Some("Signal username is required when using Signal verification".to_string()) } Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()), Self::SelfHostedDidWebDisabled => { diff --git a/crates/tranquil-pds/src/api/identity/account.rs b/crates/tranquil-pds/src/api/identity/account.rs index 73b4054..0b54371 100644 --- a/crates/tranquil-pds/src/api/identity/account.rs +++ b/crates/tranquil-pds/src/api/identity/account.rs @@ -35,9 +35,9 @@ pub struct CreateAccountInput { pub did_type: Option, pub signing_key: Option, pub verification_channel: Option, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, } #[derive(Serialize)] @@ -203,8 +203,16 @@ pub async fn create_account( Some(email) if !email.trim().is_empty() => email.trim().to_string(), _ => return ApiError::MissingEmail.into_response(), }, - "discord" => match &input.discord_id { - Some(id) if !id.trim().is_empty() => id.trim().to_string(), + "discord" => match &input.discord_username { + Some(username) if !username.trim().is_empty() => { + let clean = username.trim().to_lowercase(); + if !crate::api::validation::is_valid_discord_username(&clean) { + return ApiError::InvalidRequest( + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), + ).into_response(); + } + clean + } _ => return ApiError::MissingDiscordId.into_response(), }, "telegram" => match &input.telegram_username { @@ -219,8 +227,10 @@ pub async fn create_account( } _ => return ApiError::MissingTelegramUsername.into_response(), }, - "signal" => match &input.signal_number { - Some(number) if !number.trim().is_empty() => number.trim().to_string(), + "signal" => match &input.signal_username { + Some(username) if !username.trim().is_empty() => { + username.trim().trim_start_matches('@').to_lowercase() + } _ => return ApiError::MissingSignalNumber.into_response(), }, _ => return ApiError::InvalidVerificationChannel.into_response(), @@ -633,24 +643,23 @@ pub async fn create_account( did: unsafe { Did::new_unchecked(&did) }, password_hash, preferred_comms_channel, - discord_id: input - .discord_id + discord_username: input + .discord_username .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(String::from), + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()), telegram_username: input .telegram_username .as_deref() .map(|s| s.trim().trim_start_matches('@')) .filter(|s| !s.is_empty()) .map(String::from), - signal_number: input - .signal_number + signal_username: input + .signal_username .as_deref() - .map(|s| s.trim()) + .map(|s| s.trim().trim_start_matches('@')) .filter(|s| !s.is_empty()) - .map(String::from), + .map(|s| s.to_lowercase()), deactivated_at, encrypted_key_bytes, encryption_version: crate::config::ENCRYPTION_VERSION, @@ -750,6 +759,7 @@ pub async fn create_account( let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( + state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, verification_channel, diff --git a/crates/tranquil-pds/src/api/mod.rs b/crates/tranquil-pds/src/api/mod.rs index 493f1fc..3b8c8b3 100644 --- a/crates/tranquil-pds/src/api/mod.rs +++ b/crates/tranquil-pds/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod age_assurance; pub mod backup; pub mod delegation; +pub mod discord_webhook; pub mod error; pub mod identity; pub mod moderation; diff --git a/crates/tranquil-pds/src/api/notification_prefs.rs b/crates/tranquil-pds/src/api/notification_prefs.rs index f47dd03..b45d07d 100644 --- a/crates/tranquil-pds/src/api/notification_prefs.rs +++ b/crates/tranquil-pds/src/api/notification_prefs.rs @@ -17,11 +17,11 @@ use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType}; pub struct NotificationPrefsResponse { pub preferred_channel: CommsChannel, pub email: String, - pub discord_id: Option, + pub discord_username: Option, pub discord_verified: bool, pub telegram_username: Option, pub telegram_verified: bool, - pub signal_number: Option, + pub signal_username: Option, pub signal_verified: bool, } @@ -38,11 +38,11 @@ pub async fn get_notification_prefs( Ok(Json(NotificationPrefsResponse { preferred_channel: prefs.preferred_channel, email: prefs.email, - discord_id: prefs.discord_id, + discord_username: prefs.discord_username, discord_verified: prefs.discord_verified, telegram_username: prefs.telegram_username, telegram_verified: prefs.telegram_verified, - signal_number: prefs.signal_number, + signal_username: prefs.signal_username, signal_verified: prefs.signal_verified, }) .into_response()) @@ -120,9 +120,9 @@ pub async fn get_notification_history( pub struct UpdateNotificationPrefsInput { pub preferred_channel: Option, pub email: Option, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, } #[derive(Serialize)] @@ -172,9 +172,24 @@ pub async fn request_channel_verification( "https://{}/app/verify?token={}&identifier={}", hostname, encoded_token, encoded_identifier ); - let body = format!( - "Your verification code is: {}\n\nOr verify directly:\n{}", - formatted_token, verify_link + let prefs = state + .user_repo + .get_comms_prefs(user_id) + .await + .ok() + .flatten(); + let locale = prefs + .as_ref() + .and_then(|p| p.preferred_locale.as_deref()) + .unwrap_or("en"); + let strings = crate::comms::get_strings(locale); + let body = crate::comms::format_message( + strings.channel_verification_body, + &[("code", &formatted_token), ("verify_link", &verify_link)], + ); + let subject = crate::comms::format_message( + strings.channel_verification_subject, + &[("hostname", hostname)], ); let recipient = match comms_channel { tranquil_db_traits::CommsChannel::Telegram => state @@ -194,7 +209,7 @@ pub async fn request_channel_verification( comms_channel, tranquil_db_traits::CommsType::ChannelVerification, &recipient, - Some("Verify your channel"), + Some(&subject), &body, Some(json!({"code": formatted_token})), ) @@ -291,8 +306,9 @@ pub async fn update_notification_prefs( } } - if let Some(ref discord_id) = input.discord_id { - if discord_id.is_empty() { + if let Some(ref discord_username) = input.discord_username { + let discord_clean = discord_username.trim().to_lowercase(); + if discord_clean.is_empty() { if effective_channel == CommsChannel::Discord { return Err(ApiError::InvalidRequest( "Cannot remove Discord while it is the preferred notification channel".into(), @@ -303,13 +319,20 @@ pub async fn update_notification_prefs( .clear_discord(user_id) .await .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - info!(did = %auth.did, "Cleared Discord ID"); + info!(did = %auth.did, "Cleared Discord"); + } else if !crate::api::validation::is_valid_discord_username(&discord_clean) { + return Err(ApiError::InvalidRequest( + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)" + .into(), + )); } else { - request_channel_verification(&state, user_id, &auth.did, "discord", discord_id, None) + state + .user_repo + .set_unverified_discord(user_id, &discord_clean) .await - .map_err(|e| ApiError::InternalError(Some(e)))?; + .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; verification_required.push("discord".to_string()); - info!(did = %auth.did, "Requested Discord verification"); + info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username"); } } @@ -343,8 +366,9 @@ pub async fn update_notification_prefs( } } - if let Some(ref signal) = input.signal_number { - if signal.is_empty() { + if let Some(ref signal) = input.signal_username { + let signal_clean = signal.trim().trim_start_matches('@').to_lowercase(); + if signal_clean.is_empty() { if effective_channel == CommsChannel::Signal { return Err(ApiError::InvalidRequest( "Cannot remove Signal while it is the preferred notification channel".into(), @@ -355,13 +379,23 @@ pub async fn update_notification_prefs( .clear_signal(user_id) .await .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - info!(did = %auth.did, "Cleared Signal number"); + info!(did = %auth.did, "Cleared Signal username"); + } else if !crate::comms::is_valid_signal_username(&signal_clean) { + return Err(ApiError::InvalidRequest( + "Invalid Signal username. Must be 3-32 characters followed by .XX (e.g. username.01)" + .into(), + )); } else { - request_channel_verification(&state, user_id, &auth.did, "signal", signal, None) + state + .user_repo + .set_unverified_signal(user_id, &signal_clean) + .await + .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; + request_channel_verification(&state, user_id, &auth.did, "signal", &signal_clean, None) .await .map_err(|e| ApiError::InternalError(Some(e)))?; verification_required.push("signal".to_string()); - info!(did = %auth.did, "Requested Signal verification"); + info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username"); } } diff --git a/crates/tranquil-pds/src/api/repo/import.rs b/crates/tranquil-pds/src/api/repo/import.rs index 498d9a5..e1a4955 100644 --- a/crates/tranquil-pds/src/api/repo/import.rs +++ b/crates/tranquil-pds/src/api/repo/import.rs @@ -55,6 +55,14 @@ pub async fn import_repo( return Err(ApiError::AccountTakedown); } let user_id = user.id; + let expected_root_cid = state + .repo_repo + .get_repo_root_cid_by_user_id(user_id) + .await + .map_err(|e| { + error!("DB error fetching repo root: {:?}", e); + ApiError::InternalError(None) + })?; let (root, blocks) = match parse_car(&body).await { Ok((r, b)) => (r, b), Err(ImportError::InvalidRootCount) => { @@ -191,7 +199,16 @@ pub async fn import_repo( .and_then(|s| s.parse().ok()) .unwrap_or(DEFAULT_MAX_BLOCKS); let _write_lock = state.repo_write_locks.lock(user_id).await; - match apply_import(&state.repo_repo, user_id, root, blocks.clone(), max_blocks).await { + match apply_import( + &state.repo_repo, + user_id, + root, + blocks.clone(), + max_blocks, + expected_root_cid.as_ref(), + ) + .await + { Ok(import_result) => { info!( "Successfully imported {} records for user {}", diff --git a/crates/tranquil-pds/src/api/server/email.rs b/crates/tranquil-pds/src/api/server/email.rs index 986f002..f25f0f4 100644 --- a/crates/tranquil-pds/src/api/server/email.rs +++ b/crates/tranquil-pds/src/api/server/email.rs @@ -367,6 +367,7 @@ pub async fn update_email( crate::auth::verification_token::format_token_for_display(&verification_token); let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( + state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, "email", diff --git a/crates/tranquil-pds/src/api/server/meta.rs b/crates/tranquil-pds/src/api/server/meta.rs index 26384f9..8ef9c87 100644 --- a/crates/tranquil-pds/src/api/server/meta.rs +++ b/crates/tranquil-pds/src/api/server/meta.rs @@ -1,11 +1,11 @@ use crate::state::AppState; -use crate::util::{pds_hostname, telegram_bot_username}; +use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_bot_username}; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde_json::json; fn get_available_comms_channels() -> Vec<&'static str> { let mut channels = vec!["email"]; - if std::env::var("DISCORD_WEBHOOK_URL").is_ok() { + if std::env::var("DISCORD_BOT_TOKEN").is_ok() { channels.push("discord"); } if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { @@ -62,6 +62,12 @@ pub async fn describe_server() -> impl IntoResponse { "availableCommsChannels": get_available_comms_channels(), "selfHostedDidWebEnabled": is_self_hosted_did_web_enabled() }); + if let Some(bot_username) = discord_bot_username() { + response["discordBotUsername"] = json!(bot_username); + } + if let Some(app_id) = discord_app_id() { + response["discordAppId"] = json!(app_id); + } if let Some(bot_username) = telegram_bot_username() { response["telegramBotUsername"] = json!(bot_username); } diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index 461d120..dcf18e8 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -50,9 +50,9 @@ pub struct CreatePasskeyAccountInput { pub did_type: Option, pub signing_key: Option, pub verification_channel: Option, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, } #[derive(Serialize)] @@ -167,8 +167,16 @@ pub async fn create_passkey_account( Some(e) if !e.is_empty() => e.clone(), _ => return ApiError::MissingEmail.into_response(), }, - "discord" => match &input.discord_id { - Some(id) if !id.trim().is_empty() => id.trim().to_string(), + "discord" => match &input.discord_username { + Some(username) if !username.trim().is_empty() => { + let clean = username.trim().to_lowercase(); + if !crate::api::validation::is_valid_discord_username(&clean) { + return ApiError::InvalidRequest( + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), + ).into_response(); + } + clean + } _ => return ApiError::MissingDiscordId.into_response(), }, "telegram" => match &input.telegram_username { @@ -183,8 +191,10 @@ pub async fn create_passkey_account( } _ => return ApiError::MissingTelegramUsername.into_response(), }, - "signal" => match &input.signal_number { - Some(number) if !number.trim().is_empty() => number.trim().to_string(), + "signal" => match &input.signal_username { + Some(username) if !username.trim().is_empty() => { + username.trim().trim_start_matches('@').to_lowercase() + } _ => return ApiError::MissingSignalNumber.into_response(), }, _ => return ApiError::InvalidVerificationChannel.into_response(), @@ -409,24 +419,23 @@ pub async fn create_passkey_account( email: email.clone().unwrap_or_default(), did: did_typed.clone(), preferred_comms_channel, - discord_id: input - .discord_id + discord_username: input + .discord_username .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(String::from), + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()), telegram_username: input .telegram_username .as_deref() .map(|s| s.trim().trim_start_matches('@')) .filter(|s| !s.is_empty()) .map(String::from), - signal_number: input - .signal_number + signal_username: input + .signal_username .as_deref() - .map(|s| s.trim()) + .map(|s| s.trim().trim_start_matches('@')) .filter(|s| !s.is_empty()) - .map(String::from), + .map(|s| s.to_lowercase()), setup_token_hash, setup_expires_at, deactivated_at, @@ -501,6 +510,7 @@ pub async fn create_passkey_account( let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( + state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, verification_channel, diff --git a/crates/tranquil-pds/src/api/server/session.rs b/crates/tranquil-pds/src/api/server/session.rs index 98f540c..7d99f48 100644 --- a/crates/tranquil-pds/src/api/server/session.rs +++ b/crates/tranquil-pds/src/api/server/session.rs @@ -634,14 +634,14 @@ pub async fn confirm_signup( let (channel_str, identifier) = match row.channel { tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), tranquil_db_traits::CommsChannel::Discord => { - ("discord", row.discord_id.clone().unwrap_or_default()) + ("discord", row.discord_username.clone().unwrap_or_default()) } tranquil_db_traits::CommsChannel::Telegram => ( "telegram", row.telegram_username.clone().unwrap_or_default(), ), tranquil_db_traits::CommsChannel::Signal => { - ("signal", row.signal_number.clone().unwrap_or_default()) + ("signal", row.signal_username.clone().unwrap_or_default()) } }; @@ -786,14 +786,14 @@ pub async fn resend_verification( let (channel_str, recipient) = match row.channel { tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), tranquil_db_traits::CommsChannel::Discord => { - ("discord", row.discord_id.clone().unwrap_or_default()) + ("discord", row.discord_username.clone().unwrap_or_default()) } tranquil_db_traits::CommsChannel::Telegram => ( "telegram", row.telegram_username.clone().unwrap_or_default(), ), tranquil_db_traits::CommsChannel::Signal => { - ("signal", row.signal_number.clone().unwrap_or_default()) + ("signal", row.signal_username.clone().unwrap_or_default()) } }; @@ -804,6 +804,7 @@ pub async fn resend_verification( let hostname = pds_hostname(); if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( + state.user_repo.as_ref(), state.infra_repo.as_ref(), row.id, channel_str, diff --git a/crates/tranquil-pds/src/api/validation.rs b/crates/tranquil-pds/src/api/validation.rs index 5978934..015a37c 100644 --- a/crates/tranquil-pds/src/api/validation.rs +++ b/crates/tranquil-pds/src/api/validation.rs @@ -351,10 +351,17 @@ pub fn is_valid_email(email: &str) -> bool { pub fn is_valid_telegram_username(username: &str) -> bool { let clean = username.strip_prefix('@').unwrap_or(username); - (5..=32).contains(&clean.len()) - && clean + (5..=32).contains(&clean.len()) && clean.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +pub fn is_valid_discord_username(username: &str) -> bool { + (2..=32).contains(&username.len()) + && username .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_') + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.') + && !username.starts_with('.') + && !username.ends_with('.') + && !username.contains("..") } #[cfg(test)] @@ -534,4 +541,29 @@ mod tests { fn test_trimmed_whitespace() { assert!(is_valid_email(" user@example.com ")); } + + #[test] + fn test_valid_discord_usernames() { + assert!(is_valid_discord_username("ab")); + assert!(is_valid_discord_username("alice")); + assert!(is_valid_discord_username("user_name")); + assert!(is_valid_discord_username("user.name")); + assert!(is_valid_discord_username("user123")); + assert!(is_valid_discord_username("a_b.c_d")); + assert!(is_valid_discord_username("12345678901234567890123456789012")); + } + + #[test] + fn test_invalid_discord_usernames() { + assert!(!is_valid_discord_username("")); + assert!(!is_valid_discord_username("a")); + assert!(!is_valid_discord_username("Alice")); + assert!(!is_valid_discord_username("ALICE")); + assert!(!is_valid_discord_username("user-name")); + assert!(!is_valid_discord_username(".username")); + assert!(!is_valid_discord_username("username.")); + assert!(!is_valid_discord_username("user..name")); + assert!(!is_valid_discord_username("user name")); + assert!(!is_valid_discord_username("123456789012345678901234567890123")); + } } diff --git a/crates/tranquil-pds/src/comms/mod.rs b/crates/tranquil-pds/src/comms/mod.rs index 71be281..5e874c8 100644 --- a/crates/tranquil-pds/src/comms/mod.rs +++ b/crates/tranquil-pds/src/comms/mod.rs @@ -3,8 +3,8 @@ mod service; pub use tranquil_comms::{ CommsChannel, CommsSender, CommsStatus, CommsType, DEFAULT_LOCALE, DiscordSender, EmailSender, NewComms, NotificationStrings, QueuedComms, SendError, SignalSender, TelegramSender, - VALID_LOCALES, format_message, get_strings, is_valid_phone_number, mime_encode_header, - sanitize_header_value, validate_locale, + VALID_LOCALES, format_message, get_strings, is_valid_phone_number, is_valid_signal_username, + mime_encode_header, sanitize_header_value, validate_locale, }; pub use service::{CommsService, channel_display_name, repo as comms_repo}; diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index 4e76308..d989368 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -281,7 +281,7 @@ fn resolve_recipient( }) .unwrap_or_else(email_fallback), tranquil_db_traits::CommsChannel::Signal => prefs - .signal_number + .signal_username .as_ref() .filter(|n| !n.is_empty()) .map(|n| ResolvedRecipient { @@ -639,6 +639,7 @@ pub mod repo { } pub async fn enqueue_signup_verification( + user_repo: &dyn UserRepository, infra_repo: &dyn InfraRepository, user_id: Uuid, channel: &str, @@ -647,21 +648,19 @@ pub mod repo { hostname: &str, ) -> Result { let comms_channel = channel_from_str(channel); - let strings = get_strings("en"); - let (verify_page, verify_link) = match comms_channel { - tranquil_db_traits::CommsChannel::Email => { - let encoded_email = urlencoding::encode(recipient); - let encoded_token = urlencoding::encode(code); - ( - format!("https://{}/app/verify", hostname), - format!( - "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_email - ), - ) - } - _ => (String::new(), String::new()), - }; + let prefs = user_repo.get_comms_prefs(user_id).await.ok().flatten(); + let locale = prefs + .as_ref() + .and_then(|p| p.preferred_locale.as_deref()) + .unwrap_or("en"); + let strings = get_strings(locale); + let encoded_token = urlencoding::encode(code); + let encoded_recipient = urlencoding::encode(recipient); + let verify_page = format!("https://{}/app/verify", hostname); + let verify_link = format!( + "https://{}/app/verify?token={}&identifier={}", + hostname, encoded_token, encoded_recipient + ); let body = format_message( strings.signup_verification_body, &[ @@ -671,20 +670,17 @@ pub mod repo { ("verify_link", &verify_link), ], ); - let subject = match comms_channel { - tranquil_db_traits::CommsChannel::Email => Some(format_message( - strings.signup_verification_subject, - &[("hostname", hostname)], - )), - _ => None, - }; + let subject = format_message( + strings.signup_verification_subject, + &[("hostname", hostname)], + ); infra_repo .enqueue_comms( Some(user_id), comms_channel, CommsType::EmailVerification, recipient, - subject.as_deref(), + Some(&subject), &body, None, ) diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index f4b354e..62f3efd 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -645,7 +645,13 @@ pub fn app(state: AppState) -> Router { .route("/u/{handle}/did.json", get(api::identity::user_did_doc)) .route( "/webhook/telegram", - post(api::telegram_webhook::handle_telegram_webhook), + post(api::telegram_webhook::handle_telegram_webhook) + .layer(DefaultBodyLimit::max(64 * 1024)), + ) + .route( + "/webhook/discord", + post(api::discord_webhook::handle_discord_webhook) + .layer(DefaultBodyLimit::max(64 * 1024)), ) .layer(DefaultBodyLimit::max(util::get_max_blob_size())) .layer(middleware::from_fn(metrics::metrics_middleware)) diff --git a/crates/tranquil-pds/src/main.rs b/crates/tranquil-pds/src/main.rs index 5a97615..73bbbb9 100644 --- a/crates/tranquil-pds/src/main.rs +++ b/crates/tranquil-pds/src/main.rs @@ -64,6 +64,7 @@ async fn run() -> Result<(), Box> { }); let mut comms_service = CommsService::new(state.infra_repo.clone()); + let mut deferred_discord_endpoint: Option<(DiscordSender, String, String)> = None; if let Some(email_sender) = EmailSender::from_env() { info!("Email comms enabled"); @@ -74,6 +75,46 @@ async fn run() -> Result<(), Box> { if let Some(discord_sender) = DiscordSender::from_env() { info!("Discord comms enabled"); + match discord_sender.resolve_bot_username().await { + Ok(username) => { + info!(bot_username = %username, "Resolved Discord bot username"); + tranquil_pds::util::set_discord_bot_username(username); + } + Err(e) => { + warn!("Failed to resolve Discord bot username: {}", e); + } + } + match discord_sender.resolve_application_info().await { + Ok((app_id, verify_key)) => { + info!(app_id = %app_id, "Resolved Discord application info"); + tranquil_pds::util::set_discord_app_id(app_id.clone()); + match hex::decode(&verify_key) + .ok() + .and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()) + .and_then(|bytes| ed25519_dalek::VerifyingKey::from_bytes(&bytes).ok()) + { + Some(public_key) => { + tranquil_pds::util::set_discord_public_key(public_key); + info!("Discord Ed25519 public key loaded"); + let hostname = std::env::var("PDS_HOSTNAME") + .unwrap_or_else(|_| "localhost".to_string()); + let webhook_url = format!("https://{}/webhook/discord", hostname); + match discord_sender.register_slash_command(&app_id).await { + Ok(()) => info!("Discord /start slash command registered"), + Err(e) => warn!("Failed to register Discord slash command: {}", e), + } + deferred_discord_endpoint = + Some((discord_sender.clone(), app_id, webhook_url)); + } + None => { + warn!("Failed to parse Discord verify_key as Ed25519 public key"); + } + } + } + Err(e) => { + warn!("Failed to resolve Discord application info: {}", e); + } + } comms_service = comms_service.register_sender(discord_sender); } @@ -172,9 +213,27 @@ async fn run() -> Result<(), Box> { .await .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?; - let server_result = axum::serve(listener, app) - .with_graceful_shutdown(shutdown.clone().cancelled_owned()) - .await; + let server_handle = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(shutdown.clone().cancelled_owned()) + .await + }); + + if let Some((sender, app_id, webhook_url)) = deferred_discord_endpoint { + tokio::spawn(async move { + match sender + .set_interactions_endpoint(&app_id, &webhook_url) + .await + { + Ok(()) => info!(url = %webhook_url, "Discord interactions endpoint registered"), + Err(e) => warn!("Failed to set Discord interactions endpoint: {}", e), + } + }); + } + + let server_result = server_handle + .await + .map_err(|e| format!("Server task panicked: {}", e))?; comms_handle.await.ok(); diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs index 0f636b0..ae262fd 100644 --- a/crates/tranquil-pds/src/sso/endpoints.rs +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -817,9 +817,9 @@ pub struct CompleteRegistrationInput { pub email: Option, pub invite_code: Option, pub verification_channel: Option, - pub discord_id: Option, + pub discord_username: Option, pub telegram_username: Option, - pub signal_number: Option, + pub signal_username: Option, pub did_type: Option, pub did: Option, } @@ -894,8 +894,16 @@ pub async fn complete_registration( _ => return Err(ApiError::MissingEmail), } } - "discord" => match &input.discord_id { - Some(id) if !id.trim().is_empty() => id.trim().to_string(), + "discord" => match &input.discord_username { + Some(username) if !username.trim().is_empty() => { + let clean = username.trim().to_lowercase(); + if !crate::api::validation::is_valid_discord_username(&clean) { + return Err(ApiError::InvalidRequest( + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), + )); + } + clean + } _ => return Err(ApiError::MissingDiscordId), }, "telegram" => match &input.telegram_username { @@ -910,8 +918,10 @@ pub async fn complete_registration( } _ => return Err(ApiError::MissingTelegramUsername), }, - "signal" => match &input.signal_number { - Some(number) if !number.trim().is_empty() => number.trim().to_string(), + "signal" => match &input.signal_username { + Some(username) if !username.trim().is_empty() => { + username.trim().trim_start_matches('@').to_lowercase() + } _ => return Err(ApiError::MissingSignalNumber), }, _ => return Err(ApiError::InvalidVerificationChannel), @@ -1104,20 +1114,20 @@ pub async fn complete_registration( email: email.clone(), did: did_typed.clone(), preferred_comms_channel, - discord_id: input - .discord_id + discord_username: input + .discord_username .clone() - .map(|s| s.trim().to_string()) + .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()), telegram_username: input .telegram_username .clone() .map(|s| s.trim().trim_start_matches('@').to_string()) .filter(|s| !s.is_empty()), - signal_number: input - .signal_number + signal_username: input + .signal_username .clone() - .map(|s| s.trim().to_string()) + .map(|s| s.trim().trim_start_matches('@').to_lowercase()) .filter(|s| !s.is_empty()), encrypted_key_bytes: encrypted_key_bytes.clone(), encryption_version: crate::config::ENCRYPTION_VERSION, @@ -1354,6 +1364,7 @@ pub async fn complete_registration( let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( + state.user_repo.as_ref(), state.infra_repo.as_ref(), uid, verification_channel, diff --git a/crates/tranquil-pds/src/sync/import.rs b/crates/tranquil-pds/src/sync/import.rs index e44cb87..3ccc38e 100644 --- a/crates/tranquil-pds/src/sync/import.rs +++ b/crates/tranquil-pds/src/sync/import.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use thiserror::Error; use tracing::debug; use tranquil_db::{ImportBlock, ImportRecord, ImportRepoError, RepoRepository}; +use tranquil_types::CidLink; use uuid::Uuid; #[derive(Error, Debug)] @@ -323,6 +324,7 @@ pub async fn apply_import( root: Cid, blocks: HashMap, max_blocks: usize, + expected_root_cid: Option<&CidLink>, ) -> Result { if blocks.len() > max_blocks { return Err(ImportError::SizeLimitExceeded); @@ -364,7 +366,7 @@ pub async fn apply_import( .collect(); repo_repo - .import_repo_data(user_id, &import_blocks, &import_records) + .import_repo_data(user_id, &import_blocks, &import_records, expected_root_cid) .await?; debug!( diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index 463609a..1ee401a 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -14,6 +14,9 @@ const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024; static MAX_BLOB_SIZE: OnceLock = OnceLock::new(); static PDS_HOSTNAME: OnceLock = OnceLock::new(); static PDS_HOSTNAME_WITHOUT_PORT: OnceLock = OnceLock::new(); +static DISCORD_BOT_USERNAME: OnceLock = OnceLock::new(); +static DISCORD_PUBLIC_KEY: OnceLock = OnceLock::new(); +static DISCORD_APP_ID: OnceLock = OnceLock::new(); static TELEGRAM_BOT_USERNAME: OnceLock = OnceLock::new(); pub fn get_max_blob_size() -> usize { @@ -105,6 +108,30 @@ pub fn pds_hostname_without_port() -> &'static str { }) } +pub fn set_discord_bot_username(username: String) { + DISCORD_BOT_USERNAME.set(username).ok(); +} + +pub fn discord_bot_username() -> Option<&'static str> { + DISCORD_BOT_USERNAME.get().map(|s| s.as_str()) +} + +pub fn set_discord_public_key(key: ed25519_dalek::VerifyingKey) { + DISCORD_PUBLIC_KEY.set(key).ok(); +} + +pub fn discord_public_key() -> Option<&'static ed25519_dalek::VerifyingKey> { + DISCORD_PUBLIC_KEY.get() +} + +pub fn set_discord_app_id(app_id: String) { + DISCORD_APP_ID.set(app_id).ok(); +} + +pub fn discord_app_id() -> Option<&'static str> { + DISCORD_APP_ID.get().map(|s| s.as_str()) +} + pub fn set_telegram_bot_username(username: String) { TELEGRAM_BOT_USERNAME.set(username).ok(); } diff --git a/crates/tranquil-pds/tests/account_notifications.rs b/crates/tranquil-pds/tests/account_notifications.rs index 19c5b09..52025eb 100644 --- a/crates/tranquil-pds/tests/account_notifications.rs +++ b/crates/tranquil-pds/tests/account_notifications.rs @@ -1,7 +1,6 @@ mod common; use common::{base_url, client, create_account_and_login, get_test_db_pool}; use serde_json::{Value, json}; -use sqlx::Row; #[tokio::test] async fn test_get_notification_history() { @@ -51,10 +50,10 @@ async fn test_get_notification_history() { async fn test_verify_channel_discord() { let client = client(); let base = base_url().await; - let (token, did) = create_account_and_login(&client).await; + let (token, _did) = create_account_and_login(&client).await; let prefs = json!({ - "discordId": "123456789" + "discordUsername": "testuser123" }); let resp = client .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) @@ -72,42 +71,6 @@ async fn test_verify_channel_discord() { .contains(&json!("discord")) ); - let pool = get_test_db_pool().await; - let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE did = $1") - .bind(&did) - .fetch_one(pool) - .await - .expect("User not found"); - - let row = sqlx::query( - "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1", - ) - .bind(user_id) - .fetch_one(pool) - .await - .expect("Verification code not found"); - - let metadata: Option = row.get("metadata"); - let code = metadata - .as_ref() - .and_then(|m| m.get("code")) - .and_then(|c| c.as_str()) - .expect("No code in metadata"); - - let input = json!({ - "channel": "discord", - "identifier": "123456789", - "code": code - }); - let resp = client - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) - .header("Authorization", format!("Bearer {}", token)) - .json(&input) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let resp = client .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) .header("Authorization", format!("Bearer {}", token)) @@ -115,8 +78,8 @@ async fn test_verify_channel_discord() { .await .unwrap(); let body: Value = resp.json().await.unwrap(); - assert_eq!(body["discordVerified"], true); - assert_eq!(body["discordId"], "123456789"); + assert_eq!(body["discordVerified"], false); + assert_eq!(body["discordUsername"], "testuser123"); } #[tokio::test] diff --git a/crates/tranquil-pds/tests/security_fixes.rs b/crates/tranquil-pds/tests/security_fixes.rs index a3ff09c..b3ebe59 100644 --- a/crates/tranquil-pds/tests/security_fixes.rs +++ b/crates/tranquil-pds/tests/security_fixes.rs @@ -1,5 +1,5 @@ mod common; -use tranquil_pds::comms::{SendError, is_valid_phone_number, sanitize_header_value}; +use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value}; use tranquil_pds::image::{ImageError, ImageProcessor}; #[test] @@ -80,6 +80,38 @@ fn test_phone_number_validation() { } } +#[test] +fn test_signal_username_validation() { + assert!(is_valid_signal_username("alice.01")); + assert!(is_valid_signal_username("bob_smith.99")); + assert!(is_valid_signal_username("user123.42")); + assert!(is_valid_signal_username("lu1.01")); + assert!(is_valid_signal_username("abc.00")); + assert!(is_valid_signal_username("a_very_long_username_here.55")); + + assert!(!is_valid_signal_username("alice")); + assert!(!is_valid_signal_username("alice.1")); + assert!(!is_valid_signal_username("alice.001")); + assert!(!is_valid_signal_username(".01")); + assert!(!is_valid_signal_username("ab.01")); + assert!(!is_valid_signal_username("")); + assert!(!is_valid_signal_username("1alice.01")); + assert!(!is_valid_signal_username("alice!.01")); + assert!(!is_valid_signal_username("alice .01")); + + assert!(!is_valid_signal_username("a".repeat(33).as_str())); + + ["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"] + .iter() + .for_each(|malicious| { + assert!( + !is_valid_signal_username(malicious), + "Command injection '{}' should be rejected", + malicious + ); + }); +} + #[test] fn test_image_file_size_limits() { let processor = ImageProcessor::new(); diff --git a/crates/tranquil-pds/tests/sso.rs b/crates/tranquil-pds/tests/sso.rs index d05346f..915457c 100644 --- a/crates/tranquil-pds/tests/sso.rs +++ b/crates/tranquil-pds/tests/sso.rs @@ -1035,7 +1035,7 @@ async fn test_sso_complete_registration_multichannel_discord() { "token": token, "handle": handle_prefix, "verification_channel": "discord", - "discord_id": discord_id + "discord_username": discord_id })) .send() .await @@ -1054,7 +1054,7 @@ async fn test_sso_complete_registration_multichannel_discord() { let did_str = body["did"].as_str().unwrap(); let user = sqlx::query!( - r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_id FROM users WHERE did = $1"#, + r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_username FROM users WHERE did = $1"#, did_str, ) .fetch_one(pool) @@ -1062,7 +1062,7 @@ async fn test_sso_complete_registration_multichannel_discord() { .unwrap(); assert_eq!(user.preferred_comms_channel, "discord"); - assert_eq!(user.discord_id, Some(discord_id.to_string())); + assert_eq!(user.discord_username, Some(discord_id.to_string())); } #[tokio::test] diff --git a/crates/tranquil-pds/tests/whole_story.rs b/crates/tranquil-pds/tests/whole_story.rs index 5c0449c..968ca4c 100644 --- a/crates/tranquil-pds/tests/whole_story.rs +++ b/crates/tranquil-pds/tests/whole_story.rs @@ -1948,7 +1948,14 @@ async fn test_concurrent_import_and_writes() { .expect("Import request failed"); let status = res.status(); let body: Value = res.json().await.unwrap_or_default(); - assert_eq!(status, StatusCode::OK, "Import should succeed: {:?}", body); + let is_concurrent_modification = status == StatusCode::BAD_REQUEST + && body.get("error").and_then(|e| e.as_str()) == Some("InvalidSwap"); + assert!( + status == StatusCode::OK || is_concurrent_modification, + "Import should succeed or fail with InvalidSwap due to concurrent writes: {:?}", + body + ); + status == StatusCode::OK } }; @@ -1987,7 +1994,7 @@ async fn test_concurrent_import_and_writes() { }) .collect(); - tokio::join!(import_future, join_all(write_futures)); + let (import_succeeded, _) = tokio::join!(import_future, join_all(write_futures)); let final_posts = client .get(format!("{}/xrpc/com.atproto.repo.listRecords", base)) @@ -2003,11 +2010,17 @@ async fn test_concurrent_import_and_writes() { let final_body: Value = final_posts.json().await.unwrap(); let record_count = final_body["records"].as_array().unwrap().len(); - let min_expected = write_count; + let min_expected = if import_succeeded { + write_count + 1 + } else { + write_count + }; assert!( record_count >= min_expected, - "Expected at least {} records (from writes), got {} (import may also contribute records)", + "Expected at least {} records (writes={}, import_succeeded={}), got {}", min_expected, + write_count, + import_succeeded, record_count ); } diff --git a/frontend/src/components/dashboard/CommsContent.svelte b/frontend/src/components/dashboard/CommsContent.svelte index b13546f..6801e10 100644 --- a/frontend/src/components/dashboard/CommsContent.svelte +++ b/frontend/src/components/dashboard/CommsContent.svelte @@ -18,16 +18,18 @@ let preferredChannel = $state('email') let availableCommsChannels = $state(['email']) let telegramBotUsername = $state(undefined) + let discordBotUsername = $state(undefined) + let discordAppId = $state(undefined) let email = $state('') - let discordId = $state('') + let discordUsername = $state('') let discordVerified = $state(false) let telegramUsername = $state('') let telegramVerified = $state(false) - let signalNumber = $state('') + let signalUsername = $state('') let signalVerified = $state(false) - let savedDiscordId = $state('') + let savedDiscordUsername = $state('') let savedTelegramUsername = $state('') - let savedSignalNumber = $state('') + let savedSignalUsername = $state('') let verifyingChannel = $state(null) let verificationCode = $state('') let historyLoading = $state(true) @@ -57,17 +59,19 @@ ]) preferredChannel = prefs.preferredChannel email = prefs.email - discordId = prefs.discordId ?? '' + discordUsername = prefs.discordUsername ?? '' discordVerified = prefs.discordVerified telegramUsername = prefs.telegramUsername ?? '' telegramVerified = prefs.telegramVerified - signalNumber = prefs.signalNumber ?? '' + signalUsername = prefs.signalUsername ?? '' signalVerified = prefs.signalVerified - savedDiscordId = discordId + savedDiscordUsername = discordUsername savedTelegramUsername = telegramUsername - savedSignalNumber = signalNumber + savedSignalUsername = signalUsername availableCommsChannels = serverInfo.availableCommsChannels ?? ['email'] telegramBotUsername = serverInfo.telegramBotUsername + discordBotUsername = serverInfo.discordBotUsername + discordAppId = serverInfo.discordAppId } catch (e) { toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad')) } finally { @@ -81,15 +85,15 @@ try { const result = await api.updateNotificationPrefs(session.accessJwt, { preferredChannel, - discordId: discordId !== savedDiscordId ? discordId : undefined, + discordUsername: discordUsername !== savedDiscordUsername ? discordUsername : undefined, telegramUsername: telegramUsername !== savedTelegramUsername ? telegramUsername : undefined, - signalNumber: signalNumber !== savedSignalNumber ? signalNumber : undefined, + signalUsername: signalUsername !== savedSignalUsername ? signalUsername : undefined, }) await refreshSession() toast.success($_('comms.preferencesSaved')) - savedDiscordId = discordId + savedDiscordUsername = discordUsername savedTelegramUsername = telegramUsername - savedSignalNumber = signalNumber + savedSignalUsername = signalUsername const channelToVerify = result.verificationRequired?.find( (ch: string) => ch === 'discord' || ch === 'telegram' || ch === 'signal' ) @@ -108,9 +112,9 @@ if (!verificationCode) return const identifierMap: Record = { - discord: discordId, + discord: discordUsername, telegram: telegramUsername, - signal: signalNumber + signal: signalUsername } const identifier = identifierMap[channel] if (!identifier) return @@ -190,9 +194,9 @@ if (!isChannelAvailableOnServer(channelId)) return false if (channelId === 'email') return true const hasIdentifier: Record = { - discord: !!discordId, + discord: !!discordUsername, telegram: !!telegramUsername, - signal: !!signalNumber + signal: !!signalUsername } return hasIdentifier[channelId] ?? false } @@ -245,8 +249,8 @@ {#if isChannelAvailableOnServer('discord')}
- - {#if discordId} + + {#if discordUsername} {preferredChannel === 'discord' && discordVerified ? $_('comms.primary') : discordVerified ? $_('comms.verified') : $_('comms.notVerified')} @@ -256,23 +260,22 @@ checkChannelInUse('discord', discordId)} - placeholder={$_('register.discordIdPlaceholder')} + bind:value={discordUsername} + onblur={() => checkChannelInUse('discord', discordUsername)} + placeholder={$_('register.discordUsernamePlaceholder')} disabled={saving} /> - {#if discordId && discordId === savedDiscordId && !discordVerified} - - {/if}
{#if discordInUse}

{$_('comms.discordInUseWarning')}

{/if} - {#if verifyingChannel === 'discord'} -
- - - + {#if discordUsername && discordUsername === savedDiscordUsername && !discordVerified && discordBotUsername} + {@const encodedHandle = session.handle.replaceAll('.', '_')} +
+ {#if discordAppId} + {$_('comms.discordOpenLink')} + {/if} + {$_('comms.discordStartBot', { values: { botUsername: discordBotUsername, handle: session.handle } })}
{/if}
@@ -314,8 +317,8 @@ {#if isChannelAvailableOnServer('signal')}
- - {#if signalNumber} + + {#if signalUsername} {preferredChannel === 'signal' && signalVerified ? $_('comms.primary') : signalVerified ? $_('comms.verified') : $_('comms.notVerified')} @@ -324,13 +327,13 @@
checkChannelInUse('signal', signalNumber)} - placeholder={$_('register.signalNumberPlaceholder')} + type="text" + bind:value={signalUsername} + onblur={() => checkChannelInUse('signal', signalUsername)} + placeholder={$_('register.signalUsernamePlaceholder')} disabled={saving} /> - {#if signalNumber && signalNumber === savedSignalNumber && !signalVerified} + {#if signalUsername && signalUsername === savedSignalUsername && !signalVerified} {/if}
@@ -533,6 +536,18 @@ color: var(--text-primary); } + .discord-verify-prompt { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: var(--accent-bg, var(--bg-card)); + border: 1px solid var(--accent, var(--border-color)); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--text-primary); + } + .manual-hint { font-size: var(--text-xs); color: var(--text-secondary); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ebac18d..0cae1ad 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -404,9 +404,9 @@ export const api = { did: params.did, signingKey: params.signingKey, verificationChannel: params.verificationChannel, - discordId: params.discordId, + discordUsername: params.discordUsername, telegramUsername: params.telegramUsername, - signalNumber: params.signalNumber, + signalUsername: params.signalUsername, }), }); const data = await response.json(); @@ -656,9 +656,9 @@ export const api = { updateNotificationPrefs(token: AccessToken, prefs: { preferredChannel?: string; - discordId?: string; + discordUsername?: string; telegramUsername?: string; - signalNumber?: string; + signalUsername?: string; }): Promise { return xrpc("_account.updateNotificationPrefs", { method: "POST", @@ -1133,9 +1133,9 @@ export const api = { did?: Did; signingKey?: string; verificationChannel?: VerificationChannel; - discordId?: string; + discordUsername?: string; telegramUsername?: string; - signalNumber?: string; + signalUsername?: string; }, byodToken?: string): Promise { const url = `${API_BASE}/_account.createPasskeyAccount`; const headers: Record = { @@ -1854,9 +1854,9 @@ export const typedApi = { token: AccessToken, prefs: { preferredChannel?: string; - discordId?: string; + discordUsername?: string; telegramUsername?: string; - signalNumber?: string; + signalUsername?: string; }, ): Promise> { return xrpcResult("_account.updateNotificationPrefs", { diff --git a/frontend/src/lib/registration/VerificationStep.svelte b/frontend/src/lib/registration/VerificationStep.svelte index 8aba59b..9270d23 100644 --- a/frontend/src/lib/registration/VerificationStep.svelte +++ b/frontend/src/lib/registration/VerificationStep.svelte @@ -14,25 +14,31 @@ let resending = $state(false) let resendMessage = $state(null) let telegramBotUsername = $state(undefined) + let discordBotUsername = $state(undefined) + let discordAppId = $state(undefined) let pollingInterval: ReturnType | null = null const isTelegram = $derived(flow.info.verificationChannel === 'telegram') + const isDiscord = $derived(flow.info.verificationChannel === 'discord') + const isBotVerified = $derived(isTelegram || isDiscord) onMount(async () => { - if (isTelegram) { + if (isTelegram || isDiscord) { try { const serverInfo = await api.describeServer() telegramBotUsername = serverInfo.telegramBotUsername + discordBotUsername = serverInfo.discordBotUsername + discordAppId = serverInfo.discordAppId } catch { } } }) $effect(() => { - if (flow.state.step === 'verify' && flow.account && (isTelegram || !verificationCode.trim())) { + if (flow.state.step === 'verify' && flow.account && (isBotVerified || !verificationCode.trim())) { pollingInterval = setInterval(async () => { - if (!isTelegram && verificationCode.trim()) return + if (!isBotVerified && verificationCode.trim()) return const advanced = await flow.checkAndAdvanceIfVerified() if (advanced && pollingInterval) { clearInterval(pollingInterval) @@ -105,6 +111,13 @@ or send /start {handle} to @{telegramBotUsername} manually.

Waiting for verification...

+ {:else if isDiscord && discordAppId} + {@const handle = flow.account?.handle ?? `${flow.info.handle.trim()}.${flow.state.pdsHostname}`} +

+ Open Discord to verify, + or send /start {handle} to {discordBotUsername ?? 'the bot'} manually. +

+

Waiting for verification...

{:else}

We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}. diff --git a/frontend/src/lib/registration/flow.svelte.ts b/frontend/src/lib/registration/flow.svelte.ts index 8740053..649c807 100644 --- a/frontend/src/lib/registration/flow.svelte.ts +++ b/frontend/src/lib/registration/flow.svelte.ts @@ -56,9 +56,9 @@ export function createRegistrationFlow( didType: "plc", externalDid: "", verificationChannel: "email", - discordId: "", + discordUsername: "", telegramUsername: "", - signalNumber: "", + signalUsername: "", }, externalDidWeb: { keyMode: "reserved", @@ -252,9 +252,9 @@ export function createRegistrationFlow( ? state.externalDidWeb.reservedSigningKey : undefined, verificationChannel: state.info.verificationChannel, - discordId: state.info.discordId?.trim() || undefined, + discordUsername: state.info.discordUsername?.trim() || undefined, telegramUsername: state.info.telegramUsername?.trim() || undefined, - signalNumber: state.info.signalNumber?.trim() || undefined, + signalUsername: state.info.signalUsername?.trim() || undefined, }, byodToken); state.account = { @@ -305,9 +305,9 @@ export function createRegistrationFlow( ? state.externalDidWeb.reservedSigningKey : undefined, verificationChannel: state.info.verificationChannel, - discordId: state.info.discordId?.trim() || undefined, + discordUsername: state.info.discordUsername?.trim() || undefined, telegramUsername: state.info.telegramUsername?.trim() || undefined, - signalNumber: state.info.signalNumber?.trim() || undefined, + signalUsername: state.info.signalUsername?.trim() || undefined, }, byodToken); state.account = { diff --git a/frontend/src/lib/registration/types.ts b/frontend/src/lib/registration/types.ts index b51ef15..029e9a1 100644 --- a/frontend/src/lib/registration/types.ts +++ b/frontend/src/lib/registration/types.ts @@ -28,9 +28,9 @@ export interface RegistrationInfo { didType: DidType; externalDid?: string; verificationChannel: VerificationChannel; - discordId?: string; + discordUsername?: string; telegramUsername?: string; - signalNumber?: string; + signalUsername?: string; } export interface ExternalDidWebState { diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index a2b924b..3ceef26 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -185,9 +185,9 @@ export interface CreateAccountParams { did?: string; signingKey?: string; verificationChannel?: VerificationChannel; - discordId?: string; + discordUsername?: string; telegramUsername?: string; - signalNumber?: string; + signalUsername?: string; } export interface CreateAccountResult { @@ -254,11 +254,11 @@ export interface ListReposResponse { export interface NotificationPrefs { preferredChannel: VerificationChannel; email: EmailAddress; - discordId: string | null; + discordUsername: string | null; discordVerified: boolean; telegramUsername: string | null; telegramVerified: boolean; - signalNumber: string | null; + signalUsername: string | null; signalVerified: boolean; } diff --git a/frontend/src/lib/types/schemas.ts b/frontend/src/lib/types/schemas.ts index 95dc358..7c69f35 100644 --- a/frontend/src/lib/types/schemas.ts +++ b/frontend/src/lib/types/schemas.ts @@ -173,11 +173,11 @@ export const reauthResponseSchema = z.object({ export const notificationPrefsSchema = z.object({ preferredChannel: verificationChannel, email: email, - discordId: z.string().nullable(), + discordUsername: z.string().nullable(), discordVerified: z.boolean(), telegramUsername: z.string().nullable(), telegramVerified: z.boolean(), - signalNumber: z.string().nullable(), + signalUsername: z.string().nullable(), signalVerified: z.boolean(), }); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 1856eeb..681e4ca 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -84,19 +84,17 @@ "emailAddress": "Email Address", "emailPlaceholder": "you@example.com", "discord": "Discord", - "discordId": "Discord User ID", - "discordIdPlaceholder": "123456789012345678", - "discordIdHint": "Enable Developer Mode to find your ID", - "discordInUseWarning": "Discord ID in use by another account", + "discordUsername": "Discord Username", + "discordUsernamePlaceholder": "yourusername", + "discordInUseWarning": "Discord username in use by another account", "telegram": "Telegram", "telegramUsername": "Telegram Username", "telegramUsernamePlaceholder": "@yourusername", "telegramInUseWarning": "Telegram username in use by another account", "signal": "Signal", - "signalNumber": "Signal Phone Number", - "signalNumberPlaceholder": "+1234567890", - "signalNumberHint": "Include country code", - "signalInUseWarning": "Signal number in use by another account", + "signalUsername": "Signal Username", + "signalUsernamePlaceholder": "username.01", + "signalInUseWarning": "Signal username in use by another account", "notConfigured": "not configured", "inviteCode": "Invite Code", "inviteCodePlaceholder": "Enter your invite code", @@ -118,9 +116,9 @@ "externalDidRequired": "External did:web is required", "externalDidFormat": "External DID must start with did:web:", "emailRequired": "Email is required for email verification", - "discordIdRequired": "Discord ID is required for Discord verification", + "discordUsernameRequired": "Discord username is required for Discord verification", "telegramRequired": "Telegram username is required for Telegram verification", - "signalRequired": "Phone number is required for Signal verification" + "signalRequired": "Signal username is required for Signal verification" } }, "dashboard": { @@ -437,11 +435,13 @@ "verifiedSuccess": "{channel} verified successfully", "messageHistory": "Message History", "noMessages": "No messages found.", - "discordInUseWarning": "This Discord ID is already associated with another account.", + "discordInUseWarning": "This Discord username is already associated with another account.", "telegramInUseWarning": "This Telegram username is already associated with another account.", - "signalInUseWarning": "This Signal number is already associated with another account.", + "signalInUseWarning": "This Signal username is already associated with another account.", "telegramStartBot": "Or send /start {handle} to @{botUsername} manually", - "telegramOpenLink": "Open Telegram to verify" + "telegramOpenLink": "Open Telegram to verify", + "discordStartBot": "DM @{botUsername} on Discord and send /start {handle}", + "discordOpenLink": "Open Discord to verify" }, "repoExplorer": { "collections": "Collections", @@ -779,9 +779,9 @@ "externalDidRequired": "External did:web is required", "externalDidFormat": "External DID must start with did:web:", "emailRequired": "Email is required for email verification", - "discordRequired": "Discord ID is required for Discord verification", + "discordRequired": "Discord username is required for Discord verification", "telegramRequired": "Telegram username is required for Telegram verification", - "signalRequired": "Phone number required", + "signalRequired": "Signal username required", "passkeysNotSupported": "Passkeys not supported in this browser", "passkeyCancelled": "Cancelled", "passkeyFailed": "Passkey registration failed" diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 676432e..4871369 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -84,19 +84,17 @@ "emailAddress": "Sähköpostiosoite", "emailPlaceholder": "sinä@esimerkki.fi", "discord": "Discord", - "discordId": "Discord-käyttäjätunnus", - "discordIdPlaceholder": "Discord-käyttäjätunnuksesi", - "discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)", - "discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.", + "discordUsername": "Discord-käyttäjänimi", + "discordUsernamePlaceholder": "käyttäjänimesi", + "discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.", "telegram": "Telegram", "telegramUsername": "Telegram-käyttäjänimi", "telegramUsernamePlaceholder": "@käyttäjänimesi", "telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.", "signal": "Signal", - "signalNumber": "Signal-puhelinnumero", - "signalNumberPlaceholder": "+358401234567", - "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", - "signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.", + "signalUsername": "Signal-käyttäjänimi", + "signalUsernamePlaceholder": "käyttäjänimi.01", + "signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.", "notConfigured": "ei määritetty", "inviteCode": "Kutsukoodi", "inviteCodePlaceholder": "Syötä kutsukoodisi", @@ -118,9 +116,9 @@ "externalDidRequired": "Ulkoinen did:web vaaditaan", "externalDidFormat": "Ulkoisen DID:n on alettava did:web:", "emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen", - "discordIdRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen", + "discordUsernameRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen", "telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen", - "signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen" + "signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen" } }, "dashboard": { @@ -433,11 +431,13 @@ "verifiedSuccess": "{channel} vahvistettu", "messageHistory": "Viestihistoria", "noMessages": "Viestejä ei löytynyt.", - "discordInUseWarning": "Tämä Discord-tunnus on jo yhdistetty toiseen tiliin.", + "discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.", "telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.", - "signalInUseWarning": "Tämä Signal-numero on jo yhdistetty toiseen tiliin.", + "signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.", "telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti", "telegramOpenLink": "Avaa Telegram vahvistaaksesi", + "discordStartBot": "Lähetä @{botUsername}-botille viesti /start {handle} Discordissa", + "discordOpenLink": "Avaa Discord vahvistaaksesi", "failedToLoad": "Asetusten lataus epäonnistui", "failedToSave": "Asetusten tallennus epäonnistui", "failedToVerify": "Vahvistus epäonnistui", @@ -751,13 +751,13 @@ "passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.", "passkeyCancelled": "Pääsyavaimen luominen peruutettu", "passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui", - "signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen", + "signalRequired": "Signal-käyttäjänimi vaaditaan Signal-vahvistukseen", "inviteRequired": "Kutsukoodi vaaditaan", "externalDidRequired": "Ulkoinen did:web vaaditaan", "emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen", "telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen", "externalDidFormat": "Ulkoisen DID:n on alettava did:web:", - "discordRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen" + "discordRequired": "Discord-käyttäjänimi vaaditaan Discord-vahvistukseen" }, "identityType": "Identiteettityyppi", "identityTypeHint": "Valitse, miten hajautettua identiteettiäsi hallitaan.", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index d5d995c..e6b64ae 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -84,19 +84,17 @@ "emailAddress": "メールアドレス", "emailPlaceholder": "you@example.com", "discord": "Discord", - "discordId": "Discord ユーザー ID", - "discordIdPlaceholder": "Discord ユーザー ID", - "discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)", - "discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。", + "discordUsername": "Discord ユーザー名", + "discordUsernamePlaceholder": "yourusername", + "discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。", "telegram": "Telegram", "telegramUsername": "Telegram ユーザー名", "telegramUsernamePlaceholder": "@yourusername", "telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。", "signal": "Signal", - "signalNumber": "Signal 電話番号", - "signalNumberPlaceholder": "+81XXXXXXXXXX", - "signalNumberHint": "国番号を含めてください(例: 日本は +81)", - "signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。", + "signalUsername": "Signal ユーザー名", + "signalUsernamePlaceholder": "username.01", + "signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。", "notConfigured": "未設定", "inviteCode": "招待コード", "inviteCodePlaceholder": "招待コードを入力", @@ -118,9 +116,9 @@ "externalDidRequired": "外部 did:web は必須です", "externalDidFormat": "外部 DID は did:web: で始まる必要があります", "emailRequired": "メール認証にはメールアドレスが必要です", - "discordIdRequired": "Discord 認証には Discord ID が必要です", + "discordUsernameRequired": "Discord 認証には Discord ユーザー名が必要です", "telegramRequired": "Telegram 認証には Telegram ユーザー名が必要です", - "signalRequired": "Signal 認証には電話番号が必要です" + "signalRequired": "Signal 認証にはユーザー名が必要です" } }, "dashboard": { @@ -433,11 +431,13 @@ "verifiedSuccess": "{channel} を確認しました", "messageHistory": "メッセージ履歴", "noMessages": "メッセージが見つかりません。", - "discordInUseWarning": "この Discord ID は既に別のアカウントに関連付けられています。", + "discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。", "telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。", - "signalInUseWarning": "この Signal 番号は既に別のアカウントに関連付けられています。", + "signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。", "telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信", "telegramOpenLink": "Telegram で確認する", + "discordStartBot": "Discordで @{botUsername} にDMして /start {handle} を送信", + "discordOpenLink": "Discordで認証", "failedToLoad": "設定の読み込みに失敗しました", "failedToSave": "設定の保存に失敗しました", "failedToVerify": "確認に失敗しました", @@ -751,13 +751,13 @@ "passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。", "passkeyCancelled": "パスキーの作成がキャンセルされました", "passkeyFailed": "パスキーの登録に失敗しました", - "signalRequired": "Signal認証には電話番号が必要です", + "signalRequired": "Signal認証にはユーザー名が必要です", "inviteRequired": "招待コードが必要です", "externalDidRequired": "外部did:webが必要です", "emailRequired": "メール認証にはメールアドレスが必要です", "telegramRequired": "Telegram認証にはTelegramユーザー名が必要です", "externalDidFormat": "外部DIDはdid:web:で始まる必要があります", - "discordRequired": "Discord認証にはDiscord IDが必要です" + "discordRequired": "Discord認証にはDiscordユーザー名が必要です" }, "identityType": "アイデンティティタイプ", "identityTypeHint": "分散型アイデンティティの管理方法を選択してください。", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index cbf0f47..f389d94 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -84,19 +84,17 @@ "emailAddress": "이메일 주소", "emailPlaceholder": "you@example.com", "discord": "Discord", - "discordId": "Discord 사용자 ID", - "discordIdPlaceholder": "Discord 사용자 ID", - "discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)", - "discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.", + "discordUsername": "Discord 사용자명", + "discordUsernamePlaceholder": "yourusername", + "discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.", "telegram": "Telegram", "telegramUsername": "Telegram 사용자 이름", "telegramUsernamePlaceholder": "@yourusername", "telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.", "signal": "Signal", - "signalNumber": "Signal 전화번호", - "signalNumberPlaceholder": "+821012345678", - "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", - "signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.", + "signalUsername": "Signal 사용자명", + "signalUsernamePlaceholder": "username.01", + "signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.", "notConfigured": "구성되지 않음", "inviteCode": "초대 코드", "inviteCodePlaceholder": "초대 코드 입력", @@ -118,9 +116,9 @@ "externalDidRequired": "외부 did:web은 필수입니다", "externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다", "emailRequired": "이메일 인증에는 이메일이 필요합니다", - "discordIdRequired": "Discord 인증에는 Discord ID가 필요합니다", + "discordUsernameRequired": "Discord 인증에는 Discord 사용자명이 필요합니다", "telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다", - "signalRequired": "Signal 인증에는 전화번호가 필요합니다" + "signalRequired": "Signal 인증에는 사용자명이 필요합니다" } }, "dashboard": { @@ -433,11 +431,13 @@ "verifiedSuccess": "{channel} 인증 완료", "messageHistory": "메시지 기록", "noMessages": "메시지가 없습니다.", - "discordInUseWarning": "이 Discord ID는 이미 다른 계정과 연결되어 있습니다.", + "discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.", "telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.", - "signalInUseWarning": "이 Signal 번호는 이미 다른 계정과 연결되어 있습니다.", + "signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.", "telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요", "telegramOpenLink": "Telegram에서 인증하기", + "discordStartBot": "Discord에서 @{botUsername}에게 DM으로 /start {handle} 보내기", + "discordOpenLink": "Discord에서 인증", "failedToLoad": "설정 로딩 실패", "failedToSave": "설정 저장 실패", "failedToVerify": "인증 실패", @@ -751,13 +751,13 @@ "passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.", "passkeyCancelled": "패스키 생성이 취소되었습니다", "passkeyFailed": "패스키 등록에 실패했습니다", - "signalRequired": "Signal 인증에는 전화번호가 필요합니다", + "signalRequired": "Signal 인증에는 사용자명이 필요합니다", "inviteRequired": "초대 코드가 필요합니다", "externalDidRequired": "외부 did:web이 필요합니다", "emailRequired": "이메일 인증에는 이메일이 필요합니다", "telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다", "externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다", - "discordRequired": "Discord 인증에는 Discord ID가 필요합니다" + "discordRequired": "Discord 인증에는 Discord 사용자명이 필요합니다" }, "identityType": "아이덴티티 유형", "identityTypeHint": "분산 아이덴티티 관리 방법을 선택하세요.", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 0739fcf..68d7ad7 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -84,19 +84,17 @@ "emailAddress": "E-postadress", "emailPlaceholder": "du@exempel.se", "discord": "Discord", - "discordId": "Discord användar-ID", - "discordIdPlaceholder": "Ditt Discord användar-ID", - "discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)", - "discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.", + "discordUsername": "Discord-användarnamn", + "discordUsernamePlaceholder": "dittanvändarnamn", + "discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.", "telegram": "Telegram", "telegramUsername": "Telegram-användarnamn", "telegramUsernamePlaceholder": "@dittanvändarnamn", "telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.", "signal": "Signal", - "signalNumber": "Signal-telefonnummer", - "signalNumberPlaceholder": "+46701234567", - "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", - "signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.", + "signalUsername": "Signal-användarnamn", + "signalUsernamePlaceholder": "användarnamn.01", + "signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.", "notConfigured": "ej konfigurerad", "inviteCode": "Inbjudningskod", "inviteCodePlaceholder": "Ange din inbjudningskod", @@ -118,9 +116,9 @@ "externalDidRequired": "Extern did:web krävs", "externalDidFormat": "Extern DID måste börja med did:web:", "emailRequired": "E-post krävs för e-postverifiering", - "discordIdRequired": "Discord-ID krävs för Discord-verifiering", + "discordUsernameRequired": "Discord-användarnamn krävs för Discord-verifiering", "telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering", - "signalRequired": "Telefonnummer krävs för Signal-verifiering" + "signalRequired": "Signal-användarnamn krävs för Signal-verifiering" } }, "dashboard": { @@ -433,11 +431,13 @@ "verifiedSuccess": "{channel} verifierad", "messageHistory": "Meddelandehistorik", "noMessages": "Inga meddelanden hittades.", - "discordInUseWarning": "Detta Discord-ID är redan kopplat till ett annat konto.", + "discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.", "telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.", - "signalInUseWarning": "Detta Signal-nummer är redan kopplat till ett annat konto.", + "signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.", "telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt", "telegramOpenLink": "Öppna Telegram för att verifiera", + "discordStartBot": "DM:a @{botUsername} på Discord och skicka /start {handle}", + "discordOpenLink": "Öppna Discord för att verifiera", "failedToLoad": "Kunde inte ladda inställningar", "failedToSave": "Kunde inte spara inställningar", "failedToVerify": "Verifiering misslyckades", @@ -751,13 +751,13 @@ "passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.", "passkeyCancelled": "Nyckelskapande avbröts", "passkeyFailed": "Nyckelregistrering misslyckades", - "signalRequired": "Telefonnummer krävs för Signal-verifiering", + "signalRequired": "Signal-användarnamn krävs för Signal-verifiering", "inviteRequired": "Inbjudningskod krävs", "externalDidRequired": "Extern did:web krävs", "emailRequired": "E-post krävs för e-postverifiering", "telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering", "externalDidFormat": "Extern DID måste börja med did:web:", - "discordRequired": "Discord-ID krävs för Discord-verifiering" + "discordRequired": "Discord-användarnamn krävs för Discord-verifiering" }, "identityType": "Identitetstyp", "identityTypeHint": "Välj hur din decentraliserade identitet ska hanteras.", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 6497af6..b564a6d 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -84,19 +84,17 @@ "emailAddress": "电子邮件地址", "emailPlaceholder": "you@example.com", "discord": "Discord", - "discordId": "Discord 用户 ID", - "discordIdPlaceholder": "您的 Discord 用户 ID", - "discordIdHint": "您的 Discord 数字用户 ID(开启开发者模式后可以复制)", - "discordInUseWarning": "此 Discord ID 已与另一个账户关联。", + "discordUsername": "Discord 用户名", + "discordUsernamePlaceholder": "yourusername", + "discordInUseWarning": "此 Discord 用户名已与另一个账户关联。", "telegram": "Telegram", "telegramUsername": "Telegram 用户名", "telegramUsernamePlaceholder": "@yourusername", "telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。", "signal": "Signal", - "signalNumber": "Signal 电话号码", - "signalNumberPlaceholder": "+1234567890", - "signalNumberHint": "包含国家代码(例如中国为 +86)", - "signalInUseWarning": "此 Signal 号码已与另一个账户关联。", + "signalUsername": "Signal 用户名", + "signalUsernamePlaceholder": "username.01", + "signalInUseWarning": "此 Signal 用户名已被其他账户使用。", "notConfigured": "未配置", "inviteCode": "邀请码", "inviteCodePlaceholder": "输入您的邀请码", @@ -118,9 +116,9 @@ "externalDidRequired": "请输入您的 did:web", "externalDidFormat": "DID 必须以 did:web: 开头", "emailRequired": "使用邮箱验证需要填写邮箱地址", - "discordIdRequired": "使用 Discord 验证需要填写 Discord ID", + "discordUsernameRequired": "使用 Discord 验证需要填写 Discord 用户名", "telegramRequired": "使用 Telegram 验证需要填写用户名", - "signalRequired": "使用 Signal 验证需要填写电话号码" + "signalRequired": "使用 Signal 验证需要填写用户名" } }, "dashboard": { @@ -433,11 +431,13 @@ "verifiedSuccess": "{channel} 验证成功", "messageHistory": "消息历史", "noMessages": "暂无消息记录", - "discordInUseWarning": "此 Discord ID 已与另一个账户关联。", + "discordInUseWarning": "此 Discord 用户名已与另一个账户关联。", "telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。", - "signalInUseWarning": "此 Signal 号码已与另一个账户关联。", + "signalInUseWarning": "此 Signal 用户名已与另一个账户关联。", "telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}", "telegramOpenLink": "打开 Telegram 验证", + "discordStartBot": "在 Discord 上私信 @{botUsername} 并发送 /start {handle}", + "discordOpenLink": "打开 Discord 验证", "failedToLoad": "加载偏好设置失败", "failedToSave": "保存偏好设置失败", "failedToVerify": "验证失败", @@ -774,9 +774,9 @@ "externalDidRequired": "请输入您的 did:web", "externalDidFormat": "DID 必须以 did:web: 开头", "emailRequired": "使用邮箱验证需要填写邮箱地址", - "discordRequired": "使用 Discord 验证需要填写 Discord ID", + "discordRequired": "使用 Discord 验证需要填写 Discord 用户名", "telegramRequired": "使用 Telegram 验证需要填写用户名", - "signalRequired": "使用 Signal 验证需要填写电话号码", + "signalRequired": "使用 Signal 验证需要填写用户名", "passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。", "passkeyCancelled": "通行密钥创建已取消", "passkeyFailed": "通行密钥注册失败" diff --git a/frontend/src/routes/OAuthRegister.svelte b/frontend/src/routes/OAuthRegister.svelte index 6f748d4..7469169 100644 --- a/frontend/src/routes/OAuthRegister.svelte +++ b/frontend/src/routes/OAuthRegister.svelte @@ -123,13 +123,13 @@ if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired') break case 'discord': - if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired') + if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired') break case 'telegram': if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired') break case 'signal': - if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired') + if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired') break } return null @@ -389,16 +389,15 @@

{:else if flow.info.verificationChannel === 'discord'}
- + -

{$_('register.discordIdHint')}

{:else if flow.info.verificationChannel === 'telegram'}
@@ -414,16 +413,16 @@
{:else if flow.info.verificationChannel === 'signal'}
- + -

{$_('register.signalNumberHint')}

+

{$_('register.signalUsernameHint')}

{/if}
diff --git a/frontend/src/routes/OAuthSsoRegister.svelte b/frontend/src/routes/OAuthSsoRegister.svelte index 87cb1db..5aaaa9b 100644 --- a/frontend/src/routes/OAuthSsoRegister.svelte +++ b/frontend/src/routes/OAuthSsoRegister.svelte @@ -30,9 +30,9 @@ let providerEmailOriginal = $state(null) let inviteCode = $state('') let verificationChannel = $state('email') - let discordId = $state('') + let discordUsername = $state('') let telegramUsername = $state('') - let signalNumber = $state('') + let signalUsername = $state('') let handleAvailable = $state(null) let checkingHandle = $state(false) @@ -189,11 +189,11 @@ case 'email': return !!email.trim() case 'discord': - return !!discordId.trim() + return !!discordUsername.trim() case 'telegram': return !!telegramUsername.trim() case 'signal': - return !!signalNumber.trim() + return !!signalUsername.trim() default: return false } @@ -234,9 +234,9 @@ email: email || null, invite_code: inviteCode || null, verification_channel: verificationChannel, - discord_id: discordId || null, + discord_username: discordUsername || null, telegram_username: telegramUsername || null, - signal_number: signalNumber || null, + signal_username: signalUsername || null, did_type: didType, did: didType === 'web-external' ? externalDid.trim() : null, }), @@ -379,16 +379,15 @@
{:else if verificationChannel === 'discord'}
- + -

{$_('register.discordIdHint')}

{:else if verificationChannel === 'telegram'}
@@ -404,16 +403,16 @@
{:else if verificationChannel === 'signal'}
- + -

{$_('register.signalNumberHint')}

+

{$_('register.signalUsernameHint')}

{/if} diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index 7565060..3ef23c3 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -136,13 +136,13 @@ if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired') break case 'discord': - if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired') + if (!info.discordUsername?.trim()) return $_('registerPasskey.errors.discordRequired') break case 'telegram': if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired') break case 'signal': - if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired') + if (!info.signalUsername?.trim()) return $_('registerPasskey.errors.signalRequired') break } return null @@ -409,16 +409,15 @@ {:else if flow.info.verificationChannel === 'discord'}
- + -

{$_('register.discordIdHint')}

{:else if flow.info.verificationChannel === 'telegram'}
@@ -434,16 +433,16 @@
{:else if flow.info.verificationChannel === 'signal'}
- + -

{$_('register.signalNumberHint')}

+

{$_('register.signalUsernameHint')}

{/if} diff --git a/frontend/src/routes/RegisterPassword.svelte b/frontend/src/routes/RegisterPassword.svelte index 8837d51..862ebcd 100644 --- a/frontend/src/routes/RegisterPassword.svelte +++ b/frontend/src/routes/RegisterPassword.svelte @@ -133,13 +133,13 @@ if (!info.email.trim()) return $_('register.validation.emailRequired') break case 'discord': - if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') + if (!info.discordUsername?.trim()) return $_('register.validation.discordUsernameRequired') break case 'telegram': if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') break case 'signal': - if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') + if (!info.signalUsername?.trim()) return $_('register.validation.signalRequired') break } return null @@ -381,17 +381,16 @@ {:else if flow.info.verificationChannel === 'discord'}
- + flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')} - placeholder={$_('register.discordIdPlaceholder')} + bind:value={flow.info.discordUsername} + onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordUsername ?? '')} + placeholder={$_('register.discordUsernamePlaceholder')} disabled={flow.state.submitting} required /> -

{$_('register.discordIdHint')}

{#if flow.state.discordInUse}

{$_('register.discordInUseWarning')}

{/if} @@ -414,17 +413,17 @@
{:else if flow.info.verificationChannel === 'signal'}
- + flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')} - placeholder={$_('register.signalNumberPlaceholder')} + bind:value={flow.info.signalUsername} + onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalUsername ?? '')} + placeholder={$_('register.signalUsernamePlaceholder')} disabled={flow.state.submitting} required /> -

{$_('register.signalNumberHint')}

+

{$_('register.signalUsernameHint')}

{#if flow.state.signalInUse}

{$_('register.signalInUseWarning')}

{/if} diff --git a/frontend/src/routes/SsoRegisterComplete.svelte b/frontend/src/routes/SsoRegisterComplete.svelte index f5c9249..ef197ce 100644 --- a/frontend/src/routes/SsoRegisterComplete.svelte +++ b/frontend/src/routes/SsoRegisterComplete.svelte @@ -40,9 +40,9 @@ let providerEmailOriginal = $state(null) let inviteCode = $state('') let verificationChannel = $state('email') - let discordId = $state('') + let discordUsername = $state('') let telegramUsername = $state('') - let signalNumber = $state('') + let signalUsername = $state('') let handleAvailable = $state(null) let checkingHandle = $state(false) @@ -204,11 +204,11 @@ case 'email': return !!email.trim() case 'discord': - return !!discordId.trim() + return !!discordUsername.trim() case 'telegram': return !!telegramUsername.trim() case 'signal': - return !!signalNumber.trim() + return !!signalUsername.trim() default: return false } @@ -281,9 +281,9 @@ email: email || null, invite_code: inviteCode || null, verification_channel: verificationChannel, - discord_id: discordId || null, + discord_username: discordUsername || null, telegram_username: telegramUsername || null, - signal_number: signalNumber || null, + signal_username: signalUsername || null, did_type: didType, did: didType === 'web-external' ? externalDid.trim() : null, }), @@ -452,16 +452,15 @@
{:else if verificationChannel === 'discord'}
- + -

{$_('register.discordIdHint')}

{:else if verificationChannel === 'telegram'}
@@ -477,16 +476,16 @@
{:else if verificationChannel === 'signal'}
- + -

{$_('register.signalNumberHint')}

+

{$_('register.signalUsernameHint')}

{/if} diff --git a/frontend/src/routes/Verify.svelte b/frontend/src/routes/Verify.svelte index 137893e..a2d2846 100644 --- a/frontend/src/routes/Verify.svelte +++ b/frontend/src/routes/Verify.svelte @@ -33,6 +33,8 @@ let tokenFromUrl = $state(false) let oauthRequestUri = $state(null) let telegramBotUsername = $state(undefined) + let discordBotUsername = $state(undefined) + let discordAppId = $state(undefined) const auth = $derived(getAuthState()) @@ -42,6 +44,8 @@ const session = $derived(getSession()) const isTelegram = $derived(pendingVerification?.channel === 'telegram') + const isDiscord = $derived(pendingVerification?.channel === 'discord') + const isBotVerified = $derived(isTelegram || isDiscord) function parseQueryParams(): Record { return Object.fromEntries(new URLSearchParams(window.location.search)) @@ -102,10 +106,12 @@ })) } - if (pendingVerification?.channel === 'telegram') { + if (pendingVerification?.channel === 'telegram' || pendingVerification?.channel === 'discord') { try { const serverInfo = await api.describeServer() telegramBotUsername = serverInfo.telegramBotUsername + discordBotUsername = serverInfo.discordBotUsername + discordAppId = serverInfo.discordAppId } catch { } } @@ -121,10 +127,10 @@ let pollingVerification = false $effect(() => { - if (mode === 'signup' && pendingVerification && (isTelegram || !verificationCode.trim())) { + if (mode === 'signup' && pendingVerification && (isBotVerified || !verificationCode.trim())) { const currentPending = pendingVerification const interval = setInterval(async () => { - if (pollingVerification || (!isTelegram && verificationCode.trim())) return + if (pollingVerification || (!isBotVerified && verificationCode.trim())) return pollingVerification = true try { const result = await api.checkChannelVerified(currentPending.did, currentPending.channel) @@ -447,7 +453,7 @@ {#if isTelegram && telegramBotUsername} {@const encodedHandle = pendingVerification.handle.replaceAll('.', '_')} -
+

{$_('comms.telegramOpenLink')}

@@ -456,6 +462,16 @@

{$_('verify.pleaseWait')}

+ {:else if isDiscord && discordAppId} +
+

+ {$_('comms.discordOpenLink')} +

+

+ {$_('comms.discordStartBot', { values: { botUsername: discordBotUsername ?? 'the bot', handle: pendingVerification.handle } })} +

+

{$_('verify.pleaseWait')}

+
{:else}
{ e.preventDefault(); handleSignupVerification(e); }}>
@@ -610,23 +626,23 @@ padding: var(--space-4) var(--space-8); } - .telegram-hint { + .bot-hint { padding: var(--space-4); background: var(--bg-secondary); border-radius: var(--radius-md); } - .telegram-hint p { + .bot-hint p { margin: 0; } - .telegram-hint .manual-text { + .bot-hint .manual-text { font-size: var(--text-sm); color: var(--text-secondary); margin-top: var(--space-1); } - .telegram-hint .waiting-text { + .bot-hint .waiting-text { font-size: var(--text-sm); color: var(--text-secondary); margin-top: var(--space-2); diff --git a/frontend/src/tests/mocks.ts b/frontend/src/tests/mocks.ts index 04cc28c..fdaf649 100644 --- a/frontend/src/tests/mocks.ts +++ b/frontend/src/tests/mocks.ts @@ -254,11 +254,11 @@ export const mockData = { notificationPrefs: (overrides?: Record) => ({ preferredChannel: "email", email: "test@example.com", - discordId: null, + discordUsername: null, discordVerified: false, telegramUsername: null, telegramVerified: false, - signalNumber: null, + signalUsername: null, signalVerified: false, ...overrides, }), diff --git a/migrations/20260205_discord_username.sql b/migrations/20260205_discord_username.sql new file mode 100644 index 0000000..f148123 --- /dev/null +++ b/migrations/20260205_discord_username.sql @@ -0,0 +1,3 @@ +ALTER TABLE users ADD COLUMN discord_username TEXT; + +UPDATE users SET discord_id = NULL, discord_verified = FALSE WHERE discord_id IS NOT NULL; diff --git a/migrations/20260206_signal_username.sql b/migrations/20260206_signal_username.sql new file mode 100644 index 0000000..4f17821 --- /dev/null +++ b/migrations/20260206_signal_username.sql @@ -0,0 +1 @@ +ALTER TABLE users RENAME COLUMN signal_number TO signal_username;