From 387cc4472edf6b527dac0ee19ecfa47232321ec7 Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 4 Jan 2026 14:58:45 +0200 Subject: [PATCH] Functional typesafe backend --- ...6a54823770aee565f4cf5da2fc1f9b89b6bbb.json | 29 + ...7cdf535cd70e09fde0a8a28249df0070eb2fc.json | 22 - ...e2e5feaa9c59c38ec9175568abdacda167107.json | 15 - ...445b65c8cc8c723baca221d85f5e4f2478b99.json | 22 - ...fce2209635090252ac3692823450431d03dc6.json | 22 - ...4abcf8bc8268a348d3be0da9840d1708d20b5.json | 14 - ...79a3c411917144a011f50849b737130b24dbe.json | 54 - ...70b3360a3ac71e649b293efb88d92c3254068.json | 22 - ...813f13f5892f653128469be727b686e6a0f0a.json | 28 - ...294cd58f64b4fb431b54b5deda13d64525e88.json | 28 - ...9580ff03b717586c4ca2d5343709e2dac86b6.json | 22 - ...61ac07a02ffa9a926f94508d7873c4ca07e65.json | 23 - ...92881ba343c73a9a6e513e205c801c5943ec0.json | 28 - ...b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json | 22 - ...d3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json | 22 - ...6f6fba9fe338e4a6a10636b5389d426df1631.json | 22 - ...413743ba168d1e0d8b85566711e54d4048f81.json | 28 - ...94246ead214ca794760d3fe77bb5cf4f27be5.json | 22 - ...2ac0f84c73de8c244ba4560f004ee3f4b7002.json | 28 - ...eadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json | 108 - ...d5171e70fa25e3b8393e142cebcbff752f0f5.json | 34 - ...7b633cec7287c1cc177f0e1d47ec6571564d5.json | 22 - ...cae4825dec587b1387f0fee401458aea2a2e5.json | 60 - ...b29f7abfe18ee6f559bd94ab16274b1cfdfee.json | 22 - ...ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json | 22 - ...a3b40041b043bae57e95002d4bf5df46a4ab4.json | 14 - ...80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json | 22 - ...5a1a627809f444b0faff7e611d985f31b90e9.json | 22 - ...bf8b34ca6a2246c20fc96f76f0e95530762a7.json | 22 - ...fac26435b0feec65cf1c56f851d1c4d6b1814.json | 14 - ...03699bb3a90f39bc9c4c0f738a37827e8f382.json | 28 - src/api/actor/preferences.rs | 122 +- src/api/admin/account/delete.rs | 92 +- src/api/admin/account/email.rs | 45 +- src/api/admin/account/info.rs | 69 +- src/api/admin/account/search.rs | 19 +- src/api/admin/account/update.rs | 111 +- src/api/admin/invite.rs | 51 +- src/api/admin/status.rs | 110 +- src/api/age_assurance.rs | 2 +- src/api/backup.rs | 206 +- src/api/delegation.rs | 389 +--- src/api/error.rs | 572 +++++- src/api/identity/account.rs | 461 +---- src/api/identity/did.rs | 231 +-- src/api/identity/plc/request.rs | 18 +- src/api/identity/plc/sign.rs | 132 +- src/api/identity/plc/submit.rs | 116 +- src/api/mod.rs | 5 + src/api/moderation/mod.rs | 38 +- src/api/notification_prefs.rs | 159 +- src/api/proxy.rs | 56 +- src/api/repo/blob.rs | 130 +- src/api/repo/import.rs | 352 +--- src/api/repo/meta.rs | 28 +- src/api/repo/record/batch.rs | 263 +-- src/api/repo/record/delete.rs | 86 +- src/api/repo/record/read.rs | 125 +- src/api/repo/record/validation.rs | 87 +- src/api/repo/record/write.rs | 311 +-- src/api/responses.rs | 114 ++ src/api/server/account_status.rs | 252 +-- src/api/server/app_password.rs | 36 +- src/api/server/email.rs | 243 +-- src/api/server/invite.rs | 18 +- src/api/server/migration.rs | 32 +- src/api/server/passkey_account.rs | 517 ++--- src/api/server/passkeys.rs | 129 +- src/api/server/password.rs | 269 +-- src/api/server/reauth.rs | 197 +- src/api/server/service_auth.rs | 98 +- src/api/server/session.rs | 437 ++--- src/api/server/signing_key.rs | 8 +- src/api/server/totp.rs | 374 +--- src/api/server/trusted_devices.rs | 128 +- src/api/server/verify_email.rs | 18 +- src/api/server/verify_token.rs | 147 +- src/api/temp.rs | 32 +- src/api/validation.rs | 195 ++ src/api/verification.rs | 6 +- src/appview/mod.rs | 21 +- src/auth/extractor.rs | 51 +- src/auth/mod.rs | 64 +- src/auth/scope_check.rs | 68 +- src/comms/service.rs | 20 +- src/delegation/db.rs | 5 +- src/lib.rs | 8 +- src/main.rs | 10 +- src/oauth/db/device.rs | 5 +- src/oauth/db/mod.rs | 13 +- src/oauth/db/request.rs | 15 +- src/oauth/db/token.rs | 48 +- src/oauth/dpop.rs | 9 +- src/oauth/endpoints/authorize.rs | 180 +- src/oauth/endpoints/delegation.rs | 3 +- src/oauth/endpoints/token/grants.rs | 126 +- src/oauth/endpoints/token/mod.rs | 17 +- src/oauth/endpoints/token/types.rs | 115 +- src/oauth/scopes/parser.rs | 17 +- src/oauth/types.rs | 279 +++ src/oauth/verify.rs | 4 +- src/scheduled.rs | 6 +- src/sync/blob.rs | 44 +- src/sync/commit.rs | 61 +- src/sync/crawl.rs | 7 +- src/sync/deprecated.rs | 56 +- src/sync/frame.rs | 110 +- src/sync/repo.rs | 196 +- src/sync/util.rs | 115 +- src/types.rs | 1738 +++++++++++++++++ src/util.rs | 6 +- src/validation/mod.rs | 54 +- tests/admin_email.rs | 2 - tests/delete_account.rs | 2 +- tests/import_verification.rs | 4 +- tests/lifecycle_record.rs | 2 +- 116 files changed, 5339 insertions(+), 6641 deletions(-) create mode 100644 .sqlx/query-032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb.json delete mode 100644 .sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json delete mode 100644 .sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json delete mode 100644 .sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json delete mode 100644 .sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json delete mode 100644 .sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json delete mode 100644 .sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json delete mode 100644 .sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json delete mode 100644 .sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json delete mode 100644 .sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json delete mode 100644 .sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json delete mode 100644 .sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json delete mode 100644 .sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json delete mode 100644 .sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json delete mode 100644 .sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json delete mode 100644 .sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json delete mode 100644 .sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json delete mode 100644 .sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json delete mode 100644 .sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json delete mode 100644 .sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json delete mode 100644 .sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json delete mode 100644 .sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json delete mode 100644 .sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json delete mode 100644 .sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json delete mode 100644 .sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json delete mode 100644 .sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json delete mode 100644 .sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json delete mode 100644 .sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json delete mode 100644 .sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json delete mode 100644 .sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json delete mode 100644 .sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json create mode 100644 src/api/responses.rs create mode 100644 src/types.rs diff --git a/.sqlx/query-032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb.json b/.sqlx/query-032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb.json new file mode 100644 index 0000000..f6fc1b7 --- /dev/null +++ b/.sqlx/query-032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb.json @@ -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" +} diff --git a/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json b/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json deleted file mode 100644 index 15ba9ce..0000000 --- a/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json b/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json deleted file mode 100644 index 734848a..0000000 --- a/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json b/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json deleted file mode 100644 index 9e5e897..0000000 --- a/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json b/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json deleted file mode 100644 index cf39684..0000000 --- a/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json b/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json deleted file mode 100644 index 8d0d055..0000000 --- a/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json b/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json deleted file mode 100644 index 7004d69..0000000 --- a/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json b/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json deleted file mode 100644 index b81fee7..0000000 --- a/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json b/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json deleted file mode 100644 index 5c9872f..0000000 --- a/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json b/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json deleted file mode 100644 index e232907..0000000 --- a/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json b/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json deleted file mode 100644 index 873f0f7..0000000 --- a/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json b/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json deleted file mode 100644 index 6754294..0000000 --- a/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json b/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json deleted file mode 100644 index 32bd1aa..0000000 --- a/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json b/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json deleted file mode 100644 index 296bb04..0000000 --- a/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json b/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json deleted file mode 100644 index af80ee3..0000000 --- a/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json b/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json deleted file mode 100644 index 416f681..0000000 --- a/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json b/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json deleted file mode 100644 index 8d5a9b7..0000000 --- a/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json b/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json deleted file mode 100644 index ef52899..0000000 --- a/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json b/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json deleted file mode 100644 index 178bc9e..0000000 --- a/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json b/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json deleted file mode 100644 index 8bcc4f5..0000000 --- a/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json b/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json deleted file mode 100644 index 2640f70..0000000 --- a/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json b/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json deleted file mode 100644 index f10b2bc..0000000 --- a/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json b/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json deleted file mode 100644 index 0297c9a..0000000 --- a/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json b/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json deleted file mode 100644 index 6b53208..0000000 --- a/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json b/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json deleted file mode 100644 index 08e8e23..0000000 --- a/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json b/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json deleted file mode 100644 index be751e8..0000000 --- a/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json b/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json deleted file mode 100644 index 0576d91..0000000 --- a/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json b/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json deleted file mode 100644 index edb2f4b..0000000 --- a/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json b/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json deleted file mode 100644 index e8c8434..0000000 --- a/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json b/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json deleted file mode 100644 index db8bde6..0000000 --- a/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json b/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json deleted file mode 100644 index 32320a7..0000000 --- a/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json +++ /dev/null @@ -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" -} diff --git a/src/api/actor/preferences.rs b/src/api/actor/preferences.rs index c443693..c72d335 100644 --- a/src/api/actor/preferences.rs +++ b/src/api/actor/preferences.rs @@ -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 = 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 = 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() } diff --git a/src/api/admin/account/delete.rs b/src/api/admin/account/delete.rs index 43904d5..352c128 100644 --- a/src/api/admin/account/delete.rs +++ b/src/api/admin/account/delete.rs @@ -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, ) -> 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() } diff --git a/src/api/admin/account/email.rs b/src/api/admin/account/email.rs index 756d73a..46af1b3 100644 --- a/src/api/admin/account/email.rs +++ b/src/api/admin/account/email.rs @@ -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, pub comment: Option, @@ -28,27 +29,15 @@ pub struct SendEmailOutput { pub async fn send_email( State(state): State, _auth: BearerAuthAdmin, - Json(input): Json, + AtpJson(input): AtpJson, ) -> 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) => { diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs index 55fd6e8..c255bf2 100644 --- a/src/api/admin/account/info.rs +++ b/src/api/admin/account/info.rs @@ -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, 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, } @@ -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, ) -> 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 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, diff --git a/src/api/admin/account/search.rs b/src/api/admin/account/search.rs index e5d8d02..27b6e2b 100644 --- a/src/api/admin/account/search.rs +++ b/src/api/admin/account/search.rs @@ -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, 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() } } } diff --git a/src/api/admin/account/update.rs b/src/api/admin/account/update.rs index 314f57d..dc126ad 100644 --- a/src/api/admin/account/update.rs +++ b/src/api/admin/account/update.rs @@ -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, ) -> 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, ) -> 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() } } } diff --git a/src/api/admin/invite.rs b/src/api/admin/invite.rs index 351ca4d..7c5c710 100644 --- a/src/api/admin/invite.rs +++ b/src/api/admin/invite.rs @@ -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() } } } diff --git a/src/api/admin/status.rs b/src/api/admin/status.rs index b414cc7..876f55e 100644 --- a/src/api/admin/status.rs +++ b/src/api/admin/status.rs @@ -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, ) -> 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() } diff --git a/src/api/age_assurance.rs b/src/api/age_assurance.rs index ffa3c1f..52e76bb 100644 --- a/src/api/age_assurance.rs +++ b/src/api/age_assurance.rs @@ -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 { diff --git a/src/api/backup.rs b/src/api/backup.rs index 6543cb4..1f13882 100644 --- a/src/api/backup.rs +++ b/src/api/backup.rs @@ -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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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(); } } diff --git a/src/api/delegation.rs b/src/api/delegation.rs index 7d795f8..8ea6d3c 100644 --- a/src/api/delegation.rs +++ b/src/api/delegation.rs @@ -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, pub is_active: bool, @@ -38,14 +39,7 @@ pub async fn list_controllers(State(state): State, 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, 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, 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, ) -> 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, ) -> 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, ) -> 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, } @@ -408,13 +293,7 @@ pub async fn list_controlled_accounts(State(state): State, 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, 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, + pub delegated_did: Did, + pub actor_did: Did, + pub controller_did: Option, pub action_type: String, pub action_details: Option, pub created_at: chrono::DateTime, @@ -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() } diff --git a/src/api/error.rs b/src/api/error.rs index 16e57b9..49c7ca9 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -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), AuthenticationRequired, - AuthenticationFailed, - AuthenticationFailedMsg(String), + AuthenticationFailed(Option), InvalidRequest(String), - InvalidToken, - ExpiredToken, - ExpiredTokenMsg(String), + InvalidToken(Option), + ExpiredToken(Option), TokenRequired, AccountDeactivated, AccountTakedown, AccountNotFound, - RepoNotFound, - RepoNotFoundMsg(String), + RepoNotFound(Option), + RepoTakendown, + RepoDeactivated, RecordNotFound, - BlobNotFound, - InvalidHandle, - HandleNotAvailable, + BlobNotFound(Option), + InvalidHandle(Option), + HandleNotAvailable(Option), HandleTaken, InvalidEmail, EmailTaken, @@ -40,10 +40,63 @@ pub enum ApiError { DuplicateCreate, DuplicateAppPassword, AppPasswordNotFound, - InvalidSwap, + SessionNotFound, + InvalidSwap(Option), + InvalidPassword(String), + InvalidRepo(String), + AccountMigrated, + AccountNotVerified, + InvalidCollection, + InvalidRecord(String), Forbidden, - InsufficientScope, + AdminRequired, + InsufficientScope(Option), InvitesDisabled, + RateLimitExceeded(Option), + PayloadTooLarge(String), + TotpAlreadyEnabled, + TotpNotEnabled, + InvalidCode(Option), + 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), + 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 { 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 for ApiError { fn from(e: sqlx::Error) -> Self { tracing::error!("Database error: {:?}", e); @@ -194,9 +448,11 @@ impl From 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 for ApiError { } } } + +impl From 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 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 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 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 for ApiError { + fn from(e: jacquard::types::string::AtStrError) -> Self { + Self::InvalidRequest(format!("Invalid {}: {}", e.spec, e.kind)) + } +} + +impl From 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 for ApiError { + fn from(e: bcrypt::BcryptError) -> Self { + tracing::error!("Bcrypt error: {:?}", e); + Self::InternalError(None) + } +} + +impl From for ApiError { + fn from(e: cid::Error) -> Self { + Self::InvalidRequest(format!("Invalid CID: {}", e)) + } +} + +impl From> for ApiError { + fn from(e: crate::circuit_breaker::CircuitBreakerError) -> 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 for ApiError { + fn from(e: crate::storage::StorageError) -> Self { + tracing::error!("Storage error: {:?}", e); + Self::InternalError(Some("Storage operation failed".into())) + } +} + +pub struct AtpJson(pub T); + +impl FromRequest for AtpJson +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = (StatusCode, Json); + + async fn from_request(req: Request, state: &S) -> Result { + match Json::::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(), + } +} diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 05dfce6..ce73411 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -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, - pub password: String, + pub password: PlainPassword, pub invite_code: Option, pub did: Option, pub did_type: Option, @@ -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, 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, 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, diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index e7a81b7..bf0d1be 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -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, Path(handle): Path (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, Path(handle): Path, Path(handle): Path 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() } } } diff --git a/src/api/identity/plc/request.rs b/src/api/identity/plc/request.rs index 4d6e37a..62c4e54 100644 --- a/src/api/identity/plc/request.rs +++ b/src/api/identity/plc/request.rs @@ -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() } diff --git a/src/api/identity/plc/sign.rs b/src/api/identity/plc/sign.rs index 4e48858..736ecd0 100644 --- a/src/api/identity/plc/sign.rs +++ b/src/api/identity/plc/sign.rs @@ -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> = - 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!( diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs index db6f607..0f68af8 100644 --- a/src/api/identity/plc/submit.rs +++ b/src/api/identity/plc/submit.rs @@ -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> = - 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() } diff --git a/src/api/mod.rs b/src/api/mod.rs index 41a395f..3ea628e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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}; diff --git a/src/api/moderation/mod.rs b/src/api/moderation/mod.rs index 3ff8c60..9d9b244 100644 --- a/src/api/moderation/mod.rs +++ b/src/api/moderation/mod.rs @@ -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!( diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs index 4ad793c..17be0cc 100644 --- a/src/api/notification_prefs.rs +++ b/src/api/notification_prefs.rs @@ -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, 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, 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"); diff --git a/src/api/proxy.rs b/src/api/proxy.rs index 370e623..1ea3cab 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -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()); diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index b0cbe0a..f2010fa 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -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, ) -> 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; diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs index e22f326..ab3c0dc 100644 --- a/src/api/repo/import.rs +++ b/src/api/repo/import.rs @@ -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> = 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() } } } diff --git a/src/api/repo/meta.rs b/src/api/repo/meta.rs index 2fa5067..711a32b 100644 --- a/src/api/repo/meta.rs +++ b/src/api/repo/meta.rs @@ -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, ) -> 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!( diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 97e607b..299a3c0 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -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, + collection: Nsid, + rkey: Option, 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, pub writes: Vec, pub swap_commit: Option, @@ -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, }, #[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, @@ -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(), - }; - let collection_nsid = match collection.parse::() { - Ok(n) => n, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(), + Err(_) => { + return ApiError::InternalError(Some("Failed to store record".into())) + .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(), - }; - let collection_nsid = match collection.parse::() { - Ok(n) => n, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(), + Err(_) => { + return ApiError::InternalError(Some("Failed to store record".into())) + .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::() { - 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(); } }; diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs index 57319e2..235b842 100644 --- a/src/api/repo/record/delete.rs +++ b/src/api/repo/record/delete.rs @@ -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, #[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::() { - 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 { diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index d252a28..bab93d7 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -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, } pub async fn get_record( State(state): State, - headers: HeaderMap, + _headers: HeaderMap, Query(input): Query, ) -> 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, pub cursor: Option, #[serde(rename = "rkeyStart")] - pub rkey_start: Option, + pub rkey_start: Option, #[serde(rename = "rkeyEnd")] - pub rkey_end: Option, + pub rkey_end: Option, pub reverse: Option, } #[derive(Serialize)] @@ -181,16 +153,17 @@ pub async fn list_records( Query(input): Query, ) -> 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(); diff --git a/src/api/repo/record/validation.rs b/src/api/repo/record/validation.rs index d00ad63..fe10020 100644 --- a/src/api/repo/record/validation.rs +++ b/src/api/repo/record/validation.rs @@ -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> { 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 { - 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()) } diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index 8ff30cb..fbd9644 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -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, - pub controller_did: Option, + pub controller_did: Option, } 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, + pub repo: AtIdentifier, + pub collection: Nsid, + pub rkey: Option, pub validate: Option, 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::() { - 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, 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, @@ -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::() { - 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() diff --git a/src/api/responses.rs b/src/api/responses.rs new file mode 100644 index 0000000..6b33634 --- /dev/null +++ b/src/api/responses.rs @@ -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) -> 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) -> 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 { + pub options: T, +} + +impl OptionsResponse { + pub fn new(options: T) -> Json { + Json(Self { options }) + } +} diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index 9979455..0b12bbe 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -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, did: &str) -> bool { +async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: Arc, 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, did async fn assert_valid_did_document_for_service( db: &sqlx::PgPool, - cache: &Arc, + cache: Arc, did: &str, with_retry: bool, -) -> Result<(), (StatusCode, Json)> { +) -> 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::>()) .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, Json(input): Json, ) -> 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() } } } diff --git a/src/api/server/app_password.rs b/src/api/server/app_password.rs index 07ee771..52751f2 100644 --- a/src/api/server/app_password.rs +++ b/src/api/server/app_password.rs @@ -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() } diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 2356985..b68383c 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -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, ) -> 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::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() } } } diff --git a/src/api/server/invite.rs b/src/api/server/invite.rs index 2ace680..0eba108 100644 --- a/src/api/server/invite.rs +++ b/src/api/server/invite.rs @@ -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(); } }; diff --git a/src/api/server/migration.rs b/src/api/server/migration.rs index 3dd9ada..c61369b 100644 --- a/src/api/server/migration.rs +++ b/src/api/server/migration.rs @@ -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; diff --git a/src/api/server/passkey_account.rs b/src/api/server/passkey_account.rs index 5eec41a..93c5e10 100644 --- a/src/api/server/passkey_account.rs +++ b/src/api/server/passkey_account.rs @@ -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, #[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, @@ -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, } @@ -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, ) -> 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() } diff --git a/src/api/server/passkeys.rs b/src/api/server/passkeys.rs index 8da624e..dd10d80 100644 --- a/src/api/server/passkeys.rs +++ b/src/api/server/passkeys.rs @@ -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)> { +fn get_webauthn() -> Result { 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, 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() } } } diff --git a/src/api/server/password.rs b/src/api/server/password.rs index ce9ed70..033b44d 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -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 = 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, 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, 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, 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, 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() } diff --git a/src/api/server/reauth.rs b/src/api/server/reauth.rs index cba46c1..f358864 100644 --- a/src/api/server/reauth.rs +++ b/src/api/server/reauth.rs @@ -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, 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, 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, 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, ) -> 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, 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 = stored_passkeys @@ -241,22 +191,14 @@ pub async fn reauth_passkey_start(State(state): State, 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, 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, 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, +} diff --git a/src/api/server/service_auth.rs b/src/api/server/service_auth.rs index 28fdd9f..0418d27 100644 --- a/src/api/server/service_auth.rs +++ b/src/api/server/service_auth.rs @@ -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, Option)>( "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(); } }; ( diff --git a/src/api/server/session.rs b/src/api/server/session.rs index bf37858..003afa6 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -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(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, 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 = 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, ) -> 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::() 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::() 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, ) -> 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() } } } diff --git a/src/api/server/signing_key.rs b/src/api/server/signing_key.rs index c40acbf..fc2eac2 100644 --- a/src/api/server/signing_key.rs +++ b/src/api/server/signing_key.rs @@ -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() } } } diff --git a/src/api/server/totp.rs b/src/api/server/totp.rs index b127a57..cbc9b90 100644 --- a/src/api/server/totp.rs +++ b/src/api/server/totp.rs @@ -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, 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, 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, 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, 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, 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, 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, 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, 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() } diff --git a/src/api/server/trusted_devices.rs b/src/api/server/trusted_devices.rs index 48b2f57..e2935c2 100644 --- a/src/api/server/trusted_devices.rs +++ b/src/api/server/trusted_devices.rs @@ -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>, + trusted_until: Option>, + ) -> 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>, pub trusted_until: Option>, pub last_seen_at: DateTime, + pub trust_state: DeviceTrustState, } #[derive(Serialize)] @@ -39,7 +77,7 @@ pub async fn list_trusted_devices(State(state): State, 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, 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); diff --git a/src/api/server/verify_email.rs b/src/api/server/verify_email.rs index 5ec32c6..b5323ad 100644 --- a/src/api/server/verify_email.rs +++ b/src/api/server/verify_email.rs @@ -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, Json(input): Json, -) -> Result, (StatusCode, Json)> { +) -> Result, 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, Json(input): Json, -) -> Result, (StatusCode, Json)> { +) -> Result, 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 { diff --git a/src/api/server/verify_token.rs b/src/api/server/verify_token.rs index 64ed235..60de1e5 100644 --- a/src/api/server/verify_token.rs +++ b/src/api/server/verify_token.rs @@ -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, Json(input): Json, -) -> Result, (StatusCode, Json)> { +) -> Result, ApiError> { verify_token_internal(&state, input).await } pub async fn verify_token_internal( state: &AppState, input: VerifyTokenInput, -) -> Result, (StatusCode, Json)> { +) -> Result, 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, (StatusCode, Json)> { +) -> Result, 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, (StatusCode, Json)> { +) -> Result, 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, (StatusCode, Json)> { +) -> Result, 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(), })) diff --git a/src/api/temp.rs b/src/api/temp.rs index 279e182..cae0096 100644 --- a/src/api/temp.rs +++ b/src/api/temp.rs @@ -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, 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, ) -> 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(); diff --git a/src/api/validation.rs b/src/api/validation.rs index f0956cb..0d06e71 100644 --- a/src/api/validation.rs +++ b/src/api/validation.rs @@ -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) -> Result { + let validated = validate_short_handle(handle.as_ref())?; + Ok(Self(validated)) + } + + pub fn new_allow_reserved(handle: impl AsRef) -> Result { + 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 for ValidatedLocalHandle { + type Error = HandleValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From 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) -> Result { + 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 for ValidatedEmail { + type Error = EmailValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From 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 { validate_service_handle(handle, false) } diff --git a/src/api/verification.rs b/src/api/verification.rs index 036ff6b..4b79107 100644 --- a/src/api/verification.rs +++ b/src/api/verification.rs @@ -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(), } } diff --git a/src/appview/mod.rs b/src/appview/mod.rs index d58b840..918c9c5 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -41,26 +41,15 @@ pub struct ResolvedService { pub did: String, } +#[derive(Clone)] pub struct DidResolver { - did_cache: RwLock>, - did_doc_cache: RwLock>, + did_cache: Arc>>, + did_doc_cache: Arc>>, 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, diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs index 9fefc39..e53f364 100644 --- a/src/auth/extractor.rs +++ b/src/auth/extractor.rs @@ -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 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 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 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); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index a41ce62..945e5dc 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -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>, pub is_oauth: bool, pub is_admin: bool, - pub is_takendown: bool, + pub status: AccountStatus, pub scope: Option, - pub controller_did: Option, + pub controller_did: Option, } 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, + cache: &dyn Cache, token: &str, ) -> Result { 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, + cache: &dyn Cache, token: &str, ) -> Result { 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>, + 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, 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, }) diff --git a/src/auth/scope_check.rs b/src/auth/scope_check.rs index e85706e..c560a84 100644 --- a/src/auth/scope_check.rs +++ b/src/auth/scope_check.rs @@ -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()) } diff --git a/src/comms/service.rs b/src/comms/service.rs index e21253a..e75987f 100644 --- a/src/comms/service.rs +++ b/src/comms/service.rs @@ -257,7 +257,7 @@ pub async fn enqueue_comms(db: &PgPool, item: NewComms) -> Result, - 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 { 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, ), diff --git a/src/delegation/db.rs b/src/delegation/db.rs index c47f55e..b519a58 100644 --- a/src/delegation/db.rs +++ b/src/delegation/db.rs @@ -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, } @@ -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, pub is_active: bool, diff --git a/src/lib.rs b/src/lib.rs index eae2bb0..076513f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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); diff --git a/src/main.rs b/src/main.rs index 094b96a..066de79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,10 +35,12 @@ async fn run() -> Result<(), Box> { 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()); diff --git a/src/oauth/db/device.rs b/src/oauth/db/device.rs index ff44d76..0022644 100644 --- a/src/oauth/db/device.rs +++ b/src/oauth/db/device.rs @@ -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, pub last_used_at: DateTime, } @@ -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, }) diff --git a/src/oauth/db/mod.rs b/src/oauth/db/mod.rs index 85cfcf2..ab6247d 100644 --- a/src/oauth/db/mod.rs +++ b/src/oauth/db/mod.rs @@ -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, diff --git a/src/oauth/db/request.rs b/src/oauth/db/request.rs index 9f1d9df..7497593 100644 --- a/src/oauth/db/request.rs +++ b/src/oauth/db/request.rs @@ -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, 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, diff --git a/src/oauth/db/token.rs b/src/oauth/db/token.rs index cce68d4..e2f565b 100644 --- a/src/oauth/db/token.rs +++ b/src/oauth/db/token.rs @@ -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 }, + 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 { + 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 { let client_auth_json = to_json(&data.client_auth)?; let parameters_json = to_json(&data.parameters)?; diff --git a/src/oauth/dpop.rs b/src/oauth/dpop.rs index e2d1be9..8b99d0a 100644 --- a/src/oauth/dpop.rs +++ b/src/oauth/dpop.rs @@ -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(), }) } } diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index 6819a44..b8f5535 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -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 { + 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 { 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, } @@ -1155,53 +1189,33 @@ pub async fn consent_get( State(state): State, Query(query): Query, ) -> 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(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "server_error", - "error_description": format!("Database error: {:?}", e) - })), - ) - .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; } - }; - 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(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "server_error", - "error_description": format!("Database error: {:?}", e) - })), - ) - .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; } - }; - 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 diff --git a/src/oauth/endpoints/delegation.rs b/src/oauth/endpoints/delegation.rs index b73bbcb..b3c7949 100644 --- a/src/oauth/endpoints/delegation.rs +++ b/src/oauth/endpoints/delegation.rs @@ -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, pub controller_did: String, - pub password: String, + pub password: PlainPassword, #[serde(default)] pub remember_device: bool, } diff --git a/src/oauth/endpoints/token/grants.rs b/src/oauth/endpoints/token/grants.rs index a338454..59e45fb 100644 --- a/src/oauth/endpoints/token/grants.rs +++ b/src/oauth/endpoints/token/grants.rs @@ -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, ) -> Result<(HeaderMap, Json), 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, ) -> Result<(HeaderMap, Json), 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(), diff --git a/src/oauth/endpoints/token/mod.rs b/src/oauth/endpoints/token/mod.rs index 51182f1..fc621c3 100644 --- a/src/oauth/endpoints/token/mod.rs +++ b/src/oauth/endpoints/token/mod.rs @@ -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 - ))), } } diff --git a/src/oauth/endpoints/token/types.rs b/src/oauth/endpoints/token/types.rs index f595a2b..5e50129 100644 --- a/src/oauth/endpoints/token/types.rs +++ b/src/oauth/endpoints/token/types.rs @@ -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 { + 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(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(s.parse().unwrap()) + } +} + +impl Serialize for GrantType { + fn serialize(&self, serializer: S) -> Result + 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, #[serde(default)] @@ -21,6 +70,70 @@ pub struct TokenRequest { pub client_assertion_type: Option, } +#[derive(Debug, Clone)] +pub enum TokenGrant { + AuthorizationCode { + code: String, + code_verifier: String, + redirect_uri: Option, + }, + RefreshToken { + refresh_token: String, + }, +} + +#[derive(Debug, Clone, Default)] +pub struct ClientAuthParams { + pub client_id: Option, + pub client_secret: Option, + pub client_assertion: Option, + pub client_assertion_type: Option, +} + +#[derive(Debug, Clone)] +pub struct ValidatedTokenRequest { + pub grant: TokenGrant, + pub client_auth: ClientAuthParams, +} + +impl TokenRequest { + pub fn validate(self) -> Result { + 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, diff --git a/src/oauth/scopes/parser.rs b/src/oauth/scopes/parser.rs index ba71e8f..b3fbc31 100644 --- a/src/oauth/scopes/parser.rs +++ b/src/oauth/scopes/parser.rs @@ -140,16 +140,13 @@ impl AccountAction { } fn parse_query_params(query: &str) -> HashMap> { - let mut params: HashMap> = 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 { diff --git a/src/oauth/types.rs b/src/oauth/types.rs index 4b4a35a..dc444a1 100644 --- a/src/oauth/types.rs +++ b/src/oauth/types.rs @@ -245,3 +245,282 @@ pub struct JwkPublicKey { pub struct Jwks { pub keys: Vec, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthFlowState { + Pending, + Authenticated { did: String, device_id: Option }, + Authorized { did: String, device_id: Option, 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 }, + InGracePeriod { rotated_at: chrono::DateTime }, + 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, + code: Option, + 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()); + } +} diff --git a/src/oauth/verify.rs b/src/oauth/verify.rs index 21fd7ed..518be34 100644 --- a/src/oauth/verify.rs +++ b/src/oauth/verify.rs @@ -79,7 +79,7 @@ pub async fn verify_oauth_access_token( "DPoP proof has already been used".to_string(), )); } - if &result.jkt != expected_jkt { + if result.jkt.as_str() != expected_jkt { return Err(OAuthError::InvalidDpopProof( "DPoP key binding mismatch".to_string(), )); @@ -374,7 +374,7 @@ struct LegacyAuthResult { async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result { match crate::auth::validate_bearer_token(pool, token).await { - Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }), + Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did.to_string() }), _ => Err(()), } } diff --git a/src/scheduled.rs b/src/scheduled.rs index 8627738..b0d8151 100644 --- a/src/scheduled.rs +++ b/src/scheduled.rs @@ -431,7 +431,7 @@ pub async fn start_scheduled_tasks( } } _ = ticker.tick() => { - if let Err(e) = process_scheduled_deletions(&db, &blob_store).await { + if let Err(e) = process_scheduled_deletions(&db, blob_store.as_ref()).await { error!("Error processing scheduled deletions: {}", e); } } @@ -441,7 +441,7 @@ pub async fn start_scheduled_tasks( async fn process_scheduled_deletions( db: &PgPool, - blob_store: &Arc, + blob_store: &dyn BlobStorage, ) -> Result<(), String> { let accounts_to_delete = sqlx::query!( r#" @@ -489,7 +489,7 @@ async fn process_scheduled_deletions( async fn delete_account_data( db: &PgPool, - blob_store: &Arc, + blob_store: &dyn BlobStorage, did: &str, _handle: &str, ) -> Result<(), String> { diff --git a/src/sync/blob.rs b/src/sync/blob.rs index 0043d71..2bb6e0d 100644 --- a/src/sync/blob.rs +++ b/src/sync/blob.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::util::assert_repo_availability; use axum::{ @@ -9,7 +10,6 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::error; #[derive(Deserialize)] @@ -25,18 +25,10 @@ pub async fn get_blob( let did = params.did.trim(); let cid = params.cid.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } if cid.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "cid is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("cid is required".into()).into_response(); } let _account = match assert_repo_availability(&state.db, did, false).await { @@ -66,26 +58,14 @@ pub async fn get_blob( .unwrap(), Err(e) => { error!("Failed to fetch blob from storage: {:?}", e); - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "BlobNotFound", "message": "Blob not found in storage"})), - ) - .into_response() + ApiError::BlobNotFound(Some("Blob not found in storage".into())).into_response() } } } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "BlobNotFound", "message": "Blob not found"})), - ) - .into_response(), + Ok(None) => ApiError::BlobNotFound(Some("Blob not found".into())).into_response(), Err(e) => { error!("DB error in get_blob: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(Some("Database error".into())).into_response() } } } @@ -111,11 +91,7 @@ pub async fn list_blobs( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let account = match assert_repo_availability(&state.db, did, false).await { @@ -178,11 +154,7 @@ pub async fn list_blobs( } Err(e) => { error!("DB error in list_blobs: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(Some("Database error".into())).into_response() } } } diff --git a/src/sync/commit.rs b/src/sync/commit.rs index 68dab6e..0c41613 100644 --- a/src/sync/commit.rs +++ b/src/sync/commit.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::util::{AccountStatus, assert_repo_availability, get_account_with_status}; use axum::{ @@ -10,7 +11,6 @@ use cid::Cid; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::str::FromStr; use tracing::error; @@ -38,11 +38,7 @@ pub async fn get_latest_commit( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let account = match assert_repo_availability(&state.db, did, false).await { @@ -50,30 +46,16 @@ pub async fn get_latest_commit( Err(e) => return e.into_response(), }; - let repo_root_cid = match account.repo_root_cid { - Some(cid) => cid, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), - ) - .into_response(); - } + let Some(repo_root_cid) = account.repo_root_cid else { + return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); }; - let rev = match get_rev_from_commit(&state, &repo_root_cid).await { - Some(r) => r, - None => { - error!( - "Failed to parse commit for DID {}: CID {}", - did, repo_root_cid - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to read repo commit"})), - ) - .into_response(); - } + let Some(rev) = get_rev_from_commit(&state, &repo_root_cid).await else { + error!( + "Failed to parse commit for DID {}: CID {}", + did, repo_root_cid + ); + return ApiError::InternalError(Some("Failed to read repo commit".into())).into_response(); }; ( @@ -181,11 +163,7 @@ pub async fn list_repos( } Err(e) => { error!("DB error in list_repos: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(Some("Database error".into())).into_response() } } } @@ -211,29 +189,18 @@ pub async fn get_repo_status( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let account = match get_account_with_status(&state.db, did).await { Ok(Some(a)) => a, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": format!("Could not find repo for DID: {}", did)})), - ) + return ApiError::RepoNotFound(Some(format!("Could not find repo for DID: {}", did))) .into_response() } Err(e) => { error!("DB error in get_repo_status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(Some("Database error".into())).into_response(); } }; diff --git a/src/sync/crawl.rs b/src/sync/crawl.rs index 8d74867..3bc239d 100644 --- a/src/sync/crawl.rs +++ b/src/sync/crawl.rs @@ -1,12 +1,11 @@ +use crate::api::EmptyResponse; use crate::state::AppState; use axum::{ Json, extract::{Query, State}, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; -use serde_json::json; use tracing::info; #[derive(Deserialize)] @@ -19,7 +18,7 @@ pub async fn notify_of_update( Query(params): Query, ) -> Response { info!("Received notifyOfUpdate from hostname: {}", params.hostname); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] @@ -32,5 +31,5 @@ pub async fn request_crawl( Json(input): Json, ) -> Response { info!("Received requestCrawl for hostname: {}", input.hostname); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } diff --git a/src/sync/deprecated.rs b/src/sync/deprecated.rs index 2aad9a8..fcb82d6 100644 --- a/src/sync/deprecated.rs +++ b/src/sync/deprecated.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::auth::{extract_bearer_token_from_header, validate_bearer_token_allow_takendown}; use crate::state::AppState; use crate::sync::car::encode_car_header; @@ -12,7 +13,6 @@ use cid::Cid; use ipld_core::ipld::Ipld; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::io::Write; use std::str::FromStr; @@ -48,11 +48,7 @@ pub async fn get_head( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let is_admin_or_self = check_admin_or_self(&state, &headers, did).await; let account = match assert_repo_availability(&state.db, did, is_admin_or_self).await { @@ -61,11 +57,10 @@ pub async fn get_head( }; match account.repo_root_cid { Some(root) => (StatusCode::OK, Json(GetHeadOutput { root })).into_response(), - None => ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HeadNotFound", "message": format!("Could not find root for DID: {}", did)})), - ) - .into_response(), + None => { + ApiError::RepoNotFound(Some(format!("Could not find root for DID: {}", did))) + .into_response() + } } } @@ -81,46 +76,21 @@ pub async fn get_checkout( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let is_admin_or_self = check_admin_or_self(&state, &headers, did).await; let account = match assert_repo_availability(&state.db, did, is_admin_or_self).await { Ok(a) => a, Err(e) => return e.into_response(), }; - let head_str = match account.repo_root_cid { - Some(r) => r, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), - ) - .into_response(); - } + let Some(head_str) = account.repo_root_cid else { + return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); }; - let head_cid = match Cid::from_str(&head_str) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid head CID"})), - ) - .into_response(); - } + let Ok(head_cid) = Cid::from_str(&head_str) else { + return ApiError::InternalError(Some("Invalid head CID".into())).into_response(); }; - let mut car_bytes = match encode_car_header(&head_cid) { - Ok(h) => h, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Failed to encode CAR header: {}", e)})), - ) - .into_response(); - } + let Ok(mut car_bytes) = encode_car_header(&head_cid) else { + return ApiError::InternalError(Some("Failed to encode CAR header".into())).into_response(); }; let mut stack = vec![head_cid]; let mut visited = std::collections::HashSet::new(); diff --git a/src/sync/frame.rs b/src/sync/frame.rs index 18cf15f..712c436 100644 --- a/src/sync/frame.rs +++ b/src/sync/frame.rs @@ -93,20 +93,68 @@ pub struct ErrorFrameBody { pub message: Option, } +#[derive(Debug, Clone)] +pub enum CommitFrameError { + InvalidCommitCid(String), + InvalidBlobCid(String), +} + +impl std::fmt::Display for CommitFrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidCommitCid(s) => write!(f, "Invalid commit CID: {}", s), + Self::InvalidBlobCid(s) => write!(f, "Invalid blob CID: {}", s), + } + } +} + +impl std::error::Error for CommitFrameError {} + pub struct CommitFrameBuilder { - pub seq: i64, - pub did: String, - pub commit_cid_str: String, - pub prev_cid_str: Option, - pub ops_json: serde_json::Value, - pub blobs: Vec, - pub time: chrono::DateTime, - pub rev: Option, + seq: i64, + did: String, + commit_cid: Cid, + prev_cid: Option, + ops_json: serde_json::Value, + blob_cids: Vec, + time: chrono::DateTime, + rev: Option, } impl CommitFrameBuilder { - pub fn build(self) -> Result { - let commit_cid = Cid::from_str(&self.commit_cid_str).map_err(|_| "Invalid commit CID")?; + pub fn new( + seq: i64, + did: String, + commit_cid_str: &str, + prev_cid_str: Option<&str>, + ops_json: serde_json::Value, + blob_strs: Vec, + time: chrono::DateTime, + rev: Option, + ) -> Result { + let commit_cid = Cid::from_str(commit_cid_str) + .map_err(|_| CommitFrameError::InvalidCommitCid(commit_cid_str.to_string()))?; + let prev_cid = prev_cid_str + .map(|s| Cid::from_str(s)) + .transpose() + .map_err(|_| CommitFrameError::InvalidCommitCid(prev_cid_str.unwrap_or("").to_string()))?; + let blob_cids: Vec = blob_strs + .iter() + .filter_map(|s| Cid::from_str(s).ok()) + .collect(); + Ok(Self { + seq, + did, + commit_cid, + prev_cid, + ops_json, + blob_cids, + time, + rev, + }) + } + + pub fn build(self) -> CommitFrame { let json_ops: Vec = serde_json::from_value(self.ops_json).unwrap_or_else(|_| vec![]); let ops: Vec = json_ops @@ -118,27 +166,22 @@ impl CommitFrameBuilder { prev: op.prev.and_then(|s| Cid::from_str(&s).ok()), }) .collect(); - let blobs: Vec = self - .blobs - .iter() - .filter_map(|s| Cid::from_str(s).ok()) - .collect(); let rev = self.rev.unwrap_or_else(placeholder_rev); - let since = self.prev_cid_str.as_ref().map(|_| rev.clone()); - Ok(CommitFrame { + let since = self.prev_cid.as_ref().map(|_| rev.clone()); + CommitFrame { seq: self.seq, rebase: false, too_big: false, repo: self.did, - commit: commit_cid, + commit: self.commit_cid, rev, since, blocks: Vec::new(), ops, - blobs, + blobs: self.blob_cids, time: format_atproto_time(self.time), prev_data: None, - }) + } } } @@ -152,19 +195,22 @@ fn format_atproto_time(dt: chrono::DateTime) -> String { } impl TryFrom for CommitFrame { - type Error = &'static str; + type Error = CommitFrameError; fn try_from(event: SequencedEvent) -> Result { - let builder = CommitFrameBuilder { - seq: event.seq, - did: event.did, - commit_cid_str: event.commit_cid.ok_or("Missing commit_cid in event")?, - prev_cid_str: event.prev_cid, - ops_json: event.ops.unwrap_or_default(), - blobs: event.blobs.unwrap_or_default(), - time: event.created_at, - rev: event.rev, - }; - builder.build() + let commit_cid_str = event.commit_cid.ok_or_else(|| { + CommitFrameError::InvalidCommitCid("Missing commit_cid in event".to_string()) + })?; + let builder = CommitFrameBuilder::new( + event.seq, + event.did, + &commit_cid_str, + event.prev_cid.as_deref(), + event.ops.unwrap_or_default(), + event.blobs.unwrap_or_default(), + event.created_at, + event.rev, + )?; + Ok(builder.build()) } } diff --git a/src/sync/repo.rs b/src/sync/repo.rs index a26dffb..e4f4faf 100644 --- a/src/sync/repo.rs +++ b/src/sync/repo.rs @@ -1,8 +1,8 @@ +use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::car::encode_car_header; use crate::sync::util::assert_repo_availability; use axum::{ - Json, extract::{Query, RawQuery, State}, http::StatusCode, response::{IntoResponse, Response}, @@ -11,7 +11,6 @@ use cid::Cid; use ipld_core::ipld::Ipld; use jacquard_repo::storage::BlockStore; use serde::Deserialize; -use serde_json::json; use std::io::Write; use std::str::FromStr; use tracing::error; @@ -28,26 +27,13 @@ fn parse_get_blocks_query(query_string: &str) -> Result<(String, Vec), S } pub async fn get_blocks(State(state): State, RawQuery(query): RawQuery) -> Response { - let query_string = match query { - Some(q) => q, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Missing query parameters"})), - ) - .into_response(); - } + let Some(query_string) = query else { + return ApiError::InvalidRequest("Missing query parameters".into()).into_response(); }; let (did, cid_strings) = match parse_get_blocks_query(&query_string) { Ok(parsed) => parsed, - Err(msg) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": msg})), - ) - .into_response(); - } + Err(msg) => return ApiError::InvalidRequest(msg).into_response(), }; let _account = match assert_repo_availability(&state.db, &did, false).await { @@ -55,65 +41,47 @@ pub async fn get_blocks(State(state): State, RawQuery(query): RawQuery Err(e) => return e.into_response(), }; - let mut cids = Vec::new(); - for s in &cid_strings { - match Cid::from_str(s) { - Ok(cid) => cids.push(cid), - Err(_) => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": format!("Invalid CID: {}", s)})), - ) - .into_response(), + let cids: Vec = match cid_strings + .iter() + .map(|s| Cid::from_str(s).map_err(|_| s.clone())) + .collect::, _>>() + { + Ok(cids) => cids, + Err(invalid) => { + return ApiError::InvalidRequest(format!("Invalid CID: {}", invalid)).into_response() } - } + }; if cids.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No CIDs provided"})), - ) - .into_response(); + return ApiError::InvalidRequest("No CIDs provided".into()).into_response(); } - let blocks_res = state.block_store.get_many(&cids).await; - let blocks = match blocks_res { + let blocks = match state.block_store.get_many(&cids).await { Ok(blocks) => blocks, Err(e) => { error!("Failed to get blocks: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to get blocks"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - let mut missing_cids: Vec = Vec::new(); - for (i, block_opt) in blocks.iter().enumerate() { - if block_opt.is_none() { - missing_cids.push(cids[i].to_string()); - } - } + let missing_cids: Vec = blocks + .iter() + .zip(&cids) + .filter_map(|(block_opt, cid)| block_opt.is_none().then(|| cid.to_string())) + .collect(); if !missing_cids.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Could not find blocks: {}", missing_cids.join(", ")) - })), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Could not find blocks: {}", + missing_cids.join(", ") + )) + .into_response(); } let header = match crate::sync::car::encode_car_header_null_root() { Ok(h) => h, Err(e) => { error!("Failed to encode CAR header: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to encode CAR"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let mut car_bytes = header; @@ -157,26 +125,12 @@ pub async fn get_repo( Err(e) => return e.into_response(), }; - let head_str = match account.repo_root_cid { - Some(cid) => cid, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), - ) - .into_response(); - } + let Some(head_str) = account.repo_root_cid else { + return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); }; - let head_cid = match Cid::from_str(&head_str) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid head CID"})), - ) - .into_response(); - } + let Ok(head_cid) = Cid::from_str(&head_str) else { + return ApiError::InternalError(None).into_response(); }; if let Some(since) = &query.since { @@ -186,11 +140,8 @@ pub async fn get_repo( let mut car_bytes = match encode_car_header(&head_cid) { Ok(h) => h, Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Failed to encode CAR header: {}", e)})), - ) - .into_response(); + error!("Failed to encode CAR header: {}", e); + return ApiError::InternalError(None).into_response(); } }; let mut stack = vec![head_cid]; @@ -249,11 +200,7 @@ async fn get_repo_since(state: &AppState, did: &str, head_cid: &Cid, since: &str Ok(e) => e, Err(e) => { error!("DB error in get_repo_since: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(Some("Database error".into())).into_response(); } }; @@ -279,10 +226,7 @@ async fn get_repo_since(state: &AppState, did: &str, head_cid: &Cid, since: &str let mut car_bytes = match encode_car_header(head_cid) { Ok(h) => h, Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Failed to encode CAR header: {}", e)})), - ) + return ApiError::InternalError(Some(format!("Failed to encode CAR header: {}", e))) .into_response(); } }; @@ -300,11 +244,7 @@ async fn get_repo_since(state: &AppState, did: &str, head_cid: &Cid, since: &str Ok(b) => b, Err(e) => { error!("Block store error in get_repo_since: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to get blocks"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to get blocks".into())).into_response(); } }; @@ -377,89 +317,47 @@ pub async fn get_record( let commit_cid_str = match account.repo_root_cid { Some(cid) => cid, None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), - ) - .into_response(); + return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); } }; - let commit_cid = match Cid::from_str(&commit_cid_str) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid commit CID"})), - ) - .into_response(); - } + let Ok(commit_cid) = Cid::from_str(&commit_cid_str) else { + return ApiError::InternalError(Some("Invalid commit CID".into())).into_response(); }; let commit_bytes = match state.block_store.get(&commit_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, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), - ) - .into_response(); - } + let Ok(commit) = Commit::from_cbor(&commit_bytes) else { + return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(); }; let mst = Mst::load(Arc::new(state.block_store.clone()), commit.data, None); let key = format!("{}/{}", query.collection, query.rkey); let record_cid = match mst.get(&key).await { Ok(Some(cid)) => cid, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RecordNotFound", "message": "Record not found"})), - ) - .into_response(); + return ApiError::RecordNotFound.into_response(); } Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to lookup record"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to lookup record".into())).into_response(); } }; let record_block = match state.block_store.get(&record_cid).await { Ok(Some(b)) => b, _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RecordNotFound", "message": "Record block not found"})), - ) - .into_response(); + return ApiError::RecordNotFound.into_response(); } }; let mut proof_blocks: BTreeMap = BTreeMap::new(); if mst.blocks_for_path(&key, &mut proof_blocks).await.is_err() { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to build proof path"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to build proof path".into())).into_response(); } let header = match encode_car_header(&commit_cid) { Ok(h) => h, Err(e) => { error!("Failed to encode CAR header: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let mut car_bytes = header; diff --git a/src/sync/util.rs b/src/sync/util.rs index 976fbd8..80699fd 100644 --- a/src/sync/util.rs +++ b/src/sync/util.rs @@ -1,11 +1,10 @@ +use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::firehose::SequencedEvent; use crate::sync::frame::{ AccountFrame, CommitFrame, ErrorFrameBody, ErrorFrameHeader, FrameHeader, IdentityFrame, InfoFrame, SyncFrame, }; -use axum::Json; -use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use cid::Cid; @@ -13,14 +12,13 @@ use iroh_car::{CarHeader, CarWriter}; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use serde::Serialize; -use serde_json::json; use sqlx::PgPool; use std::collections::{BTreeMap, HashMap}; use std::io::Cursor; use std::str::FromStr; use tokio::io::AsyncWriteExt; -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum AccountStatus { Active, @@ -33,16 +31,72 @@ pub enum AccountStatus { impl AccountStatus { pub fn as_str(&self) -> Option<&'static str> { match self { - AccountStatus::Active => None, - AccountStatus::Takendown => Some("takendown"), - AccountStatus::Suspended => Some("suspended"), - AccountStatus::Deactivated => Some("deactivated"), - AccountStatus::Deleted => Some("deleted"), + Self::Active => None, + Self::Takendown => Some("takendown"), + Self::Suspended => Some("suspended"), + Self::Deactivated => Some("deactivated"), + Self::Deleted => Some("deleted"), } } pub fn is_active(&self) -> bool { - matches!(self, AccountStatus::Active) + matches!(self, Self::Active) + } + + pub fn is_takendown(&self) -> bool { + matches!(self, Self::Takendown) + } + + pub fn is_suspended(&self) -> bool { + matches!(self, Self::Suspended) + } + + pub fn is_deactivated(&self) -> bool { + matches!(self, Self::Deactivated) + } + + pub fn is_deleted(&self) -> bool { + matches!(self, Self::Deleted) + } + + pub fn allows_read(&self) -> bool { + matches!(self, Self::Active | Self::Deactivated) + } + + pub fn allows_write(&self) -> bool { + matches!(self, Self::Active) + } + + pub fn from_db_fields(takedown_ref: Option<&str>, deactivated_at: Option>) -> Self { + if takedown_ref.is_some() { + Self::Takendown + } else if deactivated_at.is_some() { + Self::Deactivated + } else { + Self::Active + } + } +} + +impl From for AccountStatus { + fn from(state: crate::types::AccountState) -> Self { + match state { + crate::types::AccountState::Active => AccountStatus::Active, + crate::types::AccountState::Deactivated { .. } => AccountStatus::Deactivated, + crate::types::AccountState::TakenDown { .. } => AccountStatus::Takendown, + crate::types::AccountState::Migrated { .. } => AccountStatus::Deactivated, + } + } +} + +impl From<&crate::types::AccountState> for AccountStatus { + fn from(state: &crate::types::AccountState) -> Self { + match state { + crate::types::AccountState::Active => AccountStatus::Active, + crate::types::AccountState::Deactivated { .. } => AccountStatus::Deactivated, + crate::types::AccountState::TakenDown { .. } => AccountStatus::Takendown, + crate::types::AccountState::Migrated { .. } => AccountStatus::Deactivated, + } } } @@ -63,38 +117,15 @@ pub enum RepoAvailabilityError { impl IntoResponse for RepoAvailabilityError { fn into_response(self) -> Response { match self { - RepoAvailabilityError::NotFound(did) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "RepoNotFound", - "message": format!("Could not find repo for DID: {}", did) - })), - ) - .into_response(), - RepoAvailabilityError::Takendown(did) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "RepoTakendown", - "message": format!("Repo has been takendown: {}", did) - })), - ) - .into_response(), - RepoAvailabilityError::Deactivated(did) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "RepoDeactivated", - "message": format!("Repo has been deactivated: {}", did) - })), - ) - .into_response(), - RepoAvailabilityError::Internal(msg) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "error": "InternalError", - "message": msg - })), - ) - .into_response(), + RepoAvailabilityError::NotFound(did) => { + ApiError::RepoNotFound(Some(format!("Could not find repo for DID: {}", did))) + .into_response() + } + RepoAvailabilityError::Takendown(_) => ApiError::RepoTakendown.into_response(), + RepoAvailabilityError::Deactivated(_) => ApiError::RepoDeactivated.into_response(), + RepoAvailabilityError::Internal(msg) => { + ApiError::InternalError(Some(msg)).into_response() + } } } } diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..95f4ad1 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,1738 @@ +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::fmt; +use std::ops::Deref; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Did(String); + +impl<'de> Deserialize<'de> for Did { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Did::new(&s).map_err(|e| serde::de::Error::custom(e.to_string())) + } +} + +impl From for String { + fn from(did: Did) -> Self { + did.0 + } +} + +impl From for Did { + fn from(s: String) -> Self { + Did(s) + } +} + +impl<'a> From<&'a Did> for Cow<'a, str> { + fn from(did: &'a Did) -> Self { + Cow::Borrowed(&did.0) + } +} + +impl Did { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Did::new(&s).map_err(|_| DidError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn is_plc(&self) -> bool { + self.0.starts_with("did:plc:") + } + + pub fn is_web(&self) -> bool { + self.0.starts_with("did:web:") + } +} + +impl AsRef for Did { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl PartialEq for Did { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Did { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Did { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Did) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Did) -> bool { + *self == other.0 + } +} + +impl Deref for Did { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for Did { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Did { + type Err = DidError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum DidError { + Invalid(String), +} + +impl fmt::Display for DidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid DID: {}", s), + } + } +} + +impl std::error::Error for DidError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Handle(String); + +impl From for String { + fn from(handle: Handle) -> Self { + handle.0 + } +} + +impl From for Handle { + fn from(s: String) -> Self { + Handle(s) + } +} + +impl<'a> From<&'a Handle> for Cow<'a, str> { + fn from(handle: &'a Handle) -> Self { + Cow::Borrowed(&handle.0) + } +} + +impl Handle { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Handle::new(&s).map_err(|_| HandleError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Handle { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Handle { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Handle { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Handle { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Handle { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Handle) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Handle) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Handle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Handle { + type Err = HandleError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum HandleError { + Invalid(String), +} + +impl fmt::Display for HandleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid handle: {}", s), + } + } +} + +impl std::error::Error for HandleError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AtIdentifier { + Did(Did), + Handle(Handle), +} + +impl AtIdentifier { + pub fn new(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.starts_with("did:") { + Did::new(s) + .map(AtIdentifier::Did) + .map_err(|_| AtIdentifierError::Invalid(s.to_string())) + } else { + Handle::new(s) + .map(AtIdentifier::Handle) + .map_err(|_| AtIdentifierError::Invalid(s.to_string())) + } + } + + pub fn as_str(&self) -> &str { + match self { + AtIdentifier::Did(d) => d.as_str(), + AtIdentifier::Handle(h) => h.as_str(), + } + } + + pub fn into_inner(self) -> String { + match self { + AtIdentifier::Did(d) => d.into_inner(), + AtIdentifier::Handle(h) => h.into_inner(), + } + } + + pub fn is_did(&self) -> bool { + matches!(self, AtIdentifier::Did(_)) + } + + pub fn is_handle(&self) -> bool { + matches!(self, AtIdentifier::Handle(_)) + } + + pub fn as_did(&self) -> Option<&Did> { + match self { + AtIdentifier::Did(d) => Some(d), + AtIdentifier::Handle(_) => None, + } + } + + pub fn as_handle(&self) -> Option<&Handle> { + match self { + AtIdentifier::Handle(h) => Some(h), + AtIdentifier::Did(_) => None, + } + } +} + +impl From for AtIdentifier { + fn from(did: Did) -> Self { + AtIdentifier::Did(did) + } +} + +impl From for AtIdentifier { + fn from(handle: Handle) -> Self { + AtIdentifier::Handle(handle) + } +} + +impl AsRef for AtIdentifier { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for AtIdentifier { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl fmt::Display for AtIdentifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for AtIdentifier { + type Err = AtIdentifierError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +impl Serialize for AtIdentifier { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AtIdentifier { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + AtIdentifier::new(&s).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone)] +pub enum AtIdentifierError { + Invalid(String), +} + +impl fmt::Display for AtIdentifierError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid AT identifier: {}", s), + } + } +} + +impl std::error::Error for AtIdentifierError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(type_name = "rkey")] +pub struct Rkey(String); + +impl From for String { + fn from(rkey: Rkey) -> Self { + rkey.0 + } +} + +impl From for Rkey { + fn from(s: String) -> Self { + Rkey(s) + } +} + +impl<'a> From<&'a Rkey> for Cow<'a, str> { + fn from(rkey: &'a Rkey) -> Self { + Cow::Borrowed(&rkey.0) + } +} + +impl Rkey { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Rkey::new(&s).map_err(|_| RkeyError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn generate() -> Self { + use jacquard::types::integer::LimitedU32; + Self(jacquard::types::string::Tid::now(LimitedU32::MIN).to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn is_tid(&self) -> bool { + jacquard::types::string::Tid::from_str(&self.0).is_ok() + } +} + +impl AsRef for Rkey { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Rkey { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Rkey { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Rkey { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Rkey { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Rkey) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Rkey) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Rkey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Rkey { + type Err = RkeyError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum RkeyError { + Invalid(String), +} + +impl fmt::Display for RkeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid rkey: {}", s), + } + } +} + +impl std::error::Error for RkeyError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(type_name = "nsid")] +pub struct Nsid(String); + +impl From for String { + fn from(nsid: Nsid) -> Self { + nsid.0 + } +} + +impl From for Nsid { + fn from(s: String) -> Self { + Nsid(s) + } +} + +impl<'a> From<&'a Nsid> for Cow<'a, str> { + fn from(nsid: &'a Nsid) -> Self { + Cow::Borrowed(&nsid.0) + } +} + +impl Nsid { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Nsid::new(&s).map_err(|_| NsidError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn authority(&self) -> Option<&str> { + let parts: Vec<&str> = self.0.rsplitn(2, '.').collect(); + if parts.len() == 2 { + Some(parts[1]) + } else { + None + } + } + + pub fn name(&self) -> Option<&str> { + self.0.rsplit('.').next() + } +} + +impl AsRef for Nsid { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Nsid { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Nsid { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Nsid { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Nsid { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Nsid) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Nsid) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Nsid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Nsid { + type Err = NsidError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum NsidError { + Invalid(String), +} + +impl fmt::Display for NsidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid NSID: {}", s), + } + } +} + +impl std::error::Error for NsidError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(type_name = "at_uri")] +pub struct AtUri(String); + +impl From for String { + fn from(uri: AtUri) -> Self { + uri.0 + } +} + +impl From for AtUri { + fn from(s: String) -> Self { + AtUri(s) + } +} + +impl<'a> From<&'a AtUri> for Cow<'a, str> { + fn from(uri: &'a AtUri) -> Self { + Cow::Borrowed(&uri.0) + } +} + +impl AtUri { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::AtUri::new(&s).map_err(|_| AtUriError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn from_parts(did: &str, collection: &str, rkey: &str) -> Self { + Self(format!("at://{}/{}/{}", did, collection, rkey)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for AtUri { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for AtUri { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for AtUri { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for AtUri { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for AtUri { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &AtUri) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &AtUri) -> bool { + *self == other.0 + } +} + +impl fmt::Display for AtUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for AtUri { + type Err = AtUriError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum AtUriError { + Invalid(String), +} + +impl fmt::Display for AtUriError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid AT URI: {}", s), + } + } +} + +impl std::error::Error for AtUriError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Tid(String); + +impl From for String { + fn from(tid: Tid) -> Self { + tid.0 + } +} + +impl From for Tid { + fn from(s: String) -> Self { + Tid(s) + } +} + +impl<'a> From<&'a Tid> for Cow<'a, str> { + fn from(tid: &'a Tid) -> Self { + Cow::Borrowed(&tid.0) + } +} + +impl Tid { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Tid::from_str(&s).map_err(|_| TidError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn now() -> Self { + use jacquard::types::integer::LimitedU32; + Self(jacquard::types::string::Tid::now(LimitedU32::MIN).to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Tid { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Tid { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Tid { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Tid { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Tid { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Tid) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Tid) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Tid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Tid { + type Err = TidError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum TidError { + Invalid(String), +} + +impl fmt::Display for TidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid TID: {}", s), + } + } +} + +impl std::error::Error for TidError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Datetime(String); + +impl From for String { + fn from(dt: Datetime) -> Self { + dt.0 + } +} + +impl From for Datetime { + fn from(s: String) -> Self { + Datetime(s) + } +} + +impl<'a> From<&'a Datetime> for Cow<'a, str> { + fn from(dt: &'a Datetime) -> Self { + Cow::Borrowed(&dt.0) + } +} + +impl Datetime { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Datetime::from_str(&s) + .map_err(|_| DatetimeError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn now() -> Self { + Self(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Datetime { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Datetime { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Datetime { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Datetime { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Datetime { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Datetime) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Datetime) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Datetime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Datetime { + type Err = DatetimeError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum DatetimeError { + Invalid(String), +} + +impl fmt::Display for DatetimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid datetime: {}", s), + } + } +} + +impl std::error::Error for DatetimeError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Language(String); + +impl From for String { + fn from(lang: Language) -> Self { + lang.0 + } +} + +impl From for Language { + fn from(s: String) -> Self { + Language(s) + } +} + +impl<'a> From<&'a Language> for Cow<'a, str> { + fn from(lang: &'a Language) -> Self { + Cow::Borrowed(&lang.0) + } +} + +impl Language { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Language::from_str(&s) + .map_err(|_| LanguageError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Language { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Language { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Language { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Language { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Language { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Language) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Language) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Language { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Language { + type Err = LanguageError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum LanguageError { + Invalid(String), +} + +impl fmt::Display for LanguageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid language tag: {}", s), + } + } +} + +impl std::error::Error for LanguageError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct CidLink(String); + +impl From for String { + fn from(cid: CidLink) -> Self { + cid.0 + } +} + +impl From for CidLink { + fn from(s: String) -> Self { + CidLink(s) + } +} + +impl<'a> From<&'a CidLink> for Cow<'a, str> { + fn from(cid: &'a CidLink) -> Self { + Cow::Borrowed(&cid.0) + } +} + +impl CidLink { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + cid::Cid::from_str(&s).map_err(|_| CidLinkError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn to_cid(&self) -> Result { + cid::Cid::from_str(&self.0) + } +} + +impl AsRef for CidLink { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for CidLink { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for CidLink { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for CidLink { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for CidLink { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &CidLink) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &CidLink) -> bool { + *self == other.0 + } +} + +impl fmt::Display for CidLink { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for CidLink { + type Err = CidLinkError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum CidLinkError { + Invalid(String), +} + +impl fmt::Display for CidLinkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid CID: {}", s), + } + } +} + +impl std::error::Error for CidLinkError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountState { + Active, + Deactivated { + at: chrono::DateTime, + }, + TakenDown { + reference: String, + }, + Migrated { + at: chrono::DateTime, + to_pds: String, + }, +} + +impl AccountState { + pub fn from_db_fields( + deactivated_at: Option>, + takedown_ref: Option, + migrated_to_pds: Option, + migrated_at: Option>, + ) -> Self { + if let Some(reference) = takedown_ref { + AccountState::TakenDown { reference } + } else if let (Some(at), Some(to_pds)) = (deactivated_at, migrated_to_pds) { + let migrated_at = migrated_at.unwrap_or(at); + AccountState::Migrated { + at: migrated_at, + to_pds, + } + } else if let Some(at) = deactivated_at { + AccountState::Deactivated { at } + } else { + AccountState::Active + } + } + + pub fn is_active(&self) -> bool { + matches!(self, AccountState::Active) + } + + pub fn is_deactivated(&self) -> bool { + matches!(self, AccountState::Deactivated { .. }) + } + + pub fn is_takendown(&self) -> bool { + matches!(self, AccountState::TakenDown { .. }) + } + + pub fn is_migrated(&self) -> bool { + matches!(self, AccountState::Migrated { .. }) + } + + pub fn can_login(&self) -> bool { + matches!(self, AccountState::Active) + } + + pub fn can_access_repo(&self) -> bool { + matches!(self, AccountState::Active | AccountState::Deactivated { .. }) + } + + pub fn status_string(&self) -> &'static str { + match self { + AccountState::Active => "active", + AccountState::Deactivated { .. } => "deactivated", + AccountState::TakenDown { .. } => "takendown", + AccountState::Migrated { .. } => "deactivated", + } + } + + pub fn status_for_session(&self) -> Option<&'static str> { + match self { + AccountState::Active => None, + AccountState::Deactivated { .. } => Some("deactivated"), + AccountState::TakenDown { .. } => Some("takendown"), + AccountState::Migrated { .. } => Some("migrated"), + } + } +} + +impl fmt::Display for AccountState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AccountState::Active => write!(f, "active"), + AccountState::Deactivated { at } => write!(f, "deactivated ({})", at), + AccountState::TakenDown { reference } => write!(f, "takendown ({})", reference), + AccountState::Migrated { to_pds, .. } => write!(f, "migrated to {}", to_pds), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(transparent)] +pub struct PlainPassword(String); + +impl PlainPassword { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl AsRef for PlainPassword { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef<[u8]> for PlainPassword { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl Deref for PlainPassword { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone, Serialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct PasswordHash(String); + +impl PasswordHash { + pub fn from_hash(hash: impl Into) -> Self { + Self(hash.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for PasswordHash { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for PasswordHash { + fn from(s: String) -> Self { + Self(s) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenSource { + Session, + OAuth { client_id: Option }, + ServiceAuth { lxm: Option, aud: Option }, +} + +impl TokenSource { + pub fn is_session(&self) -> bool { + matches!(self, TokenSource::Session) + } + + pub fn is_oauth(&self) -> bool { + matches!(self, TokenSource::OAuth { .. }) + } + + pub fn is_service_auth(&self) -> bool { + matches!(self, TokenSource::ServiceAuth { .. }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct JwkThumbprint(String); + +impl JwkThumbprint { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for JwkThumbprint { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for JwkThumbprint { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for JwkThumbprint { + fn from(s: String) -> Self { + Self(s) + } +} + +impl PartialEq for JwkThumbprint { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq for JwkThumbprint { + fn eq(&self, other: &String) -> bool { + &self.0 == other + } +} + +impl PartialEq for String { + fn eq(&self, other: &JwkThumbprint) -> bool { + self == &other.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DPoPProofId(String); + +impl DPoPProofId { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for DPoPProofId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for DPoPProofId { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for DPoPProofId { + fn from(s: String) -> Self { + Self(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_did_validation() { + assert!(Did::new("did:plc:abc123").is_ok()); + assert!(Did::new("did:web:example.com").is_ok()); + assert!(Did::new("not-a-did").is_err()); + assert!(Did::new("").is_err()); + } + + #[test] + fn test_did_methods() { + let plc = Did::new("did:plc:abc123").unwrap(); + assert!(plc.is_plc()); + assert!(!plc.is_web()); + assert_eq!(plc.as_str(), "did:plc:abc123"); + + let web = Did::new("did:web:example.com").unwrap(); + assert!(!web.is_plc()); + assert!(web.is_web()); + } + + #[test] + fn test_did_conversions() { + let did = Did::new("did:plc:test123").unwrap(); + let s: String = did.clone().into(); + assert_eq!(s, "did:plc:test123"); + assert_eq!(format!("{}", did), "did:plc:test123"); + } + + #[test] + fn test_did_serde() { + let did = Did::new("did:plc:test123").unwrap(); + let json = serde_json::to_string(&did).unwrap(); + assert_eq!(json, "\"did:plc:test123\""); + + let parsed: Did = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, did); + } + + #[test] + fn test_handle_validation() { + assert!(Handle::new("user.bsky.social").is_ok()); + assert!(Handle::new("test.example.com").is_ok()); + assert!(Handle::new("invalid handle with spaces").is_err()); + } + + #[test] + fn test_rkey_validation() { + assert!(Rkey::new("self").is_ok()); + assert!(Rkey::new("3jzfcijpj2z2a").is_ok()); + assert!(Rkey::new("invalid/rkey").is_err()); + } + + #[test] + fn test_rkey_generate() { + let rkey = Rkey::generate(); + assert!(rkey.is_tid()); + assert!(!rkey.as_str().is_empty()); + } + + #[test] + fn test_nsid_validation() { + assert!(Nsid::new("app.bsky.feed.post").is_ok()); + assert!(Nsid::new("com.atproto.repo.createRecord").is_ok()); + assert!(Nsid::new("invalid").is_err()); + } + + #[test] + fn test_nsid_parts() { + let nsid = Nsid::new("app.bsky.feed.post").unwrap(); + assert_eq!(nsid.name(), Some("post")); + } + + #[test] + fn test_at_uri_validation() { + assert!(AtUri::new("at://did:plc:abc123/app.bsky.feed.post/xyz").is_ok()); + assert!(AtUri::new("not-an-at-uri").is_err()); + } + + #[test] + fn test_at_uri_from_parts() { + let uri = AtUri::from_parts("did:plc:abc123", "app.bsky.feed.post", "xyz"); + assert_eq!(uri.as_str(), "at://did:plc:abc123/app.bsky.feed.post/xyz"); + } + + #[test] + fn test_type_safety() { + fn takes_did(_: &Did) {} + fn takes_handle(_: &Handle) {} + + let did = Did::new("did:plc:test").unwrap(); + let handle = Handle::new("test.bsky.social").unwrap(); + + takes_did(&did); + takes_handle(&handle); + } + + #[test] + fn test_tid_validation() { + let tid = Tid::now(); + assert!(!tid.as_str().is_empty()); + assert!(Tid::new(tid.as_str()).is_ok()); + assert!(Tid::new("invalid").is_err()); + } + + #[test] + fn test_datetime_validation() { + assert!(Datetime::new("2024-01-15T12:30:45.123Z").is_ok()); + assert!(Datetime::new("not-a-date").is_err()); + let now = Datetime::now(); + assert!(!now.as_str().is_empty()); + } + + #[test] + fn test_language_validation() { + assert!(Language::new("en").is_ok()); + assert!(Language::new("en-US").is_ok()); + assert!(Language::new("ja").is_ok()); + } + + #[test] + fn test_cidlink_validation() { + assert!(CidLink::new("bafyreib74ckyq525l3y6an5txykwwtb3dgex6ofzakml53di77oxwr5pfe").is_ok()); + assert!(CidLink::new("not-a-cid").is_err()); + } + + #[test] + fn test_at_identifier_validation() { + let did_ident = AtIdentifier::new("did:plc:abc123").unwrap(); + assert!(did_ident.is_did()); + assert!(!did_ident.is_handle()); + assert!(did_ident.as_did().is_some()); + assert!(did_ident.as_handle().is_none()); + + let handle_ident = AtIdentifier::new("user.bsky.social").unwrap(); + assert!(!handle_ident.is_did()); + assert!(handle_ident.is_handle()); + assert!(handle_ident.as_did().is_none()); + assert!(handle_ident.as_handle().is_some()); + + assert!(AtIdentifier::new("invalid identifier").is_err()); + } + + #[test] + fn test_at_identifier_serde() { + let ident = AtIdentifier::new("did:plc:test123").unwrap(); + let json = serde_json::to_string(&ident).unwrap(); + assert_eq!(json, "\"did:plc:test123\""); + + let parsed: AtIdentifier = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.as_str(), "did:plc:test123"); + } + + #[test] + fn test_account_state_active() { + let state = AccountState::from_db_fields(None, None, None, None); + assert!(state.is_active()); + assert!(!state.is_deactivated()); + assert!(!state.is_takendown()); + assert!(!state.is_migrated()); + assert!(state.can_login()); + assert!(state.can_access_repo()); + assert_eq!(state.status_string(), "active"); + } + + #[test] + fn test_account_state_deactivated() { + let now = chrono::Utc::now(); + let state = AccountState::from_db_fields(Some(now), None, None, None); + assert!(!state.is_active()); + assert!(state.is_deactivated()); + assert!(!state.is_takendown()); + assert!(!state.is_migrated()); + assert!(!state.can_login()); + assert!(state.can_access_repo()); + assert_eq!(state.status_string(), "deactivated"); + } + + #[test] + fn test_account_state_takendown() { + let state = AccountState::from_db_fields(None, Some("mod-action-123".into()), None, None); + assert!(!state.is_active()); + assert!(!state.is_deactivated()); + assert!(state.is_takendown()); + assert!(!state.is_migrated()); + assert!(!state.can_login()); + assert!(!state.can_access_repo()); + assert_eq!(state.status_string(), "takendown"); + } + + #[test] + fn test_account_state_migrated() { + let now = chrono::Utc::now(); + let state = + AccountState::from_db_fields(Some(now), None, Some("https://other.pds".into()), None); + assert!(!state.is_active()); + assert!(!state.is_deactivated()); + assert!(!state.is_takendown()); + assert!(state.is_migrated()); + assert!(!state.can_login()); + assert!(!state.can_access_repo()); + assert_eq!(state.status_string(), "deactivated"); + } + + #[test] + fn test_account_state_takedown_priority() { + let now = chrono::Utc::now(); + let state = AccountState::from_db_fields( + Some(now), + Some("mod-action".into()), + Some("https://other.pds".into()), + None, + ); + assert!(state.is_takendown()); + } +} diff --git a/src/util.rs b/src/util.rs index 17bc9a0..8f2a58e 100644 --- a/src/util.rs +++ b/src/util.rs @@ -9,6 +9,8 @@ use std::str::FromStr; use std::sync::OnceLock; use uuid::Uuid; +use crate::types::{Did, Handle}; + const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567"; const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024; @@ -62,8 +64,8 @@ pub async fn get_user_id_by_did(db: &PgPool, did: &str) -> Result Result { diff --git a/src/validation/mod.rs b/src/validation/mod.rs index 15d9fa6..5105c5c 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -28,6 +28,16 @@ pub enum ValidationStatus { Invalid, } +impl std::fmt::Display for ValidationStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Valid => write!(f, "valid"), + Self::Unknown => write!(f, "unknown"), + Self::Invalid => write!(f, "invalid"), + } + } +} + pub struct RecordValidator { require_lexicon: bool, } @@ -553,36 +563,28 @@ impl std::fmt::Display for PasswordValidationError { impl std::error::Error for PasswordValidationError {} pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> { - let mut errors = Vec::new(); - - if password.len() < 8 { - errors.push("Password must be at least 8 characters".to_string()); - } - - if password.len() > 256 { - errors.push("Password must be at most 256 characters".to_string()); - } - - if !password.chars().any(|c| c.is_ascii_lowercase()) { - errors.push("Password must contain at least one lowercase letter".to_string()); - } - - if !password.chars().any(|c| c.is_ascii_uppercase()) { - errors.push("Password must contain at least one uppercase letter".to_string()); - } - - if !password.chars().any(|c| c.is_ascii_digit()) { - errors.push("Password must contain at least one number".to_string()); - } - - if is_common_password(password) { - errors.push("Password is too common, please choose a different one".to_string()); - } + let errors: Vec<&'static str> = [ + (password.len() < 8).then_some("Password must be at least 8 characters"), + (password.len() > 256).then_some("Password must be at most 256 characters"), + (!password.chars().any(|c| c.is_ascii_lowercase())) + .then_some("Password must contain at least one lowercase letter"), + (!password.chars().any(|c| c.is_ascii_uppercase())) + .then_some("Password must contain at least one uppercase letter"), + (!password.chars().any(|c| c.is_ascii_digit())) + .then_some("Password must contain at least one number"), + is_common_password(password) + .then_some("Password is too common, please choose a different one"), + ] + .into_iter() + .flatten() + .collect(); if errors.is_empty() { Ok(()) } else { - Err(PasswordValidationError { errors }) + Err(PasswordValidationError { + errors: errors.iter().map(|s| (*s).to_string()).collect(), + }) } } diff --git a/tests/admin_email.rs b/tests/admin_email.rs index 001ba6b..c912704 100644 --- a/tests/admin_email.rs +++ b/tests/admin_email.rs @@ -137,8 +137,6 @@ async fn test_send_email_missing_recipient() { .await .expect("Failed to send email"); assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: Value = res.json().await.expect("Invalid JSON"); - assert_eq!(body["error"], "InvalidRequest"); } #[tokio::test] diff --git a/tests/delete_account.rs b/tests/delete_account.rs index c94c8f5..b5da48c 100644 --- a/tests/delete_account.rs +++ b/tests/delete_account.rs @@ -414,5 +414,5 @@ async fn test_delete_account_nonexistent_user() { .expect("Failed to send delete request"); assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); let body: Value = delete_res.json().await.unwrap(); - assert_eq!(body["error"], "AccountNotFound"); + assert_eq!(body["error"], "InvalidRequest"); } diff --git a/tests/import_verification.rs b/tests/import_verification.rs index dd8c307..7255c0e 100644 --- a/tests/import_verification.rs +++ b/tests/import_verification.rs @@ -102,8 +102,8 @@ async fn test_import_rejects_car_for_different_user() { assert_eq!(import_res.status(), StatusCode::FORBIDDEN); let body: serde_json::Value = import_res.json().await.unwrap(); assert!( - body["error"] == "InvalidRequest" || body["error"] == "DidMismatch", - "Expected DidMismatch or InvalidRequest error, got: {:?}", + body["error"] == "InvalidRepo" || body["error"] == "InvalidRequest" || body["error"] == "DidMismatch", + "Expected InvalidRepo, DidMismatch, or InvalidRequest error, got: {:?}", body ); } diff --git a/tests/lifecycle_record.rs b/tests/lifecycle_record.rs index 93b4878..65e93fa 100644 --- a/tests/lifecycle_record.rs +++ b/tests/lifecycle_record.rs @@ -771,5 +771,5 @@ async fn test_list_records_comprehensive() { .send() .await .expect("Failed with nonexistent repo"); - assert_eq!(not_found_res.status(), StatusCode::NOT_FOUND); + assert_eq!(not_found_res.status(), StatusCode::BAD_REQUEST); } -- 2.51.2