mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-08 21:30:08 +00:00
More work on the pds notifs
This commit is contained in:
20
.sqlx/query-084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076.json
generated
Normal file
20
.sqlx/query-084bc136aa68b48346ea6acaa5d171d1dbd5ce5a5a18fa1e62cfd76558082076.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
30
.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json
generated
Normal file
30
.sqlx/query-0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
14
.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json
generated
Normal file
14
.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
28
.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
generated
Normal file
28
.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -37,7 +37,8 @@
|
||||
"account_deletion",
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code"
|
||||
"two_factor_code",
|
||||
"channel_verification"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
15
.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json
generated
Normal file
15
.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
17
.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
generated
Normal file
17
.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
47
.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json
generated
Normal file
47
.sqlx/query-4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
93
.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json
generated
Normal file
93
.sqlx/query-4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -37,7 +37,8 @@
|
||||
"account_deletion",
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code"
|
||||
"two_factor_code",
|
||||
"channel_verification"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
34
.sqlx/query-62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e.json
generated
Normal file
34
.sqlx/query-62b370470a950d02c2daf6e4fd3ad231f1558c4c020026f857b0026dae4f513e.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
15
.sqlx/query-6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08.json
generated
Normal file
15
.sqlx/query-6cdae6ee4f1f3ba47fbe7b98573b07161029623905e42d28e107207c3ab91c08.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
14
.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json
generated
Normal file
14
.sqlx/query-76a3a8d7e969c4e10e2326675720230bad1d85da784c826df7fe4f037e1cb7f1.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
27
.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json
generated
Normal file
27
.sqlx/query-90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
20
.sqlx/query-91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e.json
generated
Normal file
20
.sqlx/query-91b5c8338e5842836f044b5d6f353ba77d8f2dc4177215d2293ab18a1ad5870e.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
20
.sqlx/query-96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4.json
generated
Normal file
20
.sqlx/query-96e53c7d68298d0e99d64263d076b2d02891e7e5cbee233917405559c05878a4.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
17
.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
generated
Normal file
17
.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
14
.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json
generated
Normal file
14
.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
30
.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json
generated
Normal file
30
.sqlx/query-9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
34
.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json
generated
Normal file
34
.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
15
.sqlx/query-a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb.json
generated
Normal file
15
.sqlx/query-a1e383acb16c3514df0d138d77cd5c9965ca091d6a3d035eae9ef7a8bf8be4eb.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
14
.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json
generated
Normal file
14
.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
14
.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json
generated
Normal file
14
.sqlx/query-c7ebbeca2ba26ef7b5a7c00441f51f68eb47f1421fa0a937eaa5a79aec75001d.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -45,7 +45,8 @@
|
||||
"account_deletion",
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code"
|
||||
"two_factor_code",
|
||||
"channel_verification"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
28
.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json
generated
Normal file
28
.sqlx/query-d6ec64a262936b8def9e5cad7d021df408b3a9b80fce5a7446647fbf276092ca.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
15
.sqlx/query-db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c.json
generated
Normal file
15
.sqlx/query-db62569122d7561b2f1b7d0e276f5de156489b637a93db667fc5106450dcad0c.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
20
.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json
generated
Normal file
20
.sqlx/query-dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
14
.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json
generated
Normal file
14
.sqlx/query-ea12e747e409ca5e27369b1b17014000c6f0d53743007046d820a76497a2fa37.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
14
.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json
generated
Normal file
14
.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
15
.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json
generated
Normal file
15
.sqlx/query-f056a1ff7979927e5581342edb213d1f79c2541a5e992b2b2aa7c0ae006364c3.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
10
TODO.md
10
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`)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
12
migrations/20251216_add_channel_verification.sql
Normal file
12
migrations/20251216_add_channel_verification.sql
Normal file
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
|
||||
76
src/api/admin/server_stats.rs
Normal file
76
src/api/admin/server_stats.rs
Normal file
@@ -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<AppState>,
|
||||
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()
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<AppState>, 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<String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetNotificationHistoryResponse {
|
||||
pub notifications: Vec<NotificationHistoryEntry>,
|
||||
}
|
||||
|
||||
pub async fn get_notification_history(
|
||||
State(state): State<AppState>,
|
||||
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<String>,
|
||||
pub email: Option<String>,
|
||||
pub discord_id: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateNotificationPrefsResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub verification_required: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn request_channel_verification(
|
||||
db: &sqlx::PgPool,
|
||||
user_id: uuid::Uuid,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
handle: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
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<AppState>,
|
||||
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<String> = 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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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::<u32>() % 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())
|
||||
|
||||
191
src/api/verification.rs
Normal file
191
src/api/verification.rs
Normal file
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<ConfirmChannelVerificationInput>,
|
||||
) -> 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()
|
||||
}
|
||||
12
src/lib.rs
12
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(
|
||||
|
||||
221
tests/account_notifications.rs
Normal file
221
tests/account_notifications.rs
Normal file
@@ -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");
|
||||
}
|
||||
41
tests/admin_stats.rs
Normal file
41
tests/admin_stats.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user