this repo has no description

Rename notifications to comms, add handle changing to own domain ability

lewis 89377201 a050e405

Changed files
+1443 -760
.sqlx
frontend
migrations
src
tests
+5 -5
.sqlx/query-088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1.json .sqlx/query-de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 15 15 }, 16 16 { 17 17 "ordinal": 2, 18 - "name": "email_confirmed", 18 + "name": "email_verified", 19 19 "type_info": "Bool" 20 20 }, 21 21 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 - "name": "preferred_channel: crate::notifications::NotificationChannel", 28 + "name": "preferred_channel: crate::comms::CommsChannel", 29 29 "type_info": { 30 30 "Custom": { 31 - "name": "notification_channel", 31 + "name": "comms_channel", 32 32 "kind": { 33 33 "Enum": [ 34 34 "email", ··· 72 72 false 73 73 ] 74 74 }, 75 - "hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1" 75 + "hash": "de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9" 76 76 }
-30
.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - { 10 - "Custom": { 11 - "name": "notification_channel", 12 - "kind": { 13 - "Enum": [ 14 - "email", 15 - "discord", 16 - "telegram", 17 - "signal" 18 - ] 19 - } 20 - } 21 - }, 22 - "Text", 23 - "Text", 24 - "Jsonb" 25 - ] 26 - }, 27 - "nullable": [] 28 - }, 29 - "hash": "0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac" 30 - }
+4 -4
.sqlx/query-0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e.json .sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT preferred_notification_channel as \"channel: NotificationChannel\" FROM users WHERE did = $1", 3 + "query": "SELECT preferred_comms_channel as \"channel: CommsChannel\" FROM users WHERE did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { 7 7 "ordinal": 0, 8 - "name": "channel: NotificationChannel", 8 + "name": "channel: CommsChannel", 9 9 "type_info": { 10 10 "Custom": { 11 - "name": "notification_channel", 11 + "name": "comms_channel", 12 12 "kind": { 13 13 "Enum": [ 14 14 "email", ··· 30 30 false 31 31 ] 32 32 }, 33 - "hash": "0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e" 33 + "hash": "94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c" 34 34 }
+14
.sqlx/query-17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE comms_queue\n SET status = 'sent', processed_at = NOW(), updated_at = NOW()\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad" 14 + }
+3 -3
.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json .sqlx/query-d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 - "name": "email_confirmed", 28 + "name": "email_verified", 29 29 "type_info": "Bool" 30 30 }, 31 31 { ··· 72 72 true 73 73 ] 74 74 }, 75 - "hash": "1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd" 75 + "hash": "d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89" 76 76 }
-15
.sqlx/query-2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511" 15 - }
+4 -4
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json .sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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 ", 3 + "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 ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 14 14 "Uuid", 15 15 { 16 16 "Custom": { 17 - "name": "notification_channel", 17 + "name": "comms_channel", 18 18 "kind": { 19 19 "Enum": [ 20 20 "email", ··· 27 27 }, 28 28 { 29 29 "Custom": { 30 - "name": "notification_type", 30 + "name": "comms_type", 31 31 "kind": { 32 32 "Enum": [ 33 33 "welcome", ··· 53 53 false 54 54 ] 55 55 }, 56 - "hash": "303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6" 56 + "hash": "3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc" 57 57 }
-14
.sqlx/query-344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n UPDATE notification_queue\n SET status = 'sent', processed_at = NOW(), updated_at = NOW()\n WHERE id = $1\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3" 14 - }
-76
.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "email", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "password_hash", 24 - "type_info": "Text" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "two_factor_enabled", 29 - "type_info": "Bool" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "preferred_notification_channel: NotificationChannel", 34 - "type_info": { 35 - "Custom": { 36 - "name": "notification_channel", 37 - "kind": { 38 - "Enum": [ 39 - "email", 40 - "discord", 41 - "telegram", 42 - "signal" 43 - ] 44 - } 45 - } 46 - } 47 - }, 48 - { 49 - "ordinal": 6, 50 - "name": "deactivated_at", 51 - "type_info": "Timestamptz" 52 - }, 53 - { 54 - "ordinal": 7, 55 - "name": "takedown_ref", 56 - "type_info": "Text" 57 - } 58 - ], 59 - "parameters": { 60 - "Left": [ 61 - "Text" 62 - ] 63 - }, 64 - "nullable": [ 65 - false, 66 - false, 67 - true, 68 - false, 69 - false, 70 - false, 71 - true, 72 - true 73 - ] 74 - }, 75 - "hash": "458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810" 76 - }
+3 -3
.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json .sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::notification_channel\n ", 3 + "query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::comms_channel\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 24 24 "Uuid", 25 25 { 26 26 "Custom": { 27 - "name": "notification_channel", 27 + "name": "comms_channel", 28 28 "kind": { 29 29 "Enum": [ 30 30 "email", ··· 43 43 false 44 44 ] 45 45 }, 46 - "hash": "4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26" 46 + "hash": "f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5" 47 47 }
+6 -6
.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json .sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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 ", 3 + "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 ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 13 13 "name": "channel: String", 14 14 "type_info": { 15 15 "Custom": { 16 - "name": "notification_channel", 16 + "name": "comms_channel", 17 17 "kind": { 18 18 "Enum": [ 19 19 "email", ··· 27 27 }, 28 28 { 29 29 "ordinal": 2, 30 - "name": "notification_type: String", 30 + "name": "comms_type: String", 31 31 "type_info": { 32 32 "Custom": { 33 - "name": "notification_type", 33 + "name": "comms_type", 34 34 "kind": { 35 35 "Enum": [ 36 36 "welcome", ··· 52 52 "name": "status: String", 53 53 "type_info": { 54 54 "Custom": { 55 - "name": "notification_status", 55 + "name": "comms_status", 56 56 "kind": { 57 57 "Enum": [ 58 58 "pending", ··· 89 89 false 90 90 ] 91 91 }, 92 - "hash": "4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a" 92 + "hash": "fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4" 93 93 }
+4 -4
.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json .sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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 ", 3 + "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 ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 14 14 "Uuid", 15 15 { 16 16 "Custom": { 17 - "name": "notification_channel", 17 + "name": "comms_channel", 18 18 "kind": { 19 19 "Enum": [ 20 20 "email", ··· 27 27 }, 28 28 { 29 29 "Custom": { 30 - "name": "notification_type", 30 + "name": "comms_type", 31 31 "kind": { 32 32 "Enum": [ 33 33 "welcome", ··· 53 53 false 54 54 ] 55 55 }, 56 - "hash": "5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf" 56 + "hash": "17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5" 57 57 }
-46
.sqlx/query-62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, two_factor_enabled,\n preferred_notification_channel as \"preferred_notification_channel: NotificationChannel\"\n FROM users\n WHERE did = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "two_factor_enabled", 14 - "type_info": "Bool" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "preferred_notification_channel: NotificationChannel", 19 - "type_info": { 20 - "Custom": { 21 - "name": "notification_channel", 22 - "kind": { 23 - "Enum": [ 24 - "email", 25 - "discord", 26 - "telegram", 27 - "signal" 28 - ] 29 - } 30 - } 31 - } 32 - } 33 - ], 34 - "parameters": { 35 - "Left": [ 36 - "Text" 37 - ] 38 - }, 39 - "nullable": [ 40 - false, 41 - false, 42 - false 43 - ] 44 - }, 45 - "hash": "62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f" 46 - }
+15
.sqlx/query-64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230" 15 + }
+3 -3
.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json .sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel", 3 + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", 4 4 "describe": { 5 5 "columns": [], 6 6 "parameters": { ··· 8 8 "Uuid", 9 9 { 10 10 "Custom": { 11 - "name": "notification_channel", 11 + "name": "comms_channel", 12 12 "kind": { 13 13 "Enum": [ 14 14 "email", ··· 23 23 }, 24 24 "nullable": [] 25 25 }, 26 - "hash": "90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849" 26 + "hash": "57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e" 27 27 }
+3 -3
.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json .sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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 ", 3 + "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 ", 4 4 "describe": { 5 5 "columns": [], 6 6 "parameters": { ··· 8 8 "Uuid", 9 9 { 10 10 "Custom": { 11 - "name": "notification_channel", 11 + "name": "comms_channel", 12 12 "kind": { 13 13 "Enum": [ 14 14 "email", ··· 26 26 }, 27 27 "nullable": [] 28 28 }, 29 - "hash": "9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546" 29 + "hash": "c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6" 30 30 }
+5 -5
.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json .sqlx/query-4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 20 20 }, 21 21 { 22 22 "ordinal": 3, 23 - "name": "channel: crate::notifications::NotificationChannel", 23 + "name": "channel: crate::comms::CommsChannel", 24 24 "type_info": { 25 25 "Custom": { 26 - "name": "notification_channel", 26 + "name": "comms_channel", 27 27 "kind": { 28 28 "Enum": [ 29 29 "email", ··· 52 52 }, 53 53 { 54 54 "ordinal": 7, 55 - "name": "email_confirmed", 55 + "name": "email_verified", 56 56 "type_info": "Bool" 57 57 }, 58 58 { ··· 90 90 false 91 91 ] 92 92 }, 93 - "hash": "ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6" 93 + "hash": "4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5" 94 94 }
+4 -4
.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json .sqlx/query-8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT\n email,\n handle,\n preferred_notification_channel as \"channel: NotificationChannel\"\n FROM users\n WHERE id = $1\n ", 3 + "query": "\n SELECT\n email,\n handle,\n preferred_comms_channel as \"channel: CommsChannel\"\n FROM users\n WHERE id = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 15 15 }, 16 16 { 17 17 "ordinal": 2, 18 - "name": "channel: NotificationChannel", 18 + "name": "channel: CommsChannel", 19 19 "type_info": { 20 20 "Custom": { 21 - "name": "notification_channel", 21 + "name": "comms_channel", 22 22 "kind": { 23 23 "Enum": [ 24 24 "email", ··· 42 42 false 43 43 ] 44 44 }, 45 - "hash": "bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508" 45 + "hash": "8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721" 46 46 }
+8 -8
.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json .sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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 ", 3 + "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 ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 15 15 }, 16 16 { 17 17 "ordinal": 2, 18 - "name": "channel: NotificationChannel", 18 + "name": "channel: CommsChannel", 19 19 "type_info": { 20 20 "Custom": { 21 - "name": "notification_channel", 21 + "name": "comms_channel", 22 22 "kind": { 23 23 "Enum": [ 24 24 "email", ··· 32 32 }, 33 33 { 34 34 "ordinal": 3, 35 - "name": "notification_type: super::types::NotificationType", 35 + "name": "comms_type: super::types::CommsType", 36 36 "type_info": { 37 37 "Custom": { 38 - "name": "notification_type", 38 + "name": "comms_type", 39 39 "kind": { 40 40 "Enum": [ 41 41 "welcome", ··· 54 54 }, 55 55 { 56 56 "ordinal": 4, 57 - "name": "status: NotificationStatus", 57 + "name": "status: CommsStatus", 58 58 "type_info": { 59 59 "Custom": { 60 - "name": "notification_status", 60 + "name": "comms_status", 61 61 "kind": { 62 62 "Enum": [ 63 63 "pending", ··· 150 150 true 151 151 ] 152 152 }, 153 - "hash": "cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de" 153 + "hash": "20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08" 154 154 }
+34
.sqlx/query-d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "key_bytes", 14 + "type_info": "Bytea" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "encryption_version", 19 + "type_info": "Int4" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + true 31 + ] 32 + }, 33 + "hash": "d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b" 34 + }
+70
.sqlx/query-daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "two_factor_enabled", 14 + "type_info": "Bool" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "preferred_comms_channel: CommsChannel", 19 + "type_info": { 20 + "Custom": { 21 + "name": "comms_channel", 22 + "kind": { 23 + "Enum": [ 24 + "email", 25 + "discord", 26 + "telegram", 27 + "signal" 28 + ] 29 + } 30 + } 31 + } 32 + }, 33 + { 34 + "ordinal": 3, 35 + "name": "email_verified", 36 + "type_info": "Bool" 37 + }, 38 + { 39 + "ordinal": 4, 40 + "name": "discord_verified", 41 + "type_info": "Bool" 42 + }, 43 + { 44 + "ordinal": 5, 45 + "name": "telegram_verified", 46 + "type_info": "Bool" 47 + }, 48 + { 49 + "ordinal": 6, 50 + "name": "signal_verified", 51 + "type_info": "Bool" 52 + } 53 + ], 54 + "parameters": { 55 + "Left": [ 56 + "Text" 57 + ] 58 + }, 59 + "nullable": [ 60 + false, 61 + false, 62 + false, 63 + false, 64 + false, 65 + false, 66 + false 67 + ] 68 + }, 69 + "hash": "daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24" 70 + }
+4 -4
.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json .sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "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", 3 + "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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 - "name": "channel: crate::notifications::NotificationChannel", 28 + "name": "channel: crate::comms::CommsChannel", 29 29 "type_info": { 30 30 "Custom": { 31 - "name": "notification_channel", 31 + "name": "comms_channel", 32 32 "kind": { 33 33 "Enum": [ 34 34 "email", ··· 66 66 true 67 67 ] 68 68 }, 69 - "hash": "dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3" 69 + "hash": "efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a" 70 70 }
+30
.sqlx/query-e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + { 10 + "Custom": { 11 + "name": "comms_channel", 12 + "kind": { 13 + "Enum": [ 14 + "email", 15 + "discord", 16 + "telegram", 17 + "signal" 18 + ] 19 + } 20 + } 21 + }, 22 + "Text", 23 + "Text", 24 + "Jsonb" 25 + ] 26 + }, 27 + "nullable": [] 28 + }, 29 + "hash": "e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134" 30 + }
+100
.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "password_hash", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "two_factor_enabled", 29 + "type_info": "Bool" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "preferred_comms_channel: CommsChannel", 34 + "type_info": { 35 + "Custom": { 36 + "name": "comms_channel", 37 + "kind": { 38 + "Enum": [ 39 + "email", 40 + "discord", 41 + "telegram", 42 + "signal" 43 + ] 44 + } 45 + } 46 + } 47 + }, 48 + { 49 + "ordinal": 6, 50 + "name": "deactivated_at", 51 + "type_info": "Timestamptz" 52 + }, 53 + { 54 + "ordinal": 7, 55 + "name": "takedown_ref", 56 + "type_info": "Text" 57 + }, 58 + { 59 + "ordinal": 8, 60 + "name": "email_verified", 61 + "type_info": "Bool" 62 + }, 63 + { 64 + "ordinal": 9, 65 + "name": "discord_verified", 66 + "type_info": "Bool" 67 + }, 68 + { 69 + "ordinal": 10, 70 + "name": "telegram_verified", 71 + "type_info": "Bool" 72 + }, 73 + { 74 + "ordinal": 11, 75 + "name": "signal_verified", 76 + "type_info": "Bool" 77 + } 78 + ], 79 + "parameters": { 80 + "Left": [ 81 + "Text" 82 + ] 83 + }, 84 + "nullable": [ 85 + false, 86 + false, 87 + true, 88 + false, 89 + false, 90 + false, 91 + true, 92 + true, 93 + false, 94 + false, 95 + false, 96 + false 97 + ] 98 + }, 99 + "hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0" 100 + }
+1
Cargo.lock
··· 950 950 "ed25519-dalek", 951 951 "futures", 952 952 "governor", 953 + "hickory-resolver", 953 954 "hkdf", 954 955 "hmac", 955 956 "image",
+1
Cargo.toml
··· 51 51 image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } 52 52 redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } 53 53 tower-http = { version = "0.6", features = ["fs", "cors"] } 54 + hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } 54 55 metrics = "0.24" 55 56 metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] } 56 57 [features]
+3
frontend/src/App.svelte
··· 3 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 4 import Login from './routes/Login.svelte' 5 5 import Register from './routes/Register.svelte' 6 + import Verify from './routes/Verify.svelte' 6 7 import ResetPassword from './routes/ResetPassword.svelte' 7 8 import Dashboard from './routes/Dashboard.svelte' 8 9 import AppPasswords from './routes/AppPasswords.svelte' ··· 25 26 return Login 26 27 case '/register': 27 28 return Register 29 + case '/verify': 30 + return Verify 28 31 case '/reset-password': 29 32 return ResetPassword 30 33 case '/dashboard':
-5
frontend/src/routes/Login.svelte
··· 8 8 let resendMessage = $state<string | null>(null) 9 9 let showNewLogin = $state(false) 10 10 const auth = getAuthState() 11 - $effect(() => { 12 - if (auth.session) { 13 - navigate('/dashboard') 14 - } 15 - }) 16 11 async function handleSwitchAccount(did: string) { 17 12 submitting = true 18 13 try {
+15 -121
frontend/src/routes/Register.svelte
··· 1 1 <script lang="ts"> 2 - import { register, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte' 2 + import { register, getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError, type VerificationChannel } from '../lib/api' 5 + 6 + const STORAGE_KEY = 'bspds_pending_verification' 7 + 5 8 let handle = $state('') 6 9 let email = $state('') 7 10 let password = $state('') ··· 13 16 let signalNumber = $state('') 14 17 let submitting = $state(false) 15 18 let error = $state<string | null>(null) 16 - let pendingVerification = $state<{ did: string; handle: string; channel: string } | null>(null) 17 - let verificationCode = $state('') 18 - let resendingCode = $state(false) 19 - let resendMessage = $state<string | null>(null) 20 19 let serverInfo = $state<{ 21 20 availableUserDomains: string[] 22 21 inviteCodeRequired: boolean 23 22 } | null>(null) 24 23 let loadingServerInfo = $state(true) 25 24 let serverInfoLoaded = false 25 + 26 26 const auth = getAuthState() 27 + 27 28 $effect(() => { 28 29 if (auth.session) { 29 30 navigate('/dashboard') 30 31 } 31 32 }) 33 + 32 34 $effect(() => { 33 35 if (!serverInfoLoaded) { 34 36 serverInfoLoaded = true 35 37 loadServerInfo() 36 38 } 37 39 }) 40 + 38 41 async function loadServerInfo() { 39 42 try { 40 43 serverInfo = await api.describeServer() ··· 44 47 loadingServerInfo = false 45 48 } 46 49 } 50 + 47 51 function validateForm(): string | null { 48 52 if (!handle.trim()) return 'Handle is required' 49 53 if (!password) return 'Password is required' ··· 68 72 } 69 73 return null 70 74 } 75 + 71 76 async function handleSubmit(e: Event) { 72 77 e.preventDefault() 73 - console.log('[Register] handleSubmit called') 74 78 const validationError = validateForm() 75 79 if (validationError) { 76 - console.log('[Register] validation error:', validationError) 77 80 error = validationError 78 81 return 79 82 } 80 83 submitting = true 81 84 error = null 82 - console.log('[Register] starting registration...') 83 85 try { 84 86 const result = await register({ 85 87 handle: handle.trim(), ··· 91 93 telegramUsername: telegramUsername.trim() || undefined, 92 94 signalNumber: signalNumber.trim() || undefined, 93 95 }) 94 - console.log('[Register] registration result:', result) 95 96 if (result.verificationRequired) { 96 - console.log('[Register] setting pendingVerification') 97 - pendingVerification = { 97 + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 98 98 did: result.did, 99 99 handle: result.handle, 100 100 channel: result.verificationChannel, 101 - } 102 - console.log('[Register] pendingVerification set to:', pendingVerification) 101 + })) 102 + navigate('/verify') 103 103 } else { 104 - console.log('[Register] no verification required, navigating to dashboard') 105 104 navigate('/dashboard') 106 105 } 107 106 } catch (err: any) { 108 - console.error('[Register] error:', err) 109 107 if (err instanceof ApiError) { 110 108 error = err.message || 'Registration failed' 111 109 } else if (err instanceof Error) { ··· 115 113 } 116 114 } finally { 117 115 submitting = false 118 - console.log('[Register] finished, submitting=false') 119 116 } 120 117 } 121 - async function handleVerification(e: Event) { 122 - e.preventDefault() 123 - if (!pendingVerification || !verificationCode.trim()) return 124 - submitting = true 125 - error = null 126 - try { 127 - await confirmSignup(pendingVerification.did, verificationCode.trim()) 128 - navigate('/dashboard') 129 - } catch (e: any) { 130 - error = e.message || 'Verification failed' 131 - } finally { 132 - submitting = false 133 - } 134 - } 135 - async function handleResendCode() { 136 - if (!pendingVerification || resendingCode) return 137 - resendingCode = true 138 - resendMessage = null 139 - error = null 140 - try { 141 - await resendVerification(pendingVerification.did) 142 - resendMessage = 'Verification code resent!' 143 - } catch (e: any) { 144 - error = e.message || 'Failed to resend code' 145 - } finally { 146 - resendingCode = false 147 - } 148 - } 118 + 149 119 let fullHandle = $derived(() => { 150 120 if (!handle.trim()) return '' 151 121 if (handle.includes('.')) return handle.trim() ··· 153 123 if (domain) return `${handle.trim()}.${domain}` 154 124 return handle.trim() 155 125 }) 156 - function channelLabel(ch: string): string { 157 - switch (ch) { 158 - case 'email': return 'Email' 159 - case 'discord': return 'Discord' 160 - case 'telegram': return 'Telegram' 161 - case 'signal': return 'Signal' 162 - default: return ch 163 - } 164 - } 165 126 </script> 166 127 <div class="register-container"> 167 128 {#if error} 168 129 <div class="error">{error}</div> 169 130 {/if} 170 - {#if pendingVerification} 171 - <h1>Verify Your Account</h1> 172 - <p class="subtitle"> 173 - We've sent a verification code to your {channelLabel(pendingVerification.channel)}. 174 - Enter it below to complete registration. 175 - </p> 176 - {#if resendMessage} 177 - <div class="success">{resendMessage}</div> 178 - {/if} 179 - <form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}> 180 - <div class="field"> 181 - <label for="verification-code">Verification Code</label> 182 - <input 183 - id="verification-code" 184 - type="text" 185 - bind:value={verificationCode} 186 - placeholder="Enter 6-digit code" 187 - disabled={submitting} 188 - required 189 - maxlength="6" 190 - inputmode="numeric" 191 - autocomplete="one-time-code" 192 - /> 193 - </div> 194 - <button type="submit" disabled={submitting || !verificationCode.trim()}> 195 - {submitting ? 'Verifying...' : 'Verify Account'} 196 - </button> 197 - <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 198 - {resendingCode ? 'Resending...' : 'Resend Code'} 199 - </button> 200 - </form> 201 - {:else} 202 - <h1>Create Account</h1> 131 + <h1>Create Account</h1> 203 132 <p class="subtitle">Create a new account on this PDS</p> 204 133 {#if loadingServerInfo} 205 134 <p class="loading">Loading...</p> ··· 322 251 required 323 252 /> 324 253 </div> 325 - {:else} 326 - <div class="field optional"> 327 - <label for="invite-code">Invite Code <span class="optional-label">(optional)</span></label> 328 - <input 329 - id="invite-code" 330 - type="text" 331 - bind:value={inviteCode} 332 - placeholder="Enter invite code if you have one" 333 - disabled={submitting} 334 - /> 335 - </div> 336 254 {/if} 337 255 <button type="submit" disabled={submitting}> 338 256 {submitting ? 'Creating account...' : 'Create Account'} ··· 342 260 Already have an account? <a href="#/login">Sign in</a> 343 261 </p> 344 262 {/if} 345 - {/if} 346 263 </div> 347 264 <style> 348 265 .register-container { ··· 371 288 flex-direction: column; 372 289 gap: 0.25rem; 373 290 } 374 - .field.optional { 375 - opacity: 0.8; 376 - } 377 291 label { 378 292 font-size: 0.875rem; 379 293 font-weight: 500; ··· 381 295 .required { 382 296 color: var(--error-text); 383 297 } 384 - .optional-label { 385 - color: var(--text-secondary); 386 - font-weight: normal; 387 - } 388 298 input, select { 389 299 padding: 0.75rem; 390 300 border: 1px solid var(--border-color-light); ··· 435 345 opacity: 0.6; 436 346 cursor: not-allowed; 437 347 } 438 - button.secondary { 439 - background: transparent; 440 - color: var(--accent); 441 - border: 1px solid var(--accent); 442 - } 443 - button.secondary:hover:not(:disabled) { 444 - background: var(--accent); 445 - color: white; 446 - } 447 348 .error { 448 349 padding: 0.75rem; 449 350 background: var(--error-bg); 450 351 border: 1px solid var(--error-border); 451 352 border-radius: 4px; 452 353 color: var(--error-text); 453 - } 454 - .success { 455 - padding: 0.75rem; 456 - background: var(--success-bg); 457 - border: 1px solid var(--success-border); 458 - border-radius: 4px; 459 - color: var(--success-text); 460 354 } 461 355 .login-link { 462 356 text-align: center;
+145 -15
frontend/src/routes/Settings.svelte
··· 19 19 let currentPassword = $state('') 20 20 let newPassword = $state('') 21 21 let confirmNewPassword = $state('') 22 + let showBYOHandle = $state(false) 22 23 $effect(() => { 23 24 if (!auth.loading && !auth.session) { 24 25 navigate('/login') ··· 230 231 {#if auth.session} 231 232 <p class="current">Current: @{auth.session.handle}</p> 232 233 {/if} 233 - <form onsubmit={handleUpdateHandle}> 234 - <div class="field"> 235 - <label for="new-handle">New Handle</label> 236 - <input 237 - id="new-handle" 238 - type="text" 239 - bind:value={newHandle} 240 - placeholder="newhandle.bsky.social" 241 - disabled={handleLoading} 242 - required 243 - /> 234 + <div class="tabs"> 235 + <button 236 + type="button" 237 + class="tab" 238 + class:active={!showBYOHandle} 239 + onclick={() => showBYOHandle = false} 240 + > 241 + PDS Handle 242 + </button> 243 + <button 244 + type="button" 245 + class="tab" 246 + class:active={showBYOHandle} 247 + onclick={() => showBYOHandle = true} 248 + > 249 + Custom Domain 250 + </button> 251 + </div> 252 + {#if showBYOHandle} 253 + <div class="byo-handle"> 254 + <p class="description">Use your own domain as your handle. You need to verify domain ownership first.</p> 255 + {#if auth.session} 256 + <div class="verification-info"> 257 + <h3>Setup Instructions</h3> 258 + <p>Choose one of these verification methods:</p> 259 + <div class="method"> 260 + <h4>Option 1: DNS TXT Record (Recommended)</h4> 261 + <p>Add this TXT record to your domain:</p> 262 + <code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code> 263 + </div> 264 + <div class="method"> 265 + <h4>Option 2: HTTP Well-Known File</h4> 266 + <p>Serve your DID at this URL:</p> 267 + <code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code> 268 + <p>The file should contain only:</p> 269 + <code class="record">{auth.session.did}</code> 270 + </div> 271 + </div> 272 + {/if} 273 + <form onsubmit={handleUpdateHandle}> 274 + <div class="field"> 275 + <label for="new-handle-byo">Your Domain</label> 276 + <input 277 + id="new-handle-byo" 278 + type="text" 279 + bind:value={newHandle} 280 + placeholder="example.com" 281 + disabled={handleLoading} 282 + required 283 + /> 284 + </div> 285 + <button type="submit" disabled={handleLoading || !newHandle}> 286 + {handleLoading ? 'Verifying...' : 'Verify & Update Handle'} 287 + </button> 288 + </form> 244 289 </div> 245 - <button type="submit" disabled={handleLoading || !newHandle}> 246 - {handleLoading ? 'Updating...' : 'Change Handle'} 247 - </button> 248 - </form> 290 + {:else} 291 + <form onsubmit={handleUpdateHandle}> 292 + <div class="field"> 293 + <label for="new-handle">New Handle</label> 294 + <input 295 + id="new-handle" 296 + type="text" 297 + bind:value={newHandle} 298 + placeholder="yourhandle" 299 + disabled={handleLoading} 300 + required 301 + /> 302 + </div> 303 + <button type="submit" disabled={handleLoading || !newHandle}> 304 + {handleLoading ? 'Updating...' : 'Change Handle'} 305 + </button> 306 + </form> 307 + {/if} 249 308 </section> 250 309 <section> 251 310 <h2>Change Password</h2> ··· 457 516 color: var(--error-text); 458 517 font-size: 0.875rem; 459 518 margin-bottom: 1rem; 519 + } 520 + .tabs { 521 + display: flex; 522 + gap: 0.25rem; 523 + margin-bottom: 1rem; 524 + } 525 + .tab { 526 + flex: 1; 527 + padding: 0.5rem 1rem; 528 + background: transparent; 529 + border: 1px solid var(--border-color-light); 530 + cursor: pointer; 531 + font-size: 0.875rem; 532 + color: var(--text-secondary); 533 + } 534 + .tab:first-child { 535 + border-radius: 4px 0 0 4px; 536 + } 537 + .tab:last-child { 538 + border-radius: 0 4px 4px 0; 539 + } 540 + .tab.active { 541 + background: var(--accent); 542 + border-color: var(--accent); 543 + color: white; 544 + } 545 + .tab:hover:not(.active) { 546 + background: var(--bg-card); 547 + } 548 + .byo-handle .description { 549 + margin-bottom: 1rem; 550 + } 551 + .verification-info { 552 + background: var(--bg-card); 553 + border: 1px solid var(--border-color-light); 554 + border-radius: 6px; 555 + padding: 1rem; 556 + margin-bottom: 1rem; 557 + } 558 + .verification-info h3 { 559 + margin: 0 0 0.5rem 0; 560 + font-size: 1rem; 561 + } 562 + .verification-info h4 { 563 + margin: 0.75rem 0 0.25rem 0; 564 + font-size: 0.875rem; 565 + color: var(--text-secondary); 566 + } 567 + .verification-info p { 568 + margin: 0.25rem 0; 569 + font-size: 0.8rem; 570 + color: var(--text-secondary); 571 + } 572 + .method { 573 + margin-top: 0.75rem; 574 + padding-top: 0.75rem; 575 + border-top: 1px solid var(--border-color-light); 576 + } 577 + .method:first-of-type { 578 + margin-top: 0.5rem; 579 + padding-top: 0; 580 + border-top: none; 581 + } 582 + code.record { 583 + display: block; 584 + background: var(--bg-input); 585 + padding: 0.5rem; 586 + border-radius: 4px; 587 + font-size: 0.75rem; 588 + word-break: break-all; 589 + margin: 0.25rem 0; 460 590 } 461 591 </style>
+277
frontend/src/routes/Verify.svelte
··· 1 + <script lang="ts"> 2 + import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + 5 + const STORAGE_KEY = 'bspds_pending_verification' 6 + 7 + interface PendingVerification { 8 + did: string 9 + handle: string 10 + channel: string 11 + } 12 + 13 + let pendingVerification = $state<PendingVerification | null>(null) 14 + let verificationCode = $state('') 15 + let submitting = $state(false) 16 + let resendingCode = $state(false) 17 + let error = $state<string | null>(null) 18 + let resendMessage = $state<string | null>(null) 19 + 20 + const auth = getAuthState() 21 + 22 + $effect(() => { 23 + if (auth.session) { 24 + clearPendingVerification() 25 + navigate('/dashboard') 26 + } 27 + }) 28 + 29 + $effect(() => { 30 + const stored = localStorage.getItem(STORAGE_KEY) 31 + if (stored) { 32 + try { 33 + pendingVerification = JSON.parse(stored) 34 + } catch { 35 + pendingVerification = null 36 + } 37 + } 38 + }) 39 + 40 + function clearPendingVerification() { 41 + localStorage.removeItem(STORAGE_KEY) 42 + pendingVerification = null 43 + } 44 + 45 + async function handleVerification(e: Event) { 46 + e.preventDefault() 47 + if (!pendingVerification || !verificationCode.trim()) return 48 + 49 + submitting = true 50 + error = null 51 + 52 + try { 53 + await confirmSignup(pendingVerification.did, verificationCode.trim()) 54 + clearPendingVerification() 55 + navigate('/dashboard') 56 + } catch (e: any) { 57 + error = e.message || 'Verification failed' 58 + } finally { 59 + submitting = false 60 + } 61 + } 62 + 63 + async function handleResendCode() { 64 + if (!pendingVerification || resendingCode) return 65 + 66 + resendingCode = true 67 + resendMessage = null 68 + error = null 69 + 70 + try { 71 + await resendVerification(pendingVerification.did) 72 + resendMessage = 'Verification code resent!' 73 + } catch (e: any) { 74 + error = e.message || 'Failed to resend code' 75 + } finally { 76 + resendingCode = false 77 + } 78 + } 79 + 80 + function channelLabel(ch: string): string { 81 + switch (ch) { 82 + case 'email': return 'Email' 83 + case 'discord': return 'Discord' 84 + case 'telegram': return 'Telegram' 85 + case 'signal': return 'Signal' 86 + default: return ch 87 + } 88 + } 89 + </script> 90 + 91 + <div class="verify-container"> 92 + {#if error} 93 + <div class="error">{error}</div> 94 + {/if} 95 + 96 + {#if pendingVerification} 97 + <h1>Verify Your Account</h1> 98 + <p class="subtitle"> 99 + We've sent a verification code to your {channelLabel(pendingVerification.channel)}. 100 + Enter it below to complete registration. 101 + </p> 102 + <p class="handle-info">Verifying account: <strong>@{pendingVerification.handle}</strong></p> 103 + 104 + {#if resendMessage} 105 + <div class="success">{resendMessage}</div> 106 + {/if} 107 + 108 + <form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}> 109 + <div class="field"> 110 + <label for="verification-code">Verification Code</label> 111 + <input 112 + id="verification-code" 113 + type="text" 114 + bind:value={verificationCode} 115 + placeholder="Enter 6-digit code" 116 + disabled={submitting} 117 + required 118 + maxlength="6" 119 + inputmode="numeric" 120 + autocomplete="one-time-code" 121 + /> 122 + </div> 123 + 124 + <button type="submit" disabled={submitting || !verificationCode.trim()}> 125 + {submitting ? 'Verifying...' : 'Verify Account'} 126 + </button> 127 + 128 + <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 129 + {resendingCode ? 'Resending...' : 'Resend Code'} 130 + </button> 131 + </form> 132 + 133 + <p class="cancel-link"> 134 + <a href="#/register" onclick={() => clearPendingVerification()}>Start over with a different account</a> 135 + </p> 136 + {:else} 137 + <h1>Account Verification</h1> 138 + <p class="subtitle">No pending verification found.</p> 139 + <p class="no-pending-info"> 140 + If you recently created an account and need to verify it, you may need to create a new account. 141 + If you already verified your account, you can sign in. 142 + </p> 143 + <div class="actions"> 144 + <a href="#/register" class="btn">Create Account</a> 145 + <a href="#/login" class="btn secondary">Sign In</a> 146 + </div> 147 + {/if} 148 + </div> 149 + 150 + <style> 151 + .verify-container { 152 + max-width: 400px; 153 + margin: 4rem auto; 154 + padding: 2rem; 155 + } 156 + 157 + h1 { 158 + margin: 0 0 0.5rem 0; 159 + } 160 + 161 + .subtitle { 162 + color: var(--text-secondary); 163 + margin: 0 0 1rem 0; 164 + } 165 + 166 + .handle-info { 167 + font-size: 0.9rem; 168 + color: var(--text-secondary); 169 + margin: 0 0 1.5rem 0; 170 + } 171 + 172 + .no-pending-info { 173 + color: var(--text-secondary); 174 + margin: 1rem 0 1.5rem 0; 175 + } 176 + 177 + form { 178 + display: flex; 179 + flex-direction: column; 180 + gap: 1rem; 181 + } 182 + 183 + .field { 184 + display: flex; 185 + flex-direction: column; 186 + gap: 0.25rem; 187 + } 188 + 189 + label { 190 + font-size: 0.875rem; 191 + font-weight: 500; 192 + } 193 + 194 + input { 195 + padding: 0.75rem; 196 + border: 1px solid var(--border-color-light); 197 + border-radius: 4px; 198 + font-size: 1rem; 199 + background: var(--bg-input); 200 + color: var(--text-primary); 201 + } 202 + 203 + input:focus { 204 + outline: none; 205 + border-color: var(--accent); 206 + } 207 + 208 + button, .btn { 209 + padding: 0.75rem; 210 + background: var(--accent); 211 + color: white; 212 + border: none; 213 + border-radius: 4px; 214 + font-size: 1rem; 215 + cursor: pointer; 216 + text-decoration: none; 217 + text-align: center; 218 + display: inline-block; 219 + } 220 + 221 + button:hover:not(:disabled), .btn:hover { 222 + background: var(--accent-hover); 223 + } 224 + 225 + button:disabled { 226 + opacity: 0.6; 227 + cursor: not-allowed; 228 + } 229 + 230 + button.secondary, .btn.secondary { 231 + background: transparent; 232 + color: var(--accent); 233 + border: 1px solid var(--accent); 234 + } 235 + 236 + button.secondary:hover:not(:disabled), .btn.secondary:hover { 237 + background: var(--accent); 238 + color: white; 239 + } 240 + 241 + .error { 242 + padding: 0.75rem; 243 + background: var(--error-bg); 244 + border: 1px solid var(--error-border); 245 + border-radius: 4px; 246 + color: var(--error-text); 247 + margin-bottom: 1rem; 248 + } 249 + 250 + .success { 251 + padding: 0.75rem; 252 + background: var(--success-bg); 253 + border: 1px solid var(--success-border); 254 + border-radius: 4px; 255 + color: var(--success-text); 256 + margin-bottom: 1rem; 257 + } 258 + 259 + .cancel-link { 260 + text-align: center; 261 + margin-top: 1.5rem; 262 + font-size: 0.875rem; 263 + } 264 + 265 + .cancel-link a { 266 + color: var(--text-secondary); 267 + } 268 + 269 + .actions { 270 + display: flex; 271 + gap: 1rem; 272 + } 273 + 274 + .actions .btn { 275 + flex: 1; 276 + } 277 + </style>
+6
migrations/20251219_rename_email_confirmed.sql
··· 1 + DO $$ 2 + BEGIN 3 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'email_confirmed') THEN 4 + ALTER TABLE users RENAME COLUMN email_confirmed TO email_verified; 5 + END IF; 6 + END $$;
+27
migrations/20251220_rename_notifications_to_comms.sql
··· 1 + DO $$ 2 + BEGIN 3 + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN 4 + ALTER TYPE notification_channel RENAME TO comms_channel; 5 + END IF; 6 + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN 7 + ALTER TYPE notification_status RENAME TO comms_status; 8 + END IF; 9 + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN 10 + ALTER TYPE notification_type RENAME TO comms_type; 11 + END IF; 12 + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'notification_queue') THEN 13 + ALTER TABLE notification_queue RENAME TO comms_queue; 14 + END IF; 15 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comms_queue' AND column_name = 'notification_type') THEN 16 + ALTER TABLE comms_queue RENAME COLUMN notification_type TO comms_type; 17 + END IF; 18 + IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_status_scheduled') THEN 19 + ALTER INDEX idx_notification_queue_status_scheduled RENAME TO idx_comms_queue_status_scheduled; 20 + END IF; 21 + IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_user_id') THEN 22 + ALTER INDEX idx_notification_queue_user_id RENAME TO idx_comms_queue_user_id; 23 + END IF; 24 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'preferred_notification_channel') THEN 25 + ALTER TABLE users RENAME COLUMN preferred_notification_channel TO preferred_comms_channel; 26 + END IF; 27 + END $$;
+3 -3
src/api/admin/account/email.rs
··· 87 87 .subject 88 88 .clone() 89 89 .unwrap_or_else(|| format!("Message from {}", hostname)); 90 - let notification = crate::notifications::NewNotification::email( 90 + let item = crate::comms::NewComms::email( 91 91 user_id, 92 - crate::notifications::NotificationType::AdminEmail, 92 + crate::comms::CommsType::AdminEmail, 93 93 email, 94 94 subject, 95 95 content.to_string(), 96 96 ); 97 - let result = crate::notifications::enqueue_notification(&state.db, notification).await; 97 + let result = crate::comms::enqueue_comms(&state.db, item).await; 98 98 match result { 99 99 Ok(_) => { 100 100 tracing::info!("Admin email queued for {} ({})", handle, recipient_did);
+3 -3
src/api/admin/account/info.rs
··· 24 24 pub indexed_at: String, 25 25 pub invite_note: Option<String>, 26 26 pub invites_disabled: bool, 27 - pub email_confirmed_at: Option<String>, 27 + pub email_verified_at: Option<String>, 28 28 pub deactivated_at: Option<String>, 29 29 } 30 30 ··· 67 67 indexed_at: row.created_at.to_rfc3339(), 68 68 invite_note: None, 69 69 invites_disabled: false, 70 - email_confirmed_at: None, 70 + email_verified_at: None, 71 71 deactivated_at: None, 72 72 }), 73 73 ) ··· 143 143 indexed_at: row.created_at.to_rfc3339(), 144 144 invite_note: None, 145 145 invites_disabled: false, 146 - email_confirmed_at: None, 146 + email_verified_at: None, 147 147 deactivated_at: None, 148 148 }); 149 149 }
+4 -4
src/api/admin/account/search.rs
··· 31 31 pub email: Option<String>, 32 32 pub indexed_at: String, 33 33 #[serde(skip_serializing_if = "Option::is_none")] 34 - pub email_confirmed_at: Option<String>, 34 + pub email_verified_at: Option<String>, 35 35 #[serde(skip_serializing_if = "Option::is_none")] 36 36 pub deactivated_at: Option<String>, 37 37 #[serde(skip_serializing_if = "Option::is_none")] ··· 56 56 let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h)); 57 57 let result = sqlx::query_as::<_, (String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<chrono::DateTime<chrono::Utc>>)>( 58 58 r#" 59 - SELECT did, handle, email, created_at, email_confirmed, deactivated_at 59 + SELECT did, handle, email, created_at, email_verified, deactivated_at 60 60 FROM users 61 61 WHERE did > $1 AND ($2::text IS NULL OR handle ILIKE $2) 62 62 ORDER BY did ASC ··· 74 74 let accounts: Vec<AccountView> = rows 75 75 .into_iter() 76 76 .take(limit as usize) 77 - .map(|(did, handle, email, created_at, email_confirmed, deactivated_at)| AccountView { 77 + .map(|(did, handle, email, created_at, email_verified, deactivated_at)| AccountView { 78 78 did: did.clone(), 79 79 handle, 80 80 email, 81 81 indexed_at: created_at.to_rfc3339(), 82 - email_confirmed_at: if email_confirmed { 82 + email_verified_at: if email_verified { 83 83 Some(created_at.to_rfc3339()) 84 84 } else { 85 85 None
+63 -49
src/api/identity/account.rs
··· 322 322 } 323 323 Ok(None) => {} 324 324 } 325 + let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") 326 + .map(|v| v == "true" || v == "1") 327 + .unwrap_or(false); 328 + if invite_code_required && input.invite_code.as_ref().map(|c| c.trim().is_empty()).unwrap_or(true) { 329 + return ( 330 + StatusCode::BAD_REQUEST, 331 + Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})), 332 + ) 333 + .into_response(); 334 + } 325 335 if let Some(code) = &input.invite_code { 326 - let invite_query = sqlx::query!( 327 - "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", 328 - code 329 - ) 330 - .fetch_optional(&mut *tx) 331 - .await; 332 - match invite_query { 333 - Ok(Some(row)) => { 334 - if row.available_uses <= 0 { 335 - return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response(); 336 + if !code.trim().is_empty() { 337 + let invite_query = sqlx::query!( 338 + "SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", 339 + code 340 + ) 341 + .fetch_optional(&mut *tx) 342 + .await; 343 + match invite_query { 344 + Ok(Some(row)) => { 345 + if row.available_uses <= 0 { 346 + return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response(); 347 + } 348 + let update_invite = sqlx::query!( 349 + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 350 + code 351 + ) 352 + .execute(&mut *tx) 353 + .await; 354 + if let Err(e) = update_invite { 355 + error!("Error updating invite code: {:?}", e); 356 + return ( 357 + StatusCode::INTERNAL_SERVER_ERROR, 358 + Json(json!({"error": "InternalError"})), 359 + ) 360 + .into_response(); 361 + } 336 362 } 337 - let update_invite = sqlx::query!( 338 - "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 339 - code 340 - ) 341 - .execute(&mut *tx) 342 - .await; 343 - if let Err(e) = update_invite { 344 - error!("Error updating invite code: {:?}", e); 363 + Ok(None) => { 364 + return ( 365 + StatusCode::BAD_REQUEST, 366 + Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})), 367 + ) 368 + .into_response(); 369 + } 370 + Err(e) => { 371 + error!("Error checking invite code: {:?}", e); 345 372 return ( 346 373 StatusCode::INTERNAL_SERVER_ERROR, 347 374 Json(json!({"error": "InternalError"})), ··· 349 376 .into_response(); 350 377 } 351 378 } 352 - Ok(None) => { 353 - return ( 354 - StatusCode::BAD_REQUEST, 355 - Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})), 356 - ) 357 - .into_response(); 358 - } 359 - Err(e) => { 360 - error!("Error checking invite code: {:?}", e); 361 - return ( 362 - StatusCode::INTERNAL_SERVER_ERROR, 363 - Json(json!({"error": "InternalError"})), 364 - ) 365 - .into_response(); 366 - } 367 379 } 368 380 } 369 381 let password_hash = match hash(&input.password, DEFAULT_COST) { ··· 387 399 let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 388 400 r#"INSERT INTO users ( 389 401 handle, email, did, password_hash, 390 - preferred_notification_channel, 402 + preferred_comms_channel, 391 403 discord_id, telegram_username, signal_number, 392 404 is_admin 393 - ) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8, $9) RETURNING id"#, 405 + ) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9) RETURNING id"#, 394 406 ) 395 407 .bind(short_handle) 396 408 .bind(&email) ··· 598 610 .into_response(); 599 611 } 600 612 if let Some(code) = &input.invite_code { 601 - let use_insert = sqlx::query!( 602 - "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 603 - code, 604 - user_id 605 - ) 606 - .execute(&mut *tx) 607 - .await; 608 - if let Err(e) = use_insert { 609 - error!("Error recording invite usage: {:?}", e); 610 - return ( 611 - StatusCode::INTERNAL_SERVER_ERROR, 612 - Json(json!({"error": "InternalError"})), 613 + if !code.trim().is_empty() { 614 + let use_insert = sqlx::query!( 615 + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 616 + code, 617 + user_id 613 618 ) 614 - .into_response(); 619 + .execute(&mut *tx) 620 + .await; 621 + if let Err(e) = use_insert { 622 + error!("Error recording invite usage: {:?}", e); 623 + return ( 624 + StatusCode::INTERNAL_SERVER_ERROR, 625 + Json(json!({"error": "InternalError"})), 626 + ) 627 + .into_response(); 628 + } 615 629 } 616 630 } 617 631 if let Err(e) = tx.commit().await { ··· 646 660 { 647 661 warn!("Failed to create default profile for {}: {}", did, e); 648 662 } 649 - if let Err(e) = crate::notifications::enqueue_signup_verification( 663 + if let Err(e) = crate::comms::enqueue_signup_verification( 650 664 &state.db, 651 665 user_id, 652 666 verification_channel,
+106 -11
src/api/identity/did.rs
··· 53 53 .await; 54 54 (StatusCode::OK, Json(json!({ "did": row.did }))).into_response() 55 55 } 56 - Ok(None) => ( 57 - StatusCode::NOT_FOUND, 58 - Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})), 59 - ) 60 - .into_response(), 56 + Ok(None) => { 57 + match crate::handle::resolve_handle(handle).await { 58 + Ok(did) => { 59 + let _ = state 60 + .cache 61 + .set(&cache_key, &did, std::time::Duration::from_secs(300)) 62 + .await; 63 + (StatusCode::OK, Json(json!({ "did": did }))).into_response() 64 + } 65 + Err(_) => ( 66 + StatusCode::NOT_FOUND, 67 + Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})), 68 + ) 69 + .into_response(), 70 + } 71 + } 61 72 Err(e) => { 62 73 error!("DB error resolving handle: {:?}", e); 63 74 ( ··· 396 407 ) 397 408 .into_response(); 398 409 } 410 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 411 + let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname); 412 + let (handle_to_store, full_handle) = if is_service_domain { 413 + let suffix = format!(".{}", hostname); 414 + let short_handle = if new_handle.ends_with(&suffix) { 415 + new_handle.strip_suffix(&suffix).unwrap_or(new_handle) 416 + } else { 417 + new_handle 418 + }; 419 + (short_handle.to_string(), format!("{}.{}", short_handle, hostname)) 420 + } else { 421 + match crate::handle::verify_handle_ownership(new_handle, &did).await { 422 + Ok(()) => {} 423 + Err(crate::handle::HandleResolutionError::NotFound) => { 424 + return ( 425 + StatusCode::BAD_REQUEST, 426 + Json(json!({ 427 + "error": "HandleNotAvailable", 428 + "message": "Handle verification failed. Please set up DNS TXT record at _atproto.{} or serve your DID at https://{}/.well-known/atproto-did", 429 + "handle": new_handle 430 + })), 431 + ) 432 + .into_response(); 433 + } 434 + Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => { 435 + return ( 436 + StatusCode::BAD_REQUEST, 437 + Json(json!({ 438 + "error": "HandleNotAvailable", 439 + "message": format!("Handle points to different DID. Expected {}, got {}", expected, actual) 440 + })), 441 + ) 442 + .into_response(); 443 + } 444 + Err(e) => { 445 + warn!("Handle verification failed: {}", e); 446 + return ( 447 + StatusCode::BAD_REQUEST, 448 + Json(json!({ 449 + "error": "HandleNotAvailable", 450 + "message": format!("Handle verification failed: {}", e) 451 + })), 452 + ) 453 + .into_response(); 454 + } 455 + } 456 + (new_handle.to_string(), new_handle.to_string()) 457 + }; 399 458 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id) 400 459 .fetch_optional(&state.db) 401 460 .await ··· 403 462 .flatten(); 404 463 let existing = sqlx::query!( 405 464 "SELECT id FROM users WHERE handle = $1 AND id != $2", 406 - new_handle, 465 + handle_to_store, 407 466 user_id 408 467 ) 409 468 .fetch_optional(&state.db) ··· 417 476 } 418 477 let result = sqlx::query!( 419 478 "UPDATE users SET handle = $1 WHERE id = $2", 420 - new_handle, 479 + handle_to_store, 421 480 user_id 422 481 ) 423 482 .execute(&state.db) ··· 427 486 if let Some(old) = old_handle { 428 487 let _ = state.cache.delete(&format!("handle:{}", old)).await; 429 488 } 430 - let _ = state.cache.delete(&format!("handle:{}", new_handle)).await; 431 - let hostname = 432 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 433 - let full_handle = format!("{}.{}", new_handle, hostname); 489 + let _ = state 490 + .cache 491 + .delete(&format!("handle:{}", handle_to_store)) 492 + .await; 493 + let _ = state.cache.delete(&format!("handle:{}", full_handle)).await; 434 494 if let Err(e) = 435 495 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) 436 496 .await 437 497 { 438 498 warn!("Failed to sequence identity event for handle update: {}", e); 499 + } 500 + if let Err(e) = update_plc_handle(&state, &did, &full_handle).await { 501 + warn!("Failed to update PLC handle: {}", e); 439 502 } 440 503 (StatusCode::OK, Json(json!({}))).into_response() 441 504 } ··· 448 511 .into_response() 449 512 } 450 513 } 514 + } 515 + 516 + async fn update_plc_handle( 517 + state: &AppState, 518 + did: &str, 519 + new_handle: &str, 520 + ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { 521 + if !did.starts_with("did:plc:") { 522 + return Ok(()); 523 + } 524 + let user_row = sqlx::query!( 525 + r#"SELECT u.id, uk.key_bytes, uk.encryption_version 526 + FROM users u 527 + JOIN user_keys uk ON u.id = uk.user_id 528 + WHERE u.did = $1"#, 529 + did 530 + ) 531 + .fetch_optional(&state.db) 532 + .await?; 533 + let user_row = match user_row { 534 + Some(r) => r, 535 + None => return Ok(()), 536 + }; 537 + let key_bytes = crate::config::decrypt_key(&user_row.key_bytes, user_row.encryption_version)?; 538 + let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)?; 539 + let plc_client = crate::plc::PlcClient::new(None); 540 + let last_op = plc_client.get_last_op(did).await?; 541 + let new_also_known_as = vec![format!("at://{}", new_handle)]; 542 + let update_op = crate::plc::create_update_op(&last_op, None, None, Some(new_also_known_as), None)?; 543 + let signed_op = crate::plc::sign_operation(&update_op, &signing_key)?; 544 + plc_client.send_operation(did, &signed_op).await?; 545 + Ok(()) 451 546 } 452 547 453 548 pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
+1 -1
src/api/identity/plc/request.rs
··· 68 68 } 69 69 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 70 70 if let Err(e) = 71 - crate::notifications::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await 71 + crate::comms::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await 72 72 { 73 73 warn!("Failed to enqueue PLC operation notification: {:?}", e); 74 74 }
+10 -10
src/api/notification_prefs.rs
··· 60 60 r#" 61 61 SELECT 62 62 email, 63 - preferred_notification_channel::text as channel, 63 + preferred_comms_channel::text as channel, 64 64 discord_id, 65 65 discord_verified, 66 66 telegram_username, ··· 110 110 pub struct NotificationHistoryEntry { 111 111 pub created_at: String, 112 112 pub channel: String, 113 - pub notification_type: String, 113 + pub comms_type: String, 114 114 pub status: String, 115 115 pub subject: Option<String>, 116 116 pub body: String, ··· 164 164 SELECT 165 165 created_at, 166 166 channel as "channel: String", 167 - notification_type as "notification_type: String", 167 + comms_type as "comms_type: String", 168 168 status as "status: String", 169 169 subject, 170 170 body 171 - FROM notification_queue 171 + FROM comms_queue 172 172 WHERE user_id = $1 173 173 ORDER BY created_at DESC 174 174 LIMIT 50 ··· 190 190 NotificationHistoryEntry { 191 191 created_at: row.created_at.to_rfc3339(), 192 192 channel: row.channel.clone(), 193 - notification_type: row.notification_type.clone(), 193 + comms_type: row.comms_type.clone(), 194 194 status: row.status.clone(), 195 195 subject: row.subject.clone(), 196 196 body: row.body.clone(), ··· 231 231 sqlx::query!( 232 232 r#" 233 233 INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 234 - VALUES ($1, $2::notification_channel, $3, $4, $5) 234 + VALUES ($1, $2::comms_channel, $3, $4, $5) 235 235 ON CONFLICT (user_id, channel) DO UPDATE 236 236 SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW() 237 237 "#, ··· 248 248 if channel == "email" { 249 249 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 250 250 let handle_str = handle.unwrap_or("user"); 251 - crate::notifications::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname) 251 + crate::comms::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname) 252 252 .await 253 253 .map_err(|e| format!("Failed to enqueue email notification: {}", e))?; 254 254 } else { 255 255 sqlx::query!( 256 256 r#" 257 - INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata) 258 - VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5) 257 + INSERT INTO comms_queue (user_id, channel, comms_type, recipient, subject, body, metadata) 258 + VALUES ($1, $2::comms_channel, 'channel_verification', $3, 'Verify your channel', $4, $5) 259 259 "#, 260 260 user_id, 261 261 channel as _, ··· 331 331 .into_response(); 332 332 } 333 333 if let Err(e) = sqlx::query( 334 - r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"# 334 + r#"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2"# 335 335 ) 336 336 .bind(channel) 337 337 .bind(&user.did)
+2 -2
src/api/repo/record/batch.rs
··· 1 1 use super::validation::validate_record; 2 - use super::write::has_verified_notification_channel; 2 + use super::write::has_verified_comms_channel; 3 3 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 4 4 use crate::repo::tracking::TrackingBlockStore; 5 5 use crate::state::AppState; ··· 109 109 ) 110 110 .into_response(); 111 111 } 112 - match has_verified_notification_channel(&state.db, &did).await { 112 + match has_verified_comms_channel(&state.db, &did).await { 113 113 Ok(true) => {} 114 114 Ok(false) => { 115 115 return (
+5 -5
src/api/repo/record/write.rs
··· 22 22 use tracing::error; 23 23 use uuid::Uuid; 24 24 25 - pub async fn has_verified_notification_channel( 25 + pub async fn has_verified_comms_channel( 26 26 db: &PgPool, 27 27 did: &str, 28 28 ) -> Result<bool, sqlx::Error> { 29 29 let row = sqlx::query( 30 30 r#" 31 31 SELECT 32 - email_confirmed, 32 + email_verified, 33 33 discord_verified, 34 34 telegram_verified, 35 35 signal_verified ··· 42 42 .await?; 43 43 match row { 44 44 Some(r) => { 45 - let email_confirmed: bool = r.get("email_confirmed"); 45 + let email_verified: bool = r.get("email_verified"); 46 46 let discord_verified: bool = r.get("discord_verified"); 47 47 let telegram_verified: bool = r.get("telegram_verified"); 48 48 let signal_verified: bool = r.get("signal_verified"); 49 - Ok(email_confirmed || discord_verified || telegram_verified || signal_verified) 49 + Ok(email_verified || discord_verified || telegram_verified || signal_verified) 50 50 } 51 51 None => Ok(false), 52 52 } ··· 96 96 ) 97 97 .into_response()); 98 98 } 99 - match has_verified_notification_channel(&state.db, &auth_user.did).await { 99 + match has_verified_comms_channel(&state.db, &auth_user.did).await { 100 100 Ok(true) => {} 101 101 Ok(false) => { 102 102 return Err((
+1 -1
src/api/server/account_status.rs
··· 299 299 .into_response(); 300 300 } 301 301 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 302 - if let Err(e) = crate::notifications::enqueue_account_deletion( 302 + if let Err(e) = crate::comms::enqueue_account_deletion( 303 303 &state.db, 304 304 user_id, 305 305 &confirmation_token,
+1 -1
src/api/server/password.rs
··· 100 100 } 101 101 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 102 102 if let Err(e) = 103 - crate::notifications::enqueue_password_reset(&state.db, user_id, &code, &hostname).await 103 + crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await 104 104 { 105 105 warn!("Failed to enqueue password reset notification: {:?}", e); 106 106 }
+52 -44
src/api/server/session.rs
··· 35 35 } 36 36 } 37 37 38 + fn full_handle(stored_handle: &str, pds_hostname: &str) -> String { 39 + if stored_handle.contains('.') { 40 + stored_handle.to_string() 41 + } else { 42 + format!("{}.{}", stored_handle, pds_hostname) 43 + } 44 + } 45 + 38 46 #[derive(Deserialize)] 39 47 pub struct CreateSessionInput { 40 48 pub identifier: String, ··· 76 84 let row = match sqlx::query!( 77 85 r#"SELECT 78 86 u.id, u.did, u.handle, u.password_hash, 79 - u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified, 87 + u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, 80 88 k.key_bytes, k.encryption_version 81 89 FROM users u 82 90 JOIN user_keys k ON u.id = k.user_id ··· 128 136 .into_response(); 129 137 } 130 138 let is_verified = 131 - row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; 139 + row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 132 140 if !is_verified { 133 141 warn!("Login attempt for unverified account: {}", row.did); 134 142 return ( ··· 169 177 error!("Failed to insert session: {:?}", e); 170 178 return ApiError::InternalError.into_response(); 171 179 } 172 - let full_handle = format!("{}.{}", row.handle, pds_hostname); 180 + let handle = full_handle(&row.handle, &pds_hostname); 173 181 Json(CreateSessionOutput { 174 182 access_jwt: access_meta.token, 175 183 refresh_jwt: refresh_meta.token, 176 - handle: full_handle, 184 + handle, 177 185 did: row.did, 178 186 }) 179 187 .into_response() ··· 185 193 ) -> Response { 186 194 match sqlx::query!( 187 195 r#"SELECT 188 - handle, email, email_confirmed, is_admin, 189 - preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 196 + handle, email, email_verified, is_admin, 197 + preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 190 198 discord_verified, telegram_verified, signal_verified 191 199 FROM users WHERE did = $1"#, 192 200 auth_user.did ··· 196 204 { 197 205 Ok(Some(row)) => { 198 206 let (preferred_channel, preferred_channel_verified) = match row.preferred_channel { 199 - crate::notifications::NotificationChannel::Email => ("email", row.email_confirmed), 200 - crate::notifications::NotificationChannel::Discord => ("discord", row.discord_verified), 201 - crate::notifications::NotificationChannel::Telegram => ("telegram", row.telegram_verified), 202 - crate::notifications::NotificationChannel::Signal => ("signal", row.signal_verified), 207 + crate::comms::CommsChannel::Email => ("email", row.email_verified), 208 + crate::comms::CommsChannel::Discord => ("discord", row.discord_verified), 209 + crate::comms::CommsChannel::Telegram => ("telegram", row.telegram_verified), 210 + crate::comms::CommsChannel::Signal => ("signal", row.signal_verified), 203 211 }; 204 212 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 205 - let full_handle = format!("{}.{}", row.handle, pds_hostname); 213 + let handle = full_handle(&row.handle, &pds_hostname); 206 214 Json(json!({ 207 - "handle": full_handle, 215 + "handle": handle, 208 216 "did": auth_user.did, 209 217 "email": row.email, 210 - "emailConfirmed": row.email_confirmed, 218 + "emailVerified": row.email_verified, 211 219 "preferredChannel": preferred_channel, 212 220 "preferredChannelVerified": preferred_channel_verified, 213 221 "isAdmin": row.is_admin, ··· 407 415 } 408 416 match sqlx::query!( 409 417 r#"SELECT 410 - handle, email, email_confirmed, is_admin, 411 - preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel", 418 + handle, email, email_verified, is_admin, 419 + preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 412 420 discord_verified, telegram_verified, signal_verified 413 421 FROM users WHERE did = $1"#, 414 422 session_row.did ··· 418 426 { 419 427 Ok(Some(u)) => { 420 428 let (preferred_channel, preferred_channel_verified) = match u.preferred_channel { 421 - crate::notifications::NotificationChannel::Email => ("email", u.email_confirmed), 422 - crate::notifications::NotificationChannel::Discord => ("discord", u.discord_verified), 423 - crate::notifications::NotificationChannel::Telegram => ("telegram", u.telegram_verified), 424 - crate::notifications::NotificationChannel::Signal => ("signal", u.signal_verified), 429 + crate::comms::CommsChannel::Email => ("email", u.email_verified), 430 + crate::comms::CommsChannel::Discord => ("discord", u.discord_verified), 431 + crate::comms::CommsChannel::Telegram => ("telegram", u.telegram_verified), 432 + crate::comms::CommsChannel::Signal => ("signal", u.signal_verified), 425 433 }; 426 434 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 427 - let full_handle = format!("{}.{}", u.handle, pds_hostname); 435 + let handle = full_handle(&u.handle, &pds_hostname); 428 436 Json(json!({ 429 437 "accessJwt": new_access_meta.token, 430 438 "refreshJwt": new_refresh_meta.token, 431 - "handle": full_handle, 439 + "handle": handle, 432 440 "did": session_row.did, 433 441 "email": u.email, 434 - "emailConfirmed": u.email_confirmed, 442 + "emailVerified": u.email_verified, 435 443 "preferredChannel": preferred_channel, 436 444 "preferredChannelVerified": preferred_channel_verified, 437 445 "isAdmin": u.is_admin, ··· 464 472 pub handle: String, 465 473 pub did: String, 466 474 pub email: Option<String>, 467 - pub email_confirmed: bool, 475 + pub email_verified: bool, 468 476 pub preferred_channel: String, 469 477 pub preferred_channel_verified: bool, 470 478 } ··· 477 485 let row = match sqlx::query!( 478 486 r#"SELECT 479 487 u.id, u.did, u.handle, u.email, 480 - u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 488 + u.preferred_comms_channel as "channel: crate::comms::CommsChannel", 481 489 k.key_bytes, k.encryption_version 482 490 FROM users u 483 491 JOIN user_keys k ON u.id = k.user_id ··· 534 542 } 535 543 }; 536 544 let verified_column = match row.channel { 537 - crate::notifications::NotificationChannel::Email => "email_confirmed", 538 - crate::notifications::NotificationChannel::Discord => "discord_verified", 539 - crate::notifications::NotificationChannel::Telegram => "telegram_verified", 540 - crate::notifications::NotificationChannel::Signal => "signal_verified", 545 + crate::comms::CommsChannel::Email => "email_verified", 546 + crate::comms::CommsChannel::Discord => "discord_verified", 547 + crate::comms::CommsChannel::Telegram => "telegram_verified", 548 + crate::comms::CommsChannel::Signal => "signal_verified", 541 549 }; 542 550 let update_query = format!( 543 551 "UPDATE users SET {} = TRUE WHERE did = $1", ··· 590 598 return ApiError::InternalError.into_response(); 591 599 } 592 600 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 593 - if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await { 601 + if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await { 594 602 warn!("Failed to enqueue welcome notification: {:?}", e); 595 603 } 596 - let email_confirmed = matches!( 604 + let email_verified = matches!( 597 605 row.channel, 598 - crate::notifications::NotificationChannel::Email 606 + crate::comms::CommsChannel::Email 599 607 ); 600 608 let preferred_channel = match row.channel { 601 - crate::notifications::NotificationChannel::Email => "email", 602 - crate::notifications::NotificationChannel::Discord => "discord", 603 - crate::notifications::NotificationChannel::Telegram => "telegram", 604 - crate::notifications::NotificationChannel::Signal => "signal", 609 + crate::comms::CommsChannel::Email => "email", 610 + crate::comms::CommsChannel::Discord => "discord", 611 + crate::comms::CommsChannel::Telegram => "telegram", 612 + crate::comms::CommsChannel::Signal => "signal", 605 613 }; 606 614 Json(ConfirmSignupOutput { 607 615 access_jwt: access_meta.token, ··· 609 617 handle: row.handle, 610 618 did: row.did, 611 619 email: row.email, 612 - email_confirmed, 620 + email_verified, 613 621 preferred_channel: preferred_channel.to_string(), 614 622 preferred_channel_verified: true, 615 623 }) ··· 630 638 let row = match sqlx::query!( 631 639 r#"SELECT 632 640 id, handle, email, 633 - preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 641 + preferred_comms_channel as "channel: crate::comms::CommsChannel", 634 642 discord_id, telegram_username, signal_number, 635 - email_confirmed, discord_verified, telegram_verified, signal_verified 643 + email_verified, discord_verified, telegram_verified, signal_verified 636 644 FROM users 637 645 WHERE did = $1"#, 638 646 input.did ··· 650 658 } 651 659 }; 652 660 let is_verified = 653 - row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified; 661 + row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 654 662 if is_verified { 655 663 return ApiError::InvalidRequest("Account is already verified".into()).into_response(); 656 664 } ··· 678 686 return ApiError::InternalError.into_response(); 679 687 } 680 688 let (channel_str, recipient) = match row.channel { 681 - crate::notifications::NotificationChannel::Email => { 689 + crate::comms::CommsChannel::Email => { 682 690 ("email", row.email.unwrap_or_default()) 683 691 } 684 - crate::notifications::NotificationChannel::Discord => { 692 + crate::comms::CommsChannel::Discord => { 685 693 ("discord", row.discord_id.unwrap_or_default()) 686 694 } 687 - crate::notifications::NotificationChannel::Telegram => { 695 + crate::comms::CommsChannel::Telegram => { 688 696 ("telegram", row.telegram_username.unwrap_or_default()) 689 697 } 690 - crate::notifications::NotificationChannel::Signal => { 698 + crate::comms::CommsChannel::Signal => { 691 699 ("signal", row.signal_number.unwrap_or_default()) 692 700 } 693 701 }; 694 - if let Err(e) = crate::notifications::enqueue_signup_verification( 702 + if let Err(e) = crate::comms::enqueue_signup_verification( 695 703 &state.db, 696 704 row.id, 697 705 channel_str,
+2 -2
src/api/verification.rs
··· 68 68 let record = match sqlx::query!( 69 69 r#" 70 70 SELECT code, pending_identifier, expires_at FROM channel_verifications 71 - WHERE user_id = $1 AND channel = $2::notification_channel 71 + WHERE user_id = $1 AND channel = $2::comms_channel 72 72 "#, 73 73 user_id, 74 74 channel_str as _ ··· 163 163 } 164 164 165 165 if let Err(e) = sqlx::query!( 166 - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel", 166 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", 167 167 user_id, 168 168 channel_str as _ 169 169 )
+16
src/comms/mod.rs
··· 1 + mod sender; 2 + mod service; 3 + mod types; 4 + 5 + pub use sender::{ 6 + CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender, 7 + is_valid_phone_number, sanitize_header_value, 8 + }; 9 + 10 + pub use service::{ 11 + CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, 12 + enqueue_comms, enqueue_email_update, enqueue_email_verification, enqueue_password_reset, 13 + enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, 14 + }; 15 + 16 + pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+121
src/handle/mod.rs
··· 1 + use hickory_resolver::config::{ResolverConfig, ResolverOpts}; 2 + use hickory_resolver::TokioAsyncResolver; 3 + use reqwest::Client; 4 + use std::time::Duration; 5 + use thiserror::Error; 6 + 7 + #[derive(Error, Debug)] 8 + pub enum HandleResolutionError { 9 + #[error("DNS lookup failed: {0}")] 10 + DnsError(String), 11 + #[error("HTTP request failed: {0}")] 12 + HttpError(String), 13 + #[error("No DID found for handle")] 14 + NotFound, 15 + #[error("Invalid DID format in record")] 16 + InvalidDid, 17 + #[error("DID mismatch: expected {expected}, got {actual}")] 18 + DidMismatch { expected: String, actual: String }, 19 + } 20 + 21 + pub async fn resolve_handle_dns(handle: &str) -> Result<String, HandleResolutionError> { 22 + let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()); 23 + let query_name = format!("_atproto.{}", handle); 24 + let txt_lookup = resolver 25 + .txt_lookup(&query_name) 26 + .await 27 + .map_err(|e| HandleResolutionError::DnsError(e.to_string()))?; 28 + for record in txt_lookup.iter() { 29 + for txt in record.txt_data() { 30 + let txt_str = String::from_utf8_lossy(txt); 31 + if let Some(did) = txt_str.strip_prefix("did=") { 32 + let did = did.trim(); 33 + if did.starts_with("did:") { 34 + return Ok(did.to_string()); 35 + } 36 + } 37 + } 38 + } 39 + Err(HandleResolutionError::NotFound) 40 + } 41 + 42 + pub async fn resolve_handle_http(handle: &str) -> Result<String, HandleResolutionError> { 43 + let url = format!("https://{}/.well-known/atproto-did", handle); 44 + let client = Client::builder() 45 + .timeout(Duration::from_secs(10)) 46 + .redirect(reqwest::redirect::Policy::limited(5)) 47 + .build() 48 + .map_err(|e| HandleResolutionError::HttpError(e.to_string()))?; 49 + let response = client 50 + .get(&url) 51 + .header("Accept", "text/plain") 52 + .send() 53 + .await 54 + .map_err(|e| HandleResolutionError::HttpError(e.to_string()))?; 55 + if !response.status().is_success() { 56 + return Err(HandleResolutionError::NotFound); 57 + } 58 + let body = response 59 + .text() 60 + .await 61 + .map_err(|e| HandleResolutionError::HttpError(e.to_string()))?; 62 + let did = body.trim(); 63 + if did.starts_with("did:") { 64 + Ok(did.to_string()) 65 + } else { 66 + Err(HandleResolutionError::InvalidDid) 67 + } 68 + } 69 + 70 + pub async fn resolve_handle(handle: &str) -> Result<String, HandleResolutionError> { 71 + match resolve_handle_dns(handle).await { 72 + Ok(did) => return Ok(did), 73 + Err(e) => { 74 + tracing::debug!("DNS resolution failed for {}: {}, trying HTTP", handle, e); 75 + } 76 + } 77 + resolve_handle_http(handle).await 78 + } 79 + 80 + pub async fn verify_handle_ownership( 81 + handle: &str, 82 + expected_did: &str, 83 + ) -> Result<(), HandleResolutionError> { 84 + let resolved_did = resolve_handle(handle).await?; 85 + if resolved_did == expected_did { 86 + Ok(()) 87 + } else { 88 + Err(HandleResolutionError::DidMismatch { 89 + expected: expected_did.to_string(), 90 + actual: resolved_did, 91 + }) 92 + } 93 + } 94 + 95 + pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool { 96 + let service_domains: Vec<String> = std::env::var("PDS_SERVICE_HANDLE_DOMAINS") 97 + .map(|s| s.split(',').map(|d| d.trim().to_string()).collect()) 98 + .unwrap_or_else(|_| vec![hostname.to_string()]); 99 + for domain in service_domains { 100 + if handle.ends_with(&format!(".{}", domain)) { 101 + return true; 102 + } 103 + if handle == domain { 104 + return true; 105 + } 106 + } 107 + false 108 + } 109 + 110 + #[cfg(test)] 111 + mod tests { 112 + use super::*; 113 + 114 + #[test] 115 + fn test_is_service_domain_handle() { 116 + assert!(is_service_domain_handle("user.example.com", "example.com")); 117 + assert!(is_service_domain_handle("example.com", "example.com")); 118 + assert!(!is_service_domain_handle("user.other.com", "example.com")); 119 + assert!(!is_service_domain_handle("myhandle.xyz", "example.com")); 120 + } 121 + }
+2 -1
src/lib.rs
··· 5 5 pub mod circuit_breaker; 6 6 pub mod config; 7 7 pub mod crawlers; 8 + pub mod handle; 8 9 pub mod image; 9 10 pub mod metrics; 10 - pub mod notifications; 11 + pub mod comms; 11 12 pub mod oauth; 12 13 pub mod plc; 13 14 pub mod rate_limit;
+13 -15
src/main.rs
··· 1 + use bspds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; 1 2 use bspds::crawlers::{Crawlers, start_crawlers_service}; 2 - use bspds::notifications::{ 3 - DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender, 4 - }; 5 3 use bspds::state::AppState; 6 4 use std::net::SocketAddr; 7 5 use std::process::ExitCode; ··· 68 66 69 67 let (shutdown_tx, shutdown_rx) = watch::channel(false); 70 68 71 - let mut notification_service = NotificationService::new(pool); 69 + let mut comms_service = CommsService::new(pool); 72 70 73 71 if let Some(email_sender) = EmailSender::from_env() { 74 - info!("Email notifications enabled"); 75 - notification_service = notification_service.register_sender(email_sender); 72 + info!("Email comms enabled"); 73 + comms_service = comms_service.register_sender(email_sender); 76 74 } else { 77 - warn!("Email notifications disabled (MAIL_FROM_ADDRESS not set)"); 75 + warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)"); 78 76 } 79 77 80 78 if let Some(discord_sender) = DiscordSender::from_env() { 81 - info!("Discord notifications enabled"); 82 - notification_service = notification_service.register_sender(discord_sender); 79 + info!("Discord comms enabled"); 80 + comms_service = comms_service.register_sender(discord_sender); 83 81 } 84 82 85 83 if let Some(telegram_sender) = TelegramSender::from_env() { 86 - info!("Telegram notifications enabled"); 87 - notification_service = notification_service.register_sender(telegram_sender); 84 + info!("Telegram comms enabled"); 85 + comms_service = comms_service.register_sender(telegram_sender); 88 86 } 89 87 90 88 if let Some(signal_sender) = SignalSender::from_env() { 91 - info!("Signal notifications enabled"); 92 - notification_service = notification_service.register_sender(signal_sender); 89 + info!("Signal comms enabled"); 90 + comms_service = comms_service.register_sender(signal_sender); 93 91 } 94 92 95 - let notification_handle = tokio::spawn(notification_service.run(shutdown_rx.clone())); 93 + let comms_handle = tokio::spawn(comms_service.run(shutdown_rx.clone())); 96 94 97 95 let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() { 98 96 let crawlers = Arc::new( ··· 122 120 .with_graceful_shutdown(shutdown_signal(shutdown_tx)) 123 121 .await; 124 122 125 - notification_handle.await.ok(); 123 + comms_handle.await.ok(); 126 124 127 125 if let Some(handle) = crawlers_handle { 128 126 handle.await.ok();
+4 -4
src/metrics.rs
··· 54 54 "Total number of S3/blob storage operations" 55 55 ); 56 56 metrics::describe_gauge!( 57 - "bspds_notification_queue_size", 58 - "Current size of the notification queue" 57 + "bspds_comms_queue_size", 58 + "Current size of the comms queue" 59 59 ); 60 60 metrics::describe_counter!( 61 61 "bspds_rate_limit_rejections_total", ··· 167 167 .increment(1); 168 168 } 169 169 170 - pub fn set_notification_queue_size(size: usize) { 171 - gauge!("bspds_notification_queue_size").set(size as f64); 170 + pub fn set_comms_queue_size(size: usize) { 171 + gauge!("bspds_comms_queue_size").set(size as f64); 172 172 } 173 173 174 174 pub fn record_rate_limit_rejection(limiter: &str) {
-18
src/notifications/mod.rs
··· 1 - mod sender; 2 - mod service; 3 - mod types; 4 - 5 - pub use sender::{ 6 - DiscordSender, EmailSender, NotificationSender, SendError, SignalSender, TelegramSender, 7 - is_valid_phone_number, sanitize_header_value, 8 - }; 9 - 10 - pub use service::{ 11 - NotificationService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, 12 - enqueue_email_update, enqueue_email_verification, enqueue_notification, enqueue_password_reset, 13 - enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, 14 - }; 15 - 16 - pub use types::{ 17 - NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification, 18 - };
+22 -22
src/notifications/sender.rs src/comms/sender.rs
··· 6 6 use tokio::io::AsyncWriteExt; 7 7 use tokio::process::Command; 8 8 9 - use super::types::{NotificationChannel, QueuedNotification}; 9 + use super::types::{CommsChannel, QueuedComms}; 10 10 11 11 const HTTP_TIMEOUT_SECS: u64 = 30; 12 12 const MAX_RETRIES: u32 = 3; 13 13 const INITIAL_RETRY_DELAY_MS: u64 = 500; 14 14 15 15 #[async_trait] 16 - pub trait NotificationSender: Send + Sync { 17 - fn channel(&self) -> NotificationChannel; 18 - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError>; 16 + pub trait CommsSender: Send + Sync { 17 + fn channel(&self) -> CommsChannel; 18 + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError>; 19 19 } 20 20 21 21 #[derive(Debug, thiserror::Error)] ··· 25 25 #[error("Sendmail exited with non-zero status: {0}")] 26 26 SendmailFailed(String), 27 27 #[error("Channel not configured: {0:?}")] 28 - NotConfigured(NotificationChannel), 28 + NotConfigured(CommsChannel), 29 29 #[error("External service error: {0}")] 30 30 ExternalService(String), 31 31 #[error("Invalid recipient format: {0}")] ··· 91 91 Some(Self::new(from_address, from_name)) 92 92 } 93 93 94 - pub fn format_email(&self, notification: &QueuedNotification) -> String { 94 + pub fn format_email(&self, notification: &QueuedComms) -> String { 95 95 let subject = 96 96 sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification")); 97 97 let recipient = sanitize_header_value(&notification.recipient); ··· 112 112 } 113 113 114 114 #[async_trait] 115 - impl NotificationSender for EmailSender { 116 - fn channel(&self) -> NotificationChannel { 117 - NotificationChannel::Email 115 + impl CommsSender for EmailSender { 116 + fn channel(&self) -> CommsChannel { 117 + CommsChannel::Email 118 118 } 119 119 120 - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> { 120 + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { 121 121 let email_content = self.format_email(notification); 122 122 let mut child = Command::new(&self.sendmail_path) 123 123 .arg("-t") ··· 158 158 } 159 159 160 160 #[async_trait] 161 - impl NotificationSender for DiscordSender { 162 - fn channel(&self) -> NotificationChannel { 163 - NotificationChannel::Discord 161 + impl CommsSender for DiscordSender { 162 + fn channel(&self) -> CommsChannel { 163 + CommsChannel::Discord 164 164 } 165 165 166 - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> { 166 + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { 167 167 let subject = notification.subject.as_deref().unwrap_or("Notification"); 168 168 let content = format!("**{}**\n\n{}", subject, notification.body); 169 169 let payload = json!({ ··· 237 237 } 238 238 239 239 #[async_trait] 240 - impl NotificationSender for TelegramSender { 241 - fn channel(&self) -> NotificationChannel { 242 - NotificationChannel::Telegram 240 + impl CommsSender for TelegramSender { 241 + fn channel(&self) -> CommsChannel { 242 + CommsChannel::Telegram 243 243 } 244 244 245 - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> { 245 + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { 246 246 let chat_id = &notification.recipient; 247 247 let subject = notification.subject.as_deref().unwrap_or("Notification"); 248 248 let text = format!("*{}*\n\n{}", subject, notification.body); ··· 316 316 } 317 317 318 318 #[async_trait] 319 - impl NotificationSender for SignalSender { 320 - fn channel(&self) -> NotificationChannel { 321 - NotificationChannel::Signal 319 + impl CommsSender for SignalSender { 320 + fn channel(&self) -> CommsChannel { 321 + CommsChannel::Signal 322 322 } 323 323 324 - async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> { 324 + async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> { 325 325 let recipient = &notification.recipient; 326 326 if !is_valid_phone_number(recipient) { 327 327 return Err(SendError::InvalidRecipient(format!(
+110 -113
src/notifications/service.rs src/comms/service.rs
··· 9 9 use tracing::{debug, error, info, warn}; 10 10 use uuid::Uuid; 11 11 12 - use super::sender::{NotificationSender, SendError}; 13 - use super::types::{NewNotification, NotificationChannel, NotificationStatus, QueuedNotification}; 12 + use super::sender::{CommsSender, SendError}; 13 + use super::types::{NewComms, CommsChannel, CommsStatus, QueuedComms}; 14 14 15 - pub struct NotificationService { 15 + pub struct CommsService { 16 16 db: PgPool, 17 - senders: HashMap<NotificationChannel, Arc<dyn NotificationSender>>, 17 + senders: HashMap<CommsChannel, Arc<dyn CommsSender>>, 18 18 poll_interval: Duration, 19 19 batch_size: i64, 20 20 } 21 21 22 - impl NotificationService { 22 + impl CommsService { 23 23 pub fn new(db: PgPool) -> Self { 24 24 let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS") 25 25 .ok() ··· 47 47 self 48 48 } 49 49 50 - pub fn register_sender<S: NotificationSender + 'static>(mut self, sender: S) -> Self { 50 + pub fn register_sender<S: CommsSender + 'static>(mut self, sender: S) -> Self { 51 51 self.senders.insert(sender.channel(), Arc::new(sender)); 52 52 self 53 53 } 54 54 55 - pub async fn enqueue(&self, notification: NewNotification) -> Result<Uuid, sqlx::Error> { 55 + pub async fn enqueue(&self, item: NewComms) -> Result<Uuid, sqlx::Error> { 56 56 let id = sqlx::query_scalar!( 57 57 r#" 58 - INSERT INTO notification_queue 59 - (user_id, channel, notification_type, recipient, subject, body, metadata) 58 + INSERT INTO comms_queue 59 + (user_id, channel, comms_type, recipient, subject, body, metadata) 60 60 VALUES ($1, $2, $3, $4, $5, $6, $7) 61 61 RETURNING id 62 62 "#, 63 - notification.user_id, 64 - notification.channel as NotificationChannel, 65 - notification.notification_type as super::types::NotificationType, 66 - notification.recipient, 67 - notification.subject, 68 - notification.body, 69 - notification.metadata 63 + item.user_id, 64 + item.channel as CommsChannel, 65 + item.comms_type as super::types::CommsType, 66 + item.recipient, 67 + item.subject, 68 + item.body, 69 + item.metadata 70 70 ) 71 71 .fetch_one(&self.db) 72 72 .await?; 73 - debug!(notification_id = %id, "Notification enqueued"); 73 + debug!(comms_id = %id, "Comms enqueued"); 74 74 Ok(id) 75 75 } 76 76 ··· 81 81 pub async fn run(self, mut shutdown: watch::Receiver<bool>) { 82 82 if self.senders.is_empty() { 83 83 warn!( 84 - "Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured." 84 + "Comms service starting with no senders configured. Messages will be queued but not delivered until senders are configured." 85 85 ); 86 86 } 87 87 info!( 88 88 poll_interval_secs = self.poll_interval.as_secs(), 89 89 batch_size = self.batch_size, 90 90 channels = ?self.senders.keys().collect::<Vec<_>>(), 91 - "Starting notification service" 91 + "Starting comms service" 92 92 ); 93 93 let mut ticker = interval(self.poll_interval); 94 94 loop { 95 95 tokio::select! { 96 96 _ = ticker.tick() => { 97 97 if let Err(e) = self.process_batch().await { 98 - error!(error = %e, "Failed to process notification batch"); 98 + error!(error = %e, "Failed to process comms batch"); 99 99 } 100 100 } 101 101 _ = shutdown.changed() => { 102 102 if *shutdown.borrow() { 103 - info!("Notification service shutting down"); 103 + info!("Comms service shutting down"); 104 104 break; 105 105 } 106 106 } ··· 109 109 } 110 110 111 111 async fn process_batch(&self) -> Result<(), sqlx::Error> { 112 - let notifications = self.fetch_pending_notifications().await?; 113 - if notifications.is_empty() { 112 + let items = self.fetch_pending().await?; 113 + if items.is_empty() { 114 114 return Ok(()); 115 115 } 116 - debug!(count = notifications.len(), "Processing notification batch"); 117 - for notification in notifications { 118 - self.process_notification(notification).await; 116 + debug!(count = items.len(), "Processing comms batch"); 117 + for item in items { 118 + self.process_item(item).await; 119 119 } 120 120 Ok(()) 121 121 } 122 122 123 - async fn fetch_pending_notifications(&self) -> Result<Vec<QueuedNotification>, sqlx::Error> { 123 + async fn fetch_pending(&self) -> Result<Vec<QueuedComms>, sqlx::Error> { 124 124 let now = Utc::now(); 125 125 sqlx::query_as!( 126 - QueuedNotification, 126 + QueuedComms, 127 127 r#" 128 - UPDATE notification_queue 128 + UPDATE comms_queue 129 129 SET status = 'processing', updated_at = NOW() 130 130 WHERE id IN ( 131 - SELECT id FROM notification_queue 131 + SELECT id FROM comms_queue 132 132 WHERE status = 'pending' 133 133 AND scheduled_for <= $1 134 134 AND attempts < max_attempts ··· 138 138 ) 139 139 RETURNING 140 140 id, user_id, 141 - channel as "channel: NotificationChannel", 142 - notification_type as "notification_type: super::types::NotificationType", 143 - status as "status: NotificationStatus", 141 + channel as "channel: CommsChannel", 142 + comms_type as "comms_type: super::types::CommsType", 143 + status as "status: CommsStatus", 144 144 recipient, subject, body, metadata, 145 145 attempts, max_attempts, last_error, 146 146 created_at, updated_at, scheduled_for, processed_at ··· 152 152 .await 153 153 } 154 154 155 - async fn process_notification(&self, notification: QueuedNotification) { 156 - let notification_id = notification.id; 157 - let channel = notification.channel; 155 + async fn process_item(&self, item: QueuedComms) { 156 + let comms_id = item.id; 157 + let channel = item.channel; 158 158 let result = match self.senders.get(&channel) { 159 - Some(sender) => sender.send(&notification).await, 159 + Some(sender) => sender.send(&item).await, 160 160 None => { 161 161 warn!( 162 - notification_id = %notification_id, 162 + comms_id = %comms_id, 163 163 channel = ?channel, 164 164 "No sender registered for channel" 165 165 ); ··· 168 168 }; 169 169 match result { 170 170 Ok(()) => { 171 - debug!(notification_id = %notification_id, "Notification sent successfully"); 172 - if let Err(e) = self.mark_sent(notification_id).await { 171 + debug!(comms_id = %comms_id, "Comms sent successfully"); 172 + if let Err(e) = self.mark_sent(comms_id).await { 173 173 error!( 174 - notification_id = %notification_id, 174 + comms_id = %comms_id, 175 175 error = %e, 176 - "Failed to mark notification as sent" 176 + "Failed to mark comms as sent" 177 177 ); 178 178 } 179 179 } 180 180 Err(e) => { 181 181 let error_msg = e.to_string(); 182 182 warn!( 183 - notification_id = %notification_id, 183 + comms_id = %comms_id, 184 184 error = %error_msg, 185 - "Failed to send notification" 185 + "Failed to send comms" 186 186 ); 187 - if let Err(db_err) = self.mark_failed(notification_id, &error_msg).await { 187 + if let Err(db_err) = self.mark_failed(comms_id, &error_msg).await { 188 188 error!( 189 - notification_id = %notification_id, 189 + comms_id = %comms_id, 190 190 error = %db_err, 191 - "Failed to mark notification as failed" 191 + "Failed to mark comms as failed" 192 192 ); 193 193 } 194 194 } ··· 198 198 async fn mark_sent(&self, id: Uuid) -> Result<(), sqlx::Error> { 199 199 sqlx::query!( 200 200 r#" 201 - UPDATE notification_queue 201 + UPDATE comms_queue 202 202 SET status = 'sent', processed_at = NOW(), updated_at = NOW() 203 203 WHERE id = $1 204 204 "#, ··· 212 212 async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), sqlx::Error> { 213 213 sqlx::query!( 214 214 r#" 215 - UPDATE notification_queue 215 + UPDATE comms_queue 216 216 SET 217 217 status = CASE 218 - WHEN attempts + 1 >= max_attempts THEN 'failed'::notification_status 219 - ELSE 'pending'::notification_status 218 + WHEN attempts + 1 >= max_attempts THEN 'failed'::comms_status 219 + ELSE 'pending'::comms_status 220 220 END, 221 221 attempts = attempts + 1, 222 222 last_error = $2, ··· 233 233 } 234 234 } 235 235 236 - pub async fn enqueue_notification( 237 - db: &PgPool, 238 - notification: NewNotification, 239 - ) -> Result<Uuid, sqlx::Error> { 236 + pub async fn enqueue_comms(db: &PgPool, item: NewComms) -> Result<Uuid, sqlx::Error> { 240 237 sqlx::query_scalar!( 241 238 r#" 242 - INSERT INTO notification_queue 243 - (user_id, channel, notification_type, recipient, subject, body, metadata) 239 + INSERT INTO comms_queue 240 + (user_id, channel, comms_type, recipient, subject, body, metadata) 244 241 VALUES ($1, $2, $3, $4, $5, $6, $7) 245 242 RETURNING id 246 243 "#, 247 - notification.user_id, 248 - notification.channel as NotificationChannel, 249 - notification.notification_type as super::types::NotificationType, 250 - notification.recipient, 251 - notification.subject, 252 - notification.body, 253 - notification.metadata 244 + item.user_id, 245 + item.channel as CommsChannel, 246 + item.comms_type as super::types::CommsType, 247 + item.recipient, 248 + item.subject, 249 + item.body, 250 + item.metadata 254 251 ) 255 252 .fetch_one(db) 256 253 .await 257 254 } 258 255 259 - pub struct UserNotificationPrefs { 260 - pub channel: NotificationChannel, 256 + pub struct UserCommsPrefs { 257 + pub channel: CommsChannel, 261 258 pub email: Option<String>, 262 259 pub handle: String, 263 260 } 264 261 265 - pub async fn get_user_notification_prefs( 262 + pub async fn get_user_comms_prefs( 266 263 db: &PgPool, 267 264 user_id: Uuid, 268 - ) -> Result<UserNotificationPrefs, sqlx::Error> { 265 + ) -> Result<UserCommsPrefs, sqlx::Error> { 269 266 let row = sqlx::query!( 270 267 r#" 271 268 SELECT 272 269 email, 273 270 handle, 274 - preferred_notification_channel as "channel: NotificationChannel" 271 + preferred_comms_channel as "channel: CommsChannel" 275 272 FROM users 276 273 WHERE id = $1 277 274 "#, ··· 279 276 ) 280 277 .fetch_one(db) 281 278 .await?; 282 - Ok(UserNotificationPrefs { 279 + Ok(UserCommsPrefs { 283 280 channel: row.channel, 284 281 email: row.email, 285 282 handle: row.handle, ··· 291 288 user_id: Uuid, 292 289 hostname: &str, 293 290 ) -> Result<Uuid, sqlx::Error> { 294 - let prefs = get_user_notification_prefs(db, user_id).await?; 291 + let prefs = get_user_comms_prefs(db, user_id).await?; 295 292 let body = format!( 296 293 "Welcome to {}!\n\nYour handle is: @{}\n\nThank you for joining us.", 297 294 hostname, prefs.handle 298 295 ); 299 - enqueue_notification( 296 + enqueue_comms( 300 297 db, 301 - NewNotification::new( 298 + NewComms::new( 302 299 user_id, 303 300 prefs.channel, 304 - super::types::NotificationType::Welcome, 301 + super::types::CommsType::Welcome, 305 302 prefs.email.clone().unwrap_or_default(), 306 303 Some(format!("Welcome to {}", hostname)), 307 304 body, ··· 322 319 "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.", 323 320 handle, code 324 321 ); 325 - enqueue_notification( 322 + enqueue_comms( 326 323 db, 327 - NewNotification::email( 324 + NewComms::email( 328 325 user_id, 329 - super::types::NotificationType::EmailVerification, 326 + super::types::CommsType::EmailVerification, 330 327 email.to_string(), 331 328 format!("Verify your email - {}", hostname), 332 329 body, ··· 341 338 code: &str, 342 339 hostname: &str, 343 340 ) -> Result<Uuid, sqlx::Error> { 344 - let prefs = get_user_notification_prefs(db, user_id).await?; 341 + let prefs = get_user_comms_prefs(db, user_id).await?; 345 342 let body = format!( 346 343 "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.", 347 344 prefs.handle, code 348 345 ); 349 - enqueue_notification( 346 + enqueue_comms( 350 347 db, 351 - NewNotification::new( 348 + NewComms::new( 352 349 user_id, 353 350 prefs.channel, 354 - super::types::NotificationType::PasswordReset, 351 + super::types::CommsType::PasswordReset, 355 352 prefs.email.clone().unwrap_or_default(), 356 353 Some(format!("Password Reset - {}", hostname)), 357 354 body, ··· 372 369 "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.", 373 370 handle, code 374 371 ); 375 - enqueue_notification( 372 + enqueue_comms( 376 373 db, 377 - NewNotification::email( 374 + NewComms::email( 378 375 user_id, 379 - super::types::NotificationType::EmailUpdate, 376 + super::types::CommsType::EmailUpdate, 380 377 new_email.to_string(), 381 378 format!("Confirm your new email - {}", hostname), 382 379 body, ··· 391 388 code: &str, 392 389 hostname: &str, 393 390 ) -> Result<Uuid, sqlx::Error> { 394 - let prefs = get_user_notification_prefs(db, user_id).await?; 391 + let prefs = get_user_comms_prefs(db, user_id).await?; 395 392 let body = format!( 396 393 "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.", 397 394 prefs.handle, code 398 395 ); 399 - enqueue_notification( 396 + enqueue_comms( 400 397 db, 401 - NewNotification::new( 398 + NewComms::new( 402 399 user_id, 403 400 prefs.channel, 404 - super::types::NotificationType::AccountDeletion, 401 + super::types::CommsType::AccountDeletion, 405 402 prefs.email.clone().unwrap_or_default(), 406 403 Some(format!("Account Deletion Request - {}", hostname)), 407 404 body, ··· 416 413 token: &str, 417 414 hostname: &str, 418 415 ) -> Result<Uuid, sqlx::Error> { 419 - let prefs = get_user_notification_prefs(db, user_id).await?; 416 + let prefs = get_user_comms_prefs(db, user_id).await?; 420 417 let body = format!( 421 418 "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.", 422 419 prefs.handle, token 423 420 ); 424 - enqueue_notification( 421 + enqueue_comms( 425 422 db, 426 - NewNotification::new( 423 + NewComms::new( 427 424 user_id, 428 425 prefs.channel, 429 - super::types::NotificationType::PlcOperation, 426 + super::types::CommsType::PlcOperation, 430 427 prefs.email.clone().unwrap_or_default(), 431 428 Some(format!("{} - PLC Operation Token", hostname)), 432 429 body, ··· 441 438 code: &str, 442 439 hostname: &str, 443 440 ) -> Result<Uuid, sqlx::Error> { 444 - let prefs = get_user_notification_prefs(db, user_id).await?; 441 + let prefs = get_user_comms_prefs(db, user_id).await?; 445 442 let body = format!( 446 443 "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.", 447 444 prefs.handle, code 448 445 ); 449 - enqueue_notification( 446 + enqueue_comms( 450 447 db, 451 - NewNotification::new( 448 + NewComms::new( 452 449 user_id, 453 450 prefs.channel, 454 - super::types::NotificationType::TwoFactorCode, 451 + super::types::CommsType::TwoFactorCode, 455 452 prefs.email.clone().unwrap_or_default(), 456 453 Some(format!("Sign-in Verification - {}", hostname)), 457 454 body, ··· 460 457 .await 461 458 } 462 459 463 - pub fn channel_display_name(channel: NotificationChannel) -> &'static str { 460 + pub fn channel_display_name(channel: CommsChannel) -> &'static str { 464 461 match channel { 465 - NotificationChannel::Email => "email", 466 - NotificationChannel::Discord => "Discord", 467 - NotificationChannel::Telegram => "Telegram", 468 - NotificationChannel::Signal => "Signal", 462 + CommsChannel::Email => "email", 463 + CommsChannel::Discord => "Discord", 464 + CommsChannel::Telegram => "Telegram", 465 + CommsChannel::Signal => "Signal", 469 466 } 470 467 } 471 468 ··· 477 474 code: &str, 478 475 ) -> Result<Uuid, sqlx::Error> { 479 476 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 480 - let notification_channel = match channel { 481 - "email" => NotificationChannel::Email, 482 - "discord" => NotificationChannel::Discord, 483 - "telegram" => NotificationChannel::Telegram, 484 - "signal" => NotificationChannel::Signal, 485 - _ => NotificationChannel::Email, 477 + let comms_channel = match channel { 478 + "email" => CommsChannel::Email, 479 + "discord" => CommsChannel::Discord, 480 + "telegram" => CommsChannel::Telegram, 481 + "signal" => CommsChannel::Signal, 482 + _ => CommsChannel::Email, 486 483 }; 487 484 let body = format!( 488 485 "Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.", 489 486 code, hostname 490 487 ); 491 - let subject = match notification_channel { 492 - NotificationChannel::Email => Some(format!("Verify your account - {}", hostname)), 488 + let subject = match comms_channel { 489 + CommsChannel::Email => Some(format!("Verify your account - {}", hostname)), 493 490 _ => None, 494 491 }; 495 - enqueue_notification( 492 + enqueue_comms( 496 493 db, 497 - NewNotification::new( 494 + NewComms::new( 498 495 user_id, 499 - notification_channel, 500 - super::types::NotificationType::EmailVerification, 496 + comms_channel, 497 + super::types::CommsType::EmailVerification, 501 498 recipient.to_string(), 502 499 subject, 503 500 body,
+20 -20
src/notifications/types.rs src/comms/types.rs
··· 4 4 use uuid::Uuid; 5 5 6 6 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)] 7 - #[sqlx(type_name = "notification_channel", rename_all = "lowercase")] 8 - pub enum NotificationChannel { 7 + #[sqlx(type_name = "comms_channel", rename_all = "lowercase")] 8 + pub enum CommsChannel { 9 9 Email, 10 10 Discord, 11 11 Telegram, ··· 13 13 } 14 14 15 15 #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] 16 - #[sqlx(type_name = "notification_status", rename_all = "lowercase")] 17 - pub enum NotificationStatus { 16 + #[sqlx(type_name = "comms_status", rename_all = "lowercase")] 17 + pub enum CommsStatus { 18 18 Pending, 19 19 Processing, 20 20 Sent, ··· 22 22 } 23 23 24 24 #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)] 25 - #[sqlx(type_name = "notification_type", rename_all = "snake_case")] 26 - pub enum NotificationType { 25 + #[sqlx(type_name = "comms_type", rename_all = "snake_case")] 26 + pub enum CommsType { 27 27 Welcome, 28 28 EmailVerification, 29 29 PasswordReset, ··· 35 35 } 36 36 37 37 #[derive(Debug, Clone, FromRow)] 38 - pub struct QueuedNotification { 38 + pub struct QueuedComms { 39 39 pub id: Uuid, 40 40 pub user_id: Uuid, 41 - pub channel: NotificationChannel, 42 - pub notification_type: NotificationType, 43 - pub status: NotificationStatus, 41 + pub channel: CommsChannel, 42 + pub comms_type: CommsType, 43 + pub status: CommsStatus, 44 44 pub recipient: String, 45 45 pub subject: Option<String>, 46 46 pub body: String, ··· 54 54 pub processed_at: Option<DateTime<Utc>>, 55 55 } 56 56 57 - pub struct NewNotification { 57 + pub struct NewComms { 58 58 pub user_id: Uuid, 59 - pub channel: NotificationChannel, 60 - pub notification_type: NotificationType, 59 + pub channel: CommsChannel, 60 + pub comms_type: CommsType, 61 61 pub recipient: String, 62 62 pub subject: Option<String>, 63 63 pub body: String, 64 64 pub metadata: Option<serde_json::Value>, 65 65 } 66 66 67 - impl NewNotification { 67 + impl NewComms { 68 68 pub fn new( 69 69 user_id: Uuid, 70 - channel: NotificationChannel, 71 - notification_type: NotificationType, 70 + channel: CommsChannel, 71 + comms_type: CommsType, 72 72 recipient: String, 73 73 subject: Option<String>, 74 74 body: String, ··· 76 76 Self { 77 77 user_id, 78 78 channel, 79 - notification_type, 79 + comms_type, 80 80 recipient, 81 81 subject, 82 82 body, ··· 86 86 87 87 pub fn email( 88 88 user_id: Uuid, 89 - notification_type: NotificationType, 89 + comms_type: CommsType, 90 90 recipient: String, 91 91 subject: String, 92 92 body: String, 93 93 ) -> Self { 94 94 Self::new( 95 95 user_id, 96 - NotificationChannel::Email, 97 - notification_type, 96 + CommsChannel::Email, 97 + comms_type, 98 98 recipient, 99 99 Some(subject), 100 100 body,
+27 -7
src/oauth/endpoints/authorize.rs
··· 1 - use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code}; 1 + use crate::comms::{CommsChannel, channel_display_name, enqueue_2fa_code}; 2 2 use crate::oauth::{ 3 3 Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, templates, 4 4 }; ··· 406 406 let user = match sqlx::query!( 407 407 r#" 408 408 SELECT id, did, email, password_hash, two_factor_enabled, 409 - preferred_notification_channel as "preferred_notification_channel: NotificationChannel", 410 - deactivated_at, takedown_ref 409 + preferred_comms_channel as "preferred_comms_channel: CommsChannel", 410 + deactivated_at, takedown_ref, 411 + email_verified, discord_verified, telegram_verified, signal_verified 411 412 FROM users 412 413 WHERE handle = $1 OR email = $1 413 414 "#, ··· 429 430 if user.takedown_ref.is_some() { 430 431 return show_login_error("This account has been taken down.", json_response); 431 432 } 433 + let is_verified = user.email_verified 434 + || user.discord_verified 435 + || user.telegram_verified 436 + || user.signal_verified; 437 + if !is_verified { 438 + return show_login_error("Please verify your account before logging in.", json_response); 439 + } 432 440 let password_valid = match bcrypt::verify(&form.password, &user.password_hash) { 433 441 Ok(valid) => valid, 434 442 Err(_) => return show_login_error("An error occurred. Please try again.", json_response), ··· 451 459 "Failed to enqueue 2FA notification" 452 460 ); 453 461 } 454 - let channel_name = channel_display_name(user.preferred_notification_channel); 462 + let channel_name = channel_display_name(user.preferred_comms_channel); 455 463 let redirect_url = format!( 456 464 "/oauth/authorize/2fa?request_uri={}&channel={}", 457 465 url_encode(&form.request_uri), ··· 577 585 let user = match sqlx::query!( 578 586 r#" 579 587 SELECT id, two_factor_enabled, 580 - preferred_notification_channel as "preferred_notification_channel: NotificationChannel" 588 + preferred_comms_channel as "preferred_comms_channel: CommsChannel", 589 + email_verified, discord_verified, telegram_verified, signal_verified 581 590 FROM users 582 591 WHERE did = $1 583 592 "#, ··· 600 609 )).into_response(); 601 610 } 602 611 }; 612 + let is_verified = user.email_verified 613 + || user.discord_verified 614 + || user.telegram_verified 615 + || user.signal_verified; 616 + if !is_verified { 617 + return Html(templates::error_page( 618 + "access_denied", 619 + Some("Please verify your account before logging in."), 620 + )) 621 + .into_response(); 622 + } 603 623 if user.two_factor_enabled { 604 624 let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; 605 625 match db::create_2fa_challenge(&state.db, &form.did, &form.request_uri).await { ··· 615 635 "Failed to enqueue 2FA notification" 616 636 ); 617 637 } 618 - let channel_name = channel_display_name(user.preferred_notification_channel); 638 + let channel_name = channel_display_name(user.preferred_comms_channel); 619 639 let redirect_url = format!( 620 640 "/oauth/authorize/2fa?request_uri={}&channel={}", 621 641 url_encode(&form.request_uri), ··· 836 856 if !code_valid { 837 857 let _ = db::increment_2fa_attempts(&state.db, challenge.id).await; 838 858 let channel = match sqlx::query_scalar!( 839 - r#"SELECT preferred_notification_channel as "channel: NotificationChannel" FROM users WHERE did = $1"#, 859 + r#"SELECT preferred_comms_channel as "channel: CommsChannel" FROM users WHERE did = $1"#, 840 860 challenge.did 841 861 ) 842 862 .fetch_optional(&state.db)
+1 -1
src/oauth/templates.rs
··· 369 369 </div> 370 370 <div class="buttons"> 371 371 <button type="submit" class="btn btn-primary">Sign In</button> 372 - <button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button> 372 + <button type="submit" formaction="/oauth/authorize/deny" formnovalidate class="btn btn-secondary">Cancel</button> 373 373 </div> 374 374 </form> 375 375 <p class="help-text">
+4 -4
tests/account_notifications.rs
··· 1 1 mod common; 2 2 use common::{base_url, client, create_account_and_login, get_db_connection_string}; 3 - use bspds::notifications::{NewNotification, NotificationType, enqueue_notification}; 3 + use bspds::comms::{NewComms, CommsType, enqueue_comms}; 4 4 use serde_json::{Value, json}; 5 5 use sqlx::PgPool; 6 6 ··· 26 26 .expect("User not found"); 27 27 28 28 for i in 0..3 { 29 - let notification = NewNotification::email( 29 + let comms = NewComms::email( 30 30 user_id, 31 - NotificationType::Welcome, 31 + CommsType::Welcome, 32 32 "test@example.com".to_string(), 33 33 format!("Subject {}", i), 34 34 format!("Body {}", i), 35 35 ); 36 - enqueue_notification(&pool, notification).await.expect("Failed to enqueue"); 36 + enqueue_comms(&pool, comms).await.expect("Failed to enqueue"); 37 37 } 38 38 39 39 let resp = client
+2 -2
tests/admin_email.rs
··· 39 39 .await 40 40 .expect("User not found"); 41 41 let notification = sqlx::query!( 42 - "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", 42 + "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", 43 43 user.id 44 44 ) 45 45 .fetch_one(&pool) ··· 78 78 .await 79 79 .expect("User not found"); 80 80 let notification = sqlx::query!( 81 - "SELECT subject FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", 81 + "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", 82 82 user.id 83 83 ) 84 84 .fetch_one(&pool)
+29 -30
tests/notifications.rs
··· 1 1 mod common; 2 - use bspds::notifications::{ 3 - NewNotification, NotificationChannel, NotificationStatus, NotificationType, 4 - enqueue_notification, enqueue_welcome, 2 + use bspds::comms::{ 3 + CommsChannel, CommsStatus, CommsType, NewComms, enqueue_comms, enqueue_welcome, 5 4 }; 6 5 use sqlx::PgPool; 7 6 ··· 15 14 } 16 15 17 16 #[tokio::test] 18 - async fn test_enqueue_notification() { 17 + async fn test_enqueue_comms() { 19 18 let pool = get_pool().await; 20 19 let (_, did) = common::create_account_and_login(&common::client()).await; 21 20 let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 22 21 .fetch_one(&pool) 23 22 .await 24 23 .expect("User not found"); 25 - let notification = NewNotification::email( 24 + let item = NewComms::email( 26 25 user_id, 27 - NotificationType::Welcome, 26 + CommsType::Welcome, 28 27 "test@example.com".to_string(), 29 28 "Test Subject".to_string(), 30 29 "Test body".to_string(), 31 30 ); 32 - let notification_id = enqueue_notification(&pool, notification) 31 + let comms_id = enqueue_comms(&pool, item) 33 32 .await 34 - .expect("Failed to enqueue notification"); 33 + .expect("Failed to enqueue comms"); 35 34 let row = sqlx::query!( 36 35 r#" 37 36 SELECT 38 37 id, user_id, recipient, subject, body, 39 - channel as "channel: NotificationChannel", 40 - notification_type as "notification_type: NotificationType", 41 - status as "status: NotificationStatus" 42 - FROM notification_queue 38 + channel as "channel: CommsChannel", 39 + comms_type as "comms_type: CommsType", 40 + status as "status: CommsStatus" 41 + FROM comms_queue 43 42 WHERE id = $1 44 43 "#, 45 - notification_id 44 + comms_id 46 45 ) 47 46 .fetch_one(&pool) 48 47 .await 49 - .expect("Notification not found"); 48 + .expect("Comms not found"); 50 49 assert_eq!(row.user_id, user_id); 51 50 assert_eq!(row.recipient, "test@example.com"); 52 51 assert_eq!(row.subject.as_deref(), Some("Test Subject")); 53 52 assert_eq!(row.body, "Test body"); 54 - assert_eq!(row.channel, NotificationChannel::Email); 55 - assert_eq!(row.notification_type, NotificationType::Welcome); 56 - assert_eq!(row.status, NotificationStatus::Pending); 53 + assert_eq!(row.channel, CommsChannel::Email); 54 + assert_eq!(row.comms_type, CommsType::Welcome); 55 + assert_eq!(row.status, CommsStatus::Pending); 57 56 } 58 57 59 58 #[tokio::test] ··· 64 63 .fetch_one(&pool) 65 64 .await 66 65 .expect("User not found"); 67 - let notification_id = enqueue_welcome(&pool, user_row.id, "example.com") 66 + let comms_id = enqueue_welcome(&pool, user_row.id, "example.com") 68 67 .await 69 - .expect("Failed to enqueue welcome notification"); 68 + .expect("Failed to enqueue welcome comms"); 70 69 let row = sqlx::query!( 71 70 r#" 72 71 SELECT 73 72 recipient, subject, body, 74 - notification_type as "notification_type: NotificationType" 75 - FROM notification_queue 73 + comms_type as "comms_type: CommsType" 74 + FROM comms_queue 76 75 WHERE id = $1 77 76 "#, 78 - notification_id 77 + comms_id 79 78 ) 80 79 .fetch_one(&pool) 81 80 .await 82 - .expect("Notification not found"); 81 + .expect("Comms not found"); 83 82 assert_eq!(Some(row.recipient), user_row.email); 84 83 assert_eq!(row.subject.as_deref(), Some("Welcome to example.com")); 85 84 assert!(row.body.contains(&format!("@{}", user_row.handle))); 86 - assert_eq!(row.notification_type, NotificationType::Welcome); 85 + assert_eq!(row.comms_type, CommsType::Welcome); 87 86 } 88 87 89 88 #[tokio::test] 90 - async fn test_notification_queue_status_index() { 89 + async fn test_comms_queue_status_index() { 91 90 let pool = get_pool().await; 92 91 let (_, did) = common::create_account_and_login(&common::client()).await; 93 92 let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) ··· 95 94 .await 96 95 .expect("User not found"); 97 96 let initial_count: i64 = sqlx::query_scalar!( 98 - "SELECT COUNT(*) FROM notification_queue WHERE status = 'pending' AND user_id = $1", 97 + "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1", 99 98 user_id 100 99 ) 101 100 .fetch_one(&pool) ··· 103 102 .expect("Failed to count") 104 103 .unwrap_or(0); 105 104 for i in 0..5 { 106 - let notification = NewNotification::email( 105 + let item = NewComms::email( 107 106 user_id, 108 - NotificationType::PasswordReset, 107 + CommsType::PasswordReset, 109 108 format!("test{}@example.com", i), 110 109 "Test".to_string(), 111 110 "Body".to_string(), 112 111 ); 113 - enqueue_notification(&pool, notification) 112 + enqueue_comms(&pool, item) 114 113 .await 115 114 .expect("Failed to enqueue"); 116 115 } 117 116 let final_count: i64 = sqlx::query_scalar!( 118 - "SELECT COUNT(*) FROM notification_queue WHERE status = 'pending' AND user_id = $1", 117 + "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1", 119 118 user_id 120 119 ) 121 120 .fetch_one(&pool)
+9 -2
tests/oauth.rs
··· 2 2 mod helpers; 3 3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 4 4 use chrono::Utc; 5 - use common::{base_url, client, create_account_and_login, get_db_connection_string}; 5 + use common::{base_url, client, get_db_connection_string}; 6 + use helpers::verify_new_account; 6 7 use reqwest::{StatusCode, redirect}; 7 8 use serde_json::{Value, json}; 8 9 use sha2::{Digest, Sha256}; ··· 124 125 assert_eq!(create_res.status(), StatusCode::OK); 125 126 let account: Value = create_res.json().await.unwrap(); 126 127 let user_did = account["did"].as_str().unwrap(); 128 + verify_new_account(&http_client, user_did).await; 127 129 let redirect_uri = "https://example.com/oauth/callback"; 128 130 let mock_client = setup_mock_client_metadata(redirect_uri).await; 129 131 let client_id = mock_client.uri(); ··· 261 263 assert_eq!(create_res.status(), StatusCode::OK); 262 264 let account: Value = create_res.json().await.unwrap(); 263 265 let user_did = account["did"].as_str().unwrap(); 266 + verify_new_account(&http_client, user_did).await; 264 267 let db_url = get_db_connection_string().await; 265 268 let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap(); 266 269 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") ··· 324 327 .send().await.unwrap(); 325 328 let account: Value = create_res.json().await.unwrap(); 326 329 let user_did = account["did"].as_str().unwrap(); 330 + verify_new_account(&http_client, user_did).await; 327 331 let db_url = get_db_connection_string().await; 328 332 let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap(); 329 333 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") ··· 375 379 .send().await.unwrap(); 376 380 let account: Value = create_res.json().await.unwrap(); 377 381 let user_did = account["did"].as_str().unwrap().to_string(); 382 + verify_new_account(&http_client, &user_did).await; 378 383 let redirect_uri = "https://example.com/selector-2fa-callback"; 379 384 let mock_client = setup_mock_client_metadata(redirect_uri).await; 380 385 let client_id = mock_client.uri(); ··· 451 456 let handle = format!("state-special-{}", ts); 452 457 let email = format!("state-special-{}@example.com", ts); 453 458 let password = "state-special-password"; 454 - http_client 459 + let create_res = http_client 455 460 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 456 461 .json(&json!({ "handle": handle, "email": email, "password": password })) 457 462 .send().await.unwrap(); 463 + let account: Value = create_res.json().await.unwrap(); 464 + verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 458 465 let redirect_uri = "https://example.com/state-special-callback"; 459 466 let mock_client = setup_mock_client_metadata(redirect_uri).await; 460 467 let client_id = mock_client.uri();
+13 -4
tests/oauth_security.rs
··· 45 45 async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) { 46 46 let ts = Utc::now().timestamp_millis(); 47 47 let handle = format!("sec-test-{}", ts); 48 - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 48 + let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 49 49 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" })) 50 50 .send().await.unwrap(); 51 + let account: Value = create_res.json().await.unwrap(); 52 + let did = account["did"].as_str().unwrap(); 53 + verify_new_account(http_client, did).await; 51 54 let redirect_uri = "https://example.com/sec-callback"; 52 55 let mock_client = setup_mock_client_metadata(redirect_uri).await; 53 56 let client_id = mock_client.uri(); ··· 129 132 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected"); 130 133 let ts = Utc::now().timestamp_millis(); 131 134 let handle = format!("pkce-attack-{}", ts); 132 - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 135 + let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 133 136 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" })) 134 137 .send().await.unwrap(); 138 + let account: Value = create_res.json().await.unwrap(); 139 + verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 135 140 let (_, code_challenge) = generate_pkce(); 136 141 let (attacker_verifier, _) = generate_pkce(); 137 142 let par_body: Value = http_client.post(format!("{}/oauth/par", url)) ··· 158 163 let http_client = client(); 159 164 let ts = Utc::now().timestamp_millis(); 160 165 let handle = format!("replay-{}", ts); 161 - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 166 + let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 162 167 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" })) 163 168 .send().await.unwrap(); 169 + let account: Value = create_res.json().await.unwrap(); 170 + verify_new_account(&http_client, account["did"].as_str().unwrap()).await; 164 171 let redirect_uri = "https://example.com/replay-callback"; 165 172 let mock_client = setup_mock_client_metadata(redirect_uri).await; 166 173 let client_id = mock_client.uri(); ··· 243 250 let client_id_b = mock_b.uri(); 244 251 let ts2 = Utc::now().timestamp_millis(); 245 252 let handle2 = format!("cross-{}", ts2); 246 - http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 253 + let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 247 254 .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" })) 248 255 .send().await.unwrap(); 256 + let account2: Value = create_res2.json().await.unwrap(); 257 + verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; 249 258 let (code_verifier2, code_challenge2) = generate_pkce(); 250 259 let par_a: Value = http_client.post(format!("{}/oauth/par", url)) 251 260 .form(&[("response_type", "code"), ("client_id", &client_id_a), ("redirect_uri", redirect_uri_a),
+2 -2
tests/password_reset.rs
··· 373 373 .await 374 374 .expect("User not found"); 375 375 let initial_count: i64 = sqlx::query_scalar!( 376 - "SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'", 376 + "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", 377 377 user.id 378 378 ) 379 379 .fetch_one(&pool) ··· 391 391 .expect("Failed to request password reset"); 392 392 assert_eq!(res.status(), StatusCode::OK); 393 393 let final_count: i64 = sqlx::query_scalar!( 394 - "SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'", 394 + "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", 395 395 user.id 396 396 ) 397 397 .fetch_one(&pool)
+1 -1
tests/security_fixes.rs
··· 1 1 mod common; 2 2 use bspds::image::{ImageError, ImageProcessor}; 3 - use bspds::notifications::{SendError, is_valid_phone_number, sanitize_header_value}; 3 + use bspds::comms::{SendError, is_valid_phone_number, sanitize_header_value}; 4 4 use bspds::oauth::templates::{error_page, login_page, success_page}; 5 5 6 6 #[test]