From d695135a4da24d87892998f4611bac711452cb9a Mon Sep 17 00:00:00 2001 From: lewis Date: Thu, 18 Dec 2025 20:35:15 +0200 Subject: [PATCH] Rename notifications to comms, add handle changing to own domain ability --- ...eb4ea454844828c8aca63b1252fefa8f5afad.json | 14 + ...80a02c92e579869f647cb08f65ac777854f5.json} | 8 +- ...dfa8a2e37aae3f171a2be73bee3057f86e08.json} | 16 +- ...5b60bc852c4f1042c95a76fce773efd369511.json | 15 - ...52dcbc957b7077c856da6a1a256ec2fe85ad3.json | 14 - ...14ad3bf77d47dd393a0aae1c030b8ce98bcc.json} | 8 +- ...32bfeaf782a7787784008f15254c02ef57d5.json} | 10 +- ...4b58b5ab6892846e65a4f1549ae5f147c13e.json} | 6 +- ...5e395f71596a6d6a88d2be64ce86256a9860f.json | 46 --- ...abbea919b9a90167958f018ceb9972b9e8230.json | 15 + ...eed73bb602f0b5ab48c11e53c82839a66721.json} | 8 +- ...dcc7f54b72983ba8ebd66fd805851db5c06c.json} | 8 +- ...20dd37741e9c506d85fc2608a3a6e376c5e6.json} | 6 +- ...d5c7c8e6a7c9489133bb9357d012d7a75813b.json | 34 +++ ...0c0088ce6e117fe63c5a1062a97000dedf89.json} | 6 +- ...d35c6012b7cc6aac81c5e46be9d24cfd42c24.json | 70 +++++ ...b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json} | 10 +- ...fb90b793c78402c648dd380538b6e2b47134.json} | 6 +- ...7e560dfb5912db8e40ee92cf91846f306e1a.json} | 8 +- ...8ac148363e2f98b301dbeeb1caac330528c5.json} | 6 +- ...310cc84faa056f230b0d7ea62a0b457534e0.json} | 34 ++- ...35c06b4179b5858a7b388404c4b03fc92ab4.json} | 12 +- Cargo.lock | 1 + Cargo.toml | 1 + frontend/src/App.svelte | 3 + frontend/src/routes/Login.svelte | 5 - frontend/src/routes/Register.svelte | 136 +-------- frontend/src/routes/Settings.svelte | 160 +++++++++- frontend/src/routes/Verify.svelte | 277 ++++++++++++++++++ .../20251219_rename_email_confirmed.sql | 6 + ...20251220_rename_notifications_to_comms.sql | 27 ++ src/api/admin/account/email.rs | 6 +- src/api/admin/account/info.rs | 6 +- src/api/admin/account/search.rs | 8 +- src/api/identity/account.rs | 112 +++---- src/api/identity/did.rs | 117 +++++++- src/api/identity/plc/request.rs | 2 +- src/api/notification_prefs.rs | 20 +- src/api/repo/record/batch.rs | 4 +- src/api/repo/record/write.rs | 10 +- src/api/server/account_status.rs | 2 +- src/api/server/password.rs | 2 +- src/api/server/session.rs | 96 +++--- src/api/verification.rs | 4 +- src/comms/mod.rs | 16 + src/{notifications => comms}/sender.rs | 44 +-- src/{notifications => comms}/service.rs | 223 +++++++------- src/{notifications => comms}/types.rs | 40 +-- src/handle/mod.rs | 121 ++++++++ src/lib.rs | 3 +- src/main.rs | 28 +- src/metrics.rs | 8 +- src/notifications/mod.rs | 18 -- src/oauth/endpoints/authorize.rs | 34 ++- src/oauth/templates.rs | 2 +- tests/account_notifications.rs | 8 +- tests/admin_email.rs | 4 +- tests/notifications.rs | 59 ++-- tests/oauth.rs | 11 +- tests/oauth_security.rs | 17 +- tests/password_reset.rs | 4 +- tests/security_fixes.rs | 2 +- 62 files changed, 1345 insertions(+), 662 deletions(-) create mode 100644 .sqlx/query-17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad.json rename .sqlx/{query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json => query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json} (72%) rename .sqlx/{query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json => query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json} (68%) delete mode 100644 .sqlx/query-2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511.json delete mode 100644 .sqlx/query-344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3.json rename .sqlx/{query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json => query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json} (71%) rename .sqlx/{query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json => query-4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5.json} (76%) rename .sqlx/{query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json => query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json} (73%) delete mode 100644 .sqlx/query-62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f.json create mode 100644 .sqlx/query-64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230.json rename .sqlx/{query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json => query-8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721.json} (70%) rename .sqlx/{query-0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e.json => query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json} (62%) rename .sqlx/{query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json => query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json} (64%) create mode 100644 .sqlx/query-d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b.json rename .sqlx/{query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json => query-d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89.json} (77%) create mode 100644 .sqlx/query-daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24.json rename .sqlx/{query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json => query-de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json} (69%) rename .sqlx/{query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json => query-e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134.json} (53%) rename .sqlx/{query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json => query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json} (72%) rename .sqlx/{query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json => query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json} (83%) rename .sqlx/{query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json => query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json} (57%) rename .sqlx/{query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json => query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json} (75%) create mode 100644 frontend/src/routes/Verify.svelte create mode 100644 migrations/20251219_rename_email_confirmed.sql create mode 100644 migrations/20251220_rename_notifications_to_comms.sql create mode 100644 src/comms/mod.rs rename src/{notifications => comms}/sender.rs (89%) rename src/{notifications => comms}/service.rs (63%) rename src/{notifications => comms}/types.rs (68%) create mode 100644 src/handle/mod.rs delete mode 100644 src/notifications/mod.rs diff --git a/.sqlx/query-17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad.json b/.sqlx/query-17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad.json new file mode 100644 index 0000000..b3d3bae --- /dev/null +++ b/.sqlx/query-17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE comms_queue\n SET status = 'sent', processed_at = NOW(), updated_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad" +} diff --git a/.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json similarity index 72% rename from .sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json rename to .sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json index ed46759..4444b64 100644 --- a/.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json +++ b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO notification_queue\n (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ", + "query": "\n INSERT INTO comms_queue\n (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ", "describe": { "columns": [ { @@ -14,7 +14,7 @@ "Uuid", { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -27,7 +27,7 @@ }, { "Custom": { - "name": "notification_type", + "name": "comms_type", "kind": { "Enum": [ "welcome", @@ -53,5 +53,5 @@ false ] }, - "hash": "5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf" + "hash": "17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5" } diff --git a/.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json similarity index 68% rename from .sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json rename to .sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json index dde296d..7004e69 100644 --- a/.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json +++ b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE notification_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM notification_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: NotificationChannel\",\n notification_type as \"notification_type: super::types::NotificationType\",\n status as \"status: NotificationStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n ", + "query": "\n UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: super::types::CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n ", "describe": { "columns": [ { @@ -15,10 +15,10 @@ }, { "ordinal": 2, - "name": "channel: NotificationChannel", + "name": "channel: CommsChannel", "type_info": { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -32,10 +32,10 @@ }, { "ordinal": 3, - "name": "notification_type: super::types::NotificationType", + "name": "comms_type: super::types::CommsType", "type_info": { "Custom": { - "name": "notification_type", + "name": "comms_type", "kind": { "Enum": [ "welcome", @@ -54,10 +54,10 @@ }, { "ordinal": 4, - "name": "status: NotificationStatus", + "name": "status: CommsStatus", "type_info": { "Custom": { - "name": "notification_status", + "name": "comms_status", "kind": { "Enum": [ "pending", @@ -150,5 +150,5 @@ true ] }, - "hash": "cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de" + "hash": "20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08" } diff --git a/.sqlx/query-2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511.json b/.sqlx/query-2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511.json deleted file mode 100644 index 872ce2a..0000000 --- a/.sqlx/query-2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE notification_queue\n SET\n status = CASE\n WHEN attempts + 1 >= max_attempts THEN 'failed'::notification_status\n ELSE 'pending'::notification_status\n END,\n attempts = attempts + 1,\n last_error = $2,\n updated_at = NOW(),\n scheduled_for = NOW() + (INTERVAL '1 minute' * (attempts + 1))\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511" -} diff --git a/.sqlx/query-344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3.json b/.sqlx/query-344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3.json deleted file mode 100644 index 7abae00..0000000 --- a/.sqlx/query-344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE notification_queue\n SET status = 'sent', processed_at = NOW(), updated_at = NOW()\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3" -} diff --git a/.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json similarity index 71% rename from .sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json rename to .sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json index 5d80a15..d87a8a1 100644 --- a/.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json +++ b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO notification_queue\n (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ", + "query": "\n INSERT INTO comms_queue\n (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ", "describe": { "columns": [ { @@ -14,7 +14,7 @@ "Uuid", { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -27,7 +27,7 @@ }, { "Custom": { - "name": "notification_type", + "name": "comms_type", "kind": { "Enum": [ "welcome", @@ -53,5 +53,5 @@ false ] }, - "hash": "303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6" + "hash": "3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc" } diff --git a/.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json b/.sqlx/query-4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5.json similarity index 76% rename from .sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json rename to .sqlx/query-4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5.json index c494da4..943b4d9 100644 --- a/.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json +++ b/.sqlx/query-4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n id, handle, email,\n preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n discord_id, telegram_username, signal_number,\n email_confirmed, 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: crate::comms::CommsChannel\",\n discord_id, telegram_username, signal_number,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1", "describe": { "columns": [ { @@ -20,10 +20,10 @@ }, { "ordinal": 3, - "name": "channel: crate::notifications::NotificationChannel", + "name": "channel: crate::comms::CommsChannel", "type_info": { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -52,7 +52,7 @@ }, { "ordinal": 7, - "name": "email_confirmed", + "name": "email_verified", "type_info": "Bool" }, { @@ -90,5 +90,5 @@ false ] }, - "hash": "ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6" + "hash": "4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5" } diff --git a/.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json b/.sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json similarity index 73% rename from .sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json rename to .sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json index 11ac47d..9244ba0 100644 --- a/.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json +++ b/.sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel", + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", "describe": { "columns": [], "parameters": { @@ -8,7 +8,7 @@ "Uuid", { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -23,5 +23,5 @@ }, "nullable": [] }, - "hash": "90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849" + "hash": "57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e" } diff --git a/.sqlx/query-62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f.json b/.sqlx/query-62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f.json deleted file mode 100644 index 4b9b1db..0000000 --- a/.sqlx/query-62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, two_factor_enabled,\n preferred_notification_channel as \"preferred_notification_channel: NotificationChannel\"\n FROM users\n WHERE did = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "two_factor_enabled", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "preferred_notification_channel: NotificationChannel", - "type_info": { - "Custom": { - "name": "notification_channel", - "kind": { - "Enum": [ - "email", - "discord", - "telegram", - "signal" - ] - } - } - } - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f" -} diff --git a/.sqlx/query-64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230.json b/.sqlx/query-64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230.json new file mode 100644 index 0000000..08fa520 --- /dev/null +++ b/.sqlx/query-64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE comms_queue\n SET\n status = CASE\n WHEN attempts + 1 >= max_attempts THEN 'failed'::comms_status\n ELSE 'pending'::comms_status\n END,\n attempts = attempts + 1,\n last_error = $2,\n updated_at = NOW(),\n scheduled_for = NOW() + (INTERVAL '1 minute' * (attempts + 1))\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230" +} diff --git a/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json b/.sqlx/query-8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721.json similarity index 70% rename from .sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json rename to .sqlx/query-8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721.json index b0a5d1b..4dfc823 100644 --- a/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json +++ b/.sqlx/query-8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n email,\n handle,\n preferred_notification_channel as \"channel: NotificationChannel\"\n FROM users\n WHERE id = $1\n ", + "query": "\n SELECT\n email,\n handle,\n preferred_comms_channel as \"channel: CommsChannel\"\n FROM users\n WHERE id = $1\n ", "describe": { "columns": [ { @@ -15,10 +15,10 @@ }, { "ordinal": 2, - "name": "channel: NotificationChannel", + "name": "channel: CommsChannel", "type_info": { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -42,5 +42,5 @@ false ] }, - "hash": "bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508" + "hash": "8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721" } diff --git a/.sqlx/query-0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e.json b/.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json similarity index 62% rename from .sqlx/query-0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e.json rename to .sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json index 74512ad..259fb57 100644 --- a/.sqlx/query-0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e.json +++ b/.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json @@ -1,14 +1,14 @@ { "db_name": "PostgreSQL", - "query": "SELECT preferred_notification_channel as \"channel: NotificationChannel\" FROM users WHERE did = $1", + "query": "SELECT preferred_comms_channel as \"channel: CommsChannel\" FROM users WHERE did = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "channel: NotificationChannel", + "name": "channel: CommsChannel", "type_info": { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -30,5 +30,5 @@ false ] }, - "hash": "0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e" + "hash": "94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c" } diff --git a/.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json b/.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json similarity index 64% rename from .sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json rename to .sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json index 84c9334..bba38eb 100644 --- a/.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json +++ b/.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::notification_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ", + "query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::comms_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ", "describe": { "columns": [], "parameters": { @@ -8,7 +8,7 @@ "Uuid", { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -26,5 +26,5 @@ }, "nullable": [] }, - "hash": "9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546" + "hash": "c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6" } diff --git a/.sqlx/query-d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b.json b/.sqlx/query-d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b.json new file mode 100644 index 0000000..c62fab5 --- /dev/null +++ b/.sqlx/query-d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT u.id, uk.key_bytes, uk.encryption_version\n FROM users u\n JOIN user_keys uk ON u.id = uk.user_id\n WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b" +} diff --git a/.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json b/.sqlx/query-d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89.json similarity index 77% rename from .sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json rename to .sqlx/query-d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89.json index dbd6434..23b2e51 100644 --- a/.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json +++ b/.sqlx/query-d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,\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.handle = $1 OR u.email = $1", + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\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.handle = $1 OR u.email = $1", "describe": { "columns": [ { @@ -25,7 +25,7 @@ }, { "ordinal": 4, - "name": "email_confirmed", + "name": "email_verified", "type_info": "Bool" }, { @@ -72,5 +72,5 @@ true ] }, - "hash": "1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd" + "hash": "d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89" } diff --git a/.sqlx/query-daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24.json b/.sqlx/query-daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24.json new file mode 100644 index 0000000..1ce095f --- /dev/null +++ b/.sqlx/query-daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "two_factor_enabled", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "preferred_comms_channel: CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "discord_verified", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "telegram_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "signal_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24" +} diff --git a/.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json b/.sqlx/query-de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json similarity index 69% rename from .sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json rename to .sqlx/query-de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json index 5690886..64edd35 100644 --- a/.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json +++ b/.sqlx/query-de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n handle, email, email_confirmed, is_admin,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", + "query": "SELECT\n handle, email, email_verified, is_admin,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "email_confirmed", + "name": "email_verified", "type_info": "Bool" }, { @@ -25,10 +25,10 @@ }, { "ordinal": 4, - "name": "preferred_channel: crate::notifications::NotificationChannel", + "name": "preferred_channel: crate::comms::CommsChannel", "type_info": { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -72,5 +72,5 @@ false ] }, - "hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1" + "hash": "de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9" } diff --git a/.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json b/.sqlx/query-e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134.json similarity index 53% rename from .sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json rename to .sqlx/query-e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134.json index f98e023..d5e3776 100644 --- a/.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json +++ b/.sqlx/query-e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)\n ", + "query": "\n INSERT INTO comms_queue (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2::comms_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)\n ", "describe": { "columns": [], "parameters": { @@ -8,7 +8,7 @@ "Uuid", { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -26,5 +26,5 @@ }, "nullable": [] }, - "hash": "0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac" + "hash": "e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134" } diff --git a/.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json b/.sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json similarity index 72% rename from .sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json rename to .sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json index 05577b6..b98bfef 100644 --- a/.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json +++ b/.sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\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: crate::comms::CommsChannel\",\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": [ { @@ -25,10 +25,10 @@ }, { "ordinal": 4, - "name": "channel: crate::notifications::NotificationChannel", + "name": "channel: crate::comms::CommsChannel", "type_info": { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -66,5 +66,5 @@ true ] }, - "hash": "dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3" + "hash": "efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a" } diff --git a/.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json b/.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json similarity index 83% rename from .sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json rename to .sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json index 10670b8..720cd39 100644 --- a/.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json +++ b/.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::notification_channel\n ", + "query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::comms_channel\n ", "describe": { "columns": [ { @@ -24,7 +24,7 @@ "Uuid", { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -43,5 +43,5 @@ false ] }, - "hash": "4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26" + "hash": "f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5" } diff --git a/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json b/.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json similarity index 57% rename from .sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json rename to .sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json index 99dc9cf..5f96327 100644 --- a/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json +++ b/.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_notification_channel as \"preferred_notification_channel: NotificationChannel\",\n deactivated_at, takedown_ref\n FROM users\n WHERE handle = $1 OR email = $1\n ", + "query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ", "describe": { "columns": [ { @@ -30,10 +30,10 @@ }, { "ordinal": 5, - "name": "preferred_notification_channel: NotificationChannel", + "name": "preferred_comms_channel: CommsChannel", "type_info": { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -54,6 +54,26 @@ "ordinal": 7, "name": "takedown_ref", "type_info": "Text" + }, + { + "ordinal": 8, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "discord_verified", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "telegram_verified", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "signal_verified", + "type_info": "Bool" } ], "parameters": { @@ -69,8 +89,12 @@ false, false, true, - true + true, + false, + false, + false, + false ] }, - "hash": "458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810" + "hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0" } diff --git a/.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json similarity index 75% rename from .sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json rename to .sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json index c36e63f..9e183a2 100644 --- a/.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json +++ b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n notification_type as \"notification_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM notification_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT 50\n ", + "query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n comms_type as \"comms_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT 50\n ", "describe": { "columns": [ { @@ -13,7 +13,7 @@ "name": "channel: String", "type_info": { "Custom": { - "name": "notification_channel", + "name": "comms_channel", "kind": { "Enum": [ "email", @@ -27,10 +27,10 @@ }, { "ordinal": 2, - "name": "notification_type: String", + "name": "comms_type: String", "type_info": { "Custom": { - "name": "notification_type", + "name": "comms_type", "kind": { "Enum": [ "welcome", @@ -52,7 +52,7 @@ "name": "status: String", "type_info": { "Custom": { - "name": "notification_status", + "name": "comms_status", "kind": { "Enum": [ "pending", @@ -89,5 +89,5 @@ false ] }, - "hash": "4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a" + "hash": "fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4" } diff --git a/Cargo.lock b/Cargo.lock index b48c47d..4b7ba35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -950,6 +950,7 @@ dependencies = [ "ed25519-dalek", "futures", "governor", + "hickory-resolver", "hkdf", "hmac", "image", diff --git a/Cargo.toml b/Cargo.toml index 0a522ba..8ade4a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ iroh-car = "0.5.1" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } tower-http = { version = "0.6", features = ["fs", "cors"] } +hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } metrics = "0.24" metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] } [features] diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 10dbd23..e207a58 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -3,6 +3,7 @@ import { initAuth, getAuthState } from './lib/auth.svelte' import Login from './routes/Login.svelte' import Register from './routes/Register.svelte' + import Verify from './routes/Verify.svelte' import ResetPassword from './routes/ResetPassword.svelte' import Dashboard from './routes/Dashboard.svelte' import AppPasswords from './routes/AppPasswords.svelte' @@ -25,6 +26,8 @@ return Login case '/register': return Register + case '/verify': + return Verify case '/reset-password': return ResetPassword case '/dashboard': diff --git a/frontend/src/routes/Login.svelte b/frontend/src/routes/Login.svelte index 26f7bbb..127359e 100644 --- a/frontend/src/routes/Login.svelte +++ b/frontend/src/routes/Login.svelte @@ -8,11 +8,6 @@ let resendMessage = $state(null) let showNewLogin = $state(false) const auth = getAuthState() - $effect(() => { - if (auth.session) { - navigate('/dashboard') - } - }) async function handleSwitchAccount(did: string) { submitting = true try { diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index a8e2334..daa291e 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -1,7 +1,10 @@
{#if error}
{error}
{/if} - {#if pendingVerification} -

Verify Your Account

-

- We've sent a verification code to your {channelLabel(pendingVerification.channel)}. - Enter it below to complete registration. -

- {#if resendMessage} -
{resendMessage}
- {/if} -
{ e.preventDefault(); handleVerification(e); }}> -
- - -
- - -
- {:else} -

Create Account

+

Create Account

Create a new account on this PDS

{#if loadingServerInfo}

Loading...

@@ -322,17 +251,6 @@ required />
- {:else} -
- - -
{/if} + + + + + + {:else} +

Account Verification

+

No pending verification found.

+

+ If you recently created an account and need to verify it, you may need to create a new account. + If you already verified your account, you can sign in. +

+ + {/if} + + + diff --git a/migrations/20251219_rename_email_confirmed.sql b/migrations/20251219_rename_email_confirmed.sql new file mode 100644 index 0000000..5ce3999 --- /dev/null +++ b/migrations/20251219_rename_email_confirmed.sql @@ -0,0 +1,6 @@ +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'email_confirmed') THEN + ALTER TABLE users RENAME COLUMN email_confirmed TO email_verified; + END IF; +END $$; diff --git a/migrations/20251220_rename_notifications_to_comms.sql b/migrations/20251220_rename_notifications_to_comms.sql new file mode 100644 index 0000000..9b9b2f0 --- /dev/null +++ b/migrations/20251220_rename_notifications_to_comms.sql @@ -0,0 +1,27 @@ +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN + ALTER TYPE notification_channel RENAME TO comms_channel; + END IF; + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN + ALTER TYPE notification_status RENAME TO comms_status; + END IF; + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN + ALTER TYPE notification_type RENAME TO comms_type; + END IF; + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'notification_queue') THEN + ALTER TABLE notification_queue RENAME TO comms_queue; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comms_queue' AND column_name = 'notification_type') THEN + ALTER TABLE comms_queue RENAME COLUMN notification_type TO comms_type; + END IF; + IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_status_scheduled') THEN + ALTER INDEX idx_notification_queue_status_scheduled RENAME TO idx_comms_queue_status_scheduled; + END IF; + IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_user_id') THEN + ALTER INDEX idx_notification_queue_user_id RENAME TO idx_comms_queue_user_id; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'preferred_notification_channel') THEN + ALTER TABLE users RENAME COLUMN preferred_notification_channel TO preferred_comms_channel; + END IF; +END $$; diff --git a/src/api/admin/account/email.rs b/src/api/admin/account/email.rs index d61066d..756d73a 100644 --- a/src/api/admin/account/email.rs +++ b/src/api/admin/account/email.rs @@ -87,14 +87,14 @@ pub async fn send_email( .subject .clone() .unwrap_or_else(|| format!("Message from {}", hostname)); - let notification = crate::notifications::NewNotification::email( + let item = crate::comms::NewComms::email( user_id, - crate::notifications::NotificationType::AdminEmail, + crate::comms::CommsType::AdminEmail, email, subject, content.to_string(), ); - let result = crate::notifications::enqueue_notification(&state.db, notification).await; + let result = crate::comms::enqueue_comms(&state.db, item).await; match result { Ok(_) => { tracing::info!("Admin email queued for {} ({})", handle, recipient_did); diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs index b48d7d2..48db175 100644 --- a/src/api/admin/account/info.rs +++ b/src/api/admin/account/info.rs @@ -24,7 +24,7 @@ pub struct AccountInfo { pub indexed_at: String, pub invite_note: Option, pub invites_disabled: bool, - pub email_confirmed_at: Option, + pub email_verified_at: Option, pub deactivated_at: Option, } @@ -67,7 +67,7 @@ pub async fn get_account_info( indexed_at: row.created_at.to_rfc3339(), invite_note: None, invites_disabled: false, - email_confirmed_at: None, + email_verified_at: None, deactivated_at: None, }), ) @@ -143,7 +143,7 @@ pub async fn get_account_infos( indexed_at: row.created_at.to_rfc3339(), invite_note: None, invites_disabled: false, - email_confirmed_at: None, + email_verified_at: None, deactivated_at: None, }); } diff --git a/src/api/admin/account/search.rs b/src/api/admin/account/search.rs index c14d09e..05612ff 100644 --- a/src/api/admin/account/search.rs +++ b/src/api/admin/account/search.rs @@ -31,7 +31,7 @@ pub struct AccountView { pub email: Option, pub indexed_at: String, #[serde(skip_serializing_if = "Option::is_none")] - pub email_confirmed_at: Option, + pub email_verified_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub deactivated_at: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -56,7 +56,7 @@ pub async fn search_accounts( let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h)); let result = sqlx::query_as::<_, (String, String, Option, chrono::DateTime, bool, Option>)>( r#" - SELECT did, handle, email, created_at, email_confirmed, deactivated_at + SELECT did, handle, email, created_at, email_verified, deactivated_at FROM users WHERE did > $1 AND ($2::text IS NULL OR handle ILIKE $2) ORDER BY did ASC @@ -74,12 +74,12 @@ pub async fn search_accounts( let accounts: Vec = rows .into_iter() .take(limit as usize) - .map(|(did, handle, email, created_at, email_confirmed, deactivated_at)| AccountView { + .map(|(did, handle, email, created_at, email_verified, deactivated_at)| AccountView { did: did.clone(), handle, email, indexed_at: created_at.to_rfc3339(), - email_confirmed_at: if email_confirmed { + email_verified_at: if email_verified { Some(created_at.to_rfc3339()) } else { None diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index e2eb37b..308bcb0 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -322,26 +322,53 @@ pub async fn create_account( } Ok(None) => {} } - if let Some(code) = &input.invite_code { - let invite_query = sqlx::query!( - "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", - code + let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + if invite_code_required && input.invite_code.as_ref().map(|c| c.trim().is_empty()).unwrap_or(true) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})), ) - .fetch_optional(&mut *tx) - .await; - match invite_query { - Ok(Some(row)) => { - if row.available_uses <= 0 { - return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response(); + .into_response(); + } + if let Some(code) = &input.invite_code { + if !code.trim().is_empty() { + let invite_query = sqlx::query!( + "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", + code + ) + .fetch_optional(&mut *tx) + .await; + match invite_query { + Ok(Some(row)) => { + if row.available_uses <= 0 { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response(); + } + let update_invite = sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&mut *tx) + .await; + if let Err(e) = update_invite { + error!("Error updating invite code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } } - let update_invite = sqlx::query!( - "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", - code - ) - .execute(&mut *tx) - .await; - if let Err(e) = update_invite { - error!("Error updating invite code: {:?}", e); + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})), + ) + .into_response(); + } + Err(e) => { + error!("Error checking invite code: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -349,21 +376,6 @@ pub async fn create_account( .into_response(); } } - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})), - ) - .into_response(); - } - Err(e) => { - error!("Error checking invite code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } } } let password_hash = match hash(&input.password, DEFAULT_COST) { @@ -387,10 +399,10 @@ pub async fn create_account( let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( r#"INSERT INTO users ( handle, email, did, password_hash, - preferred_notification_channel, + preferred_comms_channel, discord_id, telegram_username, signal_number, is_admin - ) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8, $9) RETURNING id"#, + ) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9) RETURNING id"#, ) .bind(short_handle) .bind(&email) @@ -598,20 +610,22 @@ pub async fn create_account( .into_response(); } if let Some(code) = &input.invite_code { - let use_insert = sqlx::query!( - "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", - code, - user_id - ) - .execute(&mut *tx) - .await; - if let Err(e) = use_insert { - error!("Error recording invite usage: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + if !code.trim().is_empty() { + let use_insert = sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + user_id ) - .into_response(); + .execute(&mut *tx) + .await; + if let Err(e) = use_insert { + error!("Error recording invite usage: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } } } if let Err(e) = tx.commit().await { @@ -646,7 +660,7 @@ pub async fn create_account( { warn!("Failed to create default profile for {}: {}", did, e); } - if let Err(e) = crate::notifications::enqueue_signup_verification( + if let Err(e) = crate::comms::enqueue_signup_verification( &state.db, user_id, verification_channel, diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index 452c6b6..18f386c 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -53,11 +53,22 @@ pub async fn resolve_handle( .await; (StatusCode::OK, Json(json!({ "did": row.did }))).into_response() } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})), - ) - .into_response(), + Ok(None) => { + match crate::handle::resolve_handle(handle).await { + Ok(did) => { + let _ = state + .cache + .set(&cache_key, &did, std::time::Duration::from_secs(300)) + .await; + (StatusCode::OK, Json(json!({ "did": did }))).into_response() + } + Err(_) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})), + ) + .into_response(), + } + } Err(e) => { error!("DB error resolving handle: {:?}", e); ( @@ -396,6 +407,54 @@ pub async fn update_handle( ) .into_response(); } + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname); + let (handle_to_store, full_handle) = if is_service_domain { + let suffix = format!(".{}", hostname); + let short_handle = if new_handle.ends_with(&suffix) { + new_handle.strip_suffix(&suffix).unwrap_or(new_handle) + } else { + new_handle + }; + (short_handle.to_string(), format!("{}.{}", short_handle, hostname)) + } else { + match crate::handle::verify_handle_ownership(new_handle, &did).await { + Ok(()) => {} + Err(crate::handle::HandleResolutionError::NotFound) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "HandleNotAvailable", + "message": "Handle verification failed. Please set up DNS TXT record at _atproto.{} or serve your DID at https://{}/.well-known/atproto-did", + "handle": new_handle + })), + ) + .into_response(); + } + Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "HandleNotAvailable", + "message": format!("Handle points to different DID. Expected {}, got {}", expected, actual) + })), + ) + .into_response(); + } + Err(e) => { + warn!("Handle verification failed: {}", e); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "HandleNotAvailable", + "message": format!("Handle verification failed: {}", e) + })), + ) + .into_response(); + } + } + (new_handle.to_string(), new_handle.to_string()) + }; let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id) .fetch_optional(&state.db) .await @@ -403,7 +462,7 @@ pub async fn update_handle( .flatten(); let existing = sqlx::query!( "SELECT id FROM users WHERE handle = $1 AND id != $2", - new_handle, + handle_to_store, user_id ) .fetch_optional(&state.db) @@ -417,7 +476,7 @@ pub async fn update_handle( } let result = sqlx::query!( "UPDATE users SET handle = $1 WHERE id = $2", - new_handle, + handle_to_store, user_id ) .execute(&state.db) @@ -427,16 +486,20 @@ pub async fn update_handle( if let Some(old) = old_handle { let _ = state.cache.delete(&format!("handle:{}", old)).await; } - let _ = state.cache.delete(&format!("handle:{}", new_handle)).await; - let hostname = - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let full_handle = format!("{}.{}", new_handle, hostname); + let _ = state + .cache + .delete(&format!("handle:{}", handle_to_store)) + .await; + let _ = state.cache.delete(&format!("handle:{}", full_handle)).await; if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) .await { warn!("Failed to sequence identity event for handle update: {}", e); } + if let Err(e) = update_plc_handle(&state, &did, &full_handle).await { + warn!("Failed to update PLC handle: {}", e); + } (StatusCode::OK, Json(json!({}))).into_response() } Err(e) => { @@ -450,6 +513,38 @@ pub async fn update_handle( } } +async fn update_plc_handle( + state: &AppState, + did: &str, + new_handle: &str, +) -> Result<(), Box> { + if !did.starts_with("did:plc:") { + return Ok(()); + } + let user_row = sqlx::query!( + r#"SELECT u.id, uk.key_bytes, uk.encryption_version + FROM users u + JOIN user_keys uk ON u.id = uk.user_id + WHERE u.did = $1"#, + did + ) + .fetch_optional(&state.db) + .await?; + let user_row = match user_row { + Some(r) => r, + None => return Ok(()), + }; + let key_bytes = crate::config::decrypt_key(&user_row.key_bytes, user_row.encryption_version)?; + let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)?; + let plc_client = crate::plc::PlcClient::new(None); + let last_op = plc_client.get_last_op(did).await?; + let new_also_known_as = vec![format!("at://{}", new_handle)]; + let update_op = crate::plc::create_update_op(&last_op, None, None, Some(new_also_known_as), None)?; + let signed_op = crate::plc::sign_operation(&update_op, &signing_key)?; + plc_client.send_operation(did, &signed_op).await?; + Ok(()) +} + pub async fn well_known_atproto_did(State(state): State, headers: HeaderMap) -> Response { let host = match headers.get("host").and_then(|h| h.to_str().ok()) { Some(h) => h, diff --git a/src/api/identity/plc/request.rs b/src/api/identity/plc/request.rs index b483cd5..dd4f45c 100644 --- a/src/api/identity/plc/request.rs +++ b/src/api/identity/plc/request.rs @@ -68,7 +68,7 @@ pub async fn request_plc_operation_signature( } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = - crate::notifications::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await + crate::comms::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await { warn!("Failed to enqueue PLC operation notification: {:?}", e); } diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs index 2b40627..989302c 100644 --- a/src/api/notification_prefs.rs +++ b/src/api/notification_prefs.rs @@ -60,7 +60,7 @@ pub async fn get_notification_prefs(State(state): State, headers: Head r#" SELECT email, - preferred_notification_channel::text as channel, + preferred_comms_channel::text as channel, discord_id, discord_verified, telegram_username, @@ -110,7 +110,7 @@ pub async fn get_notification_prefs(State(state): State, headers: Head pub struct NotificationHistoryEntry { pub created_at: String, pub channel: String, - pub notification_type: String, + pub comms_type: String, pub status: String, pub subject: Option, pub body: String, @@ -164,11 +164,11 @@ pub async fn get_notification_history( SELECT created_at, channel as "channel: String", - notification_type as "notification_type: String", + comms_type as "comms_type: String", status as "status: String", subject, body - FROM notification_queue + FROM comms_queue WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50 @@ -190,7 +190,7 @@ pub async fn get_notification_history( NotificationHistoryEntry { created_at: row.created_at.to_rfc3339(), channel: row.channel.clone(), - notification_type: row.notification_type.clone(), + comms_type: row.comms_type.clone(), status: row.status.clone(), subject: row.subject.clone(), body: row.body.clone(), @@ -231,7 +231,7 @@ pub async fn request_channel_verification( sqlx::query!( r#" INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) - VALUES ($1, $2::notification_channel, $3, $4, $5) + VALUES ($1, $2::comms_channel, $3, $4, $5) ON CONFLICT (user_id, channel) DO UPDATE SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW() "#, @@ -248,14 +248,14 @@ pub async fn request_channel_verification( if channel == "email" { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle_str = handle.unwrap_or("user"); - crate::notifications::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname) + crate::comms::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname) .await .map_err(|e| format!("Failed to enqueue email notification: {}", e))?; } else { sqlx::query!( r#" - INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata) - VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5) + INSERT INTO comms_queue (user_id, channel, comms_type, recipient, subject, body, metadata) + VALUES ($1, $2::comms_channel, 'channel_verification', $3, 'Verify your channel', $4, $5) "#, user_id, channel as _, @@ -331,7 +331,7 @@ pub async fn update_notification_prefs( .into_response(); } if let Err(e) = sqlx::query( - r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"# + r#"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2"# ) .bind(channel) .bind(&user.did) diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 2f55fee..43a2295 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -1,5 +1,5 @@ use super::validation::validate_record; -use super::write::has_verified_notification_channel; +use super::write::has_verified_comms_channel; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -109,7 +109,7 @@ pub async fn apply_writes( ) .into_response(); } - match has_verified_notification_channel(&state.db, &did).await { + match has_verified_comms_channel(&state.db, &did).await { Ok(true) => {} Ok(false) => { return ( diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index a698f2d..fa2b1f6 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -22,14 +22,14 @@ use std::sync::Arc; use tracing::error; use uuid::Uuid; -pub async fn has_verified_notification_channel( +pub async fn has_verified_comms_channel( db: &PgPool, did: &str, ) -> Result { let row = sqlx::query( r#" SELECT - email_confirmed, + email_verified, discord_verified, telegram_verified, signal_verified @@ -42,11 +42,11 @@ pub async fn has_verified_notification_channel( .await?; match row { Some(r) => { - let email_confirmed: bool = r.get("email_confirmed"); + let email_verified: bool = r.get("email_verified"); let discord_verified: bool = r.get("discord_verified"); let telegram_verified: bool = r.get("telegram_verified"); let signal_verified: bool = r.get("signal_verified"); - Ok(email_confirmed || discord_verified || telegram_verified || signal_verified) + Ok(email_verified || discord_verified || telegram_verified || signal_verified) } None => Ok(false), } @@ -96,7 +96,7 @@ pub async fn prepare_repo_write( ) .into_response()); } - match has_verified_notification_channel(&state.db, &auth_user.did).await { + match has_verified_comms_channel(&state.db, &auth_user.did).await { Ok(true) => {} Ok(false) => { return Err(( diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index abbefee..b5eb30d 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -299,7 +299,7 @@ pub async fn request_account_delete( .into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = crate::notifications::enqueue_account_deletion( + if let Err(e) = crate::comms::enqueue_account_deletion( &state.db, user_id, &confirmation_token, diff --git a/src/api/server/password.rs b/src/api/server/password.rs index dca17e8..6f623b8 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -100,7 +100,7 @@ pub async fn request_password_reset( } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = - crate::notifications::enqueue_password_reset(&state.db, user_id, &code, &hostname).await + crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await { warn!("Failed to enqueue password reset notification: {:?}", e); } diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 1f31b31..a698b07 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -35,6 +35,14 @@ fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { } } +fn full_handle(stored_handle: &str, pds_hostname: &str) -> String { + if stored_handle.contains('.') { + stored_handle.to_string() + } else { + format!("{}.{}", stored_handle, pds_hostname) + } +} + #[derive(Deserialize)] pub struct CreateSessionInput { pub identifier: String, @@ -76,7 +84,7 @@ pub async fn create_session( let row = match sqlx::query!( r#"SELECT u.id, u.did, u.handle, u.password_hash, - u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified, + u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id @@ -128,7 +136,7 @@ pub async fn create_session( .into_response(); } let is_verified = - row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; + row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; if !is_verified { warn!("Login attempt for unverified account: {}", row.did); return ( @@ -169,11 +177,11 @@ pub async fn create_session( error!("Failed to insert session: {:?}", e); return ApiError::InternalError.into_response(); } - let full_handle = format!("{}.{}", row.handle, pds_hostname); + let handle = full_handle(&row.handle, &pds_hostname); Json(CreateSessionOutput { access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, - handle: full_handle, + handle, did: row.did, }) .into_response() @@ -185,8 +193,8 @@ pub async fn get_session( ) -> Response { match sqlx::query!( r#"SELECT - handle, email, email_confirmed, is_admin, - preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", + handle, email, email_verified, is_admin, + preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1"#, auth_user.did @@ -196,18 +204,18 @@ pub async fn get_session( { Ok(Some(row)) => { let (preferred_channel, preferred_channel_verified) = match row.preferred_channel { - crate::notifications::NotificationChannel::Email => ("email", row.email_confirmed), - crate::notifications::NotificationChannel::Discord => ("discord", row.discord_verified), - crate::notifications::NotificationChannel::Telegram => ("telegram", row.telegram_verified), - crate::notifications::NotificationChannel::Signal => ("signal", row.signal_verified), + crate::comms::CommsChannel::Email => ("email", row.email_verified), + crate::comms::CommsChannel::Discord => ("discord", row.discord_verified), + crate::comms::CommsChannel::Telegram => ("telegram", row.telegram_verified), + crate::comms::CommsChannel::Signal => ("signal", row.signal_verified), }; let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let full_handle = format!("{}.{}", row.handle, pds_hostname); + let handle = full_handle(&row.handle, &pds_hostname); Json(json!({ - "handle": full_handle, + "handle": handle, "did": auth_user.did, "email": row.email, - "emailConfirmed": row.email_confirmed, + "emailVerified": row.email_verified, "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, "isAdmin": row.is_admin, @@ -407,8 +415,8 @@ pub async fn refresh_session( } match sqlx::query!( r#"SELECT - handle, email, email_confirmed, is_admin, - preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", + handle, email, email_verified, is_admin, + preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1"#, session_row.did @@ -418,20 +426,20 @@ pub async fn refresh_session( { Ok(Some(u)) => { let (preferred_channel, preferred_channel_verified) = match u.preferred_channel { - crate::notifications::NotificationChannel::Email => ("email", u.email_confirmed), - crate::notifications::NotificationChannel::Discord => ("discord", u.discord_verified), - crate::notifications::NotificationChannel::Telegram => ("telegram", u.telegram_verified), - crate::notifications::NotificationChannel::Signal => ("signal", u.signal_verified), + crate::comms::CommsChannel::Email => ("email", u.email_verified), + crate::comms::CommsChannel::Discord => ("discord", u.discord_verified), + crate::comms::CommsChannel::Telegram => ("telegram", u.telegram_verified), + crate::comms::CommsChannel::Signal => ("signal", u.signal_verified), }; let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let full_handle = format!("{}.{}", u.handle, pds_hostname); + let handle = full_handle(&u.handle, &pds_hostname); Json(json!({ "accessJwt": new_access_meta.token, "refreshJwt": new_refresh_meta.token, - "handle": full_handle, + "handle": handle, "did": session_row.did, "email": u.email, - "emailConfirmed": u.email_confirmed, + "emailVerified": u.email_verified, "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, "isAdmin": u.is_admin, @@ -464,7 +472,7 @@ pub struct ConfirmSignupOutput { pub handle: String, pub did: String, pub email: Option, - pub email_confirmed: bool, + pub email_verified: bool, pub preferred_channel: String, pub preferred_channel_verified: bool, } @@ -477,7 +485,7 @@ pub async fn confirm_signup( let row = match sqlx::query!( r#"SELECT u.id, u.did, u.handle, u.email, - u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", + u.preferred_comms_channel as "channel: crate::comms::CommsChannel", k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id @@ -534,10 +542,10 @@ pub async fn confirm_signup( } }; let verified_column = match row.channel { - crate::notifications::NotificationChannel::Email => "email_confirmed", - crate::notifications::NotificationChannel::Discord => "discord_verified", - crate::notifications::NotificationChannel::Telegram => "telegram_verified", - crate::notifications::NotificationChannel::Signal => "signal_verified", + crate::comms::CommsChannel::Email => "email_verified", + crate::comms::CommsChannel::Discord => "discord_verified", + crate::comms::CommsChannel::Telegram => "telegram_verified", + crate::comms::CommsChannel::Signal => "signal_verified", }; let update_query = format!( "UPDATE users SET {} = TRUE WHERE did = $1", @@ -590,18 +598,18 @@ pub async fn confirm_signup( return ApiError::InternalError.into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await { + if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await { warn!("Failed to enqueue welcome notification: {:?}", e); } - let email_confirmed = matches!( + let email_verified = matches!( row.channel, - crate::notifications::NotificationChannel::Email + crate::comms::CommsChannel::Email ); let preferred_channel = match row.channel { - crate::notifications::NotificationChannel::Email => "email", - crate::notifications::NotificationChannel::Discord => "discord", - crate::notifications::NotificationChannel::Telegram => "telegram", - crate::notifications::NotificationChannel::Signal => "signal", + crate::comms::CommsChannel::Email => "email", + crate::comms::CommsChannel::Discord => "discord", + crate::comms::CommsChannel::Telegram => "telegram", + crate::comms::CommsChannel::Signal => "signal", }; Json(ConfirmSignupOutput { access_jwt: access_meta.token, @@ -609,7 +617,7 @@ pub async fn confirm_signup( handle: row.handle, did: row.did, email: row.email, - email_confirmed, + email_verified, preferred_channel: preferred_channel.to_string(), preferred_channel_verified: true, }) @@ -630,9 +638,9 @@ pub async fn resend_verification( let row = match sqlx::query!( r#"SELECT id, handle, email, - preferred_notification_channel as "channel: crate::notifications::NotificationChannel", + preferred_comms_channel as "channel: crate::comms::CommsChannel", discord_id, telegram_username, signal_number, - email_confirmed, discord_verified, telegram_verified, signal_verified + email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1"#, input.did @@ -650,7 +658,7 @@ pub async fn resend_verification( } }; let is_verified = - row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; + row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; if is_verified { return ApiError::InvalidRequest("Account is already verified".into()).into_response(); } @@ -678,20 +686,20 @@ pub async fn resend_verification( return ApiError::InternalError.into_response(); } let (channel_str, recipient) = match row.channel { - crate::notifications::NotificationChannel::Email => { + crate::comms::CommsChannel::Email => { ("email", row.email.unwrap_or_default()) } - crate::notifications::NotificationChannel::Discord => { + crate::comms::CommsChannel::Discord => { ("discord", row.discord_id.unwrap_or_default()) } - crate::notifications::NotificationChannel::Telegram => { + crate::comms::CommsChannel::Telegram => { ("telegram", row.telegram_username.unwrap_or_default()) } - crate::notifications::NotificationChannel::Signal => { + crate::comms::CommsChannel::Signal => { ("signal", row.signal_number.unwrap_or_default()) } }; - if let Err(e) = crate::notifications::enqueue_signup_verification( + if let Err(e) = crate::comms::enqueue_signup_verification( &state.db, row.id, channel_str, diff --git a/src/api/verification.rs b/src/api/verification.rs index 2bee2c9..7ed9552 100644 --- a/src/api/verification.rs +++ b/src/api/verification.rs @@ -68,7 +68,7 @@ pub async fn confirm_channel_verification( let record = match sqlx::query!( r#" SELECT code, pending_identifier, expires_at FROM channel_verifications - WHERE user_id = $1 AND channel = $2::notification_channel + WHERE user_id = $1 AND channel = $2::comms_channel "#, user_id, channel_str as _ @@ -163,7 +163,7 @@ pub async fn confirm_channel_verification( } if let Err(e) = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel", + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", user_id, channel_str as _ ) diff --git a/src/comms/mod.rs b/src/comms/mod.rs new file mode 100644 index 0000000..c14c4be --- /dev/null +++ b/src/comms/mod.rs @@ -0,0 +1,16 @@ +mod sender; +mod service; +mod types; + +pub use sender::{ + CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender, + is_valid_phone_number, sanitize_header_value, +}; + +pub use service::{ + CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, + enqueue_comms, enqueue_email_update, enqueue_email_verification, enqueue_password_reset, + enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, +}; + +pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms}; diff --git a/src/notifications/sender.rs b/src/comms/sender.rs similarity index 89% rename from src/notifications/sender.rs rename to src/comms/sender.rs index 4a2b4ff..c2b9434 100644 --- a/src/notifications/sender.rs +++ b/src/comms/sender.rs @@ -6,16 +6,16 @@ use std::time::Duration; use tokio::io::AsyncWriteExt; use tokio::process::Command; -use super::types::{NotificationChannel, QueuedNotification}; +use super::types::{CommsChannel, QueuedComms}; const HTTP_TIMEOUT_SECS: u64 = 30; const MAX_RETRIES: u32 = 3; const INITIAL_RETRY_DELAY_MS: u64 = 500; #[async_trait] -pub trait NotificationSender: Send + Sync { - fn channel(&self) -> NotificationChannel; - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError>; +pub trait CommsSender: Send + Sync { + fn channel(&self) -> CommsChannel; + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError>; } #[derive(Debug, thiserror::Error)] @@ -25,7 +25,7 @@ pub enum SendError { #[error("Sendmail exited with non-zero status: {0}")] SendmailFailed(String), #[error("Channel not configured: {0:?}")] - NotConfigured(NotificationChannel), + NotConfigured(CommsChannel), #[error("External service error: {0}")] ExternalService(String), #[error("Invalid recipient format: {0}")] @@ -91,7 +91,7 @@ impl EmailSender { Some(Self::new(from_address, from_name)) } - pub fn format_email(&self, notification: &QueuedNotification) -> String { + pub fn format_email(&self, notification: &QueuedComms) -> String { let subject = sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification")); let recipient = sanitize_header_value(¬ification.recipient); @@ -112,12 +112,12 @@ impl EmailSender { } #[async_trait] -impl NotificationSender for EmailSender { - fn channel(&self) -> NotificationChannel { - NotificationChannel::Email +impl CommsSender for EmailSender { + fn channel(&self) -> CommsChannel { + CommsChannel::Email } - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> { + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { let email_content = self.format_email(notification); let mut child = Command::new(&self.sendmail_path) .arg("-t") @@ -158,12 +158,12 @@ impl DiscordSender { } #[async_trait] -impl NotificationSender for DiscordSender { - fn channel(&self) -> NotificationChannel { - NotificationChannel::Discord +impl CommsSender for DiscordSender { + fn channel(&self) -> CommsChannel { + CommsChannel::Discord } - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> { + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { let subject = notification.subject.as_deref().unwrap_or("Notification"); let content = format!("**{}**\n\n{}", subject, notification.body); let payload = json!({ @@ -237,12 +237,12 @@ impl TelegramSender { } #[async_trait] -impl NotificationSender for TelegramSender { - fn channel(&self) -> NotificationChannel { - NotificationChannel::Telegram +impl CommsSender for TelegramSender { + fn channel(&self) -> CommsChannel { + CommsChannel::Telegram } - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> { + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { let chat_id = ¬ification.recipient; let subject = notification.subject.as_deref().unwrap_or("Notification"); let text = format!("*{}*\n\n{}", subject, notification.body); @@ -316,12 +316,12 @@ impl SignalSender { } #[async_trait] -impl NotificationSender for SignalSender { - fn channel(&self) -> NotificationChannel { - NotificationChannel::Signal +impl CommsSender for SignalSender { + fn channel(&self) -> CommsChannel { + CommsChannel::Signal } - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> { + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { let recipient = ¬ification.recipient; if !is_valid_phone_number(recipient) { return Err(SendError::InvalidRecipient(format!( diff --git a/src/notifications/service.rs b/src/comms/service.rs similarity index 63% rename from src/notifications/service.rs rename to src/comms/service.rs index de62eed..3a75585 100644 --- a/src/notifications/service.rs +++ b/src/comms/service.rs @@ -9,17 +9,17 @@ use tokio::time::interval; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use super::sender::{NotificationSender, SendError}; -use super::types::{NewNotification, NotificationChannel, NotificationStatus, QueuedNotification}; +use super::sender::{CommsSender, SendError}; +use super::types::{NewComms, CommsChannel, CommsStatus, QueuedComms}; -pub struct NotificationService { +pub struct CommsService { db: PgPool, - senders: HashMap>, + senders: HashMap>, poll_interval: Duration, batch_size: i64, } -impl NotificationService { +impl CommsService { pub fn new(db: PgPool) -> Self { let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS") .ok() @@ -47,30 +47,30 @@ impl NotificationService { self } - pub fn register_sender(mut self, sender: S) -> Self { + pub fn register_sender(mut self, sender: S) -> Self { self.senders.insert(sender.channel(), Arc::new(sender)); self } - pub async fn enqueue(&self, notification: NewNotification) -> Result { + pub async fn enqueue(&self, item: NewComms) -> Result { let id = sqlx::query_scalar!( r#" - INSERT INTO notification_queue - (user_id, channel, notification_type, recipient, subject, body, metadata) + INSERT INTO comms_queue + (user_id, channel, comms_type, recipient, subject, body, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id "#, - notification.user_id, - notification.channel as NotificationChannel, - notification.notification_type as super::types::NotificationType, - notification.recipient, - notification.subject, - notification.body, - notification.metadata + item.user_id, + item.channel as CommsChannel, + item.comms_type as super::types::CommsType, + item.recipient, + item.subject, + item.body, + item.metadata ) .fetch_one(&self.db) .await?; - debug!(notification_id = %id, "Notification enqueued"); + debug!(comms_id = %id, "Comms enqueued"); Ok(id) } @@ -81,26 +81,26 @@ impl NotificationService { pub async fn run(self, mut shutdown: watch::Receiver) { if self.senders.is_empty() { warn!( - "Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured." + "Comms service starting with no senders configured. Messages will be queued but not delivered until senders are configured." ); } info!( poll_interval_secs = self.poll_interval.as_secs(), batch_size = self.batch_size, channels = ?self.senders.keys().collect::>(), - "Starting notification service" + "Starting comms service" ); let mut ticker = interval(self.poll_interval); loop { tokio::select! { _ = ticker.tick() => { if let Err(e) = self.process_batch().await { - error!(error = %e, "Failed to process notification batch"); + error!(error = %e, "Failed to process comms batch"); } } _ = shutdown.changed() => { if *shutdown.borrow() { - info!("Notification service shutting down"); + info!("Comms service shutting down"); break; } } @@ -109,26 +109,26 @@ impl NotificationService { } async fn process_batch(&self) -> Result<(), sqlx::Error> { - let notifications = self.fetch_pending_notifications().await?; - if notifications.is_empty() { + let items = self.fetch_pending().await?; + if items.is_empty() { return Ok(()); } - debug!(count = notifications.len(), "Processing notification batch"); - for notification in notifications { - self.process_notification(notification).await; + debug!(count = items.len(), "Processing comms batch"); + for item in items { + self.process_item(item).await; } Ok(()) } - async fn fetch_pending_notifications(&self) -> Result, sqlx::Error> { + async fn fetch_pending(&self) -> Result, sqlx::Error> { let now = Utc::now(); sqlx::query_as!( - QueuedNotification, + QueuedComms, r#" - UPDATE notification_queue + UPDATE comms_queue SET status = 'processing', updated_at = NOW() WHERE id IN ( - SELECT id FROM notification_queue + SELECT id FROM comms_queue WHERE status = 'pending' AND scheduled_for <= $1 AND attempts < max_attempts @@ -138,9 +138,9 @@ impl NotificationService { ) RETURNING id, user_id, - channel as "channel: NotificationChannel", - notification_type as "notification_type: super::types::NotificationType", - status as "status: NotificationStatus", + channel as "channel: CommsChannel", + comms_type as "comms_type: super::types::CommsType", + status as "status: CommsStatus", recipient, subject, body, metadata, attempts, max_attempts, last_error, created_at, updated_at, scheduled_for, processed_at @@ -152,14 +152,14 @@ impl NotificationService { .await } - async fn process_notification(&self, notification: QueuedNotification) { - let notification_id = notification.id; - let channel = notification.channel; + async fn process_item(&self, item: QueuedComms) { + let comms_id = item.id; + let channel = item.channel; let result = match self.senders.get(&channel) { - Some(sender) => sender.send(¬ification).await, + Some(sender) => sender.send(&item).await, None => { warn!( - notification_id = %notification_id, + comms_id = %comms_id, channel = ?channel, "No sender registered for channel" ); @@ -168,27 +168,27 @@ impl NotificationService { }; match result { Ok(()) => { - debug!(notification_id = %notification_id, "Notification sent successfully"); - if let Err(e) = self.mark_sent(notification_id).await { + debug!(comms_id = %comms_id, "Comms sent successfully"); + if let Err(e) = self.mark_sent(comms_id).await { error!( - notification_id = %notification_id, + comms_id = %comms_id, error = %e, - "Failed to mark notification as sent" + "Failed to mark comms as sent" ); } } Err(e) => { let error_msg = e.to_string(); warn!( - notification_id = %notification_id, + comms_id = %comms_id, error = %error_msg, - "Failed to send notification" + "Failed to send comms" ); - if let Err(db_err) = self.mark_failed(notification_id, &error_msg).await { + if let Err(db_err) = self.mark_failed(comms_id, &error_msg).await { error!( - notification_id = %notification_id, + comms_id = %comms_id, error = %db_err, - "Failed to mark notification as failed" + "Failed to mark comms as failed" ); } } @@ -198,7 +198,7 @@ impl NotificationService { async fn mark_sent(&self, id: Uuid) -> Result<(), sqlx::Error> { sqlx::query!( r#" - UPDATE notification_queue + UPDATE comms_queue SET status = 'sent', processed_at = NOW(), updated_at = NOW() WHERE id = $1 "#, @@ -212,11 +212,11 @@ impl NotificationService { async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), sqlx::Error> { sqlx::query!( r#" - UPDATE notification_queue + UPDATE comms_queue SET status = CASE - WHEN attempts + 1 >= max_attempts THEN 'failed'::notification_status - ELSE 'pending'::notification_status + WHEN attempts + 1 >= max_attempts THEN 'failed'::comms_status + ELSE 'pending'::comms_status END, attempts = attempts + 1, last_error = $2, @@ -233,45 +233,42 @@ impl NotificationService { } } -pub async fn enqueue_notification( - db: &PgPool, - notification: NewNotification, -) -> Result { +pub async fn enqueue_comms(db: &PgPool, item: NewComms) -> Result { sqlx::query_scalar!( r#" - INSERT INTO notification_queue - (user_id, channel, notification_type, recipient, subject, body, metadata) + INSERT INTO comms_queue + (user_id, channel, comms_type, recipient, subject, body, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id "#, - notification.user_id, - notification.channel as NotificationChannel, - notification.notification_type as super::types::NotificationType, - notification.recipient, - notification.subject, - notification.body, - notification.metadata + item.user_id, + item.channel as CommsChannel, + item.comms_type as super::types::CommsType, + item.recipient, + item.subject, + item.body, + item.metadata ) .fetch_one(db) .await } -pub struct UserNotificationPrefs { - pub channel: NotificationChannel, +pub struct UserCommsPrefs { + pub channel: CommsChannel, pub email: Option, pub handle: String, } -pub async fn get_user_notification_prefs( +pub async fn get_user_comms_prefs( db: &PgPool, user_id: Uuid, -) -> Result { +) -> Result { let row = sqlx::query!( r#" SELECT email, handle, - preferred_notification_channel as "channel: NotificationChannel" + preferred_comms_channel as "channel: CommsChannel" FROM users WHERE id = $1 "#, @@ -279,7 +276,7 @@ pub async fn get_user_notification_prefs( ) .fetch_one(db) .await?; - Ok(UserNotificationPrefs { + Ok(UserCommsPrefs { channel: row.channel, email: row.email, handle: row.handle, @@ -291,17 +288,17 @@ pub async fn enqueue_welcome( user_id: Uuid, hostname: &str, ) -> Result { - let prefs = get_user_notification_prefs(db, user_id).await?; + let prefs = get_user_comms_prefs(db, user_id).await?; let body = format!( "Welcome to {}!\n\nYour handle is: @{}\n\nThank you for joining us.", hostname, prefs.handle ); - enqueue_notification( + enqueue_comms( db, - NewNotification::new( + NewComms::new( user_id, prefs.channel, - super::types::NotificationType::Welcome, + super::types::CommsType::Welcome, prefs.email.clone().unwrap_or_default(), Some(format!("Welcome to {}", hostname)), body, @@ -322,11 +319,11 @@ pub async fn enqueue_email_verification( "Hello @{},\n\nYour email verification code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.", handle, code ); - enqueue_notification( + enqueue_comms( db, - NewNotification::email( + NewComms::email( user_id, - super::types::NotificationType::EmailVerification, + super::types::CommsType::EmailVerification, email.to_string(), format!("Verify your email - {}", hostname), body, @@ -341,17 +338,17 @@ pub async fn enqueue_password_reset( code: &str, hostname: &str, ) -> Result { - let prefs = get_user_notification_prefs(db, user_id).await?; + let prefs = get_user_comms_prefs(db, user_id).await?; let body = format!( "Hello @{},\n\nYour password reset code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.", prefs.handle, code ); - enqueue_notification( + enqueue_comms( db, - NewNotification::new( + NewComms::new( user_id, prefs.channel, - super::types::NotificationType::PasswordReset, + super::types::CommsType::PasswordReset, prefs.email.clone().unwrap_or_default(), Some(format!("Password Reset - {}", hostname)), body, @@ -372,11 +369,11 @@ pub async fn enqueue_email_update( "Hello @{},\n\nYour email update confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.", handle, code ); - enqueue_notification( + enqueue_comms( db, - NewNotification::email( + NewComms::email( user_id, - super::types::NotificationType::EmailUpdate, + super::types::CommsType::EmailUpdate, new_email.to_string(), format!("Confirm your new email - {}", hostname), body, @@ -391,17 +388,17 @@ pub async fn enqueue_account_deletion( code: &str, hostname: &str, ) -> Result { - let prefs = get_user_notification_prefs(db, user_id).await?; + let prefs = get_user_comms_prefs(db, user_id).await?; let body = format!( "Hello @{},\n\nYour account deletion confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", prefs.handle, code ); - enqueue_notification( + enqueue_comms( db, - NewNotification::new( + NewComms::new( user_id, prefs.channel, - super::types::NotificationType::AccountDeletion, + super::types::CommsType::AccountDeletion, prefs.email.clone().unwrap_or_default(), Some(format!("Account Deletion Request - {}", hostname)), body, @@ -416,17 +413,17 @@ pub async fn enqueue_plc_operation( token: &str, hostname: &str, ) -> Result { - let prefs = get_user_notification_prefs(db, user_id).await?; + let prefs = get_user_comms_prefs(db, user_id).await?; let body = format!( "Hello @{},\n\nYou requested to sign a PLC operation for your account.\n\nYour verification token is: {}\n\nThis token will expire in 10 minutes.\n\nIf you did not request this, you can safely ignore this message.", prefs.handle, token ); - enqueue_notification( + enqueue_comms( db, - NewNotification::new( + NewComms::new( user_id, prefs.channel, - super::types::NotificationType::PlcOperation, + super::types::CommsType::PlcOperation, prefs.email.clone().unwrap_or_default(), Some(format!("{} - PLC Operation Token", hostname)), body, @@ -441,17 +438,17 @@ pub async fn enqueue_2fa_code( code: &str, hostname: &str, ) -> Result { - let prefs = get_user_notification_prefs(db, user_id).await?; + let prefs = get_user_comms_prefs(db, user_id).await?; let body = format!( "Hello @{},\n\nYour sign-in verification code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", prefs.handle, code ); - enqueue_notification( + enqueue_comms( db, - NewNotification::new( + NewComms::new( user_id, prefs.channel, - super::types::NotificationType::TwoFactorCode, + super::types::CommsType::TwoFactorCode, prefs.email.clone().unwrap_or_default(), Some(format!("Sign-in Verification - {}", hostname)), body, @@ -460,12 +457,12 @@ pub async fn enqueue_2fa_code( .await } -pub fn channel_display_name(channel: NotificationChannel) -> &'static str { +pub fn channel_display_name(channel: CommsChannel) -> &'static str { match channel { - NotificationChannel::Email => "email", - NotificationChannel::Discord => "Discord", - NotificationChannel::Telegram => "Telegram", - NotificationChannel::Signal => "Signal", + CommsChannel::Email => "email", + CommsChannel::Discord => "Discord", + CommsChannel::Telegram => "Telegram", + CommsChannel::Signal => "Signal", } } @@ -477,27 +474,27 @@ pub async fn enqueue_signup_verification( code: &str, ) -> Result { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let notification_channel = match channel { - "email" => NotificationChannel::Email, - "discord" => NotificationChannel::Discord, - "telegram" => NotificationChannel::Telegram, - "signal" => NotificationChannel::Signal, - _ => NotificationChannel::Email, + let comms_channel = match channel { + "email" => CommsChannel::Email, + "discord" => CommsChannel::Discord, + "telegram" => CommsChannel::Telegram, + "signal" => CommsChannel::Signal, + _ => CommsChannel::Email, }; let body = format!( "Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.", code, hostname ); - let subject = match notification_channel { - NotificationChannel::Email => Some(format!("Verify your account - {}", hostname)), + let subject = match comms_channel { + CommsChannel::Email => Some(format!("Verify your account - {}", hostname)), _ => None, }; - enqueue_notification( + enqueue_comms( db, - NewNotification::new( + NewComms::new( user_id, - notification_channel, - super::types::NotificationType::EmailVerification, + comms_channel, + super::types::CommsType::EmailVerification, recipient.to_string(), subject, body, diff --git a/src/notifications/types.rs b/src/comms/types.rs similarity index 68% rename from src/notifications/types.rs rename to src/comms/types.rs index f8eacb3..4c3d6e2 100644 --- a/src/notifications/types.rs +++ b/src/comms/types.rs @@ -4,8 +4,8 @@ use sqlx::FromRow; use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)] -#[sqlx(type_name = "notification_channel", rename_all = "lowercase")] -pub enum NotificationChannel { +#[sqlx(type_name = "comms_channel", rename_all = "lowercase")] +pub enum CommsChannel { Email, Discord, Telegram, @@ -13,8 +13,8 @@ pub enum NotificationChannel { } #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] -#[sqlx(type_name = "notification_status", rename_all = "lowercase")] -pub enum NotificationStatus { +#[sqlx(type_name = "comms_status", rename_all = "lowercase")] +pub enum CommsStatus { Pending, Processing, Sent, @@ -22,8 +22,8 @@ pub enum NotificationStatus { } #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] -#[sqlx(type_name = "notification_type", rename_all = "snake_case")] -pub enum NotificationType { +#[sqlx(type_name = "comms_type", rename_all = "snake_case")] +pub enum CommsType { Welcome, EmailVerification, PasswordReset, @@ -35,12 +35,12 @@ pub enum NotificationType { } #[derive(Debug, Clone, FromRow)] -pub struct QueuedNotification { +pub struct QueuedComms { pub id: Uuid, pub user_id: Uuid, - pub channel: NotificationChannel, - pub notification_type: NotificationType, - pub status: NotificationStatus, + pub channel: CommsChannel, + pub comms_type: CommsType, + pub status: CommsStatus, pub recipient: String, pub subject: Option, pub body: String, @@ -54,21 +54,21 @@ pub struct QueuedNotification { pub processed_at: Option>, } -pub struct NewNotification { +pub struct NewComms { pub user_id: Uuid, - pub channel: NotificationChannel, - pub notification_type: NotificationType, + pub channel: CommsChannel, + pub comms_type: CommsType, pub recipient: String, pub subject: Option, pub body: String, pub metadata: Option, } -impl NewNotification { +impl NewComms { pub fn new( user_id: Uuid, - channel: NotificationChannel, - notification_type: NotificationType, + channel: CommsChannel, + comms_type: CommsType, recipient: String, subject: Option, body: String, @@ -76,7 +76,7 @@ impl NewNotification { Self { user_id, channel, - notification_type, + comms_type, recipient, subject, body, @@ -86,15 +86,15 @@ impl NewNotification { pub fn email( user_id: Uuid, - notification_type: NotificationType, + comms_type: CommsType, recipient: String, subject: String, body: String, ) -> Self { Self::new( user_id, - NotificationChannel::Email, - notification_type, + CommsChannel::Email, + comms_type, recipient, Some(subject), body, diff --git a/src/handle/mod.rs b/src/handle/mod.rs new file mode 100644 index 0000000..31cb09a --- /dev/null +++ b/src/handle/mod.rs @@ -0,0 +1,121 @@ +use hickory_resolver::config::{ResolverConfig, ResolverOpts}; +use hickory_resolver::TokioAsyncResolver; +use reqwest::Client; +use std::time::Duration; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum HandleResolutionError { + #[error("DNS lookup failed: {0}")] + DnsError(String), + #[error("HTTP request failed: {0}")] + HttpError(String), + #[error("No DID found for handle")] + NotFound, + #[error("Invalid DID format in record")] + InvalidDid, + #[error("DID mismatch: expected {expected}, got {actual}")] + DidMismatch { expected: String, actual: String }, +} + +pub async fn resolve_handle_dns(handle: &str) -> Result { + let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()); + let query_name = format!("_atproto.{}", handle); + let txt_lookup = resolver + .txt_lookup(&query_name) + .await + .map_err(|e| HandleResolutionError::DnsError(e.to_string()))?; + for record in txt_lookup.iter() { + for txt in record.txt_data() { + let txt_str = String::from_utf8_lossy(txt); + if let Some(did) = txt_str.strip_prefix("did=") { + let did = did.trim(); + if did.starts_with("did:") { + return Ok(did.to_string()); + } + } + } + } + Err(HandleResolutionError::NotFound) +} + +pub async fn resolve_handle_http(handle: &str) -> Result { + let url = format!("https://{}/.well-known/atproto-did", handle); + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .map_err(|e| HandleResolutionError::HttpError(e.to_string()))?; + let response = client + .get(&url) + .header("Accept", "text/plain") + .send() + .await + .map_err(|e| HandleResolutionError::HttpError(e.to_string()))?; + if !response.status().is_success() { + return Err(HandleResolutionError::NotFound); + } + let body = response + .text() + .await + .map_err(|e| HandleResolutionError::HttpError(e.to_string()))?; + let did = body.trim(); + if did.starts_with("did:") { + Ok(did.to_string()) + } else { + Err(HandleResolutionError::InvalidDid) + } +} + +pub async fn resolve_handle(handle: &str) -> Result { + match resolve_handle_dns(handle).await { + Ok(did) => return Ok(did), + Err(e) => { + tracing::debug!("DNS resolution failed for {}: {}, trying HTTP", handle, e); + } + } + resolve_handle_http(handle).await +} + +pub async fn verify_handle_ownership( + handle: &str, + expected_did: &str, +) -> Result<(), HandleResolutionError> { + let resolved_did = resolve_handle(handle).await?; + if resolved_did == expected_did { + Ok(()) + } else { + Err(HandleResolutionError::DidMismatch { + expected: expected_did.to_string(), + actual: resolved_did, + }) + } +} + +pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool { + let service_domains: Vec = std::env::var("PDS_SERVICE_HANDLE_DOMAINS") + .map(|s| s.split(',').map(|d| d.trim().to_string()).collect()) + .unwrap_or_else(|_| vec![hostname.to_string()]); + for domain in service_domains { + if handle.ends_with(&format!(".{}", domain)) { + return true; + } + if handle == domain { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_service_domain_handle() { + assert!(is_service_domain_handle("user.example.com", "example.com")); + assert!(is_service_domain_handle("example.com", "example.com")); + assert!(!is_service_domain_handle("user.other.com", "example.com")); + assert!(!is_service_domain_handle("myhandle.xyz", "example.com")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3f2c2f7..81e200b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,10 @@ pub mod cache; pub mod circuit_breaker; pub mod config; pub mod crawlers; +pub mod handle; pub mod image; pub mod metrics; -pub mod notifications; +pub mod comms; pub mod oauth; pub mod plc; pub mod rate_limit; diff --git a/src/main.rs b/src/main.rs index d9dc231..1a15ef6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,5 @@ +use bspds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; use bspds::crawlers::{Crawlers, start_crawlers_service}; -use bspds::notifications::{ - DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender, -}; use bspds::state::AppState; use std::net::SocketAddr; use std::process::ExitCode; @@ -68,31 +66,31 @@ async fn run() -> Result<(), Box> { let (shutdown_tx, shutdown_rx) = watch::channel(false); - let mut notification_service = NotificationService::new(pool); + let mut comms_service = CommsService::new(pool); if let Some(email_sender) = EmailSender::from_env() { - info!("Email notifications enabled"); - notification_service = notification_service.register_sender(email_sender); + info!("Email comms enabled"); + comms_service = comms_service.register_sender(email_sender); } else { - warn!("Email notifications disabled (MAIL_FROM_ADDRESS not set)"); + warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)"); } if let Some(discord_sender) = DiscordSender::from_env() { - info!("Discord notifications enabled"); - notification_service = notification_service.register_sender(discord_sender); + info!("Discord comms enabled"); + comms_service = comms_service.register_sender(discord_sender); } if let Some(telegram_sender) = TelegramSender::from_env() { - info!("Telegram notifications enabled"); - notification_service = notification_service.register_sender(telegram_sender); + info!("Telegram comms enabled"); + comms_service = comms_service.register_sender(telegram_sender); } if let Some(signal_sender) = SignalSender::from_env() { - info!("Signal notifications enabled"); - notification_service = notification_service.register_sender(signal_sender); + info!("Signal comms enabled"); + comms_service = comms_service.register_sender(signal_sender); } - let notification_handle = tokio::spawn(notification_service.run(shutdown_rx.clone())); + let comms_handle = tokio::spawn(comms_service.run(shutdown_rx.clone())); let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() { let crawlers = Arc::new( @@ -122,7 +120,7 @@ async fn run() -> Result<(), Box> { .with_graceful_shutdown(shutdown_signal(shutdown_tx)) .await; - notification_handle.await.ok(); + comms_handle.await.ok(); if let Some(handle) = crawlers_handle { handle.await.ok(); diff --git a/src/metrics.rs b/src/metrics.rs index a8a0850..2945888 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -54,8 +54,8 @@ fn describe_metrics() { "Total number of S3/blob storage operations" ); metrics::describe_gauge!( - "bspds_notification_queue_size", - "Current size of the notification queue" + "bspds_comms_queue_size", + "Current size of the comms queue" ); metrics::describe_counter!( "bspds_rate_limit_rejections_total", @@ -167,8 +167,8 @@ pub fn record_s3_operation(op_type: &str, status: &str) { .increment(1); } -pub fn set_notification_queue_size(size: usize) { - gauge!("bspds_notification_queue_size").set(size as f64); +pub fn set_comms_queue_size(size: usize) { + gauge!("bspds_comms_queue_size").set(size as f64); } pub fn record_rate_limit_rejection(limiter: &str) { diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs deleted file mode 100644 index df25f31..0000000 --- a/src/notifications/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod sender; -mod service; -mod types; - -pub use sender::{ - DiscordSender, EmailSender, NotificationSender, SendError, SignalSender, TelegramSender, - is_valid_phone_number, sanitize_header_value, -}; - -pub use service::{ - NotificationService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, - enqueue_email_update, enqueue_email_verification, enqueue_notification, enqueue_password_reset, - enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, -}; - -pub use types::{ - NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification, -}; diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index d731f8d..60d0698 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -1,4 +1,4 @@ -use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code}; +use crate::comms::{CommsChannel, channel_display_name, enqueue_2fa_code}; use crate::oauth::{ Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, templates, }; @@ -406,8 +406,9 @@ pub async fn authorize_post( let user = match sqlx::query!( r#" SELECT id, did, email, password_hash, two_factor_enabled, - preferred_notification_channel as "preferred_notification_channel: NotificationChannel", - deactivated_at, takedown_ref + preferred_comms_channel as "preferred_comms_channel: CommsChannel", + deactivated_at, takedown_ref, + email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE handle = $1 OR email = $1 "#, @@ -429,6 +430,13 @@ pub async fn authorize_post( if user.takedown_ref.is_some() { return show_login_error("This account has been taken down.", json_response); } + let is_verified = user.email_verified + || user.discord_verified + || user.telegram_verified + || user.signal_verified; + if !is_verified { + return show_login_error("Please verify your account before logging in.", json_response); + } let password_valid = match bcrypt::verify(&form.password, &user.password_hash) { Ok(valid) => valid, Err(_) => return show_login_error("An error occurred. Please try again.", json_response), @@ -451,7 +459,7 @@ pub async fn authorize_post( "Failed to enqueue 2FA notification" ); } - let channel_name = channel_display_name(user.preferred_notification_channel); + let channel_name = channel_display_name(user.preferred_comms_channel); let redirect_url = format!( "/oauth/authorize/2fa?request_uri={}&channel={}", url_encode(&form.request_uri), @@ -577,7 +585,8 @@ pub async fn authorize_select( let user = match sqlx::query!( r#" SELECT id, two_factor_enabled, - preferred_notification_channel as "preferred_notification_channel: NotificationChannel" + preferred_comms_channel as "preferred_comms_channel: CommsChannel", + email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1 "#, @@ -600,6 +609,17 @@ pub async fn authorize_select( )).into_response(); } }; + let is_verified = user.email_verified + || user.discord_verified + || user.telegram_verified + || user.signal_verified; + if !is_verified { + return Html(templates::error_page( + "access_denied", + Some("Please verify your account before logging in."), + )) + .into_response(); + } if user.two_factor_enabled { let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; match db::create_2fa_challenge(&state.db, &form.did, &form.request_uri).await { @@ -615,7 +635,7 @@ pub async fn authorize_select( "Failed to enqueue 2FA notification" ); } - let channel_name = channel_display_name(user.preferred_notification_channel); + let channel_name = channel_display_name(user.preferred_comms_channel); let redirect_url = format!( "/oauth/authorize/2fa?request_uri={}&channel={}", url_encode(&form.request_uri), @@ -836,7 +856,7 @@ pub async fn authorize_2fa_post( if !code_valid { let _ = db::increment_2fa_attempts(&state.db, challenge.id).await; let channel = match sqlx::query_scalar!( - r#"SELECT preferred_notification_channel as "channel: NotificationChannel" FROM users WHERE did = $1"#, + r#"SELECT preferred_comms_channel as "channel: CommsChannel" FROM users WHERE did = $1"#, challenge.did ) .fetch_optional(&state.db) diff --git a/src/oauth/templates.rs b/src/oauth/templates.rs index 5c2cfaa..d3ecb61 100644 --- a/src/oauth/templates.rs +++ b/src/oauth/templates.rs @@ -369,7 +369,7 @@ pub fn login_page(
- +

diff --git a/tests/account_notifications.rs b/tests/account_notifications.rs index 279760e..d8c2a72 100644 --- a/tests/account_notifications.rs +++ b/tests/account_notifications.rs @@ -1,6 +1,6 @@ mod common; use common::{base_url, client, create_account_and_login, get_db_connection_string}; -use bspds::notifications::{NewNotification, NotificationType, enqueue_notification}; +use bspds::comms::{NewComms, CommsType, enqueue_comms}; use serde_json::{Value, json}; use sqlx::PgPool; @@ -26,14 +26,14 @@ async fn test_get_notification_history() { .expect("User not found"); for i in 0..3 { - let notification = NewNotification::email( + let comms = NewComms::email( user_id, - NotificationType::Welcome, + CommsType::Welcome, "test@example.com".to_string(), format!("Subject {}", i), format!("Body {}", i), ); - enqueue_notification(&pool, notification).await.expect("Failed to enqueue"); + enqueue_comms(&pool, comms).await.expect("Failed to enqueue"); } let resp = client diff --git a/tests/admin_email.rs b/tests/admin_email.rs index dfa9e05..5dd2809 100644 --- a/tests/admin_email.rs +++ b/tests/admin_email.rs @@ -39,7 +39,7 @@ async fn test_send_email_success() { .await .expect("User not found"); let notification = sqlx::query!( - "SELECT subject, body, notification_type as \"notification_type: String\" FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' ORDER BY created_at DESC LIMIT 1", + "SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1", user.id ) .fetch_one(&pool) @@ -78,7 +78,7 @@ async fn test_send_email_default_subject() { .await .expect("User not found"); let notification = sqlx::query!( - "SELECT subject FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", + "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", user.id ) .fetch_one(&pool) diff --git a/tests/notifications.rs b/tests/notifications.rs index 5ebec14..74bac10 100644 --- a/tests/notifications.rs +++ b/tests/notifications.rs @@ -1,7 +1,6 @@ mod common; -use bspds::notifications::{ - NewNotification, NotificationChannel, NotificationStatus, NotificationType, - enqueue_notification, enqueue_welcome, +use bspds::comms::{ + CommsChannel, CommsStatus, CommsType, NewComms, enqueue_comms, enqueue_welcome, }; use sqlx::PgPool; @@ -15,45 +14,45 @@ async fn get_pool() -> PgPool { } #[tokio::test] -async fn test_enqueue_notification() { +async fn test_enqueue_comms() { let pool = get_pool().await; let (_, did) = common::create_account_and_login(&common::client()).await; let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) .fetch_one(&pool) .await .expect("User not found"); - let notification = NewNotification::email( + let item = NewComms::email( user_id, - NotificationType::Welcome, + CommsType::Welcome, "test@example.com".to_string(), "Test Subject".to_string(), "Test body".to_string(), ); - let notification_id = enqueue_notification(&pool, notification) + let comms_id = enqueue_comms(&pool, item) .await - .expect("Failed to enqueue notification"); + .expect("Failed to enqueue comms"); let row = sqlx::query!( r#" SELECT id, user_id, recipient, subject, body, - channel as "channel: NotificationChannel", - notification_type as "notification_type: NotificationType", - status as "status: NotificationStatus" - FROM notification_queue + channel as "channel: CommsChannel", + comms_type as "comms_type: CommsType", + status as "status: CommsStatus" + FROM comms_queue WHERE id = $1 "#, - notification_id + comms_id ) .fetch_one(&pool) .await - .expect("Notification not found"); + .expect("Comms not found"); assert_eq!(row.user_id, user_id); assert_eq!(row.recipient, "test@example.com"); assert_eq!(row.subject.as_deref(), Some("Test Subject")); assert_eq!(row.body, "Test body"); - assert_eq!(row.channel, NotificationChannel::Email); - assert_eq!(row.notification_type, NotificationType::Welcome); - assert_eq!(row.status, NotificationStatus::Pending); + assert_eq!(row.channel, CommsChannel::Email); + assert_eq!(row.comms_type, CommsType::Welcome); + assert_eq!(row.status, CommsStatus::Pending); } #[tokio::test] @@ -64,30 +63,30 @@ async fn test_enqueue_welcome() { .fetch_one(&pool) .await .expect("User not found"); - let notification_id = enqueue_welcome(&pool, user_row.id, "example.com") + let comms_id = enqueue_welcome(&pool, user_row.id, "example.com") .await - .expect("Failed to enqueue welcome notification"); + .expect("Failed to enqueue welcome comms"); let row = sqlx::query!( r#" SELECT recipient, subject, body, - notification_type as "notification_type: NotificationType" - FROM notification_queue + comms_type as "comms_type: CommsType" + FROM comms_queue WHERE id = $1 "#, - notification_id + comms_id ) .fetch_one(&pool) .await - .expect("Notification not found"); + .expect("Comms not found"); assert_eq!(Some(row.recipient), user_row.email); assert_eq!(row.subject.as_deref(), Some("Welcome to example.com")); assert!(row.body.contains(&format!("@{}", user_row.handle))); - assert_eq!(row.notification_type, NotificationType::Welcome); + assert_eq!(row.comms_type, CommsType::Welcome); } #[tokio::test] -async fn test_notification_queue_status_index() { +async fn test_comms_queue_status_index() { let pool = get_pool().await; let (_, did) = common::create_account_and_login(&common::client()).await; let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) @@ -95,7 +94,7 @@ async fn test_notification_queue_status_index() { .await .expect("User not found"); let initial_count: i64 = sqlx::query_scalar!( - "SELECT COUNT(*) FROM notification_queue WHERE status = 'pending' AND user_id = $1", + "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1", user_id ) .fetch_one(&pool) @@ -103,19 +102,19 @@ async fn test_notification_queue_status_index() { .expect("Failed to count") .unwrap_or(0); for i in 0..5 { - let notification = NewNotification::email( + let item = NewComms::email( user_id, - NotificationType::PasswordReset, + CommsType::PasswordReset, format!("test{}@example.com", i), "Test".to_string(), "Body".to_string(), ); - enqueue_notification(&pool, notification) + enqueue_comms(&pool, item) .await .expect("Failed to enqueue"); } let final_count: i64 = sqlx::query_scalar!( - "SELECT COUNT(*) FROM notification_queue WHERE status = 'pending' AND user_id = $1", + "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1", user_id ) .fetch_one(&pool) diff --git a/tests/oauth.rs b/tests/oauth.rs index 774c3b4..b544530 100644 --- a/tests/oauth.rs +++ b/tests/oauth.rs @@ -2,7 +2,8 @@ mod common; mod helpers; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::Utc; -use common::{base_url, client, create_account_and_login, get_db_connection_string}; +use common::{base_url, client, get_db_connection_string}; +use helpers::verify_new_account; use reqwest::{StatusCode, redirect}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -124,6 +125,7 @@ async fn test_full_oauth_flow() { assert_eq!(create_res.status(), StatusCode::OK); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); + verify_new_account(&http_client, user_did).await; let redirect_uri = "https://example.com/oauth/callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); @@ -261,6 +263,7 @@ async fn test_oauth_2fa_flow() { assert_eq!(create_res.status(), StatusCode::OK); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); + verify_new_account(&http_client, user_did).await; let db_url = get_db_connection_string().await; let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap(); sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") @@ -324,6 +327,7 @@ async fn test_oauth_2fa_lockout() { .send().await.unwrap(); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); + verify_new_account(&http_client, user_did).await; let db_url = get_db_connection_string().await; let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap(); sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") @@ -375,6 +379,7 @@ async fn test_account_selector_with_2fa() { .send().await.unwrap(); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap().to_string(); + verify_new_account(&http_client, &user_did).await; let redirect_uri = "https://example.com/selector-2fa-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); @@ -451,10 +456,12 @@ async fn test_oauth_state_encoding() { let handle = format!("state-special-{}", ts); let email = format!("state-special-{}@example.com", ts); let password = "state-special-password"; - http_client + let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) .send().await.unwrap(); + let account: Value = create_res.json().await.unwrap(); + verify_new_account(&http_client, account["did"].as_str().unwrap()).await; let redirect_uri = "https://example.com/state-special-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs index a83f872..94d115a 100644 --- a/tests/oauth_security.rs +++ b/tests/oauth_security.rs @@ -45,9 +45,12 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) { let ts = Utc::now().timestamp_millis(); let handle = format!("sec-test-{}", ts); - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" })) .send().await.unwrap(); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap(); + verify_new_account(http_client, did).await; let redirect_uri = "https://example.com/sec-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); @@ -129,9 +132,11 @@ async fn test_pkce_security() { assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected"); let ts = Utc::now().timestamp_millis(); let handle = format!("pkce-attack-{}", ts); - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" })) .send().await.unwrap(); + let account: Value = create_res.json().await.unwrap(); + verify_new_account(&http_client, account["did"].as_str().unwrap()).await; let (_, code_challenge) = generate_pkce(); let (attacker_verifier, _) = generate_pkce(); let par_body: Value = http_client.post(format!("{}/oauth/par", url)) @@ -158,9 +163,11 @@ async fn test_replay_attacks() { let http_client = client(); let ts = Utc::now().timestamp_millis(); let handle = format!("replay-{}", ts); - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" })) .send().await.unwrap(); + let account: Value = create_res.json().await.unwrap(); + verify_new_account(&http_client, account["did"].as_str().unwrap()).await; let redirect_uri = "https://example.com/replay-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); @@ -243,9 +250,11 @@ async fn test_oauth_security_boundaries() { let client_id_b = mock_b.uri(); let ts2 = Utc::now().timestamp_millis(); let handle2 = format!("cross-{}", ts2); - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" })) .send().await.unwrap(); + let account2: Value = create_res2.json().await.unwrap(); + verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; let (code_verifier2, code_challenge2) = generate_pkce(); let par_a: Value = http_client.post(format!("{}/oauth/par", url)) .form(&[("response_type", "code"), ("client_id", &client_id_a), ("redirect_uri", redirect_uri_a), diff --git a/tests/password_reset.rs b/tests/password_reset.rs index 8fcd139..84f814d 100644 --- a/tests/password_reset.rs +++ b/tests/password_reset.rs @@ -373,7 +373,7 @@ async fn test_reset_password_creates_notification() { .await .expect("User not found"); let initial_count: i64 = sqlx::query_scalar!( - "SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'", + "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", user.id ) .fetch_one(&pool) @@ -391,7 +391,7 @@ async fn test_reset_password_creates_notification() { .expect("Failed to request password reset"); assert_eq!(res.status(), StatusCode::OK); let final_count: i64 = sqlx::query_scalar!( - "SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'", + "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", user.id ) .fetch_one(&pool) diff --git a/tests/security_fixes.rs b/tests/security_fixes.rs index 7285081..315d100 100644 --- a/tests/security_fixes.rs +++ b/tests/security_fixes.rs @@ -1,6 +1,6 @@ mod common; use bspds::image::{ImageError, ImageProcessor}; -use bspds::notifications::{SendError, is_valid_phone_number, sanitize_header_value}; +use bspds::comms::{SendError, is_valid_phone_number, sanitize_header_value}; use bspds::oauth::templates::{error_page, login_page, success_page}; #[test]