diff --git a/.sqlx/query-084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076.json b/.sqlx/query-084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076.json new file mode 100644 index 0000000..63cbeec --- /dev/null +++ b/.sqlx/query-084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM records", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076" +} diff --git a/.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json b/.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json new file mode 100644 index 0000000..f98e023 --- /dev/null +++ b/.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "notification_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + }, + "Text", + "Text", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac" +} diff --git a/.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json b/.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json new file mode 100644 index 0000000..e27197a --- /dev/null +++ b/.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8" +} diff --git a/.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json b/.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json new file mode 100644 index 0000000..aaaa50b --- /dev/null +++ b/.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32" +} diff --git a/.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json b/.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json index 866b7e7..5d80a15 100644 --- a/.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json +++ b/.sqlx/query-303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6.json @@ -37,7 +37,8 @@ "account_deletion", "admin_email", "plc_operation", - "two_factor_code" + "two_factor_code", + "channel_verification" ] } } diff --git a/.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json b/.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json new file mode 100644 index 0000000..bdf5ff6 --- /dev/null +++ b/.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5" +} diff --git a/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json b/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json new file mode 100644 index 0000000..53ad312 --- /dev/null +++ b/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20" +} diff --git a/.sqlx/query-4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190.json b/.sqlx/query-4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190.json deleted file mode 100644 index 833572e..0000000 --- a/.sqlx/query-4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Timestamptz", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "4b9243c9ef4bf260d179a778536e815c8d563017ecda7dc530aeeebd5362d190" -} diff --git a/.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json b/.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json new file mode 100644 index 0000000..10670b8 --- /dev/null +++ b/.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::notification_channel\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "pending_identifier", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "notification_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + ] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26" +} diff --git a/.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json b/.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json new file mode 100644 index 0000000..c36e63f --- /dev/null +++ b/.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json @@ -0,0 +1,93 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n notification_type as \"notification_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM notification_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT 50\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "channel: String", + "type_info": { + "Custom": { + "name": "notification_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "notification_type: String", + "type_info": { + "Custom": { + "name": "notification_type", + "kind": { + "Enum": [ + "welcome", + "email_verification", + "password_reset", + "email_update", + "account_deletion", + "admin_email", + "plc_operation", + "two_factor_code", + "channel_verification" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "status: String", + "type_info": { + "Custom": { + "name": "notification_status", + "kind": { + "Enum": [ + "pending", + "processing", + "sent", + "failed" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "subject", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "body", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false + ] + }, + "hash": "4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a" +} diff --git a/.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json b/.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json index f8bf0e5..ed46759 100644 --- a/.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json +++ b/.sqlx/query-5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf.json @@ -37,7 +37,8 @@ "account_deletion", "admin_email", "plc_operation", - "two_factor_code" + "two_factor_code", + "channel_verification" ] } } diff --git a/.sqlx/query-62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e.json b/.sqlx/query-62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e.json new file mode 100644 index 0000000..8c54fed --- /dev/null +++ b/.sqlx/query-62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, handle, email FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e" +} diff --git a/.sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json b/.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json similarity index 75% rename from .sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json rename to .sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json index e717a04..344cdc0 100644 --- a/.sqlx/query-f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693.json +++ b/.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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", + "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", "describe": { "columns": [ { @@ -36,5 +36,5 @@ true ] }, - "hash": "f4f4b6a9e5d2345efa8e48380f66c819c1818030aa4bf26757d9fb40e654b693" + "hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761" } diff --git a/.sqlx/query-6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08.json b/.sqlx/query-6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08.json new file mode 100644 index 0000000..407dffd --- /dev/null +++ b/.sqlx/query-6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08" +} diff --git a/.sqlx/query-76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057.json b/.sqlx/query-76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057.json deleted file mode 100644 index bfe89cf..0000000 --- a/.sqlx/query-76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "76a239da5103f43b16a768d6970cc7e04d9d27c88cc54072818033a03bf53057" -} diff --git a/.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json b/.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json new file mode 100644 index 0000000..6d7e2e1 --- /dev/null +++ b/.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1" +} diff --git a/.sqlx/query-8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19.json b/.sqlx/query-8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19.json deleted file mode 100644 index 48e93f0..0000000 --- a/.sqlx/query-8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT 1 as one FROM users WHERE LOWER(email) = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "one", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "8c9c899187a8b19747b1c25dbac1501de14985beafcfed5f0a23549e18da2c19" -} diff --git a/.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json b/.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json new file mode 100644 index 0000000..11ac47d --- /dev/null +++ b/.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "notification_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849" +} diff --git a/.sqlx/query-91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e.json b/.sqlx/query-91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e.json new file mode 100644 index 0000000..275d8d1 --- /dev/null +++ b/.sqlx/query-91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "coalesce", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e" +} diff --git a/.sqlx/query-96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4.json b/.sqlx/query-96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4.json new file mode 100644 index 0000000..5f4660a --- /dev/null +++ b/.sqlx/query-96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM repos", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4" +} diff --git a/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json b/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json new file mode 100644 index 0000000..2ae0005 --- /dev/null +++ b/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5" +} diff --git a/.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json b/.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json new file mode 100644 index 0000000..5b1d026 --- /dev/null +++ b/.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161" +} diff --git a/.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json b/.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json new file mode 100644 index 0000000..84c9334 --- /dev/null +++ b/.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::notification_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "notification_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + }, + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546" +} diff --git a/.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json b/.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json new file mode 100644 index 0000000..d5fe56b --- /dev/null +++ b/.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "pending_identifier", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3" +} diff --git a/.sqlx/query-a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb.json b/.sqlx/query-a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb.json new file mode 100644 index 0000000..f7e8648 --- /dev/null +++ b/.sqlx/query-a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb" +} diff --git a/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json b/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json deleted file mode 100644 index e176d54..0000000 --- a/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Timestamptz", - "Text" - ] - }, - "nullable": [] - }, - "hash": "a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1" -} diff --git a/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json b/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json deleted file mode 100644 index 31e7026..0000000 --- a/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "email_confirmation_code", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "email_confirmation_code_expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "email_pending_verification", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - true, - true, - true, - true - ] - }, - "hash": "a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a" -} diff --git a/.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json b/.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json new file mode 100644 index 0000000..c82d660 --- /dev/null +++ b/.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2" +} diff --git a/.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json b/.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json deleted file mode 100644 index 5c9bf0b..0000000 --- a/.sqlx/query-b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "email_confirmation_code", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "email_confirmation_code_expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "email_pending_verification", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - true, - true, - true - ] - }, - "hash": "b551a83dbe436c1d0e4ce674f23668a9d5ef7ac5b76a332a8f8b5dc2220e9ea5" -} diff --git a/.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json b/.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json new file mode 100644 index 0000000..e6ec9a2 --- /dev/null +++ b/.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d" +} diff --git a/.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json b/.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json index c004720..dde296d 100644 --- a/.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json +++ b/.sqlx/query-cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de.json @@ -45,7 +45,8 @@ "account_deletion", "admin_email", "plc_operation", - "two_factor_code" + "two_factor_code", + "channel_verification" ] } } diff --git a/.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json b/.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json new file mode 100644 index 0000000..fdc3811 --- /dev/null +++ b/.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, email FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca" +} diff --git a/.sqlx/query-db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c.json b/.sqlx/query-db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c.json new file mode 100644 index 0000000..c4eaf53 --- /dev/null +++ b/.sqlx/query-db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "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", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c" +} diff --git a/.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json b/.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json new file mode 100644 index 0000000..c958e8d --- /dev/null +++ b/.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM users", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d" +} diff --git a/.sqlx/query-257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b.json b/.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json similarity index 65% rename from .sqlx/query-257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b.json rename to .sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json index 36c4b7a..05577b6 100644 --- a/.sqlx/query-257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b.json +++ b/.sqlx/query-dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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", + "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", "describe": { "columns": [ { @@ -25,16 +25,6 @@ }, { "ordinal": 4, - "name": "email_confirmation_code", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "email_confirmation_code_expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, "name": "channel: crate::notifications::NotificationChannel", "type_info": { "Custom": { @@ -51,12 +41,12 @@ } }, { - "ordinal": 7, + "ordinal": 5, "name": "key_bytes", "type_info": "Bytea" }, { - "ordinal": 8, + "ordinal": 6, "name": "encryption_version", "type_info": "Int4" } @@ -71,12 +61,10 @@ false, false, true, - true, - true, false, false, true ] }, - "hash": "257aa21927a280477b9b59ad726a67a587a0164a38665594d4925b11bf2b260b" + "hash": "dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3" } diff --git a/.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json b/.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json new file mode 100644 index 0000000..02204fe --- /dev/null +++ b/.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37" +} diff --git a/.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json b/.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json new file mode 100644 index 0000000..c0b5eda --- /dev/null +++ b/.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839" +} diff --git a/.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json b/.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json new file mode 100644 index 0000000..76674da --- /dev/null +++ b/.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3" +} diff --git a/.sqlx/query-fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580.json b/.sqlx/query-fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580.json deleted file mode 100644 index 1df2336..0000000 --- a/.sqlx/query-fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "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 ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "fb7eb6dcbe91352f2d154c384a93c6c55a91f735832618b464208b70d6a8f580" -} diff --git a/TODO.md b/TODO.md index 3b7ac9a..aa92771 100644 --- a/TODO.md +++ b/TODO.md @@ -244,10 +244,10 @@ Absolutely subject to change, "bspds" isn't even the real name of this pds thus Anyway... endpoints for PDS settings not covered by standard ATProto: - [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels - [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel -- [ ] `com.bspds.account.getNotificationHistory` - list past notifications -- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal -- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code -- [ ] `com.bspds.admin.getServerStats` - user count, storage usage, etc. +- [x] `com.bspds.account.getNotificationHistory` - list past notifications +- [x] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal +- [x] `com.bspds.account.confirmChannelVerification` - confirm with code +- [x] `com.bspds.admin.getServerStats` - user count, storage usage, etc. ### Frontend Views Uses existing ATProto endpoints where possible: Authentication @@ -262,7 +262,7 @@ User Dashboard - [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) Notification Preferences - [x] Channel selector (uses `com.bspds.account.*` endpoints above) -- [ ] Verification flows for Discord/Telegram/Signal +- [x] Verification flows for Discord/Telegram/Signal - [ ] Notification history view Account Settings - [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 36fc8bf..adb52df 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,18 +1,24 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte' -export default defineConfig({ - plugins: [svelte()], - build: { - outDir: 'dist', - }, - server: { - port: 5173, - proxy: { - '/xrpc': 'http://localhost:3000', - '/oauth': 'http://localhost:3000', - '/.well-known': 'http://localhost:3000', - '/health': 'http://localhost:3000', - '/u': 'http://localhost:3000', + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const target = env.VITE_API_URL || 'http://localhost:3000' + + return { + plugins: [svelte()], + build: { + outDir: 'dist', + }, + server: { + port: 5173, + proxy: { + '/xrpc': target, + '/oauth': target, + '/.well-known': target, + '/health': target, + '/u': target, + } } } }) diff --git a/migrations/20251216_add_channel_verification.sql b/migrations/20251216_add_channel_verification.sql new file mode 100644 index 0000000..eb232df --- /dev/null +++ b/migrations/20251216_add_channel_verification.sql @@ -0,0 +1,12 @@ +ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'channel_verification'; + +CREATE TABLE IF NOT EXISTS channel_verifications ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel notification_channel NOT NULL, + code TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, channel) +); + +CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at); diff --git a/migrations/20251217_migrate_email_to_channel_verifications.sql b/migrations/20251217_migrate_email_to_channel_verifications.sql new file mode 100644 index 0000000..106c0c0 --- /dev/null +++ b/migrations/20251217_migrate_email_to_channel_verifications.sql @@ -0,0 +1,11 @@ +ALTER TABLE channel_verifications ADD COLUMN pending_identifier TEXT; + +INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) +SELECT id, 'email', email_confirmation_code, email_pending_verification, email_confirmation_code_expires_at +FROM users +WHERE email_confirmation_code IS NOT NULL AND email_confirmation_code_expires_at IS NOT NULL; + +ALTER TABLE users +DROP COLUMN email_confirmation_code, +DROP COLUMN email_confirmation_code_expires_at, +DROP COLUMN email_pending_verification; diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index 91f0cd2..268f1ba 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -1,5 +1,6 @@ pub mod account; pub mod invite; +pub mod server_stats; pub mod status; pub use account::{ @@ -9,4 +10,5 @@ pub use account::{ pub use invite::{ disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes, }; +pub use server_stats::get_server_stats; pub use status::{get_subject_status, update_subject_status}; diff --git a/src/api/admin/server_stats.rs b/src/api/admin/server_stats.rs new file mode 100644 index 0000000..6a24f31 --- /dev/null +++ b/src/api/admin/server_stats.rs @@ -0,0 +1,76 @@ +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use serde_json::json; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerStatsResponse { + pub user_count: i64, + pub repo_count: i64, + pub record_count: i64, + pub blob_storage_bytes: i64, +} + +pub async fn get_server_stats( + State(state): State, + headers: HeaderMap, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let user_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM users") + .fetch_one(&state.db) + .await + { + Ok(Some(count)) => count, + Ok(None) => 0, + Err(_) => 0, + }; + + let repo_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM repos") + .fetch_one(&state.db) + .await + { + Ok(Some(count)) => count, + Ok(None) => 0, + Err(_) => 0, + }; + + let record_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM records") + .fetch_one(&state.db) + .await + { + Ok(Some(count)) => count, + Ok(None) => 0, + Err(_) => 0, + }; + + let blob_storage_bytes: i64 = match sqlx::query_scalar!("SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs") + .fetch_one(&state.db) + .await + { + Ok(Some(bytes)) => bytes, + Ok(None) => 0, + Err(_) => 0, + }; + + Json(ServerStatsResponse { + user_count, + repo_count, + record_count, + blob_storage_bytes, + }) + .into_response() +} diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 81cb16e..b9513e1 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -382,17 +382,14 @@ pub async fn create_account( let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( r#"INSERT INTO users ( handle, email, did, password_hash, - email_confirmation_code, email_confirmation_code_expires_at, preferred_notification_channel, discord_id, telegram_username, signal_number - ) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#, + ) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8) RETURNING id"#, ) .bind(short_handle) .bind(&email) .bind(&did) .bind(&password_hash) - .bind(&verification_code) - .bind(code_expires_at) .bind(verification_channel) .bind( input @@ -460,6 +457,23 @@ pub async fn create_account( .into_response(); } }; + + if let Err(e) = sqlx::query!( + "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", + user_id, + verification_code, + email, + code_expires_at + ) + .execute(&mut *tx) + .await { + error!("Error inserting verification code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { Ok(enc) => enc, Err(e) => { diff --git a/src/api/mod.rs b/src/api/mod.rs index 65e04ca..190d7a6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -13,6 +13,7 @@ pub mod repo; pub mod server; pub mod temp; pub mod validation; +pub mod verification; pub use error::ApiError; pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit}; diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs index 412e55b..2b40627 100644 --- a/src/api/notification_prefs.rs +++ b/src/api/notification_prefs.rs @@ -6,11 +6,21 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; +use chrono::{Duration, Utc}; +use rand::Rng; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::Row; use tracing::info; +fn generate_verification_code() -> String { + rand::thread_rng() + .sample_iter(&rand::distributions::Uniform::new(0, 10)) + .take(6) + .map(|x| x.to_string()) + .collect() +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct NotificationPrefsResponse { @@ -95,15 +105,172 @@ pub async fn get_notification_prefs(State(state): State, headers: Head .into_response() } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationHistoryEntry { + pub created_at: String, + pub channel: String, + pub notification_type: String, + pub status: String, + pub subject: Option, + pub body: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetNotificationHistoryResponse { + pub notifications: Vec, +} + +pub async fn get_notification_history( + State(state): State, + headers: HeaderMap, +) -> Response { + let token = match crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), + ) + .into_response(), + }; + let user = match validate_bearer_token(&state.db, &token).await { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), + ) + .into_response(); + } + }; + + let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did) + .fetch_one(&state.db) + .await + { + Ok(id) => id, + Err(e) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(), + }; + + let rows = match sqlx::query!( + r#" + SELECT + created_at, + channel as "channel: String", + notification_type as "notification_type: String", + status as "status: String", + subject, + body + FROM notification_queue + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 50 + "#, + user_id + ) + .fetch_all(&state.db) + .await + { + Ok(r) => r, + Err(e) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(), + }; + + let notifications = rows.iter().map(|row| { + NotificationHistoryEntry { + created_at: row.created_at.to_rfc3339(), + channel: row.channel.clone(), + notification_type: row.notification_type.clone(), + status: row.status.clone(), + subject: row.subject.clone(), + body: row.body.clone(), + } + }).collect(); + + Json(GetNotificationHistoryResponse { notifications }).into_response() +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateNotificationPrefsInput { pub preferred_channel: Option, + pub email: Option, pub discord_id: Option, pub telegram_username: Option, pub signal_number: Option, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateNotificationPrefsResponse { + pub success: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub verification_required: Vec, +} + +pub async fn request_channel_verification( + db: &sqlx::PgPool, + user_id: uuid::Uuid, + channel: &str, + identifier: &str, + handle: Option<&str>, +) -> Result { + let code = generate_verification_code(); + let expires_at = Utc::now() + Duration::minutes(10); + + sqlx::query!( + r#" + INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) + VALUES ($1, $2::notification_channel, $3, $4, $5) + ON CONFLICT (user_id, channel) DO UPDATE + SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW() + "#, + user_id, + channel as _, + code, + identifier, + expires_at + ) + .execute(db) + .await + .map_err(|e| format!("Database error: {}", e))?; + + if channel == "email" { + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let handle_str = handle.unwrap_or("user"); + crate::notifications::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname) + .await + .map_err(|e| format!("Failed to enqueue email notification: {}", e))?; + } else { + sqlx::query!( + r#" + INSERT INTO notification_queue (user_id, channel, notification_type, recipient, subject, body, metadata) + VALUES ($1, $2::notification_channel, 'channel_verification', $3, 'Verify your channel', $4, $5) + "#, + user_id, + channel as _, + identifier, + format!("Your verification code is: {}", code), + json!({"code": code}) + ) + .execute(db) + .await + .map_err(|e| format!("Failed to enqueue notification: {}", e))?; + } + + Ok(code) +} + pub async fn update_notification_prefs( State(state): State, headers: HeaderMap, @@ -129,6 +296,28 @@ pub async fn update_notification_prefs( .into_response(); } }; + + let user_row = match sqlx::query!( + "SELECT id, handle, email FROM users WHERE did = $1", + user.did + ) + .fetch_one(&state.db) + .await + { + Ok(row) => row, + Err(e) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(), + }; + + let user_id = user_row.id; + let handle = user_row.handle; + let current_email = user_row.email; + + let mut verification_required: Vec = Vec::new(); + if let Some(ref channel) = input.preferred_channel { let valid_channels = ["email", "discord", "telegram", "signal"]; if !valid_channels.contains(&channel.as_str()) { @@ -157,71 +346,164 @@ pub async fn update_notification_prefs( } info!(did = %user.did, channel = %channel, "Updated preferred notification channel"); } + + if let Some(ref new_email) = input.email { + let email_clean = new_email.trim().to_lowercase(); + if email_clean.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Email cannot be empty"})), + ) + .into_response(); + } + + if !crate::api::validation::is_valid_email(&email_clean) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), + ) + .into_response(); + } + + if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) { + info!(did = %user.did, "Email unchanged, skipping"); + } else { + let exists = sqlx::query!( + "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", + email_clean, + user_id + ) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(_)) = exists { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "EmailTaken", "message": "Email already in use"})), + ) + .into_response(); + } + + if let Err(e) = request_channel_verification(&state.db, user_id, "email", &email_clean, Some(&handle)).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); + } + verification_required.push("email".to_string()); + info!(did = %user.did, "Requested email verification"); + } + } + if let Some(ref discord_id) = input.discord_id { - let discord_id_clean: Option<&str> = if discord_id.is_empty() { - None - } else { - Some(discord_id.as_str()) - }; - if let Err(e) = sqlx::query( - r#"UPDATE users SET discord_id = $1, discord_verified = FALSE, updated_at = NOW() WHERE did = $2"# - ) - .bind(discord_id_clean) - .bind(&user.did) - .execute(&state.db) - .await - { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + if discord_id.is_empty() { + if let Err(e) = sqlx::query!( + "UPDATE users SET discord_id = NULL, discord_verified = FALSE, updated_at = NOW() WHERE id = $1", + user_id ) - .into_response(); + .execute(&state.db) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(); + } + let _ = sqlx::query!( + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", + user_id + ) + .execute(&state.db) + .await; + info!(did = %user.did, "Cleared Discord ID"); + } else { + if let Err(e) = request_channel_verification(&state.db, user_id, "discord", discord_id, None).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); + } + verification_required.push("discord".to_string()); + info!(did = %user.did, "Requested Discord verification"); } - info!(did = %user.did, "Updated Discord ID"); } + if let Some(ref telegram) = input.telegram_username { - let telegram_clean: Option<&str> = if telegram.is_empty() { - None - } else { - Some(telegram.trim_start_matches('@')) - }; - if let Err(e) = sqlx::query( - r#"UPDATE users SET telegram_username = $1, telegram_verified = FALSE, updated_at = NOW() WHERE did = $2"# - ) - .bind(telegram_clean) - .bind(&user.did) - .execute(&state.db) - .await - { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + let telegram_clean = telegram.trim_start_matches('@'); + if telegram_clean.is_empty() { + if let Err(e) = sqlx::query!( + "UPDATE users SET telegram_username = NULL, telegram_verified = FALSE, updated_at = NOW() WHERE id = $1", + user_id ) - .into_response(); + .execute(&state.db) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(); + } + let _ = sqlx::query!( + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'", + user_id + ) + .execute(&state.db) + .await; + info!(did = %user.did, "Cleared Telegram username"); + } else { + if let Err(e) = request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); + } + verification_required.push("telegram".to_string()); + info!(did = %user.did, "Requested Telegram verification"); } - info!(did = %user.did, "Updated Telegram username"); } + if let Some(ref signal) = input.signal_number { - let signal_clean: Option<&str> = if signal.is_empty() { - None - } else { - Some(signal.as_str()) - }; - if let Err(e) = sqlx::query( - r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"# - ) - .bind(signal_clean) - .bind(&user.did) - .execute(&state.db) - .await - { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + if signal.is_empty() { + if let Err(e) = sqlx::query!( + "UPDATE users SET signal_number = NULL, signal_verified = FALSE, updated_at = NOW() WHERE id = $1", + user_id ) - .into_response(); + .execute(&state.db) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(); + } + let _ = sqlx::query!( + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'", + user_id + ) + .execute(&state.db) + .await; + info!(did = %user.did, "Cleared Signal number"); + } else { + if let Err(e) = request_channel_verification(&state.db, user_id, "signal", signal, None).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": e})), + ) + .into_response(); + } + verification_required.push("signal".to_string()); + info!(did = %user.did, "Requested Signal verification"); } - info!(did = %user.did, "Updated Signal number"); } - Json(json!({"success": true})).into_response() + + Json(UpdateNotificationPrefsResponse { + success: true, + verification_required, + }).into_response() } diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 70b9014..1cd9e4a 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -6,15 +6,11 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use chrono::{Duration, Utc}; +use chrono::Utc; use serde::Deserialize; use serde_json::json; use tracing::{error, info, warn}; -fn generate_confirmation_code() -> String { - crate::util::generate_token_code() -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RequestEmailUpdateInput { @@ -41,6 +37,7 @@ pub async fn request_email_update( ) .into_response(); } + let token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { @@ -53,12 +50,14 @@ pub async fn request_email_update( .into_response(); } }; + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; let did = match auth_result { Ok(user) => user.did, Err(e) => return ApiError::from(e).into_response(), }; - let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) + + let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await { @@ -71,9 +70,12 @@ pub async fn request_email_update( .into_response(); } }; + let user_id = user.id; let handle = user.handle; + let current_email = user.email; let email = input.email.trim().to_lowercase(); + if !crate::api::validation::is_valid_email(&email) { return ( StatusCode::BAD_REQUEST, @@ -81,9 +83,19 @@ pub async fn request_email_update( ) .into_response(); } - let exists = sqlx::query!("SELECT 1 as one FROM users WHERE LOWER(email) = $1", email) - .fetch_optional(&state.db) - .await; + + if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email.clone()) { + return (StatusCode::OK, Json(json!({ "tokenRequired": false }))).into_response(); + } + + let exists = sqlx::query!( + "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", + email, + user_id + ) + .fetch_optional(&state.db) + .await; + if let Ok(Some(_)) = exists { return ( StatusCode::BAD_REQUEST, @@ -91,33 +103,24 @@ pub async fn request_email_update( ) .into_response(); } - let code = generate_confirmation_code(); - let expires_at = Utc::now() + Duration::minutes(10); - let update = sqlx::query!( - "UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4", - email, - code, - expires_at, - user_id + + if let Err(e) = crate::api::notification_prefs::request_channel_verification( + &state.db, + user_id, + "email", + &email, + Some(&handle), ) - .execute(&state.db) - .await; - if let Err(e) = update { - error!("DB error setting email update code: {:?}", e); + .await + { + error!("Failed to request email verification: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), ) .into_response(); } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = crate::notifications::enqueue_email_update( - &state.db, user_id, &email, &handle, &code, &hostname, - ) - .await - { - warn!("Failed to enqueue email update notification: {:?}", e); - } + info!("Email update requested for user {}", user_id); (StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response() } @@ -149,6 +152,7 @@ pub async fn confirm_email( ) .into_response(); } + let token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { @@ -161,20 +165,19 @@ pub async fn confirm_email( .into_response(); } }; + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; let did = match auth_result { Ok(user) => user.did, Err(e) => return ApiError::from(e).into_response(), }; - let user = match sqlx::query!( - "SELECT id, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", - did - ) - .fetch_optional(&state.db) - .await + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_one(&state.db) + .await { - Ok(Some(row)) => row, - _ => { + Ok(id) => id, + Err(_) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), @@ -182,25 +185,28 @@ pub async fn confirm_email( .into_response(); } }; - let user_id = user.id; - let stored_code = user.email_confirmation_code; - let expires_at = user.email_confirmation_code_expires_at; - let email_pending_verification = user.email_pending_verification; - let email = input.email.trim().to_lowercase(); - let confirmation_code = input.token.trim(); - let (pending_email, saved_code, expiry) = - match (email_pending_verification, stored_code, expires_at) { - (Some(p), Some(c), Some(e)) => (p, c, e), - _ => { - return ( + + let verification = match sqlx::query!( + "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + user_id + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { + return ( StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidRequest", "message": "No pending email update found"}), - ), + Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), ) .into_response(); - } - }; + } + }; + + let pending_email = verification.pending_identifier.unwrap_or_default(); + let email = input.email.trim().to_lowercase(); + let confirmation_code = input.token.trim(); + if pending_email != email { return ( StatusCode::BAD_REQUEST, @@ -208,27 +214,36 @@ pub async fn confirm_email( ) .into_response(); } - if saved_code != confirmation_code { + + if verification.code != confirmation_code { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidToken", "message": "Invalid token"})), ) .into_response(); } - if Utc::now() > expiry { + + if Utc::now() > verification.expires_at { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), ) .into_response(); } + + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(_) => return ApiError::InternalError.into_response(), + }; + let update = sqlx::query!( - "UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2", + "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", pending_email, user_id ) - .execute(&state.db) + .execute(&mut *tx) .await; + if let Err(e) = update { error!("DB error finalizing email update: {:?}", e); if e.as_database_error() @@ -247,6 +262,22 @@ pub async fn confirm_email( ) .into_response(); } + + if let Err(e) = sqlx::query!( + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + user_id + ) + .execute(&mut *tx) + .await + { + error!("Failed to delete verification record: {:?}", e); + return ApiError::InternalError.into_response(); + } + + if let Err(_) = tx.commit().await { + return ApiError::InternalError.into_response(); + } + info!("Email updated for user {}", user_id); (StatusCode::OK, Json(json!({}))).into_response() } @@ -277,13 +308,15 @@ pub async fn update_email( .into_response(); } }; + let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; let did = match auth_result { Ok(user) => user.did, Err(e) => return ApiError::from(e).into_response(), }; + let user = match sqlx::query!( - "SELECT id, email, email_confirmation_code, email_confirmation_code_expires_at, email_pending_verification FROM users WHERE did = $1", + "SELECT id, email FROM users WHERE did = $1", did ) .fetch_optional(&state.db) @@ -298,12 +331,11 @@ pub async fn update_email( .into_response(); } }; + let user_id = user.id; let current_email = user.email; - let stored_code = user.email_confirmation_code; - let expires_at = user.email_confirmation_code_expires_at; - let email_pending_verification = user.email_pending_verification; let new_email = input.email.trim().to_lowercase(); + if !crate::api::validation::is_valid_email(&new_email) { return ( StatusCode::BAD_REQUEST, @@ -311,32 +343,34 @@ pub async fn update_email( ) .into_response(); } + if let Some(ref current) = current_email - && new_email == current.to_lowercase() { - return (StatusCode::OK, Json(json!({}))).into_response(); - } - let email_confirmed = stored_code.is_some() && email_pending_verification.is_some(); - if email_confirmed { + && new_email == current.to_lowercase() + { + return (StatusCode::OK, Json(json!({}))).into_response(); + } + + let verification = sqlx::query!( + "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + user_id + ) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + if let Some(ver) = verification { let confirmation_token = match &input.token { Some(t) => t.trim(), None => { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "TokenRequired", "message": "Token required for confirmed accounts. Call requestEmailUpdate first."})), - ) - .into_response(); - } - }; - let pending_email = match email_pending_verification { - Some(p) => p, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), + Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})), ) .into_response(); } }; + + let pending_email = ver.pending_identifier.unwrap_or_default(); if pending_email.to_lowercase() != new_email { return ( StatusCode::BAD_REQUEST, @@ -344,32 +378,24 @@ pub async fn update_email( ) .into_response(); } - let saved_code = match stored_code { - Some(c) => c, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), - ) - .into_response(); - } - }; - if saved_code != confirmation_token { + + if ver.code != confirmation_token { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidToken", "message": "Invalid token"})), ) .into_response(); } - if let Some(exp) = expires_at - && Utc::now() > exp { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), - ) - .into_response(); - } + + if Utc::now() > ver.expires_at { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), + ) + .into_response(); + } } + let exists = sqlx::query!( "SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2", new_email, @@ -377,6 +403,7 @@ pub async fn update_email( ) .fetch_optional(&state.db) .await; + if let Ok(Some(_)) = exists { return ( StatusCode::BAD_REQUEST, @@ -384,43 +411,62 @@ pub async fn update_email( ) .into_response(); } + + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(_) => return ApiError::InternalError.into_response(), + }; + let update = sqlx::query!( - r#" - UPDATE users - SET email = $1, - email_pending_verification = NULL, - email_confirmation_code = NULL, - email_confirmation_code_expires_at = NULL, - updated_at = NOW() - WHERE id = $2 - "#, + "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", new_email, user_id ) - .execute(&state.db) + .execute(&mut *tx) .await; - match update { - Ok(_) => { - info!("Email updated for user {}", user_id); - (StatusCode::OK, Json(json!({}))).into_response() - } - Err(e) => { - error!("DB error finalizing email update: {:?}", e); - if e.as_database_error() - .map(|db_err| db_err.is_unique_violation()) - .unwrap_or(false) - { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), - ) - .into_response(); - } - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + + if let Err(e) = update { + error!("DB error finalizing email update: {:?}", e); + if e.as_database_error() + .map(|db_err| db_err.is_unique_violation()) + .unwrap_or(false) + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Email already in use"})), ) - .into_response() + .into_response(); } + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); } + + let _ = sqlx::query!( + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + user_id + ) + .execute(&mut *tx) + .await; + + if let Err(_) = tx.commit().await { + return ApiError::InternalError.into_response(); + } + + match sqlx::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", + user_id, + json!(input.email_auth_factor.unwrap_or(false)) + ) + .execute(&state.db) + .await + { + Ok(_) => {} + Err(e) => warn!("Failed to update email_auth_factor preference: {}", e), + } + + info!("Email updated for user {}", user_id); + (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 190dc1a..e606df8 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -475,8 +475,6 @@ pub async fn confirm_signup( let row = match sqlx::query!( r#"SELECT u.id, u.did, u.handle, u.email, - u.email_confirmation_code, - u.email_confirmation_code_expires_at, u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", k.key_bytes, k.encryption_version FROM users u @@ -497,23 +495,35 @@ pub async fn confirm_signup( return ApiError::InternalError.into_response(); } }; - let stored_code = match &row.email_confirmation_code { - Some(code) => code, - None => { + + let verification = match sqlx::query!( + "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + row.id + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(v)) => v, + Ok(None) => { warn!("No verification code found for user: {}", input.did); return ApiError::InvalidRequest("No pending verification".into()).into_response(); } + Err(e) => { + error!("Database error fetching verification: {:?}", e); + return ApiError::InternalError.into_response(); + } }; - if stored_code != &input.verification_code { + + if verification.code != input.verification_code { warn!("Invalid verification code for user: {}", input.did); return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); } - if let Some(expires_at) = row.email_confirmation_code_expires_at - && expires_at < Utc::now() { - warn!("Verification code expired for user: {}", input.did); - return ApiError::ExpiredTokenMsg("Verification code has expired".into()) - .into_response(); - } + if verification.expires_at < Utc::now() { + warn!("Verification code expired for user: {}", input.did); + return ApiError::ExpiredTokenMsg("Verification code has expired".into()) + .into_response(); + } + let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { Ok(k) => k, Err(e) => { @@ -528,7 +538,7 @@ pub async fn confirm_signup( crate::notifications::NotificationChannel::Signal => "signal_verified", }; let update_query = format!( - "UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1", + "UPDATE users SET {} = TRUE WHERE did = $1", verified_column ); if let Err(e) = sqlx::query(&update_query) @@ -539,6 +549,16 @@ pub async fn confirm_signup( error!("Failed to update verification status: {:?}", e); return ApiError::InternalError.into_response(); } + + if let Err(e) = sqlx::query!( + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + row.id + ) + .execute(&state.db) + .await { + error!("Failed to delete verification record: {:?}", e); + } + let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, Err(e) => { @@ -634,11 +654,20 @@ pub async fn resend_verification( } let verification_code = format!("{:06}", rand::random::() % 1_000_000); let code_expires_at = Utc::now() + chrono::Duration::minutes(30); + + let email = row.email.clone(); + if let Err(e) = sqlx::query!( - "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3", + r#" + INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) + VALUES ($1, 'email', $2, $3, $4) + ON CONFLICT (user_id, channel) DO UPDATE + SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW() + "#, + row.id, verification_code, - code_expires_at, - input.did + email, + code_expires_at ) .execute(&state.db) .await @@ -648,7 +677,7 @@ pub async fn resend_verification( } let (channel_str, recipient) = match row.channel { crate::notifications::NotificationChannel::Email => { - ("email", row.email.clone().unwrap_or_default()) + ("email", row.email.unwrap_or_default()) } crate::notifications::NotificationChannel::Discord => { ("discord", row.discord_id.unwrap_or_default()) diff --git a/src/api/verification.rs b/src/api/verification.rs new file mode 100644 index 0000000..2bee2c9 --- /dev/null +++ b/src/api/verification.rs @@ -0,0 +1,191 @@ +use crate::auth::validate_bearer_token; +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use chrono::Utc; +use serde::Deserialize; +use serde_json::json; +use tracing::{error, info}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmChannelVerificationInput { + pub channel: String, + pub code: String, +} + +pub async fn confirm_channel_verification( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let token = match crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), + ) + .into_response(), + }; + let user = match validate_bearer_token(&state.db, &token).await { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), + ) + .into_response(); + } + }; + + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did) + .fetch_one(&state.db) + .await + { + Ok(id) => id, + Err(_) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "User not found"})), + ) + .into_response(), + }; + + let channel_str = input.channel.as_str(); + if !["email", "discord", "telegram", "signal"].contains(&channel_str) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Invalid channel"})), + ) + .into_response(); + } + + let record = match sqlx::query!( + r#" + SELECT code, pending_identifier, expires_at FROM channel_verifications + WHERE user_id = $1 AND channel = $2::notification_channel + "#, + user_id, + channel_str as _ + ) + .fetch_optional(&state.db) + .await { + Ok(Some(r)) => r, + Ok(None) => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "No pending verification found. Update notification preferences first."})), + ) + .into_response(), + Err(e) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(), + }; + + let pending_identifier = match record.pending_identifier { + Some(p) => p, + None => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})), + ) + .into_response(), + }; + + if record.expires_at < Utc::now() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "ExpiredToken", "message": "Verification code expired"})), + ) + .into_response(); + } + + if record.code != input.code { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidCode", "message": "Invalid verification code"})), + ) + .into_response(); + } + + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(_) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(), + }; + + let update_result = match channel_str { + "email" => sqlx::query!( + "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", + pending_identifier, + user_id + ).execute(&mut *tx).await, + "discord" => sqlx::query!( + "UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2", + pending_identifier, + user_id + ).execute(&mut *tx).await, + "telegram" => sqlx::query!( + "UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2", + pending_identifier, + user_id + ).execute(&mut *tx).await, + "signal" => sqlx::query!( + "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", + pending_identifier, + user_id + ).execute(&mut *tx).await, + _ => unreachable!(), + }; + + if let Err(e) = update_result { + error!("Failed to update user channel: {:?}", e); + if channel_str == "email" && e.as_database_error().map(|db| db.is_unique_violation()).unwrap_or(false) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "EmailTaken", "message": "Email already in use"})), + ) + .into_response(); + } + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to update channel"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!( + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel", + user_id, + channel_str as _ + ) + .execute(&mut *tx) + .await { + error!("Failed to delete verification record: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Err(_) = tx.commit().await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + info!(did = %user.did, channel = %channel_str, "Channel verified successfully"); + + Json(json!({"success": true})).into_response() +} diff --git a/src/lib.rs b/src/lib.rs index ea757c9..d088f03 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -272,6 +272,10 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.admin.getInviteCodes", get(api::admin::get_invite_codes), ) + .route( + "/xrpc/com.bspds.admin.getServerStats", + get(api::admin::get_server_stats), + ) .route( "/xrpc/com.atproto.admin.disableAccountInvites", post(api::admin::disable_account_invites), @@ -388,6 +392,14 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.bspds.account.updateNotificationPrefs", post(api::notification_prefs::update_notification_prefs), ) + .route( + "/xrpc/com.bspds.account.getNotificationHistory", + get(api::notification_prefs::get_notification_history), + ) + .route( + "/xrpc/com.bspds.account.confirmChannelVerification", + post(api::verification::confirm_channel_verification), + ) .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) .layer(middleware::from_fn(metrics::metrics_middleware)) .layer( diff --git a/tests/account_notifications.rs b/tests/account_notifications.rs new file mode 100644 index 0000000..ad96219 --- /dev/null +++ b/tests/account_notifications.rs @@ -0,0 +1,221 @@ +mod common; +use common::{base_url, client, create_account_and_login, get_db_connection_string}; +use bspds::notifications::{NewNotification, NotificationType, enqueue_notification}; +use serde_json::{Value, json}; +use sqlx::PgPool; + +async fn get_pool() -> PgPool { + let conn_str = get_db_connection_string().await; + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&conn_str) + .await + .expect("Failed to connect to test database") +} + +#[tokio::test] +async fn test_get_notification_history() { + let client = client(); + let base = base_url().await; + let pool = get_pool().await; + let (token, did) = create_account_and_login(&client).await; + + let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_one(&pool) + .await + .expect("User not found"); + + for i in 0..3 { + let notification = NewNotification::email( + user_id, + NotificationType::Welcome, + "test@example.com".to_string(), + format!("Subject {}", i), + format!("Body {}", i), + ); + enqueue_notification(&pool, notification).await.expect("Failed to enqueue"); + } + + let resp = client + .get(format!("{}/xrpc/com.bspds.account.getNotificationHistory", base)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + let notifications = body["notifications"].as_array().unwrap(); + assert_eq!(notifications.len(), 5); + + assert_eq!(notifications[0]["subject"], "Subject 2"); + assert_eq!(notifications[1]["subject"], "Subject 1"); + assert_eq!(notifications[2]["subject"], "Subject 0"); +} + +#[tokio::test] +async fn test_verify_channel_discord() { + let client = client(); + let base = base_url().await; + let (token, did) = create_account_and_login(&client).await; + + let prefs = json!({ + "discordId": "123456789" + }); + let resp = client + .post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&prefs) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("discord"))); + + let pool = get_pool().await; + let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_one(&pool) + .await + .expect("User not found"); + + let code: String = sqlx::query_scalar!( + "SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", + user_id + ) + .fetch_one(&pool) + .await + .expect("Verification code not found"); + + let input = json!({ + "channel": "discord", + "code": code + }); + let resp = client + .post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&input) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let resp = client + .get(format!("{}/xrpc/com.bspds.account.getNotificationPrefs", base)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .unwrap(); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["discordVerified"], true); + assert_eq!(body["discordId"], "123456789"); +} + +#[tokio::test] +async fn test_verify_channel_invalid_code() { + let client = client(); + let base = base_url().await; + let (token, _did) = create_account_and_login(&client).await; + + let prefs = json!({ + "telegramUsername": "testuser" + }); + let resp = client + .post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&prefs) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let input = json!({ + "channel": "telegram", + "code": "000000" + }); + let resp = client + .post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&input) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); +} + +#[tokio::test] +async fn test_verify_channel_not_set() { + let client = client(); + let base = base_url().await; + let (token, _did) = create_account_and_login(&client).await; + + let input = json!({ + "channel": "signal", + "code": "123456" + }); + let resp = client + .post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&input) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); +} + +#[tokio::test] +async fn test_update_email_via_notification_prefs() { + let client = client(); + let base = base_url().await; + let pool = get_pool().await; + let (token, did) = create_account_and_login(&client).await; + + let prefs = json!({ + "email": "newemail@example.com" + }); + let resp = client + .post(format!("{}/xrpc/com.bspds.account.updateNotificationPrefs", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&prefs) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("email"))); + + let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_one(&pool) + .await + .expect("User not found"); + + let code: String = sqlx::query_scalar!( + "SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + user_id + ) + .fetch_one(&pool) + .await + .expect("Verification code not found"); + + let input = json!({ + "channel": "email", + "code": code + }); + let resp = client + .post(format!("{}/xrpc/com.bspds.account.confirmChannelVerification", base)) + .header("Authorization", format!("Bearer {}", token)) + .json(&input) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + let resp = client + .get(format!("{}/xrpc/com.bspds.account.getNotificationPrefs", base)) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .unwrap(); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["email"], "newemail@example.com"); +} diff --git a/tests/admin_stats.rs b/tests/admin_stats.rs new file mode 100644 index 0000000..4aaa826 --- /dev/null +++ b/tests/admin_stats.rs @@ -0,0 +1,41 @@ +mod common; +use common::{base_url, client, create_account_and_login}; +use serde_json::Value; + +#[tokio::test] +async fn test_get_server_stats() { + let client = client(); + let base = base_url().await; + let (token1, _) = create_account_and_login(&client).await; + + let (_, _) = create_account_and_login(&client).await; + + let resp = client + .get(format!("{}/xrpc/com.bspds.admin.getServerStats", base)) + .header("Authorization", format!("Bearer {}", token1)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + + let user_count = body["userCount"].as_i64().unwrap(); + assert!(user_count >= 2); + + assert!(body["repoCount"].is_number()); + assert!(body["recordCount"].is_number()); + assert!(body["blobStorageBytes"].is_number()); +} + +#[tokio::test] +async fn test_get_server_stats_no_auth() { + let client = client(); + let base = base_url().await; + let resp = client + .get(format!("{}/xrpc/com.bspds.admin.getServerStats", base)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} \ No newline at end of file diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 2d806eb..3367858 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -79,6 +79,9 @@ pub async fn base_url() -> &'static str { SERVER_URL.get_or_init(|| { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { + unsafe { + std::env::set_var("BSPDS_ALLOW_INSECURE_SECRETS", "1"); + } if std::env::var("DOCKER_HOST").is_err() { if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { let podman_sock = std::path::Path::new(&runtime_dir).join("podman/podman.sock"); @@ -406,13 +409,13 @@ pub async fn verify_new_account(client: &Client, did: &str) -> String { .await .expect("Failed to connect to test database"); let verification_code: String = sqlx::query_scalar!( - "SELECT email_confirmation_code FROM users WHERE did = $1", + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", did ) .fetch_one(&pool) .await - .expect("Failed to get verification code") - .expect("No verification code found"); + .expect("Failed to get verification code"); + let confirm_payload = json!({ "did": did, "verificationCode": verification_code @@ -548,13 +551,13 @@ pub async fn create_account_and_login(client: &Client) -> (String, String) { .await .expect("Failed to connect to test database"); let verification_code: String = sqlx::query_scalar!( - "SELECT email_confirmation_code FROM users WHERE did = $1", + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", &did ) .fetch_one(&pool) .await - .expect("Failed to get verification code") - .expect("No verification code found"); + .expect("Failed to get verification code"); + let confirm_payload = json!({ "did": did, "verificationCode": verification_code diff --git a/tests/email_update.rs b/tests/email_update.rs index a9bcdf4..dd4ddfb 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -59,19 +59,20 @@ async fn test_email_update_flow_success() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.expect("Invalid JSON"); assert_eq!(body["tokenRequired"], true); - let user = sqlx::query!( - "SELECT email_pending_verification, email_confirmation_code, email FROM users WHERE handle = $1", + + let verification = sqlx::query!( + "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", handle ) .fetch_one(&pool) .await - .expect("User not found"); + .expect("Verification not found"); + assert_eq!( - user.email_pending_verification.as_deref(), + verification.pending_identifier.as_deref(), Some(new_email.as_str()) ); - assert!(user.email_confirmation_code.is_some()); - let code = user.email_confirmation_code.unwrap(); + let code = verification.code; let res = client .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) .bearer_auth(&access_jwt) @@ -84,15 +85,22 @@ async fn test_email_update_flow_success() { .expect("Failed to confirm email"); assert_eq!(res.status(), StatusCode::OK); let user = sqlx::query!( - "SELECT email, email_pending_verification, email_confirmation_code FROM users WHERE handle = $1", + "SELECT email FROM users WHERE handle = $1", handle ) .fetch_one(&pool) .await .expect("User not found"); assert_eq!(user.email, Some(new_email)); - assert!(user.email_pending_verification.is_none()); - assert!(user.email_confirmation_code.is_none()); + + let verification = sqlx::query!( + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", + handle + ) + .fetch_optional(&pool) + .await + .expect("DB error"); + assert!(verification.is_none()); } #[tokio::test] @@ -174,14 +182,14 @@ async fn test_confirm_email_wrong_email() { .await .expect("Failed to request email update"); assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!( - "SELECT email_confirmation_code FROM users WHERE handle = $1", + let verification = sqlx::query!( + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", handle ) .fetch_one(&pool) .await - .expect("User not found"); - let code = user.email_confirmation_code.unwrap(); + .expect("Verification not found"); + let code = verification.code; let res = client .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) .bearer_auth(&access_jwt) @@ -293,14 +301,14 @@ async fn test_update_email_with_valid_token() { .await .expect("Failed to request email update"); assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!( - "SELECT email_confirmation_code FROM users WHERE handle = $1", + let verification = sqlx::query!( + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", handle ) .fetch_one(&pool) .await - .expect("User not found"); - let code = user.email_confirmation_code.unwrap(); + .expect("Verification not found"); + let code = verification.code; let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .bearer_auth(&access_jwt) @@ -313,14 +321,21 @@ async fn test_update_email_with_valid_token() { .expect("Failed to update email"); assert_eq!(res.status(), StatusCode::OK); let user = sqlx::query!( - "SELECT email, email_pending_verification FROM users WHERE handle = $1", + "SELECT email FROM users WHERE handle = $1", handle ) .fetch_one(&pool) .await .expect("User not found"); assert_eq!(user.email, Some(new_email)); - assert!(user.email_pending_verification.is_none()); + let verification = sqlx::query!( + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", + handle + ) + .fetch_optional(&pool) + .await + .expect("DB error"); + assert!(verification.is_none()); } #[tokio::test] diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs index 0ff6063..80f1f71 100644 --- a/tests/jwt_security.rs +++ b/tests/jwt_security.rs @@ -872,13 +872,12 @@ async fn test_jwt_security_refresh_token_replay_protection() { .await .expect("Failed to connect to test database"); let verification_code: String = sqlx::query_scalar!( - "SELECT email_confirmation_code FROM users WHERE did = $1", + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", did ) .fetch_one(&pool) .await - .expect("Failed to get verification code") - .expect("No verification code found"); + .expect("Failed to get verification code"); let confirm_res = http_client .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url)) .json(&json!({