this repo has no description

More work on the pds notifs

lewis 23b56c3e 68715119

Changed files
+1858 -439
.sqlx
frontend
migrations
src
tests
+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 { 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", 4 "describe": { 5 "columns": [ 6 { ··· 25 }, 26 { 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 "name": "channel: crate::notifications::NotificationChannel", 39 "type_info": { 40 "Custom": { ··· 51 } 52 }, 53 { 54 - "ordinal": 7, 55 "name": "key_bytes", 56 "type_info": "Bytea" 57 }, 58 { 59 - "ordinal": 8, 60 "name": "encryption_version", 61 "type_info": "Int4" 62 } ··· 71 false, 72 false, 73 true, 74 - true, 75 - true, 76 false, 77 false, 78 true 79 ] 80 }, 81 - "hash": "257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b" 82 }
··· 1 { 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", 4 "describe": { 5 "columns": [ 6 { ··· 25 }, 26 { 27 "ordinal": 4, 28 "name": "channel: crate::notifications::NotificationChannel", 29 "type_info": { 30 "Custom": { ··· 41 } 42 }, 43 { 44 + "ordinal": 5, 45 "name": "key_bytes", 46 "type_info": "Bytea" 47 }, 48 { 49 + "ordinal": 6, 50 "name": "encryption_version", 51 "type_info": "Int4" 52 } ··· 61 false, 62 false, 63 true, 64 false, 65 false, 66 true 67 ] 68 }, 69 + "hash": "dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3" 70 }
+2 -1
.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json
··· 37 "account_deletion", 38 "admin_email", 39 "plc_operation", 40 - "two_factor_code" 41 ] 42 } 43 }
··· 37 "account_deletion", 38 "admin_email", 39 "plc_operation", 40 + "two_factor_code", 41 + "channel_verification" 42 ] 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 "account_deletion", 38 "admin_email", 39 "plc_operation", 40 - "two_factor_code" 41 ] 42 } 43 }
··· 37 "account_deletion", 38 "admin_email", 39 "plc_operation", 40 + "two_factor_code", 41 + "channel_verification" 42 ] 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 "account_deletion", 46 "admin_email", 47 "plc_operation", 48 - "two_factor_code" 49 ] 50 } 51 }
··· 45 "account_deletion", 46 "admin_email", 47 "plc_operation", 48 + "two_factor_code", 49 + "channel_verification" 50 ] 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 { 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", 4 "describe": { 5 "columns": [ 6 { ··· 36 true 37 ] 38 }, 39 - "hash": "f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693" 40 }
··· 1 { 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", 4 "describe": { 5 "columns": [ 6 { ··· 36 true 37 ] 38 }, 39 + "hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761" 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 Anyway... endpoints for PDS settings not covered by standard ATProto: 245 - [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels 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. 251 ### Frontend Views 252 Uses existing ATProto endpoints where possible: 253 Authentication ··· 262 - [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) 263 Notification Preferences 264 - [x] Channel selector (uses `com.bspds.account.*` endpoints above) 265 - - [ ] Verification flows for Discord/Telegram/Signal 266 - [ ] Notification history view 267 Account Settings 268 - [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
··· 244 Anyway... endpoints for PDS settings not covered by standard ATProto: 245 - [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels 246 - [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel 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 ### Frontend Views 252 Uses existing ATProto endpoints where possible: 253 Authentication ··· 262 - [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) 263 Notification Preferences 264 - [x] Channel selector (uses `com.bspds.account.*` endpoints above) 265 + - [x] Verification flows for Discord/Telegram/Signal 266 - [ ] Notification history view 267 Account Settings 268 - [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
+20 -14
frontend/vite.config.ts
··· 1 - import { defineConfig } from 'vite' 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', 16 } 17 } 18 })
··· 1 + import { defineConfig, loadEnv } from 'vite' 2 import { svelte } from '@sveltejs/vite-plugin-svelte' 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 + } 22 } 23 } 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 pub mod account; 2 pub mod invite; 3 pub mod status; 4 5 pub use account::{ ··· 9 pub use invite::{ 10 disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes, 11 }; 12 pub use status::{get_subject_status, update_subject_status};
··· 1 pub mod account; 2 pub mod invite; 3 + pub mod server_stats; 4 pub mod status; 5 6 pub use account::{ ··· 10 pub use invite::{ 11 disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes, 12 }; 13 + pub use server_stats::get_server_stats; 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 let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 383 r#"INSERT INTO users ( 384 handle, email, did, password_hash, 385 - email_confirmation_code, email_confirmation_code_expires_at, 386 preferred_notification_channel, 387 discord_id, telegram_username, signal_number 388 - ) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#, 389 ) 390 .bind(short_handle) 391 .bind(&email) 392 .bind(&did) 393 .bind(&password_hash) 394 - .bind(&verification_code) 395 - .bind(code_expires_at) 396 .bind(verification_channel) 397 .bind( 398 input ··· 460 .into_response(); 461 } 462 }; 463 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 464 Ok(enc) => enc, 465 Err(e) => {
··· 382 let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 383 r#"INSERT INTO users ( 384 handle, email, did, password_hash, 385 preferred_notification_channel, 386 discord_id, telegram_username, signal_number 387 + ) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8) RETURNING id"#, 388 ) 389 .bind(short_handle) 390 .bind(&email) 391 .bind(&did) 392 .bind(&password_hash) 393 .bind(verification_channel) 394 .bind( 395 input ··· 457 .into_response(); 458 } 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 + } 477 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 478 Ok(enc) => enc, 479 Err(e) => {
+1
src/api/mod.rs
··· 13 pub mod server; 14 pub mod temp; 15 pub mod validation; 16 17 pub use error::ApiError; 18 pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
··· 13 pub mod server; 14 pub mod temp; 15 pub mod validation; 16 + pub mod verification; 17 18 pub use error::ApiError; 19 pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
+336 -54
src/api/notification_prefs.rs
··· 6 http::{HeaderMap, StatusCode}, 7 response::{IntoResponse, Response}, 8 }; 9 use serde::{Deserialize, Serialize}; 10 use serde_json::json; 11 use sqlx::Row; 12 use tracing::info; 13 14 #[derive(Serialize)] 15 #[serde(rename_all = "camelCase")] ··· 95 .into_response() 96 } 97 98 #[derive(Deserialize)] 99 #[serde(rename_all = "camelCase")] 100 pub struct UpdateNotificationPrefsInput { 101 pub preferred_channel: Option<String>, 102 pub discord_id: Option<String>, 103 pub telegram_username: Option<String>, 104 pub signal_number: Option<String>, 105 } 106 107 pub async fn update_notification_prefs( 108 State(state): State<AppState>, 109 headers: HeaderMap, ··· 129 .into_response(); 130 } 131 }; 132 if let Some(ref channel) = input.preferred_channel { 133 let valid_channels = ["email", "discord", "telegram", "signal"]; 134 if !valid_channels.contains(&channel.as_str()) { ··· 157 } 158 info!(did = %user.did, channel = %channel, "Updated preferred notification channel"); 159 } 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 - { 174 return ( 175 - StatusCode::INTERNAL_SERVER_ERROR, 176 - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), 177 ) 178 .into_response(); 179 } 180 - info!(did = %user.did, "Updated Discord ID"); 181 } 182 if let Some(ref telegram) = input.telegram_username { 183 - let telegram_clean: Option<&str> = if telegram.is_empty() { 184 - None 185 } 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(); 201 } 202 - info!(did = %user.did, "Updated Telegram username"); 203 } 204 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)})), 221 ) 222 - .into_response(); 223 } 224 - info!(did = %user.did, "Updated Signal number"); 225 } 226 - Json(json!({"success": true})).into_response() 227 }
··· 6 http::{HeaderMap, StatusCode}, 7 response::{IntoResponse, Response}, 8 }; 9 + use chrono::{Duration, Utc}; 10 + use rand::Rng; 11 use serde::{Deserialize, Serialize}; 12 use serde_json::json; 13 use sqlx::Row; 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 + } 23 24 #[derive(Serialize)] 25 #[serde(rename_all = "camelCase")] ··· 105 .into_response() 106 } 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 + 203 #[derive(Deserialize)] 204 #[serde(rename_all = "camelCase")] 205 pub struct UpdateNotificationPrefsInput { 206 pub preferred_channel: Option<String>, 207 + pub email: Option<String>, 208 pub discord_id: Option<String>, 209 pub telegram_username: Option<String>, 210 pub signal_number: Option<String>, 211 } 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 + 274 pub async fn update_notification_prefs( 275 State(state): State<AppState>, 276 headers: HeaderMap, ··· 296 .into_response(); 297 } 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 + 321 if let Some(ref channel) = input.preferred_channel { 322 let valid_channels = ["email", "discord", "telegram", "signal"]; 323 if !valid_channels.contains(&channel.as_str()) { ··· 346 } 347 info!(did = %user.did, channel = %channel, "Updated preferred notification channel"); 348 } 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) { 361 return ( 362 + StatusCode::BAD_REQUEST, 363 + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), 364 ) 365 .into_response(); 366 } 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 + } 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 + 434 if let Some(ref telegram) = input.telegram_username { 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"); 457 } else { 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"); 467 } 468 } 469 + 470 if let Some(ref signal) = input.signal_number { 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 488 ) 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"); 502 } 503 } 504 + 505 + Json(UpdateNotificationPrefsResponse { 506 + success: true, 507 + verification_required, 508 + }).into_response() 509 }
+171 -125
src/api/server/email.rs
··· 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; 9 - use chrono::{Duration, Utc}; 10 use serde::Deserialize; 11 use serde_json::json; 12 use tracing::{error, info, warn}; 13 - 14 - fn generate_confirmation_code() -> String { 15 - crate::util::generate_token_code() 16 - } 17 18 #[derive(Deserialize)] 19 #[serde(rename_all = "camelCase")] ··· 41 ) 42 .into_response(); 43 } 44 let token = match crate::auth::extract_bearer_token_from_header( 45 headers.get("Authorization").and_then(|h| h.to_str().ok()), 46 ) { ··· 53 .into_response(); 54 } 55 }; 56 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 57 let did = match auth_result { 58 Ok(user) => user.did, 59 Err(e) => return ApiError::from(e).into_response(), 60 }; 61 - let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) 62 .fetch_optional(&state.db) 63 .await 64 { ··· 71 .into_response(); 72 } 73 }; 74 let user_id = user.id; 75 let handle = user.handle; 76 let email = input.email.trim().to_lowercase(); 77 if !crate::api::validation::is_valid_email(&email) { 78 return ( 79 StatusCode::BAD_REQUEST, ··· 81 ) 82 .into_response(); 83 } 84 - let exists = sqlx::query!("SELECT 1 as one FROM users WHERE LOWER(email) = $1", email) 85 - .fetch_optional(&state.db) 86 - .await; 87 if let Ok(Some(_)) = exists { 88 return ( 89 StatusCode::BAD_REQUEST, ··· 91 ) 92 .into_response(); 93 } 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 102 ) 103 - .execute(&state.db) 104 - .await; 105 - if let Err(e) = update { 106 - error!("DB error setting email update code: {:?}", e); 107 return ( 108 StatusCode::INTERNAL_SERVER_ERROR, 109 Json(json!({"error": "InternalError"})), 110 ) 111 .into_response(); 112 } 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 - } 121 info!("Email update requested for user {}", user_id); 122 (StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response() 123 } ··· 149 ) 150 .into_response(); 151 } 152 let token = match crate::auth::extract_bearer_token_from_header( 153 headers.get("Authorization").and_then(|h| h.to_str().ok()), 154 ) { ··· 161 .into_response(); 162 } 163 }; 164 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 165 let did = match auth_result { 166 Ok(user) => user.did, 167 Err(e) => return ApiError::from(e).into_response(), 168 }; 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 172 ) 173 .fetch_optional(&state.db) 174 .await ··· 176 Ok(Some(row)) => row, 177 _ => { 178 return ( 179 - StatusCode::INTERNAL_SERVER_ERROR, 180 - Json(json!({"error": "InternalError"})), 181 ) 182 .into_response(); 183 } 184 }; 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; 189 let email = input.email.trim().to_lowercase(); 190 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 - }; 204 if pending_email != email { 205 return ( 206 StatusCode::BAD_REQUEST, ··· 208 ) 209 .into_response(); 210 } 211 - if saved_code != confirmation_code { 212 return ( 213 StatusCode::BAD_REQUEST, 214 Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 215 ) 216 .into_response(); 217 } 218 - if Utc::now() > expiry { 219 return ( 220 StatusCode::BAD_REQUEST, 221 Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 222 ) 223 .into_response(); 224 } 225 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", 227 pending_email, 228 user_id 229 ) 230 - .execute(&state.db) 231 .await; 232 if let Err(e) = update { 233 error!("DB error finalizing email update: {:?}", e); 234 if e.as_database_error() ··· 247 ) 248 .into_response(); 249 } 250 info!("Email updated for user {}", user_id); 251 (StatusCode::OK, Json(json!({}))).into_response() 252 } ··· 277 .into_response(); 278 } 279 }; 280 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 281 let did = match auth_result { 282 Ok(user) => user.did, 283 Err(e) => return ApiError::from(e).into_response(), 284 }; 285 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", 287 did 288 ) 289 .fetch_optional(&state.db) ··· 298 .into_response(); 299 } 300 }; 301 let user_id = user.id; 302 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 let new_email = input.email.trim().to_lowercase(); 307 if !crate::api::validation::is_valid_email(&new_email) { 308 return ( 309 StatusCode::BAD_REQUEST, ··· 311 ) 312 .into_response(); 313 } 314 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 { 320 let confirmation_token = match &input.token { 321 Some(t) => t.trim(), 322 None => { 323 return ( 324 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"})), 336 ) 337 .into_response(); 338 } 339 }; 340 if pending_email.to_lowercase() != new_email { 341 return ( 342 StatusCode::BAD_REQUEST, ··· 344 ) 345 .into_response(); 346 } 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 { 358 return ( 359 StatusCode::BAD_REQUEST, 360 Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 361 ) 362 .into_response(); 363 } 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 - } 372 } 373 let exists = sqlx::query!( 374 "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", 375 new_email, ··· 377 ) 378 .fetch_optional(&state.db) 379 .await; 380 if let Ok(Some(_)) = exists { 381 return ( 382 StatusCode::BAD_REQUEST, ··· 384 ) 385 .into_response(); 386 } 387 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 - "#, 397 new_email, 398 user_id 399 ) 400 - .execute(&state.db) 401 .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"})), 422 ) 423 - .into_response() 424 } 425 } 426 }
··· 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; 9 + use chrono::Utc; 10 use serde::Deserialize; 11 use serde_json::json; 12 use tracing::{error, info, warn}; 13 14 #[derive(Deserialize)] 15 #[serde(rename_all = "camelCase")] ··· 37 ) 38 .into_response(); 39 } 40 + 41 let token = match crate::auth::extract_bearer_token_from_header( 42 headers.get("Authorization").and_then(|h| h.to_str().ok()), 43 ) { ··· 50 .into_response(); 51 } 52 }; 53 + 54 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 55 let did = match auth_result { 56 Ok(user) => user.did, 57 Err(e) => return ApiError::from(e).into_response(), 58 }; 59 + 60 + let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did) 61 .fetch_optional(&state.db) 62 .await 63 { ··· 70 .into_response(); 71 } 72 }; 73 + 74 let user_id = user.id; 75 let handle = user.handle; 76 + let current_email = user.email; 77 let email = input.email.trim().to_lowercase(); 78 + 79 if !crate::api::validation::is_valid_email(&email) { 80 return ( 81 StatusCode::BAD_REQUEST, ··· 83 ) 84 .into_response(); 85 } 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 + 99 if let Ok(Some(_)) = exists { 100 return ( 101 StatusCode::BAD_REQUEST, ··· 103 ) 104 .into_response(); 105 } 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), 113 ) 114 + .await 115 + { 116 + error!("Failed to request email verification: {}", e); 117 return ( 118 StatusCode::INTERNAL_SERVER_ERROR, 119 Json(json!({"error": "InternalError"})), 120 ) 121 .into_response(); 122 } 123 + 124 info!("Email update requested for user {}", user_id); 125 (StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response() 126 } ··· 152 ) 153 .into_response(); 154 } 155 + 156 let token = match crate::auth::extract_bearer_token_from_header( 157 headers.get("Authorization").and_then(|h| h.to_str().ok()), 158 ) { ··· 165 .into_response(); 166 } 167 }; 168 + 169 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 170 let did = match auth_result { 171 Ok(user) => user.did, 172 Err(e) => return ApiError::from(e).into_response(), 173 }; 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 192 ) 193 .fetch_optional(&state.db) 194 .await ··· 196 Ok(Some(row)) => row, 197 _ => { 198 return ( 199 + StatusCode::BAD_REQUEST, 200 + Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), 201 ) 202 .into_response(); 203 } 204 }; 205 + 206 + let pending_email = verification.pending_identifier.unwrap_or_default(); 207 let email = input.email.trim().to_lowercase(); 208 let confirmation_code = input.token.trim(); 209 + 210 if pending_email != email { 211 return ( 212 StatusCode::BAD_REQUEST, ··· 214 ) 215 .into_response(); 216 } 217 + 218 + if verification.code != confirmation_code { 219 return ( 220 StatusCode::BAD_REQUEST, 221 Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 222 ) 223 .into_response(); 224 } 225 + 226 + if Utc::now() > verification.expires_at { 227 return ( 228 StatusCode::BAD_REQUEST, 229 Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), 230 ) 231 .into_response(); 232 } 233 + 234 + let mut tx = match state.db.begin().await { 235 + Ok(tx) => tx, 236 + Err(_) => return ApiError::InternalError.into_response(), 237 + }; 238 + 239 let update = sqlx::query!( 240 + "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", 241 pending_email, 242 user_id 243 ) 244 + .execute(&mut *tx) 245 .await; 246 + 247 if let Err(e) = update { 248 error!("DB error finalizing email update: {:?}", e); 249 if e.as_database_error() ··· 262 ) 263 .into_response(); 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 + 281 info!("Email updated for user {}", user_id); 282 (StatusCode::OK, Json(json!({}))).into_response() 283 } ··· 308 .into_response(); 309 } 310 }; 311 + 312 let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; 313 let did = match auth_result { 314 Ok(user) => user.did, 315 Err(e) => return ApiError::from(e).into_response(), 316 }; 317 + 318 let user = match sqlx::query!( 319 + "SELECT id, email FROM users WHERE did = $1", 320 did 321 ) 322 .fetch_optional(&state.db) ··· 331 .into_response(); 332 } 333 }; 334 + 335 let user_id = user.id; 336 let current_email = user.email; 337 let new_email = input.email.trim().to_lowercase(); 338 + 339 if !crate::api::validation::is_valid_email(&new_email) { 340 return ( 341 StatusCode::BAD_REQUEST, ··· 343 ) 344 .into_response(); 345 } 346 + 347 if let Some(ref current) = current_email 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 { 362 let confirmation_token = match &input.token { 363 Some(t) => t.trim(), 364 None => { 365 return ( 366 StatusCode::BAD_REQUEST, 367 + Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})), 368 ) 369 .into_response(); 370 } 371 }; 372 + 373 + let pending_email = ver.pending_identifier.unwrap_or_default(); 374 if pending_email.to_lowercase() != new_email { 375 return ( 376 StatusCode::BAD_REQUEST, ··· 378 ) 379 .into_response(); 380 } 381 + 382 + if ver.code != confirmation_token { 383 return ( 384 StatusCode::BAD_REQUEST, 385 Json(json!({"error": "InvalidToken", "message": "Invalid token"})), 386 ) 387 .into_response(); 388 } 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 + } 397 } 398 + 399 let exists = sqlx::query!( 400 "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", 401 new_email, ··· 403 ) 404 .fetch_optional(&state.db) 405 .await; 406 + 407 if let Ok(Some(_)) = exists { 408 return ( 409 StatusCode::BAD_REQUEST, ··· 411 ) 412 .into_response(); 413 } 414 + 415 + let mut tx = match state.db.begin().await { 416 + Ok(tx) => tx, 417 + Err(_) => return ApiError::InternalError.into_response(), 418 + }; 419 + 420 let update = sqlx::query!( 421 + "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", 422 new_email, 423 user_id 424 ) 425 + .execute(&mut *tx) 426 .await; 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"})), 437 ) 438 + .into_response(); 439 } 440 + return ( 441 + StatusCode::INTERNAL_SERVER_ERROR, 442 + Json(json!({"error": "InternalError"})), 443 + ) 444 + .into_response(); 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() 472 }
+46 -17
src/api/server/session.rs
··· 475 let row = match sqlx::query!( 476 r#"SELECT 477 u.id, u.did, u.handle, u.email, 478 - u.email_confirmation_code, 479 - u.email_confirmation_code_expires_at, 480 u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 481 k.key_bytes, k.encryption_version 482 FROM users u ··· 497 return ApiError::InternalError.into_response(); 498 } 499 }; 500 - let stored_code = match &row.email_confirmation_code { 501 - Some(code) => code, 502 - None => { 503 warn!("No verification code found for user: {}", input.did); 504 return ApiError::InvalidRequest("No pending verification".into()).into_response(); 505 } 506 }; 507 - if stored_code != &input.verification_code { 508 warn!("Invalid verification code for user: {}", input.did); 509 return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); 510 } 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 - } 517 let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 518 Ok(k) => k, 519 Err(e) => { ··· 528 crate::notifications::NotificationChannel::Signal => "signal_verified", 529 }; 530 let update_query = format!( 531 - "UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1", 532 verified_column 533 ); 534 if let Err(e) = sqlx::query(&update_query) ··· 539 error!("Failed to update verification status: {:?}", e); 540 return ApiError::InternalError.into_response(); 541 } 542 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 543 Ok(m) => m, 544 Err(e) => { ··· 634 } 635 let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000); 636 let code_expires_at = Utc::now() + chrono::Duration::minutes(30); 637 if let Err(e) = sqlx::query!( 638 - "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3", 639 verification_code, 640 - code_expires_at, 641 - input.did 642 ) 643 .execute(&state.db) 644 .await ··· 648 } 649 let (channel_str, recipient) = match row.channel { 650 crate::notifications::NotificationChannel::Email => { 651 - ("email", row.email.clone().unwrap_or_default()) 652 } 653 crate::notifications::NotificationChannel::Discord => { 654 ("discord", row.discord_id.unwrap_or_default())
··· 475 let row = match sqlx::query!( 476 r#"SELECT 477 u.id, u.did, u.handle, u.email, 478 u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", 479 k.key_bytes, k.encryption_version 480 FROM users u ··· 495 return ApiError::InternalError.into_response(); 496 } 497 }; 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) => { 508 warn!("No verification code found for user: {}", input.did); 509 return ApiError::InvalidRequest("No pending verification".into()).into_response(); 510 } 511 + Err(e) => { 512 + error!("Database error fetching verification: {:?}", e); 513 + return ApiError::InternalError.into_response(); 514 + } 515 }; 516 + 517 + if verification.code != input.verification_code { 518 warn!("Invalid verification code for user: {}", input.did); 519 return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); 520 } 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 + 527 let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 528 Ok(k) => k, 529 Err(e) => { ··· 538 crate::notifications::NotificationChannel::Signal => "signal_verified", 539 }; 540 let update_query = format!( 541 + "UPDATE users SET {} = TRUE WHERE did = $1", 542 verified_column 543 ); 544 if let Err(e) = sqlx::query(&update_query) ··· 549 error!("Failed to update verification status: {:?}", e); 550 return ApiError::InternalError.into_response(); 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 + 562 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 563 Ok(m) => m, 564 Err(e) => { ··· 654 } 655 let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000); 656 let code_expires_at = Utc::now() + chrono::Duration::minutes(30); 657 + 658 + let email = row.email.clone(); 659 + 660 if let Err(e) = sqlx::query!( 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, 668 verification_code, 669 + email, 670 + code_expires_at 671 ) 672 .execute(&state.db) 673 .await ··· 677 } 678 let (channel_str, recipient) = match row.channel { 679 crate::notifications::NotificationChannel::Email => { 680 + ("email", row.email.unwrap_or_default()) 681 } 682 crate::notifications::NotificationChannel::Discord => { 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 get(api::admin::get_invite_codes), 274 ) 275 .route( 276 "/xrpc/com.atproto.admin.disableAccountInvites", 277 post(api::admin::disable_account_invites), 278 ) ··· 387 .route( 388 "/xrpc/com.bspds.account.updateNotificationPrefs", 389 post(api::notification_prefs::update_notification_prefs), 390 ) 391 .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) 392 .layer(middleware::from_fn(metrics::metrics_middleware))
··· 273 get(api::admin::get_invite_codes), 274 ) 275 .route( 276 + "/xrpc/com.bspds.admin.getServerStats", 277 + get(api::admin::get_server_stats), 278 + ) 279 + .route( 280 "/xrpc/com.atproto.admin.disableAccountInvites", 281 post(api::admin::disable_account_invites), 282 ) ··· 391 .route( 392 "/xrpc/com.bspds.account.updateNotificationPrefs", 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), 402 ) 403 .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) 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 SERVER_URL.get_or_init(|| { 80 let (tx, rx) = std::sync::mpsc::channel(); 81 std::thread::spawn(move || { 82 if std::env::var("DOCKER_HOST").is_err() { 83 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { 84 let podman_sock = std::path::Path::new(&runtime_dir).join("podman/podman.sock"); ··· 406 .await 407 .expect("Failed to connect to test database"); 408 let verification_code: String = sqlx::query_scalar!( 409 - "SELECT email_confirmation_code FROM users WHERE did = $1", 410 did 411 ) 412 .fetch_one(&pool) 413 .await 414 - .expect("Failed to get verification code") 415 - .expect("No verification code found"); 416 let confirm_payload = json!({ 417 "did": did, 418 "verificationCode": verification_code ··· 548 .await 549 .expect("Failed to connect to test database"); 550 let verification_code: String = sqlx::query_scalar!( 551 - "SELECT email_confirmation_code FROM users WHERE did = $1", 552 &did 553 ) 554 .fetch_one(&pool) 555 .await 556 - .expect("Failed to get verification code") 557 - .expect("No verification code found"); 558 let confirm_payload = json!({ 559 "did": did, 560 "verificationCode": verification_code
··· 79 SERVER_URL.get_or_init(|| { 80 let (tx, rx) = std::sync::mpsc::channel(); 81 std::thread::spawn(move || { 82 + unsafe { 83 + std::env::set_var("BSPDS_ALLOW_INSECURE_SECRETS", "1"); 84 + } 85 if std::env::var("DOCKER_HOST").is_err() { 86 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { 87 let podman_sock = std::path::Path::new(&runtime_dir).join("podman/podman.sock"); ··· 409 .await 410 .expect("Failed to connect to test database"); 411 let verification_code: String = sqlx::query_scalar!( 412 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 413 did 414 ) 415 .fetch_one(&pool) 416 .await 417 + .expect("Failed to get verification code"); 418 + 419 let confirm_payload = json!({ 420 "did": did, 421 "verificationCode": verification_code ··· 551 .await 552 .expect("Failed to connect to test database"); 553 let verification_code: String = sqlx::query_scalar!( 554 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 555 &did 556 ) 557 .fetch_one(&pool) 558 .await 559 + .expect("Failed to get verification code"); 560 + 561 let confirm_payload = json!({ 562 "did": did, 563 "verificationCode": verification_code
+34 -19
tests/email_update.rs
··· 59 assert_eq!(res.status(), StatusCode::OK); 60 let body: Value = res.json().await.expect("Invalid JSON"); 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", 64 handle 65 ) 66 .fetch_one(&pool) 67 .await 68 - .expect("User not found"); 69 assert_eq!( 70 - user.email_pending_verification.as_deref(), 71 Some(new_email.as_str()) 72 ); 73 - assert!(user.email_confirmation_code.is_some()); 74 - let code = user.email_confirmation_code.unwrap(); 75 let res = client 76 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 77 .bearer_auth(&access_jwt) ··· 84 .expect("Failed to confirm email"); 85 assert_eq!(res.status(), StatusCode::OK); 86 let user = sqlx::query!( 87 - "SELECT email, email_pending_verification, email_confirmation_code FROM users WHERE handle = $1", 88 handle 89 ) 90 .fetch_one(&pool) 91 .await 92 .expect("User not found"); 93 assert_eq!(user.email, Some(new_email)); 94 - assert!(user.email_pending_verification.is_none()); 95 - assert!(user.email_confirmation_code.is_none()); 96 } 97 98 #[tokio::test] ··· 174 .await 175 .expect("Failed to request email update"); 176 assert_eq!(res.status(), StatusCode::OK); 177 - let user = sqlx::query!( 178 - "SELECT email_confirmation_code FROM users WHERE handle = $1", 179 handle 180 ) 181 .fetch_one(&pool) 182 .await 183 - .expect("User not found"); 184 - let code = user.email_confirmation_code.unwrap(); 185 let res = client 186 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 187 .bearer_auth(&access_jwt) ··· 293 .await 294 .expect("Failed to request email update"); 295 assert_eq!(res.status(), StatusCode::OK); 296 - let user = sqlx::query!( 297 - "SELECT email_confirmation_code FROM users WHERE handle = $1", 298 handle 299 ) 300 .fetch_one(&pool) 301 .await 302 - .expect("User not found"); 303 - let code = user.email_confirmation_code.unwrap(); 304 let res = client 305 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 306 .bearer_auth(&access_jwt) ··· 313 .expect("Failed to update email"); 314 assert_eq!(res.status(), StatusCode::OK); 315 let user = sqlx::query!( 316 - "SELECT email, email_pending_verification FROM users WHERE handle = $1", 317 handle 318 ) 319 .fetch_one(&pool) 320 .await 321 .expect("User not found"); 322 assert_eq!(user.email, Some(new_email)); 323 - assert!(user.email_pending_verification.is_none()); 324 } 325 326 #[tokio::test]
··· 59 assert_eq!(res.status(), StatusCode::OK); 60 let body: Value = res.json().await.expect("Invalid JSON"); 61 assert_eq!(body["tokenRequired"], true); 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'", 65 handle 66 ) 67 .fetch_one(&pool) 68 .await 69 + .expect("Verification not found"); 70 + 71 assert_eq!( 72 + verification.pending_identifier.as_deref(), 73 Some(new_email.as_str()) 74 ); 75 + let code = verification.code; 76 let res = client 77 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 78 .bearer_auth(&access_jwt) ··· 85 .expect("Failed to confirm email"); 86 assert_eq!(res.status(), StatusCode::OK); 87 let user = sqlx::query!( 88 + "SELECT email FROM users WHERE handle = $1", 89 handle 90 ) 91 .fetch_one(&pool) 92 .await 93 .expect("User not found"); 94 assert_eq!(user.email, Some(new_email)); 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()); 104 } 105 106 #[tokio::test] ··· 182 .await 183 .expect("Failed to request email update"); 184 assert_eq!(res.status(), StatusCode::OK); 185 + let verification = sqlx::query!( 186 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 187 handle 188 ) 189 .fetch_one(&pool) 190 .await 191 + .expect("Verification not found"); 192 + let code = verification.code; 193 let res = client 194 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) 195 .bearer_auth(&access_jwt) ··· 301 .await 302 .expect("Failed to request email update"); 303 assert_eq!(res.status(), StatusCode::OK); 304 + let verification = sqlx::query!( 305 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 306 handle 307 ) 308 .fetch_one(&pool) 309 .await 310 + .expect("Verification not found"); 311 + let code = verification.code; 312 let res = client 313 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 314 .bearer_auth(&access_jwt) ··· 321 .expect("Failed to update email"); 322 assert_eq!(res.status(), StatusCode::OK); 323 let user = sqlx::query!( 324 + "SELECT email FROM users WHERE handle = $1", 325 handle 326 ) 327 .fetch_one(&pool) 328 .await 329 .expect("User not found"); 330 assert_eq!(user.email, Some(new_email)); 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()); 339 } 340 341 #[tokio::test]
+2 -3
tests/jwt_security.rs
··· 872 .await 873 .expect("Failed to connect to test database"); 874 let verification_code: String = sqlx::query_scalar!( 875 - "SELECT email_confirmation_code FROM users WHERE did = $1", 876 did 877 ) 878 .fetch_one(&pool) 879 .await 880 - .expect("Failed to get verification code") 881 - .expect("No verification code found"); 882 let confirm_res = http_client 883 .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url)) 884 .json(&json!({
··· 872 .await 873 .expect("Failed to connect to test database"); 874 let verification_code: String = sqlx::query_scalar!( 875 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 876 did 877 ) 878 .fetch_one(&pool) 879 .await 880 + .expect("Failed to get verification code"); 881 let confirm_res = http_client 882 .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url)) 883 .json(&json!({