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

Configure Feed

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

More work on the pds notifs

+1858 -439
+20
.sqlx/query-084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) FROM records", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [] 14 + }, 15 + "nullable": [ 16 + null 17 + ] 18 + }, 19 + "hash": "084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076" 20 + }
+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 + }
+14
.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8" 14 + }
+28
.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "expires_at", 14 + "type_info": "Timestamptz" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32" 28 + }
+4 -16
.sqlx/query-257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b.json .sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.email_confirmation_code,\n u.email_confirmation_code_expires_at,\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_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", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 - "name": "email_confirmation_code", 29 - "type_info": "Text" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "email_confirmation_code_expires_at", 34 - "type_info": "Timestamptz" 35 - }, 36 - { 37 - "ordinal": 6, 38 28 "name": "channel: crate::notifications::NotificationChannel", 39 29 "type_info": { 40 30 "Custom": { ··· 51 41 } 52 42 }, 53 43 { 54 - "ordinal": 7, 44 + "ordinal": 5, 55 45 "name": "key_bytes", 56 46 "type_info": "Bytea" 57 47 }, 58 48 { 59 - "ordinal": 8, 49 + "ordinal": 6, 60 50 "name": "encryption_version", 61 51 "type_info": "Int4" 62 52 } ··· 71 61 false, 72 62 false, 73 63 true, 74 - true, 75 - true, 76 64 false, 77 65 false, 78 66 true 79 67 ] 80 68 }, 81 - "hash": "257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b" 69 + "hash": "dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3" 82 70 }
+2 -1
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json
··· 37 37 "account_deletion", 38 38 "admin_email", 39 39 "plc_operation", 40 - "two_factor_code" 40 + "two_factor_code", 41 + "channel_verification" 41 42 ] 42 43 } 43 44 }
+15
.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5" 15 + }
+17
.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, 'email', $2, $3, $4)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Text", 11 + "Timestamptz" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20" 17 + }
-17
.sqlx/query-4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - "Timestamptz", 11 - "Uuid" 12 - ] 13 - }, 14 - "nullable": [] 15 - }, 16 - "hash": "4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190" 17 - }
+47
.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json
··· 1 + { 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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "pending_identifier", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "expires_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Uuid", 25 + { 26 + "Custom": { 27 + "name": "notification_channel", 28 + "kind": { 29 + "Enum": [ 30 + "email", 31 + "discord", 32 + "telegram", 33 + "signal" 34 + ] 35 + } 36 + } 37 + } 38 + ] 39 + }, 40 + "nullable": [ 41 + false, 42 + true, 43 + false 44 + ] 45 + }, 46 + "hash": "4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26" 47 + }
+93
.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json
··· 1 + { 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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "created_at", 9 + "type_info": "Timestamptz" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "channel: String", 14 + "type_info": { 15 + "Custom": { 16 + "name": "notification_channel", 17 + "kind": { 18 + "Enum": [ 19 + "email", 20 + "discord", 21 + "telegram", 22 + "signal" 23 + ] 24 + } 25 + } 26 + } 27 + }, 28 + { 29 + "ordinal": 2, 30 + "name": "notification_type: String", 31 + "type_info": { 32 + "Custom": { 33 + "name": "notification_type", 34 + "kind": { 35 + "Enum": [ 36 + "welcome", 37 + "email_verification", 38 + "password_reset", 39 + "email_update", 40 + "account_deletion", 41 + "admin_email", 42 + "plc_operation", 43 + "two_factor_code", 44 + "channel_verification" 45 + ] 46 + } 47 + } 48 + } 49 + }, 50 + { 51 + "ordinal": 3, 52 + "name": "status: String", 53 + "type_info": { 54 + "Custom": { 55 + "name": "notification_status", 56 + "kind": { 57 + "Enum": [ 58 + "pending", 59 + "processing", 60 + "sent", 61 + "failed" 62 + ] 63 + } 64 + } 65 + } 66 + }, 67 + { 68 + "ordinal": 4, 69 + "name": "subject", 70 + "type_info": "Text" 71 + }, 72 + { 73 + "ordinal": 5, 74 + "name": "body", 75 + "type_info": "Text" 76 + } 77 + ], 78 + "parameters": { 79 + "Left": [ 80 + "Uuid" 81 + ] 82 + }, 83 + "nullable": [ 84 + false, 85 + false, 86 + false, 87 + false, 88 + true, 89 + false 90 + ] 91 + }, 92 + "hash": "4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a" 93 + }
+2 -1
.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json
··· 37 37 "account_deletion", 38 38 "admin_email", 39 39 "plc_operation", 40 - "two_factor_code" 40 + "two_factor_code", 41 + "channel_verification" 41 42 ] 42 43 } 43 44 }
+34
.sqlx/query-62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, handle, email FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + true 31 + ] 32 + }, 33 + "hash": "62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e" 34 + }
+15
.sqlx/query-6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08" 15 + }
-15
.sqlx/query-76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Uuid" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057" 15 - }
+14
.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1" 14 + }
-22
.sqlx/query-8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT 1 as one FROM users WHERE LOWER(email) = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "one", 9 - "type_info": "Int4" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19" 22 - }
+27
.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel", 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 + ] 23 + }, 24 + "nullable": [] 25 + }, 26 + "hash": "90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849" 27 + }
+20
.sqlx/query-91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "coalesce", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [] 14 + }, 15 + "nullable": [ 16 + null 17 + ] 18 + }, 19 + "hash": "91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e" 20 + }
+20
.sqlx/query-96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) FROM repos", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [] 14 + }, 15 + "nullable": [ 16 + null 17 + ] 18 + }, 19 + "hash": "96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4" 20 + }
+17
.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Text", 11 + "Timestamptz" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5" 17 + }
+14
.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161" 14 + }
+30
.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json
··· 1 + { 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 ", 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 + "Timestamptz" 25 + ] 26 + }, 27 + "nullable": [] 28 + }, 29 + "hash": "9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546" 30 + }
+34
.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "pending_identifier", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "expires_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Uuid" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + true, 30 + false 31 + ] 32 + }, 33 + "hash": "a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3" 34 + }
+15
.sqlx/query-a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb" 15 + }
-16
.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Timestamptz", 10 - "Text" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1" 16 - }
-46
.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "email", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "email_confirmation_code", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "email_confirmation_code_expires_at", 24 - "type_info": "Timestamptz" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "email_pending_verification", 29 - "type_info": "Text" 30 - } 31 - ], 32 - "parameters": { 33 - "Left": [ 34 - "Text" 35 - ] 36 - }, 37 - "nullable": [ 38 - false, 39 - true, 40 - true, 41 - true, 42 - true 43 - ] 44 - }, 45 - "hash": "a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a" 46 - }
+14
.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2" 14 + }
-40
.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "email_confirmation_code", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "email_confirmation_code_expires_at", 19 - "type_info": "Timestamptz" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "email_pending_verification", 24 - "type_info": "Text" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Text" 30 - ] 31 - }, 32 - "nullable": [ 33 - false, 34 - true, 35 - true, 36 - true 37 - ] 38 - }, 39 - "hash": "b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5" 40 - }
+14
.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d" 14 + }
+2 -1
.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json
··· 45 45 "account_deletion", 46 46 "admin_email", 47 47 "plc_operation", 48 - "two_factor_code" 48 + "two_factor_code", 49 + "channel_verification" 49 50 ] 50 51 } 51 52 }
+28
.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, email FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca" 28 + }
+15
.sqlx/query-db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Jsonb" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c" 15 + }
+20
.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) FROM users", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [] 14 + }, 15 + "nullable": [ 16 + null 17 + ] 18 + }, 19 + "hash": "dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d" 20 + }
+14
.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37" 14 + }
+14
.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839" 14 + }
+15
.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3" 15 + }
+2 -2
.sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json .sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", 3 + "query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\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 { ··· 36 36 true 37 37 ] 38 38 }, 39 - "hash": "f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693" 39 + "hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761" 40 40 }
-15
.sqlx/query-fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n UPDATE users\n SET email = $1,\n email_pending_verification = NULL,\n email_confirmation_code = NULL,\n email_confirmation_code_expires_at = NULL,\n updated_at = NOW()\n WHERE id = $2\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Uuid" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580" 15 - }
+5 -5
TODO.md
··· 244 244 Anyway... endpoints for PDS settings not covered by standard ATProto: 245 245 - [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels 246 246 - [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel 247 - - [ ] `com.bspds.account.getNotificationHistory` - list past notifications 248 - - [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal 249 - - [ ] `com.bspds.account.confirmChannelVerification` - confirm with code 250 - - [ ] `com.bspds.admin.getServerStats` - user count, storage usage, etc. 247 + - [x] `com.bspds.account.getNotificationHistory` - list past notifications 248 + - [x] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal 249 + - [x] `com.bspds.account.confirmChannelVerification` - confirm with code 250 + - [x] `com.bspds.admin.getServerStats` - user count, storage usage, etc. 251 251 ### Frontend Views 252 252 Uses existing ATProto endpoints where possible: 253 253 Authentication ··· 262 262 - [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) 263 263 Notification Preferences 264 264 - [x] Channel selector (uses `com.bspds.account.*` endpoints above) 265 - - [ ] Verification flows for Discord/Telegram/Signal 265 + - [x] Verification flows for Discord/Telegram/Signal 266 266 - [ ] Notification history view 267 267 Account Settings 268 268 - [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
+20 -14
frontend/vite.config.ts
··· 1 - import { defineConfig } from 'vite' 1 + import { defineConfig, loadEnv } from 'vite' 2 2 import { svelte } from '@sveltejs/vite-plugin-svelte' 3 - export default defineConfig({ 4 - plugins: [svelte()], 5 - build: { 6 - outDir: 'dist', 7 - }, 8 - server: { 9 - port: 5173, 10 - proxy: { 11 - '/xrpc': 'http://localhost:3000', 12 - '/oauth': 'http://localhost:3000', 13 - '/.well-known': 'http://localhost:3000', 14 - '/health': 'http://localhost:3000', 15 - '/u': 'http://localhost:3000', 3 + 4 + export default defineConfig(({ mode }) => { 5 + const env = loadEnv(mode, process.cwd(), '') 6 + const target = env.VITE_API_URL || 'http://localhost:3000' 7 + 8 + return { 9 + plugins: [svelte()], 10 + build: { 11 + outDir: 'dist', 12 + }, 13 + server: { 14 + port: 5173, 15 + proxy: { 16 + '/xrpc': target, 17 + '/oauth': target, 18 + '/.well-known': target, 19 + '/health': target, 20 + '/u': target, 21 + } 16 22 } 17 23 } 18 24 })
+12
migrations/20251216_add_channel_verification.sql
··· 1 + ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'channel_verification'; 2 + 3 + CREATE TABLE IF NOT EXISTS channel_verifications ( 4 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 5 + channel notification_channel NOT NULL, 6 + code TEXT NOT NULL, 7 + expires_at TIMESTAMPTZ NOT NULL, 8 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 + PRIMARY KEY (user_id, channel) 10 + ); 11 + 12 + CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
+11
migrations/20251217_migrate_email_to_channel_verifications.sql
··· 1 + ALTER TABLE channel_verifications ADD COLUMN pending_identifier TEXT; 2 + 3 + INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 4 + SELECT id, 'email', email_confirmation_code, email_pending_verification, email_confirmation_code_expires_at 5 + FROM users 6 + WHERE email_confirmation_code IS NOT NULL AND email_confirmation_code_expires_at IS NOT NULL; 7 + 8 + ALTER TABLE users 9 + DROP COLUMN email_confirmation_code, 10 + DROP COLUMN email_confirmation_code_expires_at, 11 + DROP COLUMN email_pending_verification;
+2
src/api/admin/mod.rs
··· 1 1 pub mod account; 2 2 pub mod invite; 3 + pub mod server_stats; 3 4 pub mod status; 4 5 5 6 pub use account::{ ··· 9 10 pub use invite::{ 10 11 disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes, 11 12 }; 13 + pub use server_stats::get_server_stats; 12 14 pub use status::{get_subject_status, update_subject_status};
+76
src/api/admin/server_stats.rs
··· 1 + use crate::state::AppState; 2 + use axum::{ 3 + Json, 4 + extract::State, 5 + http::{HeaderMap, StatusCode}, 6 + response::{IntoResponse, Response}, 7 + }; 8 + use serde::Serialize; 9 + use serde_json::json; 10 + 11 + #[derive(Serialize)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct ServerStatsResponse { 14 + pub user_count: i64, 15 + pub repo_count: i64, 16 + pub record_count: i64, 17 + pub blob_storage_bytes: i64, 18 + } 19 + 20 + pub async fn get_server_stats( 21 + State(state): State<AppState>, 22 + headers: HeaderMap, 23 + ) -> Response { 24 + let auth_header = headers.get("Authorization"); 25 + if auth_header.is_none() { 26 + return ( 27 + StatusCode::UNAUTHORIZED, 28 + Json(json!({"error": "AuthenticationRequired"})), 29 + ) 30 + .into_response(); 31 + } 32 + 33 + let user_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM users") 34 + .fetch_one(&state.db) 35 + .await 36 + { 37 + Ok(Some(count)) => count, 38 + Ok(None) => 0, 39 + Err(_) => 0, 40 + }; 41 + 42 + let repo_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM repos") 43 + .fetch_one(&state.db) 44 + .await 45 + { 46 + Ok(Some(count)) => count, 47 + Ok(None) => 0, 48 + Err(_) => 0, 49 + }; 50 + 51 + let record_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM records") 52 + .fetch_one(&state.db) 53 + .await 54 + { 55 + Ok(Some(count)) => count, 56 + Ok(None) => 0, 57 + Err(_) => 0, 58 + }; 59 + 60 + let blob_storage_bytes: i64 = match sqlx::query_scalar!("SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs") 61 + .fetch_one(&state.db) 62 + .await 63 + { 64 + Ok(Some(bytes)) => bytes, 65 + Ok(None) => 0, 66 + Err(_) => 0, 67 + }; 68 + 69 + Json(ServerStatsResponse { 70 + user_count, 71 + repo_count, 72 + record_count, 73 + blob_storage_bytes, 74 + }) 75 + .into_response() 76 + }
+18 -4
src/api/identity/account.rs
··· 382 382 let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 383 383 r#"INSERT INTO users ( 384 384 handle, email, did, password_hash, 385 - email_confirmation_code, email_confirmation_code_expires_at, 386 385 preferred_notification_channel, 387 386 discord_id, telegram_username, signal_number 388 - ) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#, 387 + ) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8) RETURNING id"#, 389 388 ) 390 389 .bind(short_handle) 391 390 .bind(&email) 392 391 .bind(&did) 393 392 .bind(&password_hash) 394 - .bind(&verification_code) 395 - .bind(code_expires_at) 396 393 .bind(verification_channel) 397 394 .bind( 398 395 input ··· 460 457 .into_response(); 461 458 } 462 459 }; 460 + 461 + if let Err(e) = sqlx::query!( 462 + "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", 463 + user_id, 464 + verification_code, 465 + email, 466 + code_expires_at 467 + ) 468 + .execute(&mut *tx) 469 + .await { 470 + error!("Error inserting verification code: {:?}", e); 471 + return ( 472 + StatusCode::INTERNAL_SERVER_ERROR, 473 + Json(json!({"error": "InternalError"})), 474 + ) 475 + .into_response(); 476 + } 463 477 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 464 478 Ok(enc) => enc, 465 479 Err(e) => {
+1
src/api/mod.rs
··· 13 13 pub mod server; 14 14 pub mod temp; 15 15 pub mod validation; 16 + pub mod verification; 16 17 17 18 pub use error::ApiError; 18 19 pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
+336 -54
src/api/notification_prefs.rs
··· 6 6 http::{HeaderMap, StatusCode}, 7 7 response::{IntoResponse, Response}, 8 8 }; 9 + use chrono::{Duration, Utc}; 10 + use rand::Rng; 9 11 use serde::{Deserialize, Serialize}; 10 12 use serde_json::json; 11 13 use sqlx::Row; 12 14 use tracing::info; 15 + 16 + fn generate_verification_code() -> String { 17 + rand::thread_rng() 18 + .sample_iter(&rand::distributions::Uniform::new(0, 10)) 19 + .take(6) 20 + .map(|x| x.to_string()) 21 + .collect() 22 + } 13 23 14 24 #[derive(Serialize)] 15 25 #[serde(rename_all = "camelCase")] ··· 95 105 .into_response() 96 106 } 97 107 108 + #[derive(Serialize)] 109 + #[serde(rename_all = "camelCase")] 110 + pub struct NotificationHistoryEntry { 111 + pub created_at: String, 112 + pub channel: String, 113 + pub notification_type: String, 114 + pub status: String, 115 + pub subject: Option<String>, 116 + pub body: String, 117 + } 118 + 119 + #[derive(Serialize)] 120 + #[serde(rename_all = "camelCase")] 121 + pub struct GetNotificationHistoryResponse { 122 + pub notifications: Vec<NotificationHistoryEntry>, 123 + } 124 + 125 + pub async fn get_notification_history( 126 + State(state): State<AppState>, 127 + headers: HeaderMap, 128 + ) -> Response { 129 + let token = match crate::auth::extract_bearer_token_from_header( 130 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 131 + ) { 132 + Some(t) => t, 133 + None => return ( 134 + StatusCode::UNAUTHORIZED, 135 + Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), 136 + ) 137 + .into_response(), 138 + }; 139 + let user = match validate_bearer_token(&state.db, &token).await { 140 + Ok(u) => u, 141 + Err(_) => { 142 + return ( 143 + StatusCode::UNAUTHORIZED, 144 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), 145 + ) 146 + .into_response(); 147 + } 148 + }; 149 + 150 + let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did) 151 + .fetch_one(&state.db) 152 + .await 153 + { 154 + Ok(id) => id, 155 + Err(e) => return ( 156 + StatusCode::INTERNAL_SERVER_ERROR, 157 + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 158 + ) 159 + .into_response(), 160 + }; 161 + 162 + let rows = match sqlx::query!( 163 + r#" 164 + SELECT 165 + created_at, 166 + channel as "channel: String", 167 + notification_type as "notification_type: String", 168 + status as "status: String", 169 + subject, 170 + body 171 + FROM notification_queue 172 + WHERE user_id = $1 173 + ORDER BY created_at DESC 174 + LIMIT 50 175 + "#, 176 + user_id 177 + ) 178 + .fetch_all(&state.db) 179 + .await 180 + { 181 + Ok(r) => r, 182 + Err(e) => return ( 183 + StatusCode::INTERNAL_SERVER_ERROR, 184 + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 185 + ) 186 + .into_response(), 187 + }; 188 + 189 + let notifications = rows.iter().map(|row| { 190 + NotificationHistoryEntry { 191 + created_at: row.created_at.to_rfc3339(), 192 + channel: row.channel.clone(), 193 + notification_type: row.notification_type.clone(), 194 + status: row.status.clone(), 195 + subject: row.subject.clone(), 196 + body: row.body.clone(), 197 + } 198 + }).collect(); 199 + 200 + Json(GetNotificationHistoryResponse { notifications }).into_response() 201 + } 202 + 98 203 #[derive(Deserialize)] 99 204 #[serde(rename_all = "camelCase")] 100 205 pub struct UpdateNotificationPrefsInput { 101 206 pub preferred_channel: Option<String>, 207 + pub email: Option<String>, 102 208 pub discord_id: Option<String>, 103 209 pub telegram_username: Option<String>, 104 210 pub signal_number: Option<String>, 105 211 } 106 212 213 + #[derive(Serialize)] 214 + #[serde(rename_all = "camelCase")] 215 + pub struct UpdateNotificationPrefsResponse { 216 + pub success: bool, 217 + #[serde(skip_serializing_if = "Vec::is_empty")] 218 + pub verification_required: Vec<String>, 219 + } 220 + 221 + pub async fn request_channel_verification( 222 + db: &sqlx::PgPool, 223 + user_id: uuid::Uuid, 224 + channel: &str, 225 + identifier: &str, 226 + handle: Option<&str>, 227 + ) -> Result<String, String> { 228 + let code = generate_verification_code(); 229 + let expires_at = Utc::now() + Duration::minutes(10); 230 + 231 + sqlx::query!( 232 + r#" 233 + INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 234 + VALUES ($1, $2::notification_channel, $3, $4, $5) 235 + ON CONFLICT (user_id, channel) DO UPDATE 236 + SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW() 237 + "#, 238 + user_id, 239 + channel as _, 240 + code, 241 + identifier, 242 + expires_at 243 + ) 244 + .execute(db) 245 + .await 246 + .map_err(|e| format!("Database error: {}", e))?; 247 + 248 + if channel == "email" { 249 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 250 + let handle_str = handle.unwrap_or("user"); 251 + crate::notifications::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname) 252 + .await 253 + .map_err(|e| format!("Failed to enqueue email notification: {}", e))?; 254 + } else { 255 + sqlx::query!( 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) 259 + "#, 260 + user_id, 261 + channel as _, 262 + identifier, 263 + format!("Your verification code is: {}", code), 264 + json!({"code": code}) 265 + ) 266 + .execute(db) 267 + .await 268 + .map_err(|e| format!("Failed to enqueue notification: {}", e))?; 269 + } 270 + 271 + Ok(code) 272 + } 273 + 107 274 pub async fn update_notification_prefs( 108 275 State(state): State<AppState>, 109 276 headers: HeaderMap, ··· 129 296 .into_response(); 130 297 } 131 298 }; 299 + 300 + let user_row = match sqlx::query!( 301 + "SELECT id, handle, email FROM users WHERE did = $1", 302 + user.did 303 + ) 304 + .fetch_one(&state.db) 305 + .await 306 + { 307 + Ok(row) => row, 308 + Err(e) => return ( 309 + StatusCode::INTERNAL_SERVER_ERROR, 310 + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 311 + ) 312 + .into_response(), 313 + }; 314 + 315 + let user_id = user_row.id; 316 + let handle = user_row.handle; 317 + let current_email = user_row.email; 318 + 319 + let mut verification_required: Vec<String> = Vec::new(); 320 + 132 321 if let Some(ref channel) = input.preferred_channel { 133 322 let valid_channels = ["email", "discord", "telegram", "signal"]; 134 323 if !valid_channels.contains(&channel.as_str()) { ··· 157 346 } 158 347 info!(did = %user.did, channel = %channel, "Updated preferred notification channel"); 159 348 } 160 - if let Some(ref discord_id) = input.discord_id { 161 - let discord_id_clean: Option<&str> = if discord_id.is_empty() { 162 - None 163 - } else { 164 - Some(discord_id.as_str()) 165 - }; 166 - if let Err(e) = sqlx::query( 167 - r#"UPDATE users SET discord_id = $1, discord_verified = FALSE, updated_at = NOW() WHERE did = $2"# 168 - ) 169 - .bind(discord_id_clean) 170 - .bind(&user.did) 171 - .execute(&state.db) 172 - .await 173 - { 349 + 350 + if let Some(ref new_email) = input.email { 351 + let email_clean = new_email.trim().to_lowercase(); 352 + if email_clean.is_empty() { 353 + return ( 354 + StatusCode::BAD_REQUEST, 355 + Json(json!({"error": "InvalidRequest", "message": "Email cannot be empty"})), 356 + ) 357 + .into_response(); 358 + } 359 + 360 + if !crate::api::validation::is_valid_email(&email_clean) { 174 361 return ( 175 - StatusCode::INTERNAL_SERVER_ERROR, 176 - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 362 + StatusCode::BAD_REQUEST, 363 + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), 177 364 ) 178 365 .into_response(); 179 366 } 180 - info!(did = %user.did, "Updated Discord ID"); 367 + 368 + if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) { 369 + info!(did = %user.did, "Email unchanged, skipping"); 370 + } else { 371 + let exists = sqlx::query!( 372 + "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", 373 + email_clean, 374 + user_id 375 + ) 376 + .fetch_optional(&state.db) 377 + .await; 378 + 379 + if let Ok(Some(_)) = exists { 380 + return ( 381 + StatusCode::BAD_REQUEST, 382 + Json(json!({"error": "EmailTaken", "message": "Email already in use"})), 383 + ) 384 + .into_response(); 385 + } 386 + 387 + if let Err(e) = request_channel_verification(&state.db, user_id, "email", &email_clean, Some(&handle)).await { 388 + return ( 389 + StatusCode::INTERNAL_SERVER_ERROR, 390 + Json(json!({"error": "InternalError", "message": e})), 391 + ) 392 + .into_response(); 393 + } 394 + verification_required.push("email".to_string()); 395 + info!(did = %user.did, "Requested email verification"); 396 + } 181 397 } 398 + 399 + if let Some(ref discord_id) = input.discord_id { 400 + if discord_id.is_empty() { 401 + if let Err(e) = sqlx::query!( 402 + "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", 403 + user_id 404 + ) 405 + .execute(&state.db) 406 + .await 407 + { 408 + return ( 409 + StatusCode::INTERNAL_SERVER_ERROR, 410 + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 411 + ) 412 + .into_response(); 413 + } 414 + let _ = sqlx::query!( 415 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", 416 + user_id 417 + ) 418 + .execute(&state.db) 419 + .await; 420 + info!(did = %user.did, "Cleared Discord ID"); 421 + } else { 422 + if let Err(e) = request_channel_verification(&state.db, user_id, "discord", discord_id, None).await { 423 + return ( 424 + StatusCode::INTERNAL_SERVER_ERROR, 425 + Json(json!({"error": "InternalError", "message": e})), 426 + ) 427 + .into_response(); 428 + } 429 + verification_required.push("discord".to_string()); 430 + info!(did = %user.did, "Requested Discord verification"); 431 + } 432 + } 433 + 182 434 if let Some(ref telegram) = input.telegram_username { 183 - let telegram_clean: Option<&str> = if telegram.is_empty() { 184 - None 435 + let telegram_clean = telegram.trim_start_matches('@'); 436 + if telegram_clean.is_empty() { 437 + if let Err(e) = sqlx::query!( 438 + "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1", 439 + user_id 440 + ) 441 + .execute(&state.db) 442 + .await 443 + { 444 + return ( 445 + StatusCode::INTERNAL_SERVER_ERROR, 446 + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 447 + ) 448 + .into_response(); 449 + } 450 + let _ = sqlx::query!( 451 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'", 452 + user_id 453 + ) 454 + .execute(&state.db) 455 + .await; 456 + info!(did = %user.did, "Cleared Telegram username"); 185 457 } else { 186 - Some(telegram.trim_start_matches('@')) 187 - }; 188 - if let Err(e) = sqlx::query( 189 - r#"UPDATE users SET telegram_username = $1, telegram_verified = FALSE, updated_at = NOW() WHERE did = $2"# 190 - ) 191 - .bind(telegram_clean) 192 - .bind(&user.did) 193 - .execute(&state.db) 194 - .await 195 - { 196 - return ( 197 - StatusCode::INTERNAL_SERVER_ERROR, 198 - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 199 - ) 200 - .into_response(); 458 + if let Err(e) = request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None).await { 459 + return ( 460 + StatusCode::INTERNAL_SERVER_ERROR, 461 + Json(json!({"error": "InternalError", "message": e})), 462 + ) 463 + .into_response(); 464 + } 465 + verification_required.push("telegram".to_string()); 466 + info!(did = %user.did, "Requested Telegram verification"); 201 467 } 202 - info!(did = %user.did, "Updated Telegram username"); 203 468 } 469 + 204 470 if let Some(ref signal) = input.signal_number { 205 - let signal_clean: Option<&str> = if signal.is_empty() { 206 - None 207 - } else { 208 - Some(signal.as_str()) 209 - }; 210 - if let Err(e) = sqlx::query( 211 - r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"# 212 - ) 213 - .bind(signal_clean) 214 - .bind(&user.did) 215 - .execute(&state.db) 216 - .await 217 - { 218 - return ( 219 - StatusCode::INTERNAL_SERVER_ERROR, 220 - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 471 + if signal.is_empty() { 472 + if let Err(e) = sqlx::query!( 473 + "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", 474 + user_id 475 + ) 476 + .execute(&state.db) 477 + .await 478 + { 479 + return ( 480 + StatusCode::INTERNAL_SERVER_ERROR, 481 + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 482 + ) 483 + .into_response(); 484 + } 485 + let _ = sqlx::query!( 486 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'", 487 + user_id 221 488 ) 222 - .into_response(); 489 + .execute(&state.db) 490 + .await; 491 + info!(did = %user.did, "Cleared Signal number"); 492 + } else { 493 + if let Err(e) = request_channel_verification(&state.db, user_id, "signal", signal, None).await { 494 + return ( 495 + StatusCode::INTERNAL_SERVER_ERROR, 496 + Json(json!({"error": "InternalError", "message": e})), 497 + ) 498 + .into_response(); 499 + } 500 + verification_required.push("signal".to_string()); 501 + info!(did = %user.did, "Requested Signal verification"); 223 502 } 224 - info!(did = %user.did, "Updated Signal number"); 225 503 } 226 - Json(json!({"success": true})).into_response() 504 + 505 + Json(UpdateNotificationPrefsResponse { 506 + success: true, 507 + verification_required, 508 + }).into_response() 227 509 }
+171 -125
src/api/server/email.rs
··· 6 6 http::StatusCode, 7 7 response::{IntoResponse, Response}, 8 8 }; 9 - use chrono::{Duration, Utc}; 9 + use chrono::Utc; 10 10 use serde::Deserialize; 11 11 use serde_json::json; 12 12 use tracing::{error, info, warn}; 13 - 14 - fn generate_confirmation_code() -> String { 15 - crate::util::generate_token_code() 16 - } 17 13 18 14 #[derive(Deserialize)] 19 15 #[serde(rename_all = "camelCase")] ··· 41 37 ) 42 38 .into_response(); 43 39 } 40 + 44 41 let token = match crate::auth::extract_bearer_token_from_header( 45 42 headers.get("Authorization").and_then(|h| h.to_str().ok()), 46 43 ) { ··· 53 50 .into_response(); 54 51 } 55 52 }; 53 + 56 54 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 57 55 let did = match auth_result { 58 56 Ok(user) => user.did, 59 57 Err(e) => return ApiError::from(e).into_response(), 60 58 }; 61 - let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) 59 + 60 + let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did) 62 61 .fetch_optional(&state.db) 63 62 .await 64 63 { ··· 71 70 .into_response(); 72 71 } 73 72 }; 73 + 74 74 let user_id = user.id; 75 75 let handle = user.handle; 76 + let current_email = user.email; 76 77 let email = input.email.trim().to_lowercase(); 78 + 77 79 if !crate::api::validation::is_valid_email(&email) { 78 80 return ( 79 81 StatusCode::BAD_REQUEST, ··· 81 83 ) 82 84 .into_response(); 83 85 } 84 - let exists = sqlx::query!("SELECT 1 as one FROM users WHERE LOWER(email) = $1", email) 85 - .fetch_optional(&state.db) 86 - .await; 86 + 87 + if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email.clone()) { 88 + return (StatusCode::OK, Json(json!({ "tokenRequired": false }))).into_response(); 89 + } 90 + 91 + let exists = sqlx::query!( 92 + "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", 93 + email, 94 + user_id 95 + ) 96 + .fetch_optional(&state.db) 97 + .await; 98 + 87 99 if let Ok(Some(_)) = exists { 88 100 return ( 89 101 StatusCode::BAD_REQUEST, ··· 91 103 ) 92 104 .into_response(); 93 105 } 94 - let code = generate_confirmation_code(); 95 - let expires_at = Utc::now() + Duration::minutes(10); 96 - let update = sqlx::query!( 97 - "UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4", 98 - email, 99 - code, 100 - expires_at, 101 - user_id 106 + 107 + if let Err(e) = crate::api::notification_prefs::request_channel_verification( 108 + &state.db, 109 + user_id, 110 + "email", 111 + &email, 112 + Some(&handle), 102 113 ) 103 - .execute(&state.db) 104 - .await; 105 - if let Err(e) = update { 106 - error!("DB error setting email update code: {:?}", e); 114 + .await 115 + { 116 + error!("Failed to request email verification: {}", e); 107 117 return ( 108 118 StatusCode::INTERNAL_SERVER_ERROR, 109 119 Json(json!({"error": "InternalError"})), 110 120 ) 111 121 .into_response(); 112 122 } 113 - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 114 - if let Err(e) = crate::notifications::enqueue_email_update( 115 - &state.db, user_id, &email, &handle, &code, &hostname, 116 - ) 117 - .await 118 - { 119 - warn!("Failed to enqueue email update notification: {:?}", e); 120 - } 123 + 121 124 info!("Email update requested for user {}", user_id); 122 125 (StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response() 123 126 } ··· 149 152 ) 150 153 .into_response(); 151 154 } 155 + 152 156 let token = match crate::auth::extract_bearer_token_from_header( 153 157 headers.get("Authorization").and_then(|h| h.to_str().ok()), 154 158 ) { ··· 161 165 .into_response(); 162 166 } 163 167 }; 168 + 164 169 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 165 170 let did = match auth_result { 166 171 Ok(user) => user.did, 167 172 Err(e) => return ApiError::from(e).into_response(), 168 173 }; 169 - let user = match sqlx::query!( 170 - "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", 171 - did 174 + 175 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 176 + .fetch_one(&state.db) 177 + .await 178 + { 179 + Ok(id) => id, 180 + Err(_) => { 181 + return ( 182 + StatusCode::INTERNAL_SERVER_ERROR, 183 + Json(json!({"error": "InternalError"})), 184 + ) 185 + .into_response(); 186 + } 187 + }; 188 + 189 + let verification = match sqlx::query!( 190 + "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 191 + user_id 172 192 ) 173 193 .fetch_optional(&state.db) 174 194 .await ··· 176 196 Ok(Some(row)) => row, 177 197 _ => { 178 198 return ( 179 - StatusCode::INTERNAL_SERVER_ERROR, 180 - Json(json!({"error": "InternalError"})), 199 + StatusCode::BAD_REQUEST, 200 + Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), 181 201 ) 182 202 .into_response(); 183 203 } 184 204 }; 185 - let user_id = user.id; 186 - let stored_code = user.email_confirmation_code; 187 - let expires_at = user.email_confirmation_code_expires_at; 188 - let email_pending_verification = user.email_pending_verification; 205 + 206 + let pending_email = verification.pending_identifier.unwrap_or_default(); 189 207 let email = input.email.trim().to_lowercase(); 190 208 let confirmation_code = input.token.trim(); 191 - let (pending_email, saved_code, expiry) = 192 - match (email_pending_verification, stored_code, expires_at) { 193 - (Some(p), Some(c), Some(e)) => (p, c, e), 194 - _ => { 195 - return ( 196 - StatusCode::BAD_REQUEST, 197 - Json( 198 - json!({"error": "InvalidRequest", "message": "No pending email update found"}), 199 - ), 200 - ) 201 - .into_response(); 202 - } 203 - }; 209 + 204 210 if pending_email != email { 205 211 return ( 206 212 StatusCode::BAD_REQUEST, ··· 208 214 ) 209 215 .into_response(); 210 216 } 211 - if saved_code != confirmation_code { 217 + 218 + if verification.code != confirmation_code { 212 219 return ( 213 220 StatusCode::BAD_REQUEST, 214 221 Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 215 222 ) 216 223 .into_response(); 217 224 } 218 - if Utc::now() > expiry { 225 + 226 + if Utc::now() > verification.expires_at { 219 227 return ( 220 228 StatusCode::BAD_REQUEST, 221 229 Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 222 230 ) 223 231 .into_response(); 224 232 } 233 + 234 + let mut tx = match state.db.begin().await { 235 + Ok(tx) => tx, 236 + Err(_) => return ApiError::InternalError.into_response(), 237 + }; 238 + 225 239 let update = sqlx::query!( 226 - "UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2", 240 + "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", 227 241 pending_email, 228 242 user_id 229 243 ) 230 - .execute(&state.db) 244 + .execute(&mut *tx) 231 245 .await; 246 + 232 247 if let Err(e) = update { 233 248 error!("DB error finalizing email update: {:?}", e); 234 249 if e.as_database_error() ··· 247 262 ) 248 263 .into_response(); 249 264 } 265 + 266 + if let Err(e) = sqlx::query!( 267 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 268 + user_id 269 + ) 270 + .execute(&mut *tx) 271 + .await 272 + { 273 + error!("Failed to delete verification record: {:?}", e); 274 + return ApiError::InternalError.into_response(); 275 + } 276 + 277 + if let Err(_) = tx.commit().await { 278 + return ApiError::InternalError.into_response(); 279 + } 280 + 250 281 info!("Email updated for user {}", user_id); 251 282 (StatusCode::OK, Json(json!({}))).into_response() 252 283 } ··· 277 308 .into_response(); 278 309 } 279 310 }; 311 + 280 312 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 281 313 let did = match auth_result { 282 314 Ok(user) => user.did, 283 315 Err(e) => return ApiError::from(e).into_response(), 284 316 }; 317 + 285 318 let user = match sqlx::query!( 286 - "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", 319 + "SELECT id, email FROM users WHERE did = $1", 287 320 did 288 321 ) 289 322 .fetch_optional(&state.db) ··· 298 331 .into_response(); 299 332 } 300 333 }; 334 + 301 335 let user_id = user.id; 302 336 let current_email = user.email; 303 - let stored_code = user.email_confirmation_code; 304 - let expires_at = user.email_confirmation_code_expires_at; 305 - let email_pending_verification = user.email_pending_verification; 306 337 let new_email = input.email.trim().to_lowercase(); 338 + 307 339 if !crate::api::validation::is_valid_email(&new_email) { 308 340 return ( 309 341 StatusCode::BAD_REQUEST, ··· 311 343 ) 312 344 .into_response(); 313 345 } 346 + 314 347 if let Some(ref current) = current_email 315 - && new_email == current.to_lowercase() { 316 - return (StatusCode::OK, Json(json!({}))).into_response(); 317 - } 318 - let email_confirmed = stored_code.is_some() && email_pending_verification.is_some(); 319 - if email_confirmed { 348 + && new_email == current.to_lowercase() 349 + { 350 + return (StatusCode::OK, Json(json!({}))).into_response(); 351 + } 352 + 353 + let verification = sqlx::query!( 354 + "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 355 + user_id 356 + ) 357 + .fetch_optional(&state.db) 358 + .await 359 + .unwrap_or(None); 360 + 361 + if let Some(ver) = verification { 320 362 let confirmation_token = match &input.token { 321 363 Some(t) => t.trim(), 322 364 None => { 323 365 return ( 324 366 StatusCode::BAD_REQUEST, 325 - Json(json!({"error": "TokenRequired", "message": "Token required for confirmed accounts. Call requestEmailUpdate first."})), 326 - ) 327 - .into_response(); 328 - } 329 - }; 330 - let pending_email = match email_pending_verification { 331 - Some(p) => p, 332 - None => { 333 - return ( 334 - StatusCode::BAD_REQUEST, 335 - Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), 367 + Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})), 336 368 ) 337 369 .into_response(); 338 370 } 339 371 }; 372 + 373 + let pending_email = ver.pending_identifier.unwrap_or_default(); 340 374 if pending_email.to_lowercase() != new_email { 341 375 return ( 342 376 StatusCode::BAD_REQUEST, ··· 344 378 ) 345 379 .into_response(); 346 380 } 347 - let saved_code = match stored_code { 348 - Some(c) => c, 349 - None => { 350 - return ( 351 - StatusCode::BAD_REQUEST, 352 - Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), 353 - ) 354 - .into_response(); 355 - } 356 - }; 357 - if saved_code != confirmation_token { 381 + 382 + if ver.code != confirmation_token { 358 383 return ( 359 384 StatusCode::BAD_REQUEST, 360 385 Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 361 386 ) 362 387 .into_response(); 363 388 } 364 - if let Some(exp) = expires_at 365 - && Utc::now() > exp { 366 - return ( 367 - StatusCode::BAD_REQUEST, 368 - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 369 - ) 370 - .into_response(); 371 - } 389 + 390 + if Utc::now() > ver.expires_at { 391 + return ( 392 + StatusCode::BAD_REQUEST, 393 + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 394 + ) 395 + .into_response(); 396 + } 372 397 } 398 + 373 399 let exists = sqlx::query!( 374 400 "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", 375 401 new_email, ··· 377 403 ) 378 404 .fetch_optional(&state.db) 379 405 .await; 406 + 380 407 if let Ok(Some(_)) = exists { 381 408 return ( 382 409 StatusCode::BAD_REQUEST, ··· 384 411 ) 385 412 .into_response(); 386 413 } 414 + 415 + let mut tx = match state.db.begin().await { 416 + Ok(tx) => tx, 417 + Err(_) => return ApiError::InternalError.into_response(), 418 + }; 419 + 387 420 let update = sqlx::query!( 388 - r#" 389 - UPDATE users 390 - SET email = $1, 391 - email_pending_verification = NULL, 392 - email_confirmation_code = NULL, 393 - email_confirmation_code_expires_at = NULL, 394 - updated_at = NOW() 395 - WHERE id = $2 396 - "#, 421 + "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", 397 422 new_email, 398 423 user_id 399 424 ) 400 - .execute(&state.db) 425 + .execute(&mut *tx) 401 426 .await; 402 - match update { 403 - Ok(_) => { 404 - info!("Email updated for user {}", user_id); 405 - (StatusCode::OK, Json(json!({}))).into_response() 406 - } 407 - Err(e) => { 408 - error!("DB error finalizing email update: {:?}", e); 409 - if e.as_database_error() 410 - .map(|db_err| db_err.is_unique_violation()) 411 - .unwrap_or(false) 412 - { 413 - return ( 414 - StatusCode::BAD_REQUEST, 415 - Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), 416 - ) 417 - .into_response(); 418 - } 419 - ( 420 - StatusCode::INTERNAL_SERVER_ERROR, 421 - Json(json!({"error": "InternalError"})), 427 + 428 + if let Err(e) = update { 429 + error!("DB error finalizing email update: {:?}", e); 430 + if e.as_database_error() 431 + .map(|db_err| db_err.is_unique_violation()) 432 + .unwrap_or(false) 433 + { 434 + return ( 435 + StatusCode::BAD_REQUEST, 436 + Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), 422 437 ) 423 - .into_response() 438 + .into_response(); 424 439 } 440 + return ( 441 + StatusCode::INTERNAL_SERVER_ERROR, 442 + Json(json!({"error": "InternalError"})), 443 + ) 444 + .into_response(); 425 445 } 446 + 447 + let _ = sqlx::query!( 448 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 449 + user_id 450 + ) 451 + .execute(&mut *tx) 452 + .await; 453 + 454 + if let Err(_) = tx.commit().await { 455 + return ApiError::InternalError.into_response(); 456 + } 457 + 458 + match sqlx::query!( 459 + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2", 460 + user_id, 461 + json!(input.email_auth_factor.unwrap_or(false)) 462 + ) 463 + .execute(&state.db) 464 + .await 465 + { 466 + Ok(_) => {} 467 + Err(e) => warn!("Failed to update email_auth_factor preference: {}", e), 468 + } 469 + 470 + info!("Email updated for user {}", user_id); 471 + (StatusCode::OK, Json(json!({}))).into_response() 426 472 }
+46 -17
src/api/server/session.rs
··· 475 475 let row = match sqlx::query!( 476 476 r#"SELECT 477 477 u.id, u.did, u.handle, u.email, 478 - u.email_confirmation_code, 479 - u.email_confirmation_code_expires_at, 480 478 u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 481 479 k.key_bytes, k.encryption_version 482 480 FROM users u ··· 497 495 return ApiError::InternalError.into_response(); 498 496 } 499 497 }; 500 - let stored_code = match &row.email_confirmation_code { 501 - Some(code) => code, 502 - None => { 498 + 499 + let verification = match sqlx::query!( 500 + "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 501 + row.id 502 + ) 503 + .fetch_optional(&state.db) 504 + .await 505 + { 506 + Ok(Some(v)) => v, 507 + Ok(None) => { 503 508 warn!("No verification code found for user: {}", input.did); 504 509 return ApiError::InvalidRequest("No pending verification".into()).into_response(); 505 510 } 511 + Err(e) => { 512 + error!("Database error fetching verification: {:?}", e); 513 + return ApiError::InternalError.into_response(); 514 + } 506 515 }; 507 - if stored_code != &input.verification_code { 516 + 517 + if verification.code != input.verification_code { 508 518 warn!("Invalid verification code for user: {}", input.did); 509 519 return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); 510 520 } 511 - if let Some(expires_at) = row.email_confirmation_code_expires_at 512 - && expires_at < Utc::now() { 513 - warn!("Verification code expired for user: {}", input.did); 514 - return ApiError::ExpiredTokenMsg("Verification code has expired".into()) 515 - .into_response(); 516 - } 521 + if verification.expires_at < Utc::now() { 522 + warn!("Verification code expired for user: {}", input.did); 523 + return ApiError::ExpiredTokenMsg("Verification code has expired".into()) 524 + .into_response(); 525 + } 526 + 517 527 let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 518 528 Ok(k) => k, 519 529 Err(e) => { ··· 528 538 crate::notifications::NotificationChannel::Signal => "signal_verified", 529 539 }; 530 540 let update_query = format!( 531 - "UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1", 541 + "UPDATE users SET {} = TRUE WHERE did = $1", 532 542 verified_column 533 543 ); 534 544 if let Err(e) = sqlx::query(&update_query) ··· 539 549 error!("Failed to update verification status: {:?}", e); 540 550 return ApiError::InternalError.into_response(); 541 551 } 552 + 553 + if let Err(e) = sqlx::query!( 554 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 555 + row.id 556 + ) 557 + .execute(&state.db) 558 + .await { 559 + error!("Failed to delete verification record: {:?}", e); 560 + } 561 + 542 562 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 543 563 Ok(m) => m, 544 564 Err(e) => { ··· 634 654 } 635 655 let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000); 636 656 let code_expires_at = Utc::now() + chrono::Duration::minutes(30); 657 + 658 + let email = row.email.clone(); 659 + 637 660 if let Err(e) = sqlx::query!( 638 - "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3", 661 + r#" 662 + INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 663 + VALUES ($1, 'email', $2, $3, $4) 664 + ON CONFLICT (user_id, channel) DO UPDATE 665 + SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW() 666 + "#, 667 + row.id, 639 668 verification_code, 640 - code_expires_at, 641 - input.did 669 + email, 670 + code_expires_at 642 671 ) 643 672 .execute(&state.db) 644 673 .await ··· 648 677 } 649 678 let (channel_str, recipient) = match row.channel { 650 679 crate::notifications::NotificationChannel::Email => { 651 - ("email", row.email.clone().unwrap_or_default()) 680 + ("email", row.email.unwrap_or_default()) 652 681 } 653 682 crate::notifications::NotificationChannel::Discord => { 654 683 ("discord", row.discord_id.unwrap_or_default())
+191
src/api/verification.rs
··· 1 + use crate::auth::validate_bearer_token; 2 + use crate::state::AppState; 3 + use axum::{ 4 + Json, 5 + extract::State, 6 + http::{HeaderMap, StatusCode}, 7 + response::{IntoResponse, Response}, 8 + }; 9 + use chrono::Utc; 10 + use serde::Deserialize; 11 + use serde_json::json; 12 + use tracing::{error, info}; 13 + 14 + #[derive(Deserialize)] 15 + #[serde(rename_all = "camelCase")] 16 + pub struct ConfirmChannelVerificationInput { 17 + pub channel: String, 18 + pub code: String, 19 + } 20 + 21 + pub async fn confirm_channel_verification( 22 + State(state): State<AppState>, 23 + headers: HeaderMap, 24 + Json(input): Json<ConfirmChannelVerificationInput>, 25 + ) -> Response { 26 + let token = match crate::auth::extract_bearer_token_from_header( 27 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 28 + ) { 29 + Some(t) => t, 30 + None => return ( 31 + StatusCode::UNAUTHORIZED, 32 + Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), 33 + ) 34 + .into_response(), 35 + }; 36 + let user = match validate_bearer_token(&state.db, &token).await { 37 + Ok(u) => u, 38 + Err(_) => { 39 + return ( 40 + StatusCode::UNAUTHORIZED, 41 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), 42 + ) 43 + .into_response(); 44 + } 45 + }; 46 + 47 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did) 48 + .fetch_one(&state.db) 49 + .await 50 + { 51 + Ok(id) => id, 52 + Err(_) => return ( 53 + StatusCode::INTERNAL_SERVER_ERROR, 54 + Json(json!({"error": "InternalError", "message": "User not found"})), 55 + ) 56 + .into_response(), 57 + }; 58 + 59 + let channel_str = input.channel.as_str(); 60 + if !["email", "discord", "telegram", "signal"].contains(&channel_str) { 61 + return ( 62 + StatusCode::BAD_REQUEST, 63 + Json(json!({"error": "InvalidRequest", "message": "Invalid channel"})), 64 + ) 65 + .into_response(); 66 + } 67 + 68 + let record = match sqlx::query!( 69 + r#" 70 + SELECT code, pending_identifier, expires_at FROM channel_verifications 71 + WHERE user_id = $1 AND channel = $2::notification_channel 72 + "#, 73 + user_id, 74 + channel_str as _ 75 + ) 76 + .fetch_optional(&state.db) 77 + .await { 78 + Ok(Some(r)) => r, 79 + Ok(None) => return ( 80 + StatusCode::BAD_REQUEST, 81 + Json(json!({"error": "InvalidRequest", "message": "No pending verification found. Update notification preferences first."})), 82 + ) 83 + .into_response(), 84 + Err(e) => return ( 85 + StatusCode::INTERNAL_SERVER_ERROR, 86 + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 87 + ) 88 + .into_response(), 89 + }; 90 + 91 + let pending_identifier = match record.pending_identifier { 92 + Some(p) => p, 93 + None => return ( 94 + StatusCode::BAD_REQUEST, 95 + Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})), 96 + ) 97 + .into_response(), 98 + }; 99 + 100 + if record.expires_at < Utc::now() { 101 + return ( 102 + StatusCode::BAD_REQUEST, 103 + Json(json!({"error": "ExpiredToken", "message": "Verification code expired"})), 104 + ) 105 + .into_response(); 106 + } 107 + 108 + if record.code != input.code { 109 + return ( 110 + StatusCode::BAD_REQUEST, 111 + Json(json!({"error": "InvalidCode", "message": "Invalid verification code"})), 112 + ) 113 + .into_response(); 114 + } 115 + 116 + let mut tx = match state.db.begin().await { 117 + Ok(tx) => tx, 118 + Err(_) => return ( 119 + StatusCode::INTERNAL_SERVER_ERROR, 120 + Json(json!({"error": "InternalError"})), 121 + ) 122 + .into_response(), 123 + }; 124 + 125 + let update_result = match channel_str { 126 + "email" => sqlx::query!( 127 + "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", 128 + pending_identifier, 129 + user_id 130 + ).execute(&mut *tx).await, 131 + "discord" => sqlx::query!( 132 + "UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2", 133 + pending_identifier, 134 + user_id 135 + ).execute(&mut *tx).await, 136 + "telegram" => sqlx::query!( 137 + "UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2", 138 + pending_identifier, 139 + user_id 140 + ).execute(&mut *tx).await, 141 + "signal" => sqlx::query!( 142 + "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", 143 + pending_identifier, 144 + user_id 145 + ).execute(&mut *tx).await, 146 + _ => unreachable!(), 147 + }; 148 + 149 + if let Err(e) = update_result { 150 + error!("Failed to update user channel: {:?}", e); 151 + if channel_str == "email" && e.as_database_error().map(|db| db.is_unique_violation()).unwrap_or(false) { 152 + return ( 153 + StatusCode::BAD_REQUEST, 154 + Json(json!({"error": "EmailTaken", "message": "Email already in use"})), 155 + ) 156 + .into_response(); 157 + } 158 + return ( 159 + StatusCode::INTERNAL_SERVER_ERROR, 160 + Json(json!({"error": "InternalError", "message": "Failed to update channel"})), 161 + ) 162 + .into_response(); 163 + } 164 + 165 + if let Err(e) = sqlx::query!( 166 + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel", 167 + user_id, 168 + channel_str as _ 169 + ) 170 + .execute(&mut *tx) 171 + .await { 172 + error!("Failed to delete verification record: {:?}", e); 173 + return ( 174 + StatusCode::INTERNAL_SERVER_ERROR, 175 + Json(json!({"error": "InternalError"})), 176 + ) 177 + .into_response(); 178 + } 179 + 180 + if let Err(_) = tx.commit().await { 181 + return ( 182 + StatusCode::INTERNAL_SERVER_ERROR, 183 + Json(json!({"error": "InternalError"})), 184 + ) 185 + .into_response(); 186 + } 187 + 188 + info!(did = %user.did, channel = %channel_str, "Channel verified successfully"); 189 + 190 + Json(json!({"success": true})).into_response() 191 + }
+12
src/lib.rs
··· 273 273 get(api::admin::get_invite_codes), 274 274 ) 275 275 .route( 276 + "/xrpc/com.bspds.admin.getServerStats", 277 + get(api::admin::get_server_stats), 278 + ) 279 + .route( 276 280 "/xrpc/com.atproto.admin.disableAccountInvites", 277 281 post(api::admin::disable_account_invites), 278 282 ) ··· 387 391 .route( 388 392 "/xrpc/com.bspds.account.updateNotificationPrefs", 389 393 post(api::notification_prefs::update_notification_prefs), 394 + ) 395 + .route( 396 + "/xrpc/com.bspds.account.getNotificationHistory", 397 + get(api::notification_prefs::get_notification_history), 398 + ) 399 + .route( 400 + "/xrpc/com.bspds.account.confirmChannelVerification", 401 + post(api::verification::confirm_channel_verification), 390 402 ) 391 403 .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) 392 404 .layer(middleware::from_fn(metrics::metrics_middleware))
+221
tests/account_notifications.rs
··· 1 + mod common; 2 + use common::{base_url, client, create_account_and_login, get_db_connection_string}; 3 + use bspds::notifications::{NewNotification, NotificationType, enqueue_notification}; 4 + use serde_json::{Value, json}; 5 + use sqlx::PgPool; 6 + 7 + async fn get_pool() -> PgPool { 8 + let conn_str = get_db_connection_string().await; 9 + sqlx::postgres::PgPoolOptions::new() 10 + .max_connections(5) 11 + .connect(&conn_str) 12 + .await 13 + .expect("Failed to connect to test database") 14 + } 15 + 16 + #[tokio::test] 17 + async fn test_get_notification_history() { 18 + let client = client(); 19 + let base = base_url().await; 20 + let pool = get_pool().await; 21 + let (token, did) = create_account_and_login(&client).await; 22 + 23 + let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 24 + .fetch_one(&pool) 25 + .await 26 + .expect("User not found"); 27 + 28 + for i in 0..3 { 29 + let notification = NewNotification::email( 30 + user_id, 31 + NotificationType::Welcome, 32 + "test@example.com".to_string(), 33 + format!("Subject {}", i), 34 + format!("Body {}", i), 35 + ); 36 + enqueue_notification(&pool, notification).await.expect("Failed to enqueue"); 37 + } 38 + 39 + let resp = client 40 + .get(format!("{}/xrpc/com.bspds.account.getNotificationHistory", base)) 41 + .header("Authorization", format!("Bearer {}", token)) 42 + .send() 43 + .await 44 + .unwrap(); 45 + 46 + assert_eq!(resp.status(), 200); 47 + let body: Value = resp.json().await.unwrap(); 48 + let notifications = body["notifications"].as_array().unwrap(); 49 + assert_eq!(notifications.len(), 5); 50 + 51 + assert_eq!(notifications[0]["subject"], "Subject 2"); 52 + assert_eq!(notifications[1]["subject"], "Subject 1"); 53 + assert_eq!(notifications[2]["subject"], "Subject 0"); 54 + } 55 + 56 + #[tokio::test] 57 + async fn test_verify_channel_discord() { 58 + let client = client(); 59 + let base = base_url().await; 60 + let (token, did) = create_account_and_login(&client).await; 61 + 62 + let prefs = json!({ 63 + "discordId": "123456789" 64 + }); 65 + let resp = client 66 + .post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base)) 67 + .header("Authorization", format!("Bearer {}", token)) 68 + .json(&prefs) 69 + .send() 70 + .await 71 + .unwrap(); 72 + assert_eq!(resp.status(), 200); 73 + let body: Value = resp.json().await.unwrap(); 74 + assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("discord"))); 75 + 76 + let pool = get_pool().await; 77 + let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 78 + .fetch_one(&pool) 79 + .await 80 + .expect("User not found"); 81 + 82 + let code: String = sqlx::query_scalar!( 83 + "SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", 84 + user_id 85 + ) 86 + .fetch_one(&pool) 87 + .await 88 + .expect("Verification code not found"); 89 + 90 + let input = json!({ 91 + "channel": "discord", 92 + "code": code 93 + }); 94 + let resp = client 95 + .post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base)) 96 + .header("Authorization", format!("Bearer {}", token)) 97 + .json(&input) 98 + .send() 99 + .await 100 + .unwrap(); 101 + assert_eq!(resp.status(), 200); 102 + 103 + let resp = client 104 + .get(format!("{}/xrpc/com.bspds.account.getNotificationPrefs", base)) 105 + .header("Authorization", format!("Bearer {}", token)) 106 + .send() 107 + .await 108 + .unwrap(); 109 + let body: Value = resp.json().await.unwrap(); 110 + assert_eq!(body["discordVerified"], true); 111 + assert_eq!(body["discordId"], "123456789"); 112 + } 113 + 114 + #[tokio::test] 115 + async fn test_verify_channel_invalid_code() { 116 + let client = client(); 117 + let base = base_url().await; 118 + let (token, _did) = create_account_and_login(&client).await; 119 + 120 + let prefs = json!({ 121 + "telegramUsername": "testuser" 122 + }); 123 + let resp = client 124 + .post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base)) 125 + .header("Authorization", format!("Bearer {}", token)) 126 + .json(&prefs) 127 + .send() 128 + .await 129 + .unwrap(); 130 + assert_eq!(resp.status(), 200); 131 + 132 + let input = json!({ 133 + "channel": "telegram", 134 + "code": "000000" 135 + }); 136 + let resp = client 137 + .post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base)) 138 + .header("Authorization", format!("Bearer {}", token)) 139 + .json(&input) 140 + .send() 141 + .await 142 + .unwrap(); 143 + assert_eq!(resp.status(), 400); 144 + } 145 + 146 + #[tokio::test] 147 + async fn test_verify_channel_not_set() { 148 + let client = client(); 149 + let base = base_url().await; 150 + let (token, _did) = create_account_and_login(&client).await; 151 + 152 + let input = json!({ 153 + "channel": "signal", 154 + "code": "123456" 155 + }); 156 + let resp = client 157 + .post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base)) 158 + .header("Authorization", format!("Bearer {}", token)) 159 + .json(&input) 160 + .send() 161 + .await 162 + .unwrap(); 163 + assert_eq!(resp.status(), 400); 164 + } 165 + 166 + #[tokio::test] 167 + async fn test_update_email_via_notification_prefs() { 168 + let client = client(); 169 + let base = base_url().await; 170 + let pool = get_pool().await; 171 + let (token, did) = create_account_and_login(&client).await; 172 + 173 + let prefs = json!({ 174 + "email": "newemail@example.com" 175 + }); 176 + let resp = client 177 + .post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base)) 178 + .header("Authorization", format!("Bearer {}", token)) 179 + .json(&prefs) 180 + .send() 181 + .await 182 + .unwrap(); 183 + assert_eq!(resp.status(), 200); 184 + let body: Value = resp.json().await.unwrap(); 185 + assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("email"))); 186 + 187 + let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 188 + .fetch_one(&pool) 189 + .await 190 + .expect("User not found"); 191 + 192 + let code: String = sqlx::query_scalar!( 193 + "SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", 194 + user_id 195 + ) 196 + .fetch_one(&pool) 197 + .await 198 + .expect("Verification code not found"); 199 + 200 + let input = json!({ 201 + "channel": "email", 202 + "code": code 203 + }); 204 + let resp = client 205 + .post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base)) 206 + .header("Authorization", format!("Bearer {}", token)) 207 + .json(&input) 208 + .send() 209 + .await 210 + .unwrap(); 211 + assert_eq!(resp.status(), 200); 212 + 213 + let resp = client 214 + .get(format!("{}/xrpc/com.bspds.account.getNotificationPrefs", base)) 215 + .header("Authorization", format!("Bearer {}", token)) 216 + .send() 217 + .await 218 + .unwrap(); 219 + let body: Value = resp.json().await.unwrap(); 220 + assert_eq!(body["email"], "newemail@example.com"); 221 + }
+41
tests/admin_stats.rs
··· 1 + mod common; 2 + use common::{base_url, client, create_account_and_login}; 3 + use serde_json::Value; 4 + 5 + #[tokio::test] 6 + async fn test_get_server_stats() { 7 + let client = client(); 8 + let base = base_url().await; 9 + let (token1, _) = create_account_and_login(&client).await; 10 + 11 + let (_, _) = create_account_and_login(&client).await; 12 + 13 + let resp = client 14 + .get(format!("{}/xrpc/com.bspds.admin.getServerStats", base)) 15 + .header("Authorization", format!("Bearer {}", token1)) 16 + .send() 17 + .await 18 + .unwrap(); 19 + 20 + assert_eq!(resp.status(), 200); 21 + let body: Value = resp.json().await.unwrap(); 22 + 23 + let user_count = body["userCount"].as_i64().unwrap(); 24 + assert!(user_count >= 2); 25 + 26 + assert!(body["repoCount"].is_number()); 27 + assert!(body["recordCount"].is_number()); 28 + assert!(body["blobStorageBytes"].is_number()); 29 + } 30 + 31 + #[tokio::test] 32 + async fn test_get_server_stats_no_auth() { 33 + let client = client(); 34 + let base = base_url().await; 35 + let resp = client 36 + .get(format!("{}/xrpc/com.bspds.admin.getServerStats", base)) 37 + .send() 38 + .await 39 + .unwrap(); 40 + assert_eq!(resp.status(), 401); 41 + }
+9 -6
tests/common/mod.rs
··· 79 79 SERVER_URL.get_or_init(|| { 80 80 let (tx, rx) = std::sync::mpsc::channel(); 81 81 std::thread::spawn(move || { 82 + unsafe { 83 + std::env::set_var("BSPDS_ALLOW_INSECURE_SECRETS", "1"); 84 + } 82 85 if std::env::var("DOCKER_HOST").is_err() { 83 86 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { 84 87 let podman_sock = std::path::Path::new(&runtime_dir).join("podman/podman.sock"); ··· 406 409 .await 407 410 .expect("Failed to connect to test database"); 408 411 let verification_code: String = sqlx::query_scalar!( 409 - "SELECT email_confirmation_code FROM users WHERE did = $1", 412 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 410 413 did 411 414 ) 412 415 .fetch_one(&pool) 413 416 .await 414 - .expect("Failed to get verification code") 415 - .expect("No verification code found"); 417 + .expect("Failed to get verification code"); 418 + 416 419 let confirm_payload = json!({ 417 420 "did": did, 418 421 "verificationCode": verification_code ··· 548 551 .await 549 552 .expect("Failed to connect to test database"); 550 553 let verification_code: String = sqlx::query_scalar!( 551 - "SELECT email_confirmation_code FROM users WHERE did = $1", 554 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 552 555 &did 553 556 ) 554 557 .fetch_one(&pool) 555 558 .await 556 - .expect("Failed to get verification code") 557 - .expect("No verification code found"); 559 + .expect("Failed to get verification code"); 560 + 558 561 let confirm_payload = json!({ 559 562 "did": did, 560 563 "verificationCode": verification_code
+34 -19
tests/email_update.rs
··· 59 59 assert_eq!(res.status(), StatusCode::OK); 60 60 let body: Value = res.json().await.expect("Invalid JSON"); 61 61 assert_eq!(body["tokenRequired"], true); 62 - let user = sqlx::query!( 63 - "SELECT email_pending_verification, email_confirmation_code, email FROM users WHERE handle = $1", 62 + 63 + let verification = sqlx::query!( 64 + "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 64 65 handle 65 66 ) 66 67 .fetch_one(&pool) 67 68 .await 68 - .expect("User not found"); 69 + .expect("Verification not found"); 70 + 69 71 assert_eq!( 70 - user.email_pending_verification.as_deref(), 72 + verification.pending_identifier.as_deref(), 71 73 Some(new_email.as_str()) 72 74 ); 73 - assert!(user.email_confirmation_code.is_some()); 74 - let code = user.email_confirmation_code.unwrap(); 75 + let code = verification.code; 75 76 let res = client 76 77 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 77 78 .bearer_auth(&access_jwt) ··· 84 85 .expect("Failed to confirm email"); 85 86 assert_eq!(res.status(), StatusCode::OK); 86 87 let user = sqlx::query!( 87 - "SELECT email, email_pending_verification, email_confirmation_code FROM users WHERE handle = $1", 88 + "SELECT email FROM users WHERE handle = $1", 88 89 handle 89 90 ) 90 91 .fetch_one(&pool) 91 92 .await 92 93 .expect("User not found"); 93 94 assert_eq!(user.email, Some(new_email)); 94 - assert!(user.email_pending_verification.is_none()); 95 - assert!(user.email_confirmation_code.is_none()); 95 + 96 + let verification = sqlx::query!( 97 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 98 + handle 99 + ) 100 + .fetch_optional(&pool) 101 + .await 102 + .expect("DB error"); 103 + assert!(verification.is_none()); 96 104 } 97 105 98 106 #[tokio::test] ··· 174 182 .await 175 183 .expect("Failed to request email update"); 176 184 assert_eq!(res.status(), StatusCode::OK); 177 - let user = sqlx::query!( 178 - "SELECT email_confirmation_code FROM users WHERE handle = $1", 185 + let verification = sqlx::query!( 186 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 179 187 handle 180 188 ) 181 189 .fetch_one(&pool) 182 190 .await 183 - .expect("User not found"); 184 - let code = user.email_confirmation_code.unwrap(); 191 + .expect("Verification not found"); 192 + let code = verification.code; 185 193 let res = client 186 194 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 187 195 .bearer_auth(&access_jwt) ··· 293 301 .await 294 302 .expect("Failed to request email update"); 295 303 assert_eq!(res.status(), StatusCode::OK); 296 - let user = sqlx::query!( 297 - "SELECT email_confirmation_code FROM users WHERE handle = $1", 304 + let verification = sqlx::query!( 305 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 298 306 handle 299 307 ) 300 308 .fetch_one(&pool) 301 309 .await 302 - .expect("User not found"); 303 - let code = user.email_confirmation_code.unwrap(); 310 + .expect("Verification not found"); 311 + let code = verification.code; 304 312 let res = client 305 313 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 306 314 .bearer_auth(&access_jwt) ··· 313 321 .expect("Failed to update email"); 314 322 assert_eq!(res.status(), StatusCode::OK); 315 323 let user = sqlx::query!( 316 - "SELECT email, email_pending_verification FROM users WHERE handle = $1", 324 + "SELECT email FROM users WHERE handle = $1", 317 325 handle 318 326 ) 319 327 .fetch_one(&pool) 320 328 .await 321 329 .expect("User not found"); 322 330 assert_eq!(user.email, Some(new_email)); 323 - assert!(user.email_pending_verification.is_none()); 331 + let verification = sqlx::query!( 332 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 333 + handle 334 + ) 335 + .fetch_optional(&pool) 336 + .await 337 + .expect("DB error"); 338 + assert!(verification.is_none()); 324 339 } 325 340 326 341 #[tokio::test]
+2 -3
tests/jwt_security.rs
··· 872 872 .await 873 873 .expect("Failed to connect to test database"); 874 874 let verification_code: String = sqlx::query_scalar!( 875 - "SELECT email_confirmation_code FROM users WHERE did = $1", 875 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 876 876 did 877 877 ) 878 878 .fetch_one(&pool) 879 879 .await 880 - .expect("Failed to get verification code") 881 - .expect("No verification code found"); 880 + .expect("Failed to get verification code"); 882 881 let confirm_res = http_client 883 882 .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url)) 884 883 .json(&json!({