mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-20 17:24:15 +00:00
Rename notifications to comms, add handle changing to own domain ability
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE comms_queue\n SET status = 'sent', processed_at = NOW(), updated_at = NOW()\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "17bd3bd354a6ee0a86a1c868207eb4ea454844828c8aca63b1252fefa8f5afad"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO notification_queue\n (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
|
||||
"query": "\n INSERT INTO comms_queue\n (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -14,7 +14,7 @@
|
||||
"Uuid",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
{
|
||||
"Custom": {
|
||||
"name": "notification_type",
|
||||
"name": "comms_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"welcome",
|
||||
@@ -53,5 +53,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5d49bbf0307a0c642b0174d641de748fa648c97f8109255120e969c957ff95bf"
|
||||
"hash": "17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5"
|
||||
}
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE notification_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM notification_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: NotificationChannel\",\n notification_type as \"notification_type: super::types::NotificationType\",\n status as \"status: NotificationStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n ",
|
||||
"query": "\n UPDATE comms_queue\n SET status = 'processing', updated_at = NOW()\n WHERE id IN (\n SELECT id FROM comms_queue\n WHERE status = 'pending'\n AND scheduled_for <= $1\n AND attempts < max_attempts\n ORDER BY scheduled_for ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: super::types::CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -15,10 +15,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "channel: NotificationChannel",
|
||||
"name": "channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -32,10 +32,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "notification_type: super::types::NotificationType",
|
||||
"name": "comms_type: super::types::CommsType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_type",
|
||||
"name": "comms_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"welcome",
|
||||
@@ -54,10 +54,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status: NotificationStatus",
|
||||
"name": "status: CommsStatus",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_status",
|
||||
"name": "comms_status",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"pending",
|
||||
@@ -150,5 +150,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cb6f48aaba124c79308d20e66c23adb44d1196296b7f93fad19b2d17548ed3de"
|
||||
"hash": "20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE notification_queue\n SET\n status = CASE\n WHEN attempts + 1 >= max_attempts THEN 'failed'::notification_status\n ELSE 'pending'::notification_status\n END,\n attempts = attempts + 1,\n last_error = $2,\n updated_at = NOW(),\n scheduled_for = NOW() + (INTERVAL '1 minute' * (attempts + 1))\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2c6cb8f15fe71cb5f38ffd7f5085b60bc852c4f1042c95a76fce773efd369511"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE notification_queue\n SET status = 'sent', processed_at = NOW(), updated_at = NOW()\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "344c851d3f1b026e8632aa2f04052dcbc957b7077c856da6a1a256ec2fe85ad3"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO notification_queue\n (user_id, channel, notification_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
|
||||
"query": "\n INSERT INTO comms_queue\n (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -14,7 +14,7 @@
|
||||
"Uuid",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
{
|
||||
"Custom": {
|
||||
"name": "notification_type",
|
||||
"name": "comms_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"welcome",
|
||||
@@ -53,5 +53,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "303777d97e6ed344f8c699eae37b7b0c241c734a5b7726019c2a59ae277caee6"
|
||||
"hash": "3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc"
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n id, handle, email,\n preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n discord_id, telegram_username, signal_number,\n email_confirmed, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
|
||||
"query": "SELECT\n id, handle, email,\n preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n discord_id, telegram_username, signal_number,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,10 +20,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "channel: crate::notifications::NotificationChannel",
|
||||
"name": "channel: crate::comms::CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -52,7 +52,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "email_confirmed",
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
@@ -90,5 +90,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6"
|
||||
"hash": "4a77184e491ed1f011966fd7fa1332bfeaf782a7787784008f15254c02ef57d5"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel",
|
||||
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"Uuid",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -23,5 +23,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "90f5c38a28537b2deddd0f897b8902a97547983851bd95a9ae496943064b1849"
|
||||
"hash": "57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e"
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, two_factor_enabled,\n preferred_notification_channel as \"preferred_notification_channel: NotificationChannel\"\n FROM users\n WHERE did = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "two_factor_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "preferred_notification_channel: NotificationChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "62f66fad54498d5c598af54de795e395f71596a6d6a88d2be64ce86256a9860f"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE comms_queue\n SET\n status = CASE\n WHEN attempts + 1 >= max_attempts THEN 'failed'::comms_status\n ELSE 'pending'::comms_status\n END,\n attempts = attempts + 1,\n last_error = $2,\n updated_at = NOW(),\n scheduled_for = NOW() + (INTERVAL '1 minute' * (attempts + 1))\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "64510156f2b79cdc41f08867952abbea919b9a90167958f018ceb9972b9e8230"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n email,\n handle,\n preferred_notification_channel as \"channel: NotificationChannel\"\n FROM users\n WHERE id = $1\n ",
|
||||
"query": "\n SELECT\n email,\n handle,\n preferred_comms_channel as \"channel: CommsChannel\"\n FROM users\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -15,10 +15,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "channel: NotificationChannel",
|
||||
"name": "channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -42,5 +42,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508"
|
||||
"hash": "8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721"
|
||||
}
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT preferred_notification_channel as \"channel: NotificationChannel\" FROM users WHERE did = $1",
|
||||
"query": "SELECT preferred_comms_channel as \"channel: CommsChannel\" FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "channel: NotificationChannel",
|
||||
"name": "channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -30,5 +30,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0cbeeffaf2cf782de4e9d886e26b9884e874735e76b50c42933a94d9fa70425e"
|
||||
"hash": "94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::comms_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"Uuid",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -26,5 +26,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9ebca49cb60b1891d3c1ef0087e189f2e35b982fce0c3313748c23c113a6e546"
|
||||
"hash": "c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT u.id, uk.key_bytes, uk.encryption_version\n FROM users u\n JOIN user_keys uk ON u.id = uk.user_id\n WHERE u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "encryption_version",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d41e1b7d5e22c06896ae28c6790d5c7c8e6a7c9489133bb9357d012d7a75813b"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "email_confirmed",
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
@@ -72,5 +72,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd"
|
||||
"hash": "d61c982dac3a508393b31a30bad50c0088ce6e117fe63c5a1062a97000dedf89"
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "two_factor_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "preferred_comms_channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "discord_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "telegram_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "daa235d54827ca9b2803da732d3d35c6012b7cc6aac81c5e46be9d24cfd42c24"
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n handle, email, email_confirmed, is_admin,\n preferred_notification_channel as \"preferred_channel: crate::notifications::NotificationChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1",
|
||||
"query": "SELECT\n handle, email, email_verified, is_admin,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email_confirmed",
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
@@ -25,10 +25,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "preferred_channel: crate::notifications::NotificationChannel",
|
||||
"name": "preferred_channel: crate::comms::CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -72,5 +72,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "088e0b03c2f706402d474e4431562a40a64a5ce575bd8884b2c5d51f04871de1"
|
||||
"hash": "de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"query": "\n INSERT INTO comms_queue (user_id, channel, comms_type, recipient, subject, body, metadata)\n VALUES ($1, $2::comms_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"Uuid",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -26,5 +26,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0bb332a1a1b648aaeca02415b8d2fc39c045bac9b3897b1c886b6ffc68538bac"
|
||||
"hash": "e774d655b838c219c8291a5bc8e6fb90b793c78402c648dd380538b6e2b47134"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -25,10 +25,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "channel: crate::notifications::NotificationChannel",
|
||||
"name": "channel: crate::comms::CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -66,5 +66,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "dfcfb9ccc41c389bf06548f815080e7601c636ddce2b5a04a2d33a19461a2fe3"
|
||||
"hash": "efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::comms_channel\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -24,7 +24,7 @@
|
||||
"Uuid",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -43,5 +43,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4bd5937b38e9ea67215a24f8f4ece05d107b4cdacdb59c30fa3782bb36942b26"
|
||||
"hash": "f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5"
|
||||
}
|
||||
+29
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_notification_channel as \"preferred_notification_channel: NotificationChannel\",\n deactivated_at, takedown_ref\n FROM users\n WHERE handle = $1 OR email = $1\n ",
|
||||
"query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -30,10 +30,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "preferred_notification_channel: NotificationChannel",
|
||||
"name": "preferred_comms_channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -54,6 +54,26 @@
|
||||
"ordinal": 7,
|
||||
"name": "takedown_ref",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "discord_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "telegram_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -69,8 +89,12 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810"
|
||||
"hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0"
|
||||
}
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n comms_type as \"comms_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT 50\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -13,7 +13,7 @@
|
||||
"name": "channel: String",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
@@ -27,10 +27,10 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "notification_type: String",
|
||||
"name": "comms_type: String",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_type",
|
||||
"name": "comms_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"welcome",
|
||||
@@ -52,7 +52,7 @@
|
||||
"name": "status: String",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_status",
|
||||
"name": "comms_status",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"pending",
|
||||
@@ -89,5 +89,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4f131ba30c73a48ddf5630e75f92a86b6ce8a00a4bcae60442d05abc785abc1a"
|
||||
"hash": "fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4"
|
||||
}
|
||||
Generated
+1
@@ -950,6 +950,7 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"futures",
|
||||
"governor",
|
||||
"hickory-resolver",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"image",
|
||||
|
||||
@@ -51,6 +51,7 @@ iroh-car = "0.5.1"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
|
||||
[features]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { initAuth, getAuthState } from './lib/auth.svelte'
|
||||
import Login from './routes/Login.svelte'
|
||||
import Register from './routes/Register.svelte'
|
||||
import Verify from './routes/Verify.svelte'
|
||||
import ResetPassword from './routes/ResetPassword.svelte'
|
||||
import Dashboard from './routes/Dashboard.svelte'
|
||||
import AppPasswords from './routes/AppPasswords.svelte'
|
||||
@@ -25,6 +26,8 @@
|
||||
return Login
|
||||
case '/register':
|
||||
return Register
|
||||
case '/verify':
|
||||
return Verify
|
||||
case '/reset-password':
|
||||
return ResetPassword
|
||||
case '/dashboard':
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
let resendMessage = $state<string | null>(null)
|
||||
let showNewLogin = $state(false)
|
||||
const auth = getAuthState()
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
async function handleSwitchAccount(did: string) {
|
||||
submitting = true
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { register, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
|
||||
import { register, getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError, type VerificationChannel } from '../lib/api'
|
||||
|
||||
const STORAGE_KEY = 'bspds_pending_verification'
|
||||
|
||||
let handle = $state('')
|
||||
let email = $state('')
|
||||
let password = $state('')
|
||||
@@ -13,28 +16,28 @@
|
||||
let signalNumber = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let pendingVerification = $state<{ did: string; handle: string; channel: string } | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let resendingCode = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
let serverInfo = $state<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
} | null>(null)
|
||||
let loadingServerInfo = $state(true)
|
||||
let serverInfoLoaded = false
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!serverInfoLoaded) {
|
||||
serverInfoLoaded = true
|
||||
loadServerInfo()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
serverInfo = await api.describeServer()
|
||||
@@ -44,6 +47,7 @@
|
||||
loadingServerInfo = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!handle.trim()) return 'Handle is required'
|
||||
if (!password) return 'Password is required'
|
||||
@@ -68,18 +72,16 @@
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
console.log('[Register] handleSubmit called')
|
||||
const validationError = validateForm()
|
||||
if (validationError) {
|
||||
console.log('[Register] validation error:', validationError)
|
||||
error = validationError
|
||||
return
|
||||
}
|
||||
submitting = true
|
||||
error = null
|
||||
console.log('[Register] starting registration...')
|
||||
try {
|
||||
const result = await register({
|
||||
handle: handle.trim(),
|
||||
@@ -91,21 +93,17 @@
|
||||
telegramUsername: telegramUsername.trim() || undefined,
|
||||
signalNumber: signalNumber.trim() || undefined,
|
||||
})
|
||||
console.log('[Register] registration result:', result)
|
||||
if (result.verificationRequired) {
|
||||
console.log('[Register] setting pendingVerification')
|
||||
pendingVerification = {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
channel: result.verificationChannel,
|
||||
}
|
||||
console.log('[Register] pendingVerification set to:', pendingVerification)
|
||||
}))
|
||||
navigate('/verify')
|
||||
} else {
|
||||
console.log('[Register] no verification required, navigating to dashboard')
|
||||
navigate('/dashboard')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[Register] error:', err)
|
||||
if (err instanceof ApiError) {
|
||||
error = err.message || 'Registration failed'
|
||||
} else if (err instanceof Error) {
|
||||
@@ -115,37 +113,9 @@
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
console.log('[Register] finished, submitting=false')
|
||||
}
|
||||
}
|
||||
async function handleVerification(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!pendingVerification || !verificationCode.trim()) return
|
||||
submitting = true
|
||||
error = null
|
||||
try {
|
||||
await confirmSignup(pendingVerification.did, verificationCode.trim())
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Verification failed'
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
async function handleResendCode() {
|
||||
if (!pendingVerification || resendingCode) return
|
||||
resendingCode = true
|
||||
resendMessage = null
|
||||
error = null
|
||||
try {
|
||||
await resendVerification(pendingVerification.did)
|
||||
resendMessage = 'Verification code resent!'
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Failed to resend code'
|
||||
} finally {
|
||||
resendingCode = false
|
||||
}
|
||||
}
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!handle.trim()) return ''
|
||||
if (handle.includes('.')) return handle.trim()
|
||||
@@ -153,53 +123,12 @@
|
||||
if (domain) return `${handle.trim()}.${domain}`
|
||||
return handle.trim()
|
||||
})
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return 'Email'
|
||||
case 'discord': return 'Discord'
|
||||
case 'telegram': return 'Telegram'
|
||||
case 'signal': return 'Signal'
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<div class="register-container">
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
{#if pendingVerification}
|
||||
<h1>Verify Your Account</h1>
|
||||
<p class="subtitle">
|
||||
We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
|
||||
Enter it below to complete registration.
|
||||
</p>
|
||||
{#if resendMessage}
|
||||
<div class="success">{resendMessage}</div>
|
||||
{/if}
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
|
||||
<div class="field">
|
||||
<label for="verification-code">Verification Code</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
disabled={submitting}
|
||||
required
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? 'Verifying...' : 'Verify Account'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? 'Resending...' : 'Resend Code'}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<h1>Create Account</h1>
|
||||
<h1>Create Account</h1>
|
||||
<p class="subtitle">Create a new account on this PDS</p>
|
||||
{#if loadingServerInfo}
|
||||
<p class="loading">Loading...</p>
|
||||
@@ -322,17 +251,6 @@
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="field optional">
|
||||
<label for="invite-code">Invite Code <span class="optional-label">(optional)</span></label>
|
||||
<input
|
||||
id="invite-code"
|
||||
type="text"
|
||||
bind:value={inviteCode}
|
||||
placeholder="Enter invite code if you have one"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creating account...' : 'Create Account'}
|
||||
@@ -342,7 +260,6 @@
|
||||
Already have an account? <a href="#/login">Sign in</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
.register-container {
|
||||
@@ -371,9 +288,6 @@
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.field.optional {
|
||||
opacity: 0.8;
|
||||
}
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
@@ -381,10 +295,6 @@
|
||||
.required {
|
||||
color: var(--error-text);
|
||||
}
|
||||
.optional-label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
}
|
||||
input, select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
@@ -435,15 +345,6 @@
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
@@ -451,13 +352,6 @@
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
}
|
||||
.success {
|
||||
padding: 0.75rem;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
border-radius: 4px;
|
||||
color: var(--success-text);
|
||||
}
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
let currentPassword = $state('')
|
||||
let newPassword = $state('')
|
||||
let confirmNewPassword = $state('')
|
||||
let showBYOHandle = $state(false)
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
@@ -230,22 +231,80 @@
|
||||
{#if auth.session}
|
||||
<p class="current">Current: @{auth.session.handle}</p>
|
||||
{/if}
|
||||
<form onsubmit={handleUpdateHandle}>
|
||||
<div class="field">
|
||||
<label for="new-handle">New Handle</label>
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
bind:value={newHandle}
|
||||
placeholder="newhandle.bsky.social"
|
||||
disabled={handleLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={handleLoading || !newHandle}>
|
||||
{handleLoading ? 'Updating...' : 'Change Handle'}
|
||||
<div class="tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={!showBYOHandle}
|
||||
onclick={() => showBYOHandle = false}
|
||||
>
|
||||
PDS Handle
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:active={showBYOHandle}
|
||||
onclick={() => showBYOHandle = true}
|
||||
>
|
||||
Custom Domain
|
||||
</button>
|
||||
</div>
|
||||
{#if showBYOHandle}
|
||||
<div class="byo-handle">
|
||||
<p class="description">Use your own domain as your handle. You need to verify domain ownership first.</p>
|
||||
{#if auth.session}
|
||||
<div class="verification-info">
|
||||
<h3>Setup Instructions</h3>
|
||||
<p>Choose one of these verification methods:</p>
|
||||
<div class="method">
|
||||
<h4>Option 1: DNS TXT Record (Recommended)</h4>
|
||||
<p>Add this TXT record to your domain:</p>
|
||||
<code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code>
|
||||
</div>
|
||||
<div class="method">
|
||||
<h4>Option 2: HTTP Well-Known File</h4>
|
||||
<p>Serve your DID at this URL:</p>
|
||||
<code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code>
|
||||
<p>The file should contain only:</p>
|
||||
<code class="record">{auth.session.did}</code>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<form onsubmit={handleUpdateHandle}>
|
||||
<div class="field">
|
||||
<label for="new-handle-byo">Your Domain</label>
|
||||
<input
|
||||
id="new-handle-byo"
|
||||
type="text"
|
||||
bind:value={newHandle}
|
||||
placeholder="example.com"
|
||||
disabled={handleLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={handleLoading || !newHandle}>
|
||||
{handleLoading ? 'Verifying...' : 'Verify & Update Handle'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleUpdateHandle}>
|
||||
<div class="field">
|
||||
<label for="new-handle">New Handle</label>
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
bind:value={newHandle}
|
||||
placeholder="yourhandle"
|
||||
disabled={handleLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={handleLoading || !newHandle}>
|
||||
{handleLoading ? 'Updating...' : 'Change Handle'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
<section>
|
||||
<h2>Change Password</h2>
|
||||
@@ -458,4 +517,75 @@
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color-light);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tab:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
.tab:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.tab:hover:not(.active) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.byo-handle .description {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.verification-info {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.verification-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.verification-info h4 {
|
||||
margin: 0.75rem 0 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.verification-info p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.method {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color-light);
|
||||
}
|
||||
.method:first-of-type {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
code.record {
|
||||
display: block;
|
||||
background: var(--bg-input);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
|
||||
const STORAGE_KEY = 'bspds_pending_verification'
|
||||
|
||||
interface PendingVerification {
|
||||
did: string
|
||||
handle: string
|
||||
channel: string
|
||||
}
|
||||
|
||||
let pendingVerification = $state<PendingVerification | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let submitting = $state(false)
|
||||
let resendingCode = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
clearPendingVerification()
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
pendingVerification = JSON.parse(stored)
|
||||
} catch {
|
||||
pendingVerification = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function clearPendingVerification() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
pendingVerification = null
|
||||
}
|
||||
|
||||
async function handleVerification(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!pendingVerification || !verificationCode.trim()) return
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
await confirmSignup(pendingVerification.did, verificationCode.trim())
|
||||
clearPendingVerification()
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Verification failed'
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendCode() {
|
||||
if (!pendingVerification || resendingCode) return
|
||||
|
||||
resendingCode = true
|
||||
resendMessage = null
|
||||
error = null
|
||||
|
||||
try {
|
||||
await resendVerification(pendingVerification.did)
|
||||
resendMessage = 'Verification code resent!'
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Failed to resend code'
|
||||
} finally {
|
||||
resendingCode = false
|
||||
}
|
||||
}
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return 'Email'
|
||||
case 'discord': return 'Discord'
|
||||
case 'telegram': return 'Telegram'
|
||||
case 'signal': return 'Signal'
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="verify-container">
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingVerification}
|
||||
<h1>Verify Your Account</h1>
|
||||
<p class="subtitle">
|
||||
We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
|
||||
Enter it below to complete registration.
|
||||
</p>
|
||||
<p class="handle-info">Verifying account: <strong>@{pendingVerification.handle}</strong></p>
|
||||
|
||||
{#if resendMessage}
|
||||
<div class="success">{resendMessage}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
|
||||
<div class="field">
|
||||
<label for="verification-code">Verification Code</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
disabled={submitting}
|
||||
required
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? 'Verifying...' : 'Verify Account'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? 'Resending...' : 'Resend Code'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="cancel-link">
|
||||
<a href="#/register" onclick={() => clearPendingVerification()}>Start over with a different account</a>
|
||||
</p>
|
||||
{:else}
|
||||
<h1>Account Verification</h1>
|
||||
<p class="subtitle">No pending verification found.</p>
|
||||
<p class="no-pending-info">
|
||||
If you recently created an account and need to verify it, you may need to create a new account.
|
||||
If you already verified your account, you can sign in.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="#/register" class="btn">Create Account</a>
|
||||
<a href="#/login" class="btn secondary">Sign In</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.verify-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.handle-info {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.no-pending-info {
|
||||
color: var(--text-secondary);
|
||||
margin: 1rem 0 1.5rem 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled), .btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary, .btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled), .btn.secondary:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
border-radius: 4px;
|
||||
color: var(--success-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cancel-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cancel-link a {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'email_confirmed') THEN
|
||||
ALTER TABLE users RENAME COLUMN email_confirmed TO email_verified;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,27 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN
|
||||
ALTER TYPE notification_channel RENAME TO comms_channel;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN
|
||||
ALTER TYPE notification_status RENAME TO comms_status;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN
|
||||
ALTER TYPE notification_type RENAME TO comms_type;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'notification_queue') THEN
|
||||
ALTER TABLE notification_queue RENAME TO comms_queue;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comms_queue' AND column_name = 'notification_type') THEN
|
||||
ALTER TABLE comms_queue RENAME COLUMN notification_type TO comms_type;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_status_scheduled') THEN
|
||||
ALTER INDEX idx_notification_queue_status_scheduled RENAME TO idx_comms_queue_status_scheduled;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_user_id') THEN
|
||||
ALTER INDEX idx_notification_queue_user_id RENAME TO idx_comms_queue_user_id;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'preferred_notification_channel') THEN
|
||||
ALTER TABLE users RENAME COLUMN preferred_notification_channel TO preferred_comms_channel;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -87,14 +87,14 @@ pub async fn send_email(
|
||||
.subject
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Message from {}", hostname));
|
||||
let notification = crate::notifications::NewNotification::email(
|
||||
let item = crate::comms::NewComms::email(
|
||||
user_id,
|
||||
crate::notifications::NotificationType::AdminEmail,
|
||||
crate::comms::CommsType::AdminEmail,
|
||||
email,
|
||||
subject,
|
||||
content.to_string(),
|
||||
);
|
||||
let result = crate::notifications::enqueue_notification(&state.db, notification).await;
|
||||
let result = crate::comms::enqueue_comms(&state.db, item).await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
tracing::info!("Admin email queued for {} ({})", handle, recipient_did);
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct AccountInfo {
|
||||
pub indexed_at: String,
|
||||
pub invite_note: Option<String>,
|
||||
pub invites_disabled: bool,
|
||||
pub email_confirmed_at: Option<String>,
|
||||
pub email_verified_at: Option<String>,
|
||||
pub deactivated_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ pub async fn get_account_info(
|
||||
indexed_at: row.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
invites_disabled: false,
|
||||
email_confirmed_at: None,
|
||||
email_verified_at: None,
|
||||
deactivated_at: None,
|
||||
}),
|
||||
)
|
||||
@@ -143,7 +143,7 @@ pub async fn get_account_infos(
|
||||
indexed_at: row.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
invites_disabled: false,
|
||||
email_confirmed_at: None,
|
||||
email_verified_at: None,
|
||||
deactivated_at: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub struct AccountView {
|
||||
pub email: Option<String>,
|
||||
pub indexed_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email_confirmed_at: Option<String>,
|
||||
pub email_verified_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deactivated_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -56,7 +56,7 @@ pub async fn search_accounts(
|
||||
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
|
||||
let result = sqlx::query_as::<_, (String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
||||
r#"
|
||||
SELECT did, handle, email, created_at, email_confirmed, deactivated_at
|
||||
SELECT did, handle, email, created_at, email_verified, deactivated_at
|
||||
FROM users
|
||||
WHERE did > $1 AND ($2::text IS NULL OR handle ILIKE $2)
|
||||
ORDER BY did ASC
|
||||
@@ -74,12 +74,12 @@ pub async fn search_accounts(
|
||||
let accounts: Vec<AccountView> = rows
|
||||
.into_iter()
|
||||
.take(limit as usize)
|
||||
.map(|(did, handle, email, created_at, email_confirmed, deactivated_at)| AccountView {
|
||||
.map(|(did, handle, email, created_at, email_verified, deactivated_at)| AccountView {
|
||||
did: did.clone(),
|
||||
handle,
|
||||
email,
|
||||
indexed_at: created_at.to_rfc3339(),
|
||||
email_confirmed_at: if email_confirmed {
|
||||
email_verified_at: if email_verified {
|
||||
Some(created_at.to_rfc3339())
|
||||
} else {
|
||||
None
|
||||
|
||||
+63
-49
@@ -322,26 +322,53 @@ pub async fn create_account(
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if let Some(code) = &input.invite_code {
|
||||
let invite_query = sqlx::query!(
|
||||
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
|
||||
code
|
||||
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
if invite_code_required && input.invite_code.as_ref().map(|c| c.trim().is_empty()).unwrap_or(true) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})),
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
match invite_query {
|
||||
Ok(Some(row)) => {
|
||||
if row.available_uses <= 0 {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response();
|
||||
.into_response();
|
||||
}
|
||||
if let Some(code) = &input.invite_code {
|
||||
if !code.trim().is_empty() {
|
||||
let invite_query = sqlx::query!(
|
||||
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
|
||||
code
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
match invite_query {
|
||||
Ok(Some(row)) => {
|
||||
if row.available_uses <= 0 {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response();
|
||||
}
|
||||
let update_invite = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
if let Err(e) = update_invite {
|
||||
error!("Error updating invite code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let update_invite = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
if let Err(e) = update_invite {
|
||||
error!("Error updating invite code: {:?}", e);
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error checking invite code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -349,21 +376,6 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error checking invite code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
let password_hash = match hash(&input.password, DEFAULT_COST) {
|
||||
@@ -387,10 +399,10 @@ pub async fn create_account(
|
||||
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
|
||||
r#"INSERT INTO users (
|
||||
handle, email, did, password_hash,
|
||||
preferred_notification_channel,
|
||||
preferred_comms_channel,
|
||||
discord_id, telegram_username, signal_number,
|
||||
is_admin
|
||||
) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8, $9) RETURNING id"#,
|
||||
) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9) RETURNING id"#,
|
||||
)
|
||||
.bind(short_handle)
|
||||
.bind(&email)
|
||||
@@ -598,20 +610,22 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
if let Some(code) = &input.invite_code {
|
||||
let use_insert = sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
if let Err(e) = use_insert {
|
||||
error!("Error recording invite usage: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
if !code.trim().is_empty() {
|
||||
let use_insert = sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.into_response();
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
if let Err(e) = use_insert {
|
||||
error!("Error recording invite usage: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = tx.commit().await {
|
||||
@@ -646,7 +660,7 @@ pub async fn create_account(
|
||||
{
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = crate::notifications::enqueue_signup_verification(
|
||||
if let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
verification_channel,
|
||||
|
||||
+106
-11
@@ -53,11 +53,22 @@ pub async fn resolve_handle(
|
||||
.await;
|
||||
(StatusCode::OK, Json(json!({ "did": row.did }))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => {
|
||||
match crate::handle::resolve_handle(handle).await {
|
||||
Ok(did) => {
|
||||
let _ = state
|
||||
.cache
|
||||
.set(&cache_key, &did, std::time::Duration::from_secs(300))
|
||||
.await;
|
||||
(StatusCode::OK, Json(json!({ "did": did }))).into_response()
|
||||
}
|
||||
Err(_) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error resolving handle: {:?}", e);
|
||||
(
|
||||
@@ -396,6 +407,54 @@ pub async fn update_handle(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname);
|
||||
let (handle_to_store, full_handle) = if is_service_domain {
|
||||
let suffix = format!(".{}", hostname);
|
||||
let short_handle = if new_handle.ends_with(&suffix) {
|
||||
new_handle.strip_suffix(&suffix).unwrap_or(new_handle)
|
||||
} else {
|
||||
new_handle
|
||||
};
|
||||
(short_handle.to_string(), format!("{}.{}", short_handle, hostname))
|
||||
} else {
|
||||
match crate::handle::verify_handle_ownership(new_handle, &did).await {
|
||||
Ok(()) => {}
|
||||
Err(crate::handle::HandleResolutionError::NotFound) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "HandleNotAvailable",
|
||||
"message": "Handle verification failed. Please set up DNS TXT record at _atproto.{} or serve your DID at https://{}/.well-known/atproto-did",
|
||||
"handle": new_handle
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "HandleNotAvailable",
|
||||
"message": format!("Handle points to different DID. Expected {}, got {}", expected, actual)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Handle verification failed: {}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "HandleNotAvailable",
|
||||
"message": format!("Handle verification failed: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
(new_handle.to_string(), new_handle.to_string())
|
||||
};
|
||||
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -403,7 +462,7 @@ pub async fn update_handle(
|
||||
.flatten();
|
||||
let existing = sqlx::query!(
|
||||
"SELECT id FROM users WHERE handle = $1 AND id != $2",
|
||||
new_handle,
|
||||
handle_to_store,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -417,7 +476,7 @@ pub async fn update_handle(
|
||||
}
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET handle = $1 WHERE id = $2",
|
||||
new_handle,
|
||||
handle_to_store,
|
||||
user_id
|
||||
)
|
||||
.execute(&state.db)
|
||||
@@ -427,16 +486,20 @@ pub async fn update_handle(
|
||||
if let Some(old) = old_handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", old)).await;
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", new_handle)).await;
|
||||
let hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let full_handle = format!("{}.{}", new_handle, hostname);
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&format!("handle:{}", handle_to_store))
|
||||
.await;
|
||||
let _ = state.cache.delete(&format!("handle:{}", full_handle)).await;
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
if let Err(e) = update_plc_handle(&state, &did, &full_handle).await {
|
||||
warn!("Failed to update PLC handle: {}", e);
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -450,6 +513,38 @@ pub async fn update_handle(
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_plc_handle(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
new_handle: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if !did.starts_with("did:plc:") {
|
||||
return Ok(());
|
||||
}
|
||||
let user_row = sqlx::query!(
|
||||
r#"SELECT u.id, uk.key_bytes, uk.encryption_version
|
||||
FROM users u
|
||||
JOIN user_keys uk ON u.id = uk.user_id
|
||||
WHERE u.did = $1"#,
|
||||
did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let user_row = match user_row {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let key_bytes = crate::config::decrypt_key(&user_row.key_bytes, user_row.encryption_version)?;
|
||||
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)?;
|
||||
let plc_client = crate::plc::PlcClient::new(None);
|
||||
let last_op = plc_client.get_last_op(did).await?;
|
||||
let new_also_known_as = vec![format!("at://{}", new_handle)];
|
||||
let update_op = crate::plc::create_update_op(&last_op, None, None, Some(new_also_known_as), None)?;
|
||||
let signed_op = crate::plc::sign_operation(&update_op, &signing_key)?;
|
||||
plc_client.send_operation(did, &signed_op).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let host = match headers.get("host").and_then(|h| h.to_str().ok()) {
|
||||
Some(h) => h,
|
||||
|
||||
@@ -68,7 +68,7 @@ pub async fn request_plc_operation_signature(
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) =
|
||||
crate::notifications::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await
|
||||
crate::comms::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await
|
||||
{
|
||||
warn!("Failed to enqueue PLC operation notification: {:?}", e);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub async fn get_notification_prefs(State(state): State<AppState>, headers: Head
|
||||
r#"
|
||||
SELECT
|
||||
email,
|
||||
preferred_notification_channel::text as channel,
|
||||
preferred_comms_channel::text as channel,
|
||||
discord_id,
|
||||
discord_verified,
|
||||
telegram_username,
|
||||
@@ -110,7 +110,7 @@ pub async fn get_notification_prefs(State(state): State<AppState>, headers: Head
|
||||
pub struct NotificationHistoryEntry {
|
||||
pub created_at: String,
|
||||
pub channel: String,
|
||||
pub notification_type: String,
|
||||
pub comms_type: String,
|
||||
pub status: String,
|
||||
pub subject: Option<String>,
|
||||
pub body: String,
|
||||
@@ -164,11 +164,11 @@ pub async fn get_notification_history(
|
||||
SELECT
|
||||
created_at,
|
||||
channel as "channel: String",
|
||||
notification_type as "notification_type: String",
|
||||
comms_type as "comms_type: String",
|
||||
status as "status: String",
|
||||
subject,
|
||||
body
|
||||
FROM notification_queue
|
||||
FROM comms_queue
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
@@ -190,7 +190,7 @@ pub async fn get_notification_history(
|
||||
NotificationHistoryEntry {
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
channel: row.channel.clone(),
|
||||
notification_type: row.notification_type.clone(),
|
||||
comms_type: row.comms_type.clone(),
|
||||
status: row.status.clone(),
|
||||
subject: row.subject.clone(),
|
||||
body: row.body.clone(),
|
||||
@@ -231,7 +231,7 @@ pub async fn request_channel_verification(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
|
||||
VALUES ($1, $2::notification_channel, $3, $4, $5)
|
||||
VALUES ($1, $2::comms_channel, $3, $4, $5)
|
||||
ON CONFLICT (user_id, channel) DO UPDATE
|
||||
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
|
||||
"#,
|
||||
@@ -248,14 +248,14 @@ pub async fn request_channel_verification(
|
||||
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)
|
||||
crate::comms::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)
|
||||
INSERT INTO comms_queue (user_id, channel, comms_type, recipient, subject, body, metadata)
|
||||
VALUES ($1, $2::comms_channel, 'channel_verification', $3, 'Verify your channel', $4, $5)
|
||||
"#,
|
||||
user_id,
|
||||
channel as _,
|
||||
@@ -331,7 +331,7 @@ pub async fn update_notification_prefs(
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"#
|
||||
r#"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2"#
|
||||
)
|
||||
.bind(channel)
|
||||
.bind(&user.did)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::validation::validate_record;
|
||||
use super::write::has_verified_notification_channel;
|
||||
use super::write::has_verified_comms_channel;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
@@ -109,7 +109,7 @@ pub async fn apply_writes(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match has_verified_notification_channel(&state.db, &did).await {
|
||||
match has_verified_comms_channel(&state.db, &did).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return (
|
||||
|
||||
@@ -22,14 +22,14 @@ use std::sync::Arc;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn has_verified_notification_channel(
|
||||
pub async fn has_verified_comms_channel(
|
||||
db: &PgPool,
|
||||
did: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
email_confirmed,
|
||||
email_verified,
|
||||
discord_verified,
|
||||
telegram_verified,
|
||||
signal_verified
|
||||
@@ -42,11 +42,11 @@ pub async fn has_verified_notification_channel(
|
||||
.await?;
|
||||
match row {
|
||||
Some(r) => {
|
||||
let email_confirmed: bool = r.get("email_confirmed");
|
||||
let email_verified: bool = r.get("email_verified");
|
||||
let discord_verified: bool = r.get("discord_verified");
|
||||
let telegram_verified: bool = r.get("telegram_verified");
|
||||
let signal_verified: bool = r.get("signal_verified");
|
||||
Ok(email_confirmed || discord_verified || telegram_verified || signal_verified)
|
||||
Ok(email_verified || discord_verified || telegram_verified || signal_verified)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
@@ -96,7 +96,7 @@ pub async fn prepare_repo_write(
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
match has_verified_notification_channel(&state.db, &auth_user.did).await {
|
||||
match has_verified_comms_channel(&state.db, &auth_user.did).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Err((
|
||||
|
||||
@@ -299,7 +299,7 @@ pub async fn request_account_delete(
|
||||
.into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_account_deletion(
|
||||
if let Err(e) = crate::comms::enqueue_account_deletion(
|
||||
&state.db,
|
||||
user_id,
|
||||
&confirmation_token,
|
||||
|
||||
@@ -100,7 +100,7 @@ pub async fn request_password_reset(
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) =
|
||||
crate::notifications::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
|
||||
crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
|
||||
{
|
||||
warn!("Failed to enqueue password reset notification: {:?}", e);
|
||||
}
|
||||
|
||||
+52
-44
@@ -35,6 +35,14 @@ fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn full_handle(stored_handle: &str, pds_hostname: &str) -> String {
|
||||
if stored_handle.contains('.') {
|
||||
stored_handle.to_string()
|
||||
} else {
|
||||
format!("{}.{}", stored_handle, pds_hostname)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSessionInput {
|
||||
pub identifier: String,
|
||||
@@ -76,7 +84,7 @@ pub async fn create_session(
|
||||
let row = match sqlx::query!(
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle, u.password_hash,
|
||||
u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,
|
||||
u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
|
||||
k.key_bytes, k.encryption_version
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
@@ -128,7 +136,7 @@ pub async fn create_session(
|
||||
.into_response();
|
||||
}
|
||||
let is_verified =
|
||||
row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
if !is_verified {
|
||||
warn!("Login attempt for unverified account: {}", row.did);
|
||||
return (
|
||||
@@ -169,11 +177,11 @@ pub async fn create_session(
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
let full_handle = format!("{}.{}", row.handle, pds_hostname);
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
Json(CreateSessionOutput {
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle: full_handle,
|
||||
handle,
|
||||
did: row.did,
|
||||
})
|
||||
.into_response()
|
||||
@@ -185,8 +193,8 @@ pub async fn get_session(
|
||||
) -> Response {
|
||||
match sqlx::query!(
|
||||
r#"SELECT
|
||||
handle, email, email_confirmed, is_admin,
|
||||
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
|
||||
handle, email, email_verified, is_admin,
|
||||
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
|
||||
discord_verified, telegram_verified, signal_verified
|
||||
FROM users WHERE did = $1"#,
|
||||
auth_user.did
|
||||
@@ -196,18 +204,18 @@ pub async fn get_session(
|
||||
{
|
||||
Ok(Some(row)) => {
|
||||
let (preferred_channel, preferred_channel_verified) = match row.preferred_channel {
|
||||
crate::notifications::NotificationChannel::Email => ("email", row.email_confirmed),
|
||||
crate::notifications::NotificationChannel::Discord => ("discord", row.discord_verified),
|
||||
crate::notifications::NotificationChannel::Telegram => ("telegram", row.telegram_verified),
|
||||
crate::notifications::NotificationChannel::Signal => ("signal", row.signal_verified),
|
||||
crate::comms::CommsChannel::Email => ("email", row.email_verified),
|
||||
crate::comms::CommsChannel::Discord => ("discord", row.discord_verified),
|
||||
crate::comms::CommsChannel::Telegram => ("telegram", row.telegram_verified),
|
||||
crate::comms::CommsChannel::Signal => ("signal", row.signal_verified),
|
||||
};
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let full_handle = format!("{}.{}", row.handle, pds_hostname);
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
Json(json!({
|
||||
"handle": full_handle,
|
||||
"handle": handle,
|
||||
"did": auth_user.did,
|
||||
"email": row.email,
|
||||
"emailConfirmed": row.email_confirmed,
|
||||
"emailVerified": row.email_verified,
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"isAdmin": row.is_admin,
|
||||
@@ -407,8 +415,8 @@ pub async fn refresh_session(
|
||||
}
|
||||
match sqlx::query!(
|
||||
r#"SELECT
|
||||
handle, email, email_confirmed, is_admin,
|
||||
preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
|
||||
handle, email, email_verified, is_admin,
|
||||
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
|
||||
discord_verified, telegram_verified, signal_verified
|
||||
FROM users WHERE did = $1"#,
|
||||
session_row.did
|
||||
@@ -418,20 +426,20 @@ pub async fn refresh_session(
|
||||
{
|
||||
Ok(Some(u)) => {
|
||||
let (preferred_channel, preferred_channel_verified) = match u.preferred_channel {
|
||||
crate::notifications::NotificationChannel::Email => ("email", u.email_confirmed),
|
||||
crate::notifications::NotificationChannel::Discord => ("discord", u.discord_verified),
|
||||
crate::notifications::NotificationChannel::Telegram => ("telegram", u.telegram_verified),
|
||||
crate::notifications::NotificationChannel::Signal => ("signal", u.signal_verified),
|
||||
crate::comms::CommsChannel::Email => ("email", u.email_verified),
|
||||
crate::comms::CommsChannel::Discord => ("discord", u.discord_verified),
|
||||
crate::comms::CommsChannel::Telegram => ("telegram", u.telegram_verified),
|
||||
crate::comms::CommsChannel::Signal => ("signal", u.signal_verified),
|
||||
};
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let full_handle = format!("{}.{}", u.handle, pds_hostname);
|
||||
let handle = full_handle(&u.handle, &pds_hostname);
|
||||
Json(json!({
|
||||
"accessJwt": new_access_meta.token,
|
||||
"refreshJwt": new_refresh_meta.token,
|
||||
"handle": full_handle,
|
||||
"handle": handle,
|
||||
"did": session_row.did,
|
||||
"email": u.email,
|
||||
"emailConfirmed": u.email_confirmed,
|
||||
"emailVerified": u.email_verified,
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"isAdmin": u.is_admin,
|
||||
@@ -464,7 +472,7 @@ pub struct ConfirmSignupOutput {
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
pub email: Option<String>,
|
||||
pub email_confirmed: bool,
|
||||
pub email_verified: bool,
|
||||
pub preferred_channel: String,
|
||||
pub preferred_channel_verified: bool,
|
||||
}
|
||||
@@ -477,7 +485,7 @@ pub async fn confirm_signup(
|
||||
let row = match sqlx::query!(
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle, u.email,
|
||||
u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
|
||||
u.preferred_comms_channel as "channel: crate::comms::CommsChannel",
|
||||
k.key_bytes, k.encryption_version
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
@@ -534,10 +542,10 @@ pub async fn confirm_signup(
|
||||
}
|
||||
};
|
||||
let verified_column = match row.channel {
|
||||
crate::notifications::NotificationChannel::Email => "email_confirmed",
|
||||
crate::notifications::NotificationChannel::Discord => "discord_verified",
|
||||
crate::notifications::NotificationChannel::Telegram => "telegram_verified",
|
||||
crate::notifications::NotificationChannel::Signal => "signal_verified",
|
||||
crate::comms::CommsChannel::Email => "email_verified",
|
||||
crate::comms::CommsChannel::Discord => "discord_verified",
|
||||
crate::comms::CommsChannel::Telegram => "telegram_verified",
|
||||
crate::comms::CommsChannel::Signal => "signal_verified",
|
||||
};
|
||||
let update_query = format!(
|
||||
"UPDATE users SET {} = TRUE WHERE did = $1",
|
||||
@@ -590,18 +598,18 @@ pub async fn confirm_signup(
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
|
||||
if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await {
|
||||
warn!("Failed to enqueue welcome notification: {:?}", e);
|
||||
}
|
||||
let email_confirmed = matches!(
|
||||
let email_verified = matches!(
|
||||
row.channel,
|
||||
crate::notifications::NotificationChannel::Email
|
||||
crate::comms::CommsChannel::Email
|
||||
);
|
||||
let preferred_channel = match row.channel {
|
||||
crate::notifications::NotificationChannel::Email => "email",
|
||||
crate::notifications::NotificationChannel::Discord => "discord",
|
||||
crate::notifications::NotificationChannel::Telegram => "telegram",
|
||||
crate::notifications::NotificationChannel::Signal => "signal",
|
||||
crate::comms::CommsChannel::Email => "email",
|
||||
crate::comms::CommsChannel::Discord => "discord",
|
||||
crate::comms::CommsChannel::Telegram => "telegram",
|
||||
crate::comms::CommsChannel::Signal => "signal",
|
||||
};
|
||||
Json(ConfirmSignupOutput {
|
||||
access_jwt: access_meta.token,
|
||||
@@ -609,7 +617,7 @@ pub async fn confirm_signup(
|
||||
handle: row.handle,
|
||||
did: row.did,
|
||||
email: row.email,
|
||||
email_confirmed,
|
||||
email_verified,
|
||||
preferred_channel: preferred_channel.to_string(),
|
||||
preferred_channel_verified: true,
|
||||
})
|
||||
@@ -630,9 +638,9 @@ pub async fn resend_verification(
|
||||
let row = match sqlx::query!(
|
||||
r#"SELECT
|
||||
id, handle, email,
|
||||
preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
|
||||
preferred_comms_channel as "channel: crate::comms::CommsChannel",
|
||||
discord_id, telegram_username, signal_number,
|
||||
email_confirmed, discord_verified, telegram_verified, signal_verified
|
||||
email_verified, discord_verified, telegram_verified, signal_verified
|
||||
FROM users
|
||||
WHERE did = $1"#,
|
||||
input.did
|
||||
@@ -650,7 +658,7 @@ pub async fn resend_verification(
|
||||
}
|
||||
};
|
||||
let is_verified =
|
||||
row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
if is_verified {
|
||||
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
|
||||
}
|
||||
@@ -678,20 +686,20 @@ pub async fn resend_verification(
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
let (channel_str, recipient) = match row.channel {
|
||||
crate::notifications::NotificationChannel::Email => {
|
||||
crate::comms::CommsChannel::Email => {
|
||||
("email", row.email.unwrap_or_default())
|
||||
}
|
||||
crate::notifications::NotificationChannel::Discord => {
|
||||
crate::comms::CommsChannel::Discord => {
|
||||
("discord", row.discord_id.unwrap_or_default())
|
||||
}
|
||||
crate::notifications::NotificationChannel::Telegram => {
|
||||
crate::comms::CommsChannel::Telegram => {
|
||||
("telegram", row.telegram_username.unwrap_or_default())
|
||||
}
|
||||
crate::notifications::NotificationChannel::Signal => {
|
||||
crate::comms::CommsChannel::Signal => {
|
||||
("signal", row.signal_number.unwrap_or_default())
|
||||
}
|
||||
};
|
||||
if let Err(e) = crate::notifications::enqueue_signup_verification(
|
||||
if let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
&state.db,
|
||||
row.id,
|
||||
channel_str,
|
||||
|
||||
@@ -68,7 +68,7 @@ pub async fn confirm_channel_verification(
|
||||
let record = match sqlx::query!(
|
||||
r#"
|
||||
SELECT code, pending_identifier, expires_at FROM channel_verifications
|
||||
WHERE user_id = $1 AND channel = $2::notification_channel
|
||||
WHERE user_id = $1 AND channel = $2::comms_channel
|
||||
"#,
|
||||
user_id,
|
||||
channel_str as _
|
||||
@@ -163,7 +163,7 @@ pub async fn confirm_channel_verification(
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::notification_channel",
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
|
||||
user_id,
|
||||
channel_str as _
|
||||
)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
mod sender;
|
||||
mod service;
|
||||
mod types;
|
||||
|
||||
pub use sender::{
|
||||
CommsSender, DiscordSender, EmailSender, SendError, SignalSender, TelegramSender,
|
||||
is_valid_phone_number, sanitize_header_value,
|
||||
};
|
||||
|
||||
pub use service::{
|
||||
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion,
|
||||
enqueue_comms, enqueue_email_update, enqueue_email_verification, enqueue_password_reset,
|
||||
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
|
||||
};
|
||||
|
||||
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
||||
@@ -6,16 +6,16 @@ use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::types::{NotificationChannel, QueuedNotification};
|
||||
use super::types::{CommsChannel, QueuedComms};
|
||||
|
||||
const HTTP_TIMEOUT_SECS: u64 = 30;
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
const INITIAL_RETRY_DELAY_MS: u64 = 500;
|
||||
|
||||
#[async_trait]
|
||||
pub trait NotificationSender: Send + Sync {
|
||||
fn channel(&self) -> NotificationChannel;
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError>;
|
||||
pub trait CommsSender: Send + Sync {
|
||||
fn channel(&self) -> CommsChannel;
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -25,7 +25,7 @@ pub enum SendError {
|
||||
#[error("Sendmail exited with non-zero status: {0}")]
|
||||
SendmailFailed(String),
|
||||
#[error("Channel not configured: {0:?}")]
|
||||
NotConfigured(NotificationChannel),
|
||||
NotConfigured(CommsChannel),
|
||||
#[error("External service error: {0}")]
|
||||
ExternalService(String),
|
||||
#[error("Invalid recipient format: {0}")]
|
||||
@@ -91,7 +91,7 @@ impl EmailSender {
|
||||
Some(Self::new(from_address, from_name))
|
||||
}
|
||||
|
||||
pub fn format_email(&self, notification: &QueuedNotification) -> String {
|
||||
pub fn format_email(&self, notification: &QueuedComms) -> String {
|
||||
let subject =
|
||||
sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification"));
|
||||
let recipient = sanitize_header_value(¬ification.recipient);
|
||||
@@ -112,12 +112,12 @@ impl EmailSender {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationSender for EmailSender {
|
||||
fn channel(&self) -> NotificationChannel {
|
||||
NotificationChannel::Email
|
||||
impl CommsSender for EmailSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
CommsChannel::Email
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let email_content = self.format_email(notification);
|
||||
let mut child = Command::new(&self.sendmail_path)
|
||||
.arg("-t")
|
||||
@@ -158,12 +158,12 @@ impl DiscordSender {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationSender for DiscordSender {
|
||||
fn channel(&self) -> NotificationChannel {
|
||||
NotificationChannel::Discord
|
||||
impl CommsSender for DiscordSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
CommsChannel::Discord
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||
let content = format!("**{}**\n\n{}", subject, notification.body);
|
||||
let payload = json!({
|
||||
@@ -237,12 +237,12 @@ impl TelegramSender {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationSender for TelegramSender {
|
||||
fn channel(&self) -> NotificationChannel {
|
||||
NotificationChannel::Telegram
|
||||
impl CommsSender for TelegramSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
CommsChannel::Telegram
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let chat_id = ¬ification.recipient;
|
||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||
let text = format!("*{}*\n\n{}", subject, notification.body);
|
||||
@@ -316,12 +316,12 @@ impl SignalSender {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationSender for SignalSender {
|
||||
fn channel(&self) -> NotificationChannel {
|
||||
NotificationChannel::Signal
|
||||
impl CommsSender for SignalSender {
|
||||
fn channel(&self) -> CommsChannel {
|
||||
CommsChannel::Signal
|
||||
}
|
||||
|
||||
async fn send(&self, notification: &QueuedNotification) -> Result<(), SendError> {
|
||||
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
|
||||
let recipient = ¬ification.recipient;
|
||||
if !is_valid_phone_number(recipient) {
|
||||
return Err(SendError::InvalidRecipient(format!(
|
||||
@@ -9,17 +9,17 @@ use tokio::time::interval;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::sender::{NotificationSender, SendError};
|
||||
use super::types::{NewNotification, NotificationChannel, NotificationStatus, QueuedNotification};
|
||||
use super::sender::{CommsSender, SendError};
|
||||
use super::types::{NewComms, CommsChannel, CommsStatus, QueuedComms};
|
||||
|
||||
pub struct NotificationService {
|
||||
pub struct CommsService {
|
||||
db: PgPool,
|
||||
senders: HashMap<NotificationChannel, Arc<dyn NotificationSender>>,
|
||||
senders: HashMap<CommsChannel, Arc<dyn CommsSender>>,
|
||||
poll_interval: Duration,
|
||||
batch_size: i64,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
impl CommsService {
|
||||
pub fn new(db: PgPool) -> Self {
|
||||
let poll_interval_ms: u64 = std::env::var("NOTIFICATION_POLL_INTERVAL_MS")
|
||||
.ok()
|
||||
@@ -47,30 +47,30 @@ impl NotificationService {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn register_sender<S: NotificationSender + 'static>(mut self, sender: S) -> Self {
|
||||
pub fn register_sender<S: CommsSender + 'static>(mut self, sender: S) -> Self {
|
||||
self.senders.insert(sender.channel(), Arc::new(sender));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn enqueue(&self, notification: NewNotification) -> Result<Uuid, sqlx::Error> {
|
||||
pub async fn enqueue(&self, item: NewComms) -> Result<Uuid, sqlx::Error> {
|
||||
let id = sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO notification_queue
|
||||
(user_id, channel, notification_type, recipient, subject, body, metadata)
|
||||
INSERT INTO comms_queue
|
||||
(user_id, channel, comms_type, recipient, subject, body, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
"#,
|
||||
notification.user_id,
|
||||
notification.channel as NotificationChannel,
|
||||
notification.notification_type as super::types::NotificationType,
|
||||
notification.recipient,
|
||||
notification.subject,
|
||||
notification.body,
|
||||
notification.metadata
|
||||
item.user_id,
|
||||
item.channel as CommsChannel,
|
||||
item.comms_type as super::types::CommsType,
|
||||
item.recipient,
|
||||
item.subject,
|
||||
item.body,
|
||||
item.metadata
|
||||
)
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
debug!(notification_id = %id, "Notification enqueued");
|
||||
debug!(comms_id = %id, "Comms enqueued");
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -81,26 +81,26 @@ impl NotificationService {
|
||||
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
|
||||
if self.senders.is_empty() {
|
||||
warn!(
|
||||
"Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured."
|
||||
"Comms service starting with no senders configured. Messages will be queued but not delivered until senders are configured."
|
||||
);
|
||||
}
|
||||
info!(
|
||||
poll_interval_secs = self.poll_interval.as_secs(),
|
||||
batch_size = self.batch_size,
|
||||
channels = ?self.senders.keys().collect::<Vec<_>>(),
|
||||
"Starting notification service"
|
||||
"Starting comms service"
|
||||
);
|
||||
let mut ticker = interval(self.poll_interval);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ticker.tick() => {
|
||||
if let Err(e) = self.process_batch().await {
|
||||
error!(error = %e, "Failed to process notification batch");
|
||||
error!(error = %e, "Failed to process comms batch");
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
info!("Notification service shutting down");
|
||||
info!("Comms service shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -109,26 +109,26 @@ impl NotificationService {
|
||||
}
|
||||
|
||||
async fn process_batch(&self) -> Result<(), sqlx::Error> {
|
||||
let notifications = self.fetch_pending_notifications().await?;
|
||||
if notifications.is_empty() {
|
||||
let items = self.fetch_pending().await?;
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
debug!(count = notifications.len(), "Processing notification batch");
|
||||
for notification in notifications {
|
||||
self.process_notification(notification).await;
|
||||
debug!(count = items.len(), "Processing comms batch");
|
||||
for item in items {
|
||||
self.process_item(item).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_pending_notifications(&self) -> Result<Vec<QueuedNotification>, sqlx::Error> {
|
||||
async fn fetch_pending(&self) -> Result<Vec<QueuedComms>, sqlx::Error> {
|
||||
let now = Utc::now();
|
||||
sqlx::query_as!(
|
||||
QueuedNotification,
|
||||
QueuedComms,
|
||||
r#"
|
||||
UPDATE notification_queue
|
||||
UPDATE comms_queue
|
||||
SET status = 'processing', updated_at = NOW()
|
||||
WHERE id IN (
|
||||
SELECT id FROM notification_queue
|
||||
SELECT id FROM comms_queue
|
||||
WHERE status = 'pending'
|
||||
AND scheduled_for <= $1
|
||||
AND attempts < max_attempts
|
||||
@@ -138,9 +138,9 @@ impl NotificationService {
|
||||
)
|
||||
RETURNING
|
||||
id, user_id,
|
||||
channel as "channel: NotificationChannel",
|
||||
notification_type as "notification_type: super::types::NotificationType",
|
||||
status as "status: NotificationStatus",
|
||||
channel as "channel: CommsChannel",
|
||||
comms_type as "comms_type: super::types::CommsType",
|
||||
status as "status: CommsStatus",
|
||||
recipient, subject, body, metadata,
|
||||
attempts, max_attempts, last_error,
|
||||
created_at, updated_at, scheduled_for, processed_at
|
||||
@@ -152,14 +152,14 @@ impl NotificationService {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn process_notification(&self, notification: QueuedNotification) {
|
||||
let notification_id = notification.id;
|
||||
let channel = notification.channel;
|
||||
async fn process_item(&self, item: QueuedComms) {
|
||||
let comms_id = item.id;
|
||||
let channel = item.channel;
|
||||
let result = match self.senders.get(&channel) {
|
||||
Some(sender) => sender.send(¬ification).await,
|
||||
Some(sender) => sender.send(&item).await,
|
||||
None => {
|
||||
warn!(
|
||||
notification_id = %notification_id,
|
||||
comms_id = %comms_id,
|
||||
channel = ?channel,
|
||||
"No sender registered for channel"
|
||||
);
|
||||
@@ -168,27 +168,27 @@ impl NotificationService {
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
debug!(notification_id = %notification_id, "Notification sent successfully");
|
||||
if let Err(e) = self.mark_sent(notification_id).await {
|
||||
debug!(comms_id = %comms_id, "Comms sent successfully");
|
||||
if let Err(e) = self.mark_sent(comms_id).await {
|
||||
error!(
|
||||
notification_id = %notification_id,
|
||||
comms_id = %comms_id,
|
||||
error = %e,
|
||||
"Failed to mark notification as sent"
|
||||
"Failed to mark comms as sent"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
warn!(
|
||||
notification_id = %notification_id,
|
||||
comms_id = %comms_id,
|
||||
error = %error_msg,
|
||||
"Failed to send notification"
|
||||
"Failed to send comms"
|
||||
);
|
||||
if let Err(db_err) = self.mark_failed(notification_id, &error_msg).await {
|
||||
if let Err(db_err) = self.mark_failed(comms_id, &error_msg).await {
|
||||
error!(
|
||||
notification_id = %notification_id,
|
||||
comms_id = %comms_id,
|
||||
error = %db_err,
|
||||
"Failed to mark notification as failed"
|
||||
"Failed to mark comms as failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ impl NotificationService {
|
||||
async fn mark_sent(&self, id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE notification_queue
|
||||
UPDATE comms_queue
|
||||
SET status = 'sent', processed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -212,11 +212,11 @@ impl NotificationService {
|
||||
async fn mark_failed(&self, id: Uuid, error: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE notification_queue
|
||||
UPDATE comms_queue
|
||||
SET
|
||||
status = CASE
|
||||
WHEN attempts + 1 >= max_attempts THEN 'failed'::notification_status
|
||||
ELSE 'pending'::notification_status
|
||||
WHEN attempts + 1 >= max_attempts THEN 'failed'::comms_status
|
||||
ELSE 'pending'::comms_status
|
||||
END,
|
||||
attempts = attempts + 1,
|
||||
last_error = $2,
|
||||
@@ -233,45 +233,42 @@ impl NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enqueue_notification(
|
||||
db: &PgPool,
|
||||
notification: NewNotification,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
pub async fn enqueue_comms(db: &PgPool, item: NewComms) -> Result<Uuid, sqlx::Error> {
|
||||
sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO notification_queue
|
||||
(user_id, channel, notification_type, recipient, subject, body, metadata)
|
||||
INSERT INTO comms_queue
|
||||
(user_id, channel, comms_type, recipient, subject, body, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
"#,
|
||||
notification.user_id,
|
||||
notification.channel as NotificationChannel,
|
||||
notification.notification_type as super::types::NotificationType,
|
||||
notification.recipient,
|
||||
notification.subject,
|
||||
notification.body,
|
||||
notification.metadata
|
||||
item.user_id,
|
||||
item.channel as CommsChannel,
|
||||
item.comms_type as super::types::CommsType,
|
||||
item.recipient,
|
||||
item.subject,
|
||||
item.body,
|
||||
item.metadata
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub struct UserNotificationPrefs {
|
||||
pub channel: NotificationChannel,
|
||||
pub struct UserCommsPrefs {
|
||||
pub channel: CommsChannel,
|
||||
pub email: Option<String>,
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn get_user_notification_prefs(
|
||||
pub async fn get_user_comms_prefs(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<UserNotificationPrefs, sqlx::Error> {
|
||||
) -> Result<UserCommsPrefs, sqlx::Error> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
email,
|
||||
handle,
|
||||
preferred_notification_channel as "channel: NotificationChannel"
|
||||
preferred_comms_channel as "channel: CommsChannel"
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -279,7 +276,7 @@ pub async fn get_user_notification_prefs(
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
Ok(UserNotificationPrefs {
|
||||
Ok(UserCommsPrefs {
|
||||
channel: row.channel,
|
||||
email: row.email,
|
||||
handle: row.handle,
|
||||
@@ -291,17 +288,17 @@ pub async fn enqueue_welcome(
|
||||
user_id: Uuid,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_notification_prefs(db, user_id).await?;
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let body = format!(
|
||||
"Welcome to {}!\n\nYour handle is: @{}\n\nThank you for joining us.",
|
||||
hostname, prefs.handle
|
||||
);
|
||||
enqueue_notification(
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewNotification::new(
|
||||
NewComms::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::Welcome,
|
||||
super::types::CommsType::Welcome,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Welcome to {}", hostname)),
|
||||
body,
|
||||
@@ -322,11 +319,11 @@ pub async fn enqueue_email_verification(
|
||||
"Hello @{},\n\nYour email verification code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
|
||||
handle, code
|
||||
);
|
||||
enqueue_notification(
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewNotification::email(
|
||||
NewComms::email(
|
||||
user_id,
|
||||
super::types::NotificationType::EmailVerification,
|
||||
super::types::CommsType::EmailVerification,
|
||||
email.to_string(),
|
||||
format!("Verify your email - {}", hostname),
|
||||
body,
|
||||
@@ -341,17 +338,17 @@ pub async fn enqueue_password_reset(
|
||||
code: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_notification_prefs(db, user_id).await?;
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let body = format!(
|
||||
"Hello @{},\n\nYour password reset code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.",
|
||||
prefs.handle, code
|
||||
);
|
||||
enqueue_notification(
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewNotification::new(
|
||||
NewComms::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::PasswordReset,
|
||||
super::types::CommsType::PasswordReset,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Password Reset - {}", hostname)),
|
||||
body,
|
||||
@@ -372,11 +369,11 @@ pub async fn enqueue_email_update(
|
||||
"Hello @{},\n\nYour email update confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
|
||||
handle, code
|
||||
);
|
||||
enqueue_notification(
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewNotification::email(
|
||||
NewComms::email(
|
||||
user_id,
|
||||
super::types::NotificationType::EmailUpdate,
|
||||
super::types::CommsType::EmailUpdate,
|
||||
new_email.to_string(),
|
||||
format!("Confirm your new email - {}", hostname),
|
||||
body,
|
||||
@@ -391,17 +388,17 @@ pub async fn enqueue_account_deletion(
|
||||
code: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_notification_prefs(db, user_id).await?;
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let body = format!(
|
||||
"Hello @{},\n\nYour account deletion confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
|
||||
prefs.handle, code
|
||||
);
|
||||
enqueue_notification(
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewNotification::new(
|
||||
NewComms::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::AccountDeletion,
|
||||
super::types::CommsType::AccountDeletion,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Account Deletion Request - {}", hostname)),
|
||||
body,
|
||||
@@ -416,17 +413,17 @@ pub async fn enqueue_plc_operation(
|
||||
token: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_notification_prefs(db, user_id).await?;
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let body = format!(
|
||||
"Hello @{},\n\nYou requested to sign a PLC operation for your account.\n\nYour verification token is: {}\n\nThis token will expire in 10 minutes.\n\nIf you did not request this, you can safely ignore this message.",
|
||||
prefs.handle, token
|
||||
);
|
||||
enqueue_notification(
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewNotification::new(
|
||||
NewComms::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::PlcOperation,
|
||||
super::types::CommsType::PlcOperation,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("{} - PLC Operation Token", hostname)),
|
||||
body,
|
||||
@@ -441,17 +438,17 @@ pub async fn enqueue_2fa_code(
|
||||
code: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_notification_prefs(db, user_id).await?;
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let body = format!(
|
||||
"Hello @{},\n\nYour sign-in verification code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
|
||||
prefs.handle, code
|
||||
);
|
||||
enqueue_notification(
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewNotification::new(
|
||||
NewComms::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::TwoFactorCode,
|
||||
super::types::CommsType::TwoFactorCode,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Sign-in Verification - {}", hostname)),
|
||||
body,
|
||||
@@ -460,12 +457,12 @@ pub async fn enqueue_2fa_code(
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn channel_display_name(channel: NotificationChannel) -> &'static str {
|
||||
pub fn channel_display_name(channel: CommsChannel) -> &'static str {
|
||||
match channel {
|
||||
NotificationChannel::Email => "email",
|
||||
NotificationChannel::Discord => "Discord",
|
||||
NotificationChannel::Telegram => "Telegram",
|
||||
NotificationChannel::Signal => "Signal",
|
||||
CommsChannel::Email => "email",
|
||||
CommsChannel::Discord => "Discord",
|
||||
CommsChannel::Telegram => "Telegram",
|
||||
CommsChannel::Signal => "Signal",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,27 +474,27 @@ pub async fn enqueue_signup_verification(
|
||||
code: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let notification_channel = match channel {
|
||||
"email" => NotificationChannel::Email,
|
||||
"discord" => NotificationChannel::Discord,
|
||||
"telegram" => NotificationChannel::Telegram,
|
||||
"signal" => NotificationChannel::Signal,
|
||||
_ => NotificationChannel::Email,
|
||||
let comms_channel = match channel {
|
||||
"email" => CommsChannel::Email,
|
||||
"discord" => CommsChannel::Discord,
|
||||
"telegram" => CommsChannel::Telegram,
|
||||
"signal" => CommsChannel::Signal,
|
||||
_ => CommsChannel::Email,
|
||||
};
|
||||
let body = format!(
|
||||
"Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.",
|
||||
code, hostname
|
||||
);
|
||||
let subject = match notification_channel {
|
||||
NotificationChannel::Email => Some(format!("Verify your account - {}", hostname)),
|
||||
let subject = match comms_channel {
|
||||
CommsChannel::Email => Some(format!("Verify your account - {}", hostname)),
|
||||
_ => None,
|
||||
};
|
||||
enqueue_notification(
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewNotification::new(
|
||||
NewComms::new(
|
||||
user_id,
|
||||
notification_channel,
|
||||
super::types::NotificationType::EmailVerification,
|
||||
comms_channel,
|
||||
super::types::CommsType::EmailVerification,
|
||||
recipient.to_string(),
|
||||
subject,
|
||||
body,
|
||||
@@ -4,8 +4,8 @@ use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(type_name = "notification_channel", rename_all = "lowercase")]
|
||||
pub enum NotificationChannel {
|
||||
#[sqlx(type_name = "comms_channel", rename_all = "lowercase")]
|
||||
pub enum CommsChannel {
|
||||
Email,
|
||||
Discord,
|
||||
Telegram,
|
||||
@@ -13,8 +13,8 @@ pub enum NotificationChannel {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(type_name = "notification_status", rename_all = "lowercase")]
|
||||
pub enum NotificationStatus {
|
||||
#[sqlx(type_name = "comms_status", rename_all = "lowercase")]
|
||||
pub enum CommsStatus {
|
||||
Pending,
|
||||
Processing,
|
||||
Sent,
|
||||
@@ -22,8 +22,8 @@ pub enum NotificationStatus {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
|
||||
#[sqlx(type_name = "notification_type", rename_all = "snake_case")]
|
||||
pub enum NotificationType {
|
||||
#[sqlx(type_name = "comms_type", rename_all = "snake_case")]
|
||||
pub enum CommsType {
|
||||
Welcome,
|
||||
EmailVerification,
|
||||
PasswordReset,
|
||||
@@ -35,12 +35,12 @@ pub enum NotificationType {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct QueuedNotification {
|
||||
pub struct QueuedComms {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub channel: NotificationChannel,
|
||||
pub notification_type: NotificationType,
|
||||
pub status: NotificationStatus,
|
||||
pub channel: CommsChannel,
|
||||
pub comms_type: CommsType,
|
||||
pub status: CommsStatus,
|
||||
pub recipient: String,
|
||||
pub subject: Option<String>,
|
||||
pub body: String,
|
||||
@@ -54,21 +54,21 @@ pub struct QueuedNotification {
|
||||
pub processed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub struct NewNotification {
|
||||
pub struct NewComms {
|
||||
pub user_id: Uuid,
|
||||
pub channel: NotificationChannel,
|
||||
pub notification_type: NotificationType,
|
||||
pub channel: CommsChannel,
|
||||
pub comms_type: CommsType,
|
||||
pub recipient: String,
|
||||
pub subject: Option<String>,
|
||||
pub body: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl NewNotification {
|
||||
impl NewComms {
|
||||
pub fn new(
|
||||
user_id: Uuid,
|
||||
channel: NotificationChannel,
|
||||
notification_type: NotificationType,
|
||||
channel: CommsChannel,
|
||||
comms_type: CommsType,
|
||||
recipient: String,
|
||||
subject: Option<String>,
|
||||
body: String,
|
||||
@@ -76,7 +76,7 @@ impl NewNotification {
|
||||
Self {
|
||||
user_id,
|
||||
channel,
|
||||
notification_type,
|
||||
comms_type,
|
||||
recipient,
|
||||
subject,
|
||||
body,
|
||||
@@ -86,15 +86,15 @@ impl NewNotification {
|
||||
|
||||
pub fn email(
|
||||
user_id: Uuid,
|
||||
notification_type: NotificationType,
|
||||
comms_type: CommsType,
|
||||
recipient: String,
|
||||
subject: String,
|
||||
body: String,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
user_id,
|
||||
NotificationChannel::Email,
|
||||
notification_type,
|
||||
CommsChannel::Email,
|
||||
comms_type,
|
||||
recipient,
|
||||
Some(subject),
|
||||
body,
|
||||
@@ -0,0 +1,121 @@
|
||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum HandleResolutionError {
|
||||
#[error("DNS lookup failed: {0}")]
|
||||
DnsError(String),
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpError(String),
|
||||
#[error("No DID found for handle")]
|
||||
NotFound,
|
||||
#[error("Invalid DID format in record")]
|
||||
InvalidDid,
|
||||
#[error("DID mismatch: expected {expected}, got {actual}")]
|
||||
DidMismatch { expected: String, actual: String },
|
||||
}
|
||||
|
||||
pub async fn resolve_handle_dns(handle: &str) -> Result<String, HandleResolutionError> {
|
||||
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
|
||||
let query_name = format!("_atproto.{}", handle);
|
||||
let txt_lookup = resolver
|
||||
.txt_lookup(&query_name)
|
||||
.await
|
||||
.map_err(|e| HandleResolutionError::DnsError(e.to_string()))?;
|
||||
for record in txt_lookup.iter() {
|
||||
for txt in record.txt_data() {
|
||||
let txt_str = String::from_utf8_lossy(txt);
|
||||
if let Some(did) = txt_str.strip_prefix("did=") {
|
||||
let did = did.trim();
|
||||
if did.starts_with("did:") {
|
||||
return Ok(did.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(HandleResolutionError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn resolve_handle_http(handle: &str) -> Result<String, HandleResolutionError> {
|
||||
let url = format!("https://{}/.well-known/atproto-did", handle);
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.build()
|
||||
.map_err(|e| HandleResolutionError::HttpError(e.to_string()))?;
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Accept", "text/plain")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| HandleResolutionError::HttpError(e.to_string()))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(HandleResolutionError::NotFound);
|
||||
}
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| HandleResolutionError::HttpError(e.to_string()))?;
|
||||
let did = body.trim();
|
||||
if did.starts_with("did:") {
|
||||
Ok(did.to_string())
|
||||
} else {
|
||||
Err(HandleResolutionError::InvalidDid)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_handle(handle: &str) -> Result<String, HandleResolutionError> {
|
||||
match resolve_handle_dns(handle).await {
|
||||
Ok(did) => return Ok(did),
|
||||
Err(e) => {
|
||||
tracing::debug!("DNS resolution failed for {}: {}, trying HTTP", handle, e);
|
||||
}
|
||||
}
|
||||
resolve_handle_http(handle).await
|
||||
}
|
||||
|
||||
pub async fn verify_handle_ownership(
|
||||
handle: &str,
|
||||
expected_did: &str,
|
||||
) -> Result<(), HandleResolutionError> {
|
||||
let resolved_did = resolve_handle(handle).await?;
|
||||
if resolved_did == expected_did {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HandleResolutionError::DidMismatch {
|
||||
expected: expected_did.to_string(),
|
||||
actual: resolved_did,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
|
||||
let service_domains: Vec<String> = std::env::var("PDS_SERVICE_HANDLE_DOMAINS")
|
||||
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
|
||||
.unwrap_or_else(|_| vec![hostname.to_string()]);
|
||||
for domain in service_domains {
|
||||
if handle.ends_with(&format!(".{}", domain)) {
|
||||
return true;
|
||||
}
|
||||
if handle == domain {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_service_domain_handle() {
|
||||
assert!(is_service_domain_handle("user.example.com", "example.com"));
|
||||
assert!(is_service_domain_handle("example.com", "example.com"));
|
||||
assert!(!is_service_domain_handle("user.other.com", "example.com"));
|
||||
assert!(!is_service_domain_handle("myhandle.xyz", "example.com"));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -5,9 +5,10 @@ pub mod cache;
|
||||
pub mod circuit_breaker;
|
||||
pub mod config;
|
||||
pub mod crawlers;
|
||||
pub mod handle;
|
||||
pub mod image;
|
||||
pub mod metrics;
|
||||
pub mod notifications;
|
||||
pub mod comms;
|
||||
pub mod oauth;
|
||||
pub mod plc;
|
||||
pub mod rate_limit;
|
||||
|
||||
+13
-15
@@ -1,7 +1,5 @@
|
||||
use bspds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
|
||||
use bspds::crawlers::{Crawlers, start_crawlers_service};
|
||||
use bspds::notifications::{
|
||||
DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender,
|
||||
};
|
||||
use bspds::state::AppState;
|
||||
use std::net::SocketAddr;
|
||||
use std::process::ExitCode;
|
||||
@@ -68,31 +66,31 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
let mut notification_service = NotificationService::new(pool);
|
||||
let mut comms_service = CommsService::new(pool);
|
||||
|
||||
if let Some(email_sender) = EmailSender::from_env() {
|
||||
info!("Email notifications enabled");
|
||||
notification_service = notification_service.register_sender(email_sender);
|
||||
info!("Email comms enabled");
|
||||
comms_service = comms_service.register_sender(email_sender);
|
||||
} else {
|
||||
warn!("Email notifications disabled (MAIL_FROM_ADDRESS not set)");
|
||||
warn!("Email comms disabled (MAIL_FROM_ADDRESS not set)");
|
||||
}
|
||||
|
||||
if let Some(discord_sender) = DiscordSender::from_env() {
|
||||
info!("Discord notifications enabled");
|
||||
notification_service = notification_service.register_sender(discord_sender);
|
||||
info!("Discord comms enabled");
|
||||
comms_service = comms_service.register_sender(discord_sender);
|
||||
}
|
||||
|
||||
if let Some(telegram_sender) = TelegramSender::from_env() {
|
||||
info!("Telegram notifications enabled");
|
||||
notification_service = notification_service.register_sender(telegram_sender);
|
||||
info!("Telegram comms enabled");
|
||||
comms_service = comms_service.register_sender(telegram_sender);
|
||||
}
|
||||
|
||||
if let Some(signal_sender) = SignalSender::from_env() {
|
||||
info!("Signal notifications enabled");
|
||||
notification_service = notification_service.register_sender(signal_sender);
|
||||
info!("Signal comms enabled");
|
||||
comms_service = comms_service.register_sender(signal_sender);
|
||||
}
|
||||
|
||||
let notification_handle = tokio::spawn(notification_service.run(shutdown_rx.clone()));
|
||||
let comms_handle = tokio::spawn(comms_service.run(shutdown_rx.clone()));
|
||||
|
||||
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
|
||||
let crawlers = Arc::new(
|
||||
@@ -122,7 +120,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.with_graceful_shutdown(shutdown_signal(shutdown_tx))
|
||||
.await;
|
||||
|
||||
notification_handle.await.ok();
|
||||
comms_handle.await.ok();
|
||||
|
||||
if let Some(handle) = crawlers_handle {
|
||||
handle.await.ok();
|
||||
|
||||
+4
-4
@@ -54,8 +54,8 @@ fn describe_metrics() {
|
||||
"Total number of S3/blob storage operations"
|
||||
);
|
||||
metrics::describe_gauge!(
|
||||
"bspds_notification_queue_size",
|
||||
"Current size of the notification queue"
|
||||
"bspds_comms_queue_size",
|
||||
"Current size of the comms queue"
|
||||
);
|
||||
metrics::describe_counter!(
|
||||
"bspds_rate_limit_rejections_total",
|
||||
@@ -167,8 +167,8 @@ pub fn record_s3_operation(op_type: &str, status: &str) {
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn set_notification_queue_size(size: usize) {
|
||||
gauge!("bspds_notification_queue_size").set(size as f64);
|
||||
pub fn set_comms_queue_size(size: usize) {
|
||||
gauge!("bspds_comms_queue_size").set(size as f64);
|
||||
}
|
||||
|
||||
pub fn record_rate_limit_rejection(limiter: &str) {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
mod sender;
|
||||
mod service;
|
||||
mod types;
|
||||
|
||||
pub use sender::{
|
||||
DiscordSender, EmailSender, NotificationSender, SendError, SignalSender, TelegramSender,
|
||||
is_valid_phone_number, sanitize_header_value,
|
||||
};
|
||||
|
||||
pub use service::{
|
||||
NotificationService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion,
|
||||
enqueue_email_update, enqueue_email_verification, enqueue_notification, enqueue_password_reset,
|
||||
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
|
||||
};
|
||||
|
||||
pub use types::{
|
||||
NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
|
||||
use crate::comms::{CommsChannel, channel_display_name, enqueue_2fa_code};
|
||||
use crate::oauth::{
|
||||
Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, templates,
|
||||
};
|
||||
@@ -406,8 +406,9 @@ pub async fn authorize_post(
|
||||
let user = match sqlx::query!(
|
||||
r#"
|
||||
SELECT id, did, email, password_hash, two_factor_enabled,
|
||||
preferred_notification_channel as "preferred_notification_channel: NotificationChannel",
|
||||
deactivated_at, takedown_ref
|
||||
preferred_comms_channel as "preferred_comms_channel: CommsChannel",
|
||||
deactivated_at, takedown_ref,
|
||||
email_verified, discord_verified, telegram_verified, signal_verified
|
||||
FROM users
|
||||
WHERE handle = $1 OR email = $1
|
||||
"#,
|
||||
@@ -429,6 +430,13 @@ pub async fn authorize_post(
|
||||
if user.takedown_ref.is_some() {
|
||||
return show_login_error("This account has been taken down.", json_response);
|
||||
}
|
||||
let is_verified = user.email_verified
|
||||
|| user.discord_verified
|
||||
|| user.telegram_verified
|
||||
|| user.signal_verified;
|
||||
if !is_verified {
|
||||
return show_login_error("Please verify your account before logging in.", json_response);
|
||||
}
|
||||
let password_valid = match bcrypt::verify(&form.password, &user.password_hash) {
|
||||
Ok(valid) => valid,
|
||||
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
|
||||
@@ -451,7 +459,7 @@ pub async fn authorize_post(
|
||||
"Failed to enqueue 2FA notification"
|
||||
);
|
||||
}
|
||||
let channel_name = channel_display_name(user.preferred_notification_channel);
|
||||
let channel_name = channel_display_name(user.preferred_comms_channel);
|
||||
let redirect_url = format!(
|
||||
"/oauth/authorize/2fa?request_uri={}&channel={}",
|
||||
url_encode(&form.request_uri),
|
||||
@@ -577,7 +585,8 @@ pub async fn authorize_select(
|
||||
let user = match sqlx::query!(
|
||||
r#"
|
||||
SELECT id, two_factor_enabled,
|
||||
preferred_notification_channel as "preferred_notification_channel: NotificationChannel"
|
||||
preferred_comms_channel as "preferred_comms_channel: CommsChannel",
|
||||
email_verified, discord_verified, telegram_verified, signal_verified
|
||||
FROM users
|
||||
WHERE did = $1
|
||||
"#,
|
||||
@@ -600,6 +609,17 @@ pub async fn authorize_select(
|
||||
)).into_response();
|
||||
}
|
||||
};
|
||||
let is_verified = user.email_verified
|
||||
|| user.discord_verified
|
||||
|| user.telegram_verified
|
||||
|| user.signal_verified;
|
||||
if !is_verified {
|
||||
return Html(templates::error_page(
|
||||
"access_denied",
|
||||
Some("Please verify your account before logging in."),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
if user.two_factor_enabled {
|
||||
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
|
||||
match db::create_2fa_challenge(&state.db, &form.did, &form.request_uri).await {
|
||||
@@ -615,7 +635,7 @@ pub async fn authorize_select(
|
||||
"Failed to enqueue 2FA notification"
|
||||
);
|
||||
}
|
||||
let channel_name = channel_display_name(user.preferred_notification_channel);
|
||||
let channel_name = channel_display_name(user.preferred_comms_channel);
|
||||
let redirect_url = format!(
|
||||
"/oauth/authorize/2fa?request_uri={}&channel={}",
|
||||
url_encode(&form.request_uri),
|
||||
@@ -836,7 +856,7 @@ pub async fn authorize_2fa_post(
|
||||
if !code_valid {
|
||||
let _ = db::increment_2fa_attempts(&state.db, challenge.id).await;
|
||||
let channel = match sqlx::query_scalar!(
|
||||
r#"SELECT preferred_notification_channel as "channel: NotificationChannel" FROM users WHERE did = $1"#,
|
||||
r#"SELECT preferred_comms_channel as "channel: CommsChannel" FROM users WHERE did = $1"#,
|
||||
challenge.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
|
||||
@@ -369,7 +369,7 @@ pub fn login_page(
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||
<button type="submit" formaction="/oauth/authorize/deny" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" formaction="/oauth/authorize/deny" formnovalidate class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="help-text">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod common;
|
||||
use common::{base_url, client, create_account_and_login, get_db_connection_string};
|
||||
use bspds::notifications::{NewNotification, NotificationType, enqueue_notification};
|
||||
use bspds::comms::{NewComms, CommsType, enqueue_comms};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -26,14 +26,14 @@ async fn test_get_notification_history() {
|
||||
.expect("User not found");
|
||||
|
||||
for i in 0..3 {
|
||||
let notification = NewNotification::email(
|
||||
let comms = NewComms::email(
|
||||
user_id,
|
||||
NotificationType::Welcome,
|
||||
CommsType::Welcome,
|
||||
"test@example.com".to_string(),
|
||||
format!("Subject {}", i),
|
||||
format!("Body {}", i),
|
||||
);
|
||||
enqueue_notification(&pool, notification).await.expect("Failed to enqueue");
|
||||
enqueue_comms(&pool, comms).await.expect("Failed to enqueue");
|
||||
}
|
||||
|
||||
let resp = client
|
||||
|
||||
@@ -39,7 +39,7 @@ async fn test_send_email_success() {
|
||||
.await
|
||||
.expect("User not found");
|
||||
let notification = sqlx::query!(
|
||||
"SELECT subject, body, notification_type as \"notification_type: String\" FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' ORDER BY created_at DESC LIMIT 1",
|
||||
"SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1",
|
||||
user.id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
@@ -78,7 +78,7 @@ async fn test_send_email_default_subject() {
|
||||
.await
|
||||
.expect("User not found");
|
||||
let notification = sqlx::query!(
|
||||
"SELECT subject FROM notification_queue WHERE user_id = $1 AND notification_type = 'admin_email' AND body = 'Email without subject' LIMIT 1",
|
||||
"SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1",
|
||||
user.id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
|
||||
+29
-30
@@ -1,7 +1,6 @@
|
||||
mod common;
|
||||
use bspds::notifications::{
|
||||
NewNotification, NotificationChannel, NotificationStatus, NotificationType,
|
||||
enqueue_notification, enqueue_welcome,
|
||||
use bspds::comms::{
|
||||
CommsChannel, CommsStatus, CommsType, NewComms, enqueue_comms, enqueue_welcome,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -15,45 +14,45 @@ async fn get_pool() -> PgPool {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_enqueue_notification() {
|
||||
async fn test_enqueue_comms() {
|
||||
let pool = get_pool().await;
|
||||
let (_, did) = common::create_account_and_login(&common::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");
|
||||
let notification = NewNotification::email(
|
||||
let item = NewComms::email(
|
||||
user_id,
|
||||
NotificationType::Welcome,
|
||||
CommsType::Welcome,
|
||||
"test@example.com".to_string(),
|
||||
"Test Subject".to_string(),
|
||||
"Test body".to_string(),
|
||||
);
|
||||
let notification_id = enqueue_notification(&pool, notification)
|
||||
let comms_id = enqueue_comms(&pool, item)
|
||||
.await
|
||||
.expect("Failed to enqueue notification");
|
||||
.expect("Failed to enqueue comms");
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, recipient, subject, body,
|
||||
channel as "channel: NotificationChannel",
|
||||
notification_type as "notification_type: NotificationType",
|
||||
status as "status: NotificationStatus"
|
||||
FROM notification_queue
|
||||
channel as "channel: CommsChannel",
|
||||
comms_type as "comms_type: CommsType",
|
||||
status as "status: CommsStatus"
|
||||
FROM comms_queue
|
||||
WHERE id = $1
|
||||
"#,
|
||||
notification_id
|
||||
comms_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("Notification not found");
|
||||
.expect("Comms not found");
|
||||
assert_eq!(row.user_id, user_id);
|
||||
assert_eq!(row.recipient, "test@example.com");
|
||||
assert_eq!(row.subject.as_deref(), Some("Test Subject"));
|
||||
assert_eq!(row.body, "Test body");
|
||||
assert_eq!(row.channel, NotificationChannel::Email);
|
||||
assert_eq!(row.notification_type, NotificationType::Welcome);
|
||||
assert_eq!(row.status, NotificationStatus::Pending);
|
||||
assert_eq!(row.channel, CommsChannel::Email);
|
||||
assert_eq!(row.comms_type, CommsType::Welcome);
|
||||
assert_eq!(row.status, CommsStatus::Pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -64,30 +63,30 @@ async fn test_enqueue_welcome() {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("User not found");
|
||||
let notification_id = enqueue_welcome(&pool, user_row.id, "example.com")
|
||||
let comms_id = enqueue_welcome(&pool, user_row.id, "example.com")
|
||||
.await
|
||||
.expect("Failed to enqueue welcome notification");
|
||||
.expect("Failed to enqueue welcome comms");
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
recipient, subject, body,
|
||||
notification_type as "notification_type: NotificationType"
|
||||
FROM notification_queue
|
||||
comms_type as "comms_type: CommsType"
|
||||
FROM comms_queue
|
||||
WHERE id = $1
|
||||
"#,
|
||||
notification_id
|
||||
comms_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("Notification not found");
|
||||
.expect("Comms not found");
|
||||
assert_eq!(Some(row.recipient), user_row.email);
|
||||
assert_eq!(row.subject.as_deref(), Some("Welcome to example.com"));
|
||||
assert!(row.body.contains(&format!("@{}", user_row.handle)));
|
||||
assert_eq!(row.notification_type, NotificationType::Welcome);
|
||||
assert_eq!(row.comms_type, CommsType::Welcome);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notification_queue_status_index() {
|
||||
async fn test_comms_queue_status_index() {
|
||||
let pool = get_pool().await;
|
||||
let (_, did) = common::create_account_and_login(&common::client()).await;
|
||||
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
@@ -95,7 +94,7 @@ async fn test_notification_queue_status_index() {
|
||||
.await
|
||||
.expect("User not found");
|
||||
let initial_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM notification_queue WHERE status = 'pending' AND user_id = $1",
|
||||
"SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
@@ -103,19 +102,19 @@ async fn test_notification_queue_status_index() {
|
||||
.expect("Failed to count")
|
||||
.unwrap_or(0);
|
||||
for i in 0..5 {
|
||||
let notification = NewNotification::email(
|
||||
let item = NewComms::email(
|
||||
user_id,
|
||||
NotificationType::PasswordReset,
|
||||
CommsType::PasswordReset,
|
||||
format!("test{}@example.com", i),
|
||||
"Test".to_string(),
|
||||
"Body".to_string(),
|
||||
);
|
||||
enqueue_notification(&pool, notification)
|
||||
enqueue_comms(&pool, item)
|
||||
.await
|
||||
.expect("Failed to enqueue");
|
||||
}
|
||||
let final_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM notification_queue WHERE status = 'pending' AND user_id = $1",
|
||||
"SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
|
||||
+9
-2
@@ -2,7 +2,8 @@ mod common;
|
||||
mod helpers;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::Utc;
|
||||
use common::{base_url, client, create_account_and_login, get_db_connection_string};
|
||||
use common::{base_url, client, get_db_connection_string};
|
||||
use helpers::verify_new_account;
|
||||
use reqwest::{StatusCode, redirect};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -124,6 +125,7 @@ async fn test_full_oauth_flow() {
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let user_did = account["did"].as_str().unwrap();
|
||||
verify_new_account(&http_client, user_did).await;
|
||||
let redirect_uri = "https://example.com/oauth/callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
@@ -261,6 +263,7 @@ async fn test_oauth_2fa_flow() {
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let user_did = account["did"].as_str().unwrap();
|
||||
verify_new_account(&http_client, user_did).await;
|
||||
let db_url = get_db_connection_string().await;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap();
|
||||
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
|
||||
@@ -324,6 +327,7 @@ async fn test_oauth_2fa_lockout() {
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let user_did = account["did"].as_str().unwrap();
|
||||
verify_new_account(&http_client, user_did).await;
|
||||
let db_url = get_db_connection_string().await;
|
||||
let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap();
|
||||
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
|
||||
@@ -375,6 +379,7 @@ async fn test_account_selector_with_2fa() {
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let user_did = account["did"].as_str().unwrap().to_string();
|
||||
verify_new_account(&http_client, &user_did).await;
|
||||
let redirect_uri = "https://example.com/selector-2fa-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
@@ -451,10 +456,12 @@ async fn test_oauth_state_encoding() {
|
||||
let handle = format!("state-special-{}", ts);
|
||||
let email = format!("state-special-{}@example.com", ts);
|
||||
let password = "state-special-password";
|
||||
http_client
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": password }))
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
||||
let redirect_uri = "https://example.com/state-special-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
|
||||
+13
-4
@@ -45,9 +45,12 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
|
||||
async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("sec-test-{}", ts);
|
||||
http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" }))
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let did = account["did"].as_str().unwrap();
|
||||
verify_new_account(http_client, did).await;
|
||||
let redirect_uri = "https://example.com/sec-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
@@ -129,9 +132,11 @@ async fn test_pkce_security() {
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected");
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("pkce-attack-{}", ts);
|
||||
http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" }))
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
||||
let (_, code_challenge) = generate_pkce();
|
||||
let (attacker_verifier, _) = generate_pkce();
|
||||
let par_body: Value = http_client.post(format!("{}/oauth/par", url))
|
||||
@@ -158,9 +163,11 @@ async fn test_replay_attacks() {
|
||||
let http_client = client();
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("replay-{}", ts);
|
||||
http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" }))
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
||||
let redirect_uri = "https://example.com/replay-callback";
|
||||
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
||||
let client_id = mock_client.uri();
|
||||
@@ -243,9 +250,11 @@ async fn test_oauth_security_boundaries() {
|
||||
let client_id_b = mock_b.uri();
|
||||
let ts2 = Utc::now().timestamp_millis();
|
||||
let handle2 = format!("cross-{}", ts2);
|
||||
http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" }))
|
||||
.send().await.unwrap();
|
||||
let account2: Value = create_res2.json().await.unwrap();
|
||||
verify_new_account(&http_client, account2["did"].as_str().unwrap()).await;
|
||||
let (code_verifier2, code_challenge2) = generate_pkce();
|
||||
let par_a: Value = http_client.post(format!("{}/oauth/par", url))
|
||||
.form(&[("response_type", "code"), ("client_id", &client_id_a), ("redirect_uri", redirect_uri_a),
|
||||
|
||||
@@ -373,7 +373,7 @@ async fn test_reset_password_creates_notification() {
|
||||
.await
|
||||
.expect("User not found");
|
||||
let initial_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'",
|
||||
"SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'",
|
||||
user.id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
@@ -391,7 +391,7 @@ async fn test_reset_password_creates_notification() {
|
||||
.expect("Failed to request password reset");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let final_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM notification_queue WHERE user_id = $1 AND notification_type = 'password_reset'",
|
||||
"SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'",
|
||||
user.id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mod common;
|
||||
use bspds::image::{ImageError, ImageProcessor};
|
||||
use bspds::notifications::{SendError, is_valid_phone_number, sanitize_header_value};
|
||||
use bspds::comms::{SendError, is_valid_phone_number, sanitize_header_value};
|
||||
use bspds::oauth::templates::{error_page, login_page, success_page};
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user