Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+1443 -760
+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]