mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-25 19:54:14 +00:00
Functional typesafe backend
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT trusted_at, trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "trusted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "trusted_until",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET deactivated_at = $1 WHERE did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "body",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5"
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "subject",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "body",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "comms_type: String",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"welcome",
|
||||
"email_verification",
|
||||
"password_reset",
|
||||
"email_update",
|
||||
"account_deletion",
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification",
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert",
|
||||
"migration_verification"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM users WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "body",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "metadata",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email_verified FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "trusted_until",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT k.key_bytes, k.encryption_version\n FROM user_keys k\n JOIN users u ON k.user_id = u.id\n WHERE u.did = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "encryption_version",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "body",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "subject",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT t.token\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT t.token, t.expires_at FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "body",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "public_key_did_key",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002"
|
||||
}
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, recipient, subject, body,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\"\n FROM comms_queue\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "recipient",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "subject",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "body",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "comms_type: CommsType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"welcome",
|
||||
"email_verification",
|
||||
"password_reset",
|
||||
"email_update",
|
||||
"account_deletion",
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification",
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert",
|
||||
"migration_verification"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "status: CommsStatus",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_status",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"pending",
|
||||
"processing",
|
||||
"sent",
|
||||
"failed"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe"
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "private_key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "used_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT token FROM account_deletion_requests WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5"
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n recipient, subject, body,\n comms_type as \"comms_type: CommsType\"\n FROM comms_queue\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "recipient",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "subject",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "body",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "comms_type: CommsType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"welcome",
|
||||
"email_verification",
|
||||
"password_reset",
|
||||
"email_update",
|
||||
"account_deletion",
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification",
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert",
|
||||
"migration_verification"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT password_reset_code FROM users WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "password_reset_code",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "used_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET is_admin = TRUE WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "password_reset_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "password_reset_code_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382"
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -7,7 +8,7 @@ use axum::{
|
||||
};
|
||||
use chrono::{Datelike, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
|
||||
const APP_BSKY_NAMESPACE: &str = "app.bsky";
|
||||
const MAX_PREFERENCES_COUNT: usize = 100;
|
||||
@@ -39,37 +40,25 @@ pub async fn get_preferences(
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
}
|
||||
};
|
||||
let auth_user =
|
||||
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(None).into_response();
|
||||
}
|
||||
};
|
||||
let has_full_access = auth_user.permissions().has_full_access();
|
||||
let user_id: uuid::Uuid =
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &*auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("User not found".into())).into_response();
|
||||
}
|
||||
};
|
||||
let prefs_result = sqlx::query!(
|
||||
@@ -81,11 +70,7 @@ pub async fn get_preferences(
|
||||
let prefs = match prefs_result {
|
||||
Ok(rows) => rows,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to fetch preferences"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to fetch preferences".into())).into_response();
|
||||
}
|
||||
};
|
||||
let mut personal_details_pref: Option<Value> = None;
|
||||
@@ -114,7 +99,7 @@ pub async fn get_preferences(
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(get_age_from_datestring)
|
||||
{
|
||||
let declared_age_pref = json!({
|
||||
let declared_age_pref = serde_json::json!({
|
||||
"$type": DECLARED_AGE_PREF,
|
||||
"isOverAge13": age >= 13,
|
||||
"isOverAge16": age >= 16,
|
||||
@@ -139,92 +124,75 @@ pub async fn put_preferences(
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
}
|
||||
};
|
||||
let auth_user =
|
||||
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(None).into_response();
|
||||
}
|
||||
};
|
||||
let has_full_access = auth_user.permissions().has_full_access();
|
||||
let user_id: uuid::Uuid =
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &*auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("User not found".into())).into_response();
|
||||
}
|
||||
};
|
||||
if input.preferences.len() > MAX_PREFERENCES_COUNT {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": format!("Too many preferences: {} exceeds limit of {}", input.preferences.len(), MAX_PREFERENCES_COUNT)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"Too many preferences: {} exceeds limit of {}",
|
||||
input.preferences.len(),
|
||||
MAX_PREFERENCES_COUNT
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let mut forbidden_prefs: Vec<String> = Vec::new();
|
||||
for pref in &input.preferences {
|
||||
let pref_str = serde_json::to_string(pref).unwrap_or_default();
|
||||
if pref_str.len() > MAX_PREFERENCE_SIZE {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": format!("Preference too large: {} bytes exceeds limit of {}", pref_str.len(), MAX_PREFERENCE_SIZE)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"Preference too large: {} bytes exceeds limit of {}",
|
||||
pref_str.len(),
|
||||
MAX_PREFERENCE_SIZE
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Preference is missing a $type"})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Preference is missing a $type".into())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !pref_type.starts_with(APP_BSKY_NAMESPACE) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": format!("Some preferences are not in the {} namespace", APP_BSKY_NAMESPACE)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"Some preferences are not in the {} namespace",
|
||||
APP_BSKY_NAMESPACE
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
if pref_type == PERSONAL_DETAILS_PREF && !has_full_access {
|
||||
forbidden_prefs.push(pref_type.to_string());
|
||||
}
|
||||
}
|
||||
if !forbidden_prefs.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": format!("Do not have authorization to set preferences: {}", forbidden_prefs.join(", "))})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"Do not have authorization to set preferences: {}",
|
||||
forbidden_prefs.join(", ")
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to start transaction"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to start transaction".into())).into_response();
|
||||
}
|
||||
};
|
||||
let delete_result = sqlx::query!(
|
||||
@@ -237,11 +205,7 @@ pub async fn put_preferences(
|
||||
.await;
|
||||
if delete_result.is_err() {
|
||||
let _ = tx.rollback().await;
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to clear preferences"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to clear preferences".into())).into_response();
|
||||
}
|
||||
for pref in input.preferences {
|
||||
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
|
||||
@@ -261,19 +225,11 @@ pub async fn put_preferences(
|
||||
.await;
|
||||
if insert_result.is_err() {
|
||||
let _ = tx.rollback().await;
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to save preference"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to save preference".into())).into_response();
|
||||
}
|
||||
}
|
||||
if tx.commit().await.is_err() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to commit transaction"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to commit transaction".into())).into_response();
|
||||
}
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccountInput {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
pub async fn delete_account(
|
||||
@@ -20,58 +21,35 @@ pub async fn delete_account(
|
||||
_auth: BearerAuthAdmin,
|
||||
Json(input): Json<DeleteAccountInput>,
|
||||
) -> Response {
|
||||
let did = input.did.trim();
|
||||
if did.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let user = sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
|
||||
let did = &input.did;
|
||||
let user = sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let (user_id, handle) = match user {
|
||||
Ok(Some(row)) => (row.id, row.handle),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in delete_account: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Failed to begin transaction for account deletion: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did)
|
||||
if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete session tokens for {}: {:?}", did, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete session tokens"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to delete session tokens".into())).into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did)
|
||||
if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
@@ -82,33 +60,21 @@ pub async fn delete_account(
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete records for user {}: {:?}", user_id, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete records"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to delete records".into())).into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete repos for user {}: {:?}", user_id, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete repos"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to delete repos".into())).into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete blobs for user {}: {:?}", user_id, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete blobs"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to delete blobs".into())).into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id)
|
||||
.execute(&mut *tx)
|
||||
@@ -118,11 +84,7 @@ pub async fn delete_account(
|
||||
"Failed to delete app passwords for user {}: {:?}",
|
||||
user_id, e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete app passwords"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to delete app passwords".into())).into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM invite_code_uses WHERE used_by_user = $1",
|
||||
@@ -153,33 +115,21 @@ pub async fn delete_account(
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete user keys for user {}: {:?}", user_id, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete user keys"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to delete user keys".into())).into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM users WHERE id = $1", user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete user {}: {:?}", user_id, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete user"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to delete user".into())).into_response();
|
||||
}
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit account deletion transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to commit deletion"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to commit deletion".into())).into_response();
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await
|
||||
crate::api::repo::record::sequence_account_event(&state, did.as_str(), false, Some("deleted")).await
|
||||
{
|
||||
warn!(
|
||||
"Failed to sequence account deletion event for {}: {}",
|
||||
@@ -187,5 +137,5 @@ pub async fn delete_account(
|
||||
);
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::api::error::{ApiError, AtpJson};
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -7,14 +9,13 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendEmailInput {
|
||||
pub recipient_did: String,
|
||||
pub sender_did: String,
|
||||
pub recipient_did: Did,
|
||||
pub sender_did: Did,
|
||||
pub content: String,
|
||||
pub subject: Option<String>,
|
||||
pub comment: Option<String>,
|
||||
@@ -28,27 +29,15 @@ pub struct SendEmailOutput {
|
||||
pub async fn send_email(
|
||||
State(state): State<AppState>,
|
||||
_auth: BearerAuthAdmin,
|
||||
Json(input): Json<SendEmailInput>,
|
||||
AtpJson(input): AtpJson<SendEmailInput>,
|
||||
) -> Response {
|
||||
let recipient_did = input.recipient_did.trim();
|
||||
let content = input.content.trim();
|
||||
if recipient_did.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "recipientDid is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if content.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "content is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("content is required".into()).into_response();
|
||||
}
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, email, handle FROM users WHERE did = $1",
|
||||
recipient_did
|
||||
input.recipient_did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -57,29 +46,17 @@ pub async fn send_email(
|
||||
let email = match row.email {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::NoEmail.into_response();
|
||||
}
|
||||
};
|
||||
(row.id, email, row.handle)
|
||||
}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Recipient account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in send_email: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
@@ -97,7 +74,7 @@ pub async fn send_email(
|
||||
let result = crate::comms::enqueue_comms(&state.db, item).await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
tracing::info!("Admin email queued for {} ({})", handle, recipient_did);
|
||||
tracing::info!("Admin email queued for {} ({})", handle, input.recipient_did);
|
||||
(StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, RawQuery, State},
|
||||
@@ -7,19 +9,18 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetAccountInfoParams {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
pub indexed_at: String,
|
||||
@@ -42,8 +43,8 @@ pub struct InviteCodeInfo {
|
||||
pub code: String,
|
||||
pub available: i32,
|
||||
pub disabled: bool,
|
||||
pub for_account: String,
|
||||
pub created_by: String,
|
||||
pub for_account: Did,
|
||||
pub created_by: Did,
|
||||
pub created_at: String,
|
||||
pub uses: Vec<InviteCodeUseInfo>,
|
||||
}
|
||||
@@ -51,7 +52,7 @@ pub struct InviteCodeInfo {
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InviteCodeUseInfo {
|
||||
pub used_by: String,
|
||||
pub used_by: Did,
|
||||
pub used_at: String,
|
||||
}
|
||||
|
||||
@@ -66,21 +67,13 @@ pub async fn get_account_info(
|
||||
_auth: BearerAuthAdmin,
|
||||
Query(params): Query<GetAccountInfoParams>,
|
||||
) -> Response {
|
||||
let did = params.did.trim();
|
||||
if did.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at
|
||||
FROM users
|
||||
WHERE did = $1
|
||||
"#,
|
||||
did
|
||||
params.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -91,8 +84,8 @@ pub async fn get_account_info(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(AccountInfo {
|
||||
did: row.did,
|
||||
handle: row.handle,
|
||||
did: row.did.into(),
|
||||
handle: row.handle.into(),
|
||||
email: row.email,
|
||||
indexed_at: row.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
@@ -109,18 +102,10 @@ pub async fn get_account_info(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error in get_account_info: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,13 +184,13 @@ async fn get_invite_code_info(db: &sqlx::PgPool, code: &str) -> Option<InviteCod
|
||||
code: row.code,
|
||||
available: row.available_uses,
|
||||
disabled: row.disabled.unwrap_or(false),
|
||||
for_account: row.for_account,
|
||||
created_by: row.created_by,
|
||||
for_account: row.for_account.into(),
|
||||
created_by: row.created_by.into(),
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
uses: uses
|
||||
.into_iter()
|
||||
.map(|u| InviteCodeUseInfo {
|
||||
used_by: u.used_by,
|
||||
used_by: u.used_by.into(),
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -222,11 +207,7 @@ pub async fn get_account_infos(
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
if dids.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "dids is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("dids is required".into()).into_response();
|
||||
}
|
||||
let users = match sqlx::query!(
|
||||
r#"
|
||||
@@ -242,11 +223,7 @@ pub async fn get_account_infos(
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch account infos: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -306,7 +283,7 @@ pub async fn get_account_infos(
|
||||
.entry(u.code.clone())
|
||||
.or_default()
|
||||
.push(InviteCodeUseInfo {
|
||||
used_by: u.used_by,
|
||||
used_by: u.used_by.into(),
|
||||
used_at: u.used_at.to_rfc3339(),
|
||||
});
|
||||
}
|
||||
@@ -320,8 +297,8 @@ pub async fn get_account_infos(
|
||||
code: ic.code.clone(),
|
||||
available: ic.available_uses,
|
||||
disabled: ic.disabled.unwrap_or(false),
|
||||
for_account: ic.for_account,
|
||||
created_by: ic.created_by,
|
||||
for_account: ic.for_account.into(),
|
||||
created_by: ic.created_by.into(),
|
||||
created_at: ic.created_at.to_rfc3339(),
|
||||
uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(),
|
||||
};
|
||||
@@ -339,8 +316,8 @@ pub async fn get_account_infos(
|
||||
.and_then(|code| code_info_map.get(code).cloned());
|
||||
let invites = codes_by_user.get(&row.id).cloned();
|
||||
infos.push(AccountInfo {
|
||||
did: row.did,
|
||||
handle: row.handle,
|
||||
did: row.did.into(),
|
||||
handle: row.handle.into(),
|
||||
email: row.email,
|
||||
indexed_at: row.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, Handle};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -7,7 +9,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -26,8 +27,8 @@ fn default_limit() -> i64 {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
pub indexed_at: String,
|
||||
@@ -101,8 +102,8 @@ pub async fn search_accounts(
|
||||
invites_disabled,
|
||||
)| {
|
||||
AccountView {
|
||||
did: did.clone(),
|
||||
handle,
|
||||
did: did.clone().into(),
|
||||
handle: handle.into(),
|
||||
email,
|
||||
indexed_at: created_at.to_rfc3339(),
|
||||
email_confirmed_at: if email_verified {
|
||||
@@ -117,7 +118,7 @@ pub async fn search_accounts(
|
||||
)
|
||||
.collect();
|
||||
let next_cursor = if has_more {
|
||||
accounts.last().map(|a| a.did.clone())
|
||||
accounts.last().map(|a| a.did.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -132,11 +133,7 @@ pub async fn search_accounts(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in search_accounts: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{Did, PlainPassword};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -24,11 +25,7 @@ pub async fn update_account_email(
|
||||
let account = input.account.trim();
|
||||
let email = input.email.trim();
|
||||
if account.is_empty() || email.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account and email are required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("account and email are required".into()).into_response();
|
||||
}
|
||||
let result = sqlx::query!("UPDATE users SET email = $1 WHERE did = $2", email, account)
|
||||
.execute(&state.db)
|
||||
@@ -36,28 +33,20 @@ pub async fn update_account_email(
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if r.rows_affected() == 0 {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating email: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAccountHandleInput {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
@@ -66,26 +55,16 @@ pub async fn update_account_handle(
|
||||
_auth: BearerAuthAdmin,
|
||||
Json(input): Json<UpdateAccountHandleInput>,
|
||||
) -> Response {
|
||||
let did = input.did.trim();
|
||||
let did = &input.did;
|
||||
let input_handle = input.handle.trim();
|
||||
if did.is_empty() || input_handle.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})),
|
||||
)
|
||||
.into_response();
|
||||
if input_handle.is_empty() {
|
||||
return ApiError::InvalidRequest("handle is required".into()).into_response();
|
||||
}
|
||||
if !input_handle
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidHandle(None).into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let handle = if !input_handle.contains('.') {
|
||||
@@ -93,7 +72,7 @@ pub async fn update_account_handle(
|
||||
} else {
|
||||
input_handle.to_string()
|
||||
};
|
||||
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
|
||||
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
@@ -101,62 +80,50 @@ pub async fn update_account_handle(
|
||||
let existing = sqlx::query!(
|
||||
"SELECT id FROM users WHERE handle = $1 AND did != $2",
|
||||
handle,
|
||||
did
|
||||
did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
if let Ok(Some(_)) = existing {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::HandleTaken.into_response();
|
||||
}
|
||||
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did)
|
||||
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did.as_str())
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if r.rows_affected() == 0 {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
if let Some(old) = old_handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", old)).await;
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await
|
||||
crate::api::repo::record::sequence_identity_event(&state, did.as_str(), Some(&handle)).await
|
||||
{
|
||||
warn!(
|
||||
"Failed to sequence identity event for admin handle update: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await
|
||||
if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did.as_str(), &handle).await
|
||||
{
|
||||
warn!("Failed to update PLC handle for admin handle update: {}", e);
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating handle: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAccountPasswordInput {
|
||||
pub did: String,
|
||||
pub password: String,
|
||||
pub did: Did,
|
||||
pub password: PlainPassword,
|
||||
}
|
||||
|
||||
pub async fn update_account_password(
|
||||
@@ -164,51 +131,35 @@ pub async fn update_account_password(
|
||||
_auth: BearerAuthAdmin,
|
||||
Json(input): Json<UpdateAccountPasswordInput>,
|
||||
) -> Response {
|
||||
let did = input.did.trim();
|
||||
let did = &input.did;
|
||||
let password = input.password.trim();
|
||||
if did.is_empty() || password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "did and password are required"})),
|
||||
)
|
||||
.into_response();
|
||||
if password.is_empty() {
|
||||
return ApiError::InvalidRequest("password is required".into()).into_response();
|
||||
}
|
||||
let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
error!("Failed to hash password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET password_hash = $1 WHERE did = $2",
|
||||
password_hash,
|
||||
did
|
||||
did.as_str()
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if r.rows_affected() == 0 {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating password: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-39
@@ -1,3 +1,5 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -7,7 +9,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -47,7 +48,7 @@ pub async fn disable_invite_codes(
|
||||
}
|
||||
}
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -145,11 +146,7 @@ pub async fn get_invite_codes(
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
error!("DB error fetching invite codes: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let mut codes = Vec::new();
|
||||
@@ -220,11 +217,7 @@ pub async fn disable_account_invites(
|
||||
) -> Response {
|
||||
let account = input.account.trim();
|
||||
if account.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("account is required".into()).into_response();
|
||||
}
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET invites_disabled = TRUE WHERE did = $1",
|
||||
@@ -235,21 +228,13 @@ pub async fn disable_account_invites(
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if r.rows_affected() == 0 {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error disabling account invites: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,11 +251,7 @@ pub async fn enable_account_invites(
|
||||
) -> Response {
|
||||
let account = input.account.trim();
|
||||
if account.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("account is required".into()).into_response();
|
||||
}
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET invites_disabled = FALSE WHERE did = $1",
|
||||
@@ -281,21 +262,13 @@ pub async fn enable_account_invites(
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if r.rows_affected() == 0 {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error enabling account invites: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-79
@@ -1,3 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -37,11 +38,7 @@ pub async fn get_subject_status(
|
||||
Query(params): Query<GetSubjectStatusParams>,
|
||||
) -> Response {
|
||||
if params.did.is_none() && params.uri.is_none() && params.blob.is_none() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Must provide did, uri, or blob"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Must provide did, uri, or blob".into()).into_response();
|
||||
}
|
||||
if let Some(did) = ¶ms.did {
|
||||
let user = sqlx::query!(
|
||||
@@ -74,19 +71,11 @@ pub async fn get_subject_status(
|
||||
.into_response();
|
||||
}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::SubjectNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in get_subject_status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,19 +107,11 @@ pub async fn get_subject_status(
|
||||
.into_response();
|
||||
}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RecordNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in get_subject_status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,11 +119,10 @@ pub async fn get_subject_status(
|
||||
let did = match ¶ms.did {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Must provide a did to request blob state"})),
|
||||
return ApiError::InvalidRequest(
|
||||
"Must provide a did to request blob state".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let blob = sqlx::query!(
|
||||
@@ -172,27 +152,15 @@ pub async fn get_subject_status(
|
||||
.into_response();
|
||||
}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::BlobNotFound(None).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in get_subject_status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InvalidRequest("Invalid subject type".into()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -223,11 +191,7 @@ pub async fn update_subject_status(
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Failed to begin transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if let Some(takedown) = &input.takedown {
|
||||
@@ -245,11 +209,10 @@ pub async fn update_subject_status(
|
||||
.await
|
||||
{
|
||||
error!("Failed to update user takedown status for {}: {:?}", did, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(
|
||||
"Failed to update takedown status".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
if let Some(deactivated) = &input.deactivated {
|
||||
@@ -270,20 +233,15 @@ pub async fn update_subject_status(
|
||||
"Failed to update user deactivation status for {}: {:?}",
|
||||
did, e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update deactivation status"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(
|
||||
"Failed to update deactivation status".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if let Some(takedown) = &input.takedown {
|
||||
let status = if takedown.applied {
|
||||
@@ -363,11 +321,10 @@ pub async fn update_subject_status(
|
||||
"Failed to update record takedown status for {}: {:?}",
|
||||
uri, e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(
|
||||
"Failed to update takedown status".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
return (
|
||||
@@ -401,11 +358,10 @@ pub async fn update_subject_status(
|
||||
.await
|
||||
{
|
||||
error!("Failed to update blob takedown status for {}: {:?}", cid, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(
|
||||
"Failed to update takedown status".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
return (
|
||||
@@ -423,9 +379,5 @@ pub async fn update_subject_status(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InvalidRequest("Invalid subject type".into()).into_response()
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
|
||||
}
|
||||
};
|
||||
|
||||
let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", auth_user.did)
|
||||
let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", &auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
|
||||
+41
-165
@@ -1,3 +1,5 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::{EmptyResponse, EnabledResponse};
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::scheduled::generate_full_backup;
|
||||
use crate::state::AppState;
|
||||
@@ -35,26 +37,18 @@ pub struct ListBackupsOutput {
|
||||
pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, backup_enabled FROM users WHERE did = $1",
|
||||
auth.0.did
|
||||
auth.0.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,11 +67,7 @@ pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Re
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
error!("DB error fetching backups: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,11 +106,7 @@ pub async fn get_backup(
|
||||
let backup_id = match uuid::Uuid::parse_str(&query.id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Invalid backup ID".into()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -132,39 +118,25 @@ pub async fn get_backup(
|
||||
WHERE ab.id = $1 AND u.did = $2
|
||||
"#,
|
||||
backup_id,
|
||||
auth.0.did
|
||||
auth.0.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "BackupNotFound", "message": "Backup not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::BackupNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching backup: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let backup_storage = match state.backup_storage.as_ref() {
|
||||
Some(storage) => storage,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(
|
||||
json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::BackupsDisabled.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,11 +144,7 @@ pub async fn get_backup(
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch backup from storage: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to retrieve backup".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,13 +175,7 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
|
||||
let backup_storage = match state.backup_storage.as_ref() {
|
||||
Some(storage) => storage,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(
|
||||
json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::BackupsDisabled.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -224,58 +186,36 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
|
||||
JOIN repos r ON r.user_id = u.id
|
||||
WHERE u.did = $1
|
||||
"#,
|
||||
auth.0.did
|
||||
auth.0.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.deactivated_at.is_some() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountDeactivated.into_response();
|
||||
}
|
||||
|
||||
let repo_rev = match &user.repo_rev {
|
||||
Some(rev) => rev.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RepoNotReady.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let head_cid = match Cid::from_str(&user.repo_root_cid) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -283,11 +223,7 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Failed to generate CAR: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to generate backup"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to generate backup".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -301,11 +237,7 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
error!("Failed to upload backup: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to store backup"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to store backup".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -335,11 +267,7 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
|
||||
"Failed to rollback orphaned backup from S3"
|
||||
);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to record backup"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to record backup".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -420,11 +348,7 @@ pub async fn delete_backup(
|
||||
let backup_id = match uuid::Uuid::parse_str(&query.id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Invalid backup ID".into()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -436,35 +360,23 @@ pub async fn delete_backup(
|
||||
WHERE ab.id = $1 AND u.did = $2
|
||||
"#,
|
||||
backup_id,
|
||||
auth.0.did
|
||||
auth.0.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "BackupNotFound", "message": "Backup not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::BackupNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching backup: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if backup.deactivated_at.is_some() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountDeactivated.into_response();
|
||||
}
|
||||
|
||||
if let Some(backup_storage) = state.backup_storage.as_ref()
|
||||
@@ -482,16 +394,12 @@ pub async fn delete_backup(
|
||||
.await
|
||||
{
|
||||
error!("DB error deleting backup: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete backup"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to delete backup".into())).into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup");
|
||||
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -507,78 +415,54 @@ pub async fn set_backup_enabled(
|
||||
) -> Response {
|
||||
let user = match sqlx::query!(
|
||||
"SELECT deactivated_at FROM users WHERE did = $1",
|
||||
auth.0.did
|
||||
auth.0.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.deactivated_at.is_some() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountDeactivated.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"UPDATE users SET backup_enabled = $1 WHERE did = $2",
|
||||
input.enabled,
|
||||
auth.0.did
|
||||
auth.0.did.as_str()
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error updating backup_enabled: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update setting"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to update setting".into())).into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting");
|
||||
|
||||
(StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response()
|
||||
EnabledResponse::new(input.enabled).into_response()
|
||||
}
|
||||
|
||||
pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did)
|
||||
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -597,11 +481,7 @@ pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Re
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
error!("DB error fetching blobs: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -695,11 +575,7 @@ pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Re
|
||||
|
||||
if let Err(e) = zip.finish() {
|
||||
error!("Failed to finish zip: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create zip file"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to create zip file".into())).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+79
-310
@@ -1,10 +1,11 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::oauth::db as oauth_db;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::{Did, Handle};
|
||||
use crate::util::extract_client_ip;
|
||||
use crate::validation::is_valid_did;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -21,8 +22,8 @@ use tracing::{error, info, warn};
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControllerInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub is_active: bool,
|
||||
@@ -38,14 +39,7 @@ pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list controllers: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to list controllers"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to list controllers".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,7 +47,7 @@ pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -
|
||||
controllers: controllers
|
||||
.into_iter()
|
||||
.map(|c| ControllerInfo {
|
||||
did: c.did,
|
||||
did: c.did.into(),
|
||||
handle: c.handle,
|
||||
granted_scopes: c.granted_scopes,
|
||||
granted_at: c.granted_at,
|
||||
@@ -66,7 +60,7 @@ pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddControllerInput {
|
||||
pub controller_did: String,
|
||||
pub controller_did: Did,
|
||||
pub granted_scopes: String,
|
||||
}
|
||||
|
||||
@@ -75,67 +69,32 @@ pub async fn add_controller(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<AddControllerInput>,
|
||||
) -> Response {
|
||||
if !is_valid_did(&input.controller_did) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Invalid DID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidScopes",
|
||||
"message": e
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidScopes(e).into_response();
|
||||
}
|
||||
|
||||
let controller_exists: bool = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM users WHERE did = $1) as "exists!""#,
|
||||
input.controller_did
|
||||
input.controller_did.as_str()
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !controller_exists {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "ControllerNotFound",
|
||||
"message": "Controller account not found"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::ControllerNotFound.into_response();
|
||||
}
|
||||
|
||||
match delegation::controls_any_accounts(&state.db, &auth.0.did).await {
|
||||
Ok(true) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidDelegation",
|
||||
"message": "Cannot add controllers to an account that controls other accounts"
|
||||
})),
|
||||
return ApiError::InvalidDelegation(
|
||||
"Cannot add controllers to an account that controls other accounts".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check delegation status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to verify delegation status"
|
||||
})),
|
||||
)
|
||||
return ApiError::InternalError(Some("Failed to verify delegation status".into()))
|
||||
.into_response();
|
||||
}
|
||||
Ok(false) => {}
|
||||
@@ -143,24 +102,14 @@ pub async fn add_controller(
|
||||
|
||||
match delegation::has_any_controllers(&state.db, &input.controller_did).await {
|
||||
Ok(true) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidDelegation",
|
||||
"message": "Cannot add a controlled account as a controller"
|
||||
})),
|
||||
return ApiError::InvalidDelegation(
|
||||
"Cannot add a controlled account as a controller".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to verify controller status"
|
||||
})),
|
||||
)
|
||||
return ApiError::InternalError(Some("Failed to verify controller status".into()))
|
||||
.into_response();
|
||||
}
|
||||
Ok(false) => {}
|
||||
@@ -200,21 +149,14 @@ pub async fn add_controller(
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to add controller: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to add controller"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(Some("Failed to add controller".into())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RemoveControllerInput {
|
||||
pub controller_did: String,
|
||||
pub controller_did: Did,
|
||||
}
|
||||
|
||||
pub async fn remove_controller(
|
||||
@@ -222,17 +164,6 @@ pub async fn remove_controller(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<RemoveControllerInput>,
|
||||
) -> Response {
|
||||
if !is_valid_did(&input.controller_did) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Invalid DID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match delegation::revoke_delegation(&state.db, &auth.0.did, &input.controller_did, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
@@ -242,8 +173,8 @@ pub async fn remove_controller(
|
||||
WHERE user_id = (SELECT id FROM users WHERE did = $1)
|
||||
AND created_by_controller_did = $2
|
||||
RETURNING id"#,
|
||||
auth.0.did,
|
||||
input.controller_did
|
||||
&auth.0.did,
|
||||
input.controller_did.as_str()
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
@@ -281,31 +212,17 @@ pub async fn remove_controller(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "DelegationNotFound",
|
||||
"message": "No active delegation found for this controller"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(false) => ApiError::DelegationNotFound.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to remove controller: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to remove controller"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(Some("Failed to remove controller".into())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateControllerScopesInput {
|
||||
pub controller_did: String,
|
||||
pub controller_did: Did,
|
||||
pub granted_scopes: String,
|
||||
}
|
||||
|
||||
@@ -314,26 +231,8 @@ pub async fn update_controller_scopes(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<UpdateControllerScopesInput>,
|
||||
) -> Response {
|
||||
if !is_valid_did(&input.controller_did) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Invalid DID format"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidScopes",
|
||||
"message": e
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidScopes(e).into_response();
|
||||
}
|
||||
|
||||
match delegation::update_delegation_scopes(
|
||||
@@ -367,24 +266,10 @@ pub async fn update_controller_scopes(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "DelegationNotFound",
|
||||
"message": "No active delegation found for this controller"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(false) => ApiError::DelegationNotFound.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to update controller scopes: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to update controller scopes"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(Some("Failed to update controller scopes".into())).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,8 +277,8 @@ pub async fn update_controller_scopes(
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -408,13 +293,7 @@ pub async fn list_controlled_accounts(State(state): State<AppState>, auth: Beare
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list controlled accounts: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to list controlled accounts"
|
||||
})),
|
||||
)
|
||||
return ApiError::InternalError(Some("Failed to list controlled accounts".into()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
@@ -423,7 +302,7 @@ pub async fn list_controlled_accounts(State(state): State<AppState>, auth: Beare
|
||||
accounts: accounts
|
||||
.into_iter()
|
||||
.map(|a| DelegatedAccountInfo {
|
||||
did: a.did,
|
||||
did: a.did.into(),
|
||||
handle: a.handle,
|
||||
granted_scopes: a.granted_scopes,
|
||||
granted_at: a.granted_at,
|
||||
@@ -449,9 +328,9 @@ fn default_limit() -> i64 {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuditLogEntry {
|
||||
pub id: String,
|
||||
pub delegated_did: String,
|
||||
pub actor_did: String,
|
||||
pub controller_did: Option<String>,
|
||||
pub delegated_did: Did,
|
||||
pub actor_did: Did,
|
||||
pub controller_did: Option<Did>,
|
||||
pub action_type: String,
|
||||
pub action_details: Option<serde_json::Value>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
@@ -478,14 +357,7 @@ pub async fn get_audit_log(
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get audit log: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to get audit log"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to get audit log".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -498,9 +370,9 @@ pub async fn get_audit_log(
|
||||
.into_iter()
|
||||
.map(|e| AuditLogEntry {
|
||||
id: e.id.to_string(),
|
||||
delegated_did: e.delegated_did,
|
||||
actor_did: e.actor_did,
|
||||
controller_did: e.controller_did,
|
||||
delegated_did: e.delegated_did.into(),
|
||||
actor_did: e.actor_did.into(),
|
||||
controller_did: e.controller_did.map(Into::into),
|
||||
action_type: format!("{:?}", e.action_type),
|
||||
action_details: e.action_details,
|
||||
created_at: e.created_at,
|
||||
@@ -551,8 +423,8 @@ pub struct CreateDelegatedAccountInput {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDelegatedAccountResponse {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
}
|
||||
|
||||
pub async fn create_delegated_account(
|
||||
@@ -567,47 +439,26 @@ pub async fn create_delegated_account(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Delegated account creation rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many account creation attempts. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(Some(
|
||||
"Too many account creation attempts. Please try again later.".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.controller_scopes) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidScopes",
|
||||
"message": e
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidScopes(e).into_response();
|
||||
}
|
||||
|
||||
match delegation::has_any_controllers(&state.db, &auth.0.did).await {
|
||||
Ok(true) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidDelegation",
|
||||
"message": "Cannot create delegated accounts from a controlled account"
|
||||
})),
|
||||
return ApiError::InvalidDelegation(
|
||||
"Cannot create delegated accounts from a controlled account".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check controller status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "ServerError",
|
||||
"message": "Failed to verify controller status"
|
||||
})),
|
||||
)
|
||||
return ApiError::InternalError(Some("Failed to verify controller status".into()))
|
||||
.into_response();
|
||||
}
|
||||
Ok(false) => {}
|
||||
@@ -628,11 +479,7 @@ pub async fn create_delegated_account(
|
||||
match crate::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => format!("{}.{}", h, hostname),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(e.to_string()).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -647,11 +494,7 @@ pub async fn create_delegated_account(
|
||||
if let Some(ref email) = email
|
||||
&& !crate::api::validation::is_valid_email(email)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidEmail.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref code) = input.invite_code {
|
||||
@@ -666,22 +509,14 @@ pub async fn create_delegated_account(
|
||||
.unwrap_or(Some(false));
|
||||
|
||||
if valid != Some(true) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
}
|
||||
} else {
|
||||
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
if invite_required {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InviteCodeRequired.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,11 +531,7 @@ pub async fn create_delegated_account(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Error creating signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -716,12 +547,7 @@ pub async fn create_delegated_account(
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": "Failed to create PLC operation"}),
|
||||
),
|
||||
)
|
||||
return ApiError::InternalError(Some("Failed to create PLC operation".into()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
@@ -732,28 +558,21 @@ pub async fn create_delegated_account(
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::UpstreamErrorMsg(format!(
|
||||
"Failed to register DID with PLC directory: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let did = genesis_result.did;
|
||||
info!(did = %did, handle = %handle, controller = %auth.0.did, "Created DID for delegated account");
|
||||
info!(did = %did, handle = %handle, controller = %&auth.0.did, "Created DID for delegated account");
|
||||
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Error starting transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -777,27 +596,13 @@ pub async fn create_delegated_account(
|
||||
{
|
||||
let constraint = db_err.constraint().unwrap_or("");
|
||||
if constraint.contains("handle") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::HandleNotAvailable(None).into_response();
|
||||
} else if constraint.contains("email") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidEmail", "message": "Email already registered"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::EmailTaken.into_response();
|
||||
}
|
||||
}
|
||||
error!("Error inserting user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -805,11 +610,7 @@ pub async fn create_delegated_account(
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Error encrypting signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -823,30 +624,22 @@ pub async fn create_delegated_account(
|
||||
.await
|
||||
{
|
||||
error!("Error inserting user key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
r#"INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)
|
||||
VALUES ($1, $2, $3, $4)"#,
|
||||
did,
|
||||
auth.0.did,
|
||||
&auth.0.did,
|
||||
input.controller_scopes,
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Error creating initial delegation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let mst = Mst::new(Arc::new(state.block_store.clone()));
|
||||
@@ -854,11 +647,7 @@ pub async fn create_delegated_account(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error persisting MST: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
@@ -867,22 +656,14 @@ pub async fn create_delegated_account(
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error saving genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid_str = commit_cid.to_string();
|
||||
@@ -897,11 +678,7 @@ pub async fn create_delegated_account(
|
||||
.await
|
||||
{
|
||||
error!("Error inserting repo: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
|
||||
if let Err(e) = sqlx::query!(
|
||||
@@ -917,11 +694,7 @@ pub async fn create_delegated_account(
|
||||
.await
|
||||
{
|
||||
error!("Error inserting user_blocks: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Some(ref code) = input.invite_code {
|
||||
@@ -943,11 +716,7 @@ pub async fn create_delegated_account(
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Error committing transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
@@ -991,7 +760,7 @@ pub async fn create_delegated_account(
|
||||
)
|
||||
.await;
|
||||
|
||||
info!(did = %did, handle = %handle, controller = %auth.0.did, "Delegated account created");
|
||||
info!(did = %did, handle = %handle, controller = %&auth.0.did, "Delegated account created");
|
||||
|
||||
Json(CreateDelegatedAccountResponse { did, handle }).into_response()
|
||||
Json(CreateDelegatedAccountResponse { did: did.into(), handle: handle.into() }).into_response()
|
||||
}
|
||||
|
||||
+517
-55
@@ -1,9 +1,10 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{FromRequest, Request, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -15,24 +16,23 @@ struct ErrorBody<'a> {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
InternalError,
|
||||
InternalError(Option<String>),
|
||||
AuthenticationRequired,
|
||||
AuthenticationFailed,
|
||||
AuthenticationFailedMsg(String),
|
||||
AuthenticationFailed(Option<String>),
|
||||
InvalidRequest(String),
|
||||
InvalidToken,
|
||||
ExpiredToken,
|
||||
ExpiredTokenMsg(String),
|
||||
InvalidToken(Option<String>),
|
||||
ExpiredToken(Option<String>),
|
||||
TokenRequired,
|
||||
AccountDeactivated,
|
||||
AccountTakedown,
|
||||
AccountNotFound,
|
||||
RepoNotFound,
|
||||
RepoNotFoundMsg(String),
|
||||
RepoNotFound(Option<String>),
|
||||
RepoTakendown,
|
||||
RepoDeactivated,
|
||||
RecordNotFound,
|
||||
BlobNotFound,
|
||||
InvalidHandle,
|
||||
HandleNotAvailable,
|
||||
BlobNotFound(Option<String>),
|
||||
InvalidHandle(Option<String>),
|
||||
HandleNotAvailable(Option<String>),
|
||||
HandleTaken,
|
||||
InvalidEmail,
|
||||
EmailTaken,
|
||||
@@ -40,10 +40,63 @@ pub enum ApiError {
|
||||
DuplicateCreate,
|
||||
DuplicateAppPassword,
|
||||
AppPasswordNotFound,
|
||||
InvalidSwap,
|
||||
SessionNotFound,
|
||||
InvalidSwap(Option<String>),
|
||||
InvalidPassword(String),
|
||||
InvalidRepo(String),
|
||||
AccountMigrated,
|
||||
AccountNotVerified,
|
||||
InvalidCollection,
|
||||
InvalidRecord(String),
|
||||
Forbidden,
|
||||
InsufficientScope,
|
||||
AdminRequired,
|
||||
InsufficientScope(Option<String>),
|
||||
InvitesDisabled,
|
||||
RateLimitExceeded(Option<String>),
|
||||
PayloadTooLarge(String),
|
||||
TotpAlreadyEnabled,
|
||||
TotpNotEnabled,
|
||||
InvalidCode(Option<String>),
|
||||
InvalidChannel,
|
||||
IdentifierMismatch,
|
||||
NoPasskeys,
|
||||
NoChallengeInProgress,
|
||||
InvalidCredential,
|
||||
PasskeyCounterAnomaly,
|
||||
NoRegistrationInProgress,
|
||||
RegistrationFailed,
|
||||
PasskeyNotFound,
|
||||
InvalidId,
|
||||
InvalidScopes(String),
|
||||
ControllerNotFound,
|
||||
InvalidDelegation(String),
|
||||
DelegationNotFound,
|
||||
InviteCodeRequired,
|
||||
BackupNotFound,
|
||||
BackupsDisabled,
|
||||
RepoNotReady,
|
||||
DeviceNotFound,
|
||||
NoEmail,
|
||||
MfaVerificationRequired,
|
||||
AuthorizationError(String),
|
||||
InvalidDid(String),
|
||||
InvalidSigningKey,
|
||||
SetupExpired,
|
||||
InvalidAccount,
|
||||
InvalidRecoveryLink,
|
||||
RecoveryLinkExpired,
|
||||
MissingEmail,
|
||||
MissingDiscordId,
|
||||
MissingTelegramUsername,
|
||||
MissingSignalNumber,
|
||||
InvalidVerificationChannel,
|
||||
SelfHostedDidWebDisabled,
|
||||
AccountAlreadyExists,
|
||||
HandleNotFound,
|
||||
SubjectNotFound,
|
||||
NotFoundMsg(String),
|
||||
ServiceUnavailable(Option<String>),
|
||||
UpstreamErrorMsg(String),
|
||||
DatabaseError,
|
||||
UpstreamFailure,
|
||||
UpstreamTimeout,
|
||||
@@ -58,48 +111,105 @@ pub enum ApiError {
|
||||
impl ApiError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::InternalError | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => StatusCode::BAD_GATEWAY,
|
||||
Self::InternalError(_) | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
Self::ServiceUnavailable(_) | Self::BackupsDisabled => {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
}
|
||||
Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
Self::UpstreamError { status, .. } => {
|
||||
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
|
||||
}
|
||||
Self::AuthenticationRequired
|
||||
| Self::AuthenticationFailed
|
||||
| Self::AuthenticationFailedMsg(_)
|
||||
| Self::InvalidToken
|
||||
| Self::ExpiredToken
|
||||
| Self::ExpiredTokenMsg(_)
|
||||
| Self::TokenRequired
|
||||
| Self::AuthenticationFailed(_)
|
||||
| Self::AccountDeactivated
|
||||
| Self::AccountTakedown => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden | Self::InsufficientScope | Self::InvitesDisabled => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
| Self::AccountTakedown
|
||||
| Self::InvalidCode(_)
|
||||
| Self::InvalidPassword(_)
|
||||
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden
|
||||
| Self::AdminRequired
|
||||
| Self::InsufficientScope(_)
|
||||
| Self::InvitesDisabled
|
||||
| Self::InvalidRepo(_)
|
||||
| Self::AccountMigrated
|
||||
| Self::AccountNotVerified
|
||||
| Self::MfaVerificationRequired
|
||||
| Self::AuthorizationError(_) => StatusCode::FORBIDDEN,
|
||||
Self::RateLimitExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Self::AccountNotFound
|
||||
| Self::RepoNotFound
|
||||
| Self::RepoNotFoundMsg(_)
|
||||
| Self::RecordNotFound
|
||||
| Self::BlobNotFound
|
||||
| Self::AppPasswordNotFound => StatusCode::NOT_FOUND,
|
||||
| Self::AppPasswordNotFound
|
||||
| Self::SessionNotFound
|
||||
| Self::DeviceNotFound
|
||||
| Self::ControllerNotFound
|
||||
| Self::DelegationNotFound
|
||||
| Self::BackupNotFound
|
||||
| Self::InvalidRecoveryLink
|
||||
| Self::HandleNotFound
|
||||
| Self::SubjectNotFound
|
||||
| Self::BlobNotFound(_)
|
||||
| Self::NotFoundMsg(_) => StatusCode::NOT_FOUND,
|
||||
Self::RepoTakendown
|
||||
| Self::RepoDeactivated
|
||||
| Self::RepoNotFound(_) => StatusCode::BAD_REQUEST,
|
||||
Self::InvalidSwap(_) | Self::TotpAlreadyEnabled => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
Self::InvalidRequest(_)
|
||||
| Self::InvalidHandle
|
||||
| Self::HandleNotAvailable
|
||||
| Self::InvalidHandle(_)
|
||||
| Self::HandleNotAvailable(_)
|
||||
| Self::HandleTaken
|
||||
| Self::InvalidEmail
|
||||
| Self::EmailTaken
|
||||
| Self::InvalidInviteCode
|
||||
| Self::DuplicateCreate
|
||||
| Self::DuplicateAppPassword
|
||||
| Self::InvalidSwap => StatusCode::BAD_REQUEST,
|
||||
| Self::InvalidCollection
|
||||
| Self::InvalidRecord(_)
|
||||
| Self::TotpNotEnabled
|
||||
| Self::InvalidChannel
|
||||
| Self::IdentifierMismatch
|
||||
| Self::NoPasskeys
|
||||
| Self::NoChallengeInProgress
|
||||
| Self::InvalidCredential
|
||||
| Self::NoEmail
|
||||
| Self::NoRegistrationInProgress
|
||||
| Self::RegistrationFailed
|
||||
| Self::InvalidId
|
||||
| Self::InvalidScopes(_)
|
||||
| Self::InvalidDelegation(_)
|
||||
| Self::InviteCodeRequired
|
||||
| Self::RepoNotReady
|
||||
| Self::InvalidDid(_)
|
||||
| Self::InvalidSigningKey
|
||||
| Self::SetupExpired
|
||||
| Self::InvalidAccount
|
||||
| Self::RecoveryLinkExpired
|
||||
| Self::MissingEmail
|
||||
| Self::MissingDiscordId
|
||||
| Self::MissingTelegramUsername
|
||||
| Self::MissingSignalNumber
|
||||
| Self::InvalidVerificationChannel
|
||||
| Self::SelfHostedDidWebDisabled
|
||||
| Self::AccountAlreadyExists
|
||||
| Self::InvalidToken(_)
|
||||
| Self::ExpiredToken(_)
|
||||
| Self::TokenRequired => StatusCode::BAD_REQUEST,
|
||||
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
fn error_name(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => {
|
||||
Cow::Borrowed("UpstreamFailure")
|
||||
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalError"),
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
|
||||
Cow::Borrowed("UpstreamError")
|
||||
}
|
||||
Self::ServiceUnavailable(_) => Cow::Borrowed("ServiceUnavailable"),
|
||||
Self::NotFoundMsg(_) => Cow::Borrowed("NotFound"),
|
||||
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
|
||||
Self::UpstreamError { error, .. } => {
|
||||
if let Some(e) = error {
|
||||
@@ -108,43 +218,186 @@ impl ApiError {
|
||||
Cow::Borrowed("UpstreamError")
|
||||
}
|
||||
Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"),
|
||||
Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => {
|
||||
Cow::Borrowed("AuthenticationFailed")
|
||||
}
|
||||
Self::InvalidToken => Cow::Borrowed("InvalidToken"),
|
||||
Self::ExpiredToken | Self::ExpiredTokenMsg(_) => Cow::Borrowed("ExpiredToken"),
|
||||
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
|
||||
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
|
||||
Self::ExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
|
||||
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
|
||||
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
|
||||
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
|
||||
Self::Forbidden => Cow::Borrowed("Forbidden"),
|
||||
Self::InsufficientScope => Cow::Borrowed("InsufficientScope"),
|
||||
Self::AdminRequired => Cow::Borrowed("AdminRequired"),
|
||||
Self::InsufficientScope(_) => Cow::Borrowed("InsufficientScope"),
|
||||
Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"),
|
||||
Self::AccountNotFound => Cow::Borrowed("AccountNotFound"),
|
||||
Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"),
|
||||
Self::RepoNotFound(_) => Cow::Borrowed("RepoNotFound"),
|
||||
Self::RepoTakendown => Cow::Borrowed("RepoTakendown"),
|
||||
Self::RepoDeactivated => Cow::Borrowed("RepoDeactivated"),
|
||||
Self::RecordNotFound => Cow::Borrowed("RecordNotFound"),
|
||||
Self::BlobNotFound => Cow::Borrowed("BlobNotFound"),
|
||||
Self::BlobNotFound(_) => Cow::Borrowed("BlobNotFound"),
|
||||
Self::AppPasswordNotFound => Cow::Borrowed("AppPasswordNotFound"),
|
||||
Self::SessionNotFound => Cow::Borrowed("SessionNotFound"),
|
||||
Self::InvalidRequest(_) => Cow::Borrowed("InvalidRequest"),
|
||||
Self::InvalidHandle => Cow::Borrowed("InvalidHandle"),
|
||||
Self::HandleNotAvailable => Cow::Borrowed("HandleNotAvailable"),
|
||||
Self::InvalidHandle(_) => Cow::Borrowed("InvalidHandle"),
|
||||
Self::HandleNotAvailable(_) => Cow::Borrowed("HandleNotAvailable"),
|
||||
Self::HandleTaken => Cow::Borrowed("HandleTaken"),
|
||||
Self::InvalidEmail => Cow::Borrowed("InvalidEmail"),
|
||||
Self::EmailTaken => Cow::Borrowed("EmailTaken"),
|
||||
Self::InvalidInviteCode => Cow::Borrowed("InvalidInviteCode"),
|
||||
Self::DuplicateCreate => Cow::Borrowed("DuplicateCreate"),
|
||||
Self::DuplicateAppPassword => Cow::Borrowed("DuplicateAppPassword"),
|
||||
Self::InvalidSwap => Cow::Borrowed("InvalidSwap"),
|
||||
Self::InvalidSwap(_) => Cow::Borrowed("InvalidSwap"),
|
||||
Self::InvalidPassword(_) => Cow::Borrowed("InvalidPassword"),
|
||||
Self::InvalidRepo(_) => Cow::Borrowed("InvalidRepo"),
|
||||
Self::AccountMigrated => Cow::Borrowed("AccountMigrated"),
|
||||
Self::AccountNotVerified => Cow::Borrowed("AccountNotVerified"),
|
||||
Self::InvalidCollection => Cow::Borrowed("InvalidCollection"),
|
||||
Self::InvalidRecord(_) => Cow::Borrowed("InvalidRecord"),
|
||||
Self::TotpAlreadyEnabled => Cow::Borrowed("TotpAlreadyEnabled"),
|
||||
Self::TotpNotEnabled => Cow::Borrowed("TotpNotEnabled"),
|
||||
Self::InvalidCode(_) => Cow::Borrowed("InvalidCode"),
|
||||
Self::InvalidChannel => Cow::Borrowed("InvalidChannel"),
|
||||
Self::IdentifierMismatch => Cow::Borrowed("IdentifierMismatch"),
|
||||
Self::NoPasskeys => Cow::Borrowed("NoPasskeys"),
|
||||
Self::NoChallengeInProgress => Cow::Borrowed("NoChallengeInProgress"),
|
||||
Self::InvalidCredential => Cow::Borrowed("InvalidCredential"),
|
||||
Self::PasskeyCounterAnomaly => Cow::Borrowed("PasskeyCounterAnomaly"),
|
||||
Self::NoRegistrationInProgress => Cow::Borrowed("NoRegistrationInProgress"),
|
||||
Self::RegistrationFailed => Cow::Borrowed("RegistrationFailed"),
|
||||
Self::PasskeyNotFound => Cow::Borrowed("PasskeyNotFound"),
|
||||
Self::InvalidId => Cow::Borrowed("InvalidId"),
|
||||
Self::InvalidScopes(_) => Cow::Borrowed("InvalidScopes"),
|
||||
Self::ControllerNotFound => Cow::Borrowed("ControllerNotFound"),
|
||||
Self::InvalidDelegation(_) => Cow::Borrowed("InvalidDelegation"),
|
||||
Self::DelegationNotFound => Cow::Borrowed("DelegationNotFound"),
|
||||
Self::InviteCodeRequired => Cow::Borrowed("InviteCodeRequired"),
|
||||
Self::BackupNotFound => Cow::Borrowed("BackupNotFound"),
|
||||
Self::BackupsDisabled => Cow::Borrowed("BackupsDisabled"),
|
||||
Self::RepoNotReady => Cow::Borrowed("RepoNotReady"),
|
||||
Self::MfaVerificationRequired => Cow::Borrowed("MfaVerificationRequired"),
|
||||
Self::RateLimitExceeded(_) => Cow::Borrowed("RateLimitExceeded"),
|
||||
Self::PayloadTooLarge(_) => Cow::Borrowed("PayloadTooLarge"),
|
||||
Self::DeviceNotFound => Cow::Borrowed("DeviceNotFound"),
|
||||
Self::NoEmail => Cow::Borrowed("NoEmail"),
|
||||
Self::AuthorizationError(_) => Cow::Borrowed("AuthorizationError"),
|
||||
Self::InvalidDid(_) => Cow::Borrowed("InvalidDid"),
|
||||
Self::InvalidSigningKey => Cow::Borrowed("InvalidSigningKey"),
|
||||
Self::SetupExpired => Cow::Borrowed("SetupExpired"),
|
||||
Self::InvalidAccount => Cow::Borrowed("InvalidAccount"),
|
||||
Self::InvalidRecoveryLink => Cow::Borrowed("InvalidRecoveryLink"),
|
||||
Self::RecoveryLinkExpired => Cow::Borrowed("RecoveryLinkExpired"),
|
||||
Self::MissingEmail => Cow::Borrowed("MissingEmail"),
|
||||
Self::MissingDiscordId => Cow::Borrowed("MissingDiscordId"),
|
||||
Self::MissingTelegramUsername => Cow::Borrowed("MissingTelegramUsername"),
|
||||
Self::MissingSignalNumber => Cow::Borrowed("MissingSignalNumber"),
|
||||
Self::InvalidVerificationChannel => Cow::Borrowed("InvalidVerificationChannel"),
|
||||
Self::SelfHostedDidWebDisabled => Cow::Borrowed("SelfHostedDidWebDisabled"),
|
||||
Self::AccountAlreadyExists => Cow::Borrowed("AccountAlreadyExists"),
|
||||
Self::HandleNotFound => Cow::Borrowed("HandleNotFound"),
|
||||
Self::SubjectNotFound => Cow::Borrowed("SubjectNotFound"),
|
||||
}
|
||||
}
|
||||
fn message(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::AuthenticationFailedMsg(msg)
|
||||
| Self::ExpiredTokenMsg(msg)
|
||||
| Self::InvalidRequest(msg)
|
||||
| Self::RepoNotFoundMsg(msg)
|
||||
| Self::UpstreamUnavailable(msg) => Some(msg.clone()),
|
||||
Self::InternalError(msg)
|
||||
| Self::AuthenticationFailed(msg)
|
||||
| Self::InvalidToken(msg)
|
||||
| Self::ExpiredToken(msg)
|
||||
| Self::RepoNotFound(msg)
|
||||
| Self::BlobNotFound(msg)
|
||||
| Self::InvalidHandle(msg)
|
||||
| Self::HandleNotAvailable(msg)
|
||||
| Self::InvalidSwap(msg)
|
||||
| Self::InsufficientScope(msg)
|
||||
| Self::InvalidCode(msg)
|
||||
| Self::RateLimitExceeded(msg)
|
||||
| Self::ServiceUnavailable(msg) => msg.clone(),
|
||||
Self::InvalidRequest(msg)
|
||||
| Self::UpstreamUnavailable(msg)
|
||||
| Self::InvalidPassword(msg)
|
||||
| Self::InvalidRepo(msg)
|
||||
| Self::InvalidRecord(msg)
|
||||
| Self::NotFoundMsg(msg)
|
||||
| Self::UpstreamErrorMsg(msg)
|
||||
| Self::PayloadTooLarge(msg) => Some(msg.clone()),
|
||||
Self::AccountMigrated => Some(
|
||||
"Account has been migrated to another PDS. Repo operations are not allowed."
|
||||
.to_string(),
|
||||
),
|
||||
Self::AccountNotVerified => Some(
|
||||
"You must verify at least one notification channel before creating records"
|
||||
.to_string(),
|
||||
),
|
||||
Self::NoPasskeys => {
|
||||
Some("No passkeys registered for this account".to_string())
|
||||
}
|
||||
Self::NoChallengeInProgress => Some(
|
||||
"No passkey authentication in progress or challenge expired".to_string(),
|
||||
),
|
||||
Self::InvalidCredential => Some("Failed to parse credential response".to_string()),
|
||||
Self::NoRegistrationInProgress => Some(
|
||||
"No registration in progress. Call startPasskeyRegistration first.".to_string(),
|
||||
),
|
||||
Self::RegistrationFailed => {
|
||||
Some("Failed to verify passkey registration".to_string())
|
||||
}
|
||||
Self::PasskeyNotFound => Some("Passkey not found".to_string()),
|
||||
Self::InvalidId => Some("Invalid ID format".to_string()),
|
||||
Self::InvalidScopes(msg) | Self::InvalidDelegation(msg) => Some(msg.clone()),
|
||||
Self::ControllerNotFound => Some("Controller account not found".to_string()),
|
||||
Self::DelegationNotFound => {
|
||||
Some("No active delegation found for this controller".to_string())
|
||||
}
|
||||
Self::InviteCodeRequired => {
|
||||
Some("An invite code is required to create an account".to_string())
|
||||
}
|
||||
Self::BackupNotFound => Some("Backup not found".to_string()),
|
||||
Self::BackupsDisabled => Some("Backup storage not configured".to_string()),
|
||||
Self::RepoNotReady => Some("Repository not ready for backup".to_string()),
|
||||
Self::PasskeyCounterAnomaly => Some(
|
||||
"Authentication failed: security key counter anomaly detected. This may indicate a cloned key.".to_string(),
|
||||
),
|
||||
Self::MfaVerificationRequired => Some(
|
||||
"This sensitive operation requires MFA verification".to_string(),
|
||||
),
|
||||
Self::DeviceNotFound => Some("Device not found".to_string()),
|
||||
Self::NoEmail => Some("Recipient has no email address".to_string()),
|
||||
Self::AuthorizationError(msg) | Self::InvalidDid(msg) => Some(msg.clone()),
|
||||
Self::InvalidSigningKey => {
|
||||
Some("Signing key not found, already used, or expired".to_string())
|
||||
}
|
||||
Self::SetupExpired => {
|
||||
Some("Setup has already been completed or expired".to_string())
|
||||
}
|
||||
Self::InvalidAccount => {
|
||||
Some("This account is not a passkey-only account".to_string())
|
||||
}
|
||||
Self::InvalidRecoveryLink => Some("Invalid recovery link".to_string()),
|
||||
Self::RecoveryLinkExpired => Some("Recovery link has expired".to_string()),
|
||||
Self::MissingEmail => {
|
||||
Some("Email is required when using email verification".to_string())
|
||||
}
|
||||
Self::MissingDiscordId => {
|
||||
Some("Discord ID is required when using Discord verification".to_string())
|
||||
}
|
||||
Self::MissingTelegramUsername => {
|
||||
Some("Telegram username is required when using Telegram verification".to_string())
|
||||
}
|
||||
Self::MissingSignalNumber => {
|
||||
Some("Signal phone number is required when using Signal verification".to_string())
|
||||
}
|
||||
Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()),
|
||||
Self::SelfHostedDidWebDisabled => {
|
||||
Some("Self-hosted did:web accounts are disabled on this server".to_string())
|
||||
}
|
||||
Self::AccountAlreadyExists => Some("Account already exists".to_string()),
|
||||
Self::HandleNotFound => Some("Unable to resolve handle".to_string()),
|
||||
Self::SubjectNotFound => Some("Subject not found".to_string()),
|
||||
Self::IdentifierMismatch => {
|
||||
Some("The identifier does not match the verification token".to_string())
|
||||
}
|
||||
Self::UpstreamError { message, .. } => message.clone(),
|
||||
Self::UpstreamTimeout => Some("Upstream service timed out".to_string()),
|
||||
Self::AdminRequired => Some("This action requires admin privileges".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -182,6 +435,7 @@ impl IntoResponse for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
@@ -194,9 +448,11 @@ impl From<crate::auth::TokenValidationError> for ApiError {
|
||||
match e {
|
||||
crate::auth::TokenValidationError::AccountDeactivated => Self::AccountDeactivated,
|
||||
crate::auth::TokenValidationError::AccountTakedown => Self::AccountTakedown,
|
||||
crate::auth::TokenValidationError::KeyDecryptionFailed => Self::InternalError,
|
||||
crate::auth::TokenValidationError::AuthenticationFailed => Self::AuthenticationFailed,
|
||||
crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken,
|
||||
crate::auth::TokenValidationError::KeyDecryptionFailed => Self::InternalError(None),
|
||||
crate::auth::TokenValidationError::AuthenticationFailed => {
|
||||
Self::AuthenticationFailed(None)
|
||||
}
|
||||
crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,3 +468,209 @@ impl From<crate::util::DbLookupError> for ApiError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::auth::extractor::AuthError> for ApiError {
|
||||
fn from(e: crate::auth::extractor::AuthError) -> Self {
|
||||
match e {
|
||||
crate::auth::extractor::AuthError::MissingToken => Self::AuthenticationRequired,
|
||||
crate::auth::extractor::AuthError::InvalidFormat => {
|
||||
Self::AuthenticationFailed(Some("Invalid authorization header format".to_string()))
|
||||
}
|
||||
crate::auth::extractor::AuthError::AuthenticationFailed => {
|
||||
Self::AuthenticationFailed(None)
|
||||
}
|
||||
crate::auth::extractor::AuthError::TokenExpired => {
|
||||
Self::AuthenticationFailed(Some("Token has expired".to_string()))
|
||||
}
|
||||
crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated,
|
||||
crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown,
|
||||
crate::auth::extractor::AuthError::AdminRequired => Self::AdminRequired,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::handle::HandleResolutionError> for ApiError {
|
||||
fn from(e: crate::handle::HandleResolutionError) -> Self {
|
||||
match e {
|
||||
crate::handle::HandleResolutionError::NotFound => Self::HandleNotFound,
|
||||
crate::handle::HandleResolutionError::InvalidDid => {
|
||||
Self::InvalidHandle(Some("Invalid DID format in handle record".to_string()))
|
||||
}
|
||||
crate::handle::HandleResolutionError::DidMismatch { expected, actual } => {
|
||||
Self::InvalidHandle(Some(format!(
|
||||
"Handle DID mismatch: expected {}, got {}",
|
||||
expected, actual
|
||||
)))
|
||||
}
|
||||
crate::handle::HandleResolutionError::DnsError(msg) => {
|
||||
Self::InternalError(Some(format!("DNS resolution failed: {}", msg)))
|
||||
}
|
||||
crate::handle::HandleResolutionError::HttpError(msg) => {
|
||||
Self::InternalError(Some(format!("Handle HTTP resolution failed: {}", msg)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::auth::verification_token::VerifyError> for ApiError {
|
||||
fn from(e: crate::auth::verification_token::VerifyError) -> Self {
|
||||
use crate::auth::verification_token::VerifyError;
|
||||
match e {
|
||||
VerifyError::InvalidFormat => {
|
||||
Self::InvalidRequest("The verification code is invalid or malformed".to_string())
|
||||
}
|
||||
VerifyError::UnsupportedVersion => {
|
||||
Self::InvalidRequest("This verification code version is not supported".to_string())
|
||||
}
|
||||
VerifyError::Expired => {
|
||||
Self::InvalidRequest("The verification code has expired. Please request a new one.".to_string())
|
||||
}
|
||||
VerifyError::InvalidSignature => {
|
||||
Self::InvalidRequest("The verification code is invalid".to_string())
|
||||
}
|
||||
VerifyError::IdentifierMismatch => Self::IdentifierMismatch,
|
||||
VerifyError::PurposeMismatch => {
|
||||
Self::InvalidRequest("Verification code purpose does not match".to_string())
|
||||
}
|
||||
VerifyError::ChannelMismatch => {
|
||||
Self::InvalidRequest("Verification code channel does not match".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::api::validation::HandleValidationError> for ApiError {
|
||||
fn from(e: crate::api::validation::HandleValidationError) -> Self {
|
||||
use crate::api::validation::HandleValidationError;
|
||||
match e {
|
||||
HandleValidationError::Reserved => Self::HandleNotAvailable(None),
|
||||
HandleValidationError::BannedWord => {
|
||||
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
|
||||
}
|
||||
_ => Self::InvalidHandle(Some(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<jacquard::types::string::AtStrError> for ApiError {
|
||||
fn from(e: jacquard::types::string::AtStrError) -> Self {
|
||||
Self::InvalidRequest(format!("Invalid {}: {}", e.spec, e.kind))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::plc::PlcError> for ApiError {
|
||||
fn from(e: crate::plc::PlcError) -> Self {
|
||||
use crate::plc::PlcError;
|
||||
match e {
|
||||
PlcError::NotFound => Self::NotFoundMsg("DID not found in PLC directory".into()),
|
||||
PlcError::Tombstoned => Self::InvalidRequest("DID is tombstoned".into()),
|
||||
PlcError::Timeout => Self::UpstreamTimeout,
|
||||
PlcError::CircuitBreakerOpen => {
|
||||
Self::ServiceUnavailable(Some("PLC directory service temporarily unavailable".into()))
|
||||
}
|
||||
PlcError::Http(err) => {
|
||||
tracing::error!("PLC HTTP error: {:?}", err);
|
||||
Self::UpstreamErrorMsg("Failed to communicate with PLC directory".into())
|
||||
}
|
||||
PlcError::InvalidResponse(msg) => {
|
||||
tracing::error!("PLC invalid response: {}", msg);
|
||||
Self::UpstreamErrorMsg(format!("Invalid response from PLC directory: {}", msg))
|
||||
}
|
||||
PlcError::Serialization(msg) => {
|
||||
tracing::error!("PLC serialization error: {}", msg);
|
||||
Self::InternalError(Some(format!("PLC serialization error: {}", msg)))
|
||||
}
|
||||
PlcError::Signing(msg) => {
|
||||
tracing::error!("PLC signing error: {}", msg);
|
||||
Self::InternalError(Some(format!("PLC signing error: {}", msg)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bcrypt::BcryptError> for ApiError {
|
||||
fn from(e: bcrypt::BcryptError) -> Self {
|
||||
tracing::error!("Bcrypt error: {:?}", e);
|
||||
Self::InternalError(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<cid::Error> for ApiError {
|
||||
fn from(e: cid::Error) -> Self {
|
||||
Self::InvalidRequest(format!("Invalid CID: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::circuit_breaker::CircuitBreakerError<crate::plc::PlcError>> for ApiError {
|
||||
fn from(e: crate::circuit_breaker::CircuitBreakerError<crate::plc::PlcError>) -> Self {
|
||||
use crate::circuit_breaker::CircuitBreakerError;
|
||||
match e {
|
||||
CircuitBreakerError::CircuitOpen(err) => {
|
||||
tracing::warn!("PLC directory circuit breaker open: {}", err);
|
||||
Self::ServiceUnavailable(Some(
|
||||
"PLC directory service temporarily unavailable".into(),
|
||||
))
|
||||
}
|
||||
CircuitBreakerError::OperationFailed(plc_err) => Self::from(plc_err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::storage::StorageError> for ApiError {
|
||||
fn from(e: crate::storage::StorageError) -> Self {
|
||||
tracing::error!("Storage error: {:?}", e);
|
||||
Self::InternalError(Some("Storage operation failed".into()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AtpJson<T>(pub T);
|
||||
|
||||
impl<T, S> FromRequest<S> for AtpJson<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, Json<serde_json::Value>);
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Json::<T>::from_request(req, state).await {
|
||||
Ok(Json(value)) => Ok(AtpJson(value)),
|
||||
Err(rejection) => {
|
||||
let message = extract_json_error_message(&rejection);
|
||||
Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": message
|
||||
})),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_json_error_message(rejection: &JsonRejection) -> String {
|
||||
match rejection {
|
||||
JsonRejection::JsonDataError(e) => {
|
||||
let inner = e.body_text();
|
||||
if inner.contains("missing field") {
|
||||
let field = inner
|
||||
.split("missing field `")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split('`').next())
|
||||
.unwrap_or("unknown");
|
||||
format!("Missing required field: {}", field)
|
||||
} else if inner.contains("invalid type") {
|
||||
format!("Invalid field type: {}", inner)
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
}
|
||||
JsonRejection::JsonSyntaxError(_) => "Invalid JSON syntax".to_string(),
|
||||
JsonRejection::MissingJsonContentType(_) => {
|
||||
"Content-Type must be application/json".to_string()
|
||||
}
|
||||
JsonRejection::BytesRejection(_) => "Failed to read request body".to_string(),
|
||||
_ => "Invalid request body".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
+108
-353
@@ -1,8 +1,10 @@
|
||||
use super::did::verify_did_web;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
|
||||
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::{Did, Handle, PlainPassword};
|
||||
use crate::validation::validate_password;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -10,13 +12,13 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use jacquard::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::{mst::Mst, storage::BlockStore};
|
||||
use k256::{SecretKey, ecdsa::SigningKey};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
@@ -40,7 +42,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
pub struct CreateAccountInput {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
pub password: PlainPassword,
|
||||
pub invite_code: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
@@ -54,8 +56,8 @@ pub struct CreateAccountInput {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountOutput {
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
pub handle: Handle,
|
||||
pub did: Did,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub did_doc: Option<serde_json::Value>,
|
||||
pub access_jwt: String,
|
||||
@@ -88,14 +90,8 @@ pub async fn create_account(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Account creation rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many account creation attempts. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let migration_auth = if let Some(token) =
|
||||
@@ -113,14 +109,11 @@ pub async fn create_account(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Service token verification failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "AuthenticationFailed",
|
||||
"message": format!("Service token verification failed: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(Some(format!(
|
||||
"Service token verification failed: {}",
|
||||
e
|
||||
)))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -152,14 +145,11 @@ pub async fn create_account(
|
||||
"[MIGRATION] createAccount: Service token mismatch - token_did={} provided_did={}",
|
||||
auth_did, provided_did
|
||||
);
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthorizationError(format!(
|
||||
"Service token issuer {} does not match DID {}",
|
||||
auth_did, provided_did
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
if is_did_web_byod {
|
||||
info!(did = %provided_did, "Processing did:web BYOD account creation");
|
||||
@@ -188,44 +178,26 @@ pub async fn create_account(
|
||||
};
|
||||
match crate::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => h,
|
||||
Err(crate::api::validation::HandleValidationError::Reserved) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "HandleNotAvailable", "message": "Reserved handle"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if input.handle.contains(' ') || input.handle.contains('\t') {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response();
|
||||
}
|
||||
for c in input.handle.chars() {
|
||||
if !c.is_ascii_alphanumeric() && c != '.' && c != '-' {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": format!("Handle contains invalid character: {}", c)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"Handle contains invalid character: {}",
|
||||
c
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let handle_lower = input.handle.to_lowercase();
|
||||
if crate::moderation::has_explicit_slur(&handle_lower) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Inappropriate language in handle".into())
|
||||
.into_response();
|
||||
}
|
||||
handle_lower
|
||||
@@ -238,20 +210,12 @@ pub async fn create_account(
|
||||
if let Some(ref email) = email
|
||||
&& !crate::api::validation::is_valid_email(email)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidEmail.into_response();
|
||||
}
|
||||
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
|
||||
let valid_channels = ["email", "discord", "telegram", "signal"];
|
||||
if !valid_channels.contains(&verification_channel) && !is_migration {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel. Must be one of: email, discord, telegram, signal"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidVerificationChannel.into_response();
|
||||
}
|
||||
let verification_recipient = if is_migration {
|
||||
None
|
||||
@@ -259,36 +223,21 @@ pub async fn create_account(
|
||||
Some(match verification_channel {
|
||||
"email" => match &input.email {
|
||||
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::MissingEmail.into_response(),
|
||||
},
|
||||
"discord" => match &input.discord_id {
|
||||
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::MissingSignalNumber.into_response(),
|
||||
},
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::InvalidVerificationChannel.into_response(),
|
||||
})
|
||||
};
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
@@ -319,22 +268,11 @@ pub async fn create_account(
|
||||
match reserved {
|
||||
Ok(Some(row)) => (row.private_key_bytes, Some(row.id)),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidSigningKey",
|
||||
"message": "Signing key not found, already used, or expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidSigningKey.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error looking up reserved signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -345,25 +283,14 @@ pub async fn create_account(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Error creating signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let did_type = input.did_type.as_deref().unwrap_or("plc");
|
||||
let did = match did_type {
|
||||
"web" => {
|
||||
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "SelfHostedDidWebDisabled",
|
||||
"message": "This PDS does not offer self-hosted did:web identities. Please use did:plc or bring your own did:web."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::SelfHostedDidWebDisabled.into_response();
|
||||
}
|
||||
let subdomain_host = format!("{}.{}", input.handle, hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
@@ -375,31 +302,21 @@ pub async fn create_account(
|
||||
let d = match &input.did {
|
||||
Some(d) if !d.trim().is_empty() => d,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
|
||||
return ApiError::InvalidRequest(
|
||||
"External did:web requires the 'did' field to be provided".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !d.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
|
||||
),
|
||||
)
|
||||
return ApiError::InvalidDid("External DID must be a did:web".into())
|
||||
.into_response();
|
||||
}
|
||||
if !is_did_web_byod
|
||||
&& let Err(e) =
|
||||
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidDid(e).into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web account");
|
||||
d.clone()
|
||||
@@ -419,19 +336,14 @@ pub async fn create_account(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidDid(e).into_response();
|
||||
}
|
||||
d.clone()
|
||||
} else if !d.trim().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})),
|
||||
return ApiError::InvalidDid(
|
||||
"Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth.".into()
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
} else {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
|
||||
@@ -444,11 +356,10 @@ pub async fn create_account(
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(
|
||||
"Failed to create PLC operation".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
@@ -457,14 +368,11 @@ pub async fn create_account(
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::UpstreamErrorMsg(format!(
|
||||
"Failed to register DID with PLC directory: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
|
||||
genesis_result.did
|
||||
@@ -481,11 +389,10 @@ pub async fn create_account(
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(
|
||||
"Failed to create PLC operation".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
@@ -494,14 +401,11 @@ pub async fn create_account(
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::UpstreamErrorMsg(format!(
|
||||
"Failed to register DID with PLC directory: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
|
||||
genesis_result.did
|
||||
@@ -512,11 +416,7 @@ pub async fn create_account(
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Error starting transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if is_migration {
|
||||
@@ -542,26 +442,14 @@ pub async fn create_account(
|
||||
.map(|c| c.contains("handle"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::HandleTaken.into_response();
|
||||
}
|
||||
error!("Error reactivating account: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Error committing reactivation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let key_row: Option<(Vec<u8>, i32)> = sqlx::query_as(
|
||||
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
|
||||
@@ -576,21 +464,16 @@ pub async fn create_account(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Error decrypting key for reactivated account: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
error!("No signing key found for reactivated account");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Account signing key not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(
|
||||
"Account signing key not found".into(),
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let access_meta =
|
||||
@@ -598,11 +481,7 @@ pub async fn create_account(
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Error creating access token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(
|
||||
@@ -612,11 +491,7 @@ pub async fn create_account(
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Error creating refresh token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let session_result: Result<_, sqlx::Error> = sqlx::query(
|
||||
@@ -631,17 +506,13 @@ pub async fn create_account(
|
||||
.await;
|
||||
if let Err(e) = session_result {
|
||||
error!("Error creating session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
return (
|
||||
StatusCode::OK,
|
||||
axum::http::StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone(),
|
||||
did: did.clone(),
|
||||
handle: handle.clone().into(),
|
||||
did: did.clone().into(),
|
||||
did_doc: state.did_resolver.resolve_did_document(&did).await,
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
@@ -651,11 +522,7 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountAlreadyExists", "message": "An active account with this DID already exists"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountAlreadyExists.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -666,11 +533,7 @@ pub async fn create_account(
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
if exists_result.is_some() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "HandleTaken", "message": "Handle already taken"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::HandleTaken.into_response();
|
||||
}
|
||||
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
@@ -682,11 +545,7 @@ pub async fn create_account(
|
||||
.map(|c| c.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InviteCodeRequired.into_response();
|
||||
}
|
||||
if let Some(code) = &input.invite_code
|
||||
&& !code.trim().is_empty()
|
||||
@@ -700,7 +559,7 @@ pub async fn create_account(
|
||||
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();
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
}
|
||||
let update_invite = sqlx::query!(
|
||||
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
|
||||
@@ -710,39 +569,20 @@ pub async fn create_account(
|
||||
.await;
|
||||
if let Err(e) = update_invite {
|
||||
error!("Error updating invite code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error checking invite code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = validate_password(&input.password) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(e.to_string()).into_response();
|
||||
}
|
||||
|
||||
let password_clone = input.password.clone();
|
||||
@@ -751,19 +591,11 @@ pub async fn create_account(
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
error!("Error hashing password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to spawn blocking task: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
|
||||
@@ -823,40 +655,15 @@ pub async fn create_account(
|
||||
{
|
||||
let constraint = db_err.constraint().unwrap_or("");
|
||||
if constraint.contains("handle") || constraint.contains("users_handle") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "HandleNotAvailable",
|
||||
"message": "Handle already taken"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::HandleNotAvailable(None).into_response();
|
||||
} else if constraint.contains("email") || constraint.contains("users_email") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidEmail",
|
||||
"message": "Email already registered"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::EmailTaken.into_response();
|
||||
} else if constraint.contains("did") || constraint.contains("users_did") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "AccountAlreadyExists",
|
||||
"message": "An account with this DID already exists"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountAlreadyExists.into_response();
|
||||
}
|
||||
}
|
||||
error!("Error inserting user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -864,11 +671,7 @@ pub async fn create_account(
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
error!("Error encrypting user key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let key_insert = sqlx::query!(
|
||||
@@ -881,11 +684,7 @@ pub async fn create_account(
|
||||
.await;
|
||||
if let Err(e) = key_insert {
|
||||
error!("Error inserting user key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if let Some(key_id) = reserved_key_id {
|
||||
let mark_used = sqlx::query!(
|
||||
@@ -896,11 +695,7 @@ pub async fn create_account(
|
||||
.await;
|
||||
if let Err(e) = mark_used {
|
||||
error!("Error marking reserved key as used: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
let mst = Mst::new(Arc::new(state.block_store.clone()));
|
||||
@@ -908,11 +703,7 @@ pub async fn create_account(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error persisting MST: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
@@ -921,22 +712,14 @@ pub async fn create_account(
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid = match state.block_store.put(&commit_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error saving genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid_str = commit_cid.to_string();
|
||||
@@ -951,11 +734,7 @@ pub async fn create_account(
|
||||
.await;
|
||||
if let Err(e) = repo_insert {
|
||||
error!("Error initializing repo: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
|
||||
if let Err(e) = sqlx::query!(
|
||||
@@ -971,11 +750,7 @@ pub async fn create_account(
|
||||
.await
|
||||
{
|
||||
error!("Error inserting user_blocks: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if let Some(code) = &input.invite_code
|
||||
&& !code.trim().is_empty()
|
||||
@@ -989,11 +764,7 @@ pub async fn create_account(
|
||||
.await;
|
||||
if let Err(e) = use_insert {
|
||||
error!("Error recording invite usage: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() {
|
||||
@@ -1016,11 +787,7 @@ pub async fn create_account(
|
||||
}
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Error committing transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if !is_migration && !is_did_web_byod {
|
||||
if let Err(e) =
|
||||
@@ -1117,11 +884,7 @@ pub async fn create_account(
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("createAccount: Error creating access token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let refresh_meta =
|
||||
@@ -1129,11 +892,7 @@ pub async fn create_account(
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("createAccount: Error creating refresh token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query!(
|
||||
@@ -1148,11 +907,7 @@ pub async fn create_account(
|
||||
.await
|
||||
{
|
||||
error!("createAccount: Error creating session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let did_doc = state.did_resolver.resolve_did_document(&did).await;
|
||||
@@ -1167,8 +922,8 @@ pub async fn create_account(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone(),
|
||||
did,
|
||||
handle: handle.clone().into(),
|
||||
did: did.into(),
|
||||
did_doc,
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
|
||||
+56
-175
@@ -1,4 +1,4 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::{ApiError, DidResponse, EmptyResponse};
|
||||
use crate::plc::signing_key_to_did_key;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -34,15 +34,11 @@ pub async fn resolve_handle(
|
||||
) -> Response {
|
||||
let handle = params.handle.trim();
|
||||
if handle.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "handle is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("handle is required".into()).into_response();
|
||||
}
|
||||
let cache_key = format!("handle:{}", handle);
|
||||
if let Some(did) = state.cache.get(&cache_key).await {
|
||||
return (StatusCode::OK, Json(json!({ "did": did }))).into_response();
|
||||
return DidResponse::new(did).into_response();
|
||||
}
|
||||
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -53,7 +49,7 @@ pub async fn resolve_handle(
|
||||
.cache
|
||||
.set(&cache_key, &row.did, std::time::Duration::from_secs(300))
|
||||
.await;
|
||||
(StatusCode::OK, Json(json!({ "did": row.did }))).into_response()
|
||||
DidResponse::new(row.did).into_response()
|
||||
}
|
||||
Ok(None) => match crate::handle::resolve_handle(handle).await {
|
||||
Ok(did) => {
|
||||
@@ -61,21 +57,13 @@ pub async fn resolve_handle(
|
||||
.cache
|
||||
.set(&cache_key, &did, std::time::Duration::from_secs(300))
|
||||
.await;
|
||||
(StatusCode::OK, Json(json!({ "did": did }))).into_response()
|
||||
DidResponse::new(did).into_response()
|
||||
}
|
||||
Err(_) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => ApiError::HandleNotFound.into_response(),
|
||||
},
|
||||
Err(e) => {
|
||||
error!("DB error resolving handle: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,32 +138,21 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
|
||||
let (user_id, did, migrated_to_pds) = match user {
|
||||
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
|
||||
Ok(None) => {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
|
||||
return ApiError::NotFoundMsg("User not found".into()).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB Error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if !did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "User is not did:web"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::NotFoundMsg("User is not did:web".into()).into_response();
|
||||
}
|
||||
let subdomain_host = format!("{}.{}", handle, hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let expected_self_hosted = format!("did:web:{}", encoded_subdomain);
|
||||
if did != expected_self_hosted {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
|
||||
)
|
||||
return ApiError::NotFoundMsg("External did:web - DID document hosted by user".into())
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -235,30 +212,18 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
|
||||
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to generate public key multibase: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -307,23 +272,15 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
let (user_id, did, migrated_to_pds) = match user {
|
||||
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
|
||||
Ok(None) => {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
|
||||
return ApiError::NotFoundMsg("User not found".into()).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB Error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if !did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "User is not did:web"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::NotFoundMsg("User is not did:web".into()).into_response();
|
||||
}
|
||||
let encoded_hostname = hostname.replace(':', "%3A");
|
||||
let old_path_format = format!("did:web:{}:u:{}", encoded_hostname, handle);
|
||||
@@ -331,10 +288,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let new_subdomain_format = format!("did:web:{}", encoded_subdomain);
|
||||
if did != old_path_format && did != new_subdomain_format {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
|
||||
)
|
||||
return ApiError::NotFoundMsg("External did:web - DID document hosted by user".into())
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -394,30 +348,18 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to generate public key multibase: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -587,11 +529,7 @@ pub async fn get_recommended_did_credentials(
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
}
|
||||
};
|
||||
let auth_user =
|
||||
@@ -601,20 +539,20 @@ pub async fn get_recommended_did_credentials(
|
||||
};
|
||||
let user = match sqlx::query!(
|
||||
"SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1",
|
||||
auth_user.did
|
||||
&auth_user.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => return ApiError::InternalError.into_response(),
|
||||
_ => return ApiError::InternalError(None).into_response(),
|
||||
};
|
||||
let key_bytes = match auth_user.key_bytes {
|
||||
Some(kb) => kb,
|
||||
None => {
|
||||
return ApiError::AuthenticationFailedMsg(
|
||||
return ApiError::AuthenticationFailed(Some(
|
||||
"OAuth tokens cannot get DID credentials".into(),
|
||||
)
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
@@ -622,7 +560,7 @@ pub async fn get_recommended_did_credentials(
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) {
|
||||
Ok(k) => k,
|
||||
Err(_) => return ApiError::InternalError.into_response(),
|
||||
Err(_) => return ApiError::InternalError(None).into_response(),
|
||||
};
|
||||
let did_key = signing_key_to_did_key(&signing_key);
|
||||
let rotation_keys = if auth_user.did.starts_with("did:web:") {
|
||||
@@ -689,28 +627,22 @@ pub async fn update_handle(
|
||||
.check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({"error": "RateLimitExceeded", "message": "Too many handle updates. Try again later."})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(Some("Too many handle updates. Try again later.".into(),))
|
||||
.into_response();
|
||||
}
|
||||
if !state
|
||||
.check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({"error": "RateLimitExceeded", "message": "Daily handle update limit exceeded."})),
|
||||
)
|
||||
return ApiError::RateLimitExceeded(Some("Daily handle update limit exceeded.".into()))
|
||||
.into_response();
|
||||
}
|
||||
let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
|
||||
let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => return ApiError::InternalError.into_response(),
|
||||
_ => return ApiError::InternalError(None).into_response(),
|
||||
};
|
||||
let user_id = user_row.id;
|
||||
let current_handle = user_row.handle;
|
||||
@@ -722,35 +654,21 @@ pub async fn update_handle(
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
|
||||
),
|
||||
)
|
||||
return ApiError::InvalidHandle(Some("Handle contains invalid characters".into()))
|
||||
.into_response();
|
||||
}
|
||||
for segment in new_handle.split('.') {
|
||||
if segment.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Handle contains empty segment"})),
|
||||
)
|
||||
return ApiError::InvalidHandle(Some("Handle contains empty segment".into()))
|
||||
.into_response();
|
||||
}
|
||||
if segment.starts_with('-') || segment.ends_with('-') {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Handle segment cannot start or end with hyphen"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidHandle(Some("Handle segment cannot start or end with hyphen".into(),))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
if crate::moderation::has_explicit_slur(&new_handle) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})),
|
||||
)
|
||||
return ApiError::InvalidHandle(Some("Inappropriate language in handle".into()))
|
||||
.into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
@@ -774,31 +692,17 @@ pub async fn update_handle(
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
return EmptyResponse::ok().into_response();
|
||||
}
|
||||
if short_part.contains('.') {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidHandle",
|
||||
"message": "Nested subdomains are not allowed. Use a simple handle without dots."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidHandle(Some("Nested subdomains are not allowed. Use a simple handle without dots.".into(),))
|
||||
.into_response();
|
||||
}
|
||||
if short_part.len() < 3 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Handle too short"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidHandle(Some("Handle too short".into())).into_response();
|
||||
}
|
||||
if short_part.len() > 18 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Handle too long"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidHandle(Some("Handle too long".into())).into_response();
|
||||
}
|
||||
full_handle
|
||||
} else {
|
||||
@@ -809,41 +713,26 @@ pub async fn update_handle(
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
return EmptyResponse::ok().into_response();
|
||||
}
|
||||
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();
|
||||
return ApiError::HandleNotAvailable(None).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();
|
||||
return ApiError::HandleNotAvailable(Some(
|
||||
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();
|
||||
return ApiError::HandleNotAvailable(Some(format!(
|
||||
"Handle verification failed: {}",
|
||||
e
|
||||
)))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
new_handle.clone()
|
||||
@@ -856,11 +745,7 @@ pub async fn update_handle(
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
if let Ok(Some(_)) = existing {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::HandleTaken.into_response();
|
||||
}
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET handle = $1 WHERE id = $2",
|
||||
@@ -886,15 +771,11 @@ pub async fn update_handle(
|
||||
if let Err(e) = update_plc_handle(&state, &did, &handle).await {
|
||||
warn!("Failed to update PLC handle: {}", e);
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating handle: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
fn generate_plc_token() -> String {
|
||||
@@ -36,7 +34,7 @@ pub async fn request_plc_operation_signature(
|
||||
) {
|
||||
return e;
|
||||
}
|
||||
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did)
|
||||
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", &auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
@@ -44,7 +42,7 @@ pub async fn request_plc_operation_signature(
|
||||
Ok(None) => return ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let _ = sqlx::query!(
|
||||
@@ -68,11 +66,7 @@ pub async fn request_plc_operation_signature(
|
||||
.await
|
||||
{
|
||||
error!("Failed to create PLC token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) =
|
||||
@@ -84,5 +78,5 @@ pub async fn request_plc_operation_signature(
|
||||
"PLC operation signature requested for user {}",
|
||||
auth_user.did
|
||||
);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
+21
-111
@@ -1,8 +1,6 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker};
|
||||
use crate::plc::{
|
||||
PlcClient, PlcError, PlcOpOrTombstone, PlcService, create_update_op, sign_operation,
|
||||
};
|
||||
use crate::circuit_breaker::with_circuit_breaker;
|
||||
use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -13,9 +11,9 @@ use axum::{
|
||||
use chrono::Utc;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -84,11 +82,7 @@ pub async fn sign_plc_operation(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
};
|
||||
let token_row = match sqlx::query!(
|
||||
@@ -101,22 +95,11 @@ pub async fn sign_plc_operation(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidToken",
|
||||
"message": "Invalid or expired token"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if Utc::now() > token_row.expires_at {
|
||||
@@ -126,14 +109,7 @@ pub async fn sign_plc_operation(
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "ExpiredToken",
|
||||
"message": "Token has expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::ExpiredToken(Some("Token has expired".into())).into_response();
|
||||
}
|
||||
let key_row = match sqlx::query!(
|
||||
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
|
||||
@@ -144,11 +120,7 @@ pub async fn sign_plc_operation(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User signing key not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("User signing key not found".into())).into_response();
|
||||
}
|
||||
};
|
||||
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
|
||||
@@ -156,75 +128,28 @@ pub async fn sign_plc_operation(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let signing_key = match SigningKey::from_slice(&key_bytes) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to create signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
let did_clone = did.clone();
|
||||
let result: Result<PlcOpOrTombstone, CircuitBreakerError<PlcError>> =
|
||||
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
plc_client.get_last_op(&did_clone).await
|
||||
})
|
||||
.await;
|
||||
let last_op = match result {
|
||||
let last_op = match with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
plc_client.get_last_op(&did_clone).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(op) => op,
|
||||
Err(CircuitBreakerError::CircuitOpen(e)) => {
|
||||
warn!("PLC directory circuit breaker open: {}", e);
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "ServiceUnavailable",
|
||||
"message": "PLC directory service temporarily unavailable"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(CircuitBreakerError::OperationFailed(PlcError::NotFound)) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"error": "NotFound",
|
||||
"message": "DID not found in PLC directory"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(CircuitBreakerError::OperationFailed(e)) => {
|
||||
error!("Failed to fetch PLC operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": "Failed to communicate with PLC directory"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
if last_op.is_tombstone() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "DID is tombstoned"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::from(PlcError::Tombstoned).into_response();
|
||||
}
|
||||
let services = input.services.map(|s| {
|
||||
s.into_iter()
|
||||
@@ -248,33 +173,18 @@ pub async fn sign_plc_operation(
|
||||
) {
|
||||
Ok(op) => op,
|
||||
Err(PlcError::Tombstoned) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Cannot update tombstoned DID"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Cannot update tombstoned DID".into()).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create PLC operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let signed_op = match sign_operation(&unsigned_op, &signing_key) {
|
||||
Ok(op) => op,
|
||||
Err(e) => {
|
||||
error!("Failed to sign PLC operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let _ = sqlx::query!(
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker};
|
||||
use crate::plc::{PlcClient, PlcError, signing_key_to_did_key, validate_plc_operation};
|
||||
use crate::api::{ApiError, EmptyResponse};
|
||||
use crate::circuit_breaker::with_circuit_breaker;
|
||||
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -64,11 +63,7 @@ pub async fn submit_plc_operation(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
};
|
||||
let key_row = match sqlx::query!(
|
||||
@@ -80,11 +75,7 @@ pub async fn submit_plc_operation(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User signing key not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("User signing key not found".into())).into_response();
|
||||
}
|
||||
};
|
||||
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
|
||||
@@ -92,22 +83,14 @@ pub async fn submit_plc_operation(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let signing_key = match SigningKey::from_slice(&key_bytes) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to create signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let user_did_key = signing_key_to_did_key(&signing_key);
|
||||
@@ -118,14 +101,10 @@ pub async fn submit_plc_operation(
|
||||
.iter()
|
||||
.any(|k| k.as_str() == Some(&server_rotation_key));
|
||||
if !has_server_key {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Rotation keys do not include server's rotation key"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"Rotation keys do not include server's rotation key".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
if let Some(services) = op.get("services").and_then(|v| v.as_object())
|
||||
@@ -134,23 +113,11 @@ pub async fn submit_plc_operation(
|
||||
let service_type = pds.get("type").and_then(|v| v.as_str());
|
||||
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
|
||||
if service_type != Some("AtprotoPersonalDataServer") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Incorrect type on atproto_pds service"
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Incorrect type on atproto_pds service".into())
|
||||
.into_response();
|
||||
}
|
||||
if endpoint != Some(&public_url) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Incorrect endpoint on atproto_pds service"
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Incorrect endpoint on atproto_pds service".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
@@ -158,13 +125,7 @@ pub async fn submit_plc_operation(
|
||||
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
|
||||
&& atproto_key != user_did_key
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Incorrect signing key in verificationMethods"
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Incorrect signing key in verificationMethods".into())
|
||||
.into_response();
|
||||
}
|
||||
if let Some(also_known_as) = (!user.handle.is_empty())
|
||||
@@ -174,50 +135,21 @@ pub async fn submit_plc_operation(
|
||||
let expected_handle = format!("at://{}", user.handle);
|
||||
let first_aka = also_known_as.first().and_then(|v| v.as_str());
|
||||
if first_aka != Some(&expected_handle) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Incorrect handle in alsoKnownAs"
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Incorrect handle in alsoKnownAs".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
|
||||
let operation_clone = input.operation.clone();
|
||||
let did_clone = did.clone();
|
||||
let result: Result<(), CircuitBreakerError<PlcError>> =
|
||||
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
plc_client
|
||||
.send_operation(&did_clone, &operation_clone)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => {}
|
||||
Err(CircuitBreakerError::CircuitOpen(e)) => {
|
||||
warn!("PLC directory circuit breaker open: {}", e);
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "ServiceUnavailable",
|
||||
"message": "PLC directory service temporarily unavailable"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(CircuitBreakerError::OperationFailed(e)) => {
|
||||
error!("PLC operation failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to submit to PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
plc_client
|
||||
.send_operation(&did_clone, &operation_clone)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
{
|
||||
return ApiError::from(e).into_response();
|
||||
}
|
||||
match sqlx::query!(
|
||||
"INSERT INTO repo_seq (did, event_type, handle) VALUES ($1, 'identity', $2) RETURNING seq",
|
||||
@@ -244,5 +176,5 @@ pub async fn submit_plc_operation(
|
||||
warn!(did = %did, "Failed to refresh DID cache after PLC update");
|
||||
}
|
||||
info!(did = %did, "PLC operation submitted successfully");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
@@ -10,10 +10,15 @@ pub mod notification_prefs;
|
||||
pub mod proxy;
|
||||
pub mod proxy_client;
|
||||
pub mod repo;
|
||||
pub mod responses;
|
||||
pub mod server;
|
||||
pub mod temp;
|
||||
pub mod validation;
|
||||
pub mod verification;
|
||||
|
||||
pub use error::ApiError;
|
||||
pub use responses::{
|
||||
DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse,
|
||||
StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse,
|
||||
};
|
||||
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
|
||||
|
||||
+10
-28
@@ -64,7 +64,7 @@ pub async fn create_report(
|
||||
.await;
|
||||
}
|
||||
|
||||
create_report_locally(&state, did, auth_user.is_takendown, input).await
|
||||
create_report_locally(&state, did, auth_user.is_takendown(), input).await
|
||||
}
|
||||
|
||||
async fn proxy_to_report_service(
|
||||
@@ -76,10 +76,7 @@ async fn proxy_to_report_service(
|
||||
) -> Response {
|
||||
if let Err(e) = is_ssrf_safe(service_url) {
|
||||
error!("Report service URL failed SSRF check: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Invalid report service configuration"})),
|
||||
)
|
||||
return ApiError::InternalError(Some("Invalid report service configuration".into()))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -101,20 +98,20 @@ async fn proxy_to_report_service(
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to decrypt user key for report service auth");
|
||||
return ApiError::AuthenticationFailedMsg(
|
||||
return ApiError::AuthenticationFailed(Some(
|
||||
"Failed to get signing key".into(),
|
||||
)
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return ApiError::AuthenticationFailedMsg("User has no signing key".into())
|
||||
return ApiError::AuthenticationFailed(Some("User has no signing key".into()))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "DB error fetching user key for report");
|
||||
return ApiError::AuthenticationFailedMsg("Failed to get signing key".into())
|
||||
return ApiError::AuthenticationFailed(Some("Failed to get signing key".into()))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
@@ -130,11 +127,7 @@ async fn proxy_to_report_service(
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token for report: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,10 +201,7 @@ async fn create_report_locally(
|
||||
const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal";
|
||||
|
||||
if is_takendown && input.reason_type != REASON_APPEAL {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Report not accepted from takendown account"})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Report not accepted from takendown account".into())
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -226,11 +216,7 @@ async fn create_report_locally(
|
||||
];
|
||||
|
||||
if !valid_reason_types.contains(&input.reason_type.as_str()) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid reasonType"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Invalid reasonType".into()).into_response();
|
||||
}
|
||||
|
||||
let created_at = chrono::Utc::now();
|
||||
@@ -251,11 +237,7 @@ async fn create_report_locally(
|
||||
|
||||
if let Err(e) = insert {
|
||||
error!("Failed to insert report: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
info!(
|
||||
|
||||
+36
-123
@@ -1,9 +1,10 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::validate_bearer_token;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -29,20 +30,12 @@ pub async fn get_notification_prefs(State(state): State<AppState>, headers: Head
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response(),
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let user = match validate_bearer_token(&state.db, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(None).into_response();
|
||||
}
|
||||
};
|
||||
let row =
|
||||
@@ -66,13 +59,9 @@ pub async fn get_notification_prefs(State(state): State<AppState>, headers: Head
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
|
||||
}
|
||||
};
|
||||
let email: String = row.get("email");
|
||||
let channel: String = row.get("channel");
|
||||
@@ -120,36 +109,24 @@ pub async fn get_notification_history(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response(),
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let user = match validate_bearer_token(&state.db, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let user_id: uuid::Uuid =
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &user.did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let rows =
|
||||
@@ -173,13 +150,9 @@ pub async fn get_notification_history(
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let sensitive_types = [
|
||||
@@ -288,39 +261,27 @@ pub async fn update_notification_prefs(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response(),
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let user = match validate_bearer_token(&state.db, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let user_row =
|
||||
match sqlx::query!(
|
||||
"SELECT id, handle, email FROM users WHERE did = $1",
|
||||
user.did
|
||||
&user.did
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(e) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let user_id = user_row.id;
|
||||
@@ -332,14 +293,10 @@ pub async fn update_notification_prefs(
|
||||
if let Some(ref channel) = input.preferred_channel {
|
||||
let valid_channels = ["email", "discord", "telegram", "signal"];
|
||||
if !valid_channels.contains(&channel.as_str()) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Invalid channel. Must be one of: email, discord, telegram, signal"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2"#
|
||||
@@ -349,11 +306,7 @@ pub async fn update_notification_prefs(
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
|
||||
}
|
||||
info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
|
||||
}
|
||||
@@ -361,19 +314,11 @@ pub async fn update_notification_prefs(
|
||||
if let Some(ref new_email) = input.email {
|
||||
let email_clean = new_email.trim().to_lowercase();
|
||||
if email_clean.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Email cannot be empty"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Email cannot be empty".into()).into_response();
|
||||
}
|
||||
|
||||
if !crate::api::validation::is_valid_email(&email_clean) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidEmail.into_response();
|
||||
}
|
||||
|
||||
if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) {
|
||||
@@ -388,11 +333,7 @@ pub async fn update_notification_prefs(
|
||||
.await;
|
||||
|
||||
if let Ok(Some(_)) = exists {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "EmailTaken", "message": "Email already in use"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::EmailTaken.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = request_channel_verification(
|
||||
@@ -405,11 +346,7 @@ pub async fn update_notification_prefs(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(e)).into_response();
|
||||
}
|
||||
verification_required.push("email".to_string());
|
||||
info!(did = %user.did, "Requested email verification");
|
||||
@@ -425,11 +362,7 @@ pub async fn update_notification_prefs(
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
|
||||
}
|
||||
info!(did = %user.did, "Cleared Discord ID");
|
||||
} else {
|
||||
@@ -438,11 +371,7 @@ pub async fn update_notification_prefs(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(e)).into_response();
|
||||
}
|
||||
verification_required.push("discord".to_string());
|
||||
info!(did = %user.did, "Requested Discord verification");
|
||||
@@ -459,11 +388,7 @@ pub async fn update_notification_prefs(
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
|
||||
}
|
||||
info!(did = %user.did, "Cleared Telegram username");
|
||||
} else {
|
||||
@@ -477,11 +402,7 @@ pub async fn update_notification_prefs(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(e)).into_response();
|
||||
}
|
||||
verification_required.push("telegram".to_string());
|
||||
info!(did = %user.did, "Requested Telegram verification");
|
||||
@@ -497,11 +418,7 @@ pub async fn update_notification_prefs(
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
|
||||
}
|
||||
info!(did = %user.did, "Cleared Signal number");
|
||||
} else {
|
||||
@@ -509,11 +426,7 @@ pub async fn update_notification_prefs(
|
||||
request_channel_verification(&state.db, user_id, &user.did, "signal", signal, None)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some(e)).into_response();
|
||||
}
|
||||
verification_required.push("signal".to_string());
|
||||
info!(did = %user.did, "Requested Signal verification");
|
||||
|
||||
+14
-42
@@ -1,9 +1,9 @@
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
body::Bytes,
|
||||
extract::{RawQuery, Request, State},
|
||||
handler::Handler,
|
||||
@@ -11,7 +11,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures_util::future::Either;
|
||||
use serde_json::json;
|
||||
use tower::{Service, util::BoxCloneSyncService};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
@@ -120,44 +119,23 @@ async fn proxy_handler(
|
||||
let method = uri.path().trim_start_matches("/");
|
||||
if is_protected_method(&method) {
|
||||
warn!(method = %method, "Attempted to proxy protected method");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Cannot proxy protected method: {}", method)
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest(format!("Cannot proxy protected method: {}", method))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let proxy_header = match headers.get("atproto-proxy").and_then(|h| h.to_str().ok()) {
|
||||
Some(h) => h.to_string(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Missing required atproto-proxy header"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(proxy_header) = headers
|
||||
.get("atproto-proxy")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(String::from)
|
||||
else {
|
||||
return ApiError::InvalidRequest("Missing required atproto-proxy header".into())
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let did = proxy_header.split('#').next().unwrap_or(&proxy_header);
|
||||
let resolved = match state.did_resolver.resolve_did(did).await {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
error!(did = %did, "Could not resolve service DID");
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamFailure",
|
||||
"message": "Could not resolve service DID"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(resolved) = state.did_resolver.resolve_did(did).await else {
|
||||
error!(did = %did, "Could not resolve service DID");
|
||||
return ApiError::UpstreamFailure.into_response();
|
||||
};
|
||||
|
||||
let target_url = match &query {
|
||||
@@ -220,14 +198,8 @@ async fn proxy_handler(
|
||||
"{} error=\"invalid_token\", error_description=\"Token has expired\"",
|
||||
scheme
|
||||
);
|
||||
let mut response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "ExpiredToken",
|
||||
"message": "Token has expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
let mut response =
|
||||
ApiError::ExpiredToken(Some("Token has expired".into())).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("WWW-Authenticate", www_auth.parse().unwrap());
|
||||
|
||||
+33
-97
@@ -1,3 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::{ServiceTokenVerifier, is_service_token};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::state::AppState;
|
||||
@@ -23,17 +24,10 @@ pub async fn upload_blob(
|
||||
headers: axum::http::HeaderMap,
|
||||
body: Body,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
let Some(token) = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
) else {
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
};
|
||||
|
||||
let is_service_auth = is_service_token(&token);
|
||||
@@ -51,11 +45,11 @@ pub async fn upload_blob(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Service token verification failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": format!("Service token verification failed: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(Some(format!(
|
||||
"Service token verification failed: {}",
|
||||
e
|
||||
)))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -74,22 +68,18 @@ pub async fn upload_blob(
|
||||
}
|
||||
let deactivated = sqlx::query_scalar!(
|
||||
"SELECT deactivated_at FROM users WHERE did = $1",
|
||||
user.did
|
||||
&user.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten();
|
||||
let ctrl_did = user.controller_did.clone();
|
||||
(user.did, deactivated.is_some(), ctrl_did)
|
||||
let ctrl_did = user.controller_did.map(|d| d.to_string());
|
||||
(user.did.to_string(), deactivated.is_some(), ctrl_did)
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(None).into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -98,14 +88,7 @@ pub async fn upload_blob(
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountMigrated",
|
||||
"message": "Account has been migrated to another PDS. Blob operations are not allowed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::Forbidden.into_response();
|
||||
}
|
||||
|
||||
let mime_type = headers
|
||||
@@ -120,11 +103,7 @@ pub async fn upload_blob(
|
||||
let user_id = match user_query {
|
||||
Ok(Some(row)) => row.id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,22 +122,18 @@ pub async fn upload_blob(
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Failed to stream blob to storage: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to store blob"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to store blob".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let size = upload_result.size;
|
||||
if size > max_size {
|
||||
let _ = state.blob_store.delete(&temp_key).await;
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(json!({"error": "BlobTooLarge", "message": format!("Blob size {} exceeds maximum of {} bytes", size, max_size)})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"Blob size {} exceeds maximum of {} bytes",
|
||||
size, max_size
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let multihash = match Multihash::wrap(0x12, &upload_result.sha256_hash) {
|
||||
@@ -166,11 +141,7 @@ pub async fn upload_blob(
|
||||
Err(e) => {
|
||||
let _ = state.blob_store.delete(&temp_key).await;
|
||||
error!("Failed to create multihash for blob: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to hash blob"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to hash blob".into())).into_response();
|
||||
}
|
||||
};
|
||||
let cid = Cid::new_v1(0x55, multihash);
|
||||
@@ -187,11 +158,7 @@ pub async fn upload_blob(
|
||||
Err(e) => {
|
||||
let _ = state.blob_store.delete(&temp_key).await;
|
||||
error!("Failed to begin transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -212,22 +179,14 @@ pub async fn upload_blob(
|
||||
Err(e) => {
|
||||
let _ = state.blob_store.delete(&temp_key).await;
|
||||
error!("Failed to insert blob record: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if was_inserted && let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
|
||||
let _ = state.blob_store.delete(&temp_key).await;
|
||||
error!("Failed to copy blob to final location: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to store blob"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to store blob".into())).into_response();
|
||||
}
|
||||
|
||||
let _ = state.blob_store.delete(&temp_key).await;
|
||||
@@ -240,11 +199,7 @@ pub async fn upload_blob(
|
||||
storage_key, cleanup_err
|
||||
);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
@@ -303,41 +258,26 @@ pub async fn list_missing_blobs(
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<ListMissingBlobsParams>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
let Some(token) = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
) else {
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
};
|
||||
let auth_user =
|
||||
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(None).into_response();
|
||||
}
|
||||
};
|
||||
let did = auth_user.did;
|
||||
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
|
||||
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let user_id = match user_query {
|
||||
Ok(Some(row)) => row.id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
|
||||
@@ -361,11 +301,7 @@ pub async fn list_missing_blobs(
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("DB error fetching missing blobs: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let has_more = rows.len() > limit as usize;
|
||||
|
||||
+83
-269
@@ -1,13 +1,12 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::create_signed_commit;
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::state::AppState;
|
||||
use crate::sync::import::{ImportError, apply_import, parse_car};
|
||||
use crate::sync::verify::CarVerifier;
|
||||
use axum::{
|
||||
Json,
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use jacquard::types::{integer::LimitedU32, string::Tid};
|
||||
@@ -28,13 +27,7 @@ pub async fn import_repo(
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true);
|
||||
if !accepting_imports {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Service is not accepting repo imports"
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Service is not accepting repo imports".into())
|
||||
.into_response();
|
||||
}
|
||||
let max_size: usize = std::env::var("MAX_IMPORT_SIZE")
|
||||
@@ -42,14 +35,11 @@ pub async fn import_repo(
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_IMPORT_SIZE);
|
||||
if body.len() > max_size {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Import size exceeds limit of {} bytes", max_size)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::PayloadTooLarge(format!(
|
||||
"Import size exceeds limit of {} bytes",
|
||||
max_size
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
@@ -72,64 +62,30 @@ pub async fn import_repo(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if user.takedown_ref.is_some() {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountTakenDown",
|
||||
"message": "Account has been taken down"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountTakedown.into_response();
|
||||
}
|
||||
let user_id = user.id;
|
||||
let (root, blocks) = match parse_car(&body).await {
|
||||
Ok((r, b)) => (r, b),
|
||||
Err(ImportError::InvalidRootCount) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Expected exactly one root in CAR file"
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest("Expected exactly one root in CAR file".into())
|
||||
.into_response();
|
||||
}
|
||||
Err(ImportError::CarParse(msg)) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Failed to parse CAR file: {}", msg)
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest(format!("Failed to parse CAR file: {}", msg))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("CAR parsing error: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Invalid CAR file: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!("Invalid CAR file: {}", e)).into_response();
|
||||
}
|
||||
};
|
||||
info!(
|
||||
@@ -138,44 +94,21 @@ pub async fn import_repo(
|
||||
blocks.len(),
|
||||
root
|
||||
);
|
||||
let root_block = match blocks.get(&root) {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Root block not found in CAR file"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(root_block) = blocks.get(&root) else {
|
||||
return ApiError::InvalidRequest("Root block not found in CAR file".into()).into_response();
|
||||
};
|
||||
let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
|
||||
Ok(commit) => commit.did().to_string(),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Invalid commit: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!("Invalid commit: {}", e)).into_response();
|
||||
}
|
||||
};
|
||||
if commit_did != *did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, did
|
||||
)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, did
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
@@ -197,37 +130,19 @@ pub async fn import_repo(
|
||||
commit_did,
|
||||
expected_did,
|
||||
}) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("MST validation failed: {}", msg)
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("CAR structure verification error: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("CAR verification failed: {}", e)
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest(format!("CAR verification failed: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
@@ -245,68 +160,36 @@ pub async fn import_repo(
|
||||
commit_did,
|
||||
expected_did,
|
||||
}) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRepo(format!(
|
||||
"CAR file is for DID {} but you are authenticated as {}",
|
||||
commit_did, expected_did
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(crate::sync::verify::VerifyError::InvalidSignature) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidSignature",
|
||||
"message": "CAR file commit signature verification failed"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"CAR file commit signature verification failed".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
Err(crate::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
|
||||
warn!("DID resolution failed during import verification: {}", msg);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Failed to verify DID: {}", msg)
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest(format!("Failed to verify DID: {}", msg))
|
||||
.into_response();
|
||||
}
|
||||
Err(crate::sync::verify::VerifyError::NoSigningKey) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "DID document does not contain a signing key"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"DID document does not contain a signing key".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("MST validation failed: {}", msg)
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("CAR verification error: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("CAR verification failed: {}", e)
|
||||
})),
|
||||
)
|
||||
return ApiError::InvalidRequest(format!("CAR verification failed: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
@@ -364,19 +247,12 @@ pub async fn import_repo(
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
error!("No signing key found for user {}", did);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Signing key not found"})),
|
||||
)
|
||||
return ApiError::InternalError(Some("Signing key not found".into()))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let key_bytes =
|
||||
@@ -384,22 +260,14 @@ pub async fn import_repo(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt signing key: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let signing_key = match SigningKey::from_slice(&key_bytes) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Invalid signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let new_rev = Tid::now(LimitedU32::MIN);
|
||||
@@ -414,22 +282,14 @@ pub async fn import_repo(
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Failed to create new commit: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let new_root_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
|
||||
Ok(cid) => cid,
|
||||
Err(e) => {
|
||||
error!("Failed to store new commit block: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let new_root_str = new_root_cid.to_string();
|
||||
@@ -443,11 +303,7 @@ pub async fn import_repo(
|
||||
.await
|
||||
{
|
||||
error!("Failed to update repo root: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
|
||||
all_block_cids.push(new_root_cid.to_bytes());
|
||||
@@ -464,11 +320,7 @@ pub async fn import_repo(
|
||||
.await
|
||||
{
|
||||
error!("Failed to insert user_blocks: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
info!(
|
||||
"Created new commit for imported repo: cid={}, rev={}",
|
||||
@@ -499,79 +351,41 @@ pub async fn import_repo(
|
||||
);
|
||||
}
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(ImportError::SizeLimitExceeded) => {
|
||||
ApiError::PayloadTooLarge(format!("Import exceeds block limit of {}", max_blocks))
|
||||
.into_response()
|
||||
}
|
||||
Err(ImportError::RepoNotFound) => {
|
||||
ApiError::RepoNotFound(Some("Repository not initialized for this account".into()))
|
||||
.into_response()
|
||||
}
|
||||
Err(ImportError::InvalidCbor(msg)) => {
|
||||
ApiError::InvalidRequest(format!("Invalid CBOR data: {}", msg)).into_response()
|
||||
}
|
||||
Err(ImportError::InvalidCommit(msg)) => {
|
||||
ApiError::InvalidRequest(format!("Invalid commit structure: {}", msg)).into_response()
|
||||
}
|
||||
Err(ImportError::BlockNotFound(cid)) => {
|
||||
ApiError::InvalidRequest(format!("Referenced block not found in CAR: {}", cid))
|
||||
.into_response()
|
||||
}
|
||||
Err(ImportError::ConcurrentModification) => ApiError::InvalidSwap(Some("Repository is being modified by another operation, please retry".into(),))
|
||||
.into_response(),
|
||||
Err(ImportError::VerificationFailed(ve)) => {
|
||||
ApiError::InvalidRequest(format!("CAR verification failed: {}", ve)).into_response()
|
||||
}
|
||||
Err(ImportError::DidMismatch { car_did, auth_did }) => {
|
||||
ApiError::InvalidRequest(format!(
|
||||
"CAR is for {} but authenticated as {}",
|
||||
car_did, auth_did
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
Err(ImportError::SizeLimitExceeded) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Import exceeds block limit of {}", max_blocks)
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(ImportError::RepoNotFound) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"error": "RepoNotFound",
|
||||
"message": "Repository not initialized for this account"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(ImportError::InvalidCbor(msg)) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Invalid CBOR data: {}", msg)
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(ImportError::InvalidCommit(msg)) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Invalid commit structure: {}", msg)
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(ImportError::BlockNotFound(cid)) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Referenced block not found in CAR: {}", cid)
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(ImportError::ConcurrentModification) => (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({
|
||||
"error": "ConcurrentModification",
|
||||
"message": "Repository is being modified by another operation, please retry"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(ImportError::VerificationFailed(ve)) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "VerificationFailed",
|
||||
"message": format!("CAR verification failed: {}", ve)
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(ImportError::DidMismatch { car_did, auth_did }) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "DidMismatch",
|
||||
"message": format!("CAR is for {} but authenticated as {}", car_did, auth_did)
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("Import error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-17
@@ -1,8 +1,9 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::AtIdentifier;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -10,7 +11,7 @@ use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DescribeRepoInput {
|
||||
pub repo: String,
|
||||
pub repo: AtIdentifier,
|
||||
}
|
||||
|
||||
pub async fn describe_repo(
|
||||
@@ -18,19 +19,20 @@ pub async fn describe_repo(
|
||||
Query(input): Query<DescribeRepoInput>,
|
||||
) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let user_row = if input.repo.starts_with("did:") {
|
||||
let user_row = if input.repo.is_did() {
|
||||
sqlx::query!(
|
||||
"SELECT id, handle, did FROM users WHERE did = $1",
|
||||
input.repo
|
||||
input.repo.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
|
||||
} else {
|
||||
let handle = if !input.repo.contains('.') {
|
||||
format!("{}.{}", input.repo, hostname)
|
||||
let repo_str = input.repo.as_str();
|
||||
let handle = if !repo_str.contains('.') {
|
||||
format!("{}.{}", repo_str, hostname)
|
||||
} else {
|
||||
input.repo.clone()
|
||||
repo_str.to_string()
|
||||
};
|
||||
sqlx::query!(
|
||||
"SELECT id, handle, did FROM users WHERE handle = $1",
|
||||
@@ -43,18 +45,10 @@ pub async fn describe_repo(
|
||||
let (user_id, handle, did) = match user_row {
|
||||
Ok(Some((id, handle, did))) => (id, handle, did),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "RepoNotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RepoNotFound(Some("Repo not found".into())).into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let collections_query = sqlx::query!(
|
||||
|
||||
+79
-184
@@ -1,10 +1,11 @@
|
||||
use super::validation::validate_record_with_status;
|
||||
use super::write::has_verified_comms_channel;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use crate::validation::ValidationStatus;
|
||||
use crate::types::{AtIdentifier, AtUri, Nsid, Rkey};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -12,10 +13,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use cid::Cid;
|
||||
use jacquard::types::{
|
||||
integer::LimitedU32,
|
||||
string::{Nsid, Tid},
|
||||
};
|
||||
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -30,24 +27,24 @@ const MAX_BATCH_WRITES: usize = 200;
|
||||
pub enum WriteOp {
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#create")]
|
||||
Create {
|
||||
collection: String,
|
||||
rkey: Option<String>,
|
||||
collection: Nsid,
|
||||
rkey: Option<Rkey>,
|
||||
value: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#update")]
|
||||
Update {
|
||||
collection: String,
|
||||
rkey: String,
|
||||
collection: Nsid,
|
||||
rkey: Rkey,
|
||||
value: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#delete")]
|
||||
Delete { collection: String, rkey: String },
|
||||
Delete { collection: Nsid, rkey: Rkey },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyWritesInput {
|
||||
pub repo: String,
|
||||
pub repo: AtIdentifier,
|
||||
pub validate: Option<bool>,
|
||||
pub writes: Vec<WriteOp>,
|
||||
pub swap_commit: Option<String>,
|
||||
@@ -58,14 +55,14 @@ pub struct ApplyWritesInput {
|
||||
pub enum WriteResult {
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#createResult")]
|
||||
CreateResult {
|
||||
uri: String,
|
||||
uri: AtUri,
|
||||
cid: String,
|
||||
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
|
||||
validation_status: Option<String>,
|
||||
},
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#updateResult")]
|
||||
UpdateResult {
|
||||
uri: String,
|
||||
uri: AtUri,
|
||||
cid: String,
|
||||
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
|
||||
validation_status: Option<String>,
|
||||
@@ -96,51 +93,28 @@ pub async fn apply_writes(
|
||||
input.repo,
|
||||
input.writes.len()
|
||||
);
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
let Some(token) = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
) else {
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
};
|
||||
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => return ApiError::AuthenticationFailed(None).into_response(),
|
||||
};
|
||||
let did = auth_user.did.clone();
|
||||
let is_oauth = auth_user.is_oauth;
|
||||
let scope = auth_user.scope;
|
||||
let controller_did = auth_user.controller_did.clone();
|
||||
if input.repo != did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})),
|
||||
)
|
||||
if input.repo.as_str() != did {
|
||||
return ApiError::InvalidRepo("Repo does not match authenticated user".into())
|
||||
.into_response();
|
||||
}
|
||||
if crate::util::is_account_migrated(&state.db, &did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountMigrated",
|
||||
"message": "Account has been migrated to another PDS. Repo operations are not allowed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountMigrated.into_response();
|
||||
}
|
||||
let is_verified = has_verified_comms_channel(&state.db, &did)
|
||||
.await
|
||||
@@ -149,27 +123,13 @@ pub async fn apply_writes(
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotVerified.into_response();
|
||||
}
|
||||
if input.writes.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "writes array is empty"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("writes array is empty".into()).into_response();
|
||||
}
|
||||
if input.writes.len() > MAX_BATCH_WRITES {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": format!("Too many writes (max {})", MAX_BATCH_WRITES)})),
|
||||
)
|
||||
return ApiError::InvalidRequest(format!("Too many writes (max {})", MAX_BATCH_WRITES))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -179,34 +139,34 @@ pub async fn apply_writes(
|
||||
.unwrap_or(false);
|
||||
if is_oauth || has_custom_scope {
|
||||
use std::collections::HashSet;
|
||||
let create_collections: HashSet<&str> = input
|
||||
let create_collections: HashSet<&Nsid> = input
|
||||
.writes
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
if let WriteOp::Create { collection, .. } = w {
|
||||
Some(collection.as_str())
|
||||
Some(collection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let update_collections: HashSet<&str> = input
|
||||
let update_collections: HashSet<&Nsid> = input
|
||||
.writes
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
if let WriteOp::Update { collection, .. } = w {
|
||||
Some(collection.as_str())
|
||||
Some(collection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let delete_collections: HashSet<&str> = input
|
||||
let delete_collections: HashSet<&Nsid> = input
|
||||
.writes
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
if let WriteOp::Delete { collection, .. } = w {
|
||||
Some(collection.as_str())
|
||||
Some(collection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -245,18 +205,12 @@ pub async fn apply_writes(
|
||||
}
|
||||
}
|
||||
|
||||
let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("User not found".into())).into_response(),
|
||||
};
|
||||
let root_cid_str: String = match sqlx::query_scalar!(
|
||||
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
|
||||
@@ -266,53 +220,27 @@ pub async fn apply_writes(
|
||||
.await
|
||||
{
|
||||
Ok(Some(cid_str)) => cid_str,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Repo root not found".into())).into_response(),
|
||||
};
|
||||
let current_root_cid = match Cid::from_str(&root_cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response()
|
||||
}
|
||||
};
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
{
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
|
||||
};
|
||||
let commit = match Commit::from_cbor(&commit_bytes) {
|
||||
Ok(c) => c,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
|
||||
};
|
||||
let original_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let mut mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
@@ -334,7 +262,7 @@ pub async fn apply_writes(
|
||||
match validate_record_with_status(
|
||||
value,
|
||||
collection,
|
||||
rkey.as_deref(),
|
||||
rkey.as_ref().map(|r| r.as_str()),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
@@ -342,51 +270,38 @@ pub async fn apply_writes(
|
||||
}
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let rkey = rkey
|
||||
.clone()
|
||||
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
|
||||
let rkey = rkey.clone().unwrap_or_else(Rkey::generate);
|
||||
let record_ipld = crate::util::json_to_ipld(value);
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
|
||||
return ApiError::InvalidRecord("Failed to serialize record".into())
|
||||
.into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": "Failed to store record"}),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(Some("Failed to store record".into()))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let collection_nsid = match collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(),
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, rkey);
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
mst = match mst.add(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to add to MST"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(Some("Failed to add to MST".into()))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let uri = format!("at://{}/{}/{}", did, collection, rkey);
|
||||
let uri = AtUri::from_parts(&did, collection, &rkey);
|
||||
results.push(WriteResult::CreateResult {
|
||||
uri,
|
||||
cid: record_cid.to_string(),
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
});
|
||||
ops.push(RecordOp::Create {
|
||||
collection: collection.clone(),
|
||||
rkey,
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
cid: record_cid,
|
||||
});
|
||||
}
|
||||
@@ -402,7 +317,7 @@ pub async fn apply_writes(
|
||||
match validate_record_with_status(
|
||||
value,
|
||||
collection,
|
||||
Some(rkey),
|
||||
Some(rkey.as_str()),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
@@ -413,66 +328,54 @@ pub async fn apply_writes(
|
||||
let record_ipld = crate::util::json_to_ipld(value);
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
|
||||
return ApiError::InvalidRecord("Failed to serialize record".into())
|
||||
.into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": "Failed to store record"}),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(Some("Failed to store record".into()))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let collection_nsid = match collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(),
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, rkey);
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
let prev_record_cid = mst.get(&key).await.ok().flatten();
|
||||
mst = match mst.update(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update MST"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(Some("Failed to update MST".into()))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let uri = format!("at://{}/{}/{}", did, collection, rkey);
|
||||
let uri = AtUri::from_parts(&did, collection, rkey);
|
||||
results.push(WriteResult::UpdateResult {
|
||||
uri,
|
||||
cid: record_cid.to_string(),
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
});
|
||||
ops.push(RecordOp::Update {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
cid: record_cid,
|
||||
prev: prev_record_cid,
|
||||
});
|
||||
}
|
||||
WriteOp::Delete { collection, rkey } => {
|
||||
let collection_nsid = match collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(),
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, rkey);
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
modified_keys.push(key.clone());
|
||||
let prev_record_cid = mst.get(&key).await.ok().flatten();
|
||||
mst = match mst.delete(&key).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to delete from MST"}))).into_response(),
|
||||
Err(_) => {
|
||||
return ApiError::InternalError(Some("Failed to delete from MST".into()))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
results.push(WriteResult::DeleteResult {});
|
||||
ops.push(RecordOp::Delete {
|
||||
collection: collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
prev: prev_record_cid,
|
||||
});
|
||||
}
|
||||
@@ -480,13 +383,7 @@ pub async fn apply_writes(
|
||||
}
|
||||
let new_mst_root = match mst.persist().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
|
||||
};
|
||||
let mut relevant_blocks = std::collections::BTreeMap::new();
|
||||
for key in &modified_keys {
|
||||
@@ -495,14 +392,16 @@ pub async fn apply_writes(
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
|
||||
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
|
||||
.into_response();
|
||||
}
|
||||
if original_mst
|
||||
.blocks_for_path(key, &mut relevant_blocks)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
|
||||
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let mut written_cids = tracking_store.get_all_relevant_cids();
|
||||
@@ -533,11 +432,7 @@ pub async fn apply_writes(
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
error!("Commit failed: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to commit changes"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to commit changes".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{AtIdentifier, Nsid, Rkey};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -10,7 +12,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use cid::Cid;
|
||||
use jacquard::types::string::Nsid;
|
||||
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -20,9 +21,9 @@ use tracing::error;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordInput {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
pub repo: AtIdentifier,
|
||||
pub collection: Nsid,
|
||||
pub rkey: Rkey,
|
||||
#[serde(rename = "swapRecord")]
|
||||
pub swap_record: Option<String>,
|
||||
#[serde(rename = "swapCommit")]
|
||||
@@ -68,14 +69,7 @@ pub async fn delete_record(
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "AccountMigrated",
|
||||
"message": "Account has been migrated. Repo operations are not allowed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountMigrated.into_response();
|
||||
}
|
||||
|
||||
let did = auth.did;
|
||||
@@ -86,50 +80,25 @@ pub async fn delete_record(
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
{
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
|
||||
};
|
||||
let commit = match Commit::from_cbor(&commit_bytes) {
|
||||
Ok(c) => c,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
|
||||
};
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let collection_nsid = match input.collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidCollection"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, input.rkey);
|
||||
let key = format!("{}/{}", input.collection, input.rkey);
|
||||
if let Some(swap_record_str) = &input.swap_record {
|
||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
||||
if expected_cid != actual_cid {
|
||||
return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Record has been modified or does not exist"}))).into_response();
|
||||
return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into()))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let prev_record_cid = mst.get(&key).await.ok().flatten();
|
||||
@@ -140,25 +109,22 @@ pub async fn delete_record(
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to delete from MST: {:?}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": format!("Failed to delete from MST: {:?}", e)}))).into_response();
|
||||
return ApiError::InternalError(Some(format!("Failed to delete from MST: {:?}", e)))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let new_mst_root = match new_mst.persist().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to persist MST: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response();
|
||||
}
|
||||
};
|
||||
let collection_for_audit = input.collection.clone();
|
||||
let rkey_for_audit = input.rkey.clone();
|
||||
let collection_for_audit = input.collection.to_string();
|
||||
let rkey_for_audit = input.rkey.to_string();
|
||||
let op = RecordOp::Delete {
|
||||
collection: input.collection,
|
||||
rkey: input.rkey,
|
||||
collection: input.collection.to_string(),
|
||||
rkey: rkey_for_audit.clone(),
|
||||
prev: prev_record_cid,
|
||||
};
|
||||
let mut relevant_blocks = std::collections::BTreeMap::new();
|
||||
@@ -167,14 +133,16 @@ pub async fn delete_record(
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
|
||||
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
|
||||
.into_response();
|
||||
}
|
||||
if mst
|
||||
.blocks_for_path(&key, &mut relevant_blocks)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
|
||||
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
|
||||
.into_response();
|
||||
}
|
||||
let mut written_cids = tracking_store.get_all_relevant_cids();
|
||||
for cid in relevant_blocks.keys() {
|
||||
@@ -202,13 +170,7 @@ pub async fn delete_record(
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
|
||||
+41
-84
@@ -1,8 +1,10 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::{AtIdentifier, Nsid, Rkey};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::Engine;
|
||||
@@ -46,28 +48,29 @@ fn ipld_to_json(ipld: Ipld) -> Value {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetRecordInput {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
pub repo: AtIdentifier,
|
||||
pub collection: Nsid,
|
||||
pub rkey: Rkey,
|
||||
pub cid: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_record(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
_headers: HeaderMap,
|
||||
Query(input): Query<GetRecordInput>,
|
||||
) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let user_id_opt = if input.repo.starts_with("did:") {
|
||||
sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo)
|
||||
let user_id_opt = if input.repo.is_did() {
|
||||
sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map(|opt| opt.map(|r| r.id))
|
||||
} else {
|
||||
let handle = if !input.repo.contains('.') {
|
||||
format!("{}.{}", input.repo, hostname)
|
||||
let repo_str = input.repo.as_str();
|
||||
let handle = if !repo_str.contains('.') {
|
||||
format!("{}.{}", repo_str, hostname)
|
||||
} else {
|
||||
input.repo.clone()
|
||||
repo_str.to_string()
|
||||
};
|
||||
sqlx::query!("SELECT id FROM users WHERE handle = $1", handle)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -77,76 +80,45 @@ pub async fn get_record(
|
||||
let user_id: uuid::Uuid = match user_id_opt {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "RepoNotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RepoNotFound(Some("Repo not found".into())).into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let record_row = sqlx::query!(
|
||||
"SELECT record_cid FROM records WHERE repo_id = $1 AND collection = $2 AND rkey = $3",
|
||||
user_id,
|
||||
input.collection,
|
||||
input.rkey
|
||||
input.collection.as_str(),
|
||||
input.rkey.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let record_cid_str: String = match record_row {
|
||||
Ok(Some(row)) => row.record_cid,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "RecordNotFound", "message": "Record not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RecordNotFound.into_response();
|
||||
}
|
||||
};
|
||||
if let Some(expected_cid) = &input.cid
|
||||
&& &record_cid_str != expected_cid
|
||||
{
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "RecordNotFound", "message": "Record CID mismatch"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RecordNotFound.into_response();
|
||||
}
|
||||
let cid = match Cid::from_str(&record_cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Invalid CID in DB"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Ok(cid) = Cid::from_str(&record_cid_str) else {
|
||||
return ApiError::InternalError(Some("Invalid CID in DB".into())).into_response();
|
||||
};
|
||||
let block = match state.block_store.get(&cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Record block not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Record block not found".into())).into_response();
|
||||
}
|
||||
};
|
||||
let ipld: Ipld = match serde_ipld_dagcbor::from_slice(&block) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Failed to deserialize record: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let value = ipld_to_json(ipld);
|
||||
@@ -159,14 +131,14 @@ pub async fn get_record(
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListRecordsInput {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub repo: AtIdentifier,
|
||||
pub collection: Nsid,
|
||||
pub limit: Option<i32>,
|
||||
pub cursor: Option<String>,
|
||||
#[serde(rename = "rkeyStart")]
|
||||
pub rkey_start: Option<String>,
|
||||
pub rkey_start: Option<Rkey>,
|
||||
#[serde(rename = "rkeyEnd")]
|
||||
pub rkey_end: Option<String>,
|
||||
pub rkey_end: Option<Rkey>,
|
||||
pub reverse: Option<bool>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
@@ -181,16 +153,17 @@ pub async fn list_records(
|
||||
Query(input): Query<ListRecordsInput>,
|
||||
) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let user_id_opt = if input.repo.starts_with("did:") {
|
||||
sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo)
|
||||
let user_id_opt = if input.repo.is_did() {
|
||||
sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map(|opt| opt.map(|r| r.id))
|
||||
} else {
|
||||
let handle = if !input.repo.contains('.') {
|
||||
format!("{}.{}", input.repo, hostname)
|
||||
let repo_str = input.repo.as_str();
|
||||
let handle = if !repo_str.contains('.') {
|
||||
format!("{}.{}", repo_str, hostname)
|
||||
} else {
|
||||
input.repo.clone()
|
||||
repo_str.to_string()
|
||||
};
|
||||
sqlx::query!("SELECT id FROM users WHERE handle = $1", handle)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -200,18 +173,10 @@ pub async fn list_records(
|
||||
let user_id: uuid::Uuid = match user_id_opt {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "RepoNotFound", "message": "Repo not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RepoNotFound(Some("Repo not found".into())).into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let limit = input.limit.unwrap_or(50).clamp(1, 100);
|
||||
@@ -226,7 +191,7 @@ pub async fn list_records(
|
||||
);
|
||||
sqlx::query_as(&query)
|
||||
.bind(user_id)
|
||||
.bind(&input.collection)
|
||||
.bind(input.collection.as_str())
|
||||
.bind(cursor)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&state.db)
|
||||
@@ -255,12 +220,12 @@ pub async fn list_records(
|
||||
);
|
||||
let mut query_builder = sqlx::query_as::<_, (String, String)>(&query)
|
||||
.bind(user_id)
|
||||
.bind(&input.collection);
|
||||
.bind(input.collection.as_str());
|
||||
if let Some(start) = &input.rkey_start {
|
||||
query_builder = query_builder.bind(start);
|
||||
query_builder = query_builder.bind(start.as_str());
|
||||
}
|
||||
if let Some(end) = &input.rkey_end {
|
||||
query_builder = query_builder.bind(end);
|
||||
query_builder = query_builder.bind(end.as_str());
|
||||
}
|
||||
query_builder.bind(limit_i64).fetch_all(&state.db).await
|
||||
};
|
||||
@@ -268,11 +233,7 @@ pub async fn list_records(
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error listing records: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let last_rkey = rows.last().map(|(rkey, _)| rkey.clone());
|
||||
@@ -288,11 +249,7 @@ pub async fn list_records(
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("Error fetching blocks: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let mut records = Vec::new();
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::validation::{RecordValidator, ValidationError, ValidationStatus};
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use axum::response::Response;
|
||||
|
||||
pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Box<Response>> {
|
||||
validate_record_with_rkey(record, collection, None)
|
||||
@@ -42,62 +38,27 @@ fn validation_error_to_response(
|
||||
}
|
||||
|
||||
fn validation_error_to_box_response(e: ValidationError) -> Box<Response> {
|
||||
match e {
|
||||
ValidationError::MissingType => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})),
|
||||
use axum::response::IntoResponse;
|
||||
let msg = match e {
|
||||
ValidationError::MissingType => "Record must have a $type field".to_string(),
|
||||
ValidationError::TypeMismatch { expected, actual } => {
|
||||
format!(
|
||||
"Record $type '{}' does not match collection '{}'",
|
||||
actual, expected
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::TypeMismatch { expected, actual } => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::MissingField(field) => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::InvalidField { path, message } => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::InvalidDatetime { path } => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::BannedContent { path } => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::UnknownType(type_name) => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Lexicon not found: lex:{}", type_name)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
e => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
}
|
||||
}
|
||||
ValidationError::MissingField(field) => format!("Missing required field: {}", field),
|
||||
ValidationError::InvalidField { path, message } => {
|
||||
format!("Invalid field '{}': {}", path, message)
|
||||
}
|
||||
ValidationError::InvalidDatetime { path } => {
|
||||
format!("Invalid datetime format at '{}'", path)
|
||||
}
|
||||
ValidationError::BannedContent { path } => {
|
||||
format!("Unacceptable slur in record at '{}'", path)
|
||||
}
|
||||
ValidationError::UnknownType(type_name) => format!("Lexicon not found: lex:{}", type_name),
|
||||
e => e.to_string(),
|
||||
};
|
||||
Box::new(ApiError::InvalidRecord(msg).into_response())
|
||||
}
|
||||
|
||||
+72
-239
@@ -1,9 +1,10 @@
|
||||
use super::validation::validate_record_with_status;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use crate::validation::ValidationStatus;
|
||||
use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -11,10 +12,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use cid::Cid;
|
||||
use jacquard::types::{
|
||||
integer::LimitedU32,
|
||||
string::{Nsid, Tid},
|
||||
};
|
||||
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -52,12 +49,12 @@ pub async fn has_verified_comms_channel(db: &PgPool, did: &str) -> Result<bool,
|
||||
}
|
||||
|
||||
pub struct RepoWriteAuth {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
pub user_id: Uuid,
|
||||
pub current_root_cid: Cid,
|
||||
pub is_oauth: bool,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
pub async fn prepare_repo_write(
|
||||
@@ -70,13 +67,7 @@ pub async fn prepare_repo_write(
|
||||
let extracted = crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
.ok_or_else(|| ApiError::AuthenticationRequired.into_response())?;
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let auth_user = crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
@@ -90,11 +81,7 @@ pub async fn prepare_repo_write(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
|
||||
let mut response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response();
|
||||
let mut response = ApiError::from(e).into_response();
|
||||
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
|
||||
let scheme = if extracted.is_dpop { "DPoP" } else { "Bearer" };
|
||||
let www_auth = format!(
|
||||
@@ -113,24 +100,15 @@ pub async fn prepare_repo_write(
|
||||
response
|
||||
})?;
|
||||
if repo_did != auth_user.did {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})),
|
||||
)
|
||||
.into_response());
|
||||
return Err(
|
||||
ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(),
|
||||
);
|
||||
}
|
||||
if crate::util::is_account_migrated(&state.db, &auth_user.did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountMigrated",
|
||||
"message": "Account has been migrated to another PDS. Repo operations are not allowed."
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
return Err(ApiError::AccountMigrated.into_response());
|
||||
}
|
||||
let is_verified = has_verified_comms_channel(&state.db, &auth_user.did)
|
||||
.await
|
||||
@@ -139,33 +117,16 @@ pub async fn prepare_repo_write(
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !is_verified && !is_delegated {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
return Err(ApiError::AccountNotVerified.into_response());
|
||||
}
|
||||
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
|
||||
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching user: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
.ok_or_else(|| ApiError::InternalError(Some("User not found".into())).into_response())?;
|
||||
let root_cid_str: String = sqlx::query_scalar!(
|
||||
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
|
||||
user_id
|
||||
@@ -174,41 +135,26 @@ pub async fn prepare_repo_write(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching repo root: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())).into_response())?;
|
||||
let current_root_cid = Cid::from_str(&root_cid_str)
|
||||
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())).into_response())?;
|
||||
Ok(RepoWriteAuth {
|
||||
did: auth_user.did,
|
||||
did: auth_user.did.clone(),
|
||||
user_id,
|
||||
current_root_cid,
|
||||
is_oauth: auth_user.is_oauth,
|
||||
scope: auth_user.scope,
|
||||
controller_did: auth_user.controller_did,
|
||||
controller_did: auth_user.controller_did.clone(),
|
||||
})
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CreateRecordInput {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub rkey: Option<String>,
|
||||
pub repo: AtIdentifier,
|
||||
pub collection: Nsid,
|
||||
pub rkey: Option<Rkey>,
|
||||
pub validate: Option<bool>,
|
||||
pub record: serde_json::Value,
|
||||
#[serde(rename = "swapCommit")]
|
||||
@@ -224,7 +170,7 @@ pub struct CommitInfo {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateRecordOutput {
|
||||
pub uri: String,
|
||||
pub uri: AtUri,
|
||||
pub cid: String,
|
||||
pub commit: CommitInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -266,44 +212,18 @@ pub async fn create_record(
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
{
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
|
||||
};
|
||||
let commit = match Commit::from_cbor(&commit_bytes) {
|
||||
Ok(c) => c,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
|
||||
};
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let collection_nsid = match input.collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidCollection"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let validation_status = if input.validate == Some(false) {
|
||||
None
|
||||
} else {
|
||||
@@ -311,59 +231,37 @@ pub async fn create_record(
|
||||
match validate_record_with_status(
|
||||
&input.record,
|
||||
&input.collection,
|
||||
input.rkey.as_deref(),
|
||||
input.rkey.as_ref().map(|r| r.as_str()),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return *err_response,
|
||||
}
|
||||
};
|
||||
let rkey = input
|
||||
.rkey
|
||||
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
|
||||
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
|
||||
let record_ipld = crate::util::json_to_ipld(&input.record);
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRecord("Failed to serialize record".into()).into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
Ok(c) => c,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to save record block"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to save record block".into())).into_response()
|
||||
}
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, rkey);
|
||||
let key = format!("{}/{}", input.collection, rkey);
|
||||
let new_mst = match mst.add(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to add to MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Failed to add to MST".into())).into_response(),
|
||||
};
|
||||
let new_mst_root = match new_mst.persist().await {
|
||||
Ok(c) => c,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
|
||||
};
|
||||
let op = RecordOp::Create {
|
||||
collection: input.collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
collection: input.collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
cid: record_cid,
|
||||
};
|
||||
let mut relevant_blocks = std::collections::BTreeMap::new();
|
||||
@@ -372,14 +270,16 @@ pub async fn create_record(
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
|
||||
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
|
||||
.into_response();
|
||||
}
|
||||
if mst
|
||||
.blocks_for_path(&key, &mut relevant_blocks)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
|
||||
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
|
||||
.into_response();
|
||||
}
|
||||
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
|
||||
let mut written_cids = tracking_store.get_all_relevant_cids();
|
||||
@@ -409,13 +309,7 @@ pub async fn create_record(
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
@@ -439,17 +333,13 @@ pub async fn create_record(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, rkey),
|
||||
uri: AtUri::from_parts(&did, &input.collection, &rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
},
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
@@ -457,9 +347,9 @@ pub async fn create_record(
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PutRecordInput {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
pub repo: AtIdentifier,
|
||||
pub collection: Nsid,
|
||||
pub rkey: Rkey,
|
||||
pub validate: Option<bool>,
|
||||
pub record: serde_json::Value,
|
||||
#[serde(rename = "swapCommit")]
|
||||
@@ -470,7 +360,7 @@ pub struct PutRecordInput {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PutRecordOutput {
|
||||
pub uri: String,
|
||||
pub uri: AtUri,
|
||||
pub cid: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commit: Option<CommitInfo>,
|
||||
@@ -521,45 +411,19 @@ pub async fn put_record(
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
|
||||
{
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
|
||||
};
|
||||
let commit = match Commit::from_cbor(&commit_bytes) {
|
||||
Ok(c) => c,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
|
||||
};
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let collection_nsid = match input.collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidCollection"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, input.rkey);
|
||||
let key = format!("{}/{}", input.collection, input.rkey);
|
||||
let validation_status = if input.validate == Some(false) {
|
||||
None
|
||||
} else {
|
||||
@@ -567,7 +431,7 @@ pub async fn put_record(
|
||||
match validate_record_with_status(
|
||||
&input.record,
|
||||
&input.collection,
|
||||
Some(&input.rkey),
|
||||
Some(input.rkey.as_str()),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
@@ -578,41 +442,30 @@ pub async fn put_record(
|
||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
||||
if expected_cid != actual_cid {
|
||||
return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Record has been modified or does not exist"}))).into_response();
|
||||
return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into()))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let existing_cid = mst.get(&key).await.ok().flatten();
|
||||
let record_ipld = crate::util::json_to_ipld(&input.record);
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRecord("Failed to serialize record".into()).into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
Ok(c) => c,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to save record block"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to save record block".into())).into_response()
|
||||
}
|
||||
};
|
||||
if existing_cid == Some(record_cid) {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(PutRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
|
||||
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: None,
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -621,46 +474,34 @@ pub async fn put_record(
|
||||
match mst.update(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update MST"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to update MST".into())).into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match mst.add(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to add to MST"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to add to MST".into())).into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
let new_mst_root = match new_mst.persist().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response()
|
||||
}
|
||||
};
|
||||
let op = if existing_cid.is_some() {
|
||||
RecordOp::Update {
|
||||
collection: input.collection.clone(),
|
||||
rkey: input.rkey.clone(),
|
||||
collection: input.collection.to_string(),
|
||||
rkey: input.rkey.to_string(),
|
||||
cid: record_cid,
|
||||
prev: existing_cid,
|
||||
}
|
||||
} else {
|
||||
RecordOp::Create {
|
||||
collection: input.collection.clone(),
|
||||
rkey: input.rkey.clone(),
|
||||
collection: input.collection.to_string(),
|
||||
rkey: input.rkey.to_string(),
|
||||
cid: record_cid,
|
||||
}
|
||||
};
|
||||
@@ -670,14 +511,16 @@ pub async fn put_record(
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
|
||||
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
|
||||
.into_response();
|
||||
}
|
||||
if mst
|
||||
.blocks_for_path(&key, &mut relevant_blocks)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
|
||||
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
|
||||
.into_response();
|
||||
}
|
||||
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
|
||||
let mut written_cids = tracking_store.get_all_relevant_cids();
|
||||
@@ -708,13 +551,7 @@ pub async fn put_record(
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
@@ -738,17 +575,13 @@ pub async fn put_record(
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(PutRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
|
||||
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: Some(CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
validation_status: validation_status.map(|s| s.to_string()),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::types::Did;
|
||||
use axum::{Json, response::IntoResponse};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EmptyResponse {}
|
||||
|
||||
impl EmptyResponse {
|
||||
pub fn ok() -> impl IntoResponse {
|
||||
Json(Self {})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
impl SuccessResponse {
|
||||
pub fn ok() -> impl IntoResponse {
|
||||
Json(Self { success: true })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DidResponse {
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
impl DidResponse {
|
||||
pub fn new(did: impl Into<Did>) -> impl IntoResponse {
|
||||
Json(Self { did: did.into() })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenRequiredResponse {
|
||||
pub token_required: bool,
|
||||
}
|
||||
|
||||
impl TokenRequiredResponse {
|
||||
pub fn new(required: bool) -> impl IntoResponse {
|
||||
Json(Self { token_required: required })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HasPasswordResponse {
|
||||
pub has_password: bool,
|
||||
}
|
||||
|
||||
impl HasPasswordResponse {
|
||||
pub fn new(has_password: bool) -> impl IntoResponse {
|
||||
Json(Self { has_password })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct VerifiedResponse {
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
impl VerifiedResponse {
|
||||
pub fn new(verified: bool) -> impl IntoResponse {
|
||||
Json(Self { verified })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EnabledResponse {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl EnabledResponse {
|
||||
pub fn new(enabled: bool) -> impl IntoResponse {
|
||||
Json(Self { enabled })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatusResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl StatusResponse {
|
||||
pub fn new(status: impl Into<String>) -> impl IntoResponse {
|
||||
Json(Self { status: status.into() })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DidDocumentResponse {
|
||||
pub did_document: serde_json::Value,
|
||||
}
|
||||
|
||||
impl DidDocumentResponse {
|
||||
pub fn new(did_document: serde_json::Value) -> impl IntoResponse {
|
||||
Json(Self { did_document })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OptionsResponse<T: Serialize> {
|
||||
pub options: T,
|
||||
}
|
||||
|
||||
impl<T: Serialize> OptionsResponse<T> {
|
||||
pub fn new(options: T) -> Json<Self> {
|
||||
Json(Self { options })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::cache::Cache;
|
||||
use crate::plc::PlcClient;
|
||||
use crate::state::AppState;
|
||||
use crate::types::PlainPassword;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -15,7 +17,6 @@ use jacquard_repo::commit::Commit;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -64,20 +65,16 @@ pub async fn check_account_status(
|
||||
Ok(user) => user.did,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did)
|
||||
let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let deactivated_at = match user_status {
|
||||
@@ -142,7 +139,7 @@ pub async fn check_account_status(
|
||||
.await
|
||||
.unwrap_or(Some(0))
|
||||
.unwrap_or(0);
|
||||
let valid_did = is_valid_did_for_service(&state.db, &state.cache, &did).await;
|
||||
let valid_did = is_valid_did_for_service(&state.db, state.cache.clone(), did.as_str()).await;
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CheckAccountStatusOutput {
|
||||
@@ -160,7 +157,7 @@ pub async fn check_account_status(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: &Arc<dyn Cache>, did: &str) -> bool {
|
||||
async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: Arc<dyn Cache>, did: &str) -> bool {
|
||||
assert_valid_did_document_for_service(db, cache, did, false)
|
||||
.await
|
||||
.is_ok()
|
||||
@@ -168,10 +165,10 @@ async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: &Arc<dyn Cache>, did
|
||||
|
||||
async fn assert_valid_did_document_for_service(
|
||||
db: &sqlx::PgPool,
|
||||
cache: &Arc<dyn Cache>,
|
||||
cache: Arc<dyn Cache>,
|
||||
did: &str,
|
||||
with_retry: bool,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<(), ApiError> {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let expected_endpoint = format!("https://{}", hostname);
|
||||
|
||||
@@ -228,17 +225,10 @@ async fn assert_valid_did_document_for_service(
|
||||
}
|
||||
}
|
||||
|
||||
let doc_data = match doc_data {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": last_error.unwrap_or_else(|| "DID document validation failed".to_string())
|
||||
})),
|
||||
));
|
||||
}
|
||||
let Some(doc_data) = doc_data else {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
last_error.unwrap_or_else(|| "DID document validation failed".to_string()),
|
||||
));
|
||||
};
|
||||
|
||||
let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok();
|
||||
@@ -249,12 +239,8 @@ async fn assert_valid_did_document_for_service(
|
||||
.map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
if !rotation_keys.contains(&expected_rotation_key.as_str()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Server rotation key not included in PLC DID data"
|
||||
})),
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Server rotation key not included in PLC DID data".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -272,27 +258,18 @@ async fn assert_valid_did_document_for_service(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to fetch user key: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
if let Some(row) = user_row {
|
||||
let key_bytes = crate::config::decrypt_key(&row.key_bytes, row.encryption_version)
|
||||
.map_err(|e| {
|
||||
error!("Failed to decrypt user key: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| {
|
||||
error!("Failed to create signing key: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
let expected_did_key = crate::plc::signing_key_to_did_key(&signing_key);
|
||||
|
||||
@@ -301,12 +278,8 @@ async fn assert_valid_did_document_for_service(
|
||||
"DID {} has signing key {:?}, expected {}",
|
||||
did, doc_signing_key, expected_did_key
|
||||
);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "DID document verification method does not match expected signing key"
|
||||
})),
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"DID document verification method does not match expected signing key".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -333,23 +306,11 @@ async fn assert_valid_did_document_for_service(
|
||||
};
|
||||
let resp = client.get(&url).send().await.map_err(|e| {
|
||||
warn!("Failed to fetch did:web document for {}: {:?}", did, e);
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Could not resolve DID document: {}", e)
|
||||
})),
|
||||
)
|
||||
ApiError::InvalidRequest(format!("Could not resolve DID document: {}", e))
|
||||
})?;
|
||||
let doc: serde_json::Value = resp.json().await.map_err(|e| {
|
||||
warn!("Failed to parse did:web document for {}: {:?}", did, e);
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Could not parse DID document: {}", e)
|
||||
})),
|
||||
)
|
||||
ApiError::InvalidRequest(format!("Could not parse DID document: {}", e))
|
||||
})?;
|
||||
|
||||
let pds_endpoint = doc
|
||||
@@ -370,12 +331,8 @@ async fn assert_valid_did_document_for_service(
|
||||
"DID {} has endpoint {:?}, expected {}",
|
||||
did, pds_endpoint, expected_endpoint
|
||||
);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "DID document atproto_pds service endpoint does not match PDS public url"
|
||||
})),
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"DID document atproto_pds service endpoint does not match PDS public url".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -441,15 +398,15 @@ pub async fn activate_account(
|
||||
did
|
||||
);
|
||||
let did_validation_start = std::time::Instant::now();
|
||||
if let Err((status, json)) =
|
||||
assert_valid_did_document_for_service(&state.db, &state.cache, &did, true).await
|
||||
if let Err(e) =
|
||||
assert_valid_did_document_for_service(&state.db, state.cache.clone(), did.as_str(), true).await
|
||||
{
|
||||
info!(
|
||||
"[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})",
|
||||
did,
|
||||
did_validation_start.elapsed()
|
||||
);
|
||||
return (status, json).into_response();
|
||||
return e.into_response();
|
||||
}
|
||||
info!(
|
||||
"[MIGRATION] activateAccount: DID document validation SUCCESS for {} (took {:?})",
|
||||
@@ -457,7 +414,7 @@ pub async fn activate_account(
|
||||
did_validation_start.elapsed()
|
||||
);
|
||||
|
||||
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
|
||||
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
@@ -466,7 +423,7 @@ pub async fn activate_account(
|
||||
"[MIGRATION] activateAccount: Activating account did={} handle={:?}",
|
||||
did, handle
|
||||
);
|
||||
let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did)
|
||||
let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did.as_str())
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
@@ -483,7 +440,7 @@ pub async fn activate_account(
|
||||
did
|
||||
);
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
|
||||
crate::api::repo::record::sequence_account_event(&state, did.as_str(), true, None).await
|
||||
{
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to sequence account activation event: {}",
|
||||
@@ -497,7 +454,7 @@ pub async fn activate_account(
|
||||
did, handle
|
||||
);
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, handle.as_deref())
|
||||
crate::api::repo::record::sequence_identity_event(&state, did.as_str(), handle.as_deref())
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
@@ -509,7 +466,7 @@ pub async fn activate_account(
|
||||
}
|
||||
let repo_root = sqlx::query_scalar!(
|
||||
"SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1",
|
||||
did
|
||||
did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -531,7 +488,7 @@ pub async fn activate_account(
|
||||
};
|
||||
if let Err(e) = crate::api::repo::record::sequence_sync_event(
|
||||
&state,
|
||||
&did,
|
||||
did.as_str(),
|
||||
&root_cid,
|
||||
rev.as_deref(),
|
||||
)
|
||||
@@ -551,18 +508,14 @@ pub async fn activate_account(
|
||||
);
|
||||
}
|
||||
info!("[MIGRATION] activateAccount: SUCCESS for did={}", did);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[MIGRATION] activateAccount: DB error activating account: {:?}",
|
||||
e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,7 +574,7 @@ pub async fn deactivate_account(
|
||||
|
||||
let did = auth_user.did;
|
||||
|
||||
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
|
||||
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
@@ -629,7 +582,7 @@ pub async fn deactivate_account(
|
||||
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
|
||||
did,
|
||||
did.as_str(),
|
||||
delete_after
|
||||
)
|
||||
.execute(&state.db)
|
||||
@@ -642,7 +595,7 @@ pub async fn deactivate_account(
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
did.as_str(),
|
||||
false,
|
||||
Some("deactivated"),
|
||||
)
|
||||
@@ -650,15 +603,11 @@ pub async fn deactivate_account(
|
||||
{
|
||||
warn!("Failed to sequence account deactivated event: {}", e);
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error deactivating account: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -694,21 +643,17 @@ pub async fn request_account_delete(
|
||||
};
|
||||
let did = validated.did.clone();
|
||||
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &did).await {
|
||||
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &did).await;
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, did.as_str()).await {
|
||||
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, did.as_str()).await;
|
||||
}
|
||||
|
||||
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let confirmation_token = Uuid::new_v4().to_string();
|
||||
@@ -716,18 +661,14 @@ pub async fn request_account_delete(
|
||||
let insert = sqlx::query!(
|
||||
"INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)",
|
||||
confirmation_token,
|
||||
did,
|
||||
did.as_str(),
|
||||
expires_at
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
if let Err(e) = insert {
|
||||
error!("DB error creating deletion token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) =
|
||||
@@ -737,13 +678,13 @@ pub async fn request_account_delete(
|
||||
warn!("Failed to enqueue account deletion notification: {:?}", e);
|
||||
}
|
||||
info!("Account deletion requested for user {}", did);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccountInput {
|
||||
pub did: String,
|
||||
pub password: String,
|
||||
pub did: crate::types::Did,
|
||||
pub password: PlainPassword,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
@@ -751,60 +692,33 @@ pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<DeleteAccountInput>,
|
||||
) -> Response {
|
||||
let did = input.did.trim();
|
||||
let did = &input.did;
|
||||
let password = &input.password;
|
||||
let token = input.token.trim();
|
||||
if did.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "password is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("password is required".into()).into_response();
|
||||
}
|
||||
const OLD_PASSWORD_MAX_LENGTH: usize = 512;
|
||||
if password.len() > OLD_PASSWORD_MAX_LENGTH {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid password length."})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Invalid password length".into()).into_response();
|
||||
}
|
||||
if token.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "token is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(Some("token is required".into())).into_response();
|
||||
}
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, password_hash, handle FROM users WHERE did = $1",
|
||||
did
|
||||
did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let (user_id, password_hash, handle) = match user {
|
||||
Ok(Some(row)) => (row.id, row.password_hash, row.handle),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("account not found".into()).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in delete_account: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let password_valid = if password_hash
|
||||
@@ -826,11 +740,7 @@ pub async fn delete_account(
|
||||
.any(|row| verify(password, &row.password_hash).unwrap_or(false))
|
||||
};
|
||||
if !password_valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid password"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(Some("Invalid password".into())).into_response();
|
||||
}
|
||||
let deletion_request = sqlx::query!(
|
||||
"SELECT did, expires_at FROM account_deletion_requests WHERE token = $1",
|
||||
@@ -841,27 +751,15 @@ pub async fn delete_account(
|
||||
let (token_did, expires_at) = match deletion_request {
|
||||
Ok(Some(row)) => (row.did, row.expires_at),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching deletion token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if token_did != did {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Token does not match account"})),
|
||||
)
|
||||
.into_response();
|
||||
if token_did != did.as_str() {
|
||||
return ApiError::InvalidToken(Some("Token does not match account".into())).into_response();
|
||||
}
|
||||
if Utc::now() > expires_at {
|
||||
let _ = sqlx::query!(
|
||||
@@ -870,21 +768,13 @@ pub async fn delete_account(
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::ExpiredToken(None).into_response();
|
||||
}
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Failed to begin transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let deletion_result: Result<(), sqlx::Error> = async {
|
||||
@@ -919,11 +809,7 @@ pub async fn delete_account(
|
||||
Ok(()) => {
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit account deletion transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let account_seq = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
@@ -957,15 +843,11 @@ pub async fn delete_account(
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
info!("Account {} deleted successfully", did);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error deleting account, rolling back: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
@@ -60,7 +61,7 @@ pub async fn list_app_passwords(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error listing app passwords: {:?}", e);
|
||||
ApiError::InternalError.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,14 +96,7 @@ pub async fn create_app_password(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "App password creation rate limit exceeded");
|
||||
return (
|
||||
axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await {
|
||||
Ok(id) => id,
|
||||
@@ -134,7 +128,7 @@ pub async fn create_app_password(
|
||||
let intersected = delegation::intersect_scopes(requested, &granted_scopes);
|
||||
|
||||
if intersected.is_empty() && !granted_scopes.is_empty() {
|
||||
return ApiError::InsufficientScope.into_response();
|
||||
return ApiError::InsufficientScope(None).into_response();
|
||||
}
|
||||
|
||||
let scope_result = if intersected.is_empty() {
|
||||
@@ -167,11 +161,11 @@ pub async fn create_app_password(
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
error!("Failed to hash password: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to spawn blocking task: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let privileged = input.privileged.unwrap_or(false);
|
||||
@@ -184,7 +178,7 @@ pub async fn create_app_password(
|
||||
created_at,
|
||||
privileged,
|
||||
final_scopes,
|
||||
controller_did
|
||||
controller_did.as_deref()
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@@ -218,7 +212,7 @@ pub async fn create_app_password(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error creating app password: {:?}", e);
|
||||
ApiError::InternalError.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,7 +237,7 @@ pub async fn revoke_app_password(
|
||||
}
|
||||
let sessions_to_invalidate = sqlx::query_scalar!(
|
||||
"SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2",
|
||||
auth_user.did,
|
||||
&auth_user.did,
|
||||
name
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
@@ -251,17 +245,17 @@ pub async fn revoke_app_password(
|
||||
.unwrap_or_default();
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2",
|
||||
auth_user.did,
|
||||
&auth_user.did,
|
||||
name
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking sessions for app password: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
for jti in &sessions_to_invalidate {
|
||||
let cache_key = format!("auth:session:{}:{}", auth_user.did, jti);
|
||||
let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti);
|
||||
let _ = state.cache.delete(&cache_key).await;
|
||||
}
|
||||
if let Err(e) = sqlx::query!(
|
||||
@@ -273,7 +267,7 @@ pub async fn revoke_app_password(
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking app password: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
Json(json!({})).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
+48
-195
@@ -1,10 +1,10 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -22,14 +22,7 @@ pub async fn request_email_update(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Email update rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
@@ -41,7 +34,7 @@ pub async fn request_email_update(
|
||||
return e;
|
||||
}
|
||||
|
||||
let did = auth.0.did.clone();
|
||||
let did = auth.0.did.to_string();
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, handle, email, email_verified FROM users WHERE did = $1",
|
||||
did
|
||||
@@ -51,31 +44,17 @@ pub async fn request_email_update(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let current_email: String = match user.email {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account does not have an email address"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(current_email) = user.email else {
|
||||
return ApiError::InvalidRequest("account does not have an email address".into())
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let token_required = user.email_verified;
|
||||
@@ -98,11 +77,7 @@ pub async fn request_email_update(
|
||||
}
|
||||
|
||||
info!("Email update requested for user {}", user.id);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({ "tokenRequired": token_required })),
|
||||
)
|
||||
.into_response()
|
||||
TokenRequiredResponse::new(token_required).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -124,14 +99,7 @@ pub async fn confirm_email(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_account_scope(
|
||||
@@ -143,7 +111,7 @@ pub async fn confirm_email(
|
||||
return e;
|
||||
}
|
||||
|
||||
let did = auth.0.did;
|
||||
let did = auth.0.did.to_string();
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, email, email_verified FROM users WHERE did = $1",
|
||||
did
|
||||
@@ -153,44 +121,26 @@ pub async fn confirm_email(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountNotFound", "message": "user not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let current_email = match &user.email {
|
||||
Some(e) => e.to_lowercase(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "account does not have an email address"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(ref email) = user.email else {
|
||||
return ApiError::InvalidEmail.into_response();
|
||||
};
|
||||
let current_email = email.to_lowercase();
|
||||
|
||||
let provided_email = input.email.trim().to_lowercase();
|
||||
if provided_email != current_email {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "invalid email"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidEmail.into_response();
|
||||
}
|
||||
|
||||
if user.email_verified {
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
return EmptyResponse::ok().into_response();
|
||||
}
|
||||
|
||||
let confirmation_code =
|
||||
@@ -205,28 +155,14 @@ pub async fn confirm_email(
|
||||
match verified {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != did {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidToken", "message": "Token does not match account"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
}
|
||||
Err(crate::auth::verification_token::VerifyError::Expired) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::ExpiredToken(None).into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,15 +175,11 @@ pub async fn confirm_email(
|
||||
|
||||
if let Err(e) = update {
|
||||
error!("DB error confirming email: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
info!("Email confirmed for user {}", user.id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -264,17 +196,10 @@ pub async fn update_email(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<UpdateEmailInput>,
|
||||
) -> Response {
|
||||
let bearer_token = match crate::auth::extract_bearer_token_from_header(
|
||||
let Some(bearer_token) = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
) else {
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
};
|
||||
|
||||
let auth_result = crate::auth::validate_bearer_token(&state.db, &bearer_token).await;
|
||||
@@ -292,7 +217,7 @@ pub async fn update_email(
|
||||
return e;
|
||||
}
|
||||
|
||||
let did = auth_user.did;
|
||||
let did = auth_user.did.to_string();
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, email, email_verified FROM users WHERE did = $1",
|
||||
did
|
||||
@@ -302,19 +227,11 @@ pub async fn update_email(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,36 +241,23 @@ pub async fn update_email(
|
||||
let new_email = input.email.trim().to_lowercase();
|
||||
|
||||
if !crate::api::validation::is_valid_email(&new_email) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "This email address is not supported, please use a different email."
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"This email address is not supported, please use a different email.".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref current) = current_email
|
||||
&& new_email == current.to_lowercase()
|
||||
{
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
return EmptyResponse::ok().into_response();
|
||||
}
|
||||
|
||||
if email_verified {
|
||||
let confirmation_token = match &input.token {
|
||||
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "TokenRequired",
|
||||
"message": "confirmation token required"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(ref t) = input.token else {
|
||||
return ApiError::TokenRequired.into_response();
|
||||
};
|
||||
let confirmation_token = crate::auth::verification_token::normalize_token_input(t.trim());
|
||||
|
||||
let current_email_lower = current_email
|
||||
.as_ref()
|
||||
@@ -369,28 +273,14 @@ pub async fn update_email(
|
||||
match verified {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != did {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidToken", "message": "Token does not match account"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
}
|
||||
Err(crate::auth::verification_token::VerifyError::Expired) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::ExpiredToken(None).into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,14 +294,7 @@ pub async fn update_email(
|
||||
.await;
|
||||
|
||||
if let Ok(Some(_)) = exists {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "This email address is already in use, please use a different email."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Email is already in use".into()).into_response();
|
||||
}
|
||||
|
||||
let update: Result<sqlx::postgres::PgQueryResult, sqlx::Error> = sqlx::query!(
|
||||
@@ -428,20 +311,9 @@ pub async fn update_email(
|
||||
.map(|db_err: &dyn sqlx::error::DatabaseError| db_err.is_unique_violation())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "This email address is already in use, please use a different email."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::EmailTaken.into_response();
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let verification_token =
|
||||
@@ -474,7 +346,7 @@ pub async fn update_email(
|
||||
}
|
||||
|
||||
info!("Email updated for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -492,14 +364,7 @@ pub async fn check_email_verified(
|
||||
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
@@ -510,23 +375,11 @@ pub async fn check_email_verified(
|
||||
.await;
|
||||
|
||||
match user {
|
||||
Ok(Some(row)) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({ "verified": row.email_verified })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "AccountNotFound", "message": "Account not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(Some(row)) => VerifiedResponse::new(row.email_verified).into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error checking email verified: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError" })),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ pub async fn create_invite_code(
|
||||
return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response();
|
||||
}
|
||||
|
||||
let for_account = input.for_account.unwrap_or_else(|| auth_user.did.clone());
|
||||
let for_account = input.for_account.unwrap_or_else(|| auth_user.did.to_string());
|
||||
let code = gen_invite_code();
|
||||
|
||||
match sqlx::query!(
|
||||
@@ -69,13 +69,13 @@ pub async fn create_invite_code(
|
||||
Ok(result) => {
|
||||
if result.rows_affected() == 0 {
|
||||
error!("No admin user found to create invite code");
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
Json(CreateInviteCodeOutput { code }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error creating invite code: {:?}", e);
|
||||
ApiError::InternalError.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ pub async fn create_invite_codes(
|
||||
let for_accounts = input
|
||||
.for_accounts
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| vec![auth_user.did.clone()]);
|
||||
.unwrap_or_else(|| vec![auth_user.did.to_string()]);
|
||||
|
||||
let admin_user_id =
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE is_admin = true LIMIT 1")
|
||||
@@ -122,11 +122,11 @@ pub async fn create_invite_codes(
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
error!("No admin user found to create invite codes");
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error looking up admin user: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ pub async fn create_invite_codes(
|
||||
.await
|
||||
{
|
||||
error!("DB error creating invite code: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
codes.push(code);
|
||||
}
|
||||
@@ -213,7 +213,7 @@ pub async fn get_account_invite_codes(
|
||||
WHERE ic.for_account = $1
|
||||
ORDER BY ic.created_at DESC
|
||||
"#,
|
||||
auth_user.did
|
||||
&auth_user.did
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
@@ -221,7 +221,7 @@ pub async fn get_account_invite_codes(
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
error!("DB error fetching invite codes: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+12
-20
@@ -66,19 +66,15 @@ pub async fn update_did_document(
|
||||
};
|
||||
|
||||
if !auth_user.did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "DID document updates are only available for did:web accounts"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"DID document updates are only available for did:web accounts".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, handle, deactivated_at FROM users WHERE did = $1",
|
||||
auth_user.did
|
||||
&auth_user.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -87,7 +83,7 @@ pub async fn update_did_document(
|
||||
Ok(None) => return ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("DB error getting user: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,7 +167,7 @@ pub async fn update_did_document(
|
||||
|
||||
if let Err(e) = upsert_result {
|
||||
tracing::error!("DB error upserting did_web_overrides: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Some(ref endpoint) = input.service_endpoint {
|
||||
@@ -180,20 +176,20 @@ pub async fn update_did_document(
|
||||
"UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
|
||||
endpoint_clean,
|
||||
now,
|
||||
auth_user.did
|
||||
&auth_user.did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
if let Err(e) = update_result {
|
||||
tracing::error!("DB error updating service endpoint: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let did_doc = build_did_document(&state.db, &auth_user.did).await;
|
||||
|
||||
tracing::info!("Updated DID document for {}", auth_user.did);
|
||||
tracing::info!("Updated DID document for {}", &auth_user.did);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
@@ -236,14 +232,10 @@ pub async fn get_did_document(
|
||||
};
|
||||
|
||||
if !auth_user.did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "This endpoint is only available for did:web accounts"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"This endpoint is only available for did:web accounts".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let did_doc = build_did_document(&state.db, &auth_user.did).await;
|
||||
|
||||
+120
-397
@@ -1,7 +1,9 @@
|
||||
use crate::api::SuccessResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
@@ -18,6 +20,7 @@ use uuid::Uuid;
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::{Did, Handle, PlainPassword};
|
||||
use crate::validation::validate_password;
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
@@ -80,8 +83,8 @@ pub struct CreatePasskeyAccountInput {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatePasskeyAccountResponse {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
pub setup_token: String,
|
||||
pub setup_expires_at: chrono::DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -99,14 +102,8 @@ pub async fn create_passkey_account(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Account creation rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many account creation attempts. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let byod_auth = if let Some(token) =
|
||||
@@ -127,14 +124,11 @@ pub async fn create_passkey_account(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Service token verification failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "AuthenticationFailed",
|
||||
"message": format!("Service token verification failed: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(Some(format!(
|
||||
"Service token verification failed: {}",
|
||||
e
|
||||
)))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -165,12 +159,8 @@ pub async fn create_passkey_account(
|
||||
};
|
||||
match crate::api::validation::validate_short_handle(handle_to_validate) {
|
||||
Ok(h) => format!("{}.{}", h, hostname),
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
|
||||
)
|
||||
.into_response();
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(None).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -185,11 +175,7 @@ pub async fn create_passkey_account(
|
||||
if let Some(ref email) = email
|
||||
&& !crate::api::validation::is_valid_email(email)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidEmail.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref code) = input.invite_code {
|
||||
@@ -204,22 +190,14 @@ pub async fn create_passkey_account(
|
||||
.unwrap_or(Some(false));
|
||||
|
||||
if valid != Some(true) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidInviteCode.into_response();
|
||||
}
|
||||
} else {
|
||||
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
if invite_required {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InviteCodeRequired.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,36 +205,21 @@ pub async fn create_passkey_account(
|
||||
let verification_recipient = match verification_channel {
|
||||
"email" => match &email {
|
||||
Some(e) if !e.is_empty() => e.clone(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::MissingEmail.into_response(),
|
||||
},
|
||||
"discord" => match &input.discord_id {
|
||||
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::MissingDiscordId.into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::MissingTelegramUsername.into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::MissingSignalNumber.into_response(),
|
||||
},
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
|
||||
).into_response(),
|
||||
_ => return ApiError::InvalidVerificationChannel.into_response(),
|
||||
};
|
||||
|
||||
use k256::ecdsa::SigningKey;
|
||||
@@ -283,22 +246,11 @@ pub async fn create_passkey_account(
|
||||
match reserved {
|
||||
Ok(Some(row)) => (row.private_key_bytes, Some(row.id)),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidSigningKey",
|
||||
"message": "Signing key not found, already used, or expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidSigningKey.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error looking up reserved signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -310,11 +262,7 @@ pub async fn create_passkey_account(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Error creating signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -330,34 +278,25 @@ pub async fn create_passkey_account(
|
||||
let d = match &input.did {
|
||||
Some(d) if !d.trim().is_empty() => d.trim(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
|
||||
return ApiError::InvalidRequest(
|
||||
"External did:web requires the 'did' field to be provided".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !d.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
|
||||
),
|
||||
)
|
||||
return ApiError::InvalidDid("External DID must be a did:web".into())
|
||||
.into_response();
|
||||
}
|
||||
if is_byod_did_web {
|
||||
if let Some(ref auth_did) = byod_auth
|
||||
&& d != auth_did
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
"message": format!("Service token issuer {} does not match DID {}", auth_did, d)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthorizationError(format!(
|
||||
"Service token issuer {} does not match DID {}",
|
||||
auth_did, d
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
|
||||
} else {
|
||||
@@ -369,11 +308,7 @@ pub async fn create_passkey_account(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidDid(e).into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account (reserved key)");
|
||||
}
|
||||
@@ -384,36 +319,25 @@ pub async fn create_passkey_account(
|
||||
if let Some(ref provided_did) = input.did {
|
||||
if provided_did.starts_with("did:plc:") {
|
||||
if provided_did != auth_did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthorizationError(format!(
|
||||
"Service token issuer {} does not match DID {}",
|
||||
auth_did, provided_did
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)");
|
||||
provided_did.clone()
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "BYOD migration requires a did:plc or did:web DID"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"BYOD migration requires a did:plc or did:web DID".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "BYOD migration requires the 'did' field"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"BYOD migration requires the 'did' field".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
@@ -428,10 +352,7 @@ pub async fn create_passkey_account(
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
|
||||
)
|
||||
return ApiError::InternalError(Some("Failed to create PLC operation".into()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
@@ -442,14 +363,11 @@ pub async fn create_passkey_account(
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::UpstreamErrorMsg(format!(
|
||||
"Failed to register DID with PLC directory: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
genesis_result.did
|
||||
}
|
||||
@@ -463,11 +381,7 @@ pub async fn create_passkey_account(
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
error!("Error hashing setup token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let setup_expires_at = Utc::now() + Duration::hours(1);
|
||||
@@ -476,11 +390,7 @@ pub async fn create_passkey_account(
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Error starting transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -545,27 +455,13 @@ pub async fn create_passkey_account(
|
||||
{
|
||||
let constraint = db_err.constraint().unwrap_or("");
|
||||
if constraint.contains("handle") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::HandleNotAvailable(None).into_response();
|
||||
} else if constraint.contains("email") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidEmail", "message": "Email already registered"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::EmailTaken.into_response();
|
||||
}
|
||||
}
|
||||
error!("Error inserting user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -573,11 +469,7 @@ pub async fn create_passkey_account(
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Error encrypting signing key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -591,11 +483,7 @@ pub async fn create_passkey_account(
|
||||
.await
|
||||
{
|
||||
error!("Error inserting user key: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Some(key_id) = reserved_key_id
|
||||
@@ -607,11 +495,7 @@ pub async fn create_passkey_account(
|
||||
.await
|
||||
{
|
||||
error!("Error marking reserved key as used: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let mst = Mst::new(Arc::new(state.block_store.clone()));
|
||||
@@ -619,11 +503,7 @@ pub async fn create_passkey_account(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error persisting MST: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
@@ -632,22 +512,14 @@ pub async fn create_passkey_account(
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Error creating genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Error saving genesis commit: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let commit_cid_str = commit_cid.to_string();
|
||||
@@ -662,11 +534,7 @@ pub async fn create_passkey_account(
|
||||
.await
|
||||
{
|
||||
error!("Error inserting repo: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
|
||||
if let Err(e) = sqlx::query!(
|
||||
@@ -682,11 +550,7 @@ pub async fn create_passkey_account(
|
||||
.await
|
||||
{
|
||||
error!("Error inserting user_blocks: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Some(ref code) = input.invite_code {
|
||||
@@ -727,11 +591,7 @@ pub async fn create_passkey_account(
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Error committing transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if !is_byod_did_web {
|
||||
@@ -819,8 +679,8 @@ pub async fn create_passkey_account(
|
||||
};
|
||||
|
||||
Json(CreatePasskeyAccountResponse {
|
||||
did,
|
||||
handle,
|
||||
did: did.into(),
|
||||
handle: handle.into(),
|
||||
setup_token,
|
||||
setup_expires_at,
|
||||
access_jwt,
|
||||
@@ -831,7 +691,7 @@ pub async fn create_passkey_account(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletePasskeySetupInput {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
pub setup_token: String,
|
||||
pub passkey_credential: serde_json::Value,
|
||||
pub passkey_friendly_name: Option<String>,
|
||||
@@ -840,8 +700,8 @@ pub struct CompletePasskeySetupInput {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletePasskeySetupResponse {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
pub app_password: String,
|
||||
pub app_password_name: String,
|
||||
}
|
||||
@@ -853,7 +713,7 @@ pub async fn complete_passkey_setup(
|
||||
let user = sqlx::query!(
|
||||
r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required
|
||||
FROM users WHERE did = $1"#,
|
||||
input.did
|
||||
input.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -861,57 +721,33 @@ pub async fn complete_passkey_setup(
|
||||
let user = match user {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.password_required {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidAccount", "message": "This account is not a passkey-only account"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidAccount.into_response();
|
||||
}
|
||||
|
||||
let token_hash = match &user.recovery_token {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "SetupExpired", "message": "Setup has already been completed or expired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::SetupExpired.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(expires_at) = user.recovery_token_expires_at
|
||||
&& expires_at < Utc::now()
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "SetupExpired", "message": "Setup token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::SetupExpired.into_response();
|
||||
}
|
||||
|
||||
if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid setup token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
@@ -919,11 +755,7 @@ pub async fn complete_passkey_setup(
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -932,19 +764,11 @@ pub async fn complete_passkey_setup(
|
||||
{
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "NoChallengeInProgress", "message": "Please start passkey registration first"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::NoChallengeInProgress.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error loading registration state: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -953,13 +777,7 @@ pub async fn complete_passkey_setup(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse credential: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidCredential.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -967,11 +785,7 @@ pub async fn complete_passkey_setup(
|
||||
Ok(sk) => sk,
|
||||
Err(e) => {
|
||||
warn!("Passkey registration failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "RegistrationFailed", "message": "Passkey registration failed"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RegistrationFailed.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -984,11 +798,7 @@ pub async fn complete_passkey_setup(
|
||||
.await
|
||||
{
|
||||
error!("Error saving passkey: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await;
|
||||
@@ -999,11 +809,7 @@ pub async fn complete_passkey_setup(
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
error!("Error hashing app password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1017,16 +823,12 @@ pub async fn complete_passkey_setup(
|
||||
.await
|
||||
{
|
||||
error!("Error creating app password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
|
||||
input.did
|
||||
input.did.as_str()
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@@ -1037,8 +839,8 @@ pub async fn complete_passkey_setup(
|
||||
info!(did = %input.did, "Passkey-only account setup completed");
|
||||
|
||||
Json(CompletePasskeySetupResponse {
|
||||
did: input.did,
|
||||
handle: user.handle,
|
||||
did: input.did.clone(),
|
||||
handle: user.handle.into(),
|
||||
app_password,
|
||||
app_password_name,
|
||||
})
|
||||
@@ -1052,7 +854,7 @@ pub async fn start_passkey_registration_for_setup(
|
||||
let user = sqlx::query!(
|
||||
r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required
|
||||
FROM users WHERE did = $1"#,
|
||||
input.did
|
||||
input.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -1060,57 +862,33 @@ pub async fn start_passkey_registration_for_setup(
|
||||
let user = match user {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.password_required {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidAccount"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidAccount.into_response();
|
||||
}
|
||||
|
||||
let token_hash = match &user.recovery_token {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "SetupExpired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::SetupExpired.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(expires_at) = user.recovery_token_expires_at
|
||||
&& expires_at < Utc::now()
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "SetupExpired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::SetupExpired.into_response();
|
||||
}
|
||||
|
||||
if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "InvalidToken"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
@@ -1118,11 +896,7 @@ pub async fn start_passkey_registration_for_setup(
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1146,11 +920,7 @@ pub async fn start_passkey_registration_for_setup(
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Failed to start passkey registration: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1158,11 +928,7 @@ pub async fn start_passkey_registration_for_setup(
|
||||
crate::auth::webauthn::save_registration_state(&state.db, &input.did, ®_state).await
|
||||
{
|
||||
error!("Failed to save registration state: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
|
||||
@@ -1172,7 +938,7 @@ pub async fn start_passkey_registration_for_setup(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartPasskeyRegistrationInput {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
pub setup_token: String,
|
||||
pub friendly_name: Option<String>,
|
||||
}
|
||||
@@ -1194,11 +960,7 @@ pub async fn request_passkey_recovery(
|
||||
.check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({"error": "RateLimitExceeded"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
@@ -1221,7 +983,7 @@ pub async fn request_passkey_recovery(
|
||||
let user = match user {
|
||||
Ok(Some(u)) if !u.password_required => u,
|
||||
_ => {
|
||||
return Json(json!({"success": true})).into_response();
|
||||
return SuccessResponse::ok().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1229,11 +991,7 @@ pub async fn request_passkey_recovery(
|
||||
let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let expires_at = Utc::now() + Duration::hours(1);
|
||||
@@ -1242,17 +1000,13 @@ pub async fn request_passkey_recovery(
|
||||
"UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
|
||||
recovery_token_hash,
|
||||
expires_at,
|
||||
user.did
|
||||
&user.did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Error updating recovery token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
@@ -1267,15 +1021,15 @@ pub async fn request_passkey_recovery(
|
||||
crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await;
|
||||
|
||||
info!(did = %user.did, "Passkey recovery requested");
|
||||
Json(json!({"success": true})).into_response()
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecoverPasskeyAccountInput {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
pub recovery_token: String,
|
||||
pub new_password: String,
|
||||
pub new_password: PlainPassword,
|
||||
}
|
||||
|
||||
pub async fn recover_passkey_account(
|
||||
@@ -1283,19 +1037,12 @@ pub async fn recover_passkey_account(
|
||||
Json(input): Json<RecoverPasskeyAccountInput>,
|
||||
) -> Response {
|
||||
if let Err(e) = validate_password(&input.new_password) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(e.to_string()).into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
input.did
|
||||
input.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -1303,71 +1050,47 @@ pub async fn recover_passkey_account(
|
||||
let user = match user {
|
||||
Ok(Some(u)) => u,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "InvalidRecoveryLink"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRecoveryLink.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let token_hash = match &user.recovery_token {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecoveryLink"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRecoveryLink.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(expires_at) = user.recovery_token_expires_at
|
||||
&& expires_at < Utc::now()
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "RecoveryLinkExpired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RecoveryLinkExpired.into_response();
|
||||
}
|
||||
|
||||
if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "InvalidRecoveryLink"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRecoveryLink.into_response();
|
||||
}
|
||||
|
||||
let password_hash = match hash(&input.new_password, DEFAULT_COST) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
|
||||
password_hash,
|
||||
input.did
|
||||
input.did.as_str()
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Error updating password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did)
|
||||
let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str())
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match deleted {
|
||||
@@ -1382,5 +1105,5 @@ pub async fn recover_passkey_account(
|
||||
}
|
||||
|
||||
info!(did = %input.did, "Passkey-only account recovered with temporary password");
|
||||
Json(json!({"success": true})).into_response()
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
|
||||
+25
-104
@@ -1,3 +1,5 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::auth::webauthn::{
|
||||
self, WebAuthnConfig, delete_passkey as db_delete_passkey, delete_registration_state,
|
||||
@@ -8,22 +10,17 @@ use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
use webauthn_rs::prelude::*;
|
||||
|
||||
fn get_webauthn() -> Result<WebAuthnConfig, (StatusCode, Json<serde_json::Value>)> {
|
||||
fn get_webauthn() -> Result<WebAuthnConfig, ApiError> {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
WebAuthnConfig::new(&hostname).map_err(|e| {
|
||||
error!("Failed to create WebAuthn config: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "WebAuthn configuration failed"})),
|
||||
)
|
||||
ApiError::InternalError(Some("WebAuthn configuration failed".into()))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,26 +46,18 @@ pub async fn start_passkey_registration(
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
|
||||
let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", auth.0.did)
|
||||
let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", &*auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let handle = match user {
|
||||
Ok(Some(row)) => row.handle,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,11 +65,7 @@ pub async fn start_passkey_registration(
|
||||
Ok(passkeys) => passkeys,
|
||||
Err(e) => {
|
||||
error!("DB error fetching existing passkeys: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,24 +85,17 @@ pub async fn start_passkey_registration(
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Failed to start passkey registration: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to start registration"})),
|
||||
)
|
||||
return ApiError::InternalError(Some("Failed to start registration".into()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = save_registration_state(&state.db, &auth.0.did, ®_state).await {
|
||||
error!("Failed to save registration state: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
|
||||
let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({}));
|
||||
|
||||
info!(did = %auth.0.did, "Passkey registration started");
|
||||
|
||||
@@ -151,22 +129,11 @@ pub async fn finish_passkey_registration(
|
||||
let reg_state = match load_registration_state(&state.db, &auth.0.did).await {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoRegistrationInProgress",
|
||||
"message": "No registration in progress. Call startPasskeyRegistration first."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::NoRegistrationInProgress.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error loading registration state: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,14 +141,7 @@ pub async fn finish_passkey_registration(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse credential: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidCredential",
|
||||
"message": "Failed to parse credential response"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidCredential.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,14 +149,7 @@ pub async fn finish_passkey_registration(
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
warn!("Failed to finish passkey registration: {}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "RegistrationFailed",
|
||||
"message": "Failed to verify passkey registration"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RegistrationFailed.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -211,11 +164,7 @@ pub async fn finish_passkey_registration(
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!("Failed to save passkey: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -258,11 +207,7 @@ pub async fn list_passkeys(State(state): State<AppState>, auth: BearerAuth) -> R
|
||||
Ok(pks) => pks,
|
||||
Err(e) => {
|
||||
error!("DB error fetching passkeys: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -306,31 +251,19 @@ pub async fn delete_passkey(
|
||||
let id: uuid::Uuid = match input.id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidId.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match db_delete_passkey(&state.db, id, &auth.0.did).await {
|
||||
Ok(true) => {
|
||||
info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(false) => ApiError::PasskeyNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error deleting passkey: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,31 +283,19 @@ pub async fn update_passkey(
|
||||
let id: uuid::Uuid = match input.id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidId.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await {
|
||||
Ok(true) => {
|
||||
info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Ok(false) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(false) => ApiError::PasskeyNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error updating passkey: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
-214
@@ -1,16 +1,18 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse};
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::PlainPassword;
|
||||
use crate::validation::validate_password;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash, verify};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -49,22 +51,11 @@ pub async fn request_password_reset(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Password reset rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many password reset requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let identifier = input.email.trim();
|
||||
if identifier.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "email or handle is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("email or handle is required".into()).into_response();
|
||||
}
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let normalized = identifier.to_lowercase();
|
||||
@@ -85,15 +76,11 @@ pub async fn request_password_reset(
|
||||
Ok(Some(row)) => row.id,
|
||||
Ok(None) => {
|
||||
info!("Password reset requested for unknown identifier");
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
return EmptyResponse::ok().into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in request_password_reset: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let code = generate_reset_code();
|
||||
@@ -108,11 +95,7 @@ pub async fn request_password_reset(
|
||||
.await;
|
||||
if let Err(e) = update {
|
||||
error!("DB error setting reset code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
|
||||
@@ -120,13 +103,13 @@ pub async fn request_password_reset(
|
||||
warn!("Failed to enqueue password reset notification: {:?}", e);
|
||||
}
|
||||
info!("Password reset requested for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPasswordInput {
|
||||
pub token: String,
|
||||
pub password: String,
|
||||
pub password: PlainPassword,
|
||||
}
|
||||
|
||||
pub async fn reset_password(
|
||||
@@ -140,40 +123,18 @@ pub async fn reset_password(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Reset password rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let token = input.token.trim();
|
||||
let password = &input.password;
|
||||
if token.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "token is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
if password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "password is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("password is required".into()).into_response();
|
||||
}
|
||||
if let Err(e) = validate_password(password) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(e.to_string()).into_response();
|
||||
}
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
@@ -187,19 +148,11 @@ pub async fn reset_password(
|
||||
(row.id, expires)
|
||||
}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in reset_password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if let Some(exp) = expires_at {
|
||||
@@ -213,18 +166,10 @@ pub async fn reset_password(
|
||||
{
|
||||
error!("Failed to clear expired reset code: {:?}", e);
|
||||
}
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::ExpiredToken(None).into_response();
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
}
|
||||
let password_clone = password.to_string();
|
||||
let password_hash =
|
||||
@@ -232,30 +177,18 @@ pub async fn reset_password(
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
error!("Failed to hash password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to spawn blocking task: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Failed to begin transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query!(
|
||||
@@ -267,11 +200,7 @@ pub async fn reset_password(
|
||||
.await
|
||||
{
|
||||
error!("DB error updating password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let user_did = match sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id)
|
||||
.fetch_one(&mut *tx)
|
||||
@@ -280,11 +209,7 @@ pub async fn reset_password(
|
||||
Ok(did) => did,
|
||||
Err(e) => {
|
||||
error!("Failed to get DID for user {}: {:?}", user_id, e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let session_jtis: Vec<String> = match sqlx::query_scalar!(
|
||||
@@ -308,19 +233,11 @@ pub async fn reset_password(
|
||||
"Failed to invalidate sessions after password reset: {:?}",
|
||||
e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit password reset transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
for jti in session_jtis {
|
||||
let cache_key = format!("auth:session:{}:{}", user_did, jti);
|
||||
@@ -332,14 +249,14 @@ pub async fn reset_password(
|
||||
}
|
||||
}
|
||||
info!("Password reset completed for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChangePasswordInput {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
pub current_password: PlainPassword,
|
||||
pub new_password: PlainPassword,
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
@@ -355,28 +272,13 @@ pub async fn change_password(
|
||||
let current_password = &input.current_password;
|
||||
let new_password = &input.new_password;
|
||||
if current_password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("currentPassword is required".into()).into_response();
|
||||
}
|
||||
if new_password.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("newPassword is required".into()).into_response();
|
||||
}
|
||||
if let Err(e) = validate_password(new_password) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(e.to_string()).into_response();
|
||||
}
|
||||
let user =
|
||||
sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE did = $1")
|
||||
@@ -386,38 +288,22 @@ pub async fn change_password(
|
||||
let (user_id, password_hash) = match user {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in change_password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let valid = match verify(current_password, &password_hash) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
error!("Password verification error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidPassword("Current password is incorrect".into()).into_response();
|
||||
}
|
||||
let new_password_clone = new_password.to_string();
|
||||
let new_hash =
|
||||
@@ -425,19 +311,11 @@ pub async fn change_password(
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
error!("Failed to hash password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to spawn blocking task: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||
@@ -447,40 +325,26 @@ pub async fn change_password(
|
||||
.await
|
||||
{
|
||||
error!("DB error updating password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
info!(did = %auth.0.did, "Password changed successfully");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
info!(did = %&auth.0.did, "Password changed successfully");
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let user = sqlx::query!(
|
||||
"SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
match user {
|
||||
Ok(Some(row)) => {
|
||||
Json(json!({"hasPassword": row.has_password.unwrap_or(false)})).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(Some(row)) => HasPasswordResponse::new(row.has_password.unwrap_or(false)).into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -504,19 +368,15 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
|
||||
let has_passkeys =
|
||||
crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await;
|
||||
if !has_passkeys {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoPasskeys",
|
||||
"message": "You must have at least one passkey registered before removing your password"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"You must have at least one passkey registered before removing your password".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, password_hash FROM users WHERE did = $1",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -524,31 +384,16 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
|
||||
let user = match user {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.password_hash.is_none() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoPassword",
|
||||
"message": "Account already has no password"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Account already has no password".into()).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
@@ -559,13 +404,9 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
|
||||
.await
|
||||
{
|
||||
error!("DB error removing password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, "Password removed - account is now passkey-only");
|
||||
(StatusCode::OK, Json(json!({"success": true}))).into_response()
|
||||
info!(did = %&auth.0.did, "Password removed - account is now passkey-only");
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
|
||||
+50
-147
@@ -1,3 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -6,12 +7,12 @@ use axum::{
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::PlainPassword;
|
||||
|
||||
const REAUTH_WINDOW_SECONDS: i64 = 300;
|
||||
|
||||
@@ -26,7 +27,7 @@ pub struct ReauthStatusResponse {
|
||||
pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let session = sqlx::query!(
|
||||
"SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -36,11 +37,7 @@ pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth)
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,7 +55,7 @@ pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth)
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordReauthInput {
|
||||
pub password: String,
|
||||
pub password: PlainPassword,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -72,26 +69,18 @@ pub async fn reauth_password(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<PasswordReauthInput>,
|
||||
) -> Response {
|
||||
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
|
||||
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let password_hash = match user {
|
||||
Ok(Some(row)) => row.password_hash,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,7 +94,7 @@ pub async fn reauth_password(
|
||||
"SELECT ap.password_hash FROM app_passwords ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE u.did = $1",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
@@ -116,30 +105,19 @@ pub async fn reauth_password(
|
||||
.any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false));
|
||||
|
||||
if !app_password_valid {
|
||||
warn!(did = %auth.0.did, "Re-auth failed: invalid password");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": "Password is incorrect"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
warn!(did = %&auth.0.did, "Re-auth failed: invalid password");
|
||||
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
|
||||
Ok(reauthed_at) => {
|
||||
info!(did = %auth.0.did, "Re-auth successful via password");
|
||||
info!(did = %&auth.0.did, "Re-auth successful via password");
|
||||
Json(ReauthResponse { reauthed_at }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,15 +137,9 @@ pub async fn reauth_totp(
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many verification attempts. Please try again in a few minutes."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(Some("Too many verification attempts. Please try again in a few minutes.".into(),))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let valid =
|
||||
@@ -175,29 +147,18 @@ pub async fn reauth_totp(
|
||||
.await;
|
||||
|
||||
if !valid {
|
||||
warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidCode",
|
||||
"message": "Invalid TOTP or backup code"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
warn!(did = %&auth.0.did, "Re-auth failed: invalid TOTP code");
|
||||
return ApiError::InvalidCode(Some("Invalid TOTP or backup code".into())).into_response();
|
||||
}
|
||||
|
||||
match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
|
||||
Ok(reauthed_at) => {
|
||||
info!(did = %auth.0.did, "Re-auth successful via TOTP");
|
||||
info!(did = %&auth.0.did, "Re-auth successful via TOTP");
|
||||
Json(ReauthResponse { reauthed_at }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,23 +177,12 @@ pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAut
|
||||
Ok(pks) => pks,
|
||||
Err(e) => {
|
||||
error!("Failed to get passkeys: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if stored_passkeys.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoPasskeys",
|
||||
"message": "No passkeys registered for this account"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::NoPasskeys.into_response();
|
||||
}
|
||||
|
||||
let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys
|
||||
@@ -241,22 +191,14 @@ pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAut
|
||||
.collect();
|
||||
|
||||
if passkeys.is_empty() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to load passkeys"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to load passkeys".into())).into_response();
|
||||
}
|
||||
|
||||
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,11 +206,7 @@ pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAut
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Failed to start passkey authentication: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -276,14 +214,10 @@ pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAut
|
||||
crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await
|
||||
{
|
||||
error!("Failed to save authentication state: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let options = serde_json::to_value(&rcr).unwrap_or(json!({}));
|
||||
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
|
||||
Json(PasskeyReauthStartResponse { options }).into_response()
|
||||
}
|
||||
|
||||
@@ -304,22 +238,11 @@ pub async fn reauth_passkey_finish(
|
||||
match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoChallengeInProgress",
|
||||
"message": "No passkey authentication in progress or challenge expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::NoChallengeInProgress.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to load authentication state: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -328,14 +251,7 @@ pub async fn reauth_passkey_finish(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse credential: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidCredential",
|
||||
"message": "Failed to parse credential response"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidCredential.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -343,25 +259,15 @@ pub async fn reauth_passkey_finish(
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "AuthenticationFailed",
|
||||
"message": "Passkey authentication failed"
|
||||
})),
|
||||
)
|
||||
warn!(did = %&auth.0.did, "Passkey re-auth failed: {:?}", e);
|
||||
return ApiError::AuthenticationFailed(Some("Passkey authentication failed".into()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
@@ -375,17 +281,10 @@ pub async fn reauth_passkey_finish(
|
||||
.await
|
||||
{
|
||||
Ok(false) => {
|
||||
warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
|
||||
warn!(did = %&auth.0.did, "Passkey counter anomaly detected - possible cloned key");
|
||||
let _ =
|
||||
crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "PasskeyCounterAnomaly",
|
||||
"message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::PasskeyCounterAnomaly.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update passkey counter: {:?}", e);
|
||||
@@ -397,16 +296,12 @@ pub async fn reauth_passkey_finish(
|
||||
|
||||
match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
|
||||
Ok(reauthed_at) => {
|
||||
info!(did = %auth.0.did, "Re-auth successful via passkey");
|
||||
info!(did = %&auth.0.did, "Re-auth successful via passkey");
|
||||
Json(ReauthResponse { reauthed_at }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -582,11 +477,19 @@ pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response {
|
||||
let methods = get_available_reauth_methods(db, did).await;
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "MfaVerificationRequired",
|
||||
"message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.",
|
||||
"reauthMethods": methods
|
||||
})),
|
||||
Json(MfaVerificationRequiredError {
|
||||
error: "MfaVerificationRequired".to_string(),
|
||||
message: "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.".to_string(),
|
||||
reauth_methods: methods,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MfaVerificationRequiredError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
pub reauth_methods: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::types::Did;
|
||||
use crate::AccountStatus;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -92,10 +94,10 @@ pub async fn get_service_auth(
|
||||
.await
|
||||
{
|
||||
Ok(result) => crate::auth::AuthenticatedUser {
|
||||
did: result.did,
|
||||
did: Did::new_unchecked(result.did),
|
||||
is_oauth: true,
|
||||
is_admin: false,
|
||||
is_takendown: false,
|
||||
status: AccountStatus::Active,
|
||||
scope: result.scope,
|
||||
key_bytes: None,
|
||||
controller_did: None,
|
||||
@@ -113,14 +115,7 @@ pub async fn get_service_auth(
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "AuthenticationFailed",
|
||||
"message": format!("{:?}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(Some(format!("{:?}", e))).into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -133,7 +128,7 @@ pub async fn get_service_auth(
|
||||
}
|
||||
};
|
||||
info!(
|
||||
did = %auth_user.did,
|
||||
did = %&auth_user.did,
|
||||
is_oauth = auth_user.is_oauth,
|
||||
has_key = auth_user.key_bytes.is_some(),
|
||||
"getServiceAuth auth validated"
|
||||
@@ -141,7 +136,7 @@ pub async fn get_service_auth(
|
||||
let key_bytes = match &auth_user.key_bytes {
|
||||
Some(kb) => kb.clone(),
|
||||
None => {
|
||||
warn!(did = %auth_user.did, "getServiceAuth: OAuth token has no key_bytes, fetching from DB");
|
||||
warn!(did = %&auth_user.did, "getServiceAuth: OAuth token has no key_bytes, fetching from DB");
|
||||
match sqlx::query_as::<_, (Vec<u8>, Option<i32>)>(
|
||||
"SELECT k.key_bytes, k.encryption_version
|
||||
FROM users u
|
||||
@@ -157,20 +152,20 @@ pub async fn get_service_auth(
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to decrypt user key for service auth");
|
||||
return ApiError::AuthenticationFailedMsg(
|
||||
return ApiError::AuthenticationFailed(Some(
|
||||
"Failed to get signing key".into(),
|
||||
)
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return ApiError::AuthenticationFailedMsg("User has no signing key".into())
|
||||
return ApiError::AuthenticationFailed(Some("User has no signing key".into()))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "DB error fetching user key");
|
||||
return ApiError::AuthenticationFailedMsg("Failed to get signing key".into())
|
||||
return ApiError::AuthenticationFailed(Some("Failed to get signing key".into()))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
@@ -192,20 +187,16 @@ pub async fn get_service_auth(
|
||||
} else if auth_user.is_oauth {
|
||||
let permissions = auth_user.permissions();
|
||||
if !permissions.has_full_access() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "OAuth tokens with granular scopes must specify an lxm parameter"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"OAuth tokens with granular scopes must specify an lxm parameter".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let user_status = sqlx::query!(
|
||||
"SELECT takedown_ref FROM users WHERE did = $1",
|
||||
auth_user.did
|
||||
&auth_user.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -216,27 +207,17 @@ pub async fn get_service_auth(
|
||||
};
|
||||
|
||||
if is_takendown && lxm != Some("com.atproto.server.createAccount") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidToken",
|
||||
"message": "Bad token scope"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidToken(Some("Bad token scope".into())).into_response();
|
||||
}
|
||||
|
||||
if let Some(method) = lxm
|
||||
&& PROTECTED_METHODS.contains(&method)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("cannot request a service auth token for the following protected method: {}", method)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"cannot request a service auth token for the following protected method: {}",
|
||||
method
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(exp) = params.exp {
|
||||
@@ -244,36 +225,21 @@ pub async fn get_service_auth(
|
||||
let diff = exp - now;
|
||||
|
||||
if diff < 0 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "BadExpiration",
|
||||
"message": "expiration is in past"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("expiration is in past".into()).into_response();
|
||||
}
|
||||
|
||||
if diff > HOUR_SECS {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "BadExpiration",
|
||||
"message": "cannot request a token with an expiration more than an hour in the future"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"cannot request a token with an expiration more than an hour in the future".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if lxm.is_none() && diff > MINUTE_SECS {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "BadExpiration",
|
||||
"message": "cannot request a method-less token with an expiration more than a minute in the future"
|
||||
})),
|
||||
return ApiError::InvalidRequest(
|
||||
"cannot request a method-less token with an expiration more than a minute in the future".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,11 +252,7 @@ pub async fn get_service_auth(
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
(
|
||||
|
||||
+162
-275
@@ -1,6 +1,8 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::{EmptyResponse, SuccessResponse};
|
||||
use crate::auth::{BearerAuth, BearerAuthAllowDeactivated};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::{AccountState, Did, Handle, PlainPassword};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -46,7 +48,7 @@ fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSessionInput {
|
||||
pub identifier: String,
|
||||
pub password: String,
|
||||
pub password: PlainPassword,
|
||||
#[serde(default)]
|
||||
pub allow_takendown: bool,
|
||||
}
|
||||
@@ -56,8 +58,8 @@ pub struct CreateSessionInput {
|
||||
pub struct CreateSessionOutput {
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
pub handle: Handle,
|
||||
pub did: Did,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub did_doc: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -85,14 +87,7 @@ pub async fn create_session(
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Login rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many login attempts. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
|
||||
@@ -123,19 +118,19 @@ pub async fn create_session(
|
||||
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
|
||||
);
|
||||
warn!("User not found for login attempt");
|
||||
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
|
||||
return ApiError::AuthenticationFailed(Some("Invalid identifier or password".into()))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error fetching user: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let (password_valid, app_password_name, app_password_scopes, app_password_controller) = if row
|
||||
@@ -168,20 +163,18 @@ pub async fn create_session(
|
||||
};
|
||||
if !password_valid {
|
||||
warn!("Password verification failed for login attempt");
|
||||
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
|
||||
return ApiError::AuthenticationFailed(Some("Invalid identifier or password".into()))
|
||||
.into_response();
|
||||
}
|
||||
let is_takendown = row.takedown_ref.is_some();
|
||||
if is_takendown && !input.allow_takendown {
|
||||
let account_state = AccountState::from_db_fields(
|
||||
row.deactivated_at,
|
||||
row.takedown_ref.clone(),
|
||||
row.migrated_to_pds.clone(),
|
||||
None,
|
||||
);
|
||||
if account_state.is_takendown() && !input.allow_takendown {
|
||||
warn!("Login attempt for takendown account: {}", row.did);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "AccountTakedown",
|
||||
"message": "Account has been taken down"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AccountTakedown.into_response();
|
||||
}
|
||||
let is_verified =
|
||||
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
@@ -223,14 +216,14 @@ pub async fn create_session(
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create access token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create refresh token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let did_for_doc = row.did.clone();
|
||||
@@ -254,7 +247,7 @@ pub async fn create_session(
|
||||
);
|
||||
if let Err(e) = insert_result {
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if is_legacy_login {
|
||||
warn!(
|
||||
@@ -276,22 +269,13 @@ pub async fn create_session(
|
||||
}
|
||||
}
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
|
||||
let is_active = row.deactivated_at.is_none() && !is_takendown;
|
||||
let status = if is_takendown {
|
||||
Some("takendown".to_string())
|
||||
} else if is_migrated {
|
||||
Some("migrated".to_string())
|
||||
} else if row.deactivated_at.is_some() {
|
||||
Some("deactivated".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let is_active = account_state.is_active();
|
||||
let status = account_state.status_for_session().map(String::from);
|
||||
Json(CreateSessionOutput {
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle,
|
||||
did: row.did,
|
||||
handle: handle.into(),
|
||||
did: row.did.into(),
|
||||
did_doc,
|
||||
email: row.email,
|
||||
email_confirmed: Some(row.email_verified),
|
||||
@@ -317,7 +301,7 @@ pub async fn get_session(
|
||||
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
|
||||
discord_verified, telegram_verified, signal_verified, migrated_to_pds, migrated_at
|
||||
FROM users WHERE did = $1"#,
|
||||
auth_user.did
|
||||
&auth_user.did
|
||||
)
|
||||
.fetch_optional(&state.db),
|
||||
did_resolver.resolve_did_document(&did_for_doc)
|
||||
@@ -333,9 +317,12 @@ pub async fn get_session(
|
||||
let pds_hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
let is_takendown = row.takedown_ref.is_some();
|
||||
let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
|
||||
let is_active = row.deactivated_at.is_none() && !is_takendown;
|
||||
let account_state = AccountState::from_db_fields(
|
||||
row.deactivated_at,
|
||||
row.takedown_ref.clone(),
|
||||
row.migrated_to_pds.clone(),
|
||||
row.migrated_at,
|
||||
);
|
||||
let email_value = if can_read_email {
|
||||
row.email.clone()
|
||||
} else {
|
||||
@@ -344,8 +331,8 @@ pub async fn get_session(
|
||||
let email_confirmed_value = can_read_email && row.email_verified;
|
||||
let mut response = json!({
|
||||
"handle": handle,
|
||||
"did": auth_user.did,
|
||||
"active": is_active,
|
||||
"did": &auth_user.did,
|
||||
"active": account_state.is_active(),
|
||||
"preferredChannel": preferred_channel,
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"preferredLocale": row.preferred_locale,
|
||||
@@ -355,24 +342,22 @@ pub async fn get_session(
|
||||
response["email"] = json!(email_value);
|
||||
response["emailConfirmed"] = json!(email_confirmed_value);
|
||||
}
|
||||
if is_takendown {
|
||||
response["status"] = json!("takendown");
|
||||
} else if is_migrated {
|
||||
response["status"] = json!("migrated");
|
||||
response["migratedToPds"] = json!(row.migrated_to_pds);
|
||||
response["migratedAt"] = json!(row.migrated_at);
|
||||
} else if row.deactivated_at.is_some() {
|
||||
response["status"] = json!("deactivated");
|
||||
if let Some(status) = account_state.status_for_session() {
|
||||
response["status"] = json!(status);
|
||||
}
|
||||
if let AccountState::Migrated { to_pds, at } = &account_state {
|
||||
response["migratedToPds"] = json!(to_pds);
|
||||
response["migratedAt"] = json!(at);
|
||||
}
|
||||
if let Some(doc) = did_doc {
|
||||
response["didDoc"] = doc;
|
||||
}
|
||||
Json(response).into_response()
|
||||
}
|
||||
Ok(None) => ApiError::AuthenticationFailed.into_response(),
|
||||
Ok(None) => ApiError::AuthenticationFailed(None).into_response(),
|
||||
Err(e) => {
|
||||
error!("Database error in get_session: {:?}", e);
|
||||
ApiError::InternalError.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,7 +374,7 @@ pub async fn delete_session(
|
||||
};
|
||||
let jti = match crate::auth::get_jti_from_token(&token) {
|
||||
Ok(jti) => jti,
|
||||
Err(_) => return ApiError::AuthenticationFailed.into_response(),
|
||||
Err(_) => return ApiError::AuthenticationFailed(None).into_response(),
|
||||
};
|
||||
let did = crate::auth::get_did_from_token(&token).ok();
|
||||
match sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti)
|
||||
@@ -401,12 +386,12 @@ pub async fn delete_session(
|
||||
let session_cache_key = format!("auth:session:{}:{}", did, jti);
|
||||
let _ = state.cache.delete(&session_cache_key).await;
|
||||
}
|
||||
Json(json!({})).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
Ok(_) => ApiError::AuthenticationFailed.into_response(),
|
||||
Ok(_) => ApiError::AuthenticationFailed(None).into_response(),
|
||||
Err(e) => {
|
||||
error!("Database error in delete_session: {:?}", e);
|
||||
ApiError::AuthenticationFailed.into_response()
|
||||
ApiError::AuthenticationFailed(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,14 +406,7 @@ pub async fn refresh_session(
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
|
||||
return (
|
||||
axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
let refresh_token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
@@ -439,7 +417,7 @@ pub async fn refresh_session(
|
||||
let refresh_jti = match crate::auth::get_jti_from_token(&refresh_token) {
|
||||
Ok(jti) => jti,
|
||||
Err(_) => {
|
||||
return ApiError::AuthenticationFailedMsg("Invalid token format".into())
|
||||
return ApiError::AuthenticationFailed(Some("Invalid token format".into()))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
@@ -447,7 +425,7 @@ pub async fn refresh_session(
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Failed to begin transaction: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if let Ok(Some(session_id)) = sqlx::query_scalar!(
|
||||
@@ -465,9 +443,9 @@ pub async fn refresh_session(
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
let _ = tx.commit().await;
|
||||
return ApiError::ExpiredTokenMsg(
|
||||
return ApiError::AuthenticationFailed(Some(
|
||||
"Refresh token has been revoked due to suspected compromise".into(),
|
||||
)
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let session_row = match sqlx::query!(
|
||||
@@ -484,12 +462,12 @@ pub async fn refresh_session(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into())
|
||||
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into()))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error fetching session: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let key_bytes =
|
||||
@@ -497,11 +475,11 @@ pub async fn refresh_session(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
|
||||
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response();
|
||||
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into())).into_response();
|
||||
}
|
||||
let new_access_meta = match crate::auth::create_access_token_with_delegation(
|
||||
&session_row.did,
|
||||
@@ -512,7 +490,7 @@ pub async fn refresh_session(
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create access token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let new_refresh_meta =
|
||||
@@ -520,7 +498,7 @@ pub async fn refresh_session(
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create refresh token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
match sqlx::query!(
|
||||
@@ -537,11 +515,11 @@ pub async fn refresh_session(
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
let _ = tx.commit().await;
|
||||
return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response();
|
||||
return ApiError::AuthenticationFailed(Some("Refresh token has been revoked due to suspected compromise".into())).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to record used refresh token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
@@ -557,11 +535,11 @@ pub async fn refresh_session(
|
||||
.await
|
||||
{
|
||||
error!("Database error updating session: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit transaction: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let did_for_doc = session_row.did.clone();
|
||||
let did_resolver = state.did_resolver.clone();
|
||||
@@ -588,8 +566,12 @@ pub async fn refresh_session(
|
||||
let pds_hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let handle = full_handle(&u.handle, &pds_hostname);
|
||||
let is_takendown = u.takedown_ref.is_some();
|
||||
let is_active = u.deactivated_at.is_none() && !is_takendown;
|
||||
let account_state = AccountState::from_db_fields(
|
||||
u.deactivated_at,
|
||||
u.takedown_ref.clone(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut response = json!({
|
||||
"accessJwt": new_access_meta.token,
|
||||
"refreshJwt": new_refresh_meta.token,
|
||||
@@ -601,25 +583,23 @@ pub async fn refresh_session(
|
||||
"preferredChannelVerified": preferred_channel_verified,
|
||||
"preferredLocale": u.preferred_locale,
|
||||
"isAdmin": u.is_admin,
|
||||
"active": is_active
|
||||
"active": account_state.is_active()
|
||||
});
|
||||
if let Some(doc) = did_doc {
|
||||
response["didDoc"] = doc;
|
||||
}
|
||||
if is_takendown {
|
||||
response["status"] = json!("takendown");
|
||||
} else if u.deactivated_at.is_some() {
|
||||
response["status"] = json!("deactivated");
|
||||
if let Some(status) = account_state.status_for_session() {
|
||||
response["status"] = json!(status);
|
||||
}
|
||||
Json(response).into_response()
|
||||
}
|
||||
Ok(None) => {
|
||||
error!("User not found for existing session: {}", session_row.did);
|
||||
ApiError::InternalError.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error fetching user: {:?}", e);
|
||||
ApiError::InternalError.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -627,7 +607,7 @@ pub async fn refresh_session(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmSignupInput {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
pub verification_code: String,
|
||||
}
|
||||
|
||||
@@ -636,8 +616,8 @@ pub struct ConfirmSignupInput {
|
||||
pub struct ConfirmSignupOutput {
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
pub handle: Handle,
|
||||
pub did: Did,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub preferred_channel: String,
|
||||
@@ -658,7 +638,7 @@ pub async fn confirm_signup(
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE u.did = $1"#,
|
||||
input.did
|
||||
input.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -671,7 +651,7 @@ pub async fn confirm_signup(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error in confirm_signup: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -697,7 +677,7 @@ pub async fn confirm_signup(
|
||||
&identifier,
|
||||
) {
|
||||
Ok(token_data) => {
|
||||
if token_data.did != input.did {
|
||||
if token_data.did != input.did.as_str() {
|
||||
warn!(
|
||||
"Token DID mismatch for confirm_signup: expected {}, got {}",
|
||||
input.did, token_data.did
|
||||
@@ -708,7 +688,7 @@ pub async fn confirm_signup(
|
||||
}
|
||||
Err(crate::auth::verification_token::VerifyError::Expired) => {
|
||||
warn!("Verification code expired for user: {}", input.did);
|
||||
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
|
||||
return ApiError::ExpiredToken(Some("Verification code has expired".into()))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -721,7 +701,7 @@ pub async fn confirm_signup(
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let verified_column = match row.channel {
|
||||
@@ -732,26 +712,26 @@ pub async fn confirm_signup(
|
||||
};
|
||||
let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column);
|
||||
if let Err(e) = sqlx::query(&update_query)
|
||||
.bind(&input.did)
|
||||
.bind(input.did.as_str())
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update verification status: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create access token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create refresh token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let no_scope: Option<String> = None;
|
||||
@@ -770,7 +750,7 @@ pub async fn confirm_signup(
|
||||
.await
|
||||
{
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await {
|
||||
@@ -786,8 +766,8 @@ pub async fn confirm_signup(
|
||||
Json(ConfirmSignupOutput {
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle: row.handle,
|
||||
did: row.did,
|
||||
handle: row.handle.into(),
|
||||
did: row.did.into(),
|
||||
email: row.email,
|
||||
email_verified,
|
||||
preferred_channel: preferred_channel.to_string(),
|
||||
@@ -799,7 +779,7 @@ pub async fn confirm_signup(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResendVerificationInput {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
pub async fn resend_verification(
|
||||
@@ -815,7 +795,7 @@ pub async fn resend_verification(
|
||||
email_verified, discord_verified, telegram_verified, signal_verified
|
||||
FROM users
|
||||
WHERE did = $1"#,
|
||||
input.did
|
||||
input.did.as_str()
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -826,7 +806,7 @@ pub async fn resend_verification(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error in resend_verification: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
let is_verified =
|
||||
@@ -866,7 +846,7 @@ pub async fn resend_verification(
|
||||
{
|
||||
warn!("Failed to enqueue verification notification: {:?}", e);
|
||||
}
|
||||
Json(json!({"success": true})).into_response()
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -934,11 +914,7 @@ pub async fn list_sessions(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching JWT sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,11 +956,7 @@ pub async fn list_sessions(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching OAuth sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,15 +987,8 @@ pub async fn revoke_session(
|
||||
Json(input): Json<RevokeSessionInput>,
|
||||
) -> Response {
|
||||
if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") {
|
||||
let session_id: i32 = match jwt_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Ok(session_id) = jwt_id.parse::<i32>() else {
|
||||
return ApiError::InvalidRequest("Invalid session ID".into()).into_response();
|
||||
};
|
||||
let session = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
@@ -1035,19 +1000,11 @@ pub async fn revoke_session(
|
||||
let access_jti = match session {
|
||||
Ok(Some((jti,))) => jti,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::SessionNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in revoke_session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
|
||||
@@ -1056,27 +1013,16 @@ pub async fn revoke_session(
|
||||
.await
|
||||
{
|
||||
error!("DB error deleting session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
|
||||
let cache_key = format!("auth:session:{}:{}", &auth.0.did, access_jti);
|
||||
if let Err(e) = state.cache.delete(&cache_key).await {
|
||||
warn!("Failed to invalidate session cache: {:?}", e);
|
||||
}
|
||||
info!(did = %auth.0.did, session_id = %session_id, "JWT session revoked");
|
||||
info!(did = %&auth.0.did, session_id = %session_id, "JWT session revoked");
|
||||
} else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") {
|
||||
let session_id: i32 = match oauth_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Ok(session_id) = oauth_id.parse::<i32>() else {
|
||||
return ApiError::InvalidRequest("Invalid session ID".into()).into_response();
|
||||
};
|
||||
let result = sqlx::query("DELETE FROM oauth_token WHERE id = $1 AND did = $2")
|
||||
.bind(session_id)
|
||||
@@ -1085,31 +1031,19 @@ pub async fn revoke_session(
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) if r.rows_affected() == 0 => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::SessionNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error deleting OAuth session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
info!(did = %auth.0.did, session_id = %session_id, "OAuth session revoked");
|
||||
info!(did = %&auth.0.did, session_id = %session_id, "OAuth session revoked");
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID format"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest("Invalid session ID format".into()).into_response();
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
pub async fn revoke_all_sessions(
|
||||
@@ -1123,71 +1057,51 @@ pub async fn revoke_all_sessions(
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
|
||||
|
||||
if let Some(ref jti) = current_jti {
|
||||
if auth.0.is_oauth {
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1")
|
||||
.bind(&auth.0.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2")
|
||||
let Some(ref jti) = current_jti else {
|
||||
return ApiError::InvalidToken(None).into_response();
|
||||
};
|
||||
|
||||
if auth.0.is_oauth {
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1")
|
||||
.bind(&auth.0.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2")
|
||||
.bind(&auth.0.did)
|
||||
.bind(jti)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking OAuth sessions: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
} else {
|
||||
if let Err(e) =
|
||||
sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
|
||||
.bind(&auth.0.did)
|
||||
.bind(jti)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking OAuth sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
if let Err(e) =
|
||||
sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
|
||||
.bind(&auth.0.did)
|
||||
.bind(jti)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1")
|
||||
.bind(&auth.0.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking OAuth sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
{
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1")
|
||||
.bind(&auth.0.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking OAuth sessions: {:?}", e);
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Could not identify current session"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, "All other sessions revoked");
|
||||
(StatusCode::OK, Json(json!({"success": true}))).into_response()
|
||||
info!(did = %&auth.0.did, "All other sessions revoked");
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -1207,7 +1121,7 @@ pub async fn get_legacy_login_preference(
|
||||
(EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR
|
||||
EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!"
|
||||
FROM users u WHERE u.did = $1"#,
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -1218,18 +1132,10 @@ pub async fn get_legacy_login_preference(
|
||||
has_mfa: row.has_mfa,
|
||||
})
|
||||
.into_response(),
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1257,7 +1163,7 @@ pub async fn update_legacy_login_preference(
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did",
|
||||
input.allow_legacy_login,
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -1265,7 +1171,7 @@ pub async fn update_legacy_login_preference(
|
||||
match result {
|
||||
Ok(Some(_)) => {
|
||||
info!(
|
||||
did = %auth.0.did,
|
||||
did = %&auth.0.did,
|
||||
allow_legacy_login = input.allow_legacy_login,
|
||||
"Legacy login preference updated"
|
||||
);
|
||||
@@ -1274,18 +1180,10 @@ pub async fn update_legacy_login_preference(
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1304,20 +1202,17 @@ pub async fn update_locale(
|
||||
Json(input): Json<UpdateLocaleInput>,
|
||||
) -> Response {
|
||||
if !VALID_LOCALES.contains(&input.preferred_locale.as_str()) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": format!("Invalid locale. Valid options: {}", VALID_LOCALES.join(", "))
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidRequest(format!(
|
||||
"Invalid locale. Valid options: {}",
|
||||
VALID_LOCALES.join(", ")
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET preferred_locale = $1 WHERE did = $2 RETURNING did",
|
||||
input.preferred_locale,
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -1325,7 +1220,7 @@ pub async fn update_locale(
|
||||
match result {
|
||||
Ok(Some(_)) => {
|
||||
info!(
|
||||
did = %auth.0.did,
|
||||
did = %&auth.0.did,
|
||||
locale = %input.preferred_locale,
|
||||
"User locale preference updated"
|
||||
);
|
||||
@@ -1334,18 +1229,10 @@ pub async fn update_locale(
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error updating locale: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -8,7 +9,6 @@ use axum::{
|
||||
use chrono::{Duration, Utc};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
|
||||
const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
|
||||
@@ -69,11 +69,7 @@ pub async fn reserve_signing_key(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in reserve_signing_key: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-300
@@ -1,3 +1,5 @@
|
||||
use crate::api::EmptyResponse;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::auth::totp::{
|
||||
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
|
||||
@@ -5,15 +7,14 @@ use crate::auth::totp::{
|
||||
verify_backup_code, verify_totp_code,
|
||||
};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::PlainPassword;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const ENCRYPTION_VERSION: i32 = 1;
|
||||
@@ -27,43 +28,26 @@ pub struct CreateTotpSecretResponse {
|
||||
}
|
||||
|
||||
pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
|
||||
let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(true)) = existing {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({
|
||||
"error": "TotpAlreadyEnabled",
|
||||
"message": "TOTP is already enabled for this account"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::TotpAlreadyEnabled.into_response();
|
||||
}
|
||||
|
||||
let secret = generate_totp_secret();
|
||||
|
||||
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did)
|
||||
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", &*&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let handle = match handle {
|
||||
Ok(Some(h)) => h,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(None) => return ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error fetching handle: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,11 +58,7 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
|
||||
Ok(qr) => qr,
|
||||
Err(e) => {
|
||||
error!("Failed to generate QR code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(Some("Failed to generate QR code".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,11 +66,7 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
error!("Failed to encrypt TOTP secret: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,7 +81,7 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
|
||||
created_at = NOW(),
|
||||
last_used = NULL
|
||||
"#,
|
||||
auth.0.did,
|
||||
&auth.0.did,
|
||||
encrypted_secret,
|
||||
ENCRYPTION_VERSION
|
||||
)
|
||||
@@ -114,16 +90,12 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("Failed to store TOTP secret: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
|
||||
|
||||
info!(did = %auth.0.did, "TOTP secret created (pending verification)");
|
||||
info!(did = %&auth.0.did, "TOTP secret created (pending verification)");
|
||||
|
||||
Json(CreateTotpSecretResponse {
|
||||
secret: secret_base32,
|
||||
@@ -153,55 +125,28 @@ pub async fn enable_totp(
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many verification attempts. Please try again in a few minutes."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let totp_row = sqlx::query!(
|
||||
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let totp_row = match totp_row {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "TotpNotSetup",
|
||||
"message": "Please call createTotpSecret first"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(None) => return ApiError::TotpNotEnabled.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error fetching TOTP: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if totp_row.verified {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({
|
||||
"error": "TotpAlreadyEnabled",
|
||||
"message": "TOTP is already enabled"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::TotpAlreadyEnabled.into_response();
|
||||
}
|
||||
|
||||
let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
|
||||
@@ -209,24 +154,13 @@ pub async fn enable_totp(
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt TOTP secret: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let code = input.code.trim();
|
||||
if !verify_totp_code(&secret, code) {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidCode",
|
||||
"message": "Invalid verification code"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
|
||||
}
|
||||
|
||||
let backup_codes = generate_backup_codes();
|
||||
@@ -234,39 +168,27 @@ pub async fn enable_totp(
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Failed to begin transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to enable TOTP: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
|
||||
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to clear old backup codes: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
for code in &backup_codes {
|
||||
@@ -274,48 +196,36 @@ pub async fn enable_totp(
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
error!("Failed to hash backup code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
|
||||
auth.0.did,
|
||||
&auth.0.did,
|
||||
hash
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to store backup code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
|
||||
info!(did = %&auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
|
||||
|
||||
Json(EnableTotpResponse { backup_codes }).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DisableTotpInput {
|
||||
pub password: String,
|
||||
pub password: PlainPassword,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
@@ -333,37 +243,20 @@ pub async fn disable_totp(
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many verification attempts. Please try again in a few minutes."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
|
||||
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let password_hash = match user {
|
||||
Ok(Some(row)) => row.password_hash,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(None) => return ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -372,42 +265,22 @@ pub async fn disable_totp(
|
||||
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if !password_valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": "Password is incorrect"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
|
||||
}
|
||||
|
||||
let totp_row = sqlx::query!(
|
||||
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let totp_row = match totp_row {
|
||||
Ok(Some(row)) if row.verified => row,
|
||||
Ok(Some(_)) | Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "TotpNotEnabled",
|
||||
"message": "TOTP is not enabled for this account"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error fetching TOTP: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -420,75 +293,48 @@ pub async fn disable_totp(
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt TOTP secret: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
verify_totp_code(&secret, code)
|
||||
};
|
||||
|
||||
if !code_valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidCode",
|
||||
"message": "Invalid verification code"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
|
||||
}
|
||||
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Failed to begin transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did)
|
||||
if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", &*&auth.0.did)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete TOTP: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
|
||||
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete backup codes: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, "TOTP disabled");
|
||||
info!(did = %&auth.0.did, "TOTP disabled");
|
||||
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
EmptyResponse::ok().into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -500,7 +346,7 @@ pub struct GetTotpStatusResponse {
|
||||
}
|
||||
|
||||
pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
|
||||
let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
@@ -509,17 +355,13 @@ pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) ->
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
error!("DB error fetching TOTP status: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let backup_count_row = sqlx::query!(
|
||||
"SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await;
|
||||
@@ -536,7 +378,7 @@ pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) ->
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegenerateBackupCodesInput {
|
||||
pub password: String,
|
||||
pub password: PlainPassword,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
@@ -555,37 +397,20 @@ pub async fn regenerate_backup_codes(
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many verification attempts. Please try again in a few minutes."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return ApiError::RateLimitExceeded(None).into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
|
||||
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let password_hash = match user {
|
||||
Ok(Some(row)) => row.password_hash,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(None) => return ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -594,42 +419,22 @@ pub async fn regenerate_backup_codes(
|
||||
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if !password_valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": "Password is incorrect"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
|
||||
}
|
||||
|
||||
let totp_row = sqlx::query!(
|
||||
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let totp_row = match totp_row {
|
||||
Ok(Some(row)) if row.verified => row,
|
||||
Ok(Some(_)) | Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "TotpNotEnabled",
|
||||
"message": "TOTP must be enabled to regenerate backup codes"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error fetching TOTP: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -638,24 +443,13 @@ pub async fn regenerate_backup_codes(
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt TOTP secret: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let code = input.code.trim();
|
||||
if !verify_totp_code(&secret, code) {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidCode",
|
||||
"message": "Invalid verification code"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
|
||||
}
|
||||
|
||||
let backup_codes = generate_backup_codes();
|
||||
@@ -663,24 +457,16 @@ pub async fn regenerate_backup_codes(
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
error!("Failed to begin transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
|
||||
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to clear old backup codes: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
for code in &backup_codes {
|
||||
@@ -688,41 +474,29 @@ pub async fn regenerate_backup_codes(
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
error!("Failed to hash backup code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
|
||||
auth.0.did,
|
||||
&auth.0.did,
|
||||
hash
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to store backup code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit transaction: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, "Backup codes regenerated");
|
||||
info!(did = %&auth.0.did, "Backup codes regenerated");
|
||||
|
||||
Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::SuccessResponse;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{error, info};
|
||||
|
||||
@@ -15,6 +15,43 @@ use crate::state::AppState;
|
||||
|
||||
const TRUST_DURATION_DAYS: i64 = 30;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DeviceTrustState {
|
||||
Untrusted,
|
||||
Trusted,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl DeviceTrustState {
|
||||
pub fn from_timestamps(
|
||||
trusted_at: Option<DateTime<Utc>>,
|
||||
trusted_until: Option<DateTime<Utc>>,
|
||||
) -> Self {
|
||||
match (trusted_at, trusted_until) {
|
||||
(Some(_), Some(until)) if until > Utc::now() => Self::Trusted,
|
||||
(Some(_), Some(_)) => Self::Expired,
|
||||
_ => Self::Untrusted,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_trusted(&self) -> bool {
|
||||
matches!(self, Self::Trusted)
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
matches!(self, Self::Expired)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Untrusted => "untrusted",
|
||||
Self::Trusted => "trusted",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustedDevice {
|
||||
@@ -24,6 +61,7 @@ pub struct TrustedDevice {
|
||||
pub trusted_at: Option<DateTime<Utc>>,
|
||||
pub trusted_until: Option<DateTime<Utc>>,
|
||||
pub last_seen_at: DateTime<Utc>,
|
||||
pub trust_state: DeviceTrustState,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -39,7 +77,7 @@ pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAut
|
||||
JOIN oauth_account_device oad ON od.id = oad.device_id
|
||||
WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()
|
||||
ORDER BY od.last_seen_at DESC"#,
|
||||
auth.0.did
|
||||
&auth.0.did
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
@@ -48,24 +86,24 @@ pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAut
|
||||
Ok(rows) => {
|
||||
let devices = rows
|
||||
.into_iter()
|
||||
.map(|row| TrustedDevice {
|
||||
id: row.id,
|
||||
user_agent: row.user_agent,
|
||||
friendly_name: row.friendly_name,
|
||||
trusted_at: row.trusted_at,
|
||||
trusted_until: row.trusted_until,
|
||||
last_seen_at: row.last_seen_at,
|
||||
.map(|row| {
|
||||
let trust_state = DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until);
|
||||
TrustedDevice {
|
||||
id: row.id,
|
||||
user_agent: row.user_agent,
|
||||
friendly_name: row.friendly_name,
|
||||
trusted_at: row.trusted_at,
|
||||
trusted_until: row.trusted_until,
|
||||
last_seen_at: row.last_seen_at,
|
||||
trust_state,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Json(ListTrustedDevicesResponse { devices }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +123,7 @@ pub async fn revoke_trusted_device(
|
||||
r#"SELECT 1 as one FROM oauth_device od
|
||||
JOIN oauth_account_device oad ON od.id = oad.device_id
|
||||
WHERE oad.did = $1 AND od.id = $2"#,
|
||||
auth.0.did,
|
||||
&auth.0.did,
|
||||
input.device_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -94,19 +132,11 @@ pub async fn revoke_trusted_device(
|
||||
match device_exists {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::DeviceNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,16 +149,12 @@ pub async fn revoke_trusted_device(
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device revoked");
|
||||
Json(json!({"success": true})).into_response()
|
||||
info!(did = %&auth.0.did, device_id = %input.device_id, "Trusted device revoked");
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,7 +175,7 @@ pub async fn update_trusted_device(
|
||||
r#"SELECT 1 as one FROM oauth_device od
|
||||
JOIN oauth_account_device oad ON od.id = oad.device_id
|
||||
WHERE oad.did = $1 AND od.id = $2"#,
|
||||
auth.0.did,
|
||||
&auth.0.did,
|
||||
input.device_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -158,19 +184,11 @@ pub async fn update_trusted_device(
|
||||
match device_exists {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::DeviceNotFound.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::InternalError(None).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,22 +203,18 @@ pub async fn update_trusted_device(
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated");
|
||||
Json(json!({"success": true})).into_response()
|
||||
SuccessResponse::ok().into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
ApiError::InternalError(None).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
|
||||
let result = sqlx::query_scalar!(
|
||||
r#"SELECT trusted_until FROM oauth_device od
|
||||
pub async fn get_device_trust_state(db: &PgPool, device_id: &str, did: &str) -> DeviceTrustState {
|
||||
let result = sqlx::query!(
|
||||
r#"SELECT trusted_at, trusted_until FROM oauth_device od
|
||||
JOIN oauth_account_device oad ON od.id = oad.device_id
|
||||
WHERE od.id = $1 AND oad.did = $2"#,
|
||||
device_id,
|
||||
@@ -210,11 +224,15 @@ pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(Some(trusted_until))) => trusted_until > Utc::now(),
|
||||
_ => false,
|
||||
Ok(Some(row)) => DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until),
|
||||
_ => DeviceTrustState::Untrusted,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
|
||||
get_device_trust_state(db, device_id, did).await.is_trusted()
|
||||
}
|
||||
|
||||
pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
|
||||
let now = Utc::now();
|
||||
let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::types::Did;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -16,13 +17,13 @@ pub struct VerifyMigrationEmailInput {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyMigrationEmailOutput {
|
||||
pub success: bool,
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
pub async fn verify_migration_email(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<VerifyMigrationEmailInput>,
|
||||
) -> Result<Json<VerifyMigrationEmailOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<VerifyMigrationEmailOutput>, ApiError> {
|
||||
let token_input = super::verify_token::VerifyTokenInput {
|
||||
token: input.token,
|
||||
identifier: input.email,
|
||||
@@ -32,7 +33,7 @@ pub async fn verify_migration_email(
|
||||
|
||||
Ok(Json(VerifyMigrationEmailOutput {
|
||||
success: result.success,
|
||||
did: result.did.clone(),
|
||||
did: result.did.clone().into(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -51,7 +52,7 @@ pub struct ResendMigrationVerificationOutput {
|
||||
pub async fn resend_migration_verification(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ResendMigrationVerificationInput>,
|
||||
) -> Result<Json<ResendMigrationVerificationOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<ResendMigrationVerificationOutput>, ApiError> {
|
||||
let email = input.email.trim().to_lowercase();
|
||||
|
||||
let user = sqlx::query!(
|
||||
@@ -62,10 +63,7 @@ pub async fn resend_migration_verification(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "Database error during resend verification");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Database error" })),
|
||||
)
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let user = match user {
|
||||
|
||||
+32
-115
@@ -1,10 +1,11 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::types::Did;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::auth::verification_token::{
|
||||
VerificationPurpose, VerifyError, normalize_token_input, verify_token_signature,
|
||||
VerificationPurpose, normalize_token_input, verify_token_signature,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -19,7 +20,7 @@ pub struct VerifyTokenInput {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyTokenOutput {
|
||||
pub success: bool,
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
pub purpose: String,
|
||||
pub channel: String,
|
||||
}
|
||||
@@ -27,60 +28,25 @@ pub struct VerifyTokenOutput {
|
||||
pub async fn verify_token(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<VerifyTokenInput>,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
verify_token_internal(&state, input).await
|
||||
}
|
||||
|
||||
pub async fn verify_token_internal(
|
||||
state: &AppState,
|
||||
input: VerifyTokenInput,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
let normalized_token = normalize_token_input(&input.token);
|
||||
let identifier = input.identifier.trim().to_lowercase();
|
||||
|
||||
let token_data = match verify_token_signature(&normalized_token) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
let (status, error, message) = match e {
|
||||
VerifyError::InvalidFormat => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidToken",
|
||||
"The verification token is invalid or malformed",
|
||||
),
|
||||
VerifyError::UnsupportedVersion => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidToken",
|
||||
"This verification token version is not supported",
|
||||
),
|
||||
VerifyError::Expired => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"ExpiredToken",
|
||||
"The verification token has expired. Please request a new one.",
|
||||
),
|
||||
VerifyError::InvalidSignature => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidToken",
|
||||
"The verification token signature is invalid",
|
||||
),
|
||||
_ => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidToken",
|
||||
"The verification token is not valid",
|
||||
),
|
||||
};
|
||||
warn!(error = ?e, "Token verification failed");
|
||||
return Err((status, Json(json!({ "error": error, "message": message }))));
|
||||
}
|
||||
};
|
||||
let token_data = verify_token_signature(&normalized_token).map_err(|e| {
|
||||
warn!(error = ?e, "Token verification failed");
|
||||
ApiError::from(e)
|
||||
})?;
|
||||
|
||||
let expected_hash = crate::auth::verification_token::hash_identifier(&identifier);
|
||||
if token_data.identifier_hash != expected_hash {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "IdentifierMismatch", "message": "The identifier does not match the verification token" }),
|
||||
),
|
||||
));
|
||||
return Err(ApiError::IdentifierMismatch);
|
||||
}
|
||||
|
||||
match token_data.purpose {
|
||||
@@ -103,14 +69,9 @@ async fn handle_migration_verification(
|
||||
did: &str,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
if channel != "email" {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "InvalidChannel", "message": "Migration verification is only supported for email" }),
|
||||
),
|
||||
));
|
||||
return Err(ApiError::InvalidChannel);
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
@@ -121,26 +82,13 @@ async fn handle_migration_verification(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "Database error during migration verification");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Database error" })),
|
||||
)
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let user = user.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
|
||||
)
|
||||
})?;
|
||||
let user = user.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": "IdentifierMismatch", "message": "The email address does not match the account" }),
|
||||
),
|
||||
));
|
||||
return Err(ApiError::IdentifierMismatch);
|
||||
}
|
||||
|
||||
if !user.email_verified {
|
||||
@@ -152,10 +100,7 @@ async fn handle_migration_verification(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "Failed to update email_verified status");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Failed to verify email" })),
|
||||
)
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -163,7 +108,7 @@ async fn handle_migration_verification(
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string(),
|
||||
did: did.to_string().into(),
|
||||
purpose: "migration".to_string(),
|
||||
channel: channel.to_string(),
|
||||
}))
|
||||
@@ -174,16 +119,11 @@ async fn handle_channel_update(
|
||||
did: &str,
|
||||
channel: &str,
|
||||
identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "User not found" })),
|
||||
)
|
||||
})?;
|
||||
.map_err(|_| ApiError::InternalError(None))?;
|
||||
|
||||
let update_result = match channel {
|
||||
"email" => sqlx::query!(
|
||||
@@ -207,10 +147,7 @@ async fn handle_channel_update(
|
||||
user_id
|
||||
).execute(&state.db).await,
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
|
||||
));
|
||||
return Err(ApiError::InvalidChannel);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,22 +158,16 @@ async fn handle_channel_update(
|
||||
.map(|db| db.is_unique_violation())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "EmailTaken", "message": "Email already in use" })),
|
||||
));
|
||||
return Err(ApiError::EmailTaken);
|
||||
}
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Failed to update channel" })),
|
||||
));
|
||||
return Err(ApiError::InternalError(None));
|
||||
}
|
||||
|
||||
info!(did = %did, channel = %channel, "Channel verified successfully");
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string(),
|
||||
did: did.to_string().into(),
|
||||
purpose: "channel_update".to_string(),
|
||||
channel: channel.to_string(),
|
||||
}))
|
||||
@@ -247,7 +178,7 @@ async fn handle_signup_verification(
|
||||
did: &str,
|
||||
channel: &str,
|
||||
_identifier: &str,
|
||||
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Json<VerifyTokenOutput>, ApiError> {
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1",
|
||||
did
|
||||
@@ -256,18 +187,10 @@ async fn handle_signup_verification(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "Database error during signup verification");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Database error" })),
|
||||
)
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
let user = user.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
|
||||
)
|
||||
})?;
|
||||
let user = user.ok_or(ApiError::AccountNotFound)?;
|
||||
|
||||
let is_verified = user.email_verified
|
||||
|| user.discord_verified
|
||||
@@ -277,7 +200,7 @@ async fn handle_signup_verification(
|
||||
info!(did = %did, "Account already verified");
|
||||
return Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string(),
|
||||
did: did.to_string().into(),
|
||||
purpose: "signup".to_string(),
|
||||
channel: channel.to_string(),
|
||||
}));
|
||||
@@ -317,26 +240,20 @@ async fn handle_signup_verification(
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
|
||||
));
|
||||
return Err(ApiError::InvalidChannel);
|
||||
}
|
||||
};
|
||||
|
||||
update_result.map_err(|e| {
|
||||
warn!(error = %e, "Failed to update channel verified status");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError", "message": "Failed to verify channel" })),
|
||||
)
|
||||
ApiError::InternalError(None)
|
||||
})?;
|
||||
|
||||
info!(did = %did, channel = %channel, "Signup verified successfully");
|
||||
|
||||
Ok(Json(VerifyTokenOutput {
|
||||
success: true,
|
||||
did: did.to_string(),
|
||||
did: did.to_string().into(),
|
||||
purpose: "signup".to_string(),
|
||||
channel: channel.to_string(),
|
||||
}))
|
||||
|
||||
+7
-25
@@ -1,15 +1,15 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::{extract_bearer_token_from_header, validate_bearer_token};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use cid::Cid;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -28,14 +28,7 @@ pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMa
|
||||
&& let Ok(user) = validate_bearer_token(&state.db, &token).await
|
||||
&& user.is_oauth
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "Forbidden",
|
||||
"message": "OAuth credentials are not supported for this endpoint"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::Forbidden.into_response();
|
||||
}
|
||||
Json(CheckSignupQueueOutput {
|
||||
activated: true,
|
||||
@@ -62,25 +55,14 @@ pub async fn dereference_scope(
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<DereferenceScopeInput>,
|
||||
) -> Response {
|
||||
let token = match extract_bearer_token_from_header(
|
||||
let Some(token) = extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
) else {
|
||||
return ApiError::AuthenticationRequired.into_response();
|
||||
};
|
||||
|
||||
if validate_bearer_token(&state.db, &token).await.is_err() {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
return ApiError::AuthenticationFailed(None).into_response();
|
||||
}
|
||||
|
||||
let scope_parts: Vec<&str> = input.scope.split_whitespace().collect();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::ops::Deref;
|
||||
|
||||
pub const MAX_EMAIL_LENGTH: usize = 254;
|
||||
pub const MAX_LOCAL_PART_LENGTH: usize = 64;
|
||||
pub const MAX_DOMAIN_LENGTH: usize = 253;
|
||||
@@ -8,6 +12,195 @@ pub const MIN_HANDLE_LENGTH: usize = 3;
|
||||
pub const MAX_HANDLE_LENGTH: usize = 253;
|
||||
pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct ValidatedLocalHandle(String);
|
||||
|
||||
impl ValidatedLocalHandle {
|
||||
pub fn new(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> {
|
||||
let validated = validate_short_handle(handle.as_ref())?;
|
||||
Ok(Self(validated))
|
||||
}
|
||||
|
||||
pub fn new_allow_reserved(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> {
|
||||
let validated = validate_service_handle(handle.as_ref(), true)?;
|
||||
Ok(Self(validated))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for ValidatedLocalHandle {
|
||||
type Target = str;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ValidatedLocalHandle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ValidatedLocalHandle {
|
||||
type Error = HandleValidationError;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ValidatedLocalHandle> for String {
|
||||
fn from(handle: ValidatedLocalHandle) -> Self {
|
||||
handle.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EmailValidationError {
|
||||
Empty,
|
||||
TooLong,
|
||||
MissingAtSign,
|
||||
EmptyLocalPart,
|
||||
LocalPartTooLong,
|
||||
InvalidLocalPart,
|
||||
EmptyDomain,
|
||||
DomainTooLong,
|
||||
MissingDomainDot,
|
||||
InvalidDomainLabel,
|
||||
}
|
||||
|
||||
impl fmt::Display for EmailValidationError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Empty => write!(f, "Email cannot be empty"),
|
||||
Self::TooLong => write!(f, "Email exceeds maximum length of {} characters", MAX_EMAIL_LENGTH),
|
||||
Self::MissingAtSign => write!(f, "Email must contain @"),
|
||||
Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"),
|
||||
Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"),
|
||||
Self::InvalidLocalPart => write!(f, "Email local part contains invalid characters"),
|
||||
Self::EmptyDomain => write!(f, "Email domain cannot be empty"),
|
||||
Self::DomainTooLong => write!(f, "Email domain exceeds maximum length"),
|
||||
Self::MissingDomainDot => write!(f, "Email domain must contain a dot"),
|
||||
Self::InvalidDomainLabel => write!(f, "Email domain contains invalid label"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EmailValidationError {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct ValidatedEmail(String);
|
||||
|
||||
impl ValidatedEmail {
|
||||
pub fn new(email: impl AsRef<str>) -> Result<Self, EmailValidationError> {
|
||||
let email = email.as_ref().trim();
|
||||
validate_email_detailed(email)?;
|
||||
Ok(Self(email.to_string()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn local_part(&self) -> &str {
|
||||
self.0.rsplitn(2, '@').nth(1).unwrap_or("")
|
||||
}
|
||||
|
||||
pub fn domain(&self) -> &str {
|
||||
self.0.rsplitn(2, '@').next().unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for ValidatedEmail {
|
||||
type Target = str;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ValidatedEmail {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ValidatedEmail {
|
||||
type Error = EmailValidationError;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ValidatedEmail> for String {
|
||||
fn from(email: ValidatedEmail) -> Self {
|
||||
email.0
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> {
|
||||
if email.is_empty() {
|
||||
return Err(EmailValidationError::Empty);
|
||||
}
|
||||
if email.len() > MAX_EMAIL_LENGTH {
|
||||
return Err(EmailValidationError::TooLong);
|
||||
}
|
||||
let parts: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(EmailValidationError::MissingAtSign);
|
||||
}
|
||||
let domain = parts[0];
|
||||
let local = parts[1];
|
||||
if local.is_empty() {
|
||||
return Err(EmailValidationError::EmptyLocalPart);
|
||||
}
|
||||
if local.len() > MAX_LOCAL_PART_LENGTH {
|
||||
return Err(EmailValidationError::LocalPartTooLong);
|
||||
}
|
||||
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
|
||||
return Err(EmailValidationError::InvalidLocalPart);
|
||||
}
|
||||
for c in local.chars() {
|
||||
if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) {
|
||||
return Err(EmailValidationError::InvalidLocalPart);
|
||||
}
|
||||
}
|
||||
if domain.is_empty() {
|
||||
return Err(EmailValidationError::EmptyDomain);
|
||||
}
|
||||
if domain.len() > MAX_DOMAIN_LENGTH {
|
||||
return Err(EmailValidationError::DomainTooLong);
|
||||
}
|
||||
if !domain.contains('.') {
|
||||
return Err(EmailValidationError::MissingDomainDot);
|
||||
}
|
||||
for label in domain.split('.') {
|
||||
if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH {
|
||||
return Err(EmailValidationError::InvalidDomainLabel);
|
||||
}
|
||||
if label.starts_with('-') || label.ends_with('-') {
|
||||
return Err(EmailValidationError::InvalidDomainLabel);
|
||||
}
|
||||
for c in label.chars() {
|
||||
if !c.is_ascii_alphanumeric() && c != '-' {
|
||||
return Err(EmailValidationError::InvalidDomainLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum HandleValidationError {
|
||||
Empty,
|
||||
@@ -50,6 +243,8 @@ impl std::fmt::Display for HandleValidationError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HandleValidationError {}
|
||||
|
||||
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
|
||||
validate_service_handle(handle, false)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::api::SuccessResponse;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -5,7 +6,6 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -25,7 +25,7 @@ pub async fn confirm_channel_verification(
|
||||
};
|
||||
|
||||
match crate::api::server::verify_token_internal(&state, token_input).await {
|
||||
Ok(output) => Json(json!({"success": output.success})).into_response(),
|
||||
Err((status, err_json)) => (status, err_json).into_response(),
|
||||
Ok(_output) => SuccessResponse::ok().into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
+5
-16
@@ -41,26 +41,15 @@ pub struct ResolvedService {
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DidResolver {
|
||||
did_cache: RwLock<HashMap<String, CachedDid>>,
|
||||
did_doc_cache: RwLock<HashMap<String, CachedDidDocument>>,
|
||||
did_cache: Arc<RwLock<HashMap<String, CachedDid>>>,
|
||||
did_doc_cache: Arc<RwLock<HashMap<String, CachedDidDocument>>>,
|
||||
client: Client,
|
||||
cache_ttl: Duration,
|
||||
plc_directory_url: String,
|
||||
}
|
||||
|
||||
impl Clone for DidResolver {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
did_cache: RwLock::new(HashMap::new()),
|
||||
did_doc_cache: RwLock::new(HashMap::new()),
|
||||
client: self.client.clone(),
|
||||
cache_ttl: self.cache_ttl,
|
||||
plc_directory_url: self.plc_directory_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DidResolver {
|
||||
pub fn new() -> Self {
|
||||
let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS")
|
||||
@@ -81,8 +70,8 @@ impl DidResolver {
|
||||
info!("DID resolver initialized");
|
||||
|
||||
Self {
|
||||
did_cache: RwLock::new(HashMap::new()),
|
||||
did_doc_cache: RwLock::new(HashMap::new()),
|
||||
did_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
did_doc_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
client,
|
||||
cache_ttl: Duration::from_secs(cache_ttl_secs),
|
||||
plc_directory_url,
|
||||
|
||||
+6
-45
@@ -1,15 +1,14 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, header::AUTHORIZATION, request::Parts},
|
||||
http::{header::AUTHORIZATION, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
AuthenticatedUser, TokenValidationError, validate_bearer_token_cached,
|
||||
validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop,
|
||||
};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::util::build_full_url;
|
||||
|
||||
@@ -28,45 +27,7 @@ pub enum AuthError {
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error, message) = match self {
|
||||
AuthError::MissingToken => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"AuthenticationRequired",
|
||||
"Authorization header is required",
|
||||
),
|
||||
AuthError::InvalidFormat => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"InvalidToken",
|
||||
"Invalid authorization header format",
|
||||
),
|
||||
AuthError::AuthenticationFailed => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"InvalidToken",
|
||||
"Token could not be verified",
|
||||
),
|
||||
AuthError::TokenExpired => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"ExpiredToken",
|
||||
"Token has expired",
|
||||
),
|
||||
AuthError::AccountDeactivated => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"AccountDeactivated",
|
||||
"Account is deactivated",
|
||||
),
|
||||
AuthError::AccountTakedown => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"AccountTakedown",
|
||||
"Account has been taken down",
|
||||
),
|
||||
AuthError::AdminRequired => (
|
||||
StatusCode::FORBIDDEN,
|
||||
"AdminRequired",
|
||||
"This action requires admin privileges",
|
||||
),
|
||||
};
|
||||
|
||||
(status, Json(json!({ "error": error, "message": message }))).into_response()
|
||||
ApiError::from(self).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +146,7 @@ impl FromRequestParts<AppState> for BearerAuth {
|
||||
Err(_) => Err(AuthError::AuthenticationFailed),
|
||||
}
|
||||
} else {
|
||||
match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await {
|
||||
match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await {
|
||||
Ok(user) => Ok(BearerAuth(user)),
|
||||
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
|
||||
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
|
||||
@@ -239,7 +200,7 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
|
||||
} else {
|
||||
match validate_bearer_token_cached_allow_deactivated(
|
||||
&state.db,
|
||||
&state.cache,
|
||||
state.cache.as_ref(),
|
||||
&extracted.token,
|
||||
)
|
||||
.await
|
||||
@@ -301,7 +262,7 @@ impl FromRequestParts<AppState> for BearerAuthAdmin {
|
||||
Err(_) => return Err(AuthError::AuthenticationFailed),
|
||||
}
|
||||
} else {
|
||||
match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await {
|
||||
match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await {
|
||||
Ok(user) => user,
|
||||
Err(TokenValidationError::AccountDeactivated) => {
|
||||
return Err(AuthError::AccountDeactivated);
|
||||
|
||||
+42
-22
@@ -1,9 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::types::Did;
|
||||
use crate::AccountStatus;
|
||||
use crate::cache::Cache;
|
||||
use crate::oauth::scopes::ScopePermissions;
|
||||
|
||||
@@ -66,13 +67,13 @@ impl fmt::Display for TokenValidationError {
|
||||
}
|
||||
|
||||
pub struct AuthenticatedUser {
|
||||
pub did: String,
|
||||
pub did: Did,
|
||||
pub key_bytes: Option<Vec<u8>>,
|
||||
pub is_oauth: bool,
|
||||
pub is_admin: bool,
|
||||
pub is_takendown: bool,
|
||||
pub status: AccountStatus,
|
||||
pub scope: Option<String>,
|
||||
pub controller_did: Option<String>,
|
||||
pub controller_did: Option<Did>,
|
||||
}
|
||||
|
||||
impl AuthenticatedUser {
|
||||
@@ -87,6 +88,10 @@ impl AuthenticatedUser {
|
||||
}
|
||||
ScopePermissions::from_scope_string(self.scope.as_deref())
|
||||
}
|
||||
|
||||
pub fn is_takendown(&self) -> bool {
|
||||
self.status.is_takendown()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn validate_bearer_token(
|
||||
@@ -105,7 +110,7 @@ pub async fn validate_bearer_token_allow_deactivated(
|
||||
|
||||
pub async fn validate_bearer_token_cached(
|
||||
db: &PgPool,
|
||||
cache: &Arc<dyn Cache>,
|
||||
cache: &dyn Cache,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedUser, TokenValidationError> {
|
||||
validate_bearer_token_with_options_internal(db, Some(cache), token, false, false).await
|
||||
@@ -113,7 +118,7 @@ pub async fn validate_bearer_token_cached(
|
||||
|
||||
pub async fn validate_bearer_token_cached_allow_deactivated(
|
||||
db: &PgPool,
|
||||
cache: &Arc<dyn Cache>,
|
||||
cache: &dyn Cache,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedUser, TokenValidationError> {
|
||||
validate_bearer_token_with_options_internal(db, Some(cache), token, true, false).await
|
||||
@@ -135,7 +140,7 @@ pub async fn validate_bearer_token_allow_takendown(
|
||||
|
||||
async fn validate_bearer_token_with_options_internal(
|
||||
db: &PgPool,
|
||||
cache: Option<&Arc<dyn Cache>>,
|
||||
cache: Option<&dyn Cache>,
|
||||
token: &str,
|
||||
allow_deactivated: bool,
|
||||
allow_takendown: bool,
|
||||
@@ -324,13 +329,21 @@ async fn validate_bearer_token_with_options_internal(
|
||||
}
|
||||
|
||||
if session_valid {
|
||||
let controller_did = token_data.claims.act.as_ref().map(|a| a.sub.clone());
|
||||
let controller_did = token_data
|
||||
.claims
|
||||
.act
|
||||
.as_ref()
|
||||
.map(|a| Did::new_unchecked(a.sub.clone()));
|
||||
let status = AccountStatus::from_db_fields(
|
||||
takedown_ref.as_deref(),
|
||||
deactivated_at,
|
||||
);
|
||||
return Ok(AuthenticatedUser {
|
||||
did: did.clone(),
|
||||
did: Did::new_unchecked(did.clone()),
|
||||
key_bytes: Some(decrypted_key),
|
||||
is_oauth: false,
|
||||
is_admin,
|
||||
is_takendown: takedown_ref.is_some(),
|
||||
status,
|
||||
scope: token_data.claims.scope.clone(),
|
||||
controller_did,
|
||||
});
|
||||
@@ -359,12 +372,16 @@ async fn validate_bearer_token_with_options_internal(
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
if !allow_deactivated && oauth_token.deactivated_at.is_some() {
|
||||
let status = AccountStatus::from_db_fields(
|
||||
oauth_token.takedown_ref.as_deref(),
|
||||
oauth_token.deactivated_at,
|
||||
);
|
||||
|
||||
if !allow_deactivated && status.is_deactivated() {
|
||||
return Err(TokenValidationError::AccountDeactivated);
|
||||
}
|
||||
|
||||
let is_takendown = oauth_token.takedown_ref.is_some();
|
||||
if !allow_takendown && is_takendown {
|
||||
if !allow_takendown && status.is_takendown() {
|
||||
return Err(TokenValidationError::AccountTakedown);
|
||||
}
|
||||
|
||||
@@ -378,13 +395,13 @@ async fn validate_bearer_token_with_options_internal(
|
||||
None
|
||||
};
|
||||
return Ok(AuthenticatedUser {
|
||||
did: oauth_token.did,
|
||||
did: Did::new_unchecked(oauth_token.did),
|
||||
key_bytes,
|
||||
is_oauth: true,
|
||||
is_admin: oauth_token.is_admin,
|
||||
is_takendown,
|
||||
status,
|
||||
scope: oauth_info.scope,
|
||||
controller_did: oauth_info.controller_did,
|
||||
controller_did: oauth_info.controller_did.map(Did::new_unchecked),
|
||||
});
|
||||
} else {
|
||||
return Err(TokenValidationError::TokenExpired);
|
||||
@@ -394,7 +411,7 @@ async fn validate_bearer_token_with_options_internal(
|
||||
Err(TokenValidationError::AuthenticationFailed)
|
||||
}
|
||||
|
||||
pub async fn invalidate_auth_cache(cache: &Arc<dyn Cache>, did: &str) {
|
||||
pub async fn invalidate_auth_cache(cache: &dyn Cache, did: &str) {
|
||||
let key_cache_key = format!("auth:key:{}", did);
|
||||
let status_cache_key = format!("auth:status:{}", did);
|
||||
let _ = cache.delete(&key_cache_key).await;
|
||||
@@ -442,11 +459,14 @@ pub async fn validate_token_with_dpop(
|
||||
let Some(user_info) = user_info else {
|
||||
return Err(TokenValidationError::AuthenticationFailed);
|
||||
};
|
||||
if !allow_deactivated && user_info.deactivated_at.is_some() {
|
||||
let status = AccountStatus::from_db_fields(
|
||||
user_info.takedown_ref.as_deref(),
|
||||
user_info.deactivated_at,
|
||||
);
|
||||
if !allow_deactivated && status.is_deactivated() {
|
||||
return Err(TokenValidationError::AccountDeactivated);
|
||||
}
|
||||
let is_takendown = user_info.takedown_ref.is_some();
|
||||
if is_takendown {
|
||||
if status.is_takendown() {
|
||||
return Err(TokenValidationError::AccountTakedown);
|
||||
}
|
||||
let key_bytes = if let (Some(kb), Some(ev)) =
|
||||
@@ -457,11 +477,11 @@ pub async fn validate_token_with_dpop(
|
||||
None
|
||||
};
|
||||
Ok(AuthenticatedUser {
|
||||
did: result.did,
|
||||
did: Did::new_unchecked(result.did),
|
||||
key_bytes,
|
||||
is_oauth: true,
|
||||
is_admin: user_info.is_admin,
|
||||
is_takendown,
|
||||
status,
|
||||
scope: result.scope,
|
||||
controller_did: None,
|
||||
})
|
||||
|
||||
+16
-52
@@ -1,9 +1,8 @@
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::oauth::scopes::{
|
||||
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
|
||||
};
|
||||
@@ -28,16 +27,9 @@ pub fn check_repo_scope(
|
||||
}
|
||||
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions.assert_repo(action, collection).map_err(|e| {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(json!({
|
||||
"error": "InsufficientScope",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
permissions
|
||||
.assert_repo(action, collection)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
}
|
||||
|
||||
pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> {
|
||||
@@ -46,16 +38,9 @@ pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Resu
|
||||
}
|
||||
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions.assert_blob(mime).map_err(|e| {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(json!({
|
||||
"error": "InsufficientScope",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
permissions
|
||||
.assert_blob(mime)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
}
|
||||
|
||||
pub fn check_rpc_scope(
|
||||
@@ -69,16 +54,9 @@ pub fn check_rpc_scope(
|
||||
}
|
||||
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions.assert_rpc(aud, lxm).map_err(|e| {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(json!({
|
||||
"error": "InsufficientScope",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
permissions
|
||||
.assert_rpc(aud, lxm)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
}
|
||||
|
||||
pub fn check_account_scope(
|
||||
@@ -92,16 +70,9 @@ pub fn check_account_scope(
|
||||
}
|
||||
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions.assert_account(attr, action).map_err(|e| {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(json!({
|
||||
"error": "InsufficientScope",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
permissions
|
||||
.assert_account(attr, action)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
}
|
||||
|
||||
pub fn check_identity_scope(
|
||||
@@ -114,14 +85,7 @@ pub fn check_identity_scope(
|
||||
}
|
||||
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions.assert_identity(attr).map_err(|e| {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(json!({
|
||||
"error": "InsufficientScope",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
permissions
|
||||
.assert_identity(attr)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
}
|
||||
|
||||
+10
-10
@@ -257,7 +257,7 @@ pub async fn enqueue_comms(db: &PgPool, item: NewComms) -> Result<Uuid, sqlx::Er
|
||||
pub struct UserCommsPrefs {
|
||||
pub channel: CommsChannel,
|
||||
pub email: Option<String>,
|
||||
pub handle: String,
|
||||
pub handle: crate::types::Handle,
|
||||
pub locale: String,
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ pub async fn get_user_comms_prefs(
|
||||
Ok(UserCommsPrefs {
|
||||
channel: row.channel,
|
||||
email: row.email,
|
||||
handle: row.handle,
|
||||
handle: row.handle.into(),
|
||||
locale: row.preferred_locale.unwrap_or_else(|| "en".to_string()),
|
||||
})
|
||||
}
|
||||
@@ -305,7 +305,7 @@ pub async fn enqueue_welcome(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::CommsType::Welcome,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
prefs.email.unwrap_or_default(),
|
||||
Some(subject),
|
||||
body,
|
||||
),
|
||||
@@ -332,7 +332,7 @@ pub async fn enqueue_password_reset(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::CommsType::PasswordReset,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
prefs.email.unwrap_or_default(),
|
||||
Some(subject),
|
||||
body,
|
||||
),
|
||||
@@ -388,7 +388,7 @@ pub async fn enqueue_email_update_token(
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let current_email = prefs.email.clone().unwrap_or_default();
|
||||
let current_email = prefs.email.unwrap_or_default();
|
||||
let verify_page = format!("https://{}/app/verify?type=email-update", hostname);
|
||||
let verify_link = format!(
|
||||
"https://{}/app/verify?type=email-update&token={}",
|
||||
@@ -437,7 +437,7 @@ pub async fn enqueue_account_deletion(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::CommsType::AccountDeletion,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
prefs.email.unwrap_or_default(),
|
||||
Some(subject),
|
||||
body,
|
||||
),
|
||||
@@ -464,7 +464,7 @@ pub async fn enqueue_plc_operation(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::CommsType::PlcOperation,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
prefs.email.unwrap_or_default(),
|
||||
Some(subject),
|
||||
body,
|
||||
),
|
||||
@@ -491,7 +491,7 @@ pub async fn enqueue_2fa_code(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::CommsType::TwoFactorCode,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
prefs.email.unwrap_or_default(),
|
||||
Some(subject),
|
||||
body,
|
||||
),
|
||||
@@ -518,7 +518,7 @@ pub async fn enqueue_passkey_recovery(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::CommsType::PasskeyRecovery,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
prefs.email.unwrap_or_default(),
|
||||
Some(subject),
|
||||
body,
|
||||
),
|
||||
@@ -665,7 +665,7 @@ pub async fn queue_legacy_login_notification(
|
||||
user_id,
|
||||
channel,
|
||||
super::types::CommsType::LegacyLoginAlert,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
prefs.email.unwrap_or_default(),
|
||||
Some(subject),
|
||||
body,
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::types::Handle;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
@@ -18,7 +19,7 @@ pub struct DelegationGrant {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegatedAccountInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub handle: Handle,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -26,7 +27,7 @@ pub struct DelegatedAccountInfo {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ControllerInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub handle: Handle,
|
||||
pub granted_scopes: String,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
|
||||
+6
-2
@@ -19,10 +19,13 @@ pub mod scheduled;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod types;
|
||||
pub mod util;
|
||||
pub mod validation;
|
||||
|
||||
use api::proxy::XrpcProxyLayer;
|
||||
pub use sync::util::AccountStatus;
|
||||
pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::DefaultBodyLimit,
|
||||
@@ -33,7 +36,7 @@ use axum::{
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
use state::AppState;
|
||||
use tower::{Layer, ServiceBuilder};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
@@ -571,6 +574,7 @@ pub fn app(state: AppState) -> Router {
|
||||
let router = Router::new()
|
||||
.nest_service("/xrpc", xrpc_service)
|
||||
.nest("/oauth", oauth_router)
|
||||
.nest("/.well-known", well_known_router)
|
||||
.route("/metrics", get(metrics::metrics_handler))
|
||||
.route("/health", get(api::server::health))
|
||||
.route("/robots.txt", get(api::server::robots_txt))
|
||||
@@ -606,7 +610,7 @@ pub fn app(state: AppState) -> Router {
|
||||
|
||||
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path));
|
||||
|
||||
router
|
||||
return router
|
||||
.route_service("/", ServeFile::new(&homepage_file))
|
||||
.nest("/app", spa_router)
|
||||
.fallback_service(serve_dir);
|
||||
|
||||
+6
-4
@@ -35,10 +35,12 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let backfill_db = state.db.clone();
|
||||
let backfill_block_store = state.block_store.clone();
|
||||
tokio::spawn(async move {
|
||||
backfill_genesis_commit_blocks(&backfill_db, backfill_block_store.clone()).await;
|
||||
backfill_repo_rev(&backfill_db, backfill_block_store.clone()).await;
|
||||
backfill_user_blocks(&backfill_db, backfill_block_store.clone()).await;
|
||||
backfill_record_blobs(&backfill_db, backfill_block_store).await;
|
||||
tokio::join!(
|
||||
backfill_genesis_commit_blocks(&backfill_db, backfill_block_store.clone()),
|
||||
backfill_repo_rev(&backfill_db, backfill_block_store.clone()),
|
||||
backfill_user_blocks(&backfill_db, backfill_block_store.clone()),
|
||||
backfill_record_blobs(&backfill_db, backfill_block_store),
|
||||
);
|
||||
});
|
||||
|
||||
let mut comms_service = CommsService::new(state.db.clone());
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::super::{DeviceData, OAuthError};
|
||||
use crate::types::Handle;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct DeviceAccountRow {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub handle: Handle,
|
||||
pub email: Option<String>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -116,7 +117,7 @@ pub async fn get_device_accounts(
|
||||
.into_iter()
|
||||
.map(|r| DeviceAccountRow {
|
||||
did: r.did,
|
||||
handle: r.handle,
|
||||
handle: r.handle.into(),
|
||||
email: r.email,
|
||||
last_used_at: r.last_used_at,
|
||||
})
|
||||
|
||||
+7
-6
@@ -16,18 +16,19 @@ pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis};
|
||||
pub use request::{
|
||||
consume_authorization_request_by_code, create_authorization_request,
|
||||
delete_authorization_request, delete_expired_authorization_requests, get_authorization_request,
|
||||
mark_request_authenticated, set_authorization_did, set_controller_did, set_request_did,
|
||||
update_authorization_request, update_request_scope,
|
||||
get_authorization_request_with_state, mark_request_authenticated, set_authorization_did,
|
||||
set_controller_did, set_request_did, update_authorization_request, update_request_scope,
|
||||
};
|
||||
pub use scope_preference::{
|
||||
ScopePreference, delete_scope_preferences, get_scope_preferences, should_show_consent,
|
||||
upsert_scope_preferences,
|
||||
};
|
||||
pub use token::{
|
||||
check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user,
|
||||
delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id,
|
||||
get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user,
|
||||
revoke_tokens_for_client, revoke_tokens_for_controller, rotate_token,
|
||||
RefreshTokenLookup, check_refresh_token_used, count_tokens_for_user, create_token,
|
||||
delete_oldest_tokens_for_user, delete_token, delete_token_family, enforce_token_limit_for_user,
|
||||
get_token_by_id, get_token_by_previous_refresh_token, get_token_by_refresh_token,
|
||||
list_tokens_for_user, lookup_refresh_token, revoke_tokens_for_client,
|
||||
revoke_tokens_for_controller, rotate_token,
|
||||
};
|
||||
pub use two_factor::{
|
||||
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
|
||||
|
||||
+14
-1
@@ -1,7 +1,20 @@
|
||||
use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
|
||||
use super::super::{AuthFlowState, AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
|
||||
use super::helpers::{from_json, to_json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub async fn get_authorization_request_with_state(
|
||||
pool: &PgPool,
|
||||
request_id: &str,
|
||||
) -> Result<Option<(RequestData, AuthFlowState)>, OAuthError> {
|
||||
match get_authorization_request(pool, request_id).await? {
|
||||
Some(data) => {
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
Ok(Some((data, state)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_authorization_request(
|
||||
pool: &PgPool,
|
||||
request_id: &str,
|
||||
|
||||
+47
-1
@@ -1,8 +1,54 @@
|
||||
use super::super::{OAuthError, TokenData};
|
||||
use super::super::{OAuthError, RefreshTokenState, TokenData};
|
||||
use super::helpers::{from_json, to_json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub enum RefreshTokenLookup {
|
||||
Valid { db_id: i32, token_data: TokenData },
|
||||
InGracePeriod { db_id: i32, token_data: TokenData, rotated_at: DateTime<Utc> },
|
||||
Used { original_token_id: i32 },
|
||||
Expired { db_id: i32 },
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl RefreshTokenLookup {
|
||||
pub fn state(&self) -> RefreshTokenState {
|
||||
match self {
|
||||
RefreshTokenLookup::Valid { .. } => RefreshTokenState::Valid,
|
||||
RefreshTokenLookup::InGracePeriod { rotated_at, .. } => {
|
||||
RefreshTokenState::InGracePeriod { rotated_at: *rotated_at }
|
||||
}
|
||||
RefreshTokenLookup::Used { .. } => RefreshTokenState::Used { at: Utc::now() },
|
||||
RefreshTokenLookup::Expired { .. } => RefreshTokenState::Expired,
|
||||
RefreshTokenLookup::NotFound => RefreshTokenState::Revoked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lookup_refresh_token(
|
||||
pool: &PgPool,
|
||||
refresh_token: &str,
|
||||
) -> Result<RefreshTokenLookup, OAuthError> {
|
||||
if let Some(token_id) = check_refresh_token_used(pool, refresh_token).await? {
|
||||
if let Some((db_id, token_data)) = get_token_by_previous_refresh_token(pool, refresh_token).await? {
|
||||
let rotated_at = token_data.updated_at;
|
||||
return Ok(RefreshTokenLookup::InGracePeriod { db_id, token_data, rotated_at });
|
||||
}
|
||||
return Ok(RefreshTokenLookup::Used { original_token_id: token_id });
|
||||
}
|
||||
|
||||
match get_token_by_refresh_token(pool, refresh_token).await? {
|
||||
Some((db_id, token_data)) => {
|
||||
if token_data.expires_at < Utc::now() {
|
||||
Ok(RefreshTokenLookup::Expired { db_id })
|
||||
} else {
|
||||
Ok(RefreshTokenLookup::Valid { db_id, token_data })
|
||||
}
|
||||
}
|
||||
None => Ok(RefreshTokenLookup::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result<i32, OAuthError> {
|
||||
let client_auth_json = to_json(&data.client_auth)?;
|
||||
let parameters_json = to_json(&data.parameters)?;
|
||||
|
||||
+5
-4
@@ -5,14 +5,15 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::OAuthError;
|
||||
use crate::types::{DPoPProofId, JwkThumbprint};
|
||||
|
||||
const DPOP_NONCE_VALIDITY_SECS: i64 = 300;
|
||||
const DPOP_MAX_AGE_SECS: i64 = 300;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DPoPVerifyResult {
|
||||
pub jkt: String,
|
||||
pub jti: String,
|
||||
pub jkt: JwkThumbprint,
|
||||
pub jti: DPoPProofId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -179,8 +180,8 @@ impl DPoPVerifier {
|
||||
)?;
|
||||
let jkt = compute_jwk_thumbprint(&header.jwk)?;
|
||||
Ok(DPoPVerifyResult {
|
||||
jkt,
|
||||
jti: payload.jti.clone(),
|
||||
jkt: jkt.into(),
|
||||
jti: payload.jti.clone().into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::comms::{CommsChannel, channel_display_name, enqueue_2fa_code};
|
||||
use crate::oauth::{
|
||||
Code, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db,
|
||||
AuthFlowState, Code, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache,
|
||||
db,
|
||||
};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::{Handle, PlainPassword};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
@@ -31,6 +33,38 @@ fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
|
||||
))
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, error: &str, description: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": error,
|
||||
"error_description": description
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn validate_auth_flow_state(
|
||||
flow_state: &AuthFlowState,
|
||||
require_authenticated: bool,
|
||||
) -> Option<Response> {
|
||||
if flow_state.is_expired() {
|
||||
return Some(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request has expired",
|
||||
));
|
||||
}
|
||||
if require_authenticated && flow_state.is_pending() {
|
||||
return Some(json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"access_denied",
|
||||
"Not authenticated",
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("cookie")
|
||||
@@ -97,7 +131,7 @@ pub struct AuthorizeResponse {
|
||||
pub struct AuthorizeSubmit {
|
||||
pub request_uri: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub password: PlainPassword,
|
||||
#[serde(default)]
|
||||
pub remember_device: bool,
|
||||
}
|
||||
@@ -298,7 +332,7 @@ pub async fn authorize_get_json(
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountInfo {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub handle: Handle,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
@@ -1155,53 +1189,33 @@ pub async fn consent_get(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ConsentQuery>,
|
||||
) -> Response {
|
||||
let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await {
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
let (request_data, flow_state) =
|
||||
match db::get_authorization_request_with_state(&state.db, &query.request_uri).await {
|
||||
Ok(Some(result)) => result,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
&format!("Database error: {:?}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(err_response) = validate_auth_flow_state(&flow_state, true) {
|
||||
if flow_state.is_expired() {
|
||||
let _ = db::delete_authorization_request(&state.db, &query.request_uri).await;
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": format!("Database error: {:?}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = db::delete_authorization_request(&state.db, &query.request_uri).await;
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return err_response;
|
||||
}
|
||||
let did = match &request_data.did {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Not authenticated"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let did = flow_state.did().unwrap().to_string();
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_metadata = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
@@ -1334,53 +1348,33 @@ pub async fn consent_post(
|
||||
form.approved_scopes,
|
||||
form.remember
|
||||
);
|
||||
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
let (request_data, flow_state) =
|
||||
match db::get_authorization_request_with_state(&state.db, &form.request_uri).await {
|
||||
Ok(Some(result)) => result,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
&format!("Database error: {:?}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(err_response) = validate_auth_flow_state(&flow_state, true) {
|
||||
if flow_state.is_expired() {
|
||||
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": format!("Database error: {:?}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return err_response;
|
||||
}
|
||||
let did = match &request_data.did {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Not authenticated"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let did = flow_state.did().unwrap().to_string();
|
||||
let original_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::delegation;
|
||||
use crate::oauth::db;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::types::PlainPassword;
|
||||
use crate::util::extract_client_ip;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -15,7 +16,7 @@ pub struct DelegationAuthSubmit {
|
||||
pub request_uri: String,
|
||||
pub delegated_did: Option<String>,
|
||||
pub controller_did: String,
|
||||
pub password: String,
|
||||
pub password: PlainPassword,
|
||||
#[serde(default)]
|
||||
pub remember_device: bool,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::helpers::{create_access_token_with_delegation, verify_pkce};
|
||||
use super::types::{TokenRequest, TokenResponse};
|
||||
use super::types::{TokenGrant, TokenResponse, ValidatedTokenRequest};
|
||||
use crate::config::AuthConfig;
|
||||
use crate::delegation;
|
||||
use crate::oauth::{
|
||||
ClientAuth, OAuthError, RefreshToken, TokenData, TokenId,
|
||||
AuthFlowState, ClientAuth, OAuthError, RefreshToken, TokenData, TokenId,
|
||||
client::{ClientMetadataCache, verify_client_auth},
|
||||
db,
|
||||
db::{self, RefreshTokenLookup},
|
||||
dpop::DPoPVerifier,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
@@ -20,35 +20,41 @@ const REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC: i64 = 14;
|
||||
pub async fn handle_authorization_code_grant(
|
||||
state: AppState,
|
||||
_headers: HeaderMap,
|
||||
request: TokenRequest,
|
||||
request: ValidatedTokenRequest,
|
||||
dpop_proof: Option<String>,
|
||||
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
|
||||
let code = request
|
||||
.code
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("code is required".to_string()))?;
|
||||
let code_verifier = request
|
||||
.code_verifier
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("code_verifier is required".to_string()))?;
|
||||
let (code, code_verifier, redirect_uri) = match request.grant {
|
||||
TokenGrant::AuthorizationCode { code, code_verifier, redirect_uri } => {
|
||||
(code, code_verifier, redirect_uri)
|
||||
}
|
||||
_ => return Err(OAuthError::InvalidRequest("Expected authorization_code grant".to_string())),
|
||||
};
|
||||
let auth_request = db::consume_authorization_request_by_code(&state.db, &code)
|
||||
.await?
|
||||
.ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?;
|
||||
if auth_request.expires_at < Utc::now() {
|
||||
|
||||
let flow_state = AuthFlowState::from_request_data(&auth_request);
|
||||
if flow_state.is_expired() {
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Authorization code has expired".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(request_client_id) = &request.client_id
|
||||
if !flow_state.can_exchange() {
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Authorization not completed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(request_client_id) = &request.client_auth.client_id
|
||||
&& request_client_id != &auth_request.client_id
|
||||
{
|
||||
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
|
||||
}
|
||||
let did = auth_request
|
||||
.did
|
||||
.ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?;
|
||||
let did = flow_state.did().unwrap().to_string();
|
||||
let client_metadata_cache = ClientMetadataCache::new(3600);
|
||||
let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?;
|
||||
let client_auth = if let (Some(assertion), Some(assertion_type)) =
|
||||
(&request.client_assertion, &request.client_assertion_type)
|
||||
(&request.client_auth.client_assertion, &request.client_auth.client_assertion_type)
|
||||
{
|
||||
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
@@ -58,7 +64,7 @@ pub async fn handle_authorization_code_grant(
|
||||
ClientAuth::PrivateKeyJwt {
|
||||
client_assertion: assertion.clone(),
|
||||
}
|
||||
} else if let Some(secret) = &request.client_secret {
|
||||
} else if let Some(secret) = &request.client_auth.client_secret {
|
||||
ClientAuth::SecretPost {
|
||||
client_secret: secret.clone(),
|
||||
}
|
||||
@@ -67,8 +73,8 @@ pub async fn handle_authorization_code_grant(
|
||||
};
|
||||
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
|
||||
verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?;
|
||||
if let Some(redirect_uri) = &request.redirect_uri
|
||||
&& redirect_uri != &auth_request.parameters.redirect_uri
|
||||
if let Some(req_redirect_uri) = &redirect_uri
|
||||
&& req_redirect_uri != &auth_request.parameters.redirect_uri
|
||||
{
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"redirect_uri mismatch".to_string(),
|
||||
@@ -87,13 +93,13 @@ pub async fn handle_authorization_code_grant(
|
||||
));
|
||||
}
|
||||
if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt
|
||||
&& &result.jkt != expected_jkt
|
||||
&& result.jkt.as_str() != expected_jkt
|
||||
{
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"DPoP key binding mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
Some(result.jkt)
|
||||
Some(result.jkt.as_str().to_string())
|
||||
} else if auth_request.parameters.dpop_jkt.is_some() || client_metadata.requires_dpop() {
|
||||
return Err(OAuthError::UseDpopNonce(
|
||||
crate::oauth::dpop::DPoPVerifier::new(AuthConfig::get().dpop_secret().as_bytes())
|
||||
@@ -187,23 +193,30 @@ pub async fn handle_authorization_code_grant(
|
||||
pub async fn handle_refresh_token_grant(
|
||||
state: AppState,
|
||||
_headers: HeaderMap,
|
||||
request: TokenRequest,
|
||||
request: ValidatedTokenRequest,
|
||||
dpop_proof: Option<String>,
|
||||
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
|
||||
let refresh_token_str = request
|
||||
.refresh_token
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("refresh_token is required".to_string()))?;
|
||||
let refresh_token_str = match request.grant {
|
||||
TokenGrant::RefreshToken { refresh_token } => refresh_token,
|
||||
_ => return Err(OAuthError::InvalidRequest("Expected refresh_token grant".to_string())),
|
||||
};
|
||||
let token_prefix = &refresh_token_str[..std::cmp::min(16, refresh_token_str.len())];
|
||||
tracing::info!(
|
||||
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
|
||||
refresh_token_prefix = %token_prefix,
|
||||
has_dpop = dpop_proof.is_some(),
|
||||
"Refresh token grant requested"
|
||||
);
|
||||
if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? {
|
||||
if let Some((_db_id, token_data)) =
|
||||
db::get_token_by_previous_refresh_token(&state.db, &refresh_token_str).await?
|
||||
{
|
||||
|
||||
let lookup = db::lookup_refresh_token(&state.db, &refresh_token_str).await?;
|
||||
let token_state = lookup.state();
|
||||
tracing::debug!(state = %token_state, "Refresh token state");
|
||||
|
||||
let (db_id, token_data) = match lookup {
|
||||
RefreshTokenLookup::Valid { db_id, token_data } => (db_id, token_data),
|
||||
RefreshTokenLookup::InGracePeriod { db_id: _, token_data, rotated_at } => {
|
||||
tracing::info!(
|
||||
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
|
||||
refresh_token_prefix = %token_prefix,
|
||||
rotated_at = %rotated_at,
|
||||
"Refresh token reuse within grace period, returning existing tokens"
|
||||
);
|
||||
let dpop_jkt = token_data.parameters.dpop_jkt.as_deref();
|
||||
@@ -230,35 +243,28 @@ pub async fn handle_refresh_token_grant(
|
||||
}),
|
||||
));
|
||||
}
|
||||
tracing::warn!(
|
||||
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
|
||||
"Refresh token reuse detected, revoking token family"
|
||||
);
|
||||
db::delete_token_family(&state.db, token_id).await?;
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Refresh token reuse detected, token family revoked".to_string(),
|
||||
));
|
||||
}
|
||||
let (db_id, token_data) = db::get_token_by_refresh_token(&state.db, &refresh_token_str)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
RefreshTokenLookup::Used { original_token_id } => {
|
||||
tracing::warn!(
|
||||
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
|
||||
"Refresh token not found in database"
|
||||
refresh_token_prefix = %token_prefix,
|
||||
"Refresh token reuse detected, revoking token family"
|
||||
);
|
||||
OAuthError::InvalidGrant("Invalid refresh token".to_string())
|
||||
})?;
|
||||
if token_data.expires_at < Utc::now() {
|
||||
tracing::warn!(
|
||||
did = %token_data.did,
|
||||
expired_at = %token_data.expires_at,
|
||||
"Refresh token has expired"
|
||||
);
|
||||
db::delete_token_family(&state.db, db_id).await?;
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Refresh token has expired".to_string(),
|
||||
));
|
||||
}
|
||||
db::delete_token_family(&state.db, original_token_id).await?;
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Refresh token reuse detected, token family revoked".to_string(),
|
||||
));
|
||||
}
|
||||
RefreshTokenLookup::Expired { db_id } => {
|
||||
tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token has expired");
|
||||
db::delete_token_family(&state.db, db_id).await?;
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Refresh token has expired".to_string(),
|
||||
));
|
||||
}
|
||||
RefreshTokenLookup::NotFound => {
|
||||
tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token not found");
|
||||
return Err(OAuthError::InvalidGrant("Invalid refresh token".to_string()));
|
||||
}
|
||||
};
|
||||
let dpop_jkt = if let Some(proof) = &dpop_proof {
|
||||
let config = AuthConfig::get();
|
||||
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
|
||||
@@ -272,13 +278,13 @@ pub async fn handle_refresh_token_grant(
|
||||
));
|
||||
}
|
||||
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt
|
||||
&& &result.jkt != expected_jkt
|
||||
&& result.jkt.as_str() != expected_jkt
|
||||
{
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"DPoP key binding mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
Some(result.jkt)
|
||||
Some(result.jkt.as_str().to_string())
|
||||
} else if token_data.parameters.dpop_jkt.is_some() {
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
"DPoP proof required".to_string(),
|
||||
|
||||
@@ -13,7 +13,7 @@ pub use helpers::{TokenClaims, create_access_token, extract_token_claims, verify
|
||||
pub use introspect::{
|
||||
IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token,
|
||||
};
|
||||
pub use types::{TokenRequest, TokenResponse};
|
||||
pub use types::{ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest};
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
@@ -65,14 +65,13 @@ pub async fn token_endpoint(
|
||||
.get("DPoP")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
match request.grant_type.as_str() {
|
||||
"authorization_code" => {
|
||||
handle_authorization_code_grant(state, headers, request, dpop_proof).await
|
||||
let validated = request.validate()?;
|
||||
match validated.grant {
|
||||
TokenGrant::AuthorizationCode { .. } => {
|
||||
handle_authorization_code_grant(state, headers, validated, dpop_proof).await
|
||||
}
|
||||
TokenGrant::RefreshToken { .. } => {
|
||||
handle_refresh_token_grant(state, headers, validated, dpop_proof).await
|
||||
}
|
||||
"refresh_token" => handle_refresh_token_grant(state, headers, request, dpop_proof).await,
|
||||
_ => Err(OAuthError::UnsupportedGrantType(format!(
|
||||
"Unsupported grant_type: {}",
|
||||
request.grant_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
use crate::oauth::OAuthError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GrantType {
|
||||
AuthorizationCode,
|
||||
RefreshToken,
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
impl GrantType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::AuthorizationCode => "authorization_code",
|
||||
Self::RefreshToken => "refresh_token",
|
||||
Self::Unsupported(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for GrantType {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(match s {
|
||||
"authorization_code" => Self::AuthorizationCode,
|
||||
"refresh_token" => Self::RefreshToken,
|
||||
other => Self::Unsupported(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GrantType {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(s.parse().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for GrantType {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
pub grant_type: String,
|
||||
pub grant_type: GrantType,
|
||||
#[serde(default)]
|
||||
pub code: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -21,6 +70,70 @@ pub struct TokenRequest {
|
||||
pub client_assertion_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TokenGrant {
|
||||
AuthorizationCode {
|
||||
code: String,
|
||||
code_verifier: String,
|
||||
redirect_uri: Option<String>,
|
||||
},
|
||||
RefreshToken {
|
||||
refresh_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClientAuthParams {
|
||||
pub client_id: Option<String>,
|
||||
pub client_secret: Option<String>,
|
||||
pub client_assertion: Option<String>,
|
||||
pub client_assertion_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidatedTokenRequest {
|
||||
pub grant: TokenGrant,
|
||||
pub client_auth: ClientAuthParams,
|
||||
}
|
||||
|
||||
impl TokenRequest {
|
||||
pub fn validate(self) -> Result<ValidatedTokenRequest, OAuthError> {
|
||||
let grant = match self.grant_type {
|
||||
GrantType::AuthorizationCode => {
|
||||
let code = self.code.ok_or_else(|| {
|
||||
OAuthError::InvalidRequest("code is required for authorization_code grant".to_string())
|
||||
})?;
|
||||
let code_verifier = self.code_verifier.ok_or_else(|| {
|
||||
OAuthError::InvalidRequest("code_verifier is required for authorization_code grant".to_string())
|
||||
})?;
|
||||
TokenGrant::AuthorizationCode {
|
||||
code,
|
||||
code_verifier,
|
||||
redirect_uri: self.redirect_uri,
|
||||
}
|
||||
}
|
||||
GrantType::RefreshToken => {
|
||||
let refresh_token = self.refresh_token.ok_or_else(|| {
|
||||
OAuthError::InvalidRequest("refresh_token is required for refresh_token grant".to_string())
|
||||
})?;
|
||||
TokenGrant::RefreshToken { refresh_token }
|
||||
}
|
||||
GrantType::Unsupported(grant_type) => {
|
||||
return Err(OAuthError::UnsupportedGrantType(grant_type));
|
||||
}
|
||||
};
|
||||
|
||||
let client_auth = ClientAuthParams {
|
||||
client_id: self.client_id,
|
||||
client_secret: self.client_secret,
|
||||
client_assertion: self.client_assertion,
|
||||
client_assertion_type: self.client_assertion_type,
|
||||
};
|
||||
|
||||
Ok(ValidatedTokenRequest { grant, client_auth })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
|
||||
@@ -140,16 +140,13 @@ impl AccountAction {
|
||||
}
|
||||
|
||||
fn parse_query_params(query: &str) -> HashMap<String, Vec<String>> {
|
||||
let mut params: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for part in query.split('&') {
|
||||
if let Some((key, value)) = part.split_once('=') {
|
||||
params
|
||||
.entry(key.to_string())
|
||||
.or_default()
|
||||
.push(value.to_string());
|
||||
}
|
||||
}
|
||||
params
|
||||
query
|
||||
.split('&')
|
||||
.filter_map(|part| part.split_once('='))
|
||||
.fold(HashMap::new(), |mut acc, (key, value)| {
|
||||
acc.entry(key.to_string()).or_default().push(value.to_string());
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_scope(scope: &str) -> ParsedScope {
|
||||
|
||||
@@ -245,3 +245,282 @@ pub struct JwkPublicKey {
|
||||
pub struct Jwks {
|
||||
pub keys: Vec<JwkPublicKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthFlowState {
|
||||
Pending,
|
||||
Authenticated { did: String, device_id: Option<String> },
|
||||
Authorized { did: String, device_id: Option<String>, code: String },
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl AuthFlowState {
|
||||
pub fn from_request_data(data: &RequestData) -> Self {
|
||||
if data.expires_at < chrono::Utc::now() {
|
||||
return AuthFlowState::Expired;
|
||||
}
|
||||
match (&data.did, &data.code) {
|
||||
(Some(did), Some(code)) => AuthFlowState::Authorized {
|
||||
did: did.clone(),
|
||||
device_id: data.device_id.clone(),
|
||||
code: code.clone(),
|
||||
},
|
||||
(Some(did), None) => AuthFlowState::Authenticated {
|
||||
did: did.clone(),
|
||||
device_id: data.device_id.clone(),
|
||||
},
|
||||
(None, _) => AuthFlowState::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Pending)
|
||||
}
|
||||
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Authenticated { .. })
|
||||
}
|
||||
|
||||
pub fn is_authorized(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Authorized { .. })
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Expired)
|
||||
}
|
||||
|
||||
pub fn can_authenticate(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Pending)
|
||||
}
|
||||
|
||||
pub fn can_authorize(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Authenticated { .. })
|
||||
}
|
||||
|
||||
pub fn can_exchange(&self) -> bool {
|
||||
matches!(self, AuthFlowState::Authorized { .. })
|
||||
}
|
||||
|
||||
pub fn did(&self) -> Option<&str> {
|
||||
match self {
|
||||
AuthFlowState::Authenticated { did, .. } | AuthFlowState::Authorized { did, .. } => {
|
||||
Some(did)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> Option<&str> {
|
||||
match self {
|
||||
AuthFlowState::Authorized { code, .. } => Some(code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuthFlowState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthFlowState::Pending => write!(f, "pending"),
|
||||
AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did),
|
||||
AuthFlowState::Authorized { did, code, .. } => {
|
||||
write!(f, "authorized ({}, code={}...)", did, &code[..8.min(code.len())])
|
||||
}
|
||||
AuthFlowState::Expired => write!(f, "expired"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RefreshTokenState {
|
||||
Valid,
|
||||
Used { at: chrono::DateTime<chrono::Utc> },
|
||||
InGracePeriod { rotated_at: chrono::DateTime<chrono::Utc> },
|
||||
Expired,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl RefreshTokenState {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
matches!(self, RefreshTokenState::Valid)
|
||||
}
|
||||
|
||||
pub fn is_usable(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
RefreshTokenState::Valid | RefreshTokenState::InGracePeriod { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_used(&self) -> bool {
|
||||
matches!(self, RefreshTokenState::Used { .. })
|
||||
}
|
||||
|
||||
pub fn is_in_grace_period(&self) -> bool {
|
||||
matches!(self, RefreshTokenState::InGracePeriod { .. })
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
matches!(self, RefreshTokenState::Expired)
|
||||
}
|
||||
|
||||
pub fn is_revoked(&self) -> bool {
|
||||
matches!(self, RefreshTokenState::Revoked)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RefreshTokenState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RefreshTokenState::Valid => write!(f, "valid"),
|
||||
RefreshTokenState::Used { at } => write!(f, "used ({})", at),
|
||||
RefreshTokenState::InGracePeriod { rotated_at } => {
|
||||
write!(f, "grace period (rotated {})", rotated_at)
|
||||
}
|
||||
RefreshTokenState::Expired => write!(f, "expired"),
|
||||
RefreshTokenState::Revoked => write!(f, "revoked"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
fn make_request_data(
|
||||
did: Option<String>,
|
||||
code: Option<String>,
|
||||
expires_in: Duration,
|
||||
) -> RequestData {
|
||||
RequestData {
|
||||
client_id: "test-client".into(),
|
||||
client_auth: None,
|
||||
parameters: AuthorizationRequestParameters {
|
||||
response_type: "code".into(),
|
||||
client_id: "test-client".into(),
|
||||
redirect_uri: "https://example.com/callback".into(),
|
||||
scope: Some("atproto".into()),
|
||||
state: None,
|
||||
code_challenge: "test".into(),
|
||||
code_challenge_method: "S256".into(),
|
||||
response_mode: None,
|
||||
login_hint: None,
|
||||
dpop_jkt: None,
|
||||
extra: None,
|
||||
},
|
||||
expires_at: Utc::now() + expires_in,
|
||||
did,
|
||||
device_id: None,
|
||||
code,
|
||||
controller_did: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow_state_pending() {
|
||||
let data = make_request_data(None, None, Duration::minutes(5));
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
assert!(state.is_pending());
|
||||
assert!(!state.is_authenticated());
|
||||
assert!(!state.is_authorized());
|
||||
assert!(!state.is_expired());
|
||||
assert!(state.can_authenticate());
|
||||
assert!(!state.can_authorize());
|
||||
assert!(!state.can_exchange());
|
||||
assert!(state.did().is_none());
|
||||
assert!(state.code().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow_state_authenticated() {
|
||||
let data = make_request_data(Some("did:plc:test".into()), None, Duration::minutes(5));
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
assert!(!state.is_pending());
|
||||
assert!(state.is_authenticated());
|
||||
assert!(!state.is_authorized());
|
||||
assert!(!state.is_expired());
|
||||
assert!(!state.can_authenticate());
|
||||
assert!(state.can_authorize());
|
||||
assert!(!state.can_exchange());
|
||||
assert_eq!(state.did(), Some("did:plc:test"));
|
||||
assert!(state.code().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow_state_authorized() {
|
||||
let data = make_request_data(
|
||||
Some("did:plc:test".into()),
|
||||
Some("auth-code-123".into()),
|
||||
Duration::minutes(5),
|
||||
);
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
assert!(!state.is_pending());
|
||||
assert!(!state.is_authenticated());
|
||||
assert!(state.is_authorized());
|
||||
assert!(!state.is_expired());
|
||||
assert!(!state.can_authenticate());
|
||||
assert!(!state.can_authorize());
|
||||
assert!(state.can_exchange());
|
||||
assert_eq!(state.did(), Some("did:plc:test"));
|
||||
assert_eq!(state.code(), Some("auth-code-123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_flow_state_expired() {
|
||||
let data = make_request_data(
|
||||
Some("did:plc:test".into()),
|
||||
Some("code".into()),
|
||||
Duration::minutes(-1),
|
||||
);
|
||||
let state = AuthFlowState::from_request_data(&data);
|
||||
assert!(state.is_expired());
|
||||
assert!(!state.can_authenticate());
|
||||
assert!(!state.can_authorize());
|
||||
assert!(!state.can_exchange());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_token_state_valid() {
|
||||
let state = RefreshTokenState::Valid;
|
||||
assert!(state.is_valid());
|
||||
assert!(state.is_usable());
|
||||
assert!(!state.is_used());
|
||||
assert!(!state.is_in_grace_period());
|
||||
assert!(!state.is_expired());
|
||||
assert!(!state.is_revoked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_token_state_grace_period() {
|
||||
let state = RefreshTokenState::InGracePeriod {
|
||||
rotated_at: Utc::now(),
|
||||
};
|
||||
assert!(!state.is_valid());
|
||||
assert!(state.is_usable());
|
||||
assert!(!state.is_used());
|
||||
assert!(state.is_in_grace_period());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_token_state_used() {
|
||||
let state = RefreshTokenState::Used { at: Utc::now() };
|
||||
assert!(!state.is_valid());
|
||||
assert!(!state.is_usable());
|
||||
assert!(state.is_used());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_token_state_expired() {
|
||||
let state = RefreshTokenState::Expired;
|
||||
assert!(!state.is_usable());
|
||||
assert!(state.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refresh_token_state_revoked() {
|
||||
let state = RefreshTokenState::Revoked;
|
||||
assert!(!state.is_usable());
|
||||
assert!(state.is_revoked());
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user