this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+1617 -1935
.sqlx
crates
-22
.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc" 22 - }
···
-15
.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET deactivated_at = $1 WHERE did = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Timestamptz", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107" 15 - }
···
+15
.sqlx/query-0c5ef3ffbd4d540dbd4ea993ea4af292977d35e0aed9bcc887b394f04468e2d7.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO backup_codes (did, code_hash, created_at)\n SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "TextArray" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "0c5ef3ffbd4d540dbd4ea993ea4af292977d35e0aed9bcc887b394f04468e2d7" 15 + }
-22
.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "body", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99" 22 - }
···
+16
.sqlx/query-2232b75368a91a61256976ddb659523f041b3faa3075cc61c850c1f31f7c4d78.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO record_blobs (repo_id, record_uri, blob_cid)\n SELECT $1, * FROM UNNEST($2::text[], $3::text[])\n ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "TextArray", 10 + "TextArray" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "2232b75368a91a61256976ddb659523f041b3faa3075cc61c850c1f31f7c4d78" 16 + }
-22
.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "count", 9 - "type_info": "Int8" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6" 22 - }
···
-16
.sqlx/query-297e5495004fa601f86b3ada9e512815d4b7d73aacf3f3654628c93e5db8b791.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO record_blobs (repo_id, record_uri, blob_cid)\n VALUES ($1, $2, $3)\n ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid", 9 - "Text", 10 - "Text" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "297e5495004fa601f86b3ada9e512815d4b7d73aacf3f3654628c93e5db8b791" 16 - }
···
-14
.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5" 14 - }
···
+52
.sqlx/query-2c8868a59ae63dc65d996cf21fc1bec0c2c86d5d5f17d1454440c6fcd8d4d27a.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by\n FROM invite_codes ic\n JOIN users u ON ic.created_by_user = u.id\n WHERE ic.created_by_user = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "available_uses", 14 + "type_info": "Int4" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "disabled", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "for_account", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "created_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "created_by", 34 + "type_info": "Text" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Uuid" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + true, 46 + false, 47 + false, 48 + false 49 + ] 50 + }, 51 + "hash": "2c8868a59ae63dc65d996cf21fc1bec0c2c86d5d5f17d1454440c6fcd8d4d27a" 52 + }
-18
.sqlx/query-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),\n previous_refresh_token = $5, rotated_at = NOW()\n WHERE id = $1\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Int4", 9 - "Text", 10 - "Text", 11 - "Timestamptz", 12 - "Text" 13 - ] 14 - }, 15 - "nullable": [] 16 - }, 17 - "hash": "2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f" 18 - }
···
-14
.sqlx/query-413c5b03501a399dca13f345fcae05770517091d73db93966853e944c68ee237.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Uuid" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "413c5b03501a399dca13f345fcae05770517091d73db93966853e944c68ee237" 14 - }
···
-54
.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "subject", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "body", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "comms_type: String", 19 - "type_info": { 20 - "Custom": { 21 - "name": "comms_type", 22 - "kind": { 23 - "Enum": [ 24 - "welcome", 25 - "email_verification", 26 - "password_reset", 27 - "email_update", 28 - "account_deletion", 29 - "admin_email", 30 - "plc_operation", 31 - "two_factor_code", 32 - "channel_verification", 33 - "passkey_recovery", 34 - "legacy_login_alert", 35 - "migration_verification" 36 - ] 37 - } 38 - } 39 - } 40 - } 41 - ], 42 - "parameters": { 43 - "Left": [ 44 - "Uuid" 45 - ] 46 - }, 47 - "nullable": [ 48 - true, 49 - false, 50 - false 51 - ] 52 - }, 53 - "hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe" 54 - }
···
-22
.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id FROM users WHERE email = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068" 22 - }
···
-28
.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "body", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "metadata", 14 - "type_info": "Jsonb" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - true 25 - ] 26 - }, 27 - "hash": "4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a" 28 - }
···
+28
.sqlx/query-46ea5ceff2a8f3f2b72c9c6a1bb69ce28efe8594fda026b6f9b298ef0597b40e.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, did FROM users WHERE id = ANY($1)", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "UuidArray" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "46ea5ceff2a8f3f2b72c9c6a1bb69ce28efe8594fda026b6f9b298ef0597b40e" 28 + }
-28
.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "expires_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88" 28 - }
···
-22
.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT email_verified FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email_verified", 9 - "type_info": "Bool" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6" 22 - }
···
-17
.sqlx/query-59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Int4", 10 - "Uuid", 11 - "Text" 12 - ] 13 - }, 14 - "nullable": [] 15 - }, 16 - "hash": "59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de" 17 - }
···
-28
.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "key_bytes", 9 - "type_info": "Bytea" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "encryption_version", 14 - "type_info": "Int4" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - true 25 - ] 26 - }, 27 - "hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0" 28 - }
···
-22
.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "body", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45" 22 - }
···
-22
.sqlx/query-5a98e015997942835800fcd326e69b4f54b9830d0490c4f8841f8435478c57d3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT code FROM invite_codes WHERE created_by_user = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "code", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "5a98e015997942835800fcd326e69b4f54b9830d0490c4f8841f8435478c57d3" 22 - }
···
-28
.sqlx/query-5d5442136932d4088873a935c41cb3a683c4771e4fb8c151b3fd5119fb6c1068.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT u.did, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "used_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "5d5442136932d4088873a935c41cb3a683c4771e4fb8c151b3fd5119fb6c1068" 28 - }
···
+17
.sqlx/query-6830cc85b246f5127419b0ed58f81d8ffee3806a3077281828f4bd2b8dfa7628.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)\n SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "TextArray", 9 + "Int4", 10 + "Uuid", 11 + "Text" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "6830cc85b246f5127419b0ed58f81d8ffee3806a3077281828f4bd2b8dfa7628" 17 + }
-34
.sqlx/query-6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT u.did, u.handle, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "handle", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "used_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - false 31 - ] 32 - }, 33 - "hash": "6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47" 34 - }
···
+34
.sqlx/query-779f30b9db69294997c00bc446918b3141a67c64758823256b1da11fd9e9480b.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT u.did, u.handle, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "used_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "779f30b9db69294997c00bc446918b3141a67c64758823256b1da11fd9e9480b" 34 + }
-22
.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "subject", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f" 22 - }
···
-14
.sqlx/query-7b2d1d4ac06063e07a7c7a7d0fb434db08ce312eb2864405d7f96f4e985ed036.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE invite_codes SET disabled = TRUE WHERE code = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "7b2d1d4ac06063e07a7c7a7d0fb434db08ce312eb2864405d7f96f4e985ed036" 14 - }
···
-22
.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631" 22 - }
···
-28
.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "expires_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81" 28 - }
···
+14
.sqlx/query-888f8724cfc2ed27391b661a5cfe423d28c77e1a368df7edc81708eb3038f600.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "TextArray" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "888f8724cfc2ed27391b661a5cfe423d28c77e1a368df7edc81708eb3038f600" 14 + }
-22
.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "body", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5" 22 - }
···
-28
.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "public_key_did_key", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - true, 24 - false 25 - ] 26 - }, 27 - "hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002" 28 - }
···
-108
.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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 ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "user_id", 14 - "type_info": "Uuid" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "recipient", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "subject", 24 - "type_info": "Text" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "body", 29 - "type_info": "Text" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "channel: CommsChannel", 34 - "type_info": { 35 - "Custom": { 36 - "name": "comms_channel", 37 - "kind": { 38 - "Enum": [ 39 - "email", 40 - "discord", 41 - "telegram", 42 - "signal" 43 - ] 44 - } 45 - } 46 - } 47 - }, 48 - { 49 - "ordinal": 6, 50 - "name": "comms_type: CommsType", 51 - "type_info": { 52 - "Custom": { 53 - "name": "comms_type", 54 - "kind": { 55 - "Enum": [ 56 - "welcome", 57 - "email_verification", 58 - "password_reset", 59 - "email_update", 60 - "account_deletion", 61 - "admin_email", 62 - "plc_operation", 63 - "two_factor_code", 64 - "channel_verification", 65 - "passkey_recovery", 66 - "legacy_login_alert", 67 - "migration_verification" 68 - ] 69 - } 70 - } 71 - } 72 - }, 73 - { 74 - "ordinal": 7, 75 - "name": "status: CommsStatus", 76 - "type_info": { 77 - "Custom": { 78 - "name": "comms_status", 79 - "kind": { 80 - "Enum": [ 81 - "pending", 82 - "processing", 83 - "sent", 84 - "failed" 85 - ] 86 - } 87 - } 88 - } 89 - } 90 - ], 91 - "parameters": { 92 - "Left": [ 93 - "Uuid" 94 - ] 95 - }, 96 - "nullable": [ 97 - false, 98 - false, 99 - false, 100 - true, 101 - false, 102 - false, 103 - false, 104 - false 105 - ] 106 - }, 107 - "hash": "9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe" 108 - }
···
-34
.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "private_key_bytes", 9 - "type_info": "Bytea" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "expires_at", 14 - "type_info": "Timestamptz" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "used_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - true 31 - ] 32 - }, 33 - "hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5" 34 - }
···
-22
.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT token FROM account_deletion_requests WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5" 22 - }
···
+17
.sqlx/query-ab5e6c5bc904ae54f8c559f6e1c26f8293851815a1b4666a093750fe249626b6.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE oauth_token\n SET current_refresh_token = $2, expires_at = $3, updated_at = NOW(),\n previous_refresh_token = $4, rotated_at = NOW()\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Int4", 9 + "Text", 10 + "Timestamptz", 11 + "Text" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "ab5e6c5bc904ae54f8c559f6e1c26f8293851815a1b4666a093750fe249626b6" 17 + }
+34
.sqlx/query-ae6695ae53fc5e5293f17ddf8cc4532d549d1ad8a9835da4a5c001eee89db076.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT icu.code, u.did as used_by, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = ANY($1)\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "used_by", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "used_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "TextArray" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "ae6695ae53fc5e5293f17ddf8cc4532d549d1ad8a9835da4a5c001eee89db076" 34 + }
-60
.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT\n recipient, subject, body,\n comms_type as \"comms_type: CommsType\"\n FROM comms_queue\n WHERE id = $1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "recipient", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "subject", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "body", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "comms_type: CommsType", 24 - "type_info": { 25 - "Custom": { 26 - "name": "comms_type", 27 - "kind": { 28 - "Enum": [ 29 - "welcome", 30 - "email_verification", 31 - "password_reset", 32 - "email_update", 33 - "account_deletion", 34 - "admin_email", 35 - "plc_operation", 36 - "two_factor_code", 37 - "channel_verification", 38 - "passkey_recovery", 39 - "legacy_login_alert", 40 - "migration_verification" 41 - ] 42 - } 43 - } 44 - } 45 - } 46 - ], 47 - "parameters": { 48 - "Left": [ 49 - "Uuid" 50 - ] 51 - }, 52 - "nullable": [ 53 - false, 54 - true, 55 - false, 56 - false 57 - ] 58 - }, 59 - "hash": "b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5" 60 - }
···
-22
.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT password_reset_code FROM users WHERE email = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "password_reset_code", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee" 22 - }
···
-22
.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "count!", 9 - "type_info": "Int8" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3" 22 - }
···
-14
.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4" 14 - }
···
-22
.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "count", 9 - "type_info": "Int8" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3" 22 - }
···
-22
.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "used_at", 9 - "type_info": "Timestamptz" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9" 22 - }
···
-15
.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d" 15 - }
···
+34
.sqlx/query-ed1ccbaaed6e3f34982dc118ddd9bde7269879c0547ad43f30b78bfeeef5a920.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT icu.code, u.did, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = ANY($1)\n ORDER BY icu.used_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "code", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "used_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "TextArray" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "ed1ccbaaed6e3f34982dc118ddd9bde7269879c0547ad43f30b78bfeeef5a920" 34 + }
+14
.sqlx/query-eec42a3a4b1440aa8dd580b5d0bbd1184b909f781d131aa2c69368ed021e87e4.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user IN (SELECT id FROM users WHERE did = ANY($1))", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "TextArray" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "eec42a3a4b1440aa8dd580b5d0bbd1184b909f781d131aa2c69368ed021e87e4" 14 + }
-22
.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT email FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7" 22 - }
···
-14
.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET is_admin = TRUE WHERE did = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814" 14 - }
···
-28
.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "password_reset_code", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "password_reset_code_expires_at", 14 - "type_info": "Timestamptz" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - true, 24 - true 25 - ] 26 - }, 27 - "hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382" 28 - }
···
+15
Cargo.lock
··· 776 checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" 777 dependencies = [ 778 "fastrand", 779 ] 780 781 [[package]] ··· 2295 "thiserror 1.0.69", 2296 "wasm-bindgen", 2297 "web-sys", 2298 ] 2299 2300 [[package]] ··· 6427 "aws-config", 6428 "aws-sdk-s3", 6429 "axum", 6430 "base32", 6431 "base64 0.22.1", 6432 "bcrypt",
··· 776 checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" 777 dependencies = [ 778 "fastrand", 779 + "gloo-timers", 780 + "tokio", 781 ] 782 783 [[package]] ··· 2297 "thiserror 1.0.69", 2298 "wasm-bindgen", 2299 "web-sys", 2300 + ] 2301 + 2302 + [[package]] 2303 + name = "gloo-timers" 2304 + version = "0.3.0" 2305 + source = "registry+https://github.com/rust-lang/crates.io-index" 2306 + checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" 2307 + dependencies = [ 2308 + "futures-channel", 2309 + "futures-core", 2310 + "js-sys", 2311 + "wasm-bindgen", 2312 ] 2313 2314 [[package]] ··· 6441 "aws-config", 6442 "aws-sdk-s3", 6443 "axum", 6444 + "backon", 6445 "base32", 6446 "base64 0.22.1", 6447 "bcrypt",
+2 -1
Cargo.toml
··· 32 tranquil-comms = { path = "crates/tranquil-comms" } 33 34 aes-gcm = "0.10" 35 anyhow = "1.0" 36 async-trait = "0.1" 37 aws-config = "1.8" ··· 91 tracing = "0.1" 92 tracing-subscriber = "0.3" 93 urlencoding = "2.1" 94 - uuid = { version = "1.19", features = ["v4", "v5", "fast-rng"] } 95 webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 96 webauthn-rs-proto = "0.5" 97 zip = { version = "7.0", default-features = false, features = ["deflate"] }
··· 32 tranquil-comms = { path = "crates/tranquil-comms" } 33 34 aes-gcm = "0.10" 35 + backon = "1" 36 anyhow = "1.0" 37 async-trait = "0.1" 38 aws-config = "1.8" ··· 92 tracing = "0.1" 93 tracing-subscriber = "0.3" 94 urlencoding = "2.1" 95 + uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng"] } 96 webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 97 webauthn-rs-proto = "0.5" 98 zip = { version = "7.0", default-features = false, features = ["deflate"] }
+4 -5
crates/tranquil-comms/src/locale.rs
··· 182 }; 183 184 pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String { 185 - let mut result = template.to_string(); 186 - for (key, value) in vars { 187 - result = result.replace(&format!("{{{}}}", key), value); 188 - } 189 - result 190 } 191 192 #[cfg(test)]
··· 182 }; 183 184 pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String { 185 + vars.iter() 186 + .fold(template.to_string(), |result, (key, value)| { 187 + result.replace(&format!("{{{}}}", key), value) 188 + }) 189 } 190 191 #[cfg(test)]
+42 -25
crates/tranquil-oauth/src/client.rs
··· 529 let signature_bytes = URL_SAFE_NO_PAD 530 .decode(parts[2]) 531 .map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?; 532 - for key in matching_keys { 533 - let key_alg = key.get("alg").and_then(|a| a.as_str()); 534 - if key_alg.is_some() && key_alg != Some(alg) { 535 - continue; 536 - } 537 - let kty = key.get("kty").and_then(|k| k.as_str()).unwrap_or(""); 538 - let verified = match (alg, kty) { 539 - ("ES256", "EC") => verify_es256(key, &signing_input, &signature_bytes), 540 - ("ES384", "EC") => verify_es384(key, &signing_input, &signature_bytes), 541 - ("RS256" | "RS384" | "RS512", "RSA") => { 542 - verify_rsa(alg, key, &signing_input, &signature_bytes) 543 } 544 - ("EdDSA", "OKP") => verify_eddsa(key, &signing_input, &signature_bytes), 545 - _ => continue, 546 - }; 547 - if verified.is_ok() { 548 - return Ok(()); 549 - } 550 - } 551 - Err(OAuthError::InvalidClient( 552 - "client_assertion signature verification failed".to_string(), 553 - )) 554 } 555 556 fn verify_es256( ··· 569 .get("y") 570 .and_then(|v| v.as_str()) 571 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 572 - let x_bytes = URL_SAFE_NO_PAD 573 .decode(x) 574 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 575 - let y_bytes = URL_SAFE_NO_PAD 576 .decode(y) 577 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; 578 let mut point_bytes = vec![0x04]; 579 point_bytes.extend_from_slice(&x_bytes); 580 point_bytes.extend_from_slice(&y_bytes); ··· 605 .get("y") 606 .and_then(|v| v.as_str()) 607 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 608 - let x_bytes = URL_SAFE_NO_PAD 609 .decode(x) 610 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 611 - let y_bytes = URL_SAFE_NO_PAD 612 .decode(y) 613 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; 614 let mut point_bytes = vec![0x04]; 615 point_bytes.extend_from_slice(&x_bytes); 616 point_bytes.extend_from_slice(&y_bytes);
··· 529 let signature_bytes = URL_SAFE_NO_PAD 530 .decode(parts[2]) 531 .map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?; 532 + matching_keys 533 + .into_iter() 534 + .filter(|key| { 535 + let key_alg = key.get("alg").and_then(|a| a.as_str()); 536 + key_alg.is_none() || key_alg == Some(alg) 537 + }) 538 + .find_map(|key| { 539 + let kty = key.get("kty").and_then(|k| k.as_str()).unwrap_or(""); 540 + match (alg, kty) { 541 + ("ES256", "EC") => verify_es256(key, &signing_input, &signature_bytes).ok(), 542 + ("ES384", "EC") => verify_es384(key, &signing_input, &signature_bytes).ok(), 543 + ("RS256" | "RS384" | "RS512", "RSA") => { 544 + verify_rsa(alg, key, &signing_input, &signature_bytes).ok() 545 + } 546 + ("EdDSA", "OKP") => verify_eddsa(key, &signing_input, &signature_bytes).ok(), 547 + _ => None, 548 } 549 + }) 550 + .ok_or_else(|| { 551 + OAuthError::InvalidClient("client_assertion signature verification failed".to_string()) 552 + }) 553 } 554 555 fn verify_es256( ··· 568 .get("y") 569 .and_then(|v| v.as_str()) 570 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 571 + let x_decoded = URL_SAFE_NO_PAD 572 .decode(x) 573 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 574 + let y_decoded = URL_SAFE_NO_PAD 575 .decode(y) 576 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; 577 + if x_decoded.len() > 32 || y_decoded.len() > 32 { 578 + return Err(OAuthError::InvalidClient( 579 + "EC coordinate too long".to_string(), 580 + )); 581 + } 582 + let mut x_bytes = [0u8; 32]; 583 + let mut y_bytes = [0u8; 32]; 584 + x_bytes[32 - x_decoded.len()..].copy_from_slice(&x_decoded); 585 + y_bytes[32 - y_decoded.len()..].copy_from_slice(&y_decoded); 586 let mut point_bytes = vec![0x04]; 587 point_bytes.extend_from_slice(&x_bytes); 588 point_bytes.extend_from_slice(&y_bytes); ··· 613 .get("y") 614 .and_then(|v| v.as_str()) 615 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?; 616 + let x_decoded = URL_SAFE_NO_PAD 617 .decode(x) 618 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?; 619 + let y_decoded = URL_SAFE_NO_PAD 620 .decode(y) 621 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?; 622 + if x_decoded.len() > 48 || y_decoded.len() > 48 { 623 + return Err(OAuthError::InvalidClient( 624 + "EC coordinate too long".to_string(), 625 + )); 626 + } 627 + let mut x_bytes = [0u8; 48]; 628 + let mut y_bytes = [0u8; 48]; 629 + x_bytes[48 - x_decoded.len()..].copy_from_slice(&x_decoded); 630 + y_bytes[48 - y_decoded.len()..].copy_from_slice(&y_decoded); 631 let mut point_bytes = vec![0x04]; 632 point_bytes.extend_from_slice(&x_bytes); 633 point_bytes.extend_from_slice(&y_bytes);
+72 -14
crates/tranquil-oauth/src/dpop.rs
··· 218 crv 219 ))); 220 } 221 - let x_bytes = URL_SAFE_NO_PAD 222 .decode( 223 jwk.x 224 .as_ref() 225 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 226 ) 227 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 228 - let y_bytes = URL_SAFE_NO_PAD 229 .decode( 230 jwk.y 231 .as_ref() 232 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 233 ) 234 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 235 - let point = EncodedPoint::from_affine_coordinates( 236 - x_bytes.as_slice().into(), 237 - y_bytes.as_slice().into(), 238 - false, 239 - ); 240 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 241 let affine = 242 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 264 crv 265 ))); 266 } 267 - let x_bytes = URL_SAFE_NO_PAD 268 .decode( 269 jwk.x 270 .as_ref() 271 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 272 ) 273 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 274 - let y_bytes = URL_SAFE_NO_PAD 275 .decode( 276 jwk.y 277 .as_ref() 278 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 279 ) 280 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 281 - let point = EncodedPoint::from_affine_coordinates( 282 - x_bytes.as_slice().into(), 283 - y_bytes.as_slice().into(), 284 - false, 285 - ); 286 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 287 let affine = 288 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 397 }; 398 let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); 399 assert!(!thumbprint.is_empty()); 400 } 401 }
··· 218 crv 219 ))); 220 } 221 + let x_decoded = URL_SAFE_NO_PAD 222 .decode( 223 jwk.x 224 .as_ref() 225 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 226 ) 227 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 228 + let y_decoded = URL_SAFE_NO_PAD 229 .decode( 230 jwk.y 231 .as_ref() 232 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 233 ) 234 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 235 + let mut x_bytes = [0u8; 32]; 236 + let mut y_bytes = [0u8; 32]; 237 + if x_decoded.len() > 32 || y_decoded.len() > 32 { 238 + return Err(OAuthError::InvalidDpopProof( 239 + "EC coordinate too long".to_string(), 240 + )); 241 + } 242 + x_bytes[32 - x_decoded.len()..].copy_from_slice(&x_decoded); 243 + y_bytes[32 - y_decoded.len()..].copy_from_slice(&y_decoded); 244 + let point = EncodedPoint::from_affine_coordinates((&x_bytes).into(), (&y_bytes).into(), false); 245 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 246 let affine = 247 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 269 crv 270 ))); 271 } 272 + let x_decoded = URL_SAFE_NO_PAD 273 .decode( 274 jwk.x 275 .as_ref() 276 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?, 277 ) 278 .map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?; 279 + let y_decoded = URL_SAFE_NO_PAD 280 .decode( 281 jwk.y 282 .as_ref() 283 .ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?, 284 ) 285 .map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?; 286 + let mut x_bytes = [0u8; 48]; 287 + let mut y_bytes = [0u8; 48]; 288 + if x_decoded.len() > 48 || y_decoded.len() > 48 { 289 + return Err(OAuthError::InvalidDpopProof( 290 + "EC coordinate too long".to_string(), 291 + )); 292 + } 293 + x_bytes[48 - x_decoded.len()..].copy_from_slice(&x_decoded); 294 + y_bytes[48 - y_decoded.len()..].copy_from_slice(&y_decoded); 295 + let point = EncodedPoint::from_affine_coordinates((&x_bytes).into(), (&y_bytes).into(), false); 296 let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into(); 297 let affine = 298 affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; ··· 407 }; 408 let thumbprint = compute_jwk_thumbprint(&jwk).unwrap(); 409 assert!(!thumbprint.is_empty()); 410 + } 411 + 412 + #[test] 413 + fn test_es256_short_coordinate_no_panic() { 414 + let short_31_bytes = vec![0x42u8; 31]; 415 + let short_30_bytes = vec![0x42u8; 30]; 416 + let x_b64 = URL_SAFE_NO_PAD.encode(&short_31_bytes); 417 + let y_b64 = URL_SAFE_NO_PAD.encode(&short_30_bytes); 418 + let jwk = DPoPJwk { 419 + kty: "EC".to_string(), 420 + crv: Some("P-256".to_string()), 421 + x: Some(x_b64), 422 + y: Some(y_b64), 423 + }; 424 + let result = verify_es256(&jwk, b"test", &[0u8; 64]); 425 + assert!(result.is_err(), "Invalid coordinates should return error, not panic"); 426 + } 427 + 428 + #[test] 429 + fn test_es256_valid_key_with_trimmed_coordinates() { 430 + use p256::ecdsa::{SigningKey, signature::Signer}; 431 + use p256::elliptic_curve::rand_core::OsRng; 432 + 433 + let signing_key = SigningKey::random(&mut OsRng); 434 + let verifying_key = signing_key.verifying_key(); 435 + let point = verifying_key.to_encoded_point(false); 436 + let x_bytes = point.x().unwrap(); 437 + let y_bytes = point.y().unwrap(); 438 + let x_trimmed: Vec<u8> = x_bytes.iter().copied().skip_while(|&b| b == 0).collect(); 439 + let y_trimmed: Vec<u8> = y_bytes.iter().copied().skip_while(|&b| b == 0).collect(); 440 + let x_b64 = URL_SAFE_NO_PAD.encode(&x_trimmed); 441 + let y_b64 = URL_SAFE_NO_PAD.encode(&y_trimmed); 442 + let jwk = DPoPJwk { 443 + kty: "EC".to_string(), 444 + crv: Some("P-256".to_string()), 445 + x: Some(x_b64), 446 + y: Some(y_b64), 447 + }; 448 + let message = b"test message for signature verification"; 449 + let signature: p256::ecdsa::Signature = signing_key.sign(message); 450 + let result = verify_es256(&jwk, message, signature.to_bytes().as_slice()); 451 + assert!( 452 + result.is_ok(), 453 + "Should verify signature with trimmed coordinates (x={}, y={}): {:?}", 454 + x_trimmed.len(), 455 + y_trimmed.len(), 456 + result 457 + ); 458 } 459 }
+1
crates/tranquil-pds/Cargo.toml
··· 17 tranquil-comms = { workspace = true } 18 19 aes-gcm = { workspace = true } 20 anyhow = { workspace = true } 21 async-trait = { workspace = true } 22 aws-config = { workspace = true }
··· 17 tranquil-comms = { workspace = true } 18 19 aes-gcm = { workspace = true } 20 + backon = { workspace = true } 21 anyhow = { workspace = true } 22 async-trait = { workspace = true } 23 aws-config = { workspace = true }
+101 -62
crates/tranquil-pds/src/api/admin/account/info.rs
··· 130 db: &sqlx::PgPool, 131 user_id: uuid::Uuid, 132 ) -> Option<Vec<InviteCodeInfo>> { 133 - let codes = sqlx::query_scalar!( 134 r#" 135 - SELECT code FROM invite_codes WHERE created_by_user = $1 136 "#, 137 user_id 138 ) 139 .fetch_all(db) 140 .await 141 .ok()?; 142 - if codes.is_empty() { 143 return None; 144 } 145 - let mut invites = Vec::new(); 146 - for code in codes { 147 - if let Some(info) = get_invite_code_info(db, &code).await { 148 - invites.push(info); 149 - } 150 - } 151 if invites.is_empty() { 152 None 153 } else { ··· 276 .map(|r| (r.used_by_user, r.code)) 277 .collect(); 278 279 - let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> = 280 - std::collections::HashMap::new(); 281 - for u in all_invite_uses { 282 - uses_by_code 283 - .entry(u.code.clone()) 284 - .or_default() 285 - .push(InviteCodeUseInfo { 286 - used_by: u.used_by.into(), 287 - used_at: u.used_at.to_rfc3339(), 288 }); 289 - } 290 291 - let mut codes_by_user: std::collections::HashMap<uuid::Uuid, Vec<InviteCodeInfo>> = 292 - std::collections::HashMap::new(); 293 - let mut code_info_map: std::collections::HashMap<String, InviteCodeInfo> = 294 - std::collections::HashMap::new(); 295 - for ic in all_invite_codes { 296 - let info = InviteCodeInfo { 297 - code: ic.code.clone(), 298 - available: ic.available_uses, 299 - disabled: ic.disabled.unwrap_or(false), 300 - for_account: ic.for_account.into(), 301 - created_by: ic.created_by.into(), 302 - created_at: ic.created_at.to_rfc3339(), 303 - uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(), 304 - }; 305 - code_info_map.insert(ic.code.clone(), info.clone()); 306 - codes_by_user 307 - .entry(ic.created_by_user) 308 - .or_default() 309 - .push(info); 310 - } 311 312 - let mut infos = Vec::with_capacity(users.len()); 313 - for row in users { 314 - let invited_by = invited_by_map 315 - .get(&row.id) 316 - .and_then(|code| code_info_map.get(code).cloned()); 317 - let invites = codes_by_user.get(&row.id).cloned(); 318 - infos.push(AccountInfo { 319 - did: row.did.into(), 320 - handle: row.handle.into(), 321 - email: row.email, 322 - indexed_at: row.created_at.to_rfc3339(), 323 - invite_note: None, 324 - invites_disabled: row.invites_disabled.unwrap_or(false), 325 - email_confirmed_at: if row.email_verified { 326 - Some(row.created_at.to_rfc3339()) 327 - } else { 328 - None 329 - }, 330 - deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()), 331 - invited_by, 332 - invites, 333 - }); 334 - } 335 (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() 336 }
··· 130 db: &sqlx::PgPool, 131 user_id: uuid::Uuid, 132 ) -> Option<Vec<InviteCodeInfo>> { 133 + let invite_codes = sqlx::query!( 134 r#" 135 + SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by 136 + FROM invite_codes ic 137 + JOIN users u ON ic.created_by_user = u.id 138 + WHERE ic.created_by_user = $1 139 "#, 140 user_id 141 ) 142 .fetch_all(db) 143 .await 144 .ok()?; 145 + 146 + if invite_codes.is_empty() { 147 return None; 148 } 149 + 150 + let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect(); 151 + let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> = 152 + std::collections::HashMap::new(); 153 + sqlx::query!( 154 + r#" 155 + SELECT icu.code, u.did as used_by, icu.used_at 156 + FROM invite_code_uses icu 157 + JOIN users u ON icu.used_by_user = u.id 158 + WHERE icu.code = ANY($1) 159 + "#, 160 + &code_strings 161 + ) 162 + .fetch_all(db) 163 + .await 164 + .ok()? 165 + .into_iter() 166 + .for_each(|r| { 167 + uses_by_code 168 + .entry(r.code) 169 + .or_default() 170 + .push(InviteCodeUseInfo { 171 + used_by: r.used_by.into(), 172 + used_at: r.used_at.to_rfc3339(), 173 + }); 174 + }); 175 + 176 + let invites: Vec<InviteCodeInfo> = invite_codes 177 + .into_iter() 178 + .map(|ic| InviteCodeInfo { 179 + code: ic.code.clone(), 180 + available: ic.available_uses, 181 + disabled: ic.disabled.unwrap_or(false), 182 + for_account: ic.for_account.into(), 183 + created_by: ic.created_by.into(), 184 + created_at: ic.created_at.to_rfc3339(), 185 + uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(), 186 + }) 187 + .collect(); 188 + 189 if invites.is_empty() { 190 None 191 } else { ··· 314 .map(|r| (r.used_by_user, r.code)) 315 .collect(); 316 317 + let uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> = 318 + all_invite_uses 319 + .into_iter() 320 + .fold(std::collections::HashMap::new(), |mut acc, u| { 321 + acc.entry(u.code.clone()).or_default().push(InviteCodeUseInfo { 322 + used_by: u.used_by.into(), 323 + used_at: u.used_at.to_rfc3339(), 324 + }); 325 + acc 326 }); 327 328 + let (codes_by_user, code_info_map): ( 329 + std::collections::HashMap<uuid::Uuid, Vec<InviteCodeInfo>>, 330 + std::collections::HashMap<String, InviteCodeInfo>, 331 + ) = all_invite_codes.into_iter().fold( 332 + (std::collections::HashMap::new(), std::collections::HashMap::new()), 333 + |(mut by_user, mut by_code), ic| { 334 + let info = InviteCodeInfo { 335 + code: ic.code.clone(), 336 + available: ic.available_uses, 337 + disabled: ic.disabled.unwrap_or(false), 338 + for_account: ic.for_account.into(), 339 + created_by: ic.created_by.into(), 340 + created_at: ic.created_at.to_rfc3339(), 341 + uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(), 342 + }; 343 + by_code.insert(ic.code.clone(), info.clone()); 344 + by_user.entry(ic.created_by_user).or_default().push(info); 345 + (by_user, by_code) 346 + }, 347 + ); 348 349 + let infos: Vec<AccountInfo> = users 350 + .into_iter() 351 + .map(|row| { 352 + let invited_by = invited_by_map 353 + .get(&row.id) 354 + .and_then(|code| code_info_map.get(code).cloned()); 355 + let invites = codes_by_user.get(&row.id).cloned(); 356 + AccountInfo { 357 + did: row.did.into(), 358 + handle: row.handle.into(), 359 + email: row.email, 360 + indexed_at: row.created_at.to_rfc3339(), 361 + invite_note: None, 362 + invites_disabled: row.invites_disabled.unwrap_or(false), 363 + email_confirmed_at: if row.email_verified { 364 + Some(row.created_at.to_rfc3339()) 365 + } else { 366 + None 367 + }, 368 + deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()), 369 + invited_by, 370 + invites, 371 + } 372 + }) 373 + .collect(); 374 (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() 375 }
+11 -24
crates/tranquil-pds/src/api/admin/config.rs
··· 48 .fetch_all(&state.db) 49 .await?; 50 51 - let mut server_name = "Tranquil PDS".to_string(); 52 - let mut primary_color = None; 53 - let mut primary_color_dark = None; 54 - let mut secondary_color = None; 55 - let mut secondary_color_dark = None; 56 - let mut logo_cid = None; 57 - 58 - for (key, value) in rows { 59 - match key.as_str() { 60 - "server_name" => server_name = value, 61 - "primary_color" => primary_color = Some(value), 62 - "primary_color_dark" => primary_color_dark = Some(value), 63 - "secondary_color" => secondary_color = Some(value), 64 - "secondary_color_dark" => secondary_color_dark = Some(value), 65 - "logo_cid" => logo_cid = Some(value), 66 - _ => {} 67 - } 68 - } 69 70 Ok(Json(ServerConfigResponse { 71 - server_name, 72 - primary_color, 73 - primary_color_dark, 74 - secondary_color, 75 - secondary_color_dark, 76 - logo_cid, 77 })) 78 } 79
··· 48 .fetch_all(&state.db) 49 .await?; 50 51 + let config_map: std::collections::HashMap<String, String> = 52 + rows.into_iter().collect(); 53 54 Ok(Json(ServerConfigResponse { 55 + server_name: config_map 56 + .get("server_name") 57 + .cloned() 58 + .unwrap_or_else(|| "Tranquil PDS".to_string()), 59 + primary_color: config_map.get("primary_color").cloned(), 60 + primary_color_dark: config_map.get("primary_color_dark").cloned(), 61 + secondary_color: config_map.get("secondary_color").cloned(), 62 + secondary_color_dark: config_map.get("secondary_color_dark").cloned(), 63 + logo_cid: config_map.get("logo_cid").cloned(), 64 })) 65 } 66
+69 -54
crates/tranquil-pds/src/api/admin/invite.rs
··· 24 Json(input): Json<DisableInviteCodesInput>, 25 ) -> Response { 26 if let Some(codes) = &input.codes { 27 - for code in codes { 28 - let _ = sqlx::query!( 29 - "UPDATE invite_codes SET disabled = TRUE WHERE code = $1", 30 - code 31 - ) 32 - .execute(&state.db) 33 - .await; 34 - } 35 } 36 if let Some(accounts) = &input.accounts { 37 - for account in accounts { 38 - let user = sqlx::query!("SELECT id FROM users WHERE did = $1", account) 39 - .fetch_optional(&state.db) 40 - .await; 41 - if let Ok(Some(user_row)) = user { 42 - let _ = sqlx::query!( 43 - "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1", 44 - user_row.id 45 - ) 46 - .execute(&state.db) 47 - .await; 48 - } 49 - } 50 } 51 EmptyResponse::ok().into_response() 52 } ··· 70 pub uses: Vec<InviteCodeUseInfo>, 71 } 72 73 - #[derive(Serialize)] 74 #[serde(rename_all = "camelCase")] 75 pub struct InviteCodeUseInfo { 76 pub used_by: String, ··· 149 return ApiError::InternalError(None).into_response(); 150 } 151 }; 152 - let mut codes = Vec::new(); 153 - for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows { 154 - let creator_did = 155 - sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user) 156 - .fetch_optional(&state.db) 157 - .await 158 - .ok() 159 - .flatten() 160 - .unwrap_or_else(|| "unknown".to_string()); 161 - let uses_result = sqlx::query!( 162 r#" 163 - SELECT u.did, icu.used_at 164 FROM invite_code_uses icu 165 JOIN users u ON icu.used_by_user = u.id 166 - WHERE icu.code = $1 167 ORDER BY icu.used_at DESC 168 "#, 169 - code 170 ) 171 .fetch_all(&state.db) 172 - .await; 173 - let uses = match uses_result { 174 - Ok(use_rows) => use_rows 175 - .iter() 176 - .map(|u| InviteCodeUseInfo { 177 - used_by: u.did.clone(), 178 - used_at: u.used_at.to_rfc3339(), 179 - }) 180 - .collect(), 181 - Err(_) => Vec::new(), 182 - }; 183 - codes.push(InviteCodeInfo { 184 - code: code.clone(), 185 - available: *available_uses, 186 - disabled: disabled.unwrap_or(false), 187 - for_account: creator_did.clone(), 188 - created_by: creator_did, 189 - created_at: created_at.to_rfc3339(), 190 - uses, 191 }); 192 } 193 let next_cursor = if codes_rows.len() == limit as usize { 194 codes_rows.last().map(|(code, _, _, _, _)| code.clone()) 195 } else {
··· 24 Json(input): Json<DisableInviteCodesInput>, 25 ) -> Response { 26 if let Some(codes) = &input.codes { 27 + let _ = sqlx::query!( 28 + "UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)", 29 + codes as &[String] 30 + ) 31 + .execute(&state.db) 32 + .await; 33 } 34 if let Some(accounts) = &input.accounts { 35 + let _ = sqlx::query!( 36 + "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user IN (SELECT id FROM users WHERE did = ANY($1))", 37 + accounts as &[String] 38 + ) 39 + .execute(&state.db) 40 + .await; 41 } 42 EmptyResponse::ok().into_response() 43 } ··· 61 pub uses: Vec<InviteCodeUseInfo>, 62 } 63 64 + #[derive(Clone, Serialize)] 65 #[serde(rename_all = "camelCase")] 66 pub struct InviteCodeUseInfo { 67 pub used_by: String, ··· 140 return ApiError::InternalError(None).into_response(); 141 } 142 }; 143 + 144 + let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|(_, _, _, uid, _)| *uid).collect(); 145 + let code_strings: Vec<String> = codes_rows.iter().map(|(c, _, _, _, _)| c.clone()).collect(); 146 + 147 + let mut creator_dids: std::collections::HashMap<uuid::Uuid, String> = 148 + std::collections::HashMap::new(); 149 + sqlx::query!( 150 + "SELECT id, did FROM users WHERE id = ANY($1)", 151 + &user_ids 152 + ) 153 + .fetch_all(&state.db) 154 + .await 155 + .unwrap_or_default() 156 + .into_iter() 157 + .for_each(|r| { 158 + creator_dids.insert(r.id, r.did); 159 + }); 160 + 161 + let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> = 162 + std::collections::HashMap::new(); 163 + if !code_strings.is_empty() { 164 + sqlx::query!( 165 r#" 166 + SELECT icu.code, u.did, icu.used_at 167 FROM invite_code_uses icu 168 JOIN users u ON icu.used_by_user = u.id 169 + WHERE icu.code = ANY($1) 170 ORDER BY icu.used_at DESC 171 "#, 172 + &code_strings 173 ) 174 .fetch_all(&state.db) 175 + .await 176 + .unwrap_or_default() 177 + .into_iter() 178 + .for_each(|r| { 179 + uses_by_code 180 + .entry(r.code) 181 + .or_default() 182 + .push(InviteCodeUseInfo { 183 + used_by: r.did, 184 + used_at: r.used_at.to_rfc3339(), 185 + }); 186 }); 187 } 188 + 189 + let codes: Vec<InviteCodeInfo> = codes_rows 190 + .iter() 191 + .map(|(code, available_uses, disabled, created_by_user, created_at)| { 192 + let creator_did = creator_dids 193 + .get(created_by_user) 194 + .cloned() 195 + .unwrap_or_else(|| "unknown".to_string()); 196 + InviteCodeInfo { 197 + code: code.clone(), 198 + available: *available_uses, 199 + disabled: disabled.unwrap_or(false), 200 + for_account: creator_did.clone(), 201 + created_by: creator_did, 202 + created_at: created_at.to_rfc3339(), 203 + uses: uses_by_code.get(code).cloned().unwrap_or_default(), 204 + } 205 + }) 206 + .collect(); 207 + 208 let next_cursor = if codes_rows.len() == limit as usize { 209 codes_rows.last().map(|(code, _, _, _, _)| code.clone()) 210 } else {
+26 -9
crates/tranquil-pds/src/api/error.rs
··· 22 InvalidRequest(String), 23 InvalidToken(Option<String>), 24 ExpiredToken(Option<String>), 25 TokenRequired, 26 AccountDeactivated, 27 AccountTakedown, ··· 127 | Self::InvalidCode(_) 128 | Self::InvalidPassword(_) 129 | Self::InvalidToken(_) 130 - | Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED, 131 Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, 132 Self::Forbidden 133 | Self::AdminRequired ··· 216 Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), 217 Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"), 218 Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"), 219 - Self::ExpiredToken(_) => Cow::Borrowed("ExpiredToken"), 220 Self::TokenRequired => Cow::Borrowed("TokenRequired"), 221 Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 222 Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), ··· 298 | Self::AuthenticationFailed(msg) 299 | Self::InvalidToken(msg) 300 | Self::ExpiredToken(msg) 301 | Self::RepoNotFound(msg) 302 | Self::BlobNotFound(msg) 303 | Self::InvalidHandle(msg) ··· 428 message: self.message(), 429 }; 430 let mut response = (self.status_code(), Json(body)).into_response(); 431 - if matches!(self, Self::ExpiredToken(_)) { 432 - response.headers_mut().insert( 433 - "WWW-Authenticate", 434 - "Bearer error=\"invalid_token\", error_description=\"Token has expired\"" 435 - .parse() 436 - .unwrap(), 437 - ); 438 } 439 response 440 } ··· 457 Self::AuthenticationFailed(None) 458 } 459 crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None), 460 } 461 } 462 }
··· 22 InvalidRequest(String), 23 InvalidToken(Option<String>), 24 ExpiredToken(Option<String>), 25 + OAuthExpiredToken(Option<String>), 26 TokenRequired, 27 AccountDeactivated, 28 AccountTakedown, ··· 128 | Self::InvalidCode(_) 129 | Self::InvalidPassword(_) 130 | Self::InvalidToken(_) 131 + | Self::PasskeyCounterAnomaly 132 + | Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED, 133 Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, 134 Self::Forbidden 135 | Self::AdminRequired ··· 218 Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), 219 Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"), 220 Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"), 221 + Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"), 222 Self::TokenRequired => Cow::Borrowed("TokenRequired"), 223 Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 224 Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), ··· 300 | Self::AuthenticationFailed(msg) 301 | Self::InvalidToken(msg) 302 | Self::ExpiredToken(msg) 303 + | Self::OAuthExpiredToken(msg) 304 | Self::RepoNotFound(msg) 305 | Self::BlobNotFound(msg) 306 | Self::InvalidHandle(msg) ··· 431 message: self.message(), 432 }; 433 let mut response = (self.status_code(), Json(body)).into_response(); 434 + match &self { 435 + Self::ExpiredToken(_) => { 436 + response.headers_mut().insert( 437 + "WWW-Authenticate", 438 + "Bearer error=\"invalid_token\", error_description=\"Token has expired\"" 439 + .parse() 440 + .unwrap(), 441 + ); 442 + } 443 + Self::OAuthExpiredToken(_) => { 444 + response.headers_mut().insert( 445 + "WWW-Authenticate", 446 + "DPoP error=\"invalid_token\", error_description=\"Token has expired\"" 447 + .parse() 448 + .unwrap(), 449 + ); 450 + } 451 + _ => {} 452 } 453 response 454 } ··· 471 Self::AuthenticationFailed(None) 472 } 473 crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None), 474 + crate::auth::TokenValidationError::OAuthTokenExpired => { 475 + Self::OAuthExpiredToken(Some("Token has expired".to_string())) 476 + } 477 } 478 } 479 }
+6 -7
crates/tranquil-pds/src/api/identity/account.rs
··· 189 if input.handle.contains(' ') || input.handle.contains('\t') { 190 return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response(); 191 } 192 - for c in input.handle.chars() { 193 - if !c.is_ascii_alphanumeric() && c != '.' && c != '-' { 194 - return ApiError::InvalidRequest(format!( 195 - "Handle contains invalid character: {}", 196 - c 197 - )) 198 .into_response(); 199 - } 200 } 201 let handle_lower = input.handle.to_lowercase(); 202 if crate::moderation::has_explicit_slur(&handle_lower) {
··· 189 if input.handle.contains(' ') || input.handle.contains('\t') { 190 return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response(); 191 } 192 + if let Some(c) = input 193 + .handle 194 + .chars() 195 + .find(|c| !c.is_ascii_alphanumeric() && *c != '.' && *c != '-') 196 + { 197 + return ApiError::InvalidRequest(format!("Handle contains invalid character: {}", c)) 198 .into_response(); 199 } 200 let handle_lower = input.handle.to_lowercase(); 201 if crate::moderation::has_explicit_slur(&handle_lower) {
+11 -10
crates/tranquil-pds/src/api/identity/did.rs
··· 639 return ApiError::InvalidHandle(Some("Handle contains invalid characters".into())) 640 .into_response(); 641 } 642 - for segment in new_handle.split('.') { 643 - if segment.is_empty() { 644 - return ApiError::InvalidHandle(Some("Handle contains empty segment".into())) 645 - .into_response(); 646 - } 647 - if segment.starts_with('-') || segment.ends_with('-') { 648 - return ApiError::InvalidHandle(Some( 649 - "Handle segment cannot start or end with hyphen".into(), 650 - )) 651 .into_response(); 652 - } 653 } 654 if crate::moderation::has_explicit_slur(&new_handle) { 655 return ApiError::InvalidHandle(Some("Inappropriate language in handle".into()))
··· 639 return ApiError::InvalidHandle(Some("Handle contains invalid characters".into())) 640 .into_response(); 641 } 642 + if new_handle.split('.').any(|segment| segment.is_empty()) { 643 + return ApiError::InvalidHandle(Some("Handle contains empty segment".into())) 644 .into_response(); 645 + } 646 + if new_handle 647 + .split('.') 648 + .any(|segment| segment.starts_with('-') || segment.ends_with('-')) 649 + { 650 + return ApiError::InvalidHandle(Some( 651 + "Handle segment cannot start or end with hyphen".into(), 652 + )) 653 + .into_response(); 654 } 655 if crate::moderation::has_explicit_slur(&new_handle) { 656 return ApiError::InvalidHandle(Some("Inappropriate language in handle".into()))
+1 -1
crates/tranquil-pds/src/api/moderation/mod.rs
··· 211 } 212 213 let created_at = chrono::Utc::now(); 214 - let report_id = created_at.timestamp_millis(); 215 let subject_json = json!(input.subject); 216 217 let insert = sqlx::query!(
··· 211 } 212 213 let created_at = chrono::Utc::now(); 214 + let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64; 215 let subject_json = json!(input.subject); 216 217 let insert = sqlx::query!(
+15 -26
crates/tranquil-pds/src/api/proxy.rs
··· 222 ) { 223 let token = extracted.token; 224 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 225 - let http_uri = uri.to_string(); 226 227 match crate::auth::validate_token_with_dpop( 228 &state.db, ··· 268 } 269 Err(e) => { 270 warn!("Token validation failed: {:?}", e); 271 - if matches!(e, crate::auth::TokenValidationError::TokenExpired) && extracted.is_dpop 272 - { 273 - let www_auth = 274 - "DPoP error=\"invalid_token\", error_description=\"Token has expired\""; 275 - let mut response = 276 - ApiError::ExpiredToken(Some("Token has expired".into())).into_response(); 277 - *response.status_mut() = axum::http::StatusCode::UNAUTHORIZED; 278 - response 279 - .headers_mut() 280 - .insert("WWW-Authenticate", www_auth.parse().unwrap()); 281 - let nonce = crate::oauth::verify::generate_dpop_nonce(); 282 - response 283 - .headers_mut() 284 - .insert("DPoP-Nonce", nonce.parse().unwrap()); 285 - return response; 286 } 287 } 288 } ··· 291 if let Some(val) = auth_header_val { 292 request_builder = request_builder.header("Authorization", val); 293 } 294 - for header_name in crate::api::proxy_client::HEADERS_TO_FORWARD { 295 - if let Some(val) = headers.get(*header_name) { 296 - request_builder = request_builder.header(*header_name, val); 297 - } 298 - } 299 if !body.is_empty() { 300 request_builder = request_builder.body(body); 301 } ··· 313 } 314 }; 315 let mut response_builder = Response::builder().status(status); 316 - for header_name in crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD { 317 - if let Some(val) = headers.get(*header_name) { 318 - response_builder = response_builder.header(*header_name, val); 319 - } 320 - } 321 match response_builder.body(axum::body::Body::from(body)) { 322 Ok(r) => r, 323 Err(e) => {
··· 222 ) { 223 let token = extracted.token; 224 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 225 + let http_uri = crate::util::build_full_url(&uri.to_string()); 226 227 match crate::auth::validate_token_with_dpop( 228 &state.db, ··· 268 } 269 Err(e) => { 270 warn!("Token validation failed: {:?}", e); 271 + if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) { 272 + return ApiError::from(e).into_response(); 273 } 274 } 275 } ··· 278 if let Some(val) = auth_header_val { 279 request_builder = request_builder.header("Authorization", val); 280 } 281 + request_builder = crate::api::proxy_client::HEADERS_TO_FORWARD 282 + .iter() 283 + .filter_map(|name| headers.get(*name).map(|val| (*name, val))) 284 + .fold(request_builder, |builder, (name, val)| { 285 + builder.header(name, val) 286 + }); 287 if !body.is_empty() { 288 request_builder = request_builder.body(body); 289 } ··· 301 } 302 }; 303 let mut response_builder = Response::builder().status(status); 304 + response_builder = crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD 305 + .iter() 306 + .filter_map(|name| headers.get(*name).map(|val| (*name, val))) 307 + .fold(response_builder, |builder, (name, val)| { 308 + builder.header(name, val) 309 + }); 310 match response_builder.body(axum::body::Body::from(body)) { 311 Ok(r) => r, 312 Err(e) => {
+7 -9
crates/tranquil-pds/src/api/proxy_client.rs
··· 88 Ok(addrs) => addrs.collect(), 89 Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())), 90 }; 91 - for addr in &socket_addrs { 92 - if !is_unicast_ip(&addr.ip()) { 93 - warn!( 94 - "DNS resolution for {} returned non-unicast IP: {}", 95 - host, 96 - addr.ip() 97 - ); 98 - return Err(SsrfError::NonUnicastIp(addr.ip().to_string())); 99 - } 100 } 101 Ok(()) 102 }
··· 88 Ok(addrs) => addrs.collect(), 89 Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())), 90 }; 91 + if let Some(addr) = socket_addrs.iter().find(|addr| !is_unicast_ip(&addr.ip())) { 92 + warn!( 93 + "DNS resolution for {} returned non-unicast IP: {}", 94 + host, 95 + addr.ip() 96 + ); 97 + return Err(SsrfError::NonUnicastIp(addr.ip().to_string())); 98 } 99 Ok(()) 100 }
+48 -29
crates/tranquil-pds/src/api/repo/import.rs
··· 5 use crate::state::AppState; 6 use crate::sync::import::{ImportError, apply_import, parse_car}; 7 use crate::sync::verify::CarVerifier; 8 use axum::{ 9 body::Bytes, 10 extract::State, ··· 196 import_result.records.len(), 197 did 198 ); 199 - let mut blob_ref_count = 0; 200 - for record in &import_result.records { 201 - for blob_ref in &record.blob_refs { 202 let record_uri = format!("at://{}/{}/{}", did, record.collection, record.rkey); 203 - if let Err(e) = sqlx::query!( 204 - r#" 205 - INSERT INTO record_blobs (repo_id, record_uri, blob_cid) 206 - VALUES ($1, $2, $3) 207 - ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING 208 - "#, 209 - user_id, 210 - record_uri, 211 - blob_ref.cid 212 - ) 213 - .execute(&state.db) 214 - .await 215 - { 216 - warn!("Failed to insert record_blob for {}: {:?}", record_uri, e); 217 - } else { 218 - blob_ref_count += 1; 219 } 220 } 221 - } 222 - if blob_ref_count > 0 { 223 - info!( 224 - "Recorded {} blob references for imported repo", 225 - blob_ref_count 226 - ); 227 } 228 let key_row = match sqlx::query!( 229 r#"SELECT uk.key_bytes, uk.encryption_version ··· 383 384 async fn sequence_import_event( 385 state: &AppState, 386 - did: &str, 387 commit_cid: &str, 388 ) -> Result<(), sqlx::Error> { 389 let prev_cid: Option<String> = None; ··· 391 let ops = serde_json::json!([]); 392 let blobs: Vec<String> = vec![]; 393 let blocks_cids: Vec<String> = vec![]; 394 let seq_row = sqlx::query!( 395 r#" 396 INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids) 397 VALUES ($1, 'commit', $2, $3, $4, $5, $6, $7) 398 RETURNING seq 399 "#, 400 - did, 401 commit_cid, 402 prev_cid, 403 prev_data_cid, ··· 405 &blobs, 406 &blocks_cids 407 ) 408 - .fetch_one(&state.db) 409 .await?; 410 sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq)) 411 - .execute(&state.db) 412 .await?; 413 Ok(()) 414 }
··· 5 use crate::state::AppState; 6 use crate::sync::import::{ImportError, apply_import, parse_car}; 7 use crate::sync::verify::CarVerifier; 8 + use crate::types::Did; 9 use axum::{ 10 body::Bytes, 11 extract::State, ··· 197 import_result.records.len(), 198 did 199 ); 200 + let blob_refs: Vec<(String, String)> = import_result 201 + .records 202 + .iter() 203 + .flat_map(|record| { 204 let record_uri = format!("at://{}/{}/{}", did, record.collection, record.rkey); 205 + record 206 + .blob_refs 207 + .iter() 208 + .map(move |blob_ref| (record_uri.clone(), blob_ref.cid.clone())) 209 + }) 210 + .collect(); 211 + 212 + if !blob_refs.is_empty() { 213 + let (record_uris, blob_cids): (Vec<String>, Vec<String>) = 214 + blob_refs.into_iter().unzip(); 215 + 216 + match sqlx::query!( 217 + r#" 218 + INSERT INTO record_blobs (repo_id, record_uri, blob_cid) 219 + SELECT $1, * FROM UNNEST($2::text[], $3::text[]) 220 + ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING 221 + "#, 222 + user_id, 223 + &record_uris, 224 + &blob_cids 225 + ) 226 + .execute(&state.db) 227 + .await 228 + { 229 + Ok(result) => { 230 + info!( 231 + "Recorded {} blob references for imported repo", 232 + result.rows_affected() 233 + ); 234 + } 235 + Err(e) => { 236 + warn!("Failed to insert record_blobs: {:?}", e); 237 } 238 } 239 } 240 let key_row = match sqlx::query!( 241 r#"SELECT uk.key_bytes, uk.encryption_version ··· 395 396 async fn sequence_import_event( 397 state: &AppState, 398 + did: &Did, 399 commit_cid: &str, 400 ) -> Result<(), sqlx::Error> { 401 let prev_cid: Option<String> = None; ··· 403 let ops = serde_json::json!([]); 404 let blobs: Vec<String> = vec![]; 405 let blocks_cids: Vec<String> = vec![]; 406 + let did_str = did.as_str(); 407 + 408 + let mut tx = state.db.begin().await?; 409 + 410 let seq_row = sqlx::query!( 411 r#" 412 INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids) 413 VALUES ($1, 'commit', $2, $3, $4, $5, $6, $7) 414 RETURNING seq 415 "#, 416 + did_str, 417 commit_cid, 418 prev_cid, 419 prev_data_cid, ··· 421 &blobs, 422 &blocks_cids 423 ) 424 + .fetch_one(&mut *tx) 425 .await?; 426 + 427 sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq)) 428 + .execute(&mut *tx) 429 .await?; 430 + 431 + tx.commit().await?; 432 Ok(()) 433 }
+1 -13
crates/tranquil-pds/src/api/repo/record/write.rs
··· 82 .await 83 .map_err(|e| { 84 tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 85 - let mut response = ApiError::from(e).into_response(); 86 - if matches!(e, crate::auth::TokenValidationError::TokenExpired) && extracted.is_dpop { 87 - *response.status_mut() = axum::http::StatusCode::UNAUTHORIZED; 88 - let www_auth = 89 - "DPoP error=\"invalid_token\", error_description=\"Token has expired\""; 90 - response.headers_mut().insert( 91 - "WWW-Authenticate", 92 - www_auth.parse().unwrap(), 93 - ); 94 - let nonce = crate::oauth::verify::generate_dpop_nonce(); 95 - response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap()); 96 - } 97 - response 98 })?; 99 if repo.as_str() != auth_user.did.as_str() { 100 return Err(
··· 82 .await 83 .map_err(|e| { 84 tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 85 + ApiError::from(e).into_response() 86 })?; 87 if repo.as_str() != auth_user.did.as_str() { 88 return Err(
+59 -49
crates/tranquil-pds/src/api/server/account_status.rs
··· 10 http::StatusCode, 11 response::{IntoResponse, Response}, 12 }; 13 use bcrypt::verify; 14 use chrono::{Duration, Utc}; 15 use cid::Cid; ··· 19 use serde::{Deserialize, Serialize}; 20 use std::str::FromStr; 21 use std::sync::Arc; 22 use tracing::{error, info, warn}; 23 use uuid::Uuid; 24 ··· 177 let expected_endpoint = format!("https://{}", hostname); 178 179 if did.starts_with("did:plc:") { 180 - let plc_client = PlcClient::with_cache(None, Some(cache.clone())); 181 - 182 let max_attempts = if with_retry { 5 } else { 1 }; 183 - let mut last_error = None; 184 - let mut doc_data = None; 185 - for attempt in 0..max_attempts { 186 - if attempt > 0 { 187 - let delay_ms = 500 * (1 << (attempt - 1)); 188 - info!( 189 - "Waiting {}ms before retry {} for DID document validation ({})", 190 - delay_ms, attempt, did 191 - ); 192 - tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; 193 - } 194 195 - match plc_client.get_document_data(did).await { 196 - Ok(data) => { 197 - let pds_endpoint = data 198 - .get("services") 199 - .and_then(|s| s.get("atproto_pds").or_else(|| s.get("atprotoPds"))) 200 - .and_then(|p| p.get("endpoint")) 201 - .and_then(|e| e.as_str()); 202 203 - if pds_endpoint == Some(&expected_endpoint) { 204 - doc_data = Some(data); 205 - break; 206 - } else { 207 - info!( 208 - "Attempt {}: DID {} has endpoint {:?}, expected {} - retrying", 209 attempt + 1, 210 - did, 211 - pds_endpoint, 212 - expected_endpoint 213 ); 214 - last_error = Some(format!( 215 - "DID document endpoint {:?} does not match expected {}", 216 - pds_endpoint, expected_endpoint 217 - )); 218 } 219 } 220 - Err(e) => { 221 - warn!( 222 - "Attempt {}: Failed to fetch PLC document for {}: {:?}", 223 - attempt + 1, 224 - did, 225 - e 226 - ); 227 - last_error = Some(format!("Could not resolve DID document: {}", e)); 228 - } 229 } 230 - } 231 - 232 - let Some(doc_data) = doc_data else { 233 - return Err(ApiError::InvalidRequest( 234 - last_error.unwrap_or_else(|| "DID document validation failed".to_string()), 235 - )); 236 - }; 237 238 let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok(); 239 if let Some(ref expected_rotation_key) = server_rotation_key {
··· 10 http::StatusCode, 11 response::{IntoResponse, Response}, 12 }; 13 + use backon::{ExponentialBuilder, Retryable}; 14 use bcrypt::verify; 15 use chrono::{Duration, Utc}; 16 use cid::Cid; ··· 20 use serde::{Deserialize, Serialize}; 21 use std::str::FromStr; 22 use std::sync::Arc; 23 + use std::sync::atomic::{AtomicUsize, Ordering}; 24 use tracing::{error, info, warn}; 25 use uuid::Uuid; 26 ··· 179 let expected_endpoint = format!("https://{}", hostname); 180 181 if did.starts_with("did:plc:") { 182 let max_attempts = if with_retry { 5 } else { 1 }; 183 + let cache_for_retry = cache.clone(); 184 + let did_owned = did.to_string(); 185 + let expected_owned = expected_endpoint.clone(); 186 + let attempt_counter = Arc::new(AtomicUsize::new(0)); 187 188 + let doc_data: serde_json::Value = (|| { 189 + let cache_ref = cache_for_retry.clone(); 190 + let did_ref = did_owned.clone(); 191 + let expected_ref = expected_owned.clone(); 192 + let counter = attempt_counter.clone(); 193 + async move { 194 + let attempt = counter.fetch_add(1, Ordering::SeqCst); 195 + if attempt > 0 { 196 + info!( 197 + "Retry {} for DID document validation ({})", 198 + attempt, did_ref 199 + ); 200 + } 201 + let plc_client = PlcClient::with_cache(None, Some(cache_ref)); 202 + match plc_client.get_document_data(&did_ref).await { 203 + Ok(data) => { 204 + let pds_endpoint = data 205 + .get("services") 206 + .and_then(|s: &serde_json::Value| { 207 + s.get("atproto_pds").or_else(|| s.get("atprotoPds")) 208 + }) 209 + .and_then(|p: &serde_json::Value| p.get("endpoint")) 210 + .and_then(|e: &serde_json::Value| e.as_str()); 211 212 + if pds_endpoint == Some(expected_ref.as_str()) { 213 + Ok(data) 214 + } else { 215 + info!( 216 + "Attempt {}: DID {} has endpoint {:?}, expected {}", 217 + attempt + 1, 218 + did_ref, 219 + pds_endpoint, 220 + expected_ref 221 + ); 222 + Err(format!( 223 + "DID document endpoint {:?} does not match expected {}", 224 + pds_endpoint, expected_ref 225 + )) 226 + } 227 + } 228 + Err(e) => { 229 + warn!( 230 + "Attempt {}: Failed to fetch PLC document for {}: {:?}", 231 attempt + 1, 232 + did_ref, 233 + e 234 ); 235 + Err(format!("Could not resolve DID document: {}", e)) 236 } 237 } 238 } 239 + }) 240 + .retry( 241 + ExponentialBuilder::default() 242 + .with_min_delay(std::time::Duration::from_millis(500)) 243 + .with_max_times(max_attempts), 244 + ) 245 + .await 246 + .map_err(ApiError::InvalidRequest)?; 247 248 let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok(); 249 if let Some(ref expected_rotation_key) = server_rotation_key {
+7 -3
crates/tranquil-pds/src/api/server/app_password.rs
··· 254 error!("DB error revoking sessions for app password: {:?}", e); 255 return ApiError::InternalError(None).into_response(); 256 } 257 - for jti in &sessions_to_invalidate { 258 let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti); 259 - let _ = state.cache.delete(&cache_key).await; 260 - } 261 if let Err(e) = sqlx::query!( 262 "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", 263 user_id,
··· 254 error!("DB error revoking sessions for app password: {:?}", e); 255 return ApiError::InternalError(None).into_response(); 256 } 257 + futures::future::join_all(sessions_to_invalidate.iter().map(|jti| { 258 let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti); 259 + let cache = state.cache.clone(); 260 + async move { 261 + let _ = cache.delete(&cache_key).await; 262 + } 263 + })) 264 + .await; 265 if let Err(e) = sqlx::query!( 266 "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", 267 user_id,
+77 -75
crates/tranquil-pds/src/api/server/invite.rs
··· 15 16 fn gen_random_token() -> String { 17 let mut rng = rand::thread_rng(); 18 - let mut token = String::with_capacity(11); 19 - for i in 0..10 { 20 - if i == 5 { 21 - token.push('-'); 22 - } 23 - let idx = rng.gen_range(0..32); 24 - token.push(BASE32_ALPHABET[idx] as char); 25 - } 26 - token 27 } 28 29 fn gen_invite_code() -> String { ··· 132 } 133 }; 134 135 - let mut result_codes = Vec::new(); 136 - 137 - for account in for_accounts { 138 - let mut codes = Vec::new(); 139 - for _ in 0..code_count { 140 - let code = gen_invite_code(); 141 - if let Err(e) = sqlx::query!( 142 - "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)", 143 - code, 144 - input.use_count, 145 admin_user_id, 146 account 147 ) 148 - .execute(&state.db) 149 .await 150 - { 151 - error!("DB error creating invite code: {:?}", e); 152 - return ApiError::InternalError(None).into_response(); 153 - } 154 - codes.push(code); 155 } 156 - result_codes.push(AccountCodes { account, codes }); 157 } 158 - 159 - Json(CreateInviteCodesOutput { 160 - codes: result_codes, 161 - }) 162 - .into_response() 163 } 164 165 #[derive(Deserialize)] ··· 227 } 228 }; 229 230 - let mut codes = Vec::new(); 231 - for row in codes_rows { 232 - let disabled = row.disabled.unwrap_or(false); 233 - if disabled { 234 - continue; 235 - } 236 - 237 - let use_count = row.use_count; 238 - if !include_used && use_count >= row.available_uses { 239 - continue; 240 - } 241 - 242 - let uses = sqlx::query!( 243 - r#" 244 - SELECT u.did, u.handle, icu.used_at 245 - FROM invite_code_uses icu 246 - JOIN users u ON icu.used_by_user = u.id 247 - WHERE icu.code = $1 248 - ORDER BY icu.used_at DESC 249 - "#, 250 - row.code 251 - ) 252 - .fetch_all(&state.db) 253 - .await 254 - .map(|use_rows| { 255 - use_rows 256 - .iter() 257 - .map(|u| InviteCodeUse { 258 - used_by: u.did.clone(), 259 - used_by_handle: Some(u.handle.clone()), 260 - used_at: u.used_at.to_rfc3339(), 261 - }) 262 - .collect() 263 }) 264 - .unwrap_or_default(); 265 266 - codes.push(InviteCode { 267 - code: row.code, 268 - available: row.available_uses, 269 - disabled, 270 - for_account: row.for_account, 271 - created_by: "admin".to_string(), 272 - created_at: row.created_at.to_rfc3339(), 273 - uses, 274 - }); 275 - } 276 277 Json(GetAccountInviteCodesOutput { codes }).into_response() 278 }
··· 15 16 fn gen_random_token() -> String { 17 let mut rng = rand::thread_rng(); 18 + let gen_segment = |rng: &mut rand::rngs::ThreadRng, len: usize| -> String { 19 + (0..len) 20 + .map(|_| BASE32_ALPHABET[rng.gen_range(0..32)] as char) 21 + .collect() 22 + }; 23 + format!("{}-{}", gen_segment(&mut rng, 5), gen_segment(&mut rng, 5)) 24 } 25 26 fn gen_invite_code() -> String { ··· 129 } 130 }; 131 132 + let result = futures::future::try_join_all(for_accounts.into_iter().map(|account| { 133 + let db = state.db.clone(); 134 + let use_count = input.use_count; 135 + async move { 136 + let codes: Vec<String> = (0..code_count).map(|_| gen_invite_code()).collect(); 137 + sqlx::query!( 138 + r#" 139 + INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) 140 + SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code) 141 + "#, 142 + &codes[..], 143 + use_count, 144 admin_user_id, 145 account 146 ) 147 + .execute(&db) 148 .await 149 + .map(|_| AccountCodes { account, codes }) 150 + } 151 + })) 152 + .await; 153 + 154 + match result { 155 + Ok(result_codes) => Json(CreateInviteCodesOutput { 156 + codes: result_codes, 157 + }) 158 + .into_response(), 159 + Err(e) => { 160 + error!("DB error creating invite codes: {:?}", e); 161 + ApiError::InternalError(None).into_response() 162 } 163 } 164 } 165 166 #[derive(Deserialize)] ··· 228 } 229 }; 230 231 + let filtered_rows: Vec<_> = codes_rows 232 + .into_iter() 233 + .filter(|row| { 234 + let disabled = row.disabled.unwrap_or(false); 235 + !disabled && (include_used || row.use_count < row.available_uses) 236 }) 237 + .collect(); 238 239 + let codes = futures::future::join_all(filtered_rows.into_iter().map(|row| { 240 + let db = state.db.clone(); 241 + async move { 242 + let uses = sqlx::query!( 243 + r#" 244 + SELECT u.did, u.handle, icu.used_at 245 + FROM invite_code_uses icu 246 + JOIN users u ON icu.used_by_user = u.id 247 + WHERE icu.code = $1 248 + ORDER BY icu.used_at DESC 249 + "#, 250 + row.code 251 + ) 252 + .fetch_all(&db) 253 + .await 254 + .map(|use_rows| { 255 + use_rows 256 + .iter() 257 + .map(|u| InviteCodeUse { 258 + used_by: u.did.clone(), 259 + used_by_handle: Some(u.handle.clone()), 260 + used_at: u.used_at.to_rfc3339(), 261 + }) 262 + .collect() 263 + }) 264 + .unwrap_or_default(); 265 + 266 + InviteCode { 267 + code: row.code, 268 + available: row.available_uses, 269 + disabled: false, 270 + for_account: row.for_account, 271 + created_by: "admin".to_string(), 272 + created_at: row.created_at.to_rfc3339(), 273 + uses, 274 + } 275 + } 276 + })) 277 + .await; 278 279 Json(GetAccountInviteCodesOutput { codes }).into_response() 280 }
+18 -28
crates/tranquil-pds/src/api/server/migration.rs
··· 97 return ApiError::InvalidRequest("verification_methods cannot be empty".into()) 98 .into_response(); 99 } 100 - for method in methods { 101 if method.id.is_empty() { 102 - return ApiError::InvalidRequest("verification method id is required".into()) 103 - .into_response(); 104 - } 105 - if method.method_type != "Multikey" { 106 - return ApiError::InvalidRequest( 107 - "verification method type must be 'Multikey'".into(), 108 - ) 109 - .into_response(); 110 - } 111 - if !method.public_key_multibase.starts_with('z') { 112 - return ApiError::InvalidRequest( 113 - "publicKeyMultibase must start with 'z' (base58btc)".into(), 114 - ) 115 - .into_response(); 116 - } 117 - if method.public_key_multibase.len() < 40 { 118 - return ApiError::InvalidRequest( 119 - "publicKeyMultibase appears too short for a valid key".into(), 120 - ) 121 - .into_response(); 122 } 123 } 124 } 125 126 - if let Some(ref handles) = input.also_known_as { 127 - for handle in handles { 128 - if !handle.starts_with("at://") { 129 - return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into()) 130 - .into_response(); 131 - } 132 - } 133 } 134 135 if let Some(ref endpoint) = input.service_endpoint {
··· 97 return ApiError::InvalidRequest("verification_methods cannot be empty".into()) 98 .into_response(); 99 } 100 + let validation_error = methods.iter().find_map(|method| { 101 if method.id.is_empty() { 102 + Some("verification method id is required") 103 + } else if method.method_type != "Multikey" { 104 + Some("verification method type must be 'Multikey'") 105 + } else if !method.public_key_multibase.starts_with('z') { 106 + Some("publicKeyMultibase must start with 'z' (base58btc)") 107 + } else if method.public_key_multibase.len() < 40 { 108 + Some("publicKeyMultibase appears too short for a valid key") 109 + } else { 110 + None 111 } 112 + }); 113 + if let Some(err) = validation_error { 114 + return ApiError::InvalidRequest(err.into()).into_response(); 115 } 116 } 117 118 + if let Some(ref handles) = input.also_known_as 119 + && handles.iter().any(|h| !h.starts_with("at://")) 120 + { 121 + return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into()) 122 + .into_response(); 123 } 124 125 if let Some(ref endpoint) = input.service_endpoint {
+40 -13
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 813 return ApiError::InternalError(None).into_response(); 814 } 815 816 - let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; 817 - 818 let app_password = generate_app_password(); 819 let app_password_name = "bsky.app".to_string(); 820 let password_hash = match hash(&app_password, DEFAULT_COST) { ··· 825 } 826 }; 827 828 if let Err(e) = sqlx::query!( 829 "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", 830 user.id, 831 app_password_name, 832 password_hash 833 ) 834 - .execute(&state.db) 835 .await 836 { 837 error!("Error creating app password: {:?}", e); ··· 842 "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", 843 input.did.as_str() 844 ) 845 - .execute(&state.db) 846 .await 847 { 848 error!("Error clearing setup token: {:?}", e); 849 } 850 851 info!(did = %input.did, "Passkey-only account setup completed"); 852 ··· 1090 } 1091 }; 1092 1093 if let Err(e) = sqlx::query!( 1094 "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", 1095 password_hash, 1096 input.did.as_str() 1097 ) 1098 - .execute(&state.db) 1099 .await 1100 { 1101 error!("Error updating password: {:?}", e); ··· 1103 } 1104 1105 let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str()) 1106 - .execute(&state.db) 1107 .await; 1108 - match deleted { 1109 - Ok(result) => { 1110 - if result.rows_affected() > 0 { 1111 - info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery"); 1112 - } 1113 - } 1114 Err(e) => { 1115 - warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e); 1116 } 1117 } 1118 1119 info!(did = %input.did, "Passkey-only account recovered with temporary password"); 1120 SuccessResponse::ok().into_response() 1121 }
··· 813 return ApiError::InternalError(None).into_response(); 814 } 815 816 let app_password = generate_app_password(); 817 let app_password_name = "bsky.app".to_string(); 818 let password_hash = match hash(&app_password, DEFAULT_COST) { ··· 823 } 824 }; 825 826 + let mut tx = match state.db.begin().await { 827 + Ok(tx) => tx, 828 + Err(e) => { 829 + error!("Failed to begin transaction: {:?}", e); 830 + return ApiError::InternalError(None).into_response(); 831 + } 832 + }; 833 + 834 if let Err(e) = sqlx::query!( 835 "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", 836 user.id, 837 app_password_name, 838 password_hash 839 ) 840 + .execute(&mut *tx) 841 .await 842 { 843 error!("Error creating app password: {:?}", e); ··· 848 "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", 849 input.did.as_str() 850 ) 851 + .execute(&mut *tx) 852 .await 853 { 854 error!("Error clearing setup token: {:?}", e); 855 + return ApiError::InternalError(None).into_response(); 856 } 857 + 858 + if let Err(e) = tx.commit().await { 859 + error!("Failed to commit setup transaction: {:?}", e); 860 + return ApiError::InternalError(None).into_response(); 861 + } 862 + 863 + let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; 864 865 info!(did = %input.did, "Passkey-only account setup completed"); 866 ··· 1104 } 1105 }; 1106 1107 + let mut tx = match state.db.begin().await { 1108 + Ok(tx) => tx, 1109 + Err(e) => { 1110 + error!("Failed to begin transaction: {:?}", e); 1111 + return ApiError::InternalError(None).into_response(); 1112 + } 1113 + }; 1114 + 1115 if let Err(e) = sqlx::query!( 1116 "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", 1117 password_hash, 1118 input.did.as_str() 1119 ) 1120 + .execute(&mut *tx) 1121 .await 1122 { 1123 error!("Error updating password: {:?}", e); ··· 1125 } 1126 1127 let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str()) 1128 + .execute(&mut *tx) 1129 .await; 1130 + let passkeys_deleted = match deleted { 1131 + Ok(result) => result.rows_affected(), 1132 Err(e) => { 1133 + error!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e); 1134 + return ApiError::InternalError(None).into_response(); 1135 } 1136 + }; 1137 + 1138 + if let Err(e) = tx.commit().await { 1139 + error!("Failed to commit recovery transaction: {:?}", e); 1140 + return ApiError::InternalError(None).into_response(); 1141 } 1142 1143 + if passkeys_deleted > 0 { 1144 + info!(did = %input.did, count = passkeys_deleted, "Deleted lost passkeys during account recovery"); 1145 + } 1146 info!(did = %input.did, "Passkey-only account recovered with temporary password"); 1147 SuccessResponse::ok().into_response() 1148 }
+11 -7
crates/tranquil-pds/src/api/server/password.rs
··· 239 error!("Failed to commit password reset transaction: {:?}", e); 240 return ApiError::InternalError(None).into_response(); 241 } 242 - for jti in session_jtis { 243 let cache_key = format!("auth:session:{}:{}", user_did, jti); 244 - if let Err(e) = state.cache.delete(&cache_key).await { 245 - warn!( 246 - "Failed to invalidate session cache for {}: {:?}", 247 - cache_key, e 248 - ); 249 } 250 - } 251 info!("Password reset completed for user {}", user_id); 252 EmptyResponse::ok().into_response() 253 }
··· 239 error!("Failed to commit password reset transaction: {:?}", e); 240 return ApiError::InternalError(None).into_response(); 241 } 242 + futures::future::join_all(session_jtis.into_iter().map(|jti| { 243 let cache_key = format!("auth:session:{}:{}", user_did, jti); 244 + let cache = state.cache.clone(); 245 + async move { 246 + if let Err(e) = cache.delete(&cache_key).await { 247 + warn!( 248 + "Failed to invalidate session cache for {}: {:?}", 249 + cache_key, e 250 + ); 251 + } 252 } 253 + })) 254 + .await; 255 info!("Password reset completed for user {}", user_id); 256 EmptyResponse::ok().into_response() 257 }
+84 -54
crates/tranquil-pds/src/api/server/session.rs
··· 705 return ApiError::InternalError(None).into_response(); 706 } 707 }; 708 - let verified_column = match row.channel { 709 - crate::comms::CommsChannel::Email => "email_verified", 710 - crate::comms::CommsChannel::Discord => "discord_verified", 711 - crate::comms::CommsChannel::Telegram => "telegram_verified", 712 - crate::comms::CommsChannel::Signal => "signal_verified", 713 - }; 714 - let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column); 715 - if let Err(e) = sqlx::query(&update_query) 716 - .bind(input.did.as_str()) 717 - .execute(&state.db) 718 - .await 719 - { 720 - error!("Failed to update verification status: {:?}", e); 721 - return ApiError::InternalError(None).into_response(); 722 - } 723 724 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 725 Ok(m) => m, ··· 735 return ApiError::InternalError(None).into_response(); 736 } 737 }; 738 let no_scope: Option<String> = None; 739 if let Err(e) = sqlx::query!( 740 "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ··· 747 false, 748 no_scope 749 ) 750 - .execute(&state.db) 751 .await 752 { 753 error!("Failed to insert session: {:?}", e); 754 return ApiError::InternalError(None).into_response(); 755 } 756 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 757 if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await { 758 warn!("Failed to enqueue welcome notification: {:?}", e); ··· 878 .and_then(|v| v.strip_prefix("Bearer ")) 879 .and_then(|token| crate::auth::get_jti_from_token(token).ok()); 880 881 - let mut sessions: Vec<SessionInfo> = Vec::new(); 882 - 883 - let jwt_result = sqlx::query_as::< 884 _, 885 ( 886 i32, ··· 898 ) 899 .bind(&auth.0.did) 900 .fetch_all(&state.db) 901 - .await; 902 - 903 - match jwt_result { 904 - Ok(rows) => { 905 - for (id, access_jti, created_at, expires_at) in rows { 906 - sessions.push(SessionInfo { 907 - id: format!("jwt:{}", id), 908 - session_type: "legacy".to_string(), 909 - client_name: None, 910 - created_at: created_at.to_rfc3339(), 911 - expires_at: expires_at.to_rfc3339(), 912 - is_current: current_jti.as_ref() == Some(&access_jti), 913 - }); 914 - } 915 - } 916 Err(e) => { 917 error!("DB error fetching JWT sessions: {:?}", e); 918 return ApiError::InternalError(None).into_response(); 919 } 920 - } 921 922 - let oauth_result = sqlx::query_as::< 923 _, 924 ( 925 i32, ··· 938 ) 939 .bind(&auth.0.did) 940 .fetch_all(&state.db) 941 - .await; 942 943 - match oauth_result { 944 - Ok(rows) => { 945 - for (id, token_id, created_at, expires_at, client_id) in rows { 946 let client_name = extract_client_name(&client_id); 947 - let is_current_oauth = auth.0.is_oauth && current_jti.as_ref() == Some(&token_id); 948 - sessions.push(SessionInfo { 949 id: format!("oauth:{}", id), 950 session_type: "oauth".to_string(), 951 client_name: Some(client_name), 952 created_at: created_at.to_rfc3339(), 953 expires_at: expires_at.to_rfc3339(), 954 is_current: is_current_oauth, 955 - }); 956 - } 957 - } 958 - Err(e) => { 959 - error!("DB error fetching OAuth sessions: {:?}", e); 960 - return ApiError::InternalError(None).into_response(); 961 - } 962 - } 963 964 sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); 965 966 (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() ··· 1061 return ApiError::InvalidToken(None).into_response(); 1062 }; 1063 1064 if auth.0.is_oauth { 1065 if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1") 1066 .bind(&auth.0.did) 1067 - .execute(&state.db) 1068 .await 1069 { 1070 error!("DB error revoking JWT sessions: {:?}", e); ··· 1073 if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2") 1074 .bind(&auth.0.did) 1075 .bind(jti) 1076 - .execute(&state.db) 1077 .await 1078 { 1079 error!("DB error revoking OAuth sessions: {:?}", e); ··· 1084 sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") 1085 .bind(&auth.0.did) 1086 .bind(jti) 1087 - .execute(&state.db) 1088 .await 1089 { 1090 error!("DB error revoking JWT sessions: {:?}", e); ··· 1092 } 1093 if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1") 1094 .bind(&auth.0.did) 1095 - .execute(&state.db) 1096 .await 1097 { 1098 error!("DB error revoking OAuth sessions: {:?}", e); 1099 return ApiError::InternalError(None).into_response(); 1100 } 1101 } 1102 1103 info!(did = %&auth.0.did, "All other sessions revoked");
··· 705 return ApiError::InternalError(None).into_response(); 706 } 707 }; 708 709 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 710 Ok(m) => m, ··· 720 return ApiError::InternalError(None).into_response(); 721 } 722 }; 723 + 724 + let mut tx = match state.db.begin().await { 725 + Ok(tx) => tx, 726 + Err(e) => { 727 + error!("Failed to begin transaction: {:?}", e); 728 + return ApiError::InternalError(None).into_response(); 729 + } 730 + }; 731 + 732 + let verified_column = match row.channel { 733 + crate::comms::CommsChannel::Email => "email_verified", 734 + crate::comms::CommsChannel::Discord => "discord_verified", 735 + crate::comms::CommsChannel::Telegram => "telegram_verified", 736 + crate::comms::CommsChannel::Signal => "signal_verified", 737 + }; 738 + let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column); 739 + if let Err(e) = sqlx::query(&update_query) 740 + .bind(input.did.as_str()) 741 + .execute(&mut *tx) 742 + .await 743 + { 744 + error!("Failed to update verification status: {:?}", e); 745 + return ApiError::InternalError(None).into_response(); 746 + } 747 + 748 let no_scope: Option<String> = None; 749 if let Err(e) = sqlx::query!( 750 "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ··· 757 false, 758 no_scope 759 ) 760 + .execute(&mut *tx) 761 .await 762 { 763 error!("Failed to insert session: {:?}", e); 764 return ApiError::InternalError(None).into_response(); 765 } 766 + 767 + if let Err(e) = tx.commit().await { 768 + error!("Failed to commit transaction: {:?}", e); 769 + return ApiError::InternalError(None).into_response(); 770 + } 771 + 772 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 773 if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await { 774 warn!("Failed to enqueue welcome notification: {:?}", e); ··· 894 .and_then(|v| v.strip_prefix("Bearer ")) 895 .and_then(|token| crate::auth::get_jti_from_token(token).ok()); 896 897 + let jwt_rows = match sqlx::query_as::< 898 _, 899 ( 900 i32, ··· 912 ) 913 .bind(&auth.0.did) 914 .fetch_all(&state.db) 915 + .await 916 + { 917 + Ok(rows) => rows, 918 Err(e) => { 919 error!("DB error fetching JWT sessions: {:?}", e); 920 return ApiError::InternalError(None).into_response(); 921 } 922 + }; 923 924 + let oauth_rows = match sqlx::query_as::< 925 _, 926 ( 927 i32, ··· 940 ) 941 .bind(&auth.0.did) 942 .fetch_all(&state.db) 943 + .await 944 + { 945 + Ok(rows) => rows, 946 + Err(e) => { 947 + error!("DB error fetching OAuth sessions: {:?}", e); 948 + return ApiError::InternalError(None).into_response(); 949 + } 950 + }; 951 + 952 + let jwt_sessions = jwt_rows 953 + .into_iter() 954 + .map(|(id, access_jti, created_at, expires_at)| SessionInfo { 955 + id: format!("jwt:{}", id), 956 + session_type: "legacy".to_string(), 957 + client_name: None, 958 + created_at: created_at.to_rfc3339(), 959 + expires_at: expires_at.to_rfc3339(), 960 + is_current: current_jti.as_ref() == Some(&access_jti), 961 + }); 962 963 + let is_oauth = auth.0.is_oauth; 964 + let oauth_sessions = 965 + oauth_rows 966 + .into_iter() 967 + .map(|(id, token_id, created_at, expires_at, client_id)| { 968 let client_name = extract_client_name(&client_id); 969 + let is_current_oauth = is_oauth && current_jti.as_ref() == Some(&token_id); 970 + SessionInfo { 971 id: format!("oauth:{}", id), 972 session_type: "oauth".to_string(), 973 client_name: Some(client_name), 974 created_at: created_at.to_rfc3339(), 975 expires_at: expires_at.to_rfc3339(), 976 is_current: is_current_oauth, 977 + } 978 + }); 979 980 + let mut sessions: Vec<SessionInfo> = jwt_sessions.chain(oauth_sessions).collect(); 981 sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); 982 983 (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() ··· 1078 return ApiError::InvalidToken(None).into_response(); 1079 }; 1080 1081 + let mut tx = match state.db.begin().await { 1082 + Ok(tx) => tx, 1083 + Err(e) => { 1084 + error!("Failed to begin transaction: {:?}", e); 1085 + return ApiError::InternalError(None).into_response(); 1086 + } 1087 + }; 1088 + 1089 if auth.0.is_oauth { 1090 if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1") 1091 .bind(&auth.0.did) 1092 + .execute(&mut *tx) 1093 .await 1094 { 1095 error!("DB error revoking JWT sessions: {:?}", e); ··· 1098 if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2") 1099 .bind(&auth.0.did) 1100 .bind(jti) 1101 + .execute(&mut *tx) 1102 .await 1103 { 1104 error!("DB error revoking OAuth sessions: {:?}", e); ··· 1109 sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") 1110 .bind(&auth.0.did) 1111 .bind(jti) 1112 + .execute(&mut *tx) 1113 .await 1114 { 1115 error!("DB error revoking JWT sessions: {:?}", e); ··· 1117 } 1118 if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1") 1119 .bind(&auth.0.did) 1120 + .execute(&mut *tx) 1121 .await 1122 { 1123 error!("DB error revoking OAuth sessions: {:?}", e); 1124 return ApiError::InternalError(None).into_response(); 1125 } 1126 + } 1127 + 1128 + if let Err(e) = tx.commit().await { 1129 + error!("Failed to commit transaction: {:?}", e); 1130 + return ApiError::InternalError(None).into_response(); 1131 } 1132 1133 info!(did = %&auth.0.did, "All other sessions revoked");
+50 -41
crates/tranquil-pds/src/api/server/totp.rs
··· 195 return ApiError::InternalError(None).into_response(); 196 } 197 198 - for code in &backup_codes { 199 - let hash = match hash_backup_code(code) { 200 - Ok(h) => h, 201 - Err(e) => { 202 - error!("Failed to hash backup code: {:?}", e); 203 - return ApiError::InternalError(None).into_response(); 204 - } 205 - }; 206 - 207 - if let Err(e) = sqlx::query!( 208 - "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", 209 - &auth.0.did, 210 - hash 211 - ) 212 - .execute(&mut *tx) 213 - .await 214 - { 215 - error!("Failed to store backup code: {:?}", e); 216 return ApiError::InternalError(None).into_response(); 217 } 218 } 219 220 if let Err(e) = tx.commit().await { ··· 482 return ApiError::InternalError(None).into_response(); 483 } 484 485 - for code in &backup_codes { 486 - let hash = match hash_backup_code(code) { 487 - Ok(h) => h, 488 - Err(e) => { 489 - error!("Failed to hash backup code: {:?}", e); 490 - return ApiError::InternalError(None).into_response(); 491 - } 492 - }; 493 - 494 - if let Err(e) = sqlx::query!( 495 - "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", 496 - &auth.0.did, 497 - hash 498 - ) 499 - .execute(&mut *tx) 500 - .await 501 - { 502 - error!("Failed to store backup code: {:?}", e); 503 return ApiError::InternalError(None).into_response(); 504 } 505 } 506 507 if let Err(e) = tx.commit().await { ··· 532 } 533 }; 534 535 - for row in backup_codes { 536 - if verify_backup_code(&code, &row.code_hash) { 537 let _ = sqlx::query!( 538 "UPDATE backup_codes SET used_at = $1 WHERE id = $2", 539 Utc::now(), ··· 541 ) 542 .execute(&state.db) 543 .await; 544 - return true; 545 } 546 } 547 - 548 - false 549 } 550 551 pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
··· 195 return ApiError::InternalError(None).into_response(); 196 } 197 198 + let backup_hashes: Result<Vec<_>, _> = 199 + backup_codes.iter().map(|c| hash_backup_code(c)).collect(); 200 + let backup_hashes = match backup_hashes { 201 + Ok(hashes) => hashes, 202 + Err(e) => { 203 + error!("Failed to hash backup code: {:?}", e); 204 return ApiError::InternalError(None).into_response(); 205 } 206 + }; 207 + 208 + if let Err(e) = sqlx::query!( 209 + r#" 210 + INSERT INTO backup_codes (did, code_hash, created_at) 211 + SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash) 212 + "#, 213 + &auth.0.did, 214 + &backup_hashes[..] 215 + ) 216 + .execute(&mut *tx) 217 + .await 218 + { 219 + error!("Failed to store backup codes: {:?}", e); 220 + return ApiError::InternalError(None).into_response(); 221 } 222 223 if let Err(e) = tx.commit().await { ··· 485 return ApiError::InternalError(None).into_response(); 486 } 487 488 + let backup_hashes: Result<Vec<_>, _> = 489 + backup_codes.iter().map(|c| hash_backup_code(c)).collect(); 490 + let backup_hashes = match backup_hashes { 491 + Ok(hashes) => hashes, 492 + Err(e) => { 493 + error!("Failed to hash backup code: {:?}", e); 494 return ApiError::InternalError(None).into_response(); 495 } 496 + }; 497 + 498 + if let Err(e) = sqlx::query!( 499 + r#" 500 + INSERT INTO backup_codes (did, code_hash, created_at) 501 + SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash) 502 + "#, 503 + &auth.0.did, 504 + &backup_hashes[..] 505 + ) 506 + .execute(&mut *tx) 507 + .await 508 + { 509 + error!("Failed to store backup codes: {:?}", e); 510 + return ApiError::InternalError(None).into_response(); 511 } 512 513 if let Err(e) = tx.commit().await { ··· 538 } 539 }; 540 541 + let matched = backup_codes 542 + .iter() 543 + .find(|row| verify_backup_code(&code, &row.code_hash)); 544 + 545 + match matched { 546 + Some(row) => { 547 let _ = sqlx::query!( 548 "UPDATE backup_codes SET used_at = $1 WHERE id = $2", 549 Utc::now(), ··· 551 ) 552 .execute(&state.db) 553 .await; 554 + true 555 } 556 + None => false, 557 } 558 } 559 560 pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
+30 -38
crates/tranquil-pds/src/api/validation.rs
··· 181 if local.starts_with('.') || local.ends_with('.') || local.contains("..") { 182 return Err(EmailValidationError::InvalidLocalPart); 183 } 184 - for c in local.chars() { 185 - if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { 186 - return Err(EmailValidationError::InvalidLocalPart); 187 - } 188 } 189 if domain.is_empty() { 190 return Err(EmailValidationError::EmptyDomain); ··· 195 if !domain.contains('.') { 196 return Err(EmailValidationError::MissingDomainDot); 197 } 198 - for label in domain.split('.') { 199 - if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH { 200 - return Err(EmailValidationError::InvalidDomainLabel); 201 - } 202 - if label.starts_with('-') || label.ends_with('-') { 203 - return Err(EmailValidationError::InvalidDomainLabel); 204 - } 205 - for c in label.chars() { 206 - if !c.is_ascii_alphanumeric() && c != '-' { 207 - return Err(EmailValidationError::InvalidDomainLabel); 208 - } 209 - } 210 } 211 Ok(()) 212 } ··· 293 return Err(HandleValidationError::EndsWithInvalidChar); 294 } 295 296 - for c in handle.chars() { 297 - if !c.is_ascii_alphanumeric() && c != '-' { 298 - return Err(HandleValidationError::InvalidCharacters); 299 - } 300 } 301 302 if crate::moderation::has_explicit_slur(handle) { ··· 330 if local.contains("..") { 331 return false; 332 } 333 - for c in local.chars() { 334 - if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { 335 - return false; 336 - } 337 } 338 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH { 339 return false; ··· 341 if !domain.contains('.') { 342 return false; 343 } 344 - for label in domain.split('.') { 345 - if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH { 346 - return false; 347 - } 348 - if label.starts_with('-') || label.ends_with('-') { 349 - return false; 350 - } 351 - for c in label.chars() { 352 - if !c.is_ascii_alphanumeric() && c != '-' { 353 - return false; 354 - } 355 - } 356 - } 357 - true 358 } 359 360 #[cfg(test)]
··· 181 if local.starts_with('.') || local.ends_with('.') || local.contains("..") { 182 return Err(EmailValidationError::InvalidLocalPart); 183 } 184 + if !local 185 + .chars() 186 + .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) 187 + { 188 + return Err(EmailValidationError::InvalidLocalPart); 189 } 190 if domain.is_empty() { 191 return Err(EmailValidationError::EmptyDomain); ··· 196 if !domain.contains('.') { 197 return Err(EmailValidationError::MissingDomainDot); 198 } 199 + if !domain.split('.').all(|label| { 200 + !label.is_empty() 201 + && label.len() <= MAX_DOMAIN_LABEL_LENGTH 202 + && !label.starts_with('-') 203 + && !label.ends_with('-') 204 + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') 205 + }) { 206 + return Err(EmailValidationError::InvalidDomainLabel); 207 } 208 Ok(()) 209 } ··· 290 return Err(HandleValidationError::EndsWithInvalidChar); 291 } 292 293 + if !handle 294 + .chars() 295 + .all(|c| c.is_ascii_alphanumeric() || c == '-') 296 + { 297 + return Err(HandleValidationError::InvalidCharacters); 298 } 299 300 if crate::moderation::has_explicit_slur(handle) { ··· 328 if local.contains("..") { 329 return false; 330 } 331 + if !local 332 + .chars() 333 + .all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c)) 334 + { 335 + return false; 336 } 337 if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH { 338 return false; ··· 340 if !domain.contains('.') { 341 return false; 342 } 343 + domain.split('.').all(|label| { 344 + !label.is_empty() 345 + && label.len() <= MAX_DOMAIN_LABEL_LENGTH 346 + && !label.starts_with('-') 347 + && !label.ends_with('-') 348 + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') 349 + }) 350 } 351 352 #[cfg(test)]
+5 -2
crates/tranquil-pds/src/auth/mod.rs
··· 61 KeyDecryptionFailed, 62 AuthenticationFailed, 63 TokenExpired, 64 } 65 66 impl fmt::Display for TokenValidationError { ··· 70 Self::AccountTakedown => write!(f, "AccountTakedown"), 71 Self::KeyDecryptionFailed => write!(f, "KeyDecryptionFailed"), 72 Self::AuthenticationFailed => write!(f, "AuthenticationFailed"), 73 - Self::TokenExpired => write!(f, "ExpiredToken"), 74 } 75 } 76 } ··· 497 controller_did: None, 498 }) 499 } 500 - Err(crate::oauth::OAuthError::ExpiredToken(_)) => Err(TokenValidationError::TokenExpired), 501 Err(_) => Err(TokenValidationError::AuthenticationFailed), 502 } 503 }
··· 61 KeyDecryptionFailed, 62 AuthenticationFailed, 63 TokenExpired, 64 + OAuthTokenExpired, 65 } 66 67 impl fmt::Display for TokenValidationError { ··· 71 Self::AccountTakedown => write!(f, "AccountTakedown"), 72 Self::KeyDecryptionFailed => write!(f, "KeyDecryptionFailed"), 73 Self::AuthenticationFailed => write!(f, "AuthenticationFailed"), 74 + Self::TokenExpired | Self::OAuthTokenExpired => write!(f, "ExpiredToken"), 75 } 76 } 77 } ··· 498 controller_did: None, 499 }) 500 } 501 + Err(crate::oauth::OAuthError::ExpiredToken(_)) => { 502 + Err(TokenValidationError::OAuthTokenExpired) 503 + } 504 Err(_) => Err(TokenValidationError::AuthenticationFailed), 505 } 506 }
+8 -9
crates/tranquil-pds/src/auth/verification_token.rs
··· 296 } 297 298 pub fn format_token_for_display(token: &str) -> String { 299 - let clean = token.replace(['-', ' '], ""); 300 - let mut result = String::new(); 301 - for (i, c) in clean.chars().enumerate() { 302 - if i > 0 && i % 4 == 0 { 303 - result.push('-'); 304 - } 305 - result.push(c); 306 - } 307 - result 308 } 309 310 pub fn normalize_token_input(input: &str) -> String {
··· 296 } 297 298 pub fn format_token_for_display(token: &str) -> String { 299 + token 300 + .replace(['-', ' '], "") 301 + .chars() 302 + .collect::<Vec<_>>() 303 + .chunks(4) 304 + .map(|chunk| chunk.iter().collect::<String>()) 305 + .collect::<Vec<_>>() 306 + .join("-") 307 } 308 309 pub fn normalize_token_input(input: &str) -> String {
+3 -7
crates/tranquil-pds/src/oauth/db/scope_preference.rs
··· 75 let stored_scopes: std::collections::HashSet<&str> = 76 stored_prefs.iter().map(|p| p.scope.as_str()).collect(); 77 78 - for scope in requested_scopes { 79 - if !stored_scopes.contains(scope.as_str()) { 80 - return Ok(true); 81 - } 82 - } 83 - 84 - Ok(false) 85 } 86 87 pub async fn delete_scope_preferences(
··· 75 let stored_scopes: std::collections::HashSet<&str> = 76 stored_prefs.iter().map(|p| p.scope.as_str()).collect(); 77 78 + Ok(requested_scopes 79 + .iter() 80 + .any(|scope| !stored_scopes.contains(scope.as_str()))) 81 } 82 83 pub async fn delete_scope_preferences(
+22 -24
crates/tranquil-pds/src/oauth/db/token.rs
··· 179 pub async fn rotate_token( 180 pool: &PgPool, 181 old_db_id: i32, 182 - new_token_id: &str, 183 new_refresh_token: &str, 184 new_expires_at: DateTime<Utc>, 185 ) -> Result<(), OAuthError> { ··· 207 sqlx::query!( 208 r#" 209 UPDATE oauth_token 210 - SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(), 211 - previous_refresh_token = $5, rotated_at = NOW() 212 WHERE id = $1 213 "#, 214 old_db_id, 215 - new_token_id, 216 new_refresh_token, 217 new_expires_at, 218 old_refresh ··· 317 ) 318 .fetch_all(pool) 319 .await?; 320 - let mut tokens = Vec::with_capacity(rows.len()); 321 - for r in rows { 322 - tokens.push(TokenData { 323 - did: r.did, 324 - token_id: r.token_id, 325 - created_at: r.created_at, 326 - updated_at: r.updated_at, 327 - expires_at: r.expires_at, 328 - client_id: r.client_id, 329 - client_auth: from_json(r.client_auth)?, 330 - device_id: r.device_id, 331 - parameters: from_json(r.parameters)?, 332 - details: r.details, 333 - code: r.code, 334 - current_refresh_token: r.current_refresh_token, 335 - scope: r.scope, 336 - controller_did: r.controller_did, 337 - }); 338 - } 339 - Ok(tokens) 340 } 341 342 pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result<i64, OAuthError> {
··· 179 pub async fn rotate_token( 180 pool: &PgPool, 181 old_db_id: i32, 182 new_refresh_token: &str, 183 new_expires_at: DateTime<Utc>, 184 ) -> Result<(), OAuthError> { ··· 206 sqlx::query!( 207 r#" 208 UPDATE oauth_token 209 + SET current_refresh_token = $2, expires_at = $3, updated_at = NOW(), 210 + previous_refresh_token = $4, rotated_at = NOW() 211 WHERE id = $1 212 "#, 213 old_db_id, 214 new_refresh_token, 215 new_expires_at, 216 old_refresh ··· 315 ) 316 .fetch_all(pool) 317 .await?; 318 + rows.into_iter() 319 + .map(|r| { 320 + Ok(TokenData { 321 + did: r.did, 322 + token_id: r.token_id, 323 + created_at: r.created_at, 324 + updated_at: r.updated_at, 325 + expires_at: r.expires_at, 326 + client_id: r.client_id, 327 + client_auth: from_json(r.client_auth)?, 328 + device_id: r.device_id, 329 + parameters: from_json(r.parameters)?, 330 + details: r.details, 331 + code: r.code, 332 + current_refresh_token: r.current_refresh_token, 333 + scope: r.scope, 334 + controller_did: r.controller_did, 335 + }) 336 + }) 337 + .collect() 338 } 339 340 pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result<i64, OAuthError> {
+5 -7
crates/tranquil-pds/src/oauth/endpoints/authorize.rs
··· 102 .get("cookie") 103 .and_then(|v| v.to_str().ok()) 104 .and_then(|cookie_str| { 105 - for cookie in cookie_str.split(';') { 106 - let cookie = cookie.trim(); 107 - if let Some(value) = cookie.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) { 108 - return crate::config::AuthConfig::get().verify_device_cookie(value); 109 - } 110 - } 111 - None 112 }) 113 } 114
··· 102 .get("cookie") 103 .and_then(|v| v.to_str().ok()) 104 .and_then(|cookie_str| { 105 + cookie_str.split(';').map(|c| c.trim()).find_map(|cookie| { 106 + cookie 107 + .strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) 108 + .and_then(|value| crate::config::AuthConfig::get().verify_device_cookie(value)) 109 + }) 110 }) 111 } 112
+34 -32
crates/tranquil-pds/src/oauth/endpoints/par.rs
··· 182 if requested_scopes.is_empty() { 183 return Ok(Some("atproto".to_string())); 184 } 185 - let mut has_transition = false; 186 - let mut has_granular = false; 187 188 - for scope in &requested_scopes { 189 - let parsed = parse_scope(scope); 190 - match &parsed { 191 - ParsedScope::Unknown(_) => { 192 - return Err(OAuthError::InvalidScope(format!( 193 - "Unsupported scope: {}", 194 - scope 195 - ))); 196 - } 197 ParsedScope::TransitionGeneric 198 - | ParsedScope::TransitionChat 199 - | ParsedScope::TransitionEmail => { 200 - has_transition = true; 201 - } 202 ParsedScope::Repo(_) 203 - | ParsedScope::Blob(_) 204 - | ParsedScope::Rpc(_) 205 - | ParsedScope::Account(_) 206 - | ParsedScope::Identity(_) 207 - | ParsedScope::Include(_) => { 208 - has_granular = true; 209 - } 210 - ParsedScope::Atproto => {} 211 - } 212 - } 213 214 if has_transition && has_granular { 215 return Err(OAuthError::InvalidScope( ··· 219 220 if let Some(client_scope) = &client_metadata.scope { 221 let client_scopes: Vec<&str> = client_scope.split_whitespace().collect(); 222 - for scope in &requested_scopes { 223 - if !client_scopes.iter().any(|cs| scope_matches(cs, scope)) { 224 - return Err(OAuthError::InvalidScope(format!( 225 - "Scope '{}' not registered for this client", 226 - scope 227 - ))); 228 - } 229 } 230 } 231 Ok(Some(requested_scopes.join(" ")))
··· 182 if requested_scopes.is_empty() { 183 return Ok(Some("atproto".to_string())); 184 } 185 + if let Some(unknown) = requested_scopes 186 + .iter() 187 + .find(|s| matches!(parse_scope(s), ParsedScope::Unknown(_))) 188 + { 189 + return Err(OAuthError::InvalidScope(format!( 190 + "Unsupported scope: {}", 191 + unknown 192 + ))); 193 + } 194 195 + let has_transition = requested_scopes.iter().any(|s| { 196 + matches!( 197 + parse_scope(s), 198 ParsedScope::TransitionGeneric 199 + | ParsedScope::TransitionChat 200 + | ParsedScope::TransitionEmail 201 + ) 202 + }); 203 + let has_granular = requested_scopes.iter().any(|s| { 204 + matches!( 205 + parse_scope(s), 206 ParsedScope::Repo(_) 207 + | ParsedScope::Blob(_) 208 + | ParsedScope::Rpc(_) 209 + | ParsedScope::Account(_) 210 + | ParsedScope::Identity(_) 211 + | ParsedScope::Include(_) 212 + ) 213 + }); 214 215 if has_transition && has_granular { 216 return Err(OAuthError::InvalidScope( ··· 220 221 if let Some(client_scope) = &client_metadata.scope { 222 let client_scopes: Vec<&str> = client_scope.split_whitespace().collect(); 223 + if let Some(unregistered) = requested_scopes 224 + .iter() 225 + .find(|scope| !client_scopes.iter().any(|cs| scope_matches(cs, scope))) 226 + { 227 + return Err(OAuthError::InvalidScope(format!( 228 + "Scope '{}' not registered for this client", 229 + unregistered 230 + ))); 231 } 232 } 233 Ok(Some(requested_scopes.join(" ")))
+13 -10
crates/tranquil-pds/src/oauth/endpoints/token/grants.rs
··· 24 request: ValidatedTokenRequest, 25 dpop_proof: Option<String>, 26 ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 27 let (code, code_verifier, redirect_uri) = match request.grant { 28 TokenGrant::AuthorizationCode { 29 code, ··· 178 controller_did: controller_did.clone(), 179 }; 180 db::create_token(&state.db, &token_data).await?; 181 tokio::spawn({ 182 let pool = state.db.clone(); 183 let did_clone = did.clone(); ··· 316 } else { 317 None 318 }; 319 - let new_token_id = TokenId::generate(); 320 let new_refresh_token = RefreshToken::generate(); 321 let refresh_expiry_days = if matches!(token_data.client_auth, ClientAuth::None) { 322 REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC ··· 324 REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL 325 }; 326 let new_expires_at = Utc::now() + Duration::days(refresh_expiry_days); 327 - db::rotate_token( 328 - &state.db, 329 - db_id, 330 - &new_token_id.0, 331 - &new_refresh_token.0, 332 - new_expires_at, 333 - ) 334 - .await?; 335 tracing::info!( 336 did = %token_data.did, 337 new_expires_at = %new_expires_at, 338 "Refresh token rotated successfully" 339 ); 340 let access_token = create_access_token_with_delegation( 341 - &new_token_id.0, 342 &token_data.did, 343 dpop_jkt.as_deref(), 344 token_data.scope.as_deref(),
··· 24 request: ValidatedTokenRequest, 25 dpop_proof: Option<String>, 26 ) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> { 27 + tracing::info!( 28 + has_dpop = dpop_proof.is_some(), 29 + client_id = ?request.client_auth.client_id, 30 + "Authorization code grant requested" 31 + ); 32 let (code, code_verifier, redirect_uri) = match request.grant { 33 TokenGrant::AuthorizationCode { 34 code, ··· 183 controller_did: controller_did.clone(), 184 }; 185 db::create_token(&state.db, &token_data).await?; 186 + tracing::info!( 187 + did = %did, 188 + token_id = %token_id.0, 189 + client_id = %auth_request.client_id, 190 + "Authorization code grant completed, token created" 191 + ); 192 tokio::spawn({ 193 let pool = state.db.clone(); 194 let did_clone = did.clone(); ··· 327 } else { 328 None 329 }; 330 let new_refresh_token = RefreshToken::generate(); 331 let refresh_expiry_days = if matches!(token_data.client_auth, ClientAuth::None) { 332 REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC ··· 334 REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL 335 }; 336 let new_expires_at = Utc::now() + Duration::days(refresh_expiry_days); 337 + db::rotate_token(&state.db, db_id, &new_refresh_token.0, new_expires_at).await?; 338 tracing::info!( 339 did = %token_data.did, 340 new_expires_at = %new_expires_at, 341 "Refresh token rotated successfully" 342 ); 343 let access_token = create_access_token_with_delegation( 344 + &token_data.token_id, 345 &token_data.did, 346 dpop_jkt.as_deref(), 347 token_data.scope.as_deref(),
+13 -5
crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs
··· 11 12 pub struct TokenClaims { 13 pub jti: String, 14 pub exp: i64, 15 pub iat: i64, 16 } ··· 33 } 34 35 pub fn create_access_token( 36 - token_id: &str, 37 sub: &str, 38 dpop_jkt: Option<&str>, 39 scope: Option<&str>, 40 ) -> Result<String, OAuthError> { 41 - create_access_token_with_delegation(token_id, sub, dpop_jkt, scope, None) 42 } 43 44 pub fn create_access_token_with_delegation( 45 - token_id: &str, 46 sub: &str, 47 dpop_jkt: Option<&str>, 48 scope: Option<&str>, 49 controller_did: Option<&str>, 50 ) -> Result<String, OAuthError> { 51 use serde_json::json; 52 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 53 let issuer = format!("https://{}", pds_hostname); 54 let now = Utc::now().timestamp(); ··· 60 "aud": issuer, 61 "iat": now, 62 "exp": exp, 63 - "jti": token_id, 64 "scope": actual_scope 65 }); 66 if let Some(jkt) = dpop_jkt { ··· 132 .and_then(|j| j.as_str()) 133 .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? 134 .to_string(); 135 let exp = payload 136 .get("exp") 137 .and_then(|e| e.as_i64()) ··· 140 .get("iat") 141 .and_then(|i| i.as_i64()) 142 .ok_or_else(|| OAuthError::InvalidToken("Missing iat claim".to_string()))?; 143 - Ok(TokenClaims { jti, exp, iat }) 144 }
··· 11 12 pub struct TokenClaims { 13 pub jti: String, 14 + pub sid: String, 15 pub exp: i64, 16 pub iat: i64, 17 } ··· 34 } 35 36 pub fn create_access_token( 37 + session_id: &str, 38 sub: &str, 39 dpop_jkt: Option<&str>, 40 scope: Option<&str>, 41 ) -> Result<String, OAuthError> { 42 + create_access_token_with_delegation(session_id, sub, dpop_jkt, scope, None) 43 } 44 45 pub fn create_access_token_with_delegation( 46 + session_id: &str, 47 sub: &str, 48 dpop_jkt: Option<&str>, 49 scope: Option<&str>, 50 controller_did: Option<&str>, 51 ) -> Result<String, OAuthError> { 52 use serde_json::json; 53 + let jti = uuid::Uuid::new_v4().to_string(); 54 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 55 let issuer = format!("https://{}", pds_hostname); 56 let now = Utc::now().timestamp(); ··· 62 "aud": issuer, 63 "iat": now, 64 "exp": exp, 65 + "jti": jti, 66 + "sid": session_id, 67 "scope": actual_scope 68 }); 69 if let Some(jkt) = dpop_jkt { ··· 135 .and_then(|j| j.as_str()) 136 .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? 137 .to_string(); 138 + let sid = payload 139 + .get("sid") 140 + .and_then(|s| s.as_str()) 141 + .ok_or_else(|| OAuthError::InvalidToken("Missing sid claim".to_string()))? 142 + .to_string(); 143 let exp = payload 144 .get("exp") 145 .and_then(|e| e.as_i64()) ··· 148 .get("iat") 149 .and_then(|i| i.as_i64()) 150 .ok_or_else(|| OAuthError::InvalidToken("Missing iat claim".to_string()))?; 151 + Ok(TokenClaims { jti, sid, exp, iat }) 152 }
+1 -1
crates/tranquil-pds/src/oauth/endpoints/token/introspect.rs
··· 102 Ok(info) => info, 103 Err(_) => return Ok(Json(inactive_response)), 104 }; 105 - let token_data = match db::get_token_by_id(&state.db, &token_info.jti).await { 106 Ok(Some(data)) => data, 107 _ => return Ok(Json(inactive_response)), 108 };
··· 102 Ok(info) => info, 103 Err(_) => return Ok(Json(inactive_response)), 104 }; 105 + let token_data = match db::get_token_by_id(&state.db, &token_info.sid).await { 106 Ok(Some(data)) => data, 107 _ => return Ok(Json(inactive_response)), 108 };
+2 -2
crates/tranquil-pds/src/oauth/verify.rs
··· 142 return Err(OAuthError::ExpiredToken("Token has expired".to_string())); 143 } 144 let token_id = payload 145 - .get("jti") 146 .and_then(|j| j.as_str()) 147 - .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? 148 .to_string(); 149 let did = payload 150 .get("sub")
··· 142 return Err(OAuthError::ExpiredToken("Token has expired".to_string())); 143 } 144 let token_id = payload 145 + .get("sid") 146 .and_then(|j| j.as_str()) 147 + .ok_or_else(|| OAuthError::InvalidToken("Missing sid claim".to_string()))? 148 .to_string(); 149 let did = payload 150 .get("sub")
+1 -1
crates/tranquil-pds/src/rate_limit.rs
··· 48 NonZeroU32::new(10).unwrap(), 49 ))), 50 oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute( 51 - NonZeroU32::new(30).unwrap(), 52 ))), 53 oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute( 54 NonZeroU32::new(10).unwrap(),
··· 48 NonZeroU32::new(10).unwrap(), 49 ))), 50 oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute( 51 + NonZeroU32::new(300).unwrap(), 52 ))), 53 oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute( 54 NonZeroU32::new(10).unwrap(),
+1 -1
crates/tranquil-pds/src/state.rs
··· 71 Self::PasswordReset => (5, 3_600_000), 72 Self::ResetPassword => (10, 60_000), 73 Self::RefreshSession => (60, 60_000), 74 - Self::OAuthToken => (30, 60_000), 75 Self::OAuthAuthorize => (10, 60_000), 76 Self::OAuthPar => (30, 60_000), 77 Self::OAuthIntrospect => (30, 60_000),
··· 71 Self::PasswordReset => (5, 3_600_000), 72 Self::ResetPassword => (10, 60_000), 73 Self::RefreshSession => (60, 60_000), 74 + Self::OAuthToken => (300, 60_000), 75 Self::OAuthAuthorize => (10, 60_000), 76 Self::OAuthPar => (30, 60_000), 77 Self::OAuthIntrospect => (30, 60_000),
+2 -6
crates/tranquil-pds/src/sync/deprecated.rs
··· 146 stack.push(*cid); 147 } 148 Ipld::Map(map) => { 149 - for v in map.values() { 150 - extract_links_ipld(v, stack); 151 - } 152 } 153 Ipld::List(arr) => { 154 - for v in arr { 155 - extract_links_ipld(v, stack); 156 - } 157 } 158 _ => {} 159 }
··· 146 stack.push(*cid); 147 } 148 Ipld::Map(map) => { 149 + map.values().for_each(|v| extract_links_ipld(v, stack)); 150 } 151 Ipld::List(arr) => { 152 + arr.iter().for_each(|v| extract_links_ipld(v, stack)); 153 } 154 _ => {} 155 }
+2 -6
crates/tranquil-pds/src/sync/import.rs
··· 148 links.push(*cid); 149 } 150 Ipld::Map(map) => { 151 - for v in map.values() { 152 - extract_links(v, links); 153 - } 154 } 155 Ipld::List(arr) => { 156 - for v in arr { 157 - extract_links(v, links); 158 - } 159 } 160 _ => {} 161 }
··· 148 links.push(*cid); 149 } 150 Ipld::Map(map) => { 151 + map.values().for_each(|v| extract_links(v, links)); 152 } 153 Ipld::List(arr) => { 154 + arr.iter().for_each(|v| extract_links(v, links)); 155 } 156 _ => {} 157 }
+22 -20
crates/tranquil-pds/src/sync/repo.rs
··· 181 } 182 }; 183 184 - let mut block_cids: Vec<Cid> = Vec::new(); 185 - for event in &events { 186 - if let Some(cids) = &event.blocks_cids { 187 - for cid_str in cids { 188 - if let Ok(cid) = Cid::from_str(cid_str) 189 - && !block_cids.contains(&cid) 190 - { 191 - block_cids.push(cid); 192 - } 193 } 194 - } 195 - if let Some(commit_cid_str) = &event.commit_cid 196 - && let Ok(cid) = Cid::from_str(commit_cid_str) 197 - && !block_cids.contains(&cid) 198 - { 199 - block_cids.push(cid); 200 - } 201 - } 202 203 let mut car_bytes = match encode_car_header(head_cid) { 204 Ok(h) => h, ··· 334 car.extend_from_slice(&writer); 335 }; 336 write_block(&mut car_bytes, &commit_cid, &commit_bytes); 337 - for (cid, data) in &proof_blocks { 338 - write_block(&mut car_bytes, cid, data); 339 - } 340 write_block(&mut car_bytes, &record_cid, &record_block); 341 ( 342 StatusCode::OK,
··· 181 } 182 }; 183 184 + let block_cids: Vec<Cid> = events 185 + .iter() 186 + .flat_map(|event| { 187 + let block_cids = event 188 + .blocks_cids 189 + .as_ref() 190 + .map(|cids| cids.iter().filter_map(|s| Cid::from_str(s).ok()).collect()) 191 + .unwrap_or_else(Vec::new); 192 + let commit_cid = event 193 + .commit_cid 194 + .as_ref() 195 + .and_then(|s| Cid::from_str(s).ok()); 196 + block_cids.into_iter().chain(commit_cid) 197 + }) 198 + .fold(Vec::new(), |mut acc, cid| { 199 + if !acc.contains(&cid) { 200 + acc.push(cid); 201 } 202 + acc 203 + }); 204 205 let mut car_bytes = match encode_car_header(head_cid) { 206 Ok(h) => h, ··· 336 car.extend_from_slice(&writer); 337 }; 338 write_block(&mut car_bytes, &commit_cid, &commit_bytes); 339 + proof_blocks 340 + .iter() 341 + .for_each(|(cid, data)| write_block(&mut car_bytes, cid, data)); 342 write_block(&mut car_bytes, &record_cid, &record_block); 343 ( 344 StatusCode::OK,
+43 -57
crates/tranquil-pds/src/sync/util.rs
··· 210 let mut buffer = Cursor::new(Vec::new()); 211 let header = CarHeader::new_v1(vec![commit_cid]); 212 let mut writer = CarWriter::new(header, &mut buffer); 213 - for (cid, data) in other_blocks { 214 - if cid != commit_cid { 215 - writer 216 - .write(cid, data.as_ref()) 217 - .await 218 - .map_err(|e| anyhow::anyhow!("writing block {}: {}", cid, e))?; 219 - } 220 } 221 if let Some(data) = commit_bytes { 222 writer ··· 360 } 361 let car_bytes = if !all_cids.is_empty() { 362 let fetched = state.block_store.get_many(&all_cids).await?; 363 - let mut blocks = std::collections::BTreeMap::new(); 364 - let mut commit_bytes: Option<Bytes> = None; 365 - for (cid, data_opt) in all_cids.iter().zip(fetched.iter()) { 366 - if let Some(data) = data_opt { 367 - if *cid == commit_cid { 368 - commit_bytes = Some(data.clone()); 369 - if let Some(rev) = extract_rev_from_commit_bytes(data) { 370 - frame.rev = rev; 371 - } 372 - } else { 373 - blocks.insert(*cid, data.clone()); 374 - } 375 - } 376 } 377 write_car_blocks(commit_cid, commit_bytes, blocks).await? 378 } else { 379 Vec::new() ··· 393 state: &AppState, 394 events: &[SequencedEvent], 395 ) -> Result<HashMap<Cid, Bytes>, anyhow::Error> { 396 - let mut all_cids: Vec<Cid> = Vec::new(); 397 - for event in events { 398 - if let Some(ref commit_cid_str) = event.commit_cid 399 - && let Ok(cid) = Cid::from_str(commit_cid_str) 400 - { 401 - all_cids.push(cid); 402 - } 403 - if let Some(ref prev_cid_str) = event.prev_cid 404 - && let Ok(cid) = Cid::from_str(prev_cid_str) 405 - { 406 - all_cids.push(cid); 407 - } 408 - if let Some(ref block_cids_str) = event.blocks_cids { 409 - for s in block_cids_str { 410 - if let Ok(cid) = Cid::from_str(s) { 411 - all_cids.push(cid); 412 - } 413 - } 414 - } 415 - } 416 all_cids.sort(); 417 all_cids.dedup(); 418 if all_cids.is_empty() { 419 return Ok(HashMap::new()); 420 } 421 let fetched = state.block_store.get_many(&all_cids).await?; 422 - let mut blocks_map = HashMap::with_capacity(all_cids.len()); 423 - for (cid, data_opt) in all_cids.into_iter().zip(fetched.into_iter()) { 424 - if let Some(data) = data_opt { 425 - blocks_map.insert(cid, data); 426 - } 427 - } 428 Ok(blocks_map) 429 } 430 ··· 511 frame.since = Some(rev); 512 } 513 let car_bytes = if !all_cids.is_empty() { 514 - let mut blocks = BTreeMap::new(); 515 - let mut commit_bytes_for_car: Option<Bytes> = None; 516 - for cid in all_cids { 517 - if let Some(data) = prefetched.get(&cid) { 518 - if cid == commit_cid { 519 - commit_bytes_for_car = Some(data.clone()); 520 - } else { 521 - blocks.insert(cid, data.clone()); 522 - } 523 - } 524 - } 525 write_car_blocks(commit_cid, commit_bytes_for_car, blocks).await? 526 } else { 527 Vec::new()
··· 210 let mut buffer = Cursor::new(Vec::new()); 211 let header = CarHeader::new_v1(vec![commit_cid]); 212 let mut writer = CarWriter::new(header, &mut buffer); 213 + for (cid, data) in other_blocks.iter().filter(|(c, _)| **c != commit_cid) { 214 + writer 215 + .write(*cid, data.as_ref()) 216 + .await 217 + .map_err(|e| anyhow::anyhow!("writing block {}: {}", cid, e))?; 218 } 219 if let Some(data) = commit_bytes { 220 writer ··· 358 } 359 let car_bytes = if !all_cids.is_empty() { 360 let fetched = state.block_store.get_many(&all_cids).await?; 361 + let (commit_data, other_blocks): (Vec<_>, Vec<_>) = all_cids 362 + .iter() 363 + .zip(fetched.iter()) 364 + .filter_map(|(cid, data_opt)| data_opt.as_ref().map(|data| (*cid, data.clone()))) 365 + .partition(|(cid, _)| *cid == commit_cid); 366 + let commit_bytes = commit_data.into_iter().next().map(|(_, data)| data); 367 + if let Some(ref cb) = commit_bytes 368 + && let Some(rev) = extract_rev_from_commit_bytes(cb) 369 + { 370 + frame.rev = rev; 371 } 372 + let blocks: std::collections::BTreeMap<Cid, Bytes> = other_blocks.into_iter().collect(); 373 write_car_blocks(commit_cid, commit_bytes, blocks).await? 374 } else { 375 Vec::new() ··· 389 state: &AppState, 390 events: &[SequencedEvent], 391 ) -> Result<HashMap<Cid, Bytes>, anyhow::Error> { 392 + let mut all_cids: Vec<Cid> = events 393 + .iter() 394 + .flat_map(|event| { 395 + let commit_cid = event 396 + .commit_cid 397 + .as_ref() 398 + .and_then(|s| Cid::from_str(s).ok()); 399 + let prev_cid = event.prev_cid.as_ref().and_then(|s| Cid::from_str(s).ok()); 400 + let block_cids = event 401 + .blocks_cids 402 + .as_ref() 403 + .map(|cids| cids.iter().filter_map(|s| Cid::from_str(s).ok()).collect()) 404 + .unwrap_or_else(Vec::new); 405 + commit_cid.into_iter().chain(prev_cid).chain(block_cids) 406 + }) 407 + .collect(); 408 all_cids.sort(); 409 all_cids.dedup(); 410 if all_cids.is_empty() { 411 return Ok(HashMap::new()); 412 } 413 let fetched = state.block_store.get_many(&all_cids).await?; 414 + let blocks_map: HashMap<Cid, Bytes> = all_cids 415 + .into_iter() 416 + .zip(fetched) 417 + .filter_map(|(cid, data_opt)| data_opt.map(|data| (cid, data))) 418 + .collect(); 419 Ok(blocks_map) 420 } 421 ··· 502 frame.since = Some(rev); 503 } 504 let car_bytes = if !all_cids.is_empty() { 505 + let (commit_data, other_blocks): (Vec<_>, Vec<_>) = all_cids 506 + .into_iter() 507 + .filter_map(|cid| prefetched.get(&cid).map(|data| (cid, data.clone()))) 508 + .partition(|(cid, _)| *cid == commit_cid); 509 + let commit_bytes_for_car = commit_data.into_iter().next().map(|(_, data)| data); 510 + let blocks: BTreeMap<Cid, Bytes> = other_blocks.into_iter().collect(); 511 write_car_blocks(commit_cid, commit_bytes_for_car, blocks).await? 512 } else { 513 Vec::new()
+21 -18
crates/tranquil-pds/src/util.rs
··· 106 pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> { 107 query 108 .map(|q| { 109 - let mut values = Vec::new(); 110 - for pair in q.split('&') { 111 - if let Some((k, v)) = pair.split_once('=') 112 - && k == key 113 - && let Ok(decoded) = urlencoding::decode(v) 114 - { 115 - let decoded = decoded.into_owned(); 116 if decoded.contains(',') { 117 - for part in decoded.split(',') { 118 - let trimmed = part.trim(); 119 - if !trimmed.is_empty() { 120 - values.push(trimmed.to_string()); 121 - } 122 - } 123 - } else if !decoded.is_empty() { 124 - values.push(decoded); 125 } 126 - } 127 - } 128 - values 129 }) 130 .unwrap_or_default() 131 }
··· 106 pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> { 107 query 108 .map(|q| { 109 + q.split('&') 110 + .filter_map(|pair| { 111 + pair.split_once('=') 112 + .filter(|(k, _)| *k == key) 113 + .and_then(|(_, v)| urlencoding::decode(v).ok()) 114 + .map(|decoded| decoded.into_owned()) 115 + }) 116 + .flat_map(|decoded| { 117 if decoded.contains(',') { 118 + decoded 119 + .split(',') 120 + .filter_map(|part| { 121 + let trimmed = part.trim(); 122 + (!trimmed.is_empty()).then(|| trimmed.to_string()) 123 + }) 124 + .collect::<Vec<_>>() 125 + } else if decoded.is_empty() { 126 + vec![] 127 + } else { 128 + vec![decoded] 129 } 130 + }) 131 + .collect() 132 }) 133 .unwrap_or_default() 134 }
+199 -4
crates/tranquil-pds/tests/common/mod.rs
··· 5 use reqwest::{Client, StatusCode, header}; 6 use serde_json::{Value, json}; 7 use sqlx::postgres::PgPoolOptions; 8 - #[allow(unused_imports)] 9 use std::collections::HashMap; 10 - use std::sync::OnceLock; 11 #[allow(unused_imports)] 12 use std::time::Duration; 13 use tokio::net::TcpListener; 14 use tranquil_pds::state::AppState; 15 use wiremock::matchers::{method, path}; 16 - use wiremock::{Mock, MockServer, ResponseTemplate}; 17 18 static SERVER_URL: OnceLock<String> = OnceLock::new(); 19 static APP_PORT: OnceLock<u16> = OnceLock::new(); 20 static MOCK_APPVIEW: OnceLock<MockServer> = OnceLock::new(); 21 static TEST_DB_POOL: OnceLock<sqlx::PgPool> = OnceLock::new(); 22 23 #[cfg(not(feature = "external-infra"))] ··· 117 std::env::var("DATABASE_URL").expect("DATABASE_URL must be set when using external infra"); 118 let s3_endpoint = 119 std::env::var("S3_ENDPOINT").expect("S3_ENDPOINT must be set when using external infra"); 120 unsafe { 121 std::env::set_var( 122 "S3_BUCKET", ··· 137 std::env::set_var("S3_ENDPOINT", &s3_endpoint); 138 std::env::set_var("MAX_IMPORT_SIZE", "100000000"); 139 std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); 140 } 141 let mock_server = MockServer::start().await; 142 setup_mock_appview(&mock_server).await; ··· 164 .await 165 .expect("Failed to get S3 port"); 166 let s3_endpoint = format!("http://127.0.0.1:{}", s3_port); 167 unsafe { 168 std::env::set_var("S3_BUCKET", "test-bucket"); 169 std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin"); ··· 172 std::env::set_var("S3_ENDPOINT", &s3_endpoint); 173 std::env::set_var("MAX_IMPORT_SIZE", "100000000"); 174 std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); 175 } 176 let sdk_config = aws_config::defaults(BehaviorVersion::latest()) 177 .region("us-east-1") ··· 239 240 async fn setup_mock_appview(_mock_server: &MockServer) {} 241 242 async fn spawn_app(database_url: String) -> String { 243 use tranquil_pds::rate_limit::RateLimiters; 244 let pool = PgPoolOptions::new() 245 - .max_connections(3) 246 .acquire_timeout(std::time::Duration::from_secs(30)) 247 .connect(&database_url) 248 .await
··· 5 use reqwest::{Client, StatusCode, header}; 6 use serde_json::{Value, json}; 7 use sqlx::postgres::PgPoolOptions; 8 use std::collections::HashMap; 9 + use std::sync::{Arc, OnceLock, RwLock}; 10 #[allow(unused_imports)] 11 use std::time::Duration; 12 use tokio::net::TcpListener; 13 use tranquil_pds::state::AppState; 14 use wiremock::matchers::{method, path}; 15 + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; 16 17 static SERVER_URL: OnceLock<String> = OnceLock::new(); 18 static APP_PORT: OnceLock<u16> = OnceLock::new(); 19 static MOCK_APPVIEW: OnceLock<MockServer> = OnceLock::new(); 20 + static MOCK_PLC: OnceLock<MockServer> = OnceLock::new(); 21 static TEST_DB_POOL: OnceLock<sqlx::PgPool> = OnceLock::new(); 22 23 #[cfg(not(feature = "external-infra"))] ··· 117 std::env::var("DATABASE_URL").expect("DATABASE_URL must be set when using external infra"); 118 let s3_endpoint = 119 std::env::var("S3_ENDPOINT").expect("S3_ENDPOINT must be set when using external infra"); 120 + let plc_url = setup_mock_plc_directory().await; 121 unsafe { 122 std::env::set_var( 123 "S3_BUCKET", ··· 138 std::env::set_var("S3_ENDPOINT", &s3_endpoint); 139 std::env::set_var("MAX_IMPORT_SIZE", "100000000"); 140 std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); 141 + std::env::set_var("PLC_DIRECTORY_URL", &plc_url); 142 } 143 let mock_server = MockServer::start().await; 144 setup_mock_appview(&mock_server).await; ··· 166 .await 167 .expect("Failed to get S3 port"); 168 let s3_endpoint = format!("http://127.0.0.1:{}", s3_port); 169 + let plc_url = setup_mock_plc_directory().await; 170 unsafe { 171 std::env::set_var("S3_BUCKET", "test-bucket"); 172 std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin"); ··· 175 std::env::set_var("S3_ENDPOINT", &s3_endpoint); 176 std::env::set_var("MAX_IMPORT_SIZE", "100000000"); 177 std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); 178 + std::env::set_var("PLC_DIRECTORY_URL", &plc_url); 179 } 180 let sdk_config = aws_config::defaults(BehaviorVersion::latest()) 181 .region("us-east-1") ··· 243 244 async fn setup_mock_appview(_mock_server: &MockServer) {} 245 246 + type PlcOperationStore = Arc<RwLock<HashMap<String, Value>>>; 247 + 248 + struct PlcPostResponder { 249 + store: PlcOperationStore, 250 + } 251 + 252 + impl Respond for PlcPostResponder { 253 + fn respond(&self, request: &Request) -> ResponseTemplate { 254 + let path = request.url.path(); 255 + let did = urlencoding::decode(path.trim_start_matches('/')) 256 + .unwrap_or_default() 257 + .to_string(); 258 + 259 + if let Ok(body) = serde_json::from_slice::<Value>(request.body.as_slice()) 260 + && let Ok(mut store) = self.store.write() 261 + { 262 + store.insert(did, body); 263 + } 264 + ResponseTemplate::new(200) 265 + } 266 + } 267 + 268 + struct PlcGetResponder { 269 + store: PlcOperationStore, 270 + } 271 + 272 + impl Respond for PlcGetResponder { 273 + fn respond(&self, request: &Request) -> ResponseTemplate { 274 + let path = request.url.path(); 275 + let path_clean = path.trim_start_matches('/'); 276 + 277 + let (did, endpoint) = path_clean 278 + .find("/log/") 279 + .or_else(|| path_clean.find("/data")) 280 + .map(|idx| { 281 + let did = urlencoding::decode(&path_clean[..idx]) 282 + .unwrap_or_default() 283 + .to_string(); 284 + let endpoint = &path_clean[idx..]; 285 + (did, endpoint) 286 + }) 287 + .unwrap_or_else(|| { 288 + ( 289 + urlencoding::decode(path_clean) 290 + .unwrap_or_default() 291 + .to_string(), 292 + "", 293 + ) 294 + }); 295 + 296 + let store = self.store.read().unwrap(); 297 + let operation = store.get(&did); 298 + 299 + match endpoint { 300 + "/log/last" => { 301 + let response = operation.cloned().unwrap_or_else(|| { 302 + json!({ 303 + "type": "plc_operation", 304 + "rotationKeys": [], 305 + "verificationMethods": {}, 306 + "alsoKnownAs": [], 307 + "services": {}, 308 + "prev": null 309 + }) 310 + }); 311 + ResponseTemplate::new(200).set_body_json(response) 312 + } 313 + "/log/audit" => ResponseTemplate::new(200).set_body_json(json!([])), 314 + "/data" => { 315 + let response = operation 316 + .map(|op| { 317 + json!({ 318 + "rotationKeys": op.get("rotationKeys").cloned().unwrap_or(json!([])), 319 + "verificationMethods": op.get("verificationMethods").cloned().unwrap_or(json!({})), 320 + "alsoKnownAs": op.get("alsoKnownAs").cloned().unwrap_or(json!([])), 321 + "services": op.get("services").cloned().unwrap_or(json!({})) 322 + }) 323 + }) 324 + .unwrap_or_else(|| { 325 + json!({ 326 + "rotationKeys": [], 327 + "verificationMethods": {}, 328 + "alsoKnownAs": [], 329 + "services": {} 330 + }) 331 + }); 332 + ResponseTemplate::new(200).set_body_json(response) 333 + } 334 + _ => { 335 + let did_doc = operation 336 + .map(|op| operation_to_did_document(&did, op)) 337 + .unwrap_or_else(|| { 338 + json!({ 339 + "@context": ["https://www.w3.org/ns/did/v1"], 340 + "id": did, 341 + "alsoKnownAs": [], 342 + "verificationMethod": [], 343 + "service": [] 344 + }) 345 + }); 346 + ResponseTemplate::new(200).set_body_json(did_doc) 347 + } 348 + } 349 + } 350 + } 351 + 352 + fn operation_to_did_document(did: &str, op: &Value) -> Value { 353 + let also_known_as = op 354 + .get("alsoKnownAs") 355 + .and_then(|v| v.as_array()) 356 + .cloned() 357 + .unwrap_or_default(); 358 + 359 + let verification_methods: Vec<Value> = op 360 + .get("verificationMethods") 361 + .and_then(|v| v.as_object()) 362 + .map(|methods| { 363 + methods 364 + .iter() 365 + .map(|(key, value)| { 366 + let did_key = value.as_str().unwrap_or(""); 367 + let multikey = did_key_to_multikey(did_key); 368 + json!({ 369 + "id": format!("{}#{}", did, key), 370 + "type": "Multikey", 371 + "controller": did, 372 + "publicKeyMultibase": multikey 373 + }) 374 + }) 375 + .collect() 376 + }) 377 + .unwrap_or_default(); 378 + 379 + let services: Vec<Value> = op 380 + .get("services") 381 + .and_then(|v| v.as_object()) 382 + .map(|svcs| { 383 + svcs.iter() 384 + .map(|(key, value)| { 385 + json!({ 386 + "id": format!("#{}", key), 387 + "type": value.get("type").and_then(|t| t.as_str()).unwrap_or(""), 388 + "serviceEndpoint": value.get("endpoint").and_then(|e| e.as_str()).unwrap_or("") 389 + }) 390 + }) 391 + .collect() 392 + }) 393 + .unwrap_or_default(); 394 + 395 + json!({ 396 + "@context": [ 397 + "https://www.w3.org/ns/did/v1", 398 + "https://w3id.org/security/multikey/v1" 399 + ], 400 + "id": did, 401 + "alsoKnownAs": also_known_as, 402 + "verificationMethod": verification_methods, 403 + "service": services 404 + }) 405 + } 406 + 407 + fn did_key_to_multikey(did_key: &str) -> String { 408 + if !did_key.starts_with("did:key:z") { 409 + return String::new(); 410 + } 411 + did_key[8..].to_string() 412 + } 413 + 414 + async fn setup_mock_plc_directory() -> String { 415 + let mock_plc = MockServer::start().await; 416 + let store: PlcOperationStore = Arc::new(RwLock::new(HashMap::new())); 417 + 418 + Mock::given(method("POST")) 419 + .respond_with(PlcPostResponder { 420 + store: store.clone(), 421 + }) 422 + .mount(&mock_plc) 423 + .await; 424 + 425 + Mock::given(method("GET")) 426 + .respond_with(PlcGetResponder { 427 + store: store.clone(), 428 + }) 429 + .mount(&mock_plc) 430 + .await; 431 + 432 + let plc_url = mock_plc.uri(); 433 + MOCK_PLC.set(mock_plc).ok(); 434 + plc_url 435 + } 436 + 437 async fn spawn_app(database_url: String) -> String { 438 use tranquil_pds::rate_limit::RateLimiters; 439 let pool = PgPoolOptions::new() 440 + .max_connections(10) 441 .acquire_timeout(std::time::Duration::from_secs(30)) 442 .connect(&database_url) 443 .await
+11 -7
crates/tranquil-pds/tests/helpers/mod.rs
··· 4 5 pub use crate::common::*; 6 7 #[allow(dead_code)] 8 pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { 9 let client = client(); 10 - let ts = Utc::now().timestamp_millis(); 11 - let handle = format!("{}-{}.test", handle_prefix, ts); 12 - let email = format!("{}-{}@test.com", handle_prefix, ts); 13 let password = "E2epass123!"; 14 let create_account_payload = json!({ 15 "handle": handle, ··· 51 text: &str, 52 ) -> (String, String) { 53 let collection = "app.bsky.feed.post"; 54 - let rkey = format!("e2e_social_{}", Utc::now().timestamp_millis()); 55 let now = Utc::now().to_rfc3339(); 56 let create_payload = json!({ 57 "repo": did, ··· 95 followee_did: &str, 96 ) -> (String, String) { 97 let collection = "app.bsky.graph.follow"; 98 - let rkey = format!("e2e_follow_{}", Utc::now().timestamp_millis()); 99 let now = Utc::now().to_rfc3339(); 100 let create_payload = json!({ 101 "repo": follower_did, ··· 140 subject_cid: &str, 141 ) -> (String, String) { 142 let collection = "app.bsky.feed.like"; 143 - let rkey = format!("e2e_like_{}", Utc::now().timestamp_millis()); 144 let now = Utc::now().to_rfc3339(); 145 let payload = json!({ 146 "repo": liker_did, ··· 182 subject_cid: &str, 183 ) -> (String, String) { 184 let collection = "app.bsky.feed.repost"; 185 - let rkey = format!("e2e_repost_{}", Utc::now().timestamp_millis()); 186 let now = Utc::now().to_rfc3339(); 187 let payload = json!({ 188 "repo": reposter_did,
··· 4 5 pub use crate::common::*; 6 7 + fn unique_id() -> String { 8 + uuid::Uuid::new_v4().simple().to_string()[..12].to_string() 9 + } 10 + 11 #[allow(dead_code)] 12 pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { 13 let client = client(); 14 + let uid = unique_id(); 15 + let handle = format!("{}-{}.test", handle_prefix, uid); 16 + let email = format!("{}-{}@test.com", handle_prefix, uid); 17 let password = "E2epass123!"; 18 let create_account_payload = json!({ 19 "handle": handle, ··· 55 text: &str, 56 ) -> (String, String) { 57 let collection = "app.bsky.feed.post"; 58 + let rkey = format!("e2e_social_{}", unique_id()); 59 let now = Utc::now().to_rfc3339(); 60 let create_payload = json!({ 61 "repo": did, ··· 99 followee_did: &str, 100 ) -> (String, String) { 101 let collection = "app.bsky.graph.follow"; 102 + let rkey = format!("e2e_follow_{}", unique_id()); 103 let now = Utc::now().to_rfc3339(); 104 let create_payload = json!({ 105 "repo": follower_did, ··· 144 subject_cid: &str, 145 ) -> (String, String) { 146 let collection = "app.bsky.feed.like"; 147 + let rkey = format!("e2e_like_{}", unique_id()); 148 let now = Utc::now().to_rfc3339(); 149 let payload = json!({ 150 "repo": liker_did, ··· 186 subject_cid: &str, 187 ) -> (String, String) { 188 let collection = "app.bsky.feed.repost"; 189 + let rkey = format!("e2e_repost_{}", unique_id()); 190 let now = Utc::now().to_rfc3339(); 191 let payload = json!({ 192 "repo": reposter_did,
+1 -4
crates/tranquil-pds/tests/import_verification.rs
··· 159 let status = import_res.status(); 160 if status != StatusCode::OK { 161 let body = import_res.text().await.unwrap_or_default(); 162 - panic!( 163 - "Import failed with status {}: {}", 164 - status, body 165 - ); 166 } 167 } 168
··· 159 let status = import_res.status(); 160 if status != StatusCode::OK { 161 let body = import_res.text().await.unwrap_or_default(); 162 + panic!("Import failed with status {}: {}", status, body); 163 } 164 } 165
+47 -48
crates/tranquil-scopes/src/parser.rs
··· 55 if self.accept.is_empty() || self.accept.contains("*/*") { 56 return true; 57 } 58 - for pattern in &self.accept { 59 - if pattern == mime { 60 - return true; 61 - } 62 - if let Some(prefix) = pattern.strip_suffix("/*") 63 - && mime.starts_with(prefix) 64 - && mime.chars().nth(prefix.len()) == Some('/') 65 - { 66 - return true; 67 - } 68 - } 69 - false 70 } 71 } 72 ··· 170 Some(rest.to_string()) 171 }; 172 173 - let mut actions = HashSet::new(); 174 - if let Some(action_values) = params.get("action") { 175 - for action_str in action_values { 176 - if let Some(action) = RepoAction::parse_str(action_str) { 177 - actions.insert(action); 178 - } 179 - } 180 - } 181 - if actions.is_empty() { 182 - actions.insert(RepoAction::Create); 183 - actions.insert(RepoAction::Update); 184 - actions.insert(RepoAction::Delete); 185 - } 186 187 return ParsedScope::Repo(RepoScope { 188 collection, ··· 191 } 192 193 if base == "repo" { 194 - let mut actions = HashSet::new(); 195 - if let Some(action_values) = params.get("action") { 196 - for action_str in action_values { 197 - if let Some(action) = RepoAction::parse_str(action_str) { 198 - actions.insert(action); 199 - } 200 - } 201 - } 202 - if actions.is_empty() { 203 - actions.insert(RepoAction::Create); 204 - actions.insert(RepoAction::Update); 205 - actions.insert(RepoAction::Delete); 206 - } 207 return ParsedScope::Repo(RepoScope { 208 collection: None, 209 actions, ··· 212 213 if base.starts_with("blob") { 214 let positional = base.strip_prefix("blob:").unwrap_or(""); 215 - let mut accept = HashSet::new(); 216 - 217 - if !positional.is_empty() { 218 - accept.insert(positional.to_string()); 219 - } 220 - if let Some(accept_values) = params.get("accept") { 221 - for v in accept_values { 222 - accept.insert(v.to_string()); 223 - } 224 - } 225 226 return ParsedScope::Blob(BlobScope { accept }); 227 }
··· 55 if self.accept.is_empty() || self.accept.contains("*/*") { 56 return true; 57 } 58 + self.accept.iter().any(|pattern| { 59 + pattern == mime 60 + || pattern 61 + .strip_suffix("/*") 62 + .is_some_and(|prefix| { 63 + mime.starts_with(prefix) && mime.chars().nth(prefix.len()) == Some('/') 64 + }) 65 + }) 66 } 67 } 68 ··· 166 Some(rest.to_string()) 167 }; 168 169 + let actions: HashSet<RepoAction> = params 170 + .get("action") 171 + .map(|action_values| { 172 + action_values 173 + .iter() 174 + .filter_map(|s| RepoAction::parse_str(s)) 175 + .collect() 176 + }) 177 + .filter(|set: &HashSet<RepoAction>| !set.is_empty()) 178 + .unwrap_or_else(|| { 179 + [RepoAction::Create, RepoAction::Update, RepoAction::Delete] 180 + .into_iter() 181 + .collect() 182 + }); 183 184 return ParsedScope::Repo(RepoScope { 185 collection, ··· 188 } 189 190 if base == "repo" { 191 + let actions: HashSet<RepoAction> = params 192 + .get("action") 193 + .map(|action_values| { 194 + action_values 195 + .iter() 196 + .filter_map(|s| RepoAction::parse_str(s)) 197 + .collect() 198 + }) 199 + .filter(|set: &HashSet<RepoAction>| !set.is_empty()) 200 + .unwrap_or_else(|| { 201 + [RepoAction::Create, RepoAction::Update, RepoAction::Delete] 202 + .into_iter() 203 + .collect() 204 + }); 205 return ParsedScope::Repo(RepoScope { 206 collection: None, 207 actions, ··· 210 211 if base.starts_with("blob") { 212 let positional = base.strip_prefix("blob:").unwrap_or(""); 213 + let accept: HashSet<String> = std::iter::once(positional) 214 + .filter(|s| !s.is_empty()) 215 + .map(String::from) 216 + .chain( 217 + params 218 + .get("accept") 219 + .into_iter() 220 + .flatten() 221 + .map(String::clone), 222 + ) 223 + .collect(); 224 225 return ParsedScope::Blob(BlobScope { accept }); 226 }
+78 -78
crates/tranquil-scopes/src/permissions.rs
··· 113 return Ok(()); 114 } 115 116 - for repo_scope in self.find_repo_scopes() { 117 - if !repo_scope.actions.contains(&action) { 118 - continue; 119 - } 120 - 121 - match &repo_scope.collection { 122 - None => return Ok(()), 123 - Some(coll) if coll == collection => return Ok(()), 124 - Some(coll) if coll.ends_with(".*") => { 125 - let prefix = coll.strip_suffix(".*").unwrap(); 126 - if collection.starts_with(prefix) 127 - && collection.chars().nth(prefix.len()) == Some('.') 128 - { 129 - return Ok(()); 130 } 131 } 132 - _ => {} 133 - } 134 - } 135 136 - Err(ScopeError::InsufficientScope { 137 - required: format!("repo:{}?action={}", collection, action_str(action)), 138 - message: format!( 139 - "Insufficient scope to {} records in {}", 140 - action_str(action), 141 - collection 142 - ), 143 - }) 144 } 145 146 pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> { ··· 148 return Ok(()); 149 } 150 151 - for blob_scope in self.find_blob_scopes() { 152 - if blob_scope.matches_mime(mime) { 153 - return Ok(()); 154 - } 155 } 156 - 157 - Err(ScopeError::InsufficientScope { 158 - required: format!("blob:{}", mime), 159 - message: format!("Insufficient scope to upload blob with mime type {}", mime), 160 - }) 161 } 162 163 pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { ··· 169 return Ok(()); 170 } 171 172 - for rpc_scope in self.find_rpc_scopes() { 173 let lxm_matches = match &rpc_scope.lxm { 174 None => true, 175 Some(scope_lxm) if scope_lxm == lxm => true, ··· 186 Some(scope_aud) => scope_aud == aud, 187 }; 188 189 - if lxm_matches && aud_matches { 190 - return Ok(()); 191 - } 192 - } 193 194 - Err(ScopeError::InsufficientScope { 195 - required: format!("rpc:{}?aud={}", lxm, aud), 196 - message: format!("Insufficient scope to call {} on {}", lxm, aud), 197 - }) 198 } 199 200 pub fn assert_account( ··· 211 return Ok(()); 212 } 213 214 - for account_scope in self.find_account_scopes() { 215 - if account_scope.attr == attr && account_scope.action == action { 216 - return Ok(()); 217 - } 218 - if account_scope.attr == attr && account_scope.action == AccountAction::Manage { 219 - return Ok(()); 220 - } 221 - } 222 223 - Err(ScopeError::InsufficientScope { 224 - required: format!( 225 - "account:{}?action={}", 226 - attr_str(attr), 227 - action_str_account(action) 228 - ), 229 - message: format!( 230 - "Insufficient scope to {} account {}", 231 - action_str_account(action), 232 - attr_str(attr) 233 - ), 234 - }) 235 } 236 237 pub fn allows_email_read(&self) -> bool { ··· 264 return Ok(()); 265 } 266 267 - for identity_scope in self.find_identity_scopes() { 268 - if identity_scope.attr == IdentityAttr::Wildcard { 269 - return Ok(()); 270 - } 271 - if identity_scope.attr == attr { 272 - return Ok(()); 273 - } 274 } 275 - 276 - Err(ScopeError::InsufficientScope { 277 - required: format!("identity:{}", identity_attr_str(attr)), 278 - message: format!( 279 - "Insufficient scope to modify identity {}", 280 - identity_attr_str(attr) 281 - ), 282 - }) 283 } 284 285 pub fn allows_identity(&self, attr: IdentityAttr) -> bool {
··· 113 return Ok(()); 114 } 115 116 + let has_permission = self.find_repo_scopes().any(|repo_scope| { 117 + repo_scope.actions.contains(&action) 118 + && match &repo_scope.collection { 119 + None => true, 120 + Some(coll) if coll == collection => true, 121 + Some(coll) if coll.ends_with(".*") => { 122 + let prefix = coll.strip_suffix(".*").unwrap(); 123 + collection.starts_with(prefix) 124 + && collection.chars().nth(prefix.len()) == Some('.') 125 } 126 + _ => false, 127 } 128 + }); 129 130 + if has_permission { 131 + Ok(()) 132 + } else { 133 + Err(ScopeError::InsufficientScope { 134 + required: format!("repo:{}?action={}", collection, action_str(action)), 135 + message: format!( 136 + "Insufficient scope to {} records in {}", 137 + action_str(action), 138 + collection 139 + ), 140 + }) 141 + } 142 } 143 144 pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> { ··· 146 return Ok(()); 147 } 148 149 + if self.find_blob_scopes().any(|blob_scope| blob_scope.matches_mime(mime)) { 150 + Ok(()) 151 + } else { 152 + Err(ScopeError::InsufficientScope { 153 + required: format!("blob:{}", mime), 154 + message: format!("Insufficient scope to upload blob with mime type {}", mime), 155 + }) 156 } 157 } 158 159 pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { ··· 165 return Ok(()); 166 } 167 168 + let has_permission = self.find_rpc_scopes().any(|rpc_scope| { 169 let lxm_matches = match &rpc_scope.lxm { 170 None => true, 171 Some(scope_lxm) if scope_lxm == lxm => true, ··· 182 Some(scope_aud) => scope_aud == aud, 183 }; 184 185 + lxm_matches && aud_matches 186 + }); 187 188 + if has_permission { 189 + Ok(()) 190 + } else { 191 + Err(ScopeError::InsufficientScope { 192 + required: format!("rpc:{}?aud={}", lxm, aud), 193 + message: format!("Insufficient scope to call {} on {}", lxm, aud), 194 + }) 195 + } 196 } 197 198 pub fn assert_account( ··· 209 return Ok(()); 210 } 211 212 + let has_permission = self.find_account_scopes().any(|account_scope| { 213 + account_scope.attr == attr 214 + && (account_scope.action == action 215 + || account_scope.action == AccountAction::Manage) 216 + }); 217 218 + if has_permission { 219 + Ok(()) 220 + } else { 221 + Err(ScopeError::InsufficientScope { 222 + required: format!( 223 + "account:{}?action={}", 224 + attr_str(attr), 225 + action_str_account(action) 226 + ), 227 + message: format!( 228 + "Insufficient scope to {} account {}", 229 + action_str_account(action), 230 + attr_str(attr) 231 + ), 232 + }) 233 + } 234 } 235 236 pub fn allows_email_read(&self) -> bool { ··· 263 return Ok(()); 264 } 265 266 + let has_permission = self 267 + .find_identity_scopes() 268 + .any(|identity_scope| { 269 + identity_scope.attr == IdentityAttr::Wildcard || identity_scope.attr == attr 270 + }); 271 + 272 + if has_permission { 273 + Ok(()) 274 + } else { 275 + Err(ScopeError::InsufficientScope { 276 + required: format!("identity:{}", identity_attr_str(attr)), 277 + message: format!( 278 + "Insufficient scope to modify identity {}", 279 + identity_attr_str(attr) 280 + ), 281 + }) 282 } 283 } 284 285 pub fn allows_identity(&self, attr: IdentityAttr) -> bool {