Rename notifications to comms, add handle changing to own domain ability

This commit is contained in:
lewis
2025-12-18 20:35:15 +02:00
parent 27d794d46f
commit d695135a4d
62 changed files with 1345 additions and 662 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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
View File
@@ -950,6 +950,7 @@ dependencies = [
"ed25519-dalek",
"futures",
"governor",
"hickory-resolver",
"hkdf",
"hmac",
"image",
+1
View File
@@ -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
View File
@@ -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':
-5
View File
@@ -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 {
+15 -121
View File
@@ -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;
+145 -15
View File
@@ -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>
+277
View File
@@ -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 $$;
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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,
});
}
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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);
}
+10 -10
View File
@@ -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)
+2 -2
View File
@@ -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 (
+5 -5
View File
@@ -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((
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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,
+2 -2
View File
@@ -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 _
)
+16
View File
@@ -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(&notification.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 = &notification.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 = &notification.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(&notification).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,
+121
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
-18
View File
@@ -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,
};
+27 -7
View File
@@ -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)
+1 -1
View File
@@ -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">
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+2 -2
View File
@@ -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 -1
View File
@@ -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]