Alternative ATProto PDS implementation

Compare changes

Choose any two refs to compare.

Changed files
+5813 -5618
.sqlx
migrations
src
-20
.sqlx/query-02a5737bb92665ef0a3dac013eb03366ab6b31a5c4ab856e6458a52704b86e23.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT COUNT(*) FROM oauth_used_jtis WHERE jti = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "COUNT(*)", 8 - "ordinal": 0, 9 - "type_info": "Integer" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "02a5737bb92665ef0a3dac013eb03366ab6b31a5c4ab856e6458a52704b86e23" 20 - }
-12
.sqlx/query-19dc08b9f2f609e0610b6bd1e4908fc5d7922cc95b13de3214a055bf36b80284.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO invites (id, did, count, created_at)\n VALUES (?, NULL, 1, datetime('now'))\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 1 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "19dc08b9f2f609e0610b6bd1e4908fc5d7922cc95b13de3214a055bf36b80284" 12 - }
-20
.sqlx/query-1db52857493a1e8a7004872eaff6e8fe5dec41579dd57d696008385b8d23788d.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT data FROM blocks WHERE cid = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "data", 8 - "ordinal": 0, 9 - "type_info": "Blob" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "1db52857493a1e8a7004872eaff6e8fe5dec41579dd57d696008385b8d23788d" 20 - }
-20
.sqlx/query-22c1e98ac038509ad16ce437e6670a59d3fc97a05ea8b0f1f80dba0157c53e13.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT name FROM actor_migration", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "name", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 0 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "22c1e98ac038509ad16ce437e6670a59d3fc97a05ea8b0f1f80dba0157c53e13" 20 - }
-62
.sqlx/query-243e2127a5181657d5e08c981a7a6d395fb2112ebf7a1a676d57c33866310add.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT * FROM oauth_refresh_tokens\n WHERE token = ? AND client_id = ? AND expires_at > ? AND revoked = FALSE AND dpop_thumbprint = ?\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "token", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "client_id", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - }, 16 - { 17 - "name": "subject", 18 - "ordinal": 2, 19 - "type_info": "Text" 20 - }, 21 - { 22 - "name": "dpop_thumbprint", 23 - "ordinal": 3, 24 - "type_info": "Text" 25 - }, 26 - { 27 - "name": "scope", 28 - "ordinal": 4, 29 - "type_info": "Text" 30 - }, 31 - { 32 - "name": "created_at", 33 - "ordinal": 5, 34 - "type_info": "Integer" 35 - }, 36 - { 37 - "name": "expires_at", 38 - "ordinal": 6, 39 - "type_info": "Integer" 40 - }, 41 - { 42 - "name": "revoked", 43 - "ordinal": 7, 44 - "type_info": "Bool" 45 - } 46 - ], 47 - "parameters": { 48 - "Right": 4 49 - }, 50 - "nullable": [ 51 - false, 52 - false, 53 - false, 54 - false, 55 - true, 56 - false, 57 - false, 58 - false 59 - ] 60 - }, 61 - "hash": "243e2127a5181657d5e08c981a7a6d395fb2112ebf7a1a676d57c33866310add" 62 - }
-12
.sqlx/query-2918ecf03675a789568c777904966911ca63e991dede42a2d7d87e174799ea46.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "INSERT INTO blocks (cid, data, multicodec, multihash) VALUES (?, ?, ?, ?)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 4 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "2918ecf03675a789568c777904966911ca63e991dede42a2d7d87e174799ea46" 12 - }
-20
.sqlx/query-2e13e052dfc64f29d9da1bce2bf844cbb918ad3bb01e386801d3b0d3be246573.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT COUNT(*) FROM oauth_refresh_tokens WHERE dpop_thumbprint = ? AND client_id = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "COUNT(*)", 8 - "ordinal": 0, 9 - "type_info": "Integer" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 2 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "2e13e052dfc64f29d9da1bce2bf844cbb918ad3bb01e386801d3b0d3be246573" 20 - }
-32
.sqlx/query-3516a6de0f3aa40b301d60479f5c34d0fd21a800328a05458ecc3ac688d016e6.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT a.email, a.status, (\n SELECT h.handle\n FROM handles h\n WHERE h.did = a.did\n ORDER BY h.created_at ASC\n LIMIT 1\n ) AS handle\n FROM accounts a\n WHERE a.did = ?\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "email", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "status", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - }, 16 - { 17 - "name": "handle", 18 - "ordinal": 2, 19 - "type_info": "Text" 20 - } 21 - ], 22 - "parameters": { 23 - "Right": 1 24 - }, 25 - "nullable": [ 26 - false, 27 - false, 28 - false 29 - ] 30 - }, 31 - "hash": "3516a6de0f3aa40b301d60479f5c34d0fd21a800328a05458ecc3ac688d016e6" 32 - }
-20
.sqlx/query-3b4745208f268678a84401e522c3836e0632ca34a0f23bbae5297d076610f0ab.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT content FROM repo_block WHERE cid = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "content", 8 - "ordinal": 0, 9 - "type_info": "Blob" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "3b4745208f268678a84401e522c3836e0632ca34a0f23bbae5297d076610f0ab" 20 - }
-20
.sqlx/query-3d1a877177899665c37393beae31a399054b7c02d3871c6c5d317923fec8442e.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT did FROM handles WHERE handle = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "did", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "3d1a877177899665c37393beae31a399054b7c02d3871c6c5d317923fec8442e" 20 - }
-20
.sqlx/query-4198b96804f3a0a805e441857b452e84a083d80dca12ce95c545dc9eadbac0c3.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT plc_root FROM accounts WHERE did = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "plc_root", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "4198b96804f3a0a805e441857b452e84a083d80dca12ce95c545dc9eadbac0c3" 20 - }
-12
.sqlx/query-459be26080e3497b3807d22e86377eee9e19366709864e3369c867cef01c83bb.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO repo_block (cid, repoRev, size, content)\n VALUES (?, ?, ?, ?)\n ON CONFLICT DO NOTHING\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 4 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "459be26080e3497b3807d22e86377eee9e19366709864e3369c867cef01c83bb" 12 - }
-26
.sqlx/query-50a7b5f57df41d06a8c11c8268d8dbef4c76bcf92c6b47b6316bf5e39fb889a7.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT a.status, h.handle\n FROM accounts a\n JOIN handles h ON a.did = h.did\n WHERE a.did = ?\n ORDER BY h.created_at ASC\n LIMIT 1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "status", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "handle", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Right": 1 19 - }, 20 - "nullable": [ 21 - false, 22 - false 23 - ] 24 - }, 25 - "hash": "50a7b5f57df41d06a8c11c8268d8dbef4c76bcf92c6b47b6316bf5e39fb889a7" 26 - }
-12
.sqlx/query-51f7f9d5bf4cbfe372a8fa130f4cabcb57766638792d61297df2fb91c2fe2937.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO repo_root (did, cid, rev, indexedAt)\n VALUES (?, ?, ?, ?)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 4 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "51f7f9d5bf4cbfe372a8fa130f4cabcb57766638792d61297df2fb91c2fe2937" 12 - }
-12
.sqlx/query-5bbf8300ca519576e4f60074cf16756bc1dca79f43e1e89c5a08b8c9d95d241f.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO repo_block (cid, repoRev, size, content)\n VALUES (?, ?, ?, ?)\n ON CONFLICT DO NOTHING\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 4 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "5bbf8300ca519576e4f60074cf16756bc1dca79f43e1e89c5a08b8c9d95d241f" 12 - }
-12
.sqlx/query-5d4586821dff3ed0fd1e352946751c3bb66610a472d8c42a7bfa3a565fccc30a.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO oauth_authorization_codes (\n code, client_id, subject, code_challenge, code_challenge_method,\n redirect_uri, scope, created_at, expires_at, used\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 10 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "5d4586821dff3ed0fd1e352946751c3bb66610a472d8c42a7bfa3a565fccc30a" 12 - }
-12
.sqlx/query-5ea8376fbbe3077b2fc62187cc29a2d03eda91fa468c7fe63306f04e160ecb5d.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "INSERT INTO actor_migration (name, appliedAt) VALUES (?, ?)", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 2 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "5ea8376fbbe3077b2fc62187cc29a2d03eda91fa468c7fe63306f04e160ecb5d" 12 - }
-26
.sqlx/query-5f17a390750b52886f8c3ba80cb16776f3430bc91c4158aafb3012a7812a97cc.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT rev, status FROM accounts WHERE did = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "rev", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "status", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Right": 1 19 - }, 20 - "nullable": [ 21 - false, 22 - false 23 - ] 24 - }, 25 - "hash": "5f17a390750b52886f8c3ba80cb16776f3430bc91c4158aafb3012a7812a97cc" 26 - }
-32
.sqlx/query-6b0a871527c5c37663ee17ec6f5ec4f97521900f45e549b0b065004a4e2e6207.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n WITH LatestHandles AS (\n SELECT did, handle\n FROM handles\n WHERE (did, created_at) IN (\n SELECT did, MAX(created_at) AS max_created_at\n FROM handles\n GROUP BY did\n )\n )\n SELECT a.did, a.password, h.handle\n FROM accounts a\n LEFT JOIN LatestHandles h ON a.did = h.did\n WHERE h.handle = ?\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "did", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "password", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - }, 16 - { 17 - "name": "handle", 18 - "ordinal": 2, 19 - "type_info": "Text" 20 - } 21 - ], 22 - "parameters": { 23 - "Right": 1 24 - }, 25 - "nullable": [ 26 - false, 27 - false, 28 - false 29 - ] 30 - }, 31 - "hash": "6b0a871527c5c37663ee17ec6f5ec4f97521900f45e549b0b065004a4e2e6207" 32 - }
-20
.sqlx/query-73fd3e30b7694c92cf9309751d186fe622fa7d99fdf56dde7e60c3696581116c.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT COUNT(*) FROM blocks WHERE cid = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "COUNT(*)", 8 - "ordinal": 0, 9 - "type_info": "Integer" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "73fd3e30b7694c92cf9309751d186fe622fa7d99fdf56dde7e60c3696581116c" 20 - }
-32
.sqlx/query-7eb22fdfc107b33361c599fcd4ae3a4a4fafef8438c41e1fdc6d4f7fd44f1094.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT did, root, rev FROM accounts LIMIT ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "did", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "root", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - }, 16 - { 17 - "name": "rev", 18 - "ordinal": 2, 19 - "type_info": "Text" 20 - } 21 - ], 22 - "parameters": { 23 - "Right": 1 24 - }, 25 - "nullable": [ 26 - false, 27 - false, 28 - false 29 - ] 30 - }, 31 - "hash": "7eb22fdfc107b33361c599fcd4ae3a4a4fafef8438c41e1fdc6d4f7fd44f1094" 32 - }
-20
.sqlx/query-813409fb7218c548ee3e8b1226559686cd40aa81ac1b68659b087276cbb0137d.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT cid FROM blob_ref WHERE did = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "cid", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "813409fb7218c548ee3e8b1226559686cd40aa81ac1b68659b087276cbb0137d" 20 - }
-20
.sqlx/query-865f757ca7c8b15357622bf0d1a25745288f87ad6ace019c1f4316a4ba1efb34.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT revoked FROM oauth_refresh_tokens WHERE token = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "revoked", 8 - "ordinal": 0, 9 - "type_info": "Bool" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "865f757ca7c8b15357622bf0d1a25745288f87ad6ace019c1f4316a4ba1efb34" 20 - }
-12
.sqlx/query-87cbc4f5bb615163ff62234e0de0c69b543179cffcdaf79fcae5fd6fdc7e14c7.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "UPDATE oauth_refresh_tokens SET revoked = TRUE WHERE token = ?", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 1 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "87cbc4f5bb615163ff62234e0de0c69b543179cffcdaf79fcae5fd6fdc7e14c7" 12 - }
-74
.sqlx/query-92858ad9b0a35c3b8d4be795f88325aa4a1995f53fc90ef455ef9a499335f088.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT * FROM oauth_authorization_codes\n WHERE code = ? AND client_id = ? AND redirect_uri = ? AND expires_at > ? AND used = FALSE\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "code", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "client_id", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - }, 16 - { 17 - "name": "subject", 18 - "ordinal": 2, 19 - "type_info": "Text" 20 - }, 21 - { 22 - "name": "code_challenge", 23 - "ordinal": 3, 24 - "type_info": "Text" 25 - }, 26 - { 27 - "name": "code_challenge_method", 28 - "ordinal": 4, 29 - "type_info": "Text" 30 - }, 31 - { 32 - "name": "redirect_uri", 33 - "ordinal": 5, 34 - "type_info": "Text" 35 - }, 36 - { 37 - "name": "scope", 38 - "ordinal": 6, 39 - "type_info": "Text" 40 - }, 41 - { 42 - "name": "created_at", 43 - "ordinal": 7, 44 - "type_info": "Integer" 45 - }, 46 - { 47 - "name": "expires_at", 48 - "ordinal": 8, 49 - "type_info": "Integer" 50 - }, 51 - { 52 - "name": "used", 53 - "ordinal": 9, 54 - "type_info": "Bool" 55 - } 56 - ], 57 - "parameters": { 58 - "Right": 4 59 - }, 60 - "nullable": [ 61 - false, 62 - false, 63 - false, 64 - false, 65 - false, 66 - false, 67 - true, 68 - false, 69 - false, 70 - false 71 - ] 72 - }, 73 - "hash": "92858ad9b0a35c3b8d4be795f88325aa4a1995f53fc90ef455ef9a499335f088" 74 - }
-26
.sqlx/query-9890e97761e6ed1256ed32775ad4f394e199b5a3588a711ea8ad672cf666eee4.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT cid, rev FROM repo_root WHERE did = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "cid", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "rev", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Right": 1 19 - }, 20 - "nullable": [ 21 - false, 22 - false 23 - ] 24 - }, 25 - "hash": "9890e97761e6ed1256ed32775ad4f394e199b5a3588a711ea8ad672cf666eee4" 26 - }
-12
.sqlx/query-9a04bdf627ee146ddaac6cdd1bacf2106b22bc215ef22ab400cd62b4353f414b.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "UPDATE accounts SET private_prefs = ? WHERE did = ?", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 2 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "9a04bdf627ee146ddaac6cdd1bacf2106b22bc215ef22ab400cd62b4353f414b" 12 - }
-26
.sqlx/query-9b6ac33211a2231754650bb0daca5ffb980c9e530ea47dd892aa06fab1450a05.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT cid, content\n FROM repo_block\n WHERE repoRev = ?\n LIMIT 15\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "cid", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "content", 13 - "ordinal": 1, 14 - "type_info": "Blob" 15 - } 16 - ], 17 - "parameters": { 18 - "Right": 1 19 - }, 20 - "nullable": [ 21 - false, 22 - false 23 - ] 24 - }, 25 - "hash": "9b6ac33211a2231754650bb0daca5ffb980c9e530ea47dd892aa06fab1450a05" 26 - }
-38
.sqlx/query-a16bb62753f6568238cab50d3a597d279db5564d3bcc1f8606850d5442aaf20a.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n WITH LatestHandles AS (\n SELECT did, handle\n FROM handles\n WHERE (did, created_at) IN (\n SELECT did, MAX(created_at) AS max_created_at\n FROM handles\n GROUP BY did\n )\n )\n SELECT a.did, a.email, a.password, h.handle\n FROM accounts a\n LEFT JOIN LatestHandles h ON a.did = h.did\n WHERE h.handle = ?\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "did", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "email", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - }, 16 - { 17 - "name": "password", 18 - "ordinal": 2, 19 - "type_info": "Text" 20 - }, 21 - { 22 - "name": "handle", 23 - "ordinal": 3, 24 - "type_info": "Text" 25 - } 26 - ], 27 - "parameters": { 28 - "Right": 1 29 - }, 30 - "nullable": [ 31 - false, 32 - false, 33 - false, 34 - false 35 - ] 36 - }, 37 - "hash": "a16bb62753f6568238cab50d3a597d279db5564d3bcc1f8606850d5442aaf20a" 38 - }
-12
.sqlx/query-a527a1863a9a2f5ba129c1f5ee9d0cdc78e0c69de43c7da1f9a936222c17c4bf.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO accounts (did, email, password, root, plc_root, rev, created_at)\n VALUES (?, ?, ?, ?, ?, ?, datetime('now'));\n\n INSERT INTO handles (did, handle, created_at)\n VALUES (?, ?, datetime('now'));\n\n -- Cleanup stale invite codes\n DELETE FROM invites\n WHERE count <= 0;\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 8 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "a527a1863a9a2f5ba129c1f5ee9d0cdc78e0c69de43c7da1f9a936222c17c4bf" 12 - }
-12
.sqlx/query-a9fbd43dbd50907f550a2221dab552ff5a00d7f00d7223b4cee745354f77c532.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n UPDATE repo_root\n SET cid = ?, rev = ?, indexedAt = ?\n WHERE did = ?\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 4 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "a9fbd43dbd50907f550a2221dab552ff5a00d7f00d7223b4cee745354f77c532" 12 - }
-92
.sqlx/query-b4e6da72ee82515d2ff739c805e1c0ccb837d06c62d338dd782a3ea375f7eee3.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT * FROM oauth_par_requests\n WHERE request_uri = ? AND client_id = ? AND expires_at > ?\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "request_uri", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "client_id", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - }, 16 - { 17 - "name": "response_type", 18 - "ordinal": 2, 19 - "type_info": "Text" 20 - }, 21 - { 22 - "name": "code_challenge", 23 - "ordinal": 3, 24 - "type_info": "Text" 25 - }, 26 - { 27 - "name": "code_challenge_method", 28 - "ordinal": 4, 29 - "type_info": "Text" 30 - }, 31 - { 32 - "name": "state", 33 - "ordinal": 5, 34 - "type_info": "Text" 35 - }, 36 - { 37 - "name": "login_hint", 38 - "ordinal": 6, 39 - "type_info": "Text" 40 - }, 41 - { 42 - "name": "scope", 43 - "ordinal": 7, 44 - "type_info": "Text" 45 - }, 46 - { 47 - "name": "redirect_uri", 48 - "ordinal": 8, 49 - "type_info": "Text" 50 - }, 51 - { 52 - "name": "response_mode", 53 - "ordinal": 9, 54 - "type_info": "Text" 55 - }, 56 - { 57 - "name": "display", 58 - "ordinal": 10, 59 - "type_info": "Text" 60 - }, 61 - { 62 - "name": "created_at", 63 - "ordinal": 11, 64 - "type_info": "Integer" 65 - }, 66 - { 67 - "name": "expires_at", 68 - "ordinal": 12, 69 - "type_info": "Integer" 70 - } 71 - ], 72 - "parameters": { 73 - "Right": 3 74 - }, 75 - "nullable": [ 76 - false, 77 - false, 78 - false, 79 - false, 80 - false, 81 - true, 82 - true, 83 - true, 84 - true, 85 - true, 86 - true, 87 - false, 88 - false 89 - ] 90 - }, 91 - "hash": "b4e6da72ee82515d2ff739c805e1c0ccb837d06c62d338dd782a3ea375f7eee3" 92 - }
-12
.sqlx/query-bcef1b9aeaf0db7ac4b2e8f4b3ec40b425e48af26cf91496208c04e31239f7c6.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "DELETE FROM oauth_used_jtis WHERE expires_at < ?", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 1 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "bcef1b9aeaf0db7ac4b2e8f4b3ec40b425e48af26cf91496208c04e31239f7c6" 12 - }
-12
.sqlx/query-c51b4c9de70b5be51a6e0a5fd744387ae804e8ba978b61c4d04d74b1f8de2614.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "UPDATE oauth_refresh_tokens SET revoked = TRUE\n WHERE client_id = ? AND dpop_thumbprint = ?", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 2 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "c51b4c9de70b5be51a6e0a5fd744387ae804e8ba978b61c4d04d74b1f8de2614" 12 - }
-20
.sqlx/query-cc1c5a90cfd95024cb03fe579941f296b1ac1230cce5819ae9f6eb03c8b19398.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT\n (SELECT COUNT(*) FROM accounts) + (SELECT COUNT(*) FROM invites)\n AS total_count\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "total_count", 8 - "ordinal": 0, 9 - "type_info": "Integer" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 0 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "cc1c5a90cfd95024cb03fe579941f296b1ac1230cce5819ae9f6eb03c8b19398" 20 - }
-12
.sqlx/query-cd91f7a134089bb77cac221a9bcc489b6d6860123f755c1ee2068e32dc687301.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO oauth_refresh_tokens (\n token, client_id, subject, dpop_thumbprint, scope, created_at, expires_at, revoked\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 8 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "cd91f7a134089bb77cac221a9bcc489b6d6860123f755c1ee2068e32dc687301" 12 - }
-12
.sqlx/query-d1408c77d790337a265891b5502a59a62a5d1d01e787dea74b753b1fab794b3a.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO oauth_par_requests (\n request_uri, client_id, response_type, code_challenge, code_challenge_method,\n state, login_hint, scope, redirect_uri, response_mode, display,\n created_at, expires_at\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 13 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "d1408c77d790337a265891b5502a59a62a5d1d01e787dea74b753b1fab794b3a" 12 - }
-26
.sqlx/query-d1c3ea6ebc19b0362851ebd0b8c8a0b9c87d5cddf4f03670636d29ba5ceb9435.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT cid, rev\n FROM repo_root\n WHERE did = ?\n LIMIT 1\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "cid", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "rev", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Right": 1 19 - }, 20 - "nullable": [ 21 - false, 22 - false 23 - ] 24 - }, 25 - "hash": "d1c3ea6ebc19b0362851ebd0b8c8a0b9c87d5cddf4f03670636d29ba5ceb9435" 26 - }
-12
.sqlx/query-d39b83ec2f091556e6fb5e4d729b8e6fa1cc966855f934e2b1611d8a26614849.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "UPDATE accounts SET plc_root = ? WHERE did = ?", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 2 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "d39b83ec2f091556e6fb5e4d729b8e6fa1cc966855f934e2b1611d8a26614849" 12 - }
-12
.sqlx/query-d6ddbce18d6a78a78e8713a0f0b1499517aae7ab9f49744a4cf8a722e03f82fa.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO oauth_used_jtis (jti, issuer, created_at, expires_at)\n VALUES (?, ?, ?, ?)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 4 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "d6ddbce18d6a78a78e8713a0f0b1499517aae7ab9f49744a4cf8a722e03f82fa" 12 - }
-20
.sqlx/query-dbedb512e10704bc9f0e571314ff68724edf10b76a62071bd1ef04a68c708890.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n INSERT INTO invites (id, did, count, created_at)\n VALUES (?, ?, ?, datetime('now'))\n RETURNING id\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "id", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 3 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "dbedb512e10704bc9f0e571314ff68724edf10b76a62071bd1ef04a68c708890" 20 - }
-20
.sqlx/query-dc444d99848fff3578add45fb464004c0797ef7d455652cb92f2c7de8a7f8cc4.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT status FROM accounts WHERE did = ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "status", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "dc444d99848fff3578add45fb464004c0797ef7d455652cb92f2c7de8a7f8cc4" 20 - }
-20
.sqlx/query-e26b7c36a34130e350f3f3e06b3200c56a0e3330ac0b658de6bbdb39b5497fab.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n UPDATE invites\n SET count = count - 1\n WHERE id = ?\n AND count > 0\n RETURNING id\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "id", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "e26b7c36a34130e350f3f3e06b3200c56a0e3330ac0b658de6bbdb39b5497fab" 20 - }
-12
.sqlx/query-e4bd80a305f929229b234b79b1e9e90a36af0e630c8c7530b6d935c6e32d381f.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "UPDATE oauth_authorization_codes SET used = TRUE WHERE code = ?", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Right": 1 8 - }, 9 - "nullable": [] 10 - }, 11 - "hash": "e4bd80a305f929229b234b79b1e9e90a36af0e630c8c7530b6d935c6e32d381f" 12 - }
-20
.sqlx/query-e6007f29d6b7681d7a1f5029d1bf635250ac4449494b925e67735513edfcbdb3.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "\n SELECT root FROM accounts\n WHERE did = ?\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "root", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Right": 1 14 - }, 15 - "nullable": [ 16 - false 17 - ] 18 - }, 19 - "hash": "e6007f29d6b7681d7a1f5029d1bf635250ac4449494b925e67735513edfcbdb3" 20 - }
-32
.sqlx/query-fdd74b27ee260f2cc6fa9102f5c216b86436bb6ccf9bf707118c12b0bd393922.json
··· 1 - { 2 - "db_name": "SQLite", 3 - "query": "SELECT did, root, rev FROM accounts WHERE did > ? LIMIT ?", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "name": "did", 8 - "ordinal": 0, 9 - "type_info": "Text" 10 - }, 11 - { 12 - "name": "root", 13 - "ordinal": 1, 14 - "type_info": "Text" 15 - }, 16 - { 17 - "name": "rev", 18 - "ordinal": 2, 19 - "type_info": "Text" 20 - } 21 - ], 22 - "parameters": { 23 - "Right": 2 24 - }, 25 - "nullable": [ 26 - false, 27 - false, 28 - false 29 - ] 30 - }, 31 - "hash": "fdd74b27ee260f2cc6fa9102f5c216b86436bb6ccf9bf707118c12b0bd393922" 32 - }
+2 -22
Cargo.lock
··· 1282 1282 dependencies = [ 1283 1283 "anyhow", 1284 1284 "argon2", 1285 - "async-trait", 1286 1285 "atrium-api 0.25.3", 1287 1286 "atrium-crypto", 1288 1287 "atrium-repo", 1289 - "atrium-xrpc", 1290 - "atrium-xrpc-client", 1291 1288 "axum", 1292 1289 "azure_core", 1293 1290 "azure_identity", ··· 1306 1303 "futures", 1307 1304 "hex", 1308 1305 "http-cache-reqwest", 1309 - "ipld-core", 1310 - "k256", 1311 - "lazy_static", 1312 1306 "memmap2", 1313 1307 "metrics", 1314 1308 "metrics-exporter-prometheus", 1315 - "multihash 0.19.3", 1316 - "r2d2", 1317 1309 "rand 0.8.5", 1318 - "regex", 1319 1310 "reqwest 0.12.15", 1320 1311 "reqwest-middleware", 1321 1312 "rsky-common", 1313 + "rsky-identity", 1322 1314 "rsky-lexicon", 1323 1315 "rsky-pds", 1324 1316 "rsky-repo", 1325 1317 "rsky-syntax", 1326 1318 "secp256k1", 1327 1319 "serde", 1328 - "serde_bytes", 1329 1320 "serde_ipld_dagcbor", 1330 - "serde_ipld_dagjson", 1331 1321 "serde_json", 1332 1322 "sha2", 1333 1323 "thiserror 2.0.12", ··· 1336 1326 "tower-http", 1337 1327 "tracing", 1338 1328 "tracing-subscriber", 1329 + "ubyte", 1339 1330 "url", 1340 1331 "urlencoding", 1341 1332 "uuid 1.16.0", ··· 5839 5830 "ipld-core", 5840 5831 "scopeguard", 5841 5832 "serde", 5842 - ] 5843 - 5844 - [[package]] 5845 - name = "serde_ipld_dagjson" 5846 - version = "0.2.0" 5847 - source = "registry+https://github.com/rust-lang/crates.io-index" 5848 - checksum = "3359b47ba7f4a306ef5984665e10539e212e97217afa489437d533208eecda36" 5849 - dependencies = [ 5850 - "ipld-core", 5851 - "serde", 5852 - "serde_json", 5853 5833 ] 5854 5834 5855 5835 [[package]]
+17 -12
Cargo.toml
··· 1 + # cargo-features = ["codegen-backend"] 2 + 1 3 [package] 2 4 name = "bluepds" 3 5 version = "0.0.0" ··· 13 15 14 16 [profile.dev.package."*"] 15 17 opt-level = 3 18 + # codegen-backend = "cranelift" 16 19 17 20 [profile.dev] 18 21 opt-level = 1 22 + # codegen-backend = "cranelift" 19 23 20 24 [profile.release] 21 25 opt-level = "s" # Slightly slows compile times, great improvements to file size and runtime performance. ··· 75 79 # unstable-features = "allow" 76 80 # # Temporary Allows 77 81 dead_code = "allow" 78 - unused_imports = "allow" 82 + # unused_imports = "allow" 79 83 80 84 [lints.clippy] 81 85 # Groups ··· 131 135 # expect_used = "deny" 132 136 133 137 [dependencies] 134 - multihash = "0.19.3" 138 + # multihash = "0.19.3" 135 139 diesel = { version = "2.1.5", features = [ 136 140 "chrono", 137 141 "sqlite", ··· 139 143 "returning_clauses_for_sqlite_3_35", 140 144 ] } 141 145 diesel_migrations = { version = "2.1.0" } 142 - r2d2 = "0.8.10" 146 + # r2d2 = "0.8.10" 143 147 144 148 atrium-repo = "0.1" 145 149 atrium-api = "0.25" 146 150 # atrium-common = { version = "0.1.2", path = "atrium-common" } 147 151 atrium-crypto = "0.1" 148 152 # atrium-identity = { version = "0.1.4", path = "atrium-identity" } 149 - atrium-xrpc = "0.12" 150 - atrium-xrpc-client = "0.5" 153 + # atrium-xrpc = "0.12" 154 + # atrium-xrpc-client = "0.5" 151 155 # bsky-sdk = { version = "0.1.19", path = "bsky-sdk" } 152 156 rsky-syntax = { git = "https://github.com/blacksky-algorithms/rsky.git" } 153 157 rsky-repo = { git = "https://github.com/blacksky-algorithms/rsky.git" } 154 158 rsky-pds = { git = "https://github.com/blacksky-algorithms/rsky.git" } 155 159 rsky-common = { git = "https://github.com/blacksky-algorithms/rsky.git" } 156 160 rsky-lexicon = { git = "https://github.com/blacksky-algorithms/rsky.git" } 161 + rsky-identity = { git = "https://github.com/blacksky-algorithms/rsky.git" } 157 162 158 163 # async in streams 159 164 # async-stream = "0.3" 160 165 161 166 # DAG-CBOR codec 162 - ipld-core = "0.4.2" 167 + # ipld-core = "0.4.2" 163 168 serde_ipld_dagcbor = { version = "0.6.2", default-features = false, features = [ 164 169 "std", 165 170 ] } 166 - serde_ipld_dagjson = "0.2.0" 171 + # serde_ipld_dagjson = "0.2.0" 167 172 cidv10 = { version = "0.10.1", package = "cid" } 168 173 169 174 # Parsing and validation ··· 172 177 hex = "0.4.3" 173 178 # langtag = "0.3" 174 179 # multibase = "0.9.1" 175 - regex = "1.11.1" 180 + # regex = "1.11.1" 176 181 serde = { version = "1.0.218", features = ["derive"] } 177 - serde_bytes = "0.11.17" 182 + # serde_bytes = "0.11.17" 178 183 # serde_html_form = "0.2.6" 179 184 serde_json = "1.0.139" 180 185 # unsigned-varint = "0.8" ··· 184 189 # elliptic-curve = "0.13.6" 185 190 # jose-jwa = "0.1.2" 186 191 # jose-jwk = { version = "0.1.2", default-features = false } 187 - k256 = "0.13.4" 192 + # k256 = "0.13.4" 188 193 # p256 = { version = "0.13.2", default-features = false } 189 194 rand = "0.8.5" 190 195 sha2 = "0.10.8" ··· 256 261 url = "2.5.4" 257 262 uuid = { version = "1.14.0", features = ["v4"] } 258 263 urlencoding = "2.1.3" 259 - async-trait = "0.1.88" 260 - lazy_static = "1.5.0" 264 + # lazy_static = "1.5.0" 261 265 secp256k1 = "0.28.2" 262 266 dotenvy = "0.15.7" 263 267 deadpool-diesel = { version = "0.6.1", features = [ ··· 265 269 "sqlite", 266 270 "tracing", 267 271 ] } 272 + ubyte = "0.10.4"
+31 -118
README.md
··· 11 11 \/_/ 12 12 ``` 13 13 14 - This is an implementation of an ATProto PDS, built with [Axum](https://github.com/tokio-rs/axum) and [Atrium](https://github.com/sugyan/atrium). 15 - This PDS implementation uses a SQLite database to store private account information and file storage to store canonical user data. 14 + This is an implementation of an ATProto PDS, built with [Axum](https://github.com/tokio-rs/axum), [rsky](https://github.com/blacksky-algorithms/rsky/) and [Atrium](https://github.com/sugyan/atrium). 15 + This PDS implementation uses a SQLite database and [diesel.rs](https://diesel.rs/) ORM to store canonical user data, and file system storage to store user blobs. 16 16 17 17 Heavily inspired by David Buchanan's [millipds](https://github.com/DavidBuchanan314/millipds). 18 - This implementation forked from the [azure-rust-app](https://github.com/DrChat/azure-rust-app) starter template and the upstream [DrChat/bluepds](https://github.com/DrChat/bluepds). 19 - See TODO below for this fork's changes from upstream. 18 + This implementation forked from [DrChat/bluepds](https://github.com/DrChat/bluepds), and now makes heavy use of the [rsky-repo](https://github.com/blacksky-algorithms/rsky/tree/main/rsky-repo) repository implementation. 19 + The `actor_store` and `account_manager` modules have been reimplemented from [rsky-pds](https://github.com/blacksky-algorithms/rsky/tree/main/rsky-pds) to use a SQLite backend and file storage, which are themselves adapted from the [original Bluesky implementation](https://github.com/bluesky-social/atproto) using SQLite in Typescript. 20 + 20 21 21 22 If you want to see this fork in action, there is a live account hosted by this PDS at [@teq.shatteredsky.net](https://bsky.app/profile/teq.shatteredsky.net)! 22 23 23 24 > [!WARNING] 24 - > This PDS is undergoing heavy development. Do _NOT_ use this to host your primary account or any important data! 25 + > This PDS is undergoing heavy development, and this branch is not at an operable release. Do _NOT_ use this to host your primary account or any important data! 25 26 26 27 ## Quick Start 27 28 ``` ··· 43 44 - Size: 47 GB 44 45 - VPUs/GB: 10 45 46 46 - This is about half of the 3,000 OCPU hours and 18,000 GB hours available per month for free on the VM.Standard.A1.Flex shape. This is _without_ optimizing for costs. The PDS can likely be made much cheaper. 47 - 48 - ## Code map 49 - ``` 50 - * migrations/ - SQLite database migrations 51 - * src/ 52 - * endpoints/ - ATProto API endpoints 53 - * auth.rs - Authentication primitives 54 - * config.rs - Application configuration 55 - * did.rs - Decentralized Identifier helpers 56 - * error.rs - Axum error helpers 57 - * firehose.rs - ATProto firehose producer 58 - * main.rs - Main entrypoint 59 - * metrics.rs - Definitions for telemetry instruments 60 - * oauth.rs - OAuth routes 61 - * plc.rs - Functionality to access the Public Ledger of Credentials 62 - * storage.rs - Helpers to access user repository storage 63 - ``` 47 + This is about half of the 3,000 OCPU hours and 18,000 GB hours available per month for free on the VM.Standard.A1.Flex shape. This is _without_ optimizing for costs. The PDS can likely be made to run on much less resources. 64 48 65 49 ## To-do 66 - ### Teq's fork 67 - - [ ] OAuth 68 - - [X] `/.well-known/oauth-protected-resource` - Authorization Server Metadata 69 - - [X] `/.well-known/oauth-authorization-server` 70 - - [X] `/par` - Pushed Authorization Request 71 - - [X] `/client-metadata.json` - Client metadata discovery 72 - - [X] `/oauth/authorize` 73 - - [X] `/oauth/authorize/sign-in` 74 - - [X] `/oauth/token` 75 - - [ ] Authorization flow - Backend client 76 - - [X] Authorization flow - Serverless browser app 77 - - [ ] DPoP-Nonce 78 - - [ ] Verify JWT signature with JWK 79 - - [ ] Email verification 80 - - [ ] 2FA 81 - - [ ] Admin endpoints 82 - - [ ] App passwords 83 - - [X] `listRecords` fixes 84 - - [X] Fix collection prefixing (terminate with `/`) 85 - - [X] Fix cursor handling (return `cid` instead of `key`) 86 - - [X] Session management (JWT) 87 - - [X] Match token fields to reference implementation 88 - - [X] RefreshSession from Bluesky Client 89 - - [X] Respond with JSON error message `ExpiredToken` 90 - - [X] Cursor handling 91 - - [X] Implement time-based unix microsecond sequences 92 - - [X] Startup with present cursor 93 - - [X] Respond `RecordNotFound`, required for: 94 - - [X] app.bsky.feed.postgate 95 - - [X] app.bsky.feed.threadgate 96 - - [ ] app.bsky... (profile creation?) 97 - - [X] Linting 98 - - [X] Rustfmt 99 - - [X] warnings 100 - - [X] deprecated-safe 101 - - [X] future-incompatible 102 - - [X] keyword-idents 103 - - [X] let-underscore 104 - - [X] nonstandard-style 105 - - [X] refining-impl-trait 106 - - [X] rust-2018-idioms 107 - - [X] rust-2018/2021/2024-compatibility 108 - - [X] ungrouped 109 - - [X] Clippy 110 - - [X] nursery 111 - - [X] correctness 112 - - [X] suspicious 113 - - [X] complexity 114 - - [X] perf 115 - - [X] style 116 - - [X] pedantic 117 - - [X] cargo 118 - - [X] ungrouped 119 - 120 - ### High-level features 121 - - [ ] Storage backend abstractions 122 - - [ ] Azure blob storage backend 123 - - [ ] Backblaze b2(?) 124 - - [ ] Telemetry 125 - - [X] [Metrics](https://github.com/metrics-rs/metrics) (counters/gauges/etc) 126 - - [X] Exporters for common backends (Prometheus/etc) 127 - 128 50 ### APIs 129 - - [X] [Service proxying](https://atproto.com/specs/xrpc#service-proxying) 130 - - [X] UG /xrpc/_health (undocumented, but impl by reference PDS) 51 + - [ ] [Service proxying](https://atproto.com/specs/xrpc#service-proxying) 52 + - [ ] UG /xrpc/_health (undocumented, but impl by reference PDS) 131 53 <!-- - [ ] xx /xrpc/app.bsky.notification.registerPush 132 54 - app.bsky.actor 133 - - [X] AG /xrpc/app.bsky.actor.getPreferences 55 + - [ ] AG /xrpc/app.bsky.actor.getPreferences 134 56 - [ ] xx /xrpc/app.bsky.actor.getProfile 135 57 - [ ] xx /xrpc/app.bsky.actor.getProfiles 136 - - [X] AP /xrpc/app.bsky.actor.putPreferences 58 + - [ ] AP /xrpc/app.bsky.actor.putPreferences 137 59 - app.bsky.feed 138 60 - [ ] xx /xrpc/app.bsky.feed.getActorLikes 139 61 - [ ] xx /xrpc/app.bsky.feed.getAuthorFeed ··· 157 79 - com.atproto.identity 158 80 - [ ] xx /xrpc/com.atproto.identity.getRecommendedDidCredentials 159 81 - [ ] AP /xrpc/com.atproto.identity.requestPlcOperationSignature 160 - - [X] UG /xrpc/com.atproto.identity.resolveHandle 82 + - [ ] UG /xrpc/com.atproto.identity.resolveHandle 161 83 - [ ] AP /xrpc/com.atproto.identity.signPlcOperation 162 84 - [ ] xx /xrpc/com.atproto.identity.submitPlcOperation 163 - - [X] AP /xrpc/com.atproto.identity.updateHandle 85 + - [ ] AP /xrpc/com.atproto.identity.updateHandle 164 86 <!-- - com.atproto.moderation 165 87 - [ ] xx /xrpc/com.atproto.moderation.createReport --> 166 88 - com.atproto.repo ··· 169 91 - [X] AP /xrpc/com.atproto.repo.deleteRecord 170 92 - [X] UG /xrpc/com.atproto.repo.describeRepo 171 93 - [X] UG /xrpc/com.atproto.repo.getRecord 172 - - [ ] xx /xrpc/com.atproto.repo.importRepo 173 - - [ ] xx /xrpc/com.atproto.repo.listMissingBlobs 94 + - [X] xx /xrpc/com.atproto.repo.importRepo 95 + - [X] xx /xrpc/com.atproto.repo.listMissingBlobs 174 96 - [X] UG /xrpc/com.atproto.repo.listRecords 175 97 - [X] AP /xrpc/com.atproto.repo.putRecord 176 98 - [X] AP /xrpc/com.atproto.repo.uploadBlob ··· 178 100 - [ ] xx /xrpc/com.atproto.server.activateAccount 179 101 - [ ] xx /xrpc/com.atproto.server.checkAccountStatus 180 102 - [ ] xx /xrpc/com.atproto.server.confirmEmail 181 - - [X] UP /xrpc/com.atproto.server.createAccount 103 + - [ ] UP /xrpc/com.atproto.server.createAccount 182 104 - [ ] xx /xrpc/com.atproto.server.createAppPassword 183 - - [X] AP /xrpc/com.atproto.server.createInviteCode 105 + - [ ] AP /xrpc/com.atproto.server.createInviteCode 184 106 - [ ] xx /xrpc/com.atproto.server.createInviteCodes 185 - - [X] UP /xrpc/com.atproto.server.createSession 107 + - [ ] UP /xrpc/com.atproto.server.createSession 186 108 - [ ] xx /xrpc/com.atproto.server.deactivateAccount 187 109 - [ ] xx /xrpc/com.atproto.server.deleteAccount 188 110 - [ ] xx /xrpc/com.atproto.server.deleteSession 189 - - [X] UG /xrpc/com.atproto.server.describeServer 111 + - [ ] UG /xrpc/com.atproto.server.describeServer 190 112 - [ ] xx /xrpc/com.atproto.server.getAccountInviteCodes 191 - - [X] AG /xrpc/com.atproto.server.getServiceAuth 192 - - [X] AG /xrpc/com.atproto.server.getSession 113 + - [ ] AG /xrpc/com.atproto.server.getServiceAuth 114 + - [ ] AG /xrpc/com.atproto.server.getSession 193 115 - [ ] xx /xrpc/com.atproto.server.listAppPasswords 194 116 - [ ] xx /xrpc/com.atproto.server.refreshSession 195 117 - [ ] xx /xrpc/com.atproto.server.requestAccountDelete ··· 201 123 - [ ] xx /xrpc/com.atproto.server.revokeAppPassword 202 124 - [ ] xx /xrpc/com.atproto.server.updateEmail 203 125 - com.atproto.sync 204 - - [X] UG /xrpc/com.atproto.sync.getBlob 205 - - [X] UG /xrpc/com.atproto.sync.getBlocks 206 - - [X] UG /xrpc/com.atproto.sync.getLatestCommit 207 - - [X] UG /xrpc/com.atproto.sync.getRecord 208 - - [X] UG /xrpc/com.atproto.sync.getRepo 209 - - [X] UG /xrpc/com.atproto.sync.getRepoStatus 210 - - [X] UG /xrpc/com.atproto.sync.listBlobs 211 - - [X] UG /xrpc/com.atproto.sync.listRepos 212 - - [X] UG /xrpc/com.atproto.sync.subscribeRepos 126 + - [ ] UG /xrpc/com.atproto.sync.getBlob 127 + - [ ] UG /xrpc/com.atproto.sync.getBlocks 128 + - [ ] UG /xrpc/com.atproto.sync.getLatestCommit 129 + - [ ] UG /xrpc/com.atproto.sync.getRecord 130 + - [ ] UG /xrpc/com.atproto.sync.getRepo 131 + - [ ] UG /xrpc/com.atproto.sync.getRepoStatus 132 + - [ ] UG /xrpc/com.atproto.sync.listBlobs 133 + - [ ] UG /xrpc/com.atproto.sync.listRepos 134 + - [ ] UG /xrpc/com.atproto.sync.subscribeRepos 213 135 214 - ## Quick Deployment (Azure CLI) 215 - ``` 216 - az group create --name "webapp" --location southcentralus 217 - az deployment group create --resource-group "webapp" --template-file .\deployment.bicep --parameters webAppName=testapp 218 - 219 - az acr login --name <insert name of ACR resource here> 220 - docker build -t <ACR>.azurecr.io/testapp:latest . 221 - docker push <ACR>.azurecr.io/testapp:latest 222 - ``` 223 - ## Quick Deployment (NixOS) 136 + ## Deployment (NixOS) 224 137 ```nix 225 138 { 226 139 inputs = {
+14
migrations/2025-05-15-182818_init_diff/down.sql
··· 1 + DROP TABLE IF EXISTS `repo_seq`; 2 + DROP TABLE IF EXISTS `app_password`; 3 + DROP TABLE IF EXISTS `device_account`; 4 + DROP TABLE IF EXISTS `actor`; 5 + DROP TABLE IF EXISTS `device`; 6 + DROP TABLE IF EXISTS `did_doc`; 7 + DROP TABLE IF EXISTS `email_token`; 8 + DROP TABLE IF EXISTS `invite_code`; 9 + DROP TABLE IF EXISTS `used_refresh_token`; 10 + DROP TABLE IF EXISTS `invite_code_use`; 11 + DROP TABLE IF EXISTS `authorization_request`; 12 + DROP TABLE IF EXISTS `token`; 13 + DROP TABLE IF EXISTS `refresh_token`; 14 + DROP TABLE IF EXISTS `account`;
+122
migrations/2025-05-15-182818_init_diff/up.sql
··· 1 + CREATE TABLE `repo_seq`( 2 + `seq` INT8 NOT NULL PRIMARY KEY, 3 + `did` VARCHAR NOT NULL, 4 + `eventtype` VARCHAR NOT NULL, 5 + `event` BYTEA NOT NULL, 6 + `invalidated` INT2 NOT NULL, 7 + `sequencedat` VARCHAR NOT NULL 8 + ); 9 + 10 + CREATE TABLE `app_password`( 11 + `did` VARCHAR NOT NULL, 12 + `name` VARCHAR NOT NULL, 13 + `password` VARCHAR NOT NULL, 14 + `createdat` VARCHAR NOT NULL, 15 + PRIMARY KEY(`did`, `name`) 16 + ); 17 + 18 + CREATE TABLE `device_account`( 19 + `did` VARCHAR NOT NULL, 20 + `deviceid` VARCHAR NOT NULL, 21 + `authenticatedat` TIMESTAMPTZ NOT NULL, 22 + `remember` BOOL NOT NULL, 23 + `authorizedclients` VARCHAR NOT NULL, 24 + PRIMARY KEY(`deviceId`, `did`) 25 + ); 26 + 27 + CREATE TABLE `actor`( 28 + `did` VARCHAR NOT NULL PRIMARY KEY, 29 + `handle` VARCHAR, 30 + `createdat` VARCHAR NOT NULL, 31 + `takedownref` VARCHAR, 32 + `deactivatedat` VARCHAR, 33 + `deleteafter` VARCHAR 34 + ); 35 + 36 + CREATE TABLE `device`( 37 + `id` VARCHAR NOT NULL PRIMARY KEY, 38 + `sessionid` VARCHAR, 39 + `useragent` VARCHAR, 40 + `ipaddress` VARCHAR NOT NULL, 41 + `lastseenat` TIMESTAMPTZ NOT NULL 42 + ); 43 + 44 + CREATE TABLE `did_doc`( 45 + `did` VARCHAR NOT NULL PRIMARY KEY, 46 + `doc` TEXT NOT NULL, 47 + `updatedat` INT8 NOT NULL 48 + ); 49 + 50 + CREATE TABLE `email_token`( 51 + `purpose` VARCHAR NOT NULL, 52 + `did` VARCHAR NOT NULL, 53 + `token` VARCHAR NOT NULL, 54 + `requestedat` VARCHAR NOT NULL, 55 + PRIMARY KEY(`purpose`, `did`) 56 + ); 57 + 58 + CREATE TABLE `invite_code`( 59 + `code` VARCHAR NOT NULL PRIMARY KEY, 60 + `availableuses` INT4 NOT NULL, 61 + `disabled` INT2 NOT NULL, 62 + `foraccount` VARCHAR NOT NULL, 63 + `createdby` VARCHAR NOT NULL, 64 + `createdat` VARCHAR NOT NULL 65 + ); 66 + 67 + CREATE TABLE `used_refresh_token`( 68 + `refreshtoken` VARCHAR NOT NULL PRIMARY KEY, 69 + `tokenid` VARCHAR NOT NULL 70 + ); 71 + 72 + CREATE TABLE `invite_code_use`( 73 + `code` VARCHAR NOT NULL, 74 + `usedby` VARCHAR NOT NULL, 75 + `usedat` VARCHAR NOT NULL, 76 + PRIMARY KEY(`code`, `usedBy`) 77 + ); 78 + 79 + CREATE TABLE `authorization_request`( 80 + `id` VARCHAR NOT NULL PRIMARY KEY, 81 + `did` VARCHAR, 82 + `deviceid` VARCHAR, 83 + `clientid` VARCHAR NOT NULL, 84 + `clientauth` VARCHAR NOT NULL, 85 + `parameters` VARCHAR NOT NULL, 86 + `expiresat` TIMESTAMPTZ NOT NULL, 87 + `code` VARCHAR 88 + ); 89 + 90 + CREATE TABLE `token`( 91 + `id` VARCHAR NOT NULL PRIMARY KEY, 92 + `did` VARCHAR NOT NULL, 93 + `tokenid` VARCHAR NOT NULL, 94 + `createdat` TIMESTAMPTZ NOT NULL, 95 + `updatedat` TIMESTAMPTZ NOT NULL, 96 + `expiresat` TIMESTAMPTZ NOT NULL, 97 + `clientid` VARCHAR NOT NULL, 98 + `clientauth` VARCHAR NOT NULL, 99 + `deviceid` VARCHAR, 100 + `parameters` VARCHAR NOT NULL, 101 + `details` VARCHAR, 102 + `code` VARCHAR, 103 + `currentrefreshtoken` VARCHAR 104 + ); 105 + 106 + CREATE TABLE `refresh_token`( 107 + `id` VARCHAR NOT NULL PRIMARY KEY, 108 + `did` VARCHAR NOT NULL, 109 + `expiresat` VARCHAR NOT NULL, 110 + `nextid` VARCHAR, 111 + `apppasswordname` VARCHAR 112 + ); 113 + 114 + CREATE TABLE `account`( 115 + `did` VARCHAR NOT NULL PRIMARY KEY, 116 + `email` VARCHAR NOT NULL, 117 + `recoverykey` VARCHAR, 118 + `password` VARCHAR NOT NULL, 119 + `createdat` VARCHAR NOT NULL, 120 + `invitesdisabled` INT2 NOT NULL, 121 + `emailconfirmedat` VARCHAR 122 + );
+4
migrations/2025-05-17-094600_oauth_temp/down.sql
··· 1 + DROP TABLE IF EXISTS `oauth_refresh_tokens`; 2 + DROP TABLE IF EXISTS `oauth_used_jtis`; 3 + DROP TABLE IF EXISTS `oauth_par_requests`; 4 + DROP TABLE IF EXISTS `oauth_authorization_codes`;
+46
migrations/2025-05-17-094600_oauth_temp/up.sql
··· 1 + CREATE TABLE `oauth_refresh_tokens`( 2 + `token` VARCHAR NOT NULL PRIMARY KEY, 3 + `client_id` VARCHAR NOT NULL, 4 + `subject` VARCHAR NOT NULL, 5 + `dpop_thumbprint` VARCHAR NOT NULL, 6 + `scope` VARCHAR, 7 + `created_at` INT8 NOT NULL, 8 + `expires_at` INT8 NOT NULL, 9 + `revoked` BOOL NOT NULL 10 + ); 11 + 12 + CREATE TABLE `oauth_used_jtis`( 13 + `jti` VARCHAR NOT NULL PRIMARY KEY, 14 + `issuer` VARCHAR NOT NULL, 15 + `created_at` INT8 NOT NULL, 16 + `expires_at` INT8 NOT NULL 17 + ); 18 + 19 + CREATE TABLE `oauth_par_requests`( 20 + `request_uri` VARCHAR NOT NULL PRIMARY KEY, 21 + `client_id` VARCHAR NOT NULL, 22 + `response_type` VARCHAR NOT NULL, 23 + `code_challenge` VARCHAR NOT NULL, 24 + `code_challenge_method` VARCHAR NOT NULL, 25 + `state` VARCHAR, 26 + `login_hint` VARCHAR, 27 + `scope` VARCHAR, 28 + `redirect_uri` VARCHAR, 29 + `response_mode` VARCHAR, 30 + `display` VARCHAR, 31 + `created_at` INT8 NOT NULL, 32 + `expires_at` INT8 NOT NULL 33 + ); 34 + 35 + CREATE TABLE `oauth_authorization_codes`( 36 + `code` VARCHAR NOT NULL PRIMARY KEY, 37 + `client_id` VARCHAR NOT NULL, 38 + `subject` VARCHAR NOT NULL, 39 + `code_challenge` VARCHAR NOT NULL, 40 + `code_challenge_method` VARCHAR NOT NULL, 41 + `redirect_uri` VARCHAR NOT NULL, 42 + `scope` VARCHAR, 43 + `created_at` INT8 NOT NULL, 44 + `expires_at` INT8 NOT NULL, 45 + `used` BOOL NOT NULL 46 + );
-7
migrations/20250104202448_init.down.sql
··· 1 - DROP TABLE IF EXISTS invites; 2 - 3 - DROP TABLE IF EXISTS handles; 4 - 5 - DROP TABLE IF EXISTS accounts; 6 - 7 - DROP TABLE IF EXISTS sessions;
-29
migrations/20250104202448_init.up.sql
··· 1 - CREATE TABLE IF NOT EXISTS accounts ( 2 - did TEXT PRIMARY KEY NOT NULL, 3 - email TEXT NOT NULL UNIQUE, 4 - password TEXT NOT NULL, 5 - root TEXT NOT NULL, 6 - rev TEXT NOT NULL, 7 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 8 - ); 9 - 10 - CREATE TABLE IF NOT EXISTS handles ( 11 - handle TEXT PRIMARY KEY NOT NULL, 12 - did TEXT NOT NULL, 13 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 14 - FOREIGN KEY (did) REFERENCES accounts(did) 15 - ); 16 - 17 - CREATE TABLE IF NOT EXISTS invites ( 18 - id TEXT PRIMARY KEY NOT NULL, 19 - did TEXT, 20 - count INTEGER NOT NULL DEFAULT 1, 21 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 22 - ); 23 - 24 - CREATE TABLE IF NOT EXISTS sessions ( 25 - id TEXT PRIMARY KEY NOT NULL, 26 - did TEXT NOT NULL, 27 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 28 - FOREIGN KEY (did) REFERENCES accounts(did) 29 - );
-1
migrations/20250217052304_repo_status.down.sql
··· 1 - ALTER TABLE accounts DROP COLUMN status;
-1
migrations/20250217052304_repo_status.up.sql
··· 1 - ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT "active";
-1
migrations/20250219055555_account_plc_root.down.sql
··· 1 - ALTER TABLE accounts DROP COLUMN plc_root;
-1
migrations/20250219055555_account_plc_root.up.sql
··· 1 - ALTER TABLE accounts ADD COLUMN plc_root TEXT NOT NULL;
-1
migrations/20250220235950_private_data.down.sql
··· 1 - ALTER TABLE accounts DROP COLUMN private_prefs;
-1
migrations/20250220235950_private_data.up.sql
··· 1 - ALTER TABLE accounts ADD COLUMN private_prefs JSON;
-1
migrations/20250223015249_blob_ref.down.sql
··· 1 - DROP TABLE blob_ref;
-6
migrations/20250223015249_blob_ref.up.sql
··· 1 - CREATE TABLE IF NOT EXISTS blob_ref ( 2 - -- N.B: There is a hidden `rowid` field inserted by sqlite. 3 - cid TEXT NOT NULL, 4 - did TEXT NOT NULL, 5 - record TEXT 6 - );
-1
migrations/20250330074000_oauth.down.sql
··· 1 - DROP TABLE oauth_par_requests;
-37
migrations/20250330074000_oauth.up.sql
··· 1 - CREATE TABLE IF NOT EXISTS oauth_par_requests ( 2 - request_uri TEXT PRIMARY KEY NOT NULL, 3 - client_id TEXT NOT NULL, 4 - response_type TEXT NOT NULL, 5 - code_challenge TEXT NOT NULL, 6 - code_challenge_method TEXT NOT NULL, 7 - state TEXT, 8 - login_hint TEXT, 9 - scope TEXT, 10 - redirect_uri TEXT, 11 - response_mode TEXT, 12 - display TEXT, 13 - created_at INTEGER NOT NULL, 14 - expires_at INTEGER NOT NULL 15 - ); 16 - CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( 17 - code TEXT PRIMARY KEY NOT NULL, 18 - client_id TEXT NOT NULL, 19 - subject TEXT NOT NULL, 20 - code_challenge TEXT NOT NULL, 21 - code_challenge_method TEXT NOT NULL, 22 - redirect_uri TEXT NOT NULL, 23 - scope TEXT, 24 - created_at INTEGER NOT NULL, 25 - expires_at INTEGER NOT NULL, 26 - used BOOLEAN NOT NULL DEFAULT FALSE 27 - ); 28 - CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( 29 - token TEXT PRIMARY KEY NOT NULL, 30 - client_id TEXT NOT NULL, 31 - subject TEXT NOT NULL, 32 - dpop_thumbprint TEXT NOT NULL, 33 - scope TEXT, 34 - created_at INTEGER NOT NULL, 35 - expires_at INTEGER NOT NULL, 36 - revoked BOOLEAN NOT NULL DEFAULT FALSE 37 - );
-6
migrations/20250502032700_jti.down.sql
··· 1 - DROP INDEX IF EXISTS idx_jtis_expires_at; 2 - DROP INDEX IF EXISTS idx_refresh_tokens_expires_at; 3 - DROP INDEX IF EXISTS idx_auth_codes_expires_at; 4 - DROP INDEX IF EXISTS idx_par_expires_at; 5 - 6 - DROP TABLE IF EXISTS oauth_used_jtis;
-13
migrations/20250502032700_jti.up.sql
··· 1 - -- Table for tracking used JTIs to prevent replay attacks 2 - CREATE TABLE IF NOT EXISTS oauth_used_jtis ( 3 - jti TEXT PRIMARY KEY NOT NULL, 4 - issuer TEXT NOT NULL, 5 - created_at INTEGER NOT NULL, 6 - expires_at INTEGER NOT NULL 7 - ); 8 - 9 - -- Create indexes for faster lookups and cleanup 10 - CREATE INDEX IF NOT EXISTS idx_par_expires_at ON oauth_par_requests(expires_at); 11 - CREATE INDEX IF NOT EXISTS idx_auth_codes_expires_at ON oauth_authorization_codes(expires_at); 12 - CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON oauth_refresh_tokens(expires_at); 13 - CREATE INDEX IF NOT EXISTS idx_jtis_expires_at ON oauth_used_jtis(expires_at);
-16
migrations/20250508251242_actor_store.down.sql
··· 1 - -- Drop indexes 2 - DROP INDEX IF EXISTS idx_backlink_link_to; 3 - DROP INDEX IF EXISTS idx_blob_tempkey; 4 - DROP INDEX IF EXISTS idx_record_repo_rev; 5 - DROP INDEX IF EXISTS idx_record_collection; 6 - DROP INDEX IF EXISTS idx_record_cid; 7 - DROP INDEX IF EXISTS idx_repo_block_repo_rev; 8 - 9 - -- Drop tables 10 - DROP TABLE IF EXISTS account_pref; 11 - DROP TABLE IF EXISTS backlink; 12 - DROP TABLE IF EXISTS record_blob; 13 - DROP TABLE IF EXISTS blob; 14 - DROP TABLE IF EXISTS record; 15 - DROP TABLE IF EXISTS repo_block; 16 - DROP TABLE IF EXISTS repo_root;
-70
migrations/20250508251242_actor_store.up.sql
··· 1 - -- Actor store schema matching TypeScript implementation 2 - 3 - -- Repository root information 4 - CREATE TABLE IF NOT EXISTS repo_root ( 5 - did TEXT PRIMARY KEY NOT NULL, 6 - cid TEXT NOT NULL, 7 - rev TEXT NOT NULL, 8 - indexedAt TEXT NOT NULL 9 - ); 10 - 11 - -- Repository blocks (IPLD blocks) 12 - CREATE TABLE IF NOT EXISTS repo_block ( 13 - cid TEXT PRIMARY KEY NOT NULL, 14 - repoRev TEXT NOT NULL, 15 - size INTEGER NOT NULL, 16 - content BLOB NOT NULL 17 - ); 18 - 19 - -- Record index 20 - CREATE TABLE IF NOT EXISTS record ( 21 - uri TEXT PRIMARY KEY NOT NULL, 22 - cid TEXT NOT NULL, 23 - collection TEXT NOT NULL, 24 - rkey TEXT NOT NULL, 25 - repoRev TEXT NOT NULL, 26 - indexedAt TEXT NOT NULL, 27 - takedownRef TEXT 28 - ); 29 - 30 - -- Blob storage metadata 31 - CREATE TABLE IF NOT EXISTS blob ( 32 - cid TEXT PRIMARY KEY NOT NULL, 33 - mimeType TEXT NOT NULL, 34 - size INTEGER NOT NULL, 35 - tempKey TEXT, 36 - width INTEGER, 37 - height INTEGER, 38 - createdAt TEXT NOT NULL, 39 - takedownRef TEXT 40 - ); 41 - 42 - -- Record-blob associations 43 - CREATE TABLE IF NOT EXISTS record_blob ( 44 - blobCid TEXT NOT NULL, 45 - recordUri TEXT NOT NULL, 46 - PRIMARY KEY (blobCid, recordUri) 47 - ); 48 - 49 - -- Backlinks between records 50 - CREATE TABLE IF NOT EXISTS backlink ( 51 - uri TEXT NOT NULL, 52 - path TEXT NOT NULL, 53 - linkTo TEXT NOT NULL, 54 - PRIMARY KEY (uri, path) 55 - ); 56 - 57 - -- User preferences 58 - CREATE TABLE IF NOT EXISTS account_pref ( 59 - id INTEGER PRIMARY KEY AUTOINCREMENT, 60 - name TEXT NOT NULL, 61 - valueJson TEXT NOT NULL 62 - ); 63 - 64 - -- Create indexes 65 - CREATE INDEX IF NOT EXISTS idx_repo_block_repo_rev ON repo_block(repoRev, cid); 66 - CREATE INDEX IF NOT EXISTS idx_record_cid ON record(cid); 67 - CREATE INDEX IF NOT EXISTS idx_record_collection ON record(collection); 68 - CREATE INDEX IF NOT EXISTS idx_record_repo_rev ON record(repoRev); 69 - CREATE INDEX IF NOT EXISTS idx_blob_tempkey ON blob(tempKey); 70 - CREATE INDEX IF NOT EXISTS idx_backlink_link_to ON backlink(path, linkTo);
-15
migrations/20250508252057_blockstore.up.sql
··· 1 - CREATE TABLE IF NOT EXISTS blocks ( 2 - cid TEXT PRIMARY KEY NOT NULL, 3 - data BLOB NOT NULL, 4 - multicodec INTEGER NOT NULL, 5 - multihash INTEGER NOT NULL 6 - ); 7 - CREATE TABLE IF NOT EXISTS tree_nodes ( 8 - repo_did TEXT NOT NULL, 9 - key TEXT NOT NULL, 10 - value_cid TEXT NOT NULL, 11 - PRIMARY KEY (repo_did, key), 12 - FOREIGN KEY (value_cid) REFERENCES blocks(cid) 13 - ); 14 - CREATE INDEX IF NOT EXISTS idx_blocks_cid ON blocks(cid); 15 - CREATE INDEX IF NOT EXISTS idx_tree_nodes_repo ON tree_nodes(repo_did);
-5
migrations/20250510222500_actor_migration.up.sql
··· 1 - CREATE TABLE IF NOT EXISTS actor_migration ( 2 - id INTEGER PRIMARY KEY AUTOINCREMENT, 3 - name TEXT NOT NULL, 4 - appliedAt TEXT NOT NULL 5 - );
+40 -12
src/account_manager/helpers/account.rs
··· 3 3 //! 4 4 //! Modified for SQLite backend 5 5 use crate::schema::pds::account::dsl as AccountSchema; 6 + use crate::schema::pds::account::table as AccountTable; 6 7 use crate::schema::pds::actor::dsl as ActorSchema; 8 + use crate::schema::pds::actor::table as ActorTable; 7 9 use anyhow::Result; 8 10 use chrono::DateTime; 9 11 use chrono::offset::Utc as UtcOffset; 10 - use diesel::dsl::{exists, not}; 11 12 use diesel::result::{DatabaseErrorKind, Error as DieselError}; 12 13 use diesel::*; 13 14 use rsky_common::RFC3339_VARIANT; 14 15 use rsky_lexicon::com::atproto::admin::StatusAttr; 15 16 #[expect(unused_imports)] 16 17 pub(crate) use rsky_pds::account_manager::helpers::account::{ 17 - AccountStatus, ActorAccount, ActorJoinAccount, AvailabilityFlags, FormattedAccountStatus, 18 + AccountStatus, ActorAccount, AvailabilityFlags, FormattedAccountStatus, 18 19 GetAccountAdminStatusOutput, format_account_status, 19 20 }; 20 21 use std::ops::Add; 21 22 use std::time::SystemTime; 22 23 use thiserror::Error; 23 24 25 + use diesel::dsl::{LeftJoinOn, exists, not}; 26 + use diesel::helper_types::Eq; 27 + 24 28 #[derive(Error, Debug)] 25 29 pub enum AccountHelperError { 26 30 #[error("UserAlreadyExistsError")] ··· 28 32 #[error("DatabaseError: `{0}`")] 29 33 DieselError(String), 30 34 } 31 - 35 + pub type ActorJoinAccount = 36 + LeftJoinOn<ActorTable, AccountTable, Eq<ActorSchema::did, AccountSchema::did>>; 32 37 pub type BoxedQuery<'life> = dsl::IntoBoxed<'life, ActorJoinAccount, sqlite::Sqlite>; 33 38 pub fn select_account_qb(flags: Option<AvailabilityFlags>) -> BoxedQuery<'static> { 34 39 let AvailabilityFlags { ··· 252 257 deadpool_diesel::Manager<SqliteConnection>, 253 258 deadpool_diesel::sqlite::Object, 254 259 >, 260 + actor_db: &deadpool_diesel::Pool< 261 + deadpool_diesel::Manager<SqliteConnection>, 262 + deadpool_diesel::sqlite::Object, 263 + >, 255 264 ) -> Result<()> { 265 + use crate::schema::actor_store::repo_root::dsl as RepoRootSchema; 256 266 use crate::schema::pds::email_token::dsl as EmailTokenSchema; 257 267 use crate::schema::pds::refresh_token::dsl as RefreshTokenSchema; 258 - use crate::schema::pds::repo_root::dsl as RepoRootSchema; 259 268 260 - let did = did.to_owned(); 269 + let did_clone = did.to_owned(); 270 + _ = actor_db 271 + .get() 272 + .await? 273 + .interact(move |conn| { 274 + delete(RepoRootSchema::repo_root) 275 + .filter(RepoRootSchema::did.eq(&did_clone)) 276 + .execute(conn) 277 + }) 278 + .await 279 + .expect("Failed to delete actor")?; 280 + let did_clone = did.to_owned(); 261 281 _ = db 262 282 .get() 263 283 .await? 264 284 .interact(move |conn| { 265 - _ = delete(RepoRootSchema::repo_root) 266 - .filter(RepoRootSchema::did.eq(&did)) 267 - .execute(conn)?; 268 285 _ = delete(EmailTokenSchema::email_token) 269 - .filter(EmailTokenSchema::did.eq(&did)) 286 + .filter(EmailTokenSchema::did.eq(&did_clone)) 270 287 .execute(conn)?; 271 288 _ = delete(RefreshTokenSchema::refresh_token) 272 - .filter(RefreshTokenSchema::did.eq(&did)) 289 + .filter(RefreshTokenSchema::did.eq(&did_clone)) 273 290 .execute(conn)?; 274 291 _ = delete(AccountSchema::account) 275 - .filter(AccountSchema::did.eq(&did)) 292 + .filter(AccountSchema::did.eq(&did_clone)) 276 293 .execute(conn)?; 277 294 delete(ActorSchema::actor) 278 - .filter(ActorSchema::did.eq(&did)) 295 + .filter(ActorSchema::did.eq(&did_clone)) 279 296 .execute(conn) 280 297 }) 281 298 .await 282 299 .expect("Failed to delete account")?; 300 + 301 + let data_repo_file = format!("data/repo/{}.db", did.to_owned()); 302 + let data_blob_path = format!("data/blob/{}", did); 303 + let data_blob_path = std::path::Path::new(&data_blob_path); 304 + let data_repo_file = std::path::Path::new(&data_repo_file); 305 + if data_repo_file.exists() { 306 + std::fs::remove_file(data_repo_file)?; 307 + }; 308 + if data_blob_path.exists() { 309 + std::fs::remove_dir_all(data_blob_path)?; 310 + }; 283 311 Ok(()) 284 312 } 285 313
+1 -1
src/account_manager/helpers/auth.rs
··· 2 2 //! blacksky-algorithms/rsky is licensed under the Apache License 2.0 3 3 //! 4 4 //! Modified for SQLite backend 5 + use crate::models::pds as models; 5 6 use anyhow::Result; 6 7 use diesel::*; 7 8 use rsky_common::time::from_micros_to_utc; ··· 12 13 RefreshToken, ServiceJwtHeader, ServiceJwtParams, ServiceJwtPayload, create_access_token, 13 14 create_refresh_token, create_service_jwt, create_tokens, decode_refresh_token, 14 15 }; 15 - use rsky_pds::models; 16 16 17 17 pub async fn store_refresh_token( 18 18 payload: RefreshToken,
+2 -80
src/account_manager/helpers/email_token.rs
··· 2 2 //! blacksky-algorithms/rsky is licensed under the Apache License 2.0 3 3 //! 4 4 //! Modified for SQLite backend 5 - #![allow(unnameable_types, unused_qualifications)] 5 + use crate::models::pds::EmailToken; 6 + use crate::models::pds::EmailTokenPurpose; 6 7 use anyhow::{Result, bail}; 7 8 use diesel::*; 8 9 use rsky_common::time::{MINUTE, from_str_to_utc, less_than_ago_s}; 9 10 use rsky_pds::apis::com::atproto::server::get_random_token; 10 - use rsky_pds::models::EmailToken; 11 11 12 12 pub async fn create_email_token( 13 13 did: &str, ··· 121 121 Ok(res.did) 122 122 } else { 123 123 bail!("Token is invalid") 124 - } 125 - } 126 - 127 - #[derive( 128 - Clone, 129 - Copy, 130 - Debug, 131 - PartialEq, 132 - Eq, 133 - Hash, 134 - Default, 135 - serde::Serialize, 136 - serde::Deserialize, 137 - AsExpression, 138 - )] 139 - #[diesel(sql_type = sql_types::Text)] 140 - pub enum EmailTokenPurpose { 141 - #[default] 142 - ConfirmEmail, 143 - UpdateEmail, 144 - ResetPassword, 145 - DeleteAccount, 146 - PlcOperation, 147 - } 148 - 149 - impl EmailTokenPurpose { 150 - pub const fn as_str(&self) -> &'static str { 151 - match self { 152 - Self::ConfirmEmail => "confirm_email", 153 - Self::UpdateEmail => "update_email", 154 - Self::ResetPassword => "reset_password", 155 - Self::DeleteAccount => "delete_account", 156 - Self::PlcOperation => "plc_operation", 157 - } 158 - } 159 - 160 - pub fn from_str(s: &str) -> Result<Self> { 161 - match s { 162 - "confirm_email" => Ok(Self::ConfirmEmail), 163 - "update_email" => Ok(Self::UpdateEmail), 164 - "reset_password" => Ok(Self::ResetPassword), 165 - "delete_account" => Ok(Self::DeleteAccount), 166 - "plc_operation" => Ok(Self::PlcOperation), 167 - _ => bail!("Unable to parse as EmailTokenPurpose: `{s:?}`"), 168 - } 169 - } 170 - } 171 - 172 - impl<DB> Queryable<sql_types::Text, DB> for EmailTokenPurpose 173 - where 174 - DB: backend::Backend, 175 - String: deserialize::FromSql<sql_types::Text, DB>, 176 - { 177 - type Row = String; 178 - 179 - fn build(s: String) -> deserialize::Result<Self> { 180 - Ok(Self::from_str(&s)?) 181 - } 182 - } 183 - 184 - impl serialize::ToSql<sql_types::Text, sqlite::Sqlite> for EmailTokenPurpose 185 - where 186 - String: serialize::ToSql<sql_types::Text, sqlite::Sqlite>, 187 - { 188 - fn to_sql<'lifetime>( 189 - &'lifetime self, 190 - out: &mut serialize::Output<'lifetime, '_, sqlite::Sqlite>, 191 - ) -> serialize::Result { 192 - serialize::ToSql::<sql_types::Text, sqlite::Sqlite>::to_sql( 193 - match self { 194 - Self::ConfirmEmail => "confirm_email", 195 - Self::UpdateEmail => "update_email", 196 - Self::ResetPassword => "reset_password", 197 - Self::DeleteAccount => "delete_account", 198 - Self::PlcOperation => "plc_operation", 199 - }, 200 - out, 201 - ) 202 124 } 203 125 } 204 126
+1 -1
src/account_manager/helpers/invite.rs
··· 2 2 //! blacksky-algorithms/rsky is licensed under the Apache License 2.0 3 3 //! 4 4 //! Modified for SQLite backend 5 + use crate::models::pds as models; 5 6 use anyhow::{Result, bail}; 6 7 use diesel::*; 7 8 use rsky_lexicon::com::atproto::server::AccountCodes; ··· 9 10 InviteCode as LexiconInviteCode, InviteCodeUse as LexiconInviteCodeUse, 10 11 }; 11 12 use rsky_pds::account_manager::DisableInviteCodesOpts; 12 - use rsky_pds::models::models; 13 13 use std::collections::BTreeMap; 14 14 use std::mem; 15 15
+2 -2
src/account_manager/helpers/password.rs
··· 2 2 //! blacksky-algorithms/rsky is licensed under the Apache License 2.0 3 3 //! 4 4 //! Modified for SQLite backend 5 + use crate::models::pds as models; 6 + use crate::models::pds::AppPassword; 5 7 use anyhow::{Result, bail}; 6 8 use diesel::*; 7 9 use rsky_common::{get_random_str, now}; ··· 10 12 pub(crate) use rsky_pds::account_manager::helpers::password::{ 11 13 UpdateUserPasswordOpts, gen_salt_and_hash, hash_app_password, hash_with_salt, verify, 12 14 }; 13 - use rsky_pds::models; 14 - use rsky_pds::models::AppPassword; 15 15 16 16 pub async fn verify_account_password( 17 17 did: &str,
+3 -5
src/account_manager/helpers/repo.rs
··· 4 4 //! Modified for SQLite backend 5 5 use anyhow::Result; 6 6 use cidv10::Cid; 7 + use deadpool_diesel::{Manager, Pool, sqlite::Object}; 7 8 use diesel::*; 8 9 9 10 pub async fn update_root( 10 11 did: String, 11 12 cid: Cid, 12 13 rev: String, 13 - db: &deadpool_diesel::Pool< 14 - deadpool_diesel::Manager<SqliteConnection>, 15 - deadpool_diesel::sqlite::Object, 16 - >, 14 + db: &Pool<Manager<SqliteConnection>, Object>, 17 15 ) -> Result<()> { 18 16 // @TODO balance risk of a race in the case of a long retry 19 - use crate::schema::pds::repo_root::dsl as RepoRootSchema; 17 + use crate::schema::actor_store::repo_root::dsl as RepoRootSchema; 20 18 21 19 let now = rsky_common::now(); 22 20
+63 -7
src/account_manager/mod.rs
··· 10 10 }; 11 11 use crate::account_manager::helpers::invite::CodeDetail; 12 12 use crate::account_manager::helpers::password::UpdateUserPasswordOpts; 13 + use crate::models::pds::EmailTokenPurpose; 14 + use crate::serve::ActorStorage; 13 15 use anyhow::Result; 14 16 use chrono::DateTime; 15 17 use chrono::offset::Utc as UtcOffset; 16 18 use cidv10::Cid; 17 19 use diesel::*; 18 20 use futures::try_join; 19 - use helpers::email_token::EmailTokenPurpose; 20 21 use helpers::{account, auth, email_token, invite, password, repo}; 21 22 use rsky_common::RFC3339_VARIANT; 22 23 use rsky_common::time::{HOUR, from_micros_to_str, from_str_to_micros}; ··· 31 32 use std::collections::BTreeMap; 32 33 use std::env; 33 34 use std::time::SystemTime; 35 + use tokio::sync::RwLock; 34 36 35 37 pub(crate) mod helpers { 36 38 pub mod account; ··· 129 131 } 130 132 } 131 133 132 - pub async fn create_account(&self, opts: CreateAccountOpts) -> Result<(String, String)> { 134 + pub async fn create_account( 135 + &self, 136 + opts: CreateAccountOpts, 137 + actor_pools: &mut std::collections::HashMap<String, ActorStorage>, 138 + ) -> Result<(String, String)> { 133 139 let CreateAccountOpts { 134 140 did, 135 141 handle, ··· 170 176 } 171 177 invite::record_invite_use(did.clone(), invite_code, now, &self.db).await?; 172 178 auth::store_refresh_token(refresh_payload, None, &self.db).await?; 173 - repo::update_root(did, repo_cid, repo_rev, &self.db).await?; 179 + 180 + let did_path = did 181 + .strip_prefix("did:plc:") 182 + .ok_or_else(|| anyhow::anyhow!("Invalid DID"))?; 183 + let repo_path = format!("sqlite://data/repo/{}.db", did_path); 184 + let actor_repo_pool = 185 + crate::db::establish_pool(repo_path.as_str()).expect("Failed to establish pool"); 186 + let blob_path = std::path::Path::new("data/blob").to_path_buf(); 187 + let actor_pool = ActorStorage { 188 + repo: actor_repo_pool, 189 + blob: blob_path.clone(), 190 + }; 191 + let blob_path = blob_path.join(did_path); 192 + tokio::fs::create_dir_all(&blob_path) 193 + .await 194 + .map_err(|_| anyhow::anyhow!("Failed to create blob path"))?; 195 + drop( 196 + actor_pools 197 + .insert(did.clone(), actor_pool) 198 + .expect("Failed to insert actor pools"), 199 + ); 200 + let db = actor_pools 201 + .get(&did) 202 + .ok_or_else(|| anyhow::anyhow!("Actor not found"))? 203 + .repo 204 + .clone(); 205 + repo::update_root(did, repo_cid, repo_rev, &db).await?; 174 206 Ok((access_jwt, refresh_jwt)) 175 207 } 176 208 ··· 181 213 account::get_account_admin_status(did, &self.db).await 182 214 } 183 215 184 - pub async fn update_repo_root(&self, did: String, cid: Cid, rev: String) -> Result<()> { 185 - repo::update_root(did, cid, rev, &self.db).await 216 + pub async fn update_repo_root( 217 + &self, 218 + did: String, 219 + cid: Cid, 220 + rev: String, 221 + actor_pools: &std::collections::HashMap<String, ActorStorage>, 222 + ) -> Result<()> { 223 + let db = actor_pools 224 + .get(&did) 225 + .ok_or_else(|| anyhow::anyhow!("Actor not found"))? 226 + .repo 227 + .clone(); 228 + repo::update_root(did, cid, rev, &db).await 186 229 } 187 230 188 - pub async fn delete_account(&self, did: &str) -> Result<()> { 189 - account::delete_account(did, &self.db).await 231 + pub async fn delete_account( 232 + &self, 233 + did: &str, 234 + actor_pools: &std::collections::HashMap<String, ActorStorage>, 235 + ) -> Result<()> { 236 + let db = actor_pools 237 + .get(did) 238 + .ok_or_else(|| anyhow::anyhow!("Actor not found"))? 239 + .repo 240 + .clone(); 241 + account::delete_account(did, &self.db, &db).await 190 242 } 191 243 192 244 pub async fn takedown_account(&self, did: &str, takedown: StatusAttr) -> Result<()> { ··· 500 552 email_token::create_email_token(did, purpose, &self.db).await 501 553 } 502 554 } 555 + 556 + pub struct SharedAccountManager { 557 + pub account_manager: RwLock<AccountManager>, 558 + }
+20 -8
src/actor_endpoints.rs
··· 3 3 /// We shouldn't have to know about any bsky endpoints to store private user data. 4 4 /// This will _very likely_ be changed in the future. 5 5 use atrium_api::app::bsky::actor; 6 - use axum::{Json, routing::post}; 6 + use axum::{ 7 + Json, Router, 8 + extract::State, 9 + routing::{get, post}, 10 + }; 7 11 use constcat::concat; 8 - use diesel::prelude::*; 9 12 10 - use crate::actor_store::ActorStore; 13 + use crate::auth::AuthenticatedUser; 11 14 12 - use super::*; 15 + use super::serve::*; 13 16 14 17 async fn put_preferences( 15 18 user: AuthenticatedUser, 16 - State(actor_pools): State<std::collections::HashMap<String, ActorPools>>, 19 + State(actor_pools): State<std::collections::HashMap<String, ActorStorage>>, 17 20 Json(input): Json<actor::put_preferences::Input>, 18 21 ) -> Result<()> { 19 22 let did = user.did(); 20 - let json_string = 21 - serde_json::to_string(&input.preferences).context("failed to serialize preferences")?; 23 + // let json_string = 24 + // serde_json::to_string(&input.preferences).context("failed to serialize preferences")?; 22 25 23 26 // let conn = &mut actor_pools 24 27 // .get(&did) ··· 35 38 // .context("failed to update user preferences") 36 39 // }); 37 40 todo!("Use actor_store's preferences writer instead"); 41 + // let mut actor_store = ActorStore::from_actor_pools(&did, &actor_pools).await; 42 + // let values = actor::defs::Preferences { 43 + // private_prefs: Some(json_string), 44 + // ..Default::default() 45 + // }; 46 + // let namespace = actor::defs::PreferencesNamespace::Private; 47 + // let scope = actor::defs::PreferencesScope::User; 48 + // actor_store.pref.put_preferences(values, namespace, scope); 49 + 38 50 Ok(()) 39 51 } 40 52 41 53 async fn get_preferences( 42 54 user: AuthenticatedUser, 43 - State(actor_pools): State<std::collections::HashMap<String, ActorPools>>, 55 + State(actor_pools): State<std::collections::HashMap<String, ActorStorage>>, 44 56 ) -> Result<Json<actor::get_preferences::Output>> { 45 57 let did = user.did(); 46 58 // let conn = &mut actor_pools
+56 -25
src/actor_store/blob.rs
··· 4 4 //! 5 5 //! Modified for SQLite backend 6 6 7 + use crate::models::actor_store as models; 7 8 use anyhow::{Result, bail}; 9 + use axum::body::Bytes; 8 10 use cidv10::Cid; 9 11 use diesel::dsl::{count_distinct, exists, not}; 10 12 use diesel::sql_types::{Integer, Nullable, Text}; ··· 19 21 use rsky_lexicon::com::atproto::admin::StatusAttr; 20 22 use rsky_lexicon::com::atproto::repo::ListMissingBlobsRefRecordBlob; 21 23 use rsky_pds::actor_store::blob::{ 22 - BlobMetadata, GetBlobMetadataOutput, ListBlobsOpts, ListMissingBlobsOpts, sha256_stream, 23 - verify_blob, 24 + BlobMetadata, GetBlobMetadataOutput, ListBlobsOpts, ListMissingBlobsOpts, accepted_mime, 25 + sha256_stream, 24 26 }; 25 27 use rsky_pds::image; 26 - use rsky_pds::models::models; 27 28 use rsky_repo::error::BlobError; 28 29 use rsky_repo::types::{PreparedBlobRef, PreparedWrite}; 29 30 use std::str::FromStr as _; 30 31 31 - use super::sql_blob::{BlobStoreSql, ByteStream}; 32 + use super::blob_fs::{BlobStoreFs, ByteStream}; 32 33 33 34 pub struct GetBlobOutput { 34 35 pub size: i32, ··· 39 40 /// Handles blob operations for an actor store 40 41 pub struct BlobReader { 41 42 /// SQL-based blob storage 42 - pub blobstore: BlobStoreSql, 43 + pub blobstore: BlobStoreFs, 43 44 /// DID of the actor 44 45 pub did: String, 45 46 /// Database connection ··· 52 53 impl BlobReader { 53 54 /// Create a new blob reader 54 55 pub fn new( 55 - blobstore: BlobStoreSql, 56 + blobstore: BlobStoreFs, 56 57 db: deadpool_diesel::Pool< 57 58 deadpool_diesel::Manager<SqliteConnection>, 58 59 deadpool_diesel::sqlite::Object, ··· 67 68 68 69 /// Get metadata for a blob by CID 69 70 pub async fn get_blob_metadata(&self, cid: Cid) -> Result<GetBlobMetadataOutput> { 70 - use crate::schema::pds::blob::dsl as BlobSchema; 71 + use crate::schema::actor_store::blob::dsl as BlobSchema; 71 72 72 73 let did = self.did.clone(); 73 74 let found = self ··· 112 113 113 114 /// Get all records that reference a specific blob 114 115 pub async fn get_records_for_blob(&self, cid: Cid) -> Result<Vec<String>> { 115 - use crate::schema::pds::record_blob::dsl as RecordBlobSchema; 116 + use crate::schema::actor_store::record_blob::dsl as RecordBlobSchema; 116 117 117 118 let did = self.did.clone(); 118 119 let res = self ··· 138 139 pub async fn upload_blob_and_get_metadata( 139 140 &self, 140 141 user_suggested_mime: String, 141 - blob: Vec<u8>, 142 + blob: Bytes, 142 143 ) -> Result<BlobMetadata> { 143 144 let bytes = blob; 144 145 let size = bytes.len() as i64; 145 146 146 147 let (temp_key, sha256, img_info, sniffed_mime) = try_join!( 147 148 self.blobstore.put_temp(bytes.clone()), 148 - sha256_stream(bytes.clone()), 149 - image::maybe_get_info(bytes.clone()), 150 - image::mime_type_from_bytes(bytes.clone()) 149 + // TODO: reimpl funcs to use Bytes instead of Vec<u8> 150 + sha256_stream(bytes.to_vec()), 151 + image::maybe_get_info(bytes.to_vec()), 152 + image::mime_type_from_bytes(bytes.to_vec()) 151 153 )?; 152 154 153 155 let cid = sha256_raw_to_cid(sha256); ··· 169 171 170 172 /// Track a blob that hasn't been associated with any records yet 171 173 pub async fn track_untethered_blob(&self, metadata: BlobMetadata) -> Result<BlobRef> { 172 - use crate::schema::pds::blob::dsl as BlobSchema; 174 + use crate::schema::actor_store::blob::dsl as BlobSchema; 173 175 174 176 let did = self.did.clone(); 175 177 self.db.get().await?.interact(move |conn| { ··· 254 256 255 257 /// Delete blobs that are no longer referenced by any records 256 258 pub async fn delete_dereferenced_blobs(&self, writes: Vec<PreparedWrite>) -> Result<()> { 257 - use crate::schema::pds::blob::dsl as BlobSchema; 258 - use crate::schema::pds::record_blob::dsl as RecordBlobSchema; 259 + use crate::schema::actor_store::blob::dsl as BlobSchema; 260 + use crate::schema::actor_store::record_blob::dsl as RecordBlobSchema; 259 261 260 262 // Extract URIs 261 263 let uris: Vec<String> = writes ··· 386 388 387 389 /// Verify a blob and make it permanent 388 390 pub async fn verify_blob_and_make_permanent(&self, blob: PreparedBlobRef) -> Result<()> { 389 - use crate::schema::pds::blob::dsl as BlobSchema; 391 + use crate::schema::actor_store::blob::dsl as BlobSchema; 390 392 391 393 let found = self 392 394 .db ··· 433 435 434 436 /// Associate a blob with a record 435 437 pub async fn associate_blob(&self, blob: PreparedBlobRef, record_uri: String) -> Result<()> { 436 - use crate::schema::pds::record_blob::dsl as RecordBlobSchema; 438 + use crate::schema::actor_store::record_blob::dsl as RecordBlobSchema; 437 439 438 440 let cid = blob.cid.to_string(); 439 441 let did = self.did.clone(); ··· 460 462 461 463 /// Count all blobs for this actor 462 464 pub async fn blob_count(&self) -> Result<i64> { 463 - use crate::schema::pds::blob::dsl as BlobSchema; 465 + use crate::schema::actor_store::blob::dsl as BlobSchema; 464 466 465 467 let did = self.did.clone(); 466 468 self.db ··· 479 481 480 482 /// Count blobs associated with records 481 483 pub async fn record_blob_count(&self) -> Result<i64> { 482 - use crate::schema::pds::record_blob::dsl as RecordBlobSchema; 484 + use crate::schema::actor_store::record_blob::dsl as RecordBlobSchema; 483 485 484 486 let did = self.did.clone(); 485 487 self.db ··· 501 503 &self, 502 504 opts: ListMissingBlobsOpts, 503 505 ) -> Result<Vec<ListMissingBlobsRefRecordBlob>> { 504 - use crate::schema::pds::blob::dsl as BlobSchema; 505 - use crate::schema::pds::record_blob::dsl as RecordBlobSchema; 506 + use crate::schema::actor_store::blob::dsl as BlobSchema; 507 + use crate::schema::actor_store::record_blob::dsl as RecordBlobSchema; 506 508 507 509 let did = self.did.clone(); 508 510 self.db ··· 563 565 564 566 /// List all blobs with optional filtering 565 567 pub async fn list_blobs(&self, opts: ListBlobsOpts) -> Result<Vec<String>> { 566 - use crate::schema::pds::record::dsl as RecordSchema; 567 - use crate::schema::pds::record_blob::dsl as RecordBlobSchema; 568 + use crate::schema::actor_store::record::dsl as RecordSchema; 569 + use crate::schema::actor_store::record_blob::dsl as RecordBlobSchema; 568 570 569 571 let ListBlobsOpts { 570 572 since, ··· 617 619 618 620 /// Get the takedown status of a blob 619 621 pub async fn get_blob_takedown_status(&self, cid: Cid) -> Result<Option<StatusAttr>> { 620 - use crate::schema::pds::blob::dsl as BlobSchema; 622 + use crate::schema::actor_store::blob::dsl as BlobSchema; 621 623 622 624 self.db 623 625 .get() ··· 653 655 654 656 /// Update the takedown status of a blob 655 657 pub async fn update_blob_takedown_status(&self, blob: Cid, takedown: StatusAttr) -> Result<()> { 656 - use crate::schema::pds::blob::dsl as BlobSchema; 658 + use crate::schema::actor_store::blob::dsl as BlobSchema; 657 659 658 660 let takedown_ref: Option<String> = match takedown.applied { 659 661 true => takedown.r#ref.map_or_else(|| Some(now()), Some), ··· 692 694 } 693 695 } 694 696 } 697 + 698 + pub async fn verify_blob(blob: &PreparedBlobRef, found: &models::Blob) -> Result<()> { 699 + if let Some(max_size) = blob.constraints.max_size { 700 + if found.size as usize > max_size { 701 + bail!( 702 + "BlobTooLarge: This file is too large. It is {:?} but the maximum size is {:?}", 703 + found.size, 704 + max_size 705 + ) 706 + } 707 + } 708 + if blob.mime_type != found.mime_type { 709 + bail!( 710 + "InvalidMimeType: Referenced MimeType does not match stored blob. Expected: {:?}, Got: {:?}", 711 + found.mime_type, 712 + blob.mime_type 713 + ) 714 + } 715 + if let Some(ref accept) = blob.constraints.accept { 716 + if !accepted_mime(blob.mime_type.clone(), accept.clone()).await { 717 + bail!( 718 + "Wrong type of file. It is {:?} but it must match {:?}.", 719 + blob.mime_type, 720 + accept 721 + ) 722 + } 723 + } 724 + Ok(()) 725 + }
+287
src/actor_store/blob_fs.rs
··· 1 + //! File system implementation of blob storage 2 + //! Based on the S3 implementation but using local file system instead 3 + use anyhow::Result; 4 + use axum::body::Bytes; 5 + use cidv10::Cid; 6 + use rsky_common::get_random_str; 7 + use rsky_repo::error::BlobError; 8 + use std::path::PathBuf; 9 + use std::str::FromStr; 10 + use tokio::fs as async_fs; 11 + use tokio::io::AsyncWriteExt; 12 + use tracing::{debug, error, warn}; 13 + 14 + /// ByteStream implementation for blob data 15 + pub struct ByteStream { 16 + pub bytes: Bytes, 17 + } 18 + 19 + impl ByteStream { 20 + /// Create a new ByteStream with the given bytes 21 + pub const fn new(bytes: Bytes) -> Self { 22 + Self { bytes } 23 + } 24 + 25 + /// Collect the bytes from the stream 26 + pub async fn collect(self) -> Result<Bytes> { 27 + Ok(self.bytes) 28 + } 29 + } 30 + 31 + /// Path information for moving a blob 32 + struct MoveObject { 33 + from: PathBuf, 34 + to: PathBuf, 35 + } 36 + 37 + /// File system implementation of blob storage 38 + pub struct BlobStoreFs { 39 + /// Base directory for storing blobs 40 + pub base_dir: PathBuf, 41 + /// DID of the actor 42 + pub did: String, 43 + } 44 + 45 + impl BlobStoreFs { 46 + /// Create a new file system blob store for the given DID and base directory 47 + pub const fn new(did: String, base_dir: PathBuf) -> Self { 48 + Self { base_dir, did } 49 + } 50 + 51 + /// Create a factory function for blob stores 52 + pub fn creator(base_dir: PathBuf) -> Box<dyn Fn(String) -> Self> { 53 + let base_dir_clone = base_dir; 54 + Box::new(move |did: String| Self::new(did, base_dir_clone.clone())) 55 + } 56 + 57 + /// Generate a random key for temporary storage 58 + fn gen_key(&self) -> String { 59 + get_random_str() 60 + } 61 + 62 + /// Get path to the temporary blob storage 63 + fn get_tmp_path(&self, key: &str) -> PathBuf { 64 + self.base_dir.join("tmp").join(&self.did).join(key) 65 + } 66 + 67 + /// Get path to the stored blob with appropriate sharding 68 + fn get_stored_path(&self, cid: Cid) -> PathBuf { 69 + let cid_str = cid.to_string(); 70 + 71 + // Create two-level sharded structure based on CID 72 + // First 10 chars for level 1, next 10 chars for level 2 73 + let first_level = if cid_str.len() >= 10 { 74 + &cid_str[0..10] 75 + } else { 76 + "short" 77 + }; 78 + 79 + let second_level = if cid_str.len() >= 20 { 80 + &cid_str[10..20] 81 + } else { 82 + "short" 83 + }; 84 + 85 + self.base_dir 86 + .join("blocks") 87 + .join(&self.did) 88 + .join(first_level) 89 + .join(second_level) 90 + .join(&cid_str) 91 + } 92 + 93 + /// Get path to the quarantined blob 94 + fn get_quarantined_path(&self, cid: Cid) -> PathBuf { 95 + let cid_str = cid.to_string(); 96 + self.base_dir 97 + .join("quarantine") 98 + .join(&self.did) 99 + .join(&cid_str) 100 + } 101 + 102 + /// Store a blob temporarily 103 + pub async fn put_temp(&self, bytes: Bytes) -> Result<String> { 104 + let key = self.gen_key(); 105 + let temp_path = self.get_tmp_path(&key); 106 + 107 + // Ensure the directory exists 108 + if let Some(parent) = temp_path.parent() { 109 + async_fs::create_dir_all(parent).await?; 110 + } 111 + 112 + // Write the temporary blob 113 + let mut file = async_fs::File::create(&temp_path).await?; 114 + file.write_all(&bytes).await?; 115 + file.flush().await?; 116 + 117 + debug!("Stored temp blob at: {:?}", temp_path); 118 + Ok(key) 119 + } 120 + 121 + /// Make a temporary blob permanent by moving it to the blob store 122 + pub async fn make_permanent(&self, key: String, cid: Cid) -> Result<()> { 123 + let already_has = self.has_stored(cid).await?; 124 + 125 + if !already_has { 126 + // Move the temporary blob to permanent storage 127 + self.move_object(MoveObject { 128 + from: self.get_tmp_path(&key), 129 + to: self.get_stored_path(cid), 130 + }) 131 + .await?; 132 + debug!("Moved temp blob to permanent: {} -> {}", key, cid); 133 + } else { 134 + // Already saved, so just delete the temp 135 + let temp_path = self.get_tmp_path(&key); 136 + if temp_path.exists() { 137 + async_fs::remove_file(temp_path).await?; 138 + debug!("Deleted temp blob as permanent already exists: {}", key); 139 + } 140 + } 141 + 142 + Ok(()) 143 + } 144 + 145 + /// Store a blob directly as permanent 146 + pub async fn put_permanent(&self, cid: Cid, bytes: Bytes) -> Result<()> { 147 + let target_path = self.get_stored_path(cid); 148 + 149 + // Ensure the directory exists 150 + if let Some(parent) = target_path.parent() { 151 + async_fs::create_dir_all(parent).await?; 152 + } 153 + 154 + // Write the blob 155 + let mut file = async_fs::File::create(&target_path).await?; 156 + file.write_all(&bytes).await?; 157 + file.flush().await?; 158 + 159 + debug!("Stored permanent blob: {}", cid); 160 + Ok(()) 161 + } 162 + 163 + /// Quarantine a blob by moving it to the quarantine area 164 + pub async fn quarantine(&self, cid: Cid) -> Result<()> { 165 + self.move_object(MoveObject { 166 + from: self.get_stored_path(cid), 167 + to: self.get_quarantined_path(cid), 168 + }) 169 + .await?; 170 + 171 + debug!("Quarantined blob: {}", cid); 172 + Ok(()) 173 + } 174 + 175 + /// Unquarantine a blob by moving it back to regular storage 176 + pub async fn unquarantine(&self, cid: Cid) -> Result<()> { 177 + self.move_object(MoveObject { 178 + from: self.get_quarantined_path(cid), 179 + to: self.get_stored_path(cid), 180 + }) 181 + .await?; 182 + 183 + debug!("Unquarantined blob: {}", cid); 184 + Ok(()) 185 + } 186 + 187 + /// Get a blob as a stream 188 + async fn get_object(&self, cid: Cid) -> Result<ByteStream> { 189 + let blob_path = self.get_stored_path(cid); 190 + 191 + match async_fs::read(&blob_path).await { 192 + Ok(bytes) => Ok(ByteStream::new(Bytes::from(bytes))), 193 + Err(e) => { 194 + error!("Failed to read blob at path {:?}: {}", blob_path, e); 195 + Err(anyhow::Error::new(BlobError::BlobNotFoundError)) 196 + } 197 + } 198 + } 199 + 200 + /// Get blob bytes 201 + pub async fn get_bytes(&self, cid: Cid) -> Result<Bytes> { 202 + let stream = self.get_object(cid).await?; 203 + stream.collect().await 204 + } 205 + 206 + /// Get a blob as a stream 207 + pub async fn get_stream(&self, cid: Cid) -> Result<ByteStream> { 208 + self.get_object(cid).await 209 + } 210 + 211 + /// Delete a blob by CID string 212 + pub async fn delete(&self, cid_str: String) -> Result<()> { 213 + match Cid::from_str(&cid_str) { 214 + Ok(cid) => self.delete_path(self.get_stored_path(cid)).await, 215 + Err(e) => { 216 + warn!("Invalid CID: {} - {}", cid_str, e); 217 + Err(anyhow::anyhow!("Invalid CID: {}", e)) 218 + } 219 + } 220 + } 221 + 222 + /// Delete multiple blobs by CID 223 + pub async fn delete_many(&self, cids: Vec<Cid>) -> Result<()> { 224 + let mut futures = Vec::with_capacity(cids.len()); 225 + 226 + for cid in cids { 227 + futures.push(self.delete_path(self.get_stored_path(cid))); 228 + } 229 + 230 + // Execute all delete operations concurrently 231 + let results = futures::future::join_all(futures).await; 232 + 233 + // Count errors but don't fail the operation 234 + let error_count = results.iter().filter(|r| r.is_err()).count(); 235 + if error_count > 0 { 236 + warn!( 237 + "{} errors occurred while deleting {} blobs", 238 + error_count, 239 + results.len() 240 + ); 241 + } 242 + 243 + Ok(()) 244 + } 245 + 246 + /// Check if a blob is stored in the regular storage 247 + pub async fn has_stored(&self, cid: Cid) -> Result<bool> { 248 + let blob_path = self.get_stored_path(cid); 249 + Ok(blob_path.exists()) 250 + } 251 + 252 + /// Check if a temporary blob exists 253 + pub async fn has_temp(&self, key: String) -> Result<bool> { 254 + let temp_path = self.get_tmp_path(&key); 255 + Ok(temp_path.exists()) 256 + } 257 + 258 + /// Helper function to delete a file at the given path 259 + async fn delete_path(&self, path: PathBuf) -> Result<()> { 260 + if path.exists() { 261 + async_fs::remove_file(&path).await?; 262 + debug!("Deleted file at: {:?}", path); 263 + Ok(()) 264 + } else { 265 + Err(anyhow::Error::new(BlobError::BlobNotFoundError)) 266 + } 267 + } 268 + 269 + /// Move a blob from one path to another 270 + async fn move_object(&self, mov: MoveObject) -> Result<()> { 271 + // Ensure the source exists 272 + if !mov.from.exists() { 273 + return Err(anyhow::Error::new(BlobError::BlobNotFoundError)); 274 + } 275 + 276 + // Ensure the target directory exists 277 + if let Some(parent) = mov.to.parent() { 278 + async_fs::create_dir_all(parent).await?; 279 + } 280 + 281 + // Move the file 282 + async_fs::rename(&mov.from, &mov.to).await?; 283 + 284 + debug!("Moved blob: {:?} -> {:?}", mov.from, mov.to); 285 + Ok(()) 286 + } 287 + }
+27 -5
src/actor_store/mod.rs
··· 7 7 //! Modified for SQLite backend 8 8 9 9 mod blob; 10 + pub(crate) mod blob_fs; 10 11 mod preference; 11 12 mod record; 12 13 pub(crate) mod sql_blob; ··· 33 34 use tokio::sync::RwLock; 34 35 35 36 use blob::BlobReader; 37 + use blob_fs::BlobStoreFs; 36 38 use preference::PreferenceReader; 37 39 use record::RecordReader; 38 - use sql_blob::BlobStoreSql; 39 40 use sql_repo::SqlRepoReader; 41 + 42 + use crate::serve::ActorStorage; 40 43 41 44 #[derive(Debug)] 42 45 enum FormatCommitError { ··· 71 74 72 75 // Combination of RepoReader/Transactor, BlobReader/Transactor, SqlRepoReader/Transactor 73 76 impl ActorStore { 74 - /// Concrete reader of an individual repo (hence BlobStoreSql which takes `did` param) 77 + /// Concrete reader of an individual repo (hence BlobStoreFs which takes `did` param) 75 78 pub fn new( 76 79 did: String, 77 - blobstore: BlobStoreSql, 80 + blobstore: BlobStoreFs, 78 81 db: deadpool_diesel::Pool< 79 82 deadpool_diesel::Manager<SqliteConnection>, 80 83 deadpool_diesel::sqlite::Object, ··· 88 91 did, 89 92 blob: BlobReader::new(blobstore, db), 90 93 } 94 + } 95 + 96 + /// Create a new ActorStore taking ActorPools HashMap as input 97 + pub async fn from_actor_pools( 98 + did: &String, 99 + hashmap_actor_pools: &std::collections::HashMap<String, ActorStorage>, 100 + ) -> Self { 101 + let actor_pool = hashmap_actor_pools 102 + .get(did) 103 + .expect("Actor pool not found") 104 + .clone(); 105 + let blobstore = BlobStoreFs::new(did.clone(), actor_pool.blob); 106 + let conn = actor_pool 107 + .repo 108 + .clone() 109 + .get() 110 + .await 111 + .expect("Failed to get connection"); 112 + Self::new(did.clone(), blobstore, actor_pool.repo, conn) 91 113 } 92 114 93 115 pub async fn get_repo_root(&self) -> Option<Cid> { ··· 460 482 461 483 pub async fn destroy(&mut self) -> Result<()> { 462 484 let did: String = self.did.clone(); 463 - use crate::schema::pds::blob::dsl as BlobSchema; 485 + use crate::schema::actor_store::blob::dsl as BlobSchema; 464 486 465 487 let blob_rows: Vec<String> = self 466 488 .storage ··· 499 521 return Ok(vec![]); 500 522 } 501 523 let did: String = self.did.clone(); 502 - use crate::schema::pds::record::dsl as RecordSchema; 524 + use crate::schema::actor_store::record::dsl as RecordSchema; 503 525 504 526 let cid_strs: Vec<String> = cids.into_iter().map(|c| c.to_string()).collect(); 505 527 let touched_uri_strs: Vec<String> = touched_uris.iter().map(|t| t.to_string()).collect();
+3 -3
src/actor_store/preference.rs
··· 4 4 //! 5 5 //! Modified for SQLite backend 6 6 7 + use crate::models::actor_store::AccountPref; 7 8 use anyhow::{Result, bail}; 8 9 use diesel::*; 9 10 use rsky_lexicon::app::bsky::actor::RefPreferences; 10 11 use rsky_pds::actor_store::preference::pref_match_namespace; 11 12 use rsky_pds::actor_store::preference::util::pref_in_scope; 12 13 use rsky_pds::auth_verifier::AuthScope; 13 - use rsky_pds::models::AccountPref; 14 14 15 15 pub struct PreferenceReader { 16 16 pub did: String, ··· 36 36 namespace: Option<String>, 37 37 scope: AuthScope, 38 38 ) -> Result<Vec<RefPreferences>> { 39 - use crate::schema::pds::account_pref::dsl as AccountPrefSchema; 39 + use crate::schema::actor_store::account_pref::dsl as AccountPrefSchema; 40 40 41 41 let did = self.did.clone(); 42 42 self.db ··· 99 99 bail!("Do not have authorization to set preferences."); 100 100 } 101 101 // get all current prefs for user and prep new pref rows 102 - use crate::schema::pds::account_pref::dsl as AccountPrefSchema; 102 + use crate::schema::actor_store::account_pref::dsl as AccountPrefSchema; 103 103 let all_prefs = AccountPrefSchema::account_pref 104 104 .filter(AccountPrefSchema::did.eq(&did)) 105 105 .select(AccountPref::as_select())
+67 -20
src/actor_store/record.rs
··· 4 4 //! 5 5 //! Modified for SQLite backend 6 6 7 + use crate::models::actor_store::{Backlink, Record, RepoBlock}; 7 8 use anyhow::{Result, bail}; 8 9 use cidv10::Cid; 9 10 use diesel::result::Error; 10 11 use diesel::*; 11 12 use futures::stream::{self, StreamExt}; 12 13 use rsky_lexicon::com::atproto::admin::StatusAttr; 13 - use rsky_pds::actor_store::record::{GetRecord, RecordsForCollection, get_backlinks}; 14 - use rsky_pds::models::{Backlink, Record, RepoBlock}; 15 - use rsky_repo::types::{RepoRecord, WriteOpAction}; 14 + use rsky_pds::actor_store::record::{GetRecord, RecordsForCollection}; 15 + use rsky_repo::storage::Ipld; 16 + use rsky_repo::types::{Ids, Lex, RepoRecord, WriteOpAction}; 16 17 use rsky_repo::util::cbor_to_lex_record; 17 18 use rsky_syntax::aturi::AtUri; 19 + use rsky_syntax::aturi_validation::ensure_valid_at_uri; 20 + use rsky_syntax::did::ensure_valid_did; 21 + use serde_json::Value as JsonValue; 18 22 use std::env; 19 23 use std::str::FromStr; 20 24 25 + // @NOTE in the future this can be replaced with a more generic routine that pulls backlinks based on lex docs. 26 + // For now, we just want to ensure we're tracking links from follows, blocks, likes, and reposts. 27 + pub fn get_backlinks(uri: &AtUri, record: &RepoRecord) -> Result<Vec<Backlink>> { 28 + if let Some(Lex::Ipld(Ipld::Json(JsonValue::String(record_type)))) = record.get("$type") { 29 + if record_type == Ids::AppBskyGraphFollow.as_str() 30 + || record_type == Ids::AppBskyGraphBlock.as_str() 31 + { 32 + if let Some(Lex::Ipld(Ipld::Json(JsonValue::String(subject)))) = record.get("subject") { 33 + match ensure_valid_did(uri) { 34 + Ok(_) => { 35 + return Ok(vec![Backlink { 36 + uri: uri.to_string(), 37 + path: "subject".to_owned(), 38 + link_to: subject.clone(), 39 + }]); 40 + } 41 + Err(e) => bail!("get_backlinks Error: invalid did {}", e), 42 + }; 43 + } 44 + } else if record_type == Ids::AppBskyFeedLike.as_str() 45 + || record_type == Ids::AppBskyFeedRepost.as_str() 46 + { 47 + if let Some(Lex::Map(ref_object)) = record.get("subject") { 48 + if let Some(Lex::Ipld(Ipld::Json(JsonValue::String(subject_uri)))) = 49 + ref_object.get("uri") 50 + { 51 + match ensure_valid_at_uri(uri) { 52 + Ok(_) => { 53 + return Ok(vec![Backlink { 54 + uri: uri.to_string(), 55 + path: "subject.uri".to_owned(), 56 + link_to: subject_uri.clone(), 57 + }]); 58 + } 59 + Err(e) => bail!("get_backlinks Error: invalid AtUri {}", e), 60 + }; 61 + } 62 + } 63 + } 64 + } 65 + Ok(Vec::new()) 66 + } 67 + 21 68 /// Combined handler for record operations with both read and write capabilities. 22 69 pub(crate) struct RecordReader { 23 70 /// Database connection. ··· 43 90 44 91 /// Count the total number of records. 45 92 pub(crate) async fn record_count(&mut self) -> Result<i64> { 46 - use crate::schema::pds::record::dsl::*; 93 + use crate::schema::actor_store::record::dsl::*; 47 94 48 95 let other_did = self.did.clone(); 49 96 self.db ··· 59 106 60 107 /// List all collections in the repository. 61 108 pub(crate) async fn list_collections(&self) -> Result<Vec<String>> { 62 - use crate::schema::pds::record::dsl::*; 109 + use crate::schema::actor_store::record::dsl::*; 63 110 64 111 let other_did = self.did.clone(); 65 112 self.db ··· 90 137 rkey_end: Option<String>, 91 138 include_soft_deleted: Option<bool>, 92 139 ) -> Result<Vec<RecordsForCollection>> { 93 - use crate::schema::pds::record::dsl as RecordSchema; 94 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 140 + use crate::schema::actor_store::record::dsl as RecordSchema; 141 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 95 142 96 143 let include_soft_deleted: bool = include_soft_deleted.unwrap_or(false); 97 144 let mut builder = RecordSchema::record ··· 149 196 cid: Option<String>, 150 197 include_soft_deleted: Option<bool>, 151 198 ) -> Result<Option<GetRecord>> { 152 - use crate::schema::pds::record::dsl as RecordSchema; 153 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 199 + use crate::schema::actor_store::record::dsl as RecordSchema; 200 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 154 201 155 202 let include_soft_deleted: bool = include_soft_deleted.unwrap_or(false); 156 203 let mut builder = RecordSchema::record ··· 191 238 cid: Option<String>, 192 239 include_soft_deleted: Option<bool>, 193 240 ) -> Result<bool> { 194 - use crate::schema::pds::record::dsl as RecordSchema; 241 + use crate::schema::actor_store::record::dsl as RecordSchema; 195 242 196 243 let include_soft_deleted: bool = include_soft_deleted.unwrap_or(false); 197 244 let mut builder = RecordSchema::record ··· 219 266 &self, 220 267 uri: String, 221 268 ) -> Result<Option<StatusAttr>> { 222 - use crate::schema::pds::record::dsl as RecordSchema; 269 + use crate::schema::actor_store::record::dsl as RecordSchema; 223 270 224 271 let res = self 225 272 .db ··· 257 304 258 305 /// Get the current CID for a record URI. 259 306 pub(crate) async fn get_current_record_cid(&self, uri: String) -> Result<Option<Cid>> { 260 - use crate::schema::pds::record::dsl as RecordSchema; 307 + use crate::schema::actor_store::record::dsl as RecordSchema; 261 308 262 309 let res = self 263 310 .db ··· 286 333 path: String, 287 334 link_to: String, 288 335 ) -> Result<Vec<Record>> { 289 - use crate::schema::pds::backlink::dsl as BacklinkSchema; 290 - use crate::schema::pds::record::dsl as RecordSchema; 336 + use crate::schema::actor_store::backlink::dsl as BacklinkSchema; 337 + use crate::schema::actor_store::record::dsl as RecordSchema; 291 338 292 339 let res = self 293 340 .db ··· 385 432 bail!("Expected indexed URI to contain a record key") 386 433 } 387 434 388 - use crate::schema::pds::record::dsl as RecordSchema; 435 + use crate::schema::actor_store::record::dsl as RecordSchema; 389 436 390 437 // Track current version of record 391 438 let (record, uri) = self ··· 426 473 #[tracing::instrument(skip_all)] 427 474 pub(crate) async fn delete_record(&self, uri: &AtUri) -> Result<()> { 428 475 tracing::debug!("@LOG DEBUG RecordReader::delete_record, deleting indexed record {uri}"); 429 - use crate::schema::pds::backlink::dsl as BacklinkSchema; 430 - use crate::schema::pds::record::dsl as RecordSchema; 476 + use crate::schema::actor_store::backlink::dsl as BacklinkSchema; 477 + use crate::schema::actor_store::record::dsl as RecordSchema; 431 478 let uri = uri.to_string(); 432 479 self.db 433 480 .get() ··· 450 497 451 498 /// Remove backlinks for a URI. 452 499 pub(crate) async fn remove_backlinks_by_uri(&self, uri: &AtUri) -> Result<()> { 453 - use crate::schema::pds::backlink::dsl as BacklinkSchema; 500 + use crate::schema::actor_store::backlink::dsl as BacklinkSchema; 454 501 let uri = uri.to_string(); 455 502 self.db 456 503 .get() ··· 470 517 if backlinks.is_empty() { 471 518 Ok(()) 472 519 } else { 473 - use crate::schema::pds::backlink::dsl as BacklinkSchema; 520 + use crate::schema::actor_store::backlink::dsl as BacklinkSchema; 474 521 self.db 475 522 .get() 476 523 .await? ··· 491 538 uri: &AtUri, 492 539 takedown: StatusAttr, 493 540 ) -> Result<()> { 494 - use crate::schema::pds::record::dsl as RecordSchema; 541 + use crate::schema::actor_store::record::dsl as RecordSchema; 495 542 496 543 let takedown_ref: Option<String> = match takedown.applied { 497 544 true => takedown
+13 -12
src/actor_store/sql_blob.rs
··· 72 72 } 73 73 74 74 // /// Create a factory function for blob stores 75 - // pub fn creator( 76 - // db: deadpool_diesel::Pool< 77 - // deadpool_diesel::Manager<SqliteConnection>, 78 - // deadpool_diesel::sqlite::Object, 79 - // >, 80 - // ) -> Box<dyn Fn(String) -> BlobStoreSql> { 81 - // let db_clone = db.clone(); 82 - // Box::new(move |did: String| BlobStoreSql::new(did, db_clone.clone())) 83 - // } 75 + pub fn creator( 76 + db: deadpool_diesel::Pool< 77 + deadpool_diesel::Manager<SqliteConnection>, 78 + deadpool_diesel::sqlite::Object, 79 + >, 80 + ) -> Box<dyn Fn(String) -> BlobStoreSql> { 81 + let db_clone = db.clone(); 82 + Box::new(move |did: String| BlobStoreSql::new(did, db_clone.clone())) 83 + } 84 84 85 85 /// Store a blob temporarily - now just stores permanently with a key returned for API compatibility 86 86 pub async fn put_temp(&self, bytes: Vec<u8>) -> Result<String> { 87 87 // Generate a unique key as a CID based on the data 88 - use sha2::{Digest, Sha256}; 89 - let digest = Sha256::digest(&bytes); 90 - let key = hex::encode(digest); 88 + // use sha2::{Digest, Sha256}; 89 + // let digest = Sha256::digest(&bytes); 90 + // let key = hex::encode(digest); 91 + let key = rsky_common::get_random_str(); 91 92 92 93 // Just store the blob directly 93 94 self.put_permanent_with_mime(
+12 -12
src/actor_store/sql_repo.rs
··· 3 3 //! 4 4 //! Modified for SQLite backend 5 5 6 + use crate::models::actor_store as models; 7 + use crate::models::actor_store::RepoBlock; 6 8 use anyhow::Result; 7 9 use cidv10::Cid; 8 10 use diesel::dsl::sql; ··· 10 12 use diesel::sql_types::{Bool, Text}; 11 13 use diesel::*; 12 14 use futures::{StreamExt, TryStreamExt, stream}; 13 - use rsky_pds::models; 14 - use rsky_pds::models::RepoBlock; 15 15 use rsky_repo::block_map::{BlockMap, BlocksAndMissing}; 16 16 use rsky_repo::car::blocks_to_car_file; 17 17 use rsky_repo::cid_set::CidSet; ··· 53 53 let cid = *cid; 54 54 55 55 Box::pin(async move { 56 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 56 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 57 57 let cached = { 58 58 let cache_guard = self.cache.read().await; 59 59 cache_guard.get(cid).cloned() ··· 104 104 let did: String = self.did.clone(); 105 105 106 106 Box::pin(async move { 107 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 107 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 108 108 let cached = { 109 109 let mut cache_guard = self.cache.write().await; 110 110 cache_guard.get_many(cids)? ··· 202 202 let did: String = self.did.clone(); 203 203 let bytes_cloned = bytes.clone(); 204 204 Box::pin(async move { 205 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 205 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 206 206 207 207 _ = self 208 208 .db ··· 235 235 let did: String = self.did.clone(); 236 236 237 237 Box::pin(async move { 238 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 238 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 239 239 240 240 let blocks: Vec<RepoBlock> = to_put 241 241 .map ··· 277 277 let now: String = self.now.clone(); 278 278 279 279 Box::pin(async move { 280 - use crate::schema::pds::repo_root::dsl as RepoRootSchema; 280 + use crate::schema::actor_store::repo_root::dsl as RepoRootSchema; 281 281 282 282 let is_create = is_create.unwrap_or(false); 283 283 if is_create { ··· 381 381 let did: String = self.did.clone(); 382 382 let since = since.clone(); 383 383 let cursor = cursor.clone(); 384 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 384 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 385 385 386 386 Ok(self 387 387 .db ··· 418 418 419 419 pub async fn count_blocks(&self) -> Result<i64> { 420 420 let did: String = self.did.clone(); 421 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 421 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 422 422 423 423 let res = self 424 424 .db ··· 439 439 /// Proactively cache all blocks from a particular commit (to prevent multiple roundtrips) 440 440 pub async fn cache_rev(&mut self, rev: String) -> Result<()> { 441 441 let did: String = self.did.clone(); 442 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 442 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 443 443 444 444 let result: Vec<(String, Vec<u8>)> = self 445 445 .db ··· 465 465 return Ok(()); 466 466 } 467 467 let did: String = self.did.clone(); 468 - use crate::schema::pds::repo_block::dsl as RepoBlockSchema; 468 + use crate::schema::actor_store::repo_block::dsl as RepoBlockSchema; 469 469 470 470 let cid_strings: Vec<String> = cids.into_iter().map(|c| c.to_string()).collect(); 471 471 _ = self ··· 483 483 484 484 pub async fn get_root_detailed(&self) -> Result<CidAndRev> { 485 485 let did: String = self.did.clone(); 486 - use crate::schema::pds::repo_root::dsl as RepoRootSchema; 486 + use crate::schema::actor_store::repo_root::dsl as RepoRootSchema; 487 487 488 488 let res = self 489 489 .db
+245
src/apis/com/atproto/identity/identity.rs
··· 1 + //! Identity endpoints (/xrpc/com.atproto.identity.*) 2 + use std::collections::HashMap; 3 + 4 + use anyhow::{Context as _, anyhow}; 5 + use atrium_api::{ 6 + com::atproto::identity, 7 + types::string::{Datetime, Handle}, 8 + }; 9 + use atrium_crypto::keypair::Did as _; 10 + use atrium_repo::blockstore::{AsyncBlockStoreWrite as _, CarStore, DAG_CBOR, SHA2_256}; 11 + use axum::{ 12 + Json, Router, 13 + extract::{Query, State}, 14 + http::StatusCode, 15 + routing::{get, post}, 16 + }; 17 + use constcat::concat; 18 + 19 + use crate::{ 20 + AppState, Client, Db, Error, Result, RotationKey, SigningKey, 21 + auth::AuthenticatedUser, 22 + config::AppConfig, 23 + did, 24 + firehose::FirehoseProducer, 25 + plc::{self, PlcOperation, PlcService}, 26 + }; 27 + 28 + /// (GET) Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document. 29 + /// ### Query Parameters 30 + /// - handle: The handle to resolve. 31 + /// ### Responses 32 + /// - 200 OK: {did: did} 33 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `HandleNotFound`]} 34 + /// - 401 Unauthorized 35 + async fn resolve_handle( 36 + State(db): State<Db>, 37 + State(client): State<Client>, 38 + Query(input): Query<identity::resolve_handle::ParametersData>, 39 + ) -> Result<Json<identity::resolve_handle::Output>> { 40 + let handle = input.handle.as_str(); 41 + if let Ok(did) = sqlx::query_scalar!(r#"SELECT did FROM handles WHERE handle = ?"#, handle) 42 + .fetch_one(&db) 43 + .await 44 + { 45 + return Ok(Json( 46 + identity::resolve_handle::OutputData { 47 + did: atrium_api::types::string::Did::new(did).expect("should be valid DID format"), 48 + } 49 + .into(), 50 + )); 51 + } 52 + 53 + // HACK: Query bsky to see if they have this handle cached. 54 + let response = client 55 + .get(format!( 56 + "https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={handle}" 57 + )) 58 + .send() 59 + .await 60 + .context("failed to query upstream server")? 61 + .json() 62 + .await 63 + .context("failed to decode response as JSON")?; 64 + 65 + Ok(Json(response)) 66 + } 67 + 68 + #[expect(unused_variables, clippy::todo, reason = "Not yet implemented")] 69 + /// Request an email with a code to in order to request a signed PLC operation. Requires Auth. 70 + /// - POST /xrpc/com.atproto.identity.requestPlcOperationSignature 71 + /// ### Responses 72 + /// - 200 OK 73 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 74 + /// - 401 Unauthorized 75 + async fn request_plc_operation_signature(user: AuthenticatedUser) -> Result<()> { 76 + todo!() 77 + } 78 + 79 + #[expect(unused_variables, clippy::todo, reason = "Not yet implemented")] 80 + /// Signs a PLC operation to update some value(s) in the requesting DID's document. 81 + /// - POST /xrpc/com.atproto.identity.signPlcOperation 82 + /// ### Request Body 83 + /// - token: string // A token received through com.atproto.identity.requestPlcOperationSignature 84 + /// - rotationKeys: string[] 85 + /// - alsoKnownAs: string[] 86 + /// - verificationMethods: services 87 + /// ### Responses 88 + /// - 200 OK: {operation: string} 89 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 90 + /// - 401 Unauthorized 91 + async fn sign_plc_operation( 92 + user: AuthenticatedUser, 93 + State(skey): State<SigningKey>, 94 + State(rkey): State<RotationKey>, 95 + State(config): State<AppConfig>, 96 + Json(input): Json<identity::sign_plc_operation::Input>, 97 + ) -> Result<Json<identity::sign_plc_operation::Output>> { 98 + todo!() 99 + } 100 + 101 + #[expect( 102 + clippy::too_many_arguments, 103 + reason = "Many parameters are required for this endpoint" 104 + )] 105 + /// Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth. 106 + /// - POST /xrpc/com.atproto.identity.updateHandle 107 + /// ### Query Parameters 108 + /// - handle: handle // The new handle. 109 + /// ### Responses 110 + /// - 200 OK 111 + /// ## Errors 112 + /// - If the handle is already in use. 113 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 114 + /// - 401 Unauthorized 115 + /// ## Panics 116 + /// - If the handle is not valid. 117 + async fn update_handle( 118 + user: AuthenticatedUser, 119 + State(skey): State<SigningKey>, 120 + State(rkey): State<RotationKey>, 121 + State(client): State<Client>, 122 + State(config): State<AppConfig>, 123 + State(db): State<Db>, 124 + State(fhp): State<FirehoseProducer>, 125 + Json(input): Json<identity::update_handle::Input>, 126 + ) -> Result<()> { 127 + let handle = input.handle.as_str(); 128 + let did_str = user.did(); 129 + let did = atrium_api::types::string::Did::new(user.did()).expect("should be valid DID format"); 130 + 131 + if let Some(existing_did) = 132 + sqlx::query_scalar!(r#"SELECT did FROM handles WHERE handle = ?"#, handle) 133 + .fetch_optional(&db) 134 + .await 135 + .context("failed to query did count")? 136 + { 137 + if existing_did != did_str { 138 + return Err(Error::with_status( 139 + StatusCode::BAD_REQUEST, 140 + anyhow!("attempted to update handle to one that is already in use"), 141 + )); 142 + } 143 + } 144 + 145 + // Ensure the existing DID is resolvable. 146 + // If not, we need to register the original handle. 147 + let _did = did::resolve(&client, did.clone()) 148 + .await 149 + .with_context(|| format!("failed to resolve DID for {did_str}")) 150 + .context("should be able to resolve DID")?; 151 + 152 + let op = plc::sign_op( 153 + &rkey, 154 + PlcOperation { 155 + typ: "plc_operation".to_owned(), 156 + rotation_keys: vec![rkey.did()], 157 + verification_methods: HashMap::from([("atproto".to_owned(), skey.did())]), 158 + also_known_as: vec![input.handle.as_str().to_owned()], 159 + services: HashMap::from([( 160 + "atproto_pds".to_owned(), 161 + PlcService::Pds { 162 + endpoint: config.host_name.clone(), 163 + }, 164 + )]), 165 + prev: Some( 166 + sqlx::query_scalar!(r#"SELECT plc_root FROM accounts WHERE did = ?"#, did_str) 167 + .fetch_one(&db) 168 + .await 169 + .context("failed to fetch user PLC root")?, 170 + ), 171 + }, 172 + ) 173 + .context("failed to sign plc op")?; 174 + 175 + if !config.test { 176 + plc::submit(&client, did.as_str(), &op) 177 + .await 178 + .context("failed to submit PLC operation")?; 179 + } 180 + 181 + // FIXME: Properly abstract these implementation details. 182 + let did_hash = did_str 183 + .strip_prefix("did:plc:") 184 + .context("should be valid DID format")?; 185 + let doc = tokio::fs::File::options() 186 + .read(true) 187 + .write(true) 188 + .open(config.plc.path.join(format!("{did_hash}.car"))) 189 + .await 190 + .context("failed to open did doc")?; 191 + 192 + let op_bytes = serde_ipld_dagcbor::to_vec(&op).context("failed to encode plc op")?; 193 + 194 + let plc_cid = CarStore::open(doc) 195 + .await 196 + .context("failed to open did carstore")? 197 + .write_block(DAG_CBOR, SHA2_256, &op_bytes) 198 + .await 199 + .context("failed to write genesis commit")?; 200 + 201 + let cid_str = plc_cid.to_string(); 202 + 203 + _ = sqlx::query!( 204 + r#"UPDATE accounts SET plc_root = ? WHERE did = ?"#, 205 + cid_str, 206 + did_str 207 + ) 208 + .execute(&db) 209 + .await 210 + .context("failed to update account PLC root")?; 211 + 212 + // Broadcast the identity event now that the new identity is resolvable on the public directory. 213 + fhp.identity( 214 + atrium_api::com::atproto::sync::subscribe_repos::IdentityData { 215 + did: did.clone(), 216 + handle: Some(Handle::new(handle.to_owned()).expect("should be valid handle")), 217 + seq: 0, // Filled by firehose later. 218 + time: Datetime::now(), 219 + }, 220 + ) 221 + .await; 222 + 223 + Ok(()) 224 + } 225 + 226 + async fn todo() -> Result<()> { 227 + Err(Error::unimplemented(anyhow!("not implemented"))) 228 + } 229 + 230 + #[rustfmt::skip] 231 + /// Identity endpoints (/xrpc/com.atproto.identity.*) 232 + /// ### Routes 233 + /// - AP /xrpc/com.atproto.identity.updateHandle -> [`update_handle`] 234 + /// - AP /xrpc/com.atproto.identity.requestPlcOperationSignature -> [`request_plc_operation_signature`] 235 + /// - AP /xrpc/com.atproto.identity.signPlcOperation -> [`sign_plc_operation`] 236 + /// - UG /xrpc/com.atproto.identity.resolveHandle -> [`resolve_handle`] 237 + pub(super) fn routes() -> Router<AppState> { 238 + Router::new() 239 + .route(concat!("/", identity::get_recommended_did_credentials::NSID), get(todo)) 240 + .route(concat!("/", identity::request_plc_operation_signature::NSID), post(request_plc_operation_signature)) 241 + .route(concat!("/", identity::resolve_handle::NSID), get(resolve_handle)) 242 + .route(concat!("/", identity::sign_plc_operation::NSID), post(sign_plc_operation)) 243 + .route(concat!("/", identity::submit_plc_operation::NSID), post(todo)) 244 + .route(concat!("/", identity::update_handle::NSID), post(update_handle)) 245 + }
+5
src/apis/com/atproto/mod.rs
··· 1 + // pub mod admin; 2 + // pub mod identity; 3 + pub mod repo; 4 + // pub mod server; 5 + // pub mod sync;
+142
src/apis/com/atproto/repo/apply_writes.rs
··· 1 + //! Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS. 2 + 3 + use super::*; 4 + 5 + async fn inner_apply_writes( 6 + body: ApplyWritesInput, 7 + auth: AuthenticatedUser, 8 + sequencer: Arc<RwLock<Sequencer>>, 9 + actor_pools: HashMap<String, ActorStorage>, 10 + account_manager: Arc<RwLock<AccountManager>>, 11 + ) -> Result<()> { 12 + let tx: ApplyWritesInput = body; 13 + let ApplyWritesInput { 14 + repo, 15 + validate, 16 + swap_commit, 17 + .. 18 + } = tx; 19 + let account = account_manager 20 + .read() 21 + .await 22 + .get_account( 23 + &repo, 24 + Some(AvailabilityFlags { 25 + include_deactivated: Some(true), 26 + include_taken_down: None, 27 + }), 28 + ) 29 + .await?; 30 + 31 + if let Some(account) = account { 32 + if account.deactivated_at.is_some() { 33 + bail!("Account is deactivated") 34 + } 35 + let did = account.did; 36 + if did != auth.did() { 37 + bail!("AuthRequiredError") 38 + } 39 + let did: &String = &did; 40 + if tx.writes.len() > 200 { 41 + bail!("Too many writes. Max: 200") 42 + } 43 + 44 + let writes: Vec<PreparedWrite> = stream::iter(tx.writes) 45 + .then(async |write| { 46 + Ok::<PreparedWrite, anyhow::Error>(match write { 47 + ApplyWritesInputRefWrite::Create(write) => PreparedWrite::Create( 48 + prepare_create(PrepareCreateOpts { 49 + did: did.clone(), 50 + collection: write.collection, 51 + rkey: write.rkey, 52 + swap_cid: None, 53 + record: serde_json::from_value(write.value)?, 54 + validate, 55 + }) 56 + .await?, 57 + ), 58 + ApplyWritesInputRefWrite::Update(write) => PreparedWrite::Update( 59 + prepare_update(PrepareUpdateOpts { 60 + did: did.clone(), 61 + collection: write.collection, 62 + rkey: write.rkey, 63 + swap_cid: None, 64 + record: serde_json::from_value(write.value)?, 65 + validate, 66 + }) 67 + .await?, 68 + ), 69 + ApplyWritesInputRefWrite::Delete(write) => { 70 + PreparedWrite::Delete(prepare_delete(PrepareDeleteOpts { 71 + did: did.clone(), 72 + collection: write.collection, 73 + rkey: write.rkey, 74 + swap_cid: None, 75 + })?) 76 + } 77 + }) 78 + }) 79 + .collect::<Vec<_>>() 80 + .await 81 + .into_iter() 82 + .collect::<Result<Vec<PreparedWrite>, _>>()?; 83 + 84 + let swap_commit_cid = match swap_commit { 85 + Some(swap_commit) => Some(Cid::from_str(&swap_commit)?), 86 + None => None, 87 + }; 88 + 89 + let mut actor_store = ActorStore::from_actor_pools(did, &actor_pools).await; 90 + 91 + let commit = actor_store 92 + .process_writes(writes.clone(), swap_commit_cid) 93 + .await?; 94 + 95 + _ = sequencer 96 + .write() 97 + .await 98 + .sequence_commit(did.clone(), commit.clone()) 99 + .await?; 100 + account_manager 101 + .write() 102 + .await 103 + .update_repo_root( 104 + did.to_string(), 105 + commit.commit_data.cid, 106 + commit.commit_data.rev, 107 + &actor_pools, 108 + ) 109 + .await?; 110 + Ok(()) 111 + } else { 112 + bail!("Could not find repo: `{repo}`") 113 + } 114 + } 115 + 116 + /// Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS. 117 + /// - POST /xrpc/com.atproto.repo.applyWrites 118 + /// ### Request Body 119 + /// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account). 120 + /// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons. 121 + /// - `writes`: `object[]` // One of: 122 + /// - - com.atproto.repo.applyWrites.create 123 + /// - - com.atproto.repo.applyWrites.update 124 + /// - - com.atproto.repo.applyWrites.delete 125 + /// - `swap_commit`: `cid` // If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations. 126 + #[axum::debug_handler(state = AppState)] 127 + pub(crate) async fn apply_writes( 128 + auth: AuthenticatedUser, 129 + State(actor_pools): State<HashMap<String, ActorStorage, RandomState>>, 130 + State(account_manager): State<Arc<RwLock<AccountManager>>>, 131 + State(sequencer): State<Arc<RwLock<Sequencer>>>, 132 + Json(body): Json<ApplyWritesInput>, 133 + ) -> Result<(), ApiError> { 134 + tracing::debug!("@LOG: debug apply_writes {body:#?}"); 135 + match inner_apply_writes(body, auth, sequencer, actor_pools, account_manager).await { 136 + Ok(()) => Ok(()), 137 + Err(error) => { 138 + tracing::error!("@LOG: ERROR: {error}"); 139 + Err(ApiError::RuntimeError) 140 + } 141 + } 142 + }
+140
src/apis/com/atproto/repo/create_record.rs
··· 1 + //! Create a single new repository record. Requires auth, implemented by PDS. 2 + 3 + use super::*; 4 + 5 + async fn inner_create_record( 6 + body: CreateRecordInput, 7 + user: AuthenticatedUser, 8 + sequencer: Arc<RwLock<Sequencer>>, 9 + actor_pools: HashMap<String, ActorStorage>, 10 + account_manager: Arc<RwLock<AccountManager>>, 11 + ) -> Result<CreateRecordOutput> { 12 + let CreateRecordInput { 13 + repo, 14 + collection, 15 + record, 16 + rkey, 17 + validate, 18 + swap_commit, 19 + } = body; 20 + let account = account_manager 21 + .read() 22 + .await 23 + .get_account( 24 + &repo, 25 + Some(AvailabilityFlags { 26 + include_deactivated: Some(true), 27 + include_taken_down: None, 28 + }), 29 + ) 30 + .await?; 31 + if let Some(account) = account { 32 + if account.deactivated_at.is_some() { 33 + bail!("Account is deactivated") 34 + } 35 + let did = account.did; 36 + // if did != auth.access.credentials.unwrap().did.unwrap() { 37 + if did != user.did() { 38 + bail!("AuthRequiredError") 39 + } 40 + let swap_commit_cid = match swap_commit { 41 + Some(swap_commit) => Some(Cid::from_str(&swap_commit)?), 42 + None => None, 43 + }; 44 + let write = prepare_create(PrepareCreateOpts { 45 + did: did.clone(), 46 + collection: collection.clone(), 47 + record: serde_json::from_value(record)?, 48 + rkey, 49 + validate, 50 + swap_cid: None, 51 + }) 52 + .await?; 53 + 54 + let did: &String = &did; 55 + let mut actor_store = ActorStore::from_actor_pools(did, &actor_pools).await; 56 + let backlink_conflicts: Vec<AtUri> = match validate { 57 + Some(true) => { 58 + let write_at_uri: AtUri = write.uri.clone().try_into()?; 59 + actor_store 60 + .record 61 + .get_backlink_conflicts(&write_at_uri, &write.record) 62 + .await? 63 + } 64 + _ => Vec::new(), 65 + }; 66 + 67 + let backlink_deletions: Vec<PreparedDelete> = backlink_conflicts 68 + .iter() 69 + .map(|at_uri| { 70 + prepare_delete(PrepareDeleteOpts { 71 + did: at_uri.get_hostname().to_string(), 72 + collection: at_uri.get_collection(), 73 + rkey: at_uri.get_rkey(), 74 + swap_cid: None, 75 + }) 76 + }) 77 + .collect::<Result<Vec<PreparedDelete>>>()?; 78 + let mut writes: Vec<PreparedWrite> = vec![PreparedWrite::Create(write.clone())]; 79 + for delete in backlink_deletions { 80 + writes.push(PreparedWrite::Delete(delete)); 81 + } 82 + let commit = actor_store 83 + .process_writes(writes.clone(), swap_commit_cid) 84 + .await?; 85 + 86 + _ = sequencer 87 + .write() 88 + .await 89 + .sequence_commit(did.clone(), commit.clone()) 90 + .await?; 91 + account_manager 92 + .write() 93 + .await 94 + .update_repo_root( 95 + did.to_string(), 96 + commit.commit_data.cid, 97 + commit.commit_data.rev, 98 + &actor_pools, 99 + ) 100 + .await?; 101 + 102 + Ok(CreateRecordOutput { 103 + uri: write.uri.clone(), 104 + cid: write.cid.to_string(), 105 + }) 106 + } else { 107 + bail!("Could not find repo: `{repo}`") 108 + } 109 + } 110 + 111 + /// Create a single new repository record. Requires auth, implemented by PDS. 112 + /// - POST /xrpc/com.atproto.repo.createRecord 113 + /// ### Request Body 114 + /// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account). 115 + /// - `collection`: `nsid` // The NSID of the record collection. 116 + /// - `rkey`: `string` // The record key. <= 512 characters. 117 + /// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons. 118 + /// - `record` 119 + /// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID. 120 + /// ### Responses 121 + /// - 200 OK: {`cid`: `cid`, `uri`: `at-uri`, `commit`: {`cid`: `cid`, `rev`: `tid`}, `validation_status`: [`valid`, `unknown`]} 122 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidSwap`]} 123 + /// - 401 Unauthorized 124 + #[axum::debug_handler(state = AppState)] 125 + pub async fn create_record( 126 + user: AuthenticatedUser, 127 + State(db_actors): State<HashMap<String, ActorStorage, RandomState>>, 128 + State(account_manager): State<Arc<RwLock<AccountManager>>>, 129 + State(sequencer): State<Arc<RwLock<Sequencer>>>, 130 + Json(body): Json<CreateRecordInput>, 131 + ) -> Result<Json<CreateRecordOutput>, ApiError> { 132 + tracing::debug!("@LOG: debug create_record {body:#?}"); 133 + match inner_create_record(body, user, sequencer, db_actors, account_manager).await { 134 + Ok(res) => Ok(Json(res)), 135 + Err(error) => { 136 + tracing::error!("@LOG: ERROR: {error}"); 137 + Err(ApiError::RuntimeError) 138 + } 139 + } 140 + }
+117
src/apis/com/atproto/repo/delete_record.rs
··· 1 + //! Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS. 2 + use super::*; 3 + 4 + async fn inner_delete_record( 5 + body: DeleteRecordInput, 6 + user: AuthenticatedUser, 7 + sequencer: Arc<RwLock<Sequencer>>, 8 + actor_pools: HashMap<String, ActorStorage>, 9 + account_manager: Arc<RwLock<AccountManager>>, 10 + ) -> Result<()> { 11 + let DeleteRecordInput { 12 + repo, 13 + collection, 14 + rkey, 15 + swap_record, 16 + swap_commit, 17 + } = body; 18 + let account = account_manager 19 + .read() 20 + .await 21 + .get_account( 22 + &repo, 23 + Some(AvailabilityFlags { 24 + include_deactivated: Some(true), 25 + include_taken_down: None, 26 + }), 27 + ) 28 + .await?; 29 + match account { 30 + None => bail!("Could not find repo: `{repo}`"), 31 + Some(account) if account.deactivated_at.is_some() => bail!("Account is deactivated"), 32 + Some(account) => { 33 + let did = account.did; 34 + // if did != auth.access.credentials.unwrap().did.unwrap() { 35 + if did != user.did() { 36 + bail!("AuthRequiredError") 37 + } 38 + 39 + let swap_commit_cid = match swap_commit { 40 + Some(swap_commit) => Some(Cid::from_str(&swap_commit)?), 41 + None => None, 42 + }; 43 + let swap_record_cid = match swap_record { 44 + Some(swap_record) => Some(Cid::from_str(&swap_record)?), 45 + None => None, 46 + }; 47 + 48 + let write = prepare_delete(PrepareDeleteOpts { 49 + did: did.clone(), 50 + collection, 51 + rkey, 52 + swap_cid: swap_record_cid, 53 + })?; 54 + let mut actor_store = ActorStore::from_actor_pools(&did, &actor_pools).await; 55 + let write_at_uri: AtUri = write.uri.clone().try_into()?; 56 + let record = actor_store 57 + .record 58 + .get_record(&write_at_uri, None, Some(true)) 59 + .await?; 60 + let commit = match record { 61 + None => return Ok(()), // No-op if record already doesn't exist 62 + Some(_) => { 63 + actor_store 64 + .process_writes(vec![PreparedWrite::Delete(write.clone())], swap_commit_cid) 65 + .await? 66 + } 67 + }; 68 + 69 + _ = sequencer 70 + .write() 71 + .await 72 + .sequence_commit(did.clone(), commit.clone()) 73 + .await?; 74 + account_manager 75 + .write() 76 + .await 77 + .update_repo_root( 78 + did, 79 + commit.commit_data.cid, 80 + commit.commit_data.rev, 81 + &actor_pools, 82 + ) 83 + .await?; 84 + 85 + Ok(()) 86 + } 87 + } 88 + } 89 + 90 + /// Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS. 91 + /// - POST /xrpc/com.atproto.repo.deleteRecord 92 + /// ### Request Body 93 + /// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account). 94 + /// - `collection`: `nsid` // The NSID of the record collection. 95 + /// - `rkey`: `string` // The record key. <= 512 characters. 96 + /// - `swap_record`: `boolean` // Compare and swap with the previous record by CID. 97 + /// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID. 98 + /// ### Responses 99 + /// - 200 OK: {"commit": {"cid": "string","rev": "string"}} 100 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidSwap`]} 101 + /// - 401 Unauthorized 102 + #[axum::debug_handler(state = AppState)] 103 + pub async fn delete_record( 104 + user: AuthenticatedUser, 105 + State(db_actors): State<HashMap<String, ActorStorage, RandomState>>, 106 + State(account_manager): State<Arc<RwLock<AccountManager>>>, 107 + State(sequencer): State<Arc<RwLock<Sequencer>>>, 108 + Json(body): Json<DeleteRecordInput>, 109 + ) -> Result<(), ApiError> { 110 + match inner_delete_record(body, user, sequencer, db_actors, account_manager).await { 111 + Ok(()) => Ok(()), 112 + Err(error) => { 113 + tracing::error!("@LOG: ERROR: {error}"); 114 + Err(ApiError::RuntimeError) 115 + } 116 + } 117 + }
+70
src/apis/com/atproto/repo/describe_repo.rs
··· 1 + //! Get information about an account and repository, including the list of collections. Does not require auth. 2 + use super::*; 3 + 4 + async fn inner_describe_repo( 5 + repo: String, 6 + id_resolver: Arc<RwLock<IdResolver>>, 7 + actor_pools: HashMap<String, ActorStorage>, 8 + account_manager: Arc<RwLock<AccountManager>>, 9 + ) -> Result<DescribeRepoOutput> { 10 + let account = account_manager 11 + .read() 12 + .await 13 + .get_account(&repo, None) 14 + .await?; 15 + match account { 16 + None => bail!("Cound not find user: `{repo}`"), 17 + Some(account) => { 18 + let did_doc: DidDocument = match id_resolver 19 + .write() 20 + .await 21 + .did 22 + .ensure_resolve(&account.did, None) 23 + .await 24 + { 25 + Err(err) => bail!("Could not resolve DID: `{err}`"), 26 + Ok(res) => res, 27 + }; 28 + let handle = rsky_common::get_handle(&did_doc); 29 + let handle_is_correct = handle == account.handle; 30 + 31 + let actor_store = 32 + ActorStore::from_actor_pools(&account.did.clone(), &actor_pools).await; 33 + let collections = actor_store.record.list_collections().await?; 34 + 35 + Ok(DescribeRepoOutput { 36 + handle: account.handle.unwrap_or_else(|| INVALID_HANDLE.to_owned()), 37 + did: account.did, 38 + did_doc: serde_json::to_value(did_doc)?, 39 + collections, 40 + handle_is_correct, 41 + }) 42 + } 43 + } 44 + } 45 + 46 + /// Get information about an account and repository, including the list of collections. Does not require auth. 47 + /// - GET /xrpc/com.atproto.repo.describeRepo 48 + /// ### Query Parameters 49 + /// - `repo`: `at-identifier` // The handle or DID of the repo. 50 + /// ### Responses 51 + /// - 200 OK: {"handle": "string","did": "string","didDoc": {},"collections": [string],"handleIsCorrect": true} \ 52 + /// handeIsCorrect - boolean - Indicates if handle is currently valid (resolves bi-directionally) 53 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 54 + /// - 401 Unauthorized 55 + #[tracing::instrument(skip_all)] 56 + #[axum::debug_handler(state = AppState)] 57 + pub async fn describe_repo( 58 + Query(input): Query<atrium_repo::describe_repo::ParametersData>, 59 + State(db_actors): State<HashMap<String, ActorStorage, RandomState>>, 60 + State(account_manager): State<Arc<RwLock<AccountManager>>>, 61 + State(id_resolver): State<Arc<RwLock<IdResolver>>>, 62 + ) -> Result<Json<DescribeRepoOutput>, ApiError> { 63 + match inner_describe_repo(input.repo.into(), id_resolver, db_actors, account_manager).await { 64 + Ok(res) => Ok(Json(res)), 65 + Err(error) => { 66 + tracing::error!("{error:?}"); 67 + Err(ApiError::RuntimeError) 68 + } 69 + } 70 + }
+37
src/apis/com/atproto/repo/ex.rs
··· 1 + //! 2 + use crate::account_manager::AccountManager; 3 + use crate::serve::ActorStorage; 4 + use crate::{actor_store::ActorStore, error::ApiError, serve::AppState}; 5 + use anyhow::{Result, bail}; 6 + use axum::extract::Query; 7 + use axum::{Json, extract::State}; 8 + use rsky_identity::IdResolver; 9 + use rsky_pds::sequencer::Sequencer; 10 + use std::collections::HashMap; 11 + use std::hash::RandomState; 12 + use std::sync::Arc; 13 + use tokio::sync::RwLock; 14 + 15 + async fn fun( 16 + actor_pools: HashMap<String, ActorStorage>, 17 + account_manager: Arc<RwLock<AccountManager>>, 18 + id_resolver: Arc<RwLock<IdResolver>>, 19 + sequencer: Arc<RwLock<Sequencer>>, 20 + ) -> Result<_> { 21 + todo!(); 22 + } 23 + 24 + /// 25 + #[tracing::instrument(skip_all)] 26 + #[axum::debug_handler(state = AppState)] 27 + pub async fn fun( 28 + auth: AuthenticatedUser, 29 + Query(input): Query<atrium_api::com::atproto::repo::describe_repo::ParametersData>, 30 + State(actor_pools): State<HashMap<String, ActorStorage, RandomState>>, 31 + State(account_manager): State<Arc<RwLock<AccountManager>>>, 32 + State(id_resolver): State<Arc<RwLock<IdResolver>>>, 33 + State(sequencer): State<Arc<RwLock<Sequencer>>>, 34 + Json(body): Json<ApplyWritesInput>, 35 + ) -> Result<Json<_>, ApiError> { 36 + todo!(); 37 + }
+102
src/apis/com/atproto/repo/get_record.rs
··· 1 + //! Get a single record from a repository. Does not require auth. 2 + 3 + use crate::pipethrough::{ProxyRequest, pipethrough}; 4 + 5 + use super::*; 6 + 7 + use rsky_pds::pipethrough::OverrideOpts; 8 + 9 + async fn inner_get_record( 10 + repo: String, 11 + collection: String, 12 + rkey: String, 13 + cid: Option<String>, 14 + req: ProxyRequest, 15 + actor_pools: HashMap<String, ActorStorage>, 16 + account_manager: Arc<RwLock<AccountManager>>, 17 + ) -> Result<GetRecordOutput> { 18 + let did = account_manager 19 + .read() 20 + .await 21 + .get_did_for_actor(&repo, None) 22 + .await?; 23 + 24 + // fetch from pds if available, if not then fetch from appview 25 + if let Some(did) = did { 26 + let uri = AtUri::make(did.clone(), Some(collection), Some(rkey))?; 27 + 28 + let mut actor_store = ActorStore::from_actor_pools(&did, &actor_pools).await; 29 + 30 + match actor_store.record.get_record(&uri, cid, None).await { 31 + Ok(Some(record)) if record.takedown_ref.is_none() => Ok(GetRecordOutput { 32 + uri: uri.to_string(), 33 + cid: Some(record.cid), 34 + value: serde_json::to_value(record.value)?, 35 + }), 36 + _ => bail!("Could not locate record: `{uri}`"), 37 + } 38 + } else { 39 + match req.cfg.bsky_app_view { 40 + None => bail!("Could not locate record"), 41 + Some(_) => match pipethrough( 42 + &req, 43 + None, 44 + OverrideOpts { 45 + aud: None, 46 + lxm: None, 47 + }, 48 + ) 49 + .await 50 + { 51 + Err(error) => { 52 + tracing::error!("@LOG: ERROR: {error}"); 53 + bail!("Could not locate record") 54 + } 55 + Ok(res) => { 56 + let output: GetRecordOutput = serde_json::from_slice(res.buffer.as_slice())?; 57 + Ok(output) 58 + } 59 + }, 60 + } 61 + } 62 + } 63 + 64 + /// Get a single record from a repository. Does not require auth. 65 + /// - GET /xrpc/com.atproto.repo.getRecord 66 + /// ### Query Parameters 67 + /// - `repo`: `at-identifier` // The handle or DID of the repo. 68 + /// - `collection`: `nsid` // The NSID of the record collection. 69 + /// - `rkey`: `string` // The record key. <= 512 characters. 70 + /// - `cid`: `cid` // The CID of the version of the record. If not specified, then return the most recent version. 71 + /// ### Responses 72 + /// - 200 OK: {"uri": "string","cid": "string","value": {}} 73 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RecordNotFound`]} 74 + /// - 401 Unauthorized 75 + #[tracing::instrument(skip_all)] 76 + #[axum::debug_handler(state = AppState)] 77 + pub async fn get_record( 78 + Query(input): Query<ParametersData>, 79 + State(db_actors): State<HashMap<String, ActorStorage, RandomState>>, 80 + State(account_manager): State<Arc<RwLock<AccountManager>>>, 81 + req: ProxyRequest, 82 + ) -> Result<Json<GetRecordOutput>, ApiError> { 83 + let repo = input.repo; 84 + let collection = input.collection; 85 + let rkey = input.rkey; 86 + let cid = input.cid; 87 + match inner_get_record(repo, collection, rkey, cid, req, db_actors, account_manager).await { 88 + Ok(res) => Ok(Json(res)), 89 + Err(error) => { 90 + tracing::error!("@LOG: ERROR: {error}"); 91 + Err(ApiError::RecordNotFound) 92 + } 93 + } 94 + } 95 + 96 + #[derive(serde::Deserialize, Debug)] 97 + pub struct ParametersData { 98 + pub cid: Option<String>, 99 + pub collection: String, 100 + pub repo: String, 101 + pub rkey: String, 102 + }
+183
src/apis/com/atproto/repo/import_repo.rs
··· 1 + use axum::{body::Bytes, http::HeaderMap}; 2 + use reqwest::header; 3 + use rsky_common::env::env_int; 4 + use rsky_repo::block_map::BlockMap; 5 + use rsky_repo::car::{CarWithRoot, read_stream_car_with_root}; 6 + use rsky_repo::parse::get_and_parse_record; 7 + use rsky_repo::repo::Repo; 8 + use rsky_repo::sync::consumer::{VerifyRepoInput, verify_diff}; 9 + use rsky_repo::types::{RecordWriteDescript, VerifiedDiff}; 10 + use ubyte::ToByteUnit; 11 + 12 + use super::*; 13 + 14 + async fn from_data(bytes: Bytes) -> Result<CarWithRoot, ApiError> { 15 + let max_import_size = env_int("IMPORT_REPO_LIMIT").unwrap_or(100).megabytes(); 16 + if bytes.len() > max_import_size { 17 + return Err(ApiError::InvalidRequest(format!( 18 + "Content-Length is greater than maximum of {max_import_size}" 19 + ))); 20 + } 21 + 22 + let mut cursor = std::io::Cursor::new(bytes); 23 + match read_stream_car_with_root(&mut cursor).await { 24 + Ok(car_with_root) => Ok(car_with_root), 25 + Err(error) => { 26 + tracing::error!("Error reading stream car with root\n{error}"); 27 + Err(ApiError::InvalidRequest("Invalid CAR file".to_owned())) 28 + } 29 + } 30 + } 31 + 32 + #[tracing::instrument(skip_all)] 33 + #[axum::debug_handler(state = AppState)] 34 + /// Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set. 35 + /// Request 36 + /// mime application/vnd.ipld.car 37 + /// Body - required 38 + pub async fn import_repo( 39 + // auth: AccessFullImport, 40 + auth: AuthenticatedUser, 41 + headers: HeaderMap, 42 + State(actor_pools): State<HashMap<String, ActorStorage, RandomState>>, 43 + body: Bytes, 44 + ) -> Result<(), ApiError> { 45 + // let requester = auth.access.credentials.unwrap().did.unwrap(); 46 + let requester = auth.did(); 47 + let mut actor_store = ActorStore::from_actor_pools(&requester, &actor_pools).await; 48 + 49 + // Check headers 50 + let content_length = headers 51 + .get(header::CONTENT_LENGTH) 52 + .expect("no content length provided") 53 + .to_str() 54 + .map_err(anyhow::Error::from) 55 + .and_then(|content_length| content_length.parse::<u64>().map_err(anyhow::Error::from)) 56 + .expect("invalid content-length header"); 57 + if content_length > env_int("IMPORT_REPO_LIMIT").unwrap_or(100).megabytes() { 58 + return Err(ApiError::InvalidRequest(format!( 59 + "Content-Length is greater than maximum of {}", 60 + env_int("IMPORT_REPO_LIMIT").unwrap_or(100).megabytes() 61 + ))); 62 + }; 63 + 64 + // Get current repo if it exists 65 + let curr_root: Option<Cid> = actor_store.get_repo_root().await; 66 + let curr_repo: Option<Repo> = match curr_root { 67 + None => None, 68 + Some(_root) => Some(Repo::load(actor_store.storage.clone(), curr_root).await?), 69 + }; 70 + 71 + // Process imported car 72 + // let car_with_root = import_repo_input.car_with_root; 73 + let car_with_root: CarWithRoot = match from_data(body).await { 74 + Ok(car) => car, 75 + Err(error) => { 76 + tracing::error!("Error importing repo\n{error:?}"); 77 + return Err(ApiError::InvalidRequest("Invalid CAR file".to_owned())); 78 + } 79 + }; 80 + 81 + // Get verified difference from current repo and imported repo 82 + let mut imported_blocks: BlockMap = car_with_root.blocks; 83 + let imported_root: Cid = car_with_root.root; 84 + let opts = VerifyRepoInput { 85 + ensure_leaves: Some(false), 86 + }; 87 + 88 + let diff: VerifiedDiff = match verify_diff( 89 + curr_repo, 90 + &mut imported_blocks, 91 + imported_root, 92 + None, 93 + None, 94 + Some(opts), 95 + ) 96 + .await 97 + { 98 + Ok(res) => res, 99 + Err(error) => { 100 + tracing::error!("{:?}", error); 101 + return Err(ApiError::RuntimeError); 102 + } 103 + }; 104 + 105 + let commit_data = diff.commit; 106 + let prepared_writes: Vec<PreparedWrite> = 107 + prepare_import_repo_writes(requester, diff.writes, &imported_blocks).await?; 108 + match actor_store 109 + .process_import_repo(commit_data, prepared_writes) 110 + .await 111 + { 112 + Ok(_res) => {} 113 + Err(error) => { 114 + tracing::error!("Error importing repo\n{error}"); 115 + return Err(ApiError::RuntimeError); 116 + } 117 + } 118 + 119 + Ok(()) 120 + } 121 + 122 + /// Converts list of RecordWriteDescripts into a list of PreparedWrites 123 + async fn prepare_import_repo_writes( 124 + did: String, 125 + writes: Vec<RecordWriteDescript>, 126 + blocks: &BlockMap, 127 + ) -> Result<Vec<PreparedWrite>, ApiError> { 128 + match stream::iter(writes) 129 + .then(|write| { 130 + let did = did.clone(); 131 + async move { 132 + Ok::<PreparedWrite, anyhow::Error>(match write { 133 + RecordWriteDescript::Create(write) => { 134 + let parsed_record = get_and_parse_record(blocks, write.cid)?; 135 + PreparedWrite::Create( 136 + prepare_create(PrepareCreateOpts { 137 + did: did.clone(), 138 + collection: write.collection, 139 + rkey: Some(write.rkey), 140 + swap_cid: None, 141 + record: parsed_record.record, 142 + validate: Some(true), 143 + }) 144 + .await?, 145 + ) 146 + } 147 + RecordWriteDescript::Update(write) => { 148 + let parsed_record = get_and_parse_record(blocks, write.cid)?; 149 + PreparedWrite::Update( 150 + prepare_update(PrepareUpdateOpts { 151 + did: did.clone(), 152 + collection: write.collection, 153 + rkey: write.rkey, 154 + swap_cid: None, 155 + record: parsed_record.record, 156 + validate: Some(true), 157 + }) 158 + .await?, 159 + ) 160 + } 161 + RecordWriteDescript::Delete(write) => { 162 + PreparedWrite::Delete(prepare_delete(PrepareDeleteOpts { 163 + did: did.clone(), 164 + collection: write.collection, 165 + rkey: write.rkey, 166 + swap_cid: None, 167 + })?) 168 + } 169 + }) 170 + } 171 + }) 172 + .collect::<Vec<_>>() 173 + .await 174 + .into_iter() 175 + .collect::<Result<Vec<PreparedWrite>, _>>() 176 + { 177 + Ok(res) => Ok(res), 178 + Err(error) => { 179 + tracing::error!("Error preparing import repo writes\n{error}"); 180 + Err(ApiError::RuntimeError) 181 + } 182 + } 183 + }
+48
src/apis/com/atproto/repo/list_missing_blobs.rs
··· 1 + //! Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow. 2 + use rsky_lexicon::com::atproto::repo::ListMissingBlobsOutput; 3 + use rsky_pds::actor_store::blob::ListMissingBlobsOpts; 4 + 5 + use super::*; 6 + 7 + /// Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow. 8 + /// Request 9 + /// Query Parameters 10 + /// limit integer 11 + /// Possible values: >= 1 and <= 1000 12 + /// Default value: 500 13 + /// cursor string 14 + /// Responses 15 + /// cursor string 16 + /// blobs object[] 17 + #[tracing::instrument(skip_all)] 18 + #[axum::debug_handler(state = AppState)] 19 + pub async fn list_missing_blobs( 20 + user: AuthenticatedUser, 21 + Query(input): Query<atrium_repo::list_missing_blobs::ParametersData>, 22 + State(actor_pools): State<HashMap<String, ActorStorage, RandomState>>, 23 + ) -> Result<Json<ListMissingBlobsOutput>, ApiError> { 24 + let cursor = input.cursor; 25 + let limit = input.limit; 26 + let default_limit: atrium_api::types::LimitedNonZeroU16<1000> = 27 + atrium_api::types::LimitedNonZeroU16::try_from(500).expect("default limit"); 28 + let limit: u16 = limit.unwrap_or(default_limit).into(); 29 + // let did = auth.access.credentials.unwrap().did.unwrap(); 30 + let did = user.did(); 31 + 32 + let actor_store = ActorStore::from_actor_pools(&did, &actor_pools).await; 33 + 34 + match actor_store 35 + .blob 36 + .list_missing_blobs(ListMissingBlobsOpts { cursor, limit }) 37 + .await 38 + { 39 + Ok(blobs) => { 40 + let cursor = blobs.last().map(|last_blob| last_blob.cid.clone()); 41 + Ok(Json(ListMissingBlobsOutput { cursor, blobs })) 42 + } 43 + Err(error) => { 44 + tracing::error!("{error:?}"); 45 + Err(ApiError::RuntimeError) 46 + } 47 + } 48 + }
+146
src/apis/com/atproto/repo/list_records.rs
··· 1 + //! List a range of records in a repository, matching a specific collection. Does not require auth. 2 + use super::*; 3 + 4 + // #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 + // #[serde(rename_all = "camelCase")] 6 + // /// Parameters for [`list_records`]. 7 + // pub(super) struct ListRecordsParameters { 8 + // ///The NSID of the record type. 9 + // pub collection: Nsid, 10 + // /// The cursor to start from. 11 + // #[serde(skip_serializing_if = "core::option::Option::is_none")] 12 + // pub cursor: Option<String>, 13 + // ///The number of records to return. 14 + // #[serde(skip_serializing_if = "core::option::Option::is_none")] 15 + // pub limit: Option<String>, 16 + // ///The handle or DID of the repo. 17 + // pub repo: AtIdentifier, 18 + // ///Flag to reverse the order of the returned records. 19 + // #[serde(skip_serializing_if = "core::option::Option::is_none")] 20 + // pub reverse: Option<bool>, 21 + // ///DEPRECATED: The highest sort-ordered rkey to stop at (exclusive) 22 + // #[serde(skip_serializing_if = "core::option::Option::is_none")] 23 + // pub rkey_end: Option<String>, 24 + // ///DEPRECATED: The lowest sort-ordered rkey to start from (exclusive) 25 + // #[serde(skip_serializing_if = "core::option::Option::is_none")] 26 + // pub rkey_start: Option<String>, 27 + // } 28 + 29 + #[expect(non_snake_case, clippy::too_many_arguments)] 30 + async fn inner_list_records( 31 + // The handle or DID of the repo. 32 + repo: String, 33 + // The NSID of the record type. 34 + collection: String, 35 + // The number of records to return. 36 + limit: u16, 37 + cursor: Option<String>, 38 + // DEPRECATED: The lowest sort-ordered rkey to start from (exclusive) 39 + rkeyStart: Option<String>, 40 + // DEPRECATED: The highest sort-ordered rkey to stop at (exclusive) 41 + rkeyEnd: Option<String>, 42 + // Flag to reverse the order of the returned records. 43 + reverse: bool, 44 + // The actor pools 45 + actor_pools: HashMap<String, ActorStorage>, 46 + account_manager: Arc<RwLock<AccountManager>>, 47 + ) -> Result<ListRecordsOutput> { 48 + if limit > 100 { 49 + bail!("Error: limit can not be greater than 100") 50 + } 51 + let did = account_manager 52 + .read() 53 + .await 54 + .get_did_for_actor(&repo, None) 55 + .await?; 56 + if let Some(did) = did { 57 + let mut actor_store = ActorStore::from_actor_pools(&did, &actor_pools).await; 58 + 59 + let records: Vec<Record> = actor_store 60 + .record 61 + .list_records_for_collection( 62 + collection, 63 + limit as i64, 64 + reverse, 65 + cursor, 66 + rkeyStart, 67 + rkeyEnd, 68 + None, 69 + ) 70 + .await? 71 + .into_iter() 72 + .map(|record| { 73 + Ok(Record { 74 + uri: record.uri.clone(), 75 + cid: record.cid.clone(), 76 + value: serde_json::to_value(record)?, 77 + }) 78 + }) 79 + .collect::<Result<Vec<Record>>>()?; 80 + 81 + let last_record = records.last(); 82 + let cursor: Option<String>; 83 + if let Some(last_record) = last_record { 84 + let last_at_uri: AtUri = last_record.uri.clone().try_into()?; 85 + cursor = Some(last_at_uri.get_rkey()); 86 + } else { 87 + cursor = None; 88 + } 89 + Ok(ListRecordsOutput { records, cursor }) 90 + } else { 91 + bail!("Could not find repo: {repo}") 92 + } 93 + } 94 + 95 + /// List a range of records in a repository, matching a specific collection. Does not require auth. 96 + /// - GET /xrpc/com.atproto.repo.listRecords 97 + /// ### Query Parameters 98 + /// - `repo`: `at-identifier` // The handle or DID of the repo. 99 + /// - `collection`: `nsid` // The NSID of the record type. 100 + /// - `limit`: `integer` // The maximum number of records to return. Default 50, >=1 and <=100. 101 + /// - `cursor`: `string` 102 + /// - `reverse`: `boolean` // Flag to reverse the order of the returned records. 103 + /// ### Responses 104 + /// - 200 OK: {"cursor": "string","records": [{"uri": "string","cid": "string","value": {}}]} 105 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 106 + /// - 401 Unauthorized 107 + #[tracing::instrument(skip_all)] 108 + #[allow(non_snake_case)] 109 + #[axum::debug_handler(state = AppState)] 110 + pub async fn list_records( 111 + Query(input): Query<atrium_repo::list_records::ParametersData>, 112 + State(actor_pools): State<HashMap<String, ActorStorage, RandomState>>, 113 + State(account_manager): State<Arc<RwLock<AccountManager>>>, 114 + ) -> Result<Json<ListRecordsOutput>, ApiError> { 115 + let repo = input.repo; 116 + let collection = input.collection; 117 + let limit: Option<u8> = input.limit.map(u8::from); 118 + let limit: Option<u16> = limit.map(|x| x.into()); 119 + let cursor = input.cursor; 120 + let reverse = input.reverse; 121 + let rkeyStart = None; 122 + let rkeyEnd = None; 123 + 124 + let limit = limit.unwrap_or(50); 125 + let reverse = reverse.unwrap_or(false); 126 + 127 + match inner_list_records( 128 + repo.into(), 129 + collection.into(), 130 + limit, 131 + cursor, 132 + rkeyStart, 133 + rkeyEnd, 134 + reverse, 135 + actor_pools, 136 + account_manager, 137 + ) 138 + .await 139 + { 140 + Ok(res) => Ok(Json(res)), 141 + Err(error) => { 142 + tracing::error!("@LOG: ERROR: {error}"); 143 + Err(ApiError::RuntimeError) 144 + } 145 + } 146 + }
+111
src/apis/com/atproto/repo/mod.rs
··· 1 + use atrium_api::com::atproto::repo as atrium_repo; 2 + use axum::{ 3 + Router, 4 + routing::{get, post}, 5 + }; 6 + use constcat::concat; 7 + 8 + pub mod apply_writes; 9 + pub mod create_record; 10 + pub mod delete_record; 11 + pub mod describe_repo; 12 + pub mod get_record; 13 + pub mod import_repo; 14 + pub mod list_missing_blobs; 15 + pub mod list_records; 16 + pub mod put_record; 17 + pub mod upload_blob; 18 + 19 + use crate::account_manager::AccountManager; 20 + use crate::account_manager::helpers::account::AvailabilityFlags; 21 + use crate::{ 22 + actor_store::ActorStore, 23 + auth::AuthenticatedUser, 24 + error::ApiError, 25 + serve::{ActorStorage, AppState}, 26 + }; 27 + use anyhow::{Result, bail}; 28 + use axum::extract::Query; 29 + use axum::{Json, extract::State}; 30 + use cidv10::Cid; 31 + use futures::stream::{self, StreamExt}; 32 + use rsky_identity::IdResolver; 33 + use rsky_identity::types::DidDocument; 34 + use rsky_lexicon::com::atproto::repo::DeleteRecordInput; 35 + use rsky_lexicon::com::atproto::repo::DescribeRepoOutput; 36 + use rsky_lexicon::com::atproto::repo::GetRecordOutput; 37 + use rsky_lexicon::com::atproto::repo::{ApplyWritesInput, ApplyWritesInputRefWrite}; 38 + use rsky_lexicon::com::atproto::repo::{CreateRecordInput, CreateRecordOutput}; 39 + use rsky_lexicon::com::atproto::repo::{ListRecordsOutput, Record}; 40 + // use rsky_pds::pipethrough::{OverrideOpts, ProxyRequest, pipethrough}; 41 + use rsky_pds::repo::prepare::{ 42 + PrepareCreateOpts, PrepareDeleteOpts, PrepareUpdateOpts, prepare_create, prepare_delete, 43 + prepare_update, 44 + }; 45 + use rsky_pds::sequencer::Sequencer; 46 + use rsky_repo::types::PreparedDelete; 47 + use rsky_repo::types::PreparedWrite; 48 + use rsky_syntax::aturi::AtUri; 49 + use rsky_syntax::handle::INVALID_HANDLE; 50 + use std::collections::HashMap; 51 + use std::hash::RandomState; 52 + use std::str::FromStr; 53 + use std::sync::Arc; 54 + use tokio::sync::RwLock; 55 + 56 + /// These endpoints are part of the atproto PDS repository management APIs. \ 57 + /// Requests usually require authentication (unlike the com.atproto.sync.* endpoints), and are made directly to the user's own PDS instance. 58 + /// ### Routes 59 + /// - AP /xrpc/com.atproto.repo.applyWrites -> [`apply_writes`] 60 + /// - AP /xrpc/com.atproto.repo.createRecord -> [`create_record`] 61 + /// - AP /xrpc/com.atproto.repo.putRecord -> [`put_record`] 62 + /// - AP /xrpc/com.atproto.repo.deleteRecord -> [`delete_record`] 63 + /// - AP /xrpc/com.atproto.repo.uploadBlob -> [`upload_blob`] 64 + /// - UG /xrpc/com.atproto.repo.describeRepo -> [`describe_repo`] 65 + /// - UG /xrpc/com.atproto.repo.getRecord -> [`get_record`] 66 + /// - UG /xrpc/com.atproto.repo.listRecords -> [`list_records`] 67 + /// - [ ] xx /xrpc/com.atproto.repo.importRepo 68 + // - [ ] xx /xrpc/com.atproto.repo.listMissingBlobs 69 + pub(crate) fn routes() -> Router<AppState> { 70 + Router::new() 71 + .route( 72 + concat!("/", atrium_repo::apply_writes::NSID), 73 + post(apply_writes::apply_writes), 74 + ) 75 + .route( 76 + concat!("/", atrium_repo::create_record::NSID), 77 + post(create_record::create_record), 78 + ) 79 + .route( 80 + concat!("/", atrium_repo::put_record::NSID), 81 + post(put_record::put_record), 82 + ) 83 + .route( 84 + concat!("/", atrium_repo::delete_record::NSID), 85 + post(delete_record::delete_record), 86 + ) 87 + .route( 88 + concat!("/", atrium_repo::upload_blob::NSID), 89 + post(upload_blob::upload_blob), 90 + ) 91 + .route( 92 + concat!("/", atrium_repo::describe_repo::NSID), 93 + get(describe_repo::describe_repo), 94 + ) 95 + .route( 96 + concat!("/", atrium_repo::get_record::NSID), 97 + get(get_record::get_record), 98 + ) 99 + .route( 100 + concat!("/", atrium_repo::import_repo::NSID), 101 + post(import_repo::import_repo), 102 + ) 103 + .route( 104 + concat!("/", atrium_repo::list_missing_blobs::NSID), 105 + get(list_missing_blobs::list_missing_blobs), 106 + ) 107 + .route( 108 + concat!("/", atrium_repo::list_records::NSID), 109 + get(list_records::list_records), 110 + ) 111 + }
+157
src/apis/com/atproto/repo/put_record.rs
··· 1 + //! Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS. 2 + use anyhow::bail; 3 + use rsky_lexicon::com::atproto::repo::{PutRecordInput, PutRecordOutput}; 4 + use rsky_repo::types::CommitDataWithOps; 5 + 6 + use super::*; 7 + 8 + #[tracing::instrument(skip_all)] 9 + async fn inner_put_record( 10 + body: PutRecordInput, 11 + auth: AuthenticatedUser, 12 + sequencer: Arc<RwLock<Sequencer>>, 13 + actor_pools: HashMap<String, ActorStorage>, 14 + account_manager: Arc<RwLock<AccountManager>>, 15 + ) -> Result<PutRecordOutput> { 16 + let PutRecordInput { 17 + repo, 18 + collection, 19 + rkey, 20 + validate, 21 + record, 22 + swap_record, 23 + swap_commit, 24 + } = body; 25 + let account = account_manager 26 + .read() 27 + .await 28 + .get_account( 29 + &repo, 30 + Some(AvailabilityFlags { 31 + include_deactivated: Some(true), 32 + include_taken_down: None, 33 + }), 34 + ) 35 + .await?; 36 + if let Some(account) = account { 37 + if account.deactivated_at.is_some() { 38 + bail!("Account is deactivated") 39 + } 40 + let did = account.did; 41 + // if did != auth.access.credentials.unwrap().did.unwrap() { 42 + if did != auth.did() { 43 + bail!("AuthRequiredError") 44 + } 45 + let uri = AtUri::make(did.clone(), Some(collection.clone()), Some(rkey.clone()))?; 46 + let swap_commit_cid = match swap_commit { 47 + Some(swap_commit) => Some(Cid::from_str(&swap_commit)?), 48 + None => None, 49 + }; 50 + let swap_record_cid = match swap_record { 51 + Some(swap_record) => Some(Cid::from_str(&swap_record)?), 52 + None => None, 53 + }; 54 + let (commit, write): (Option<CommitDataWithOps>, PreparedWrite) = { 55 + let mut actor_store = ActorStore::from_actor_pools(&did, &actor_pools).await; 56 + 57 + let current = actor_store 58 + .record 59 + .get_record(&uri, None, Some(true)) 60 + .await?; 61 + tracing::debug!("@LOG: debug inner_put_record, current: {current:?}"); 62 + let write: PreparedWrite = if current.is_some() { 63 + PreparedWrite::Update( 64 + prepare_update(PrepareUpdateOpts { 65 + did: did.clone(), 66 + collection, 67 + rkey, 68 + swap_cid: swap_record_cid, 69 + record: serde_json::from_value(record)?, 70 + validate, 71 + }) 72 + .await?, 73 + ) 74 + } else { 75 + PreparedWrite::Create( 76 + prepare_create(PrepareCreateOpts { 77 + did: did.clone(), 78 + collection, 79 + rkey: Some(rkey), 80 + swap_cid: swap_record_cid, 81 + record: serde_json::from_value(record)?, 82 + validate, 83 + }) 84 + .await?, 85 + ) 86 + }; 87 + 88 + match current { 89 + Some(current) if current.cid == write.cid().expect("write cid").to_string() => { 90 + (None, write) 91 + } 92 + _ => { 93 + let commit = actor_store 94 + .process_writes(vec![write.clone()], swap_commit_cid) 95 + .await?; 96 + (Some(commit), write) 97 + } 98 + } 99 + }; 100 + 101 + if let Some(commit) = commit { 102 + _ = sequencer 103 + .write() 104 + .await 105 + .sequence_commit(did.clone(), commit.clone()) 106 + .await?; 107 + account_manager 108 + .write() 109 + .await 110 + .update_repo_root( 111 + did, 112 + commit.commit_data.cid, 113 + commit.commit_data.rev, 114 + &actor_pools, 115 + ) 116 + .await?; 117 + } 118 + Ok(PutRecordOutput { 119 + uri: write.uri().to_string(), 120 + cid: write.cid().expect("write cid").to_string(), 121 + }) 122 + } else { 123 + bail!("Could not find repo: `{repo}`") 124 + } 125 + } 126 + 127 + /// Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS. 128 + /// - POST /xrpc/com.atproto.repo.putRecord 129 + /// ### Request Body 130 + /// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account). 131 + /// - `collection`: `nsid` // The NSID of the record collection. 132 + /// - `rkey`: `string` // The record key. <= 512 characters. 133 + /// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons. 134 + /// - `record` 135 + /// - `swap_record`: `boolean` // Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation 136 + /// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID. 137 + /// ### Responses 138 + /// - 200 OK: {"uri": "string","cid": "string","commit": {"cid": "string","rev": "string"},"validationStatus": "valid | unknown"} 139 + /// - 400 Bad Request: {error:"`InvalidRequest` | `ExpiredToken` | `InvalidToken` | `InvalidSwap`"} 140 + /// - 401 Unauthorized 141 + #[tracing::instrument(skip_all)] 142 + pub async fn put_record( 143 + auth: AuthenticatedUser, 144 + State(sequencer): State<Arc<RwLock<Sequencer>>>, 145 + State(actor_pools): State<HashMap<String, ActorStorage, RandomState>>, 146 + State(account_manager): State<Arc<RwLock<AccountManager>>>, 147 + Json(body): Json<PutRecordInput>, 148 + ) -> Result<Json<PutRecordOutput>, ApiError> { 149 + tracing::debug!("@LOG: debug put_record {body:#?}"); 150 + match inner_put_record(body, auth, sequencer, actor_pools, account_manager).await { 151 + Ok(res) => Ok(Json(res)), 152 + Err(error) => { 153 + tracing::error!("@LOG: ERROR: {error}"); 154 + Err(ApiError::RuntimeError) 155 + } 156 + } 157 + }
+117
src/apis/com/atproto/repo/upload_blob.rs
··· 1 + //! Upload a new blob, to be referenced from a repository record. 2 + use crate::config::AppConfig; 3 + use anyhow::Context as _; 4 + use axum::{ 5 + body::Bytes, 6 + http::{self, HeaderMap}, 7 + }; 8 + use rsky_lexicon::com::atproto::repo::{Blob, BlobOutput}; 9 + use rsky_repo::types::{BlobConstraint, PreparedBlobRef}; 10 + // use rsky_common::BadContentTypeError; 11 + 12 + use super::*; 13 + 14 + async fn inner_upload_blob( 15 + auth: AuthenticatedUser, 16 + blob: Bytes, 17 + content_type: String, 18 + actor_pools: HashMap<String, ActorStorage>, 19 + ) -> Result<BlobOutput> { 20 + // let requester = auth.access.credentials.unwrap().did.unwrap(); 21 + let requester = auth.did(); 22 + 23 + let actor_store = ActorStore::from_actor_pools(&requester, &actor_pools).await; 24 + 25 + let metadata = actor_store 26 + .blob 27 + .upload_blob_and_get_metadata(content_type, blob) 28 + .await?; 29 + let blobref = actor_store.blob.track_untethered_blob(metadata).await?; 30 + 31 + // make the blob permanent if an associated record is already indexed 32 + let records_for_blob = actor_store 33 + .blob 34 + .get_records_for_blob(blobref.get_cid()?) 35 + .await?; 36 + 37 + if !records_for_blob.is_empty() { 38 + actor_store 39 + .blob 40 + .verify_blob_and_make_permanent(PreparedBlobRef { 41 + cid: blobref.get_cid()?, 42 + mime_type: blobref.get_mime_type().to_string(), 43 + constraints: BlobConstraint { 44 + max_size: None, 45 + accept: None, 46 + }, 47 + }) 48 + .await?; 49 + } 50 + 51 + Ok(BlobOutput { 52 + blob: Blob { 53 + r#type: Some("blob".to_owned()), 54 + r#ref: Some(blobref.get_cid()?), 55 + cid: None, 56 + mime_type: blobref.get_mime_type().to_string(), 57 + size: blobref.get_size(), 58 + original: None, 59 + }, 60 + }) 61 + } 62 + 63 + /// Upload a new blob, to be referenced from a repository record. \ 64 + /// The blob will be deleted if it is not referenced within a time window (eg, minutes). \ 65 + /// Blob restrictions (mimetype, size, etc) are enforced when the reference is created. \ 66 + /// Requires auth, implemented by PDS. 67 + /// - POST /xrpc/com.atproto.repo.uploadBlob 68 + /// ### Request Body 69 + /// ### Responses 70 + /// - 200 OK: {"blob": "binary"} 71 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 72 + /// - 401 Unauthorized 73 + #[tracing::instrument(skip_all)] 74 + #[axum::debug_handler(state = AppState)] 75 + pub async fn upload_blob( 76 + auth: AuthenticatedUser, 77 + headers: HeaderMap, 78 + State(config): State<AppConfig>, 79 + State(actor_pools): State<HashMap<String, ActorStorage, RandomState>>, 80 + blob: Bytes, 81 + ) -> Result<Json<BlobOutput>, ApiError> { 82 + let content_length = headers 83 + .get(http::header::CONTENT_LENGTH) 84 + .context("no content length provided")? 85 + .to_str() 86 + .map_err(anyhow::Error::from) 87 + .and_then(|content_length| content_length.parse::<u64>().map_err(anyhow::Error::from)) 88 + .context("invalid content-length header")?; 89 + let content_type = headers 90 + .get(http::header::CONTENT_TYPE) 91 + .context("no content-type provided")? 92 + .to_str() 93 + // .map_err(BadContentTypeError::MissingType) 94 + .context("invalid content-type provided")? 95 + .to_owned(); 96 + 97 + if content_length > config.blob.limit { 98 + return Err(ApiError::InvalidRequest(format!( 99 + "Content-Length is greater than maximum of {}", 100 + config.blob.limit 101 + ))); 102 + }; 103 + if blob.len() as u64 > config.blob.limit { 104 + return Err(ApiError::InvalidRequest(format!( 105 + "Blob size is greater than maximum of {} despite content-length header", 106 + config.blob.limit 107 + ))); 108 + }; 109 + 110 + match inner_upload_blob(auth, blob, content_type, actor_pools).await { 111 + Ok(res) => Ok(Json(res)), 112 + Err(error) => { 113 + tracing::error!("{error:?}"); 114 + Err(ApiError::RuntimeError) 115 + } 116 + } 117 + }
+791
src/apis/com/atproto/server/server.rs
··· 1 + //! Server endpoints. (/xrpc/com.atproto.server.*) 2 + use std::{collections::HashMap, str::FromStr as _}; 3 + 4 + use anyhow::{Context as _, anyhow}; 5 + use argon2::{ 6 + Argon2, PasswordHash, PasswordHasher as _, PasswordVerifier as _, password_hash::SaltString, 7 + }; 8 + use atrium_api::{ 9 + com::atproto::server, 10 + types::string::{Datetime, Did, Handle, Tid}, 11 + }; 12 + use atrium_crypto::keypair::Did as _; 13 + use atrium_repo::{ 14 + Cid, Repository, 15 + blockstore::{AsyncBlockStoreWrite as _, CarStore, DAG_CBOR, SHA2_256}, 16 + }; 17 + use axum::{ 18 + Json, Router, 19 + extract::{Query, Request, State}, 20 + http::StatusCode, 21 + routing::{get, post}, 22 + }; 23 + use constcat::concat; 24 + use metrics::counter; 25 + use rand::Rng as _; 26 + use sha2::Digest as _; 27 + use uuid::Uuid; 28 + 29 + use crate::{ 30 + AppState, Client, Db, Error, Result, RotationKey, SigningKey, 31 + auth::{self, AuthenticatedUser}, 32 + config::AppConfig, 33 + firehose::{Commit, FirehoseProducer}, 34 + metrics::AUTH_FAILED, 35 + plc::{self, PlcOperation, PlcService}, 36 + storage, 37 + }; 38 + 39 + /// This is a dummy password that can be used in absence of a real password. 40 + const DUMMY_PASSWORD: &str = "$argon2id$v=19$m=19456,t=2,p=1$En2LAfHjeO0SZD5IUU1Abg$RpS8nHhhqY4qco2uyd41p9Y/1C+Lvi214MAWukzKQMI"; 41 + 42 + /// Create an invite code. 43 + /// - POST /xrpc/com.atproto.server.createInviteCode 44 + /// ### Request Body 45 + /// - `useCount`: integer 46 + /// - `forAccount`: string (optional) 47 + /// ### Responses 48 + /// - 200 OK: {code: string} 49 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 50 + /// - 401 Unauthorized 51 + async fn create_invite_code( 52 + _user: AuthenticatedUser, 53 + State(db): State<Db>, 54 + Json(input): Json<server::create_invite_code::Input>, 55 + ) -> Result<Json<server::create_invite_code::Output>> { 56 + let uuid = Uuid::new_v4().to_string(); 57 + let did = input.for_account.as_deref(); 58 + let count = std::cmp::min(input.use_count, 100); // Maximum of 100 uses for any code. 59 + 60 + if count <= 0 { 61 + return Err(anyhow!("use_count must be greater than 0").into()); 62 + } 63 + 64 + Ok(Json( 65 + server::create_invite_code::OutputData { 66 + code: sqlx::query_scalar!( 67 + r#" 68 + INSERT INTO invites (id, did, count, created_at) 69 + VALUES (?, ?, ?, datetime('now')) 70 + RETURNING id 71 + "#, 72 + uuid, 73 + did, 74 + count, 75 + ) 76 + .fetch_one(&db) 77 + .await 78 + .context("failed to create new invite code")?, 79 + } 80 + .into(), 81 + )) 82 + } 83 + 84 + #[expect(clippy::too_many_lines, reason = "TODO: refactor")] 85 + /// Create an account. Implemented by PDS. 86 + /// - POST /xrpc/com.atproto.server.createAccount 87 + /// ### Request Body 88 + /// - `email`: string 89 + /// - `handle`: string (required) 90 + /// - `did`: string - Pre-existing atproto DID, being imported to a new account. 91 + /// - `inviteCode`: string 92 + /// - `verificationCode`: string 93 + /// - `verificationPhone`: string 94 + /// - `password`: string - Initial account password. May need to meet instance-specific password strength requirements. 95 + /// - `recoveryKey`: string - DID PLC rotation key (aka, recovery key) to be included in PLC creation operation. 96 + /// - `plcOp`: object 97 + /// ## Responses 98 + /// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {}} 99 + /// - 400 Bad Request: {error: [`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidHandle`, `InvalidPassword`, \ 100 + /// `InvalidInviteCode`, `HandleNotAvailable`, `UnsupportedDomain`, `UnresolvableDid`, `IncompatibleDidDoc`)} 101 + /// - 401 Unauthorized 102 + async fn create_account( 103 + State(db): State<Db>, 104 + State(skey): State<SigningKey>, 105 + State(rkey): State<RotationKey>, 106 + State(client): State<Client>, 107 + State(config): State<AppConfig>, 108 + State(fhp): State<FirehoseProducer>, 109 + Json(input): Json<server::create_account::Input>, 110 + ) -> Result<Json<server::create_account::Output>> { 111 + let email = input.email.as_deref().context("no email provided")?; 112 + // Hash the user's password. 113 + let pass = Argon2::default() 114 + .hash_password( 115 + input 116 + .password 117 + .as_deref() 118 + .context("no password provided")? 119 + .as_bytes(), 120 + SaltString::generate(&mut rand::thread_rng()).as_salt(), 121 + ) 122 + .context("failed to hash password")? 123 + .to_string(); 124 + let handle = input.handle.as_str().to_owned(); 125 + 126 + // TODO: Handle the account migration flow. 127 + // Users will hit this endpoint with a service-level authentication token. 128 + // 129 + // https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md 130 + 131 + // TODO: `input.plc_op` 132 + if input.plc_op.is_some() { 133 + return Err(Error::unimplemented(anyhow!("plc_op"))); 134 + } 135 + 136 + let recovery_keys = if let Some(ref key) = input.recovery_key { 137 + // Ensure the provided recovery key is valid. 138 + if let Err(error) = atrium_crypto::did::parse_did_key(key) { 139 + return Err(Error::with_status( 140 + StatusCode::BAD_REQUEST, 141 + anyhow::Error::new(error).context("provided recovery key is in invalid format"), 142 + )); 143 + } 144 + 145 + // Enroll the user-provided recovery key at a higher priority than our own. 146 + vec![key.clone(), rkey.did()] 147 + } else { 148 + vec![rkey.did()] 149 + }; 150 + 151 + // Begin a new transaction to actually create the user's profile. 152 + // Unless committed, the transaction will be automatically rolled back. 153 + let mut tx = db.begin().await.context("failed to begin transaction")?; 154 + 155 + // TODO: Make this its own toggle instead of tied to test mode 156 + if !config.test { 157 + let _invite = match input.invite_code { 158 + Some(ref code) => { 159 + let invite: Option<String> = sqlx::query_scalar!( 160 + r#" 161 + UPDATE invites 162 + SET count = count - 1 163 + WHERE id = ? 164 + AND count > 0 165 + RETURNING id 166 + "#, 167 + code 168 + ) 169 + .fetch_optional(&mut *tx) 170 + .await 171 + .context("failed to check invite code")?; 172 + 173 + invite.context("invalid invite code")? 174 + } 175 + None => { 176 + return Err(anyhow!("invite code required").into()); 177 + } 178 + }; 179 + } 180 + 181 + // Account can be created. Synthesize a new DID for the user. 182 + // https://github.com/did-method-plc/did-method-plc?tab=readme-ov-file#did-creation 183 + let op = plc::sign_op( 184 + &rkey, 185 + PlcOperation { 186 + typ: "plc_operation".to_owned(), 187 + rotation_keys: recovery_keys, 188 + verification_methods: HashMap::from([("atproto".to_owned(), skey.did())]), 189 + also_known_as: vec![format!("at://{}", input.handle.as_str())], 190 + services: HashMap::from([( 191 + "atproto_pds".to_owned(), 192 + PlcService::Pds { 193 + endpoint: format!("https://{}", config.host_name), 194 + }, 195 + )]), 196 + prev: None, 197 + }, 198 + ) 199 + .context("failed to sign genesis op")?; 200 + let op_bytes = serde_ipld_dagcbor::to_vec(&op).context("failed to encode genesis op")?; 201 + 202 + let did_hash = { 203 + let digest = base32::encode( 204 + base32::Alphabet::Rfc4648Lower { padding: false }, 205 + sha2::Sha256::digest(&op_bytes).as_slice(), 206 + ); 207 + if digest.len() < 24 { 208 + return Err(anyhow!("digest too short").into()); 209 + } 210 + #[expect(clippy::string_slice, reason = "digest length confirmed")] 211 + digest[..24].to_owned() 212 + }; 213 + let did = format!("did:plc:{did_hash}"); 214 + 215 + let doc = tokio::fs::File::create(config.plc.path.join(format!("{did_hash}.car"))) 216 + .await 217 + .context("failed to create did doc")?; 218 + 219 + let mut plc_doc = CarStore::create(doc) 220 + .await 221 + .context("failed to create did doc")?; 222 + 223 + let plc_cid = plc_doc 224 + .write_block(DAG_CBOR, SHA2_256, &op_bytes) 225 + .await 226 + .context("failed to write genesis commit")? 227 + .to_string(); 228 + 229 + if !config.test { 230 + // Send the new account's data to the PLC directory. 231 + plc::submit(&client, &did, &op) 232 + .await 233 + .context("failed to submit PLC operation to directory")?; 234 + } 235 + 236 + // Write out an initial commit for the user. 237 + // https://atproto.com/guides/account-lifecycle 238 + let (cid, rev, store) = async { 239 + let store = storage::create_storage_for_did(&config.repo, &did_hash) 240 + .await 241 + .context("failed to create storage")?; 242 + 243 + // Initialize the repository with the storage 244 + let repo_builder = Repository::create( 245 + store, 246 + Did::from_str(&did).expect("should be valid DID format"), 247 + ) 248 + .await 249 + .context("failed to initialize user repo")?; 250 + 251 + // Sign the root commit. 252 + let sig = skey 253 + .sign(&repo_builder.bytes()) 254 + .context("failed to sign root commit")?; 255 + let mut repo = repo_builder 256 + .finalize(sig) 257 + .await 258 + .context("failed to attach signature to root commit")?; 259 + 260 + let root = repo.root(); 261 + let rev = repo.commit().rev(); 262 + 263 + // Create a temporary CAR store for firehose events 264 + let mut mem = Vec::new(); 265 + let mut firehose_store = 266 + CarStore::create_with_roots(std::io::Cursor::new(&mut mem), [repo.root()]) 267 + .await 268 + .context("failed to create temp carstore")?; 269 + 270 + repo.export_into(&mut firehose_store) 271 + .await 272 + .context("failed to export repository")?; 273 + 274 + Ok::<(Cid, Tid, Vec<u8>), anyhow::Error>((root, rev, mem)) 275 + } 276 + .await 277 + .context("failed to create user repo")?; 278 + 279 + let cid_str = cid.to_string(); 280 + let rev_str = rev.as_str(); 281 + 282 + _ = sqlx::query!( 283 + r#" 284 + INSERT INTO accounts (did, email, password, root, plc_root, rev, created_at) 285 + VALUES (?, ?, ?, ?, ?, ?, datetime('now')); 286 + 287 + INSERT INTO handles (did, handle, created_at) 288 + VALUES (?, ?, datetime('now')); 289 + 290 + -- Cleanup stale invite codes 291 + DELETE FROM invites 292 + WHERE count <= 0; 293 + "#, 294 + did, 295 + email, 296 + pass, 297 + cid_str, 298 + plc_cid, 299 + rev_str, 300 + did, 301 + handle 302 + ) 303 + .execute(&mut *tx) 304 + .await 305 + .context("failed to create new account")?; 306 + 307 + // The account is fully created. Commit the SQL transaction to the database. 308 + tx.commit().await.context("failed to commit transaction")?; 309 + 310 + // Broadcast the identity event now that the new identity is resolvable on the public directory. 311 + fhp.identity( 312 + atrium_api::com::atproto::sync::subscribe_repos::IdentityData { 313 + did: Did::from_str(&did).expect("should be valid DID format"), 314 + handle: Some(Handle::new(handle).expect("should be valid handle")), 315 + seq: 0, // Filled by firehose later. 316 + time: Datetime::now(), 317 + }, 318 + ) 319 + .await; 320 + 321 + // The new account is now active on this PDS, so we can broadcast the account firehose event. 322 + fhp.account( 323 + atrium_api::com::atproto::sync::subscribe_repos::AccountData { 324 + active: true, 325 + did: Did::from_str(&did).expect("should be valid DID format"), 326 + seq: 0, // Filled by firehose later. 327 + status: None, // "takedown" / "suspended" / "deactivated" 328 + time: Datetime::now(), 329 + }, 330 + ) 331 + .await; 332 + 333 + let did = Did::from_str(&did).expect("should be valid DID format"); 334 + 335 + fhp.commit(Commit { 336 + car: store, 337 + ops: Vec::new(), 338 + cid, 339 + rev: rev.to_string(), 340 + did: did.clone(), 341 + pcid: None, 342 + blobs: Vec::new(), 343 + }) 344 + .await; 345 + 346 + // Finally, sign some authentication tokens for the new user. 347 + let token = auth::sign( 348 + &skey, 349 + "at+jwt", 350 + &serde_json::json!({ 351 + "scope": "com.atproto.access", 352 + "sub": did, 353 + "iat": chrono::Utc::now().timestamp(), 354 + "exp": chrono::Utc::now().checked_add_signed(chrono::Duration::hours(4)).context("should be valid time")?.timestamp(), 355 + "aud": format!("did:web:{}", config.host_name) 356 + }), 357 + ) 358 + .context("failed to sign jwt")?; 359 + 360 + let refresh_token = auth::sign( 361 + &skey, 362 + "refresh+jwt", 363 + &serde_json::json!({ 364 + "scope": "com.atproto.refresh", 365 + "sub": did, 366 + "iat": chrono::Utc::now().timestamp(), 367 + "exp": chrono::Utc::now().checked_add_days(chrono::Days::new(90)).context("should be valid time")?.timestamp(), 368 + "aud": format!("did:web:{}", config.host_name) 369 + }), 370 + ) 371 + .context("failed to sign refresh jwt")?; 372 + 373 + Ok(Json( 374 + server::create_account::OutputData { 375 + access_jwt: token, 376 + did, 377 + did_doc: None, 378 + handle: input.handle.clone(), 379 + refresh_jwt: refresh_token, 380 + } 381 + .into(), 382 + )) 383 + } 384 + 385 + /// Create an authentication session. 386 + /// - POST /xrpc/com.atproto.server.createSession 387 + /// ### Request Body 388 + /// - `identifier`: string - Handle or other identifier supported by the server for the authenticating user. 389 + /// - `password`: string - Password for the authenticating user. 390 + /// - `authFactorToken` - string (optional) 391 + /// - `allowTakedown` - boolean (optional) - When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned 392 + /// ### Responses 393 + /// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {},"email": "string","emailConfirmed": true,"emailAuthFactor": true,"active": true,"status": "takendown"} 394 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `AccountTakedown`, `AuthFactorTokenRequired`]} 395 + /// - 401 Unauthorized 396 + async fn create_session( 397 + State(db): State<Db>, 398 + State(skey): State<SigningKey>, 399 + State(config): State<AppConfig>, 400 + Json(input): Json<server::create_session::Input>, 401 + ) -> Result<Json<server::create_session::Output>> { 402 + let handle = &input.identifier; 403 + let password = &input.password; 404 + 405 + // TODO: `input.allow_takedown` 406 + // TODO: `input.auth_factor_token` 407 + 408 + let Some(account) = sqlx::query!( 409 + r#" 410 + WITH LatestHandles AS ( 411 + SELECT did, handle 412 + FROM handles 413 + WHERE (did, created_at) IN ( 414 + SELECT did, MAX(created_at) AS max_created_at 415 + FROM handles 416 + GROUP BY did 417 + ) 418 + ) 419 + SELECT a.did, a.password, h.handle 420 + FROM accounts a 421 + LEFT JOIN LatestHandles h ON a.did = h.did 422 + WHERE h.handle = ? 423 + "#, 424 + handle 425 + ) 426 + .fetch_optional(&db) 427 + .await 428 + .context("failed to authenticate")? 429 + else { 430 + counter!(AUTH_FAILED).increment(1); 431 + 432 + // SEC: Call argon2's `verify_password` to simulate password verification and discard the result. 433 + // We do this to avoid exposing a timing attack where attackers can measure the response time to 434 + // determine whether or not an account exists. 435 + _ = Argon2::default().verify_password( 436 + password.as_bytes(), 437 + &PasswordHash::new(DUMMY_PASSWORD).context("should be valid password hash")?, 438 + ); 439 + 440 + return Err(Error::with_status( 441 + StatusCode::UNAUTHORIZED, 442 + anyhow!("failed to validate credentials"), 443 + )); 444 + }; 445 + 446 + match Argon2::default().verify_password( 447 + password.as_bytes(), 448 + &PasswordHash::new(account.password.as_str()).context("invalid password hash in db")?, 449 + ) { 450 + Ok(()) => {} 451 + Err(_e) => { 452 + counter!(AUTH_FAILED).increment(1); 453 + 454 + return Err(Error::with_status( 455 + StatusCode::UNAUTHORIZED, 456 + anyhow!("failed to validate credentials"), 457 + )); 458 + } 459 + } 460 + 461 + let did = account.did; 462 + 463 + let token = auth::sign( 464 + &skey, 465 + "at+jwt", 466 + &serde_json::json!({ 467 + "scope": "com.atproto.access", 468 + "sub": did, 469 + "iat": chrono::Utc::now().timestamp(), 470 + "exp": chrono::Utc::now().checked_add_signed(chrono::Duration::hours(4)).context("should be valid time")?.timestamp(), 471 + "aud": format!("did:web:{}", config.host_name) 472 + }), 473 + ) 474 + .context("failed to sign jwt")?; 475 + 476 + let refresh_token = auth::sign( 477 + &skey, 478 + "refresh+jwt", 479 + &serde_json::json!({ 480 + "scope": "com.atproto.refresh", 481 + "sub": did, 482 + "iat": chrono::Utc::now().timestamp(), 483 + "exp": chrono::Utc::now().checked_add_days(chrono::Days::new(90)).context("should be valid time")?.timestamp(), 484 + "aud": format!("did:web:{}", config.host_name) 485 + }), 486 + ) 487 + .context("failed to sign refresh jwt")?; 488 + 489 + Ok(Json( 490 + server::create_session::OutputData { 491 + access_jwt: token, 492 + refresh_jwt: refresh_token, 493 + 494 + active: Some(true), 495 + did: Did::from_str(&did).expect("should be valid DID format"), 496 + did_doc: None, 497 + email: None, 498 + email_auth_factor: None, 499 + email_confirmed: None, 500 + handle: Handle::new(account.handle).expect("should be valid handle"), 501 + status: None, 502 + } 503 + .into(), 504 + )) 505 + } 506 + 507 + /// Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt'). 508 + /// - POST /xrpc/com.atproto.server.refreshSession 509 + /// ### Responses 510 + /// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {},"active": true,"status": "takendown"} 511 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `AccountTakedown`]} 512 + /// - 401 Unauthorized 513 + async fn refresh_session( 514 + State(db): State<Db>, 515 + State(skey): State<SigningKey>, 516 + State(config): State<AppConfig>, 517 + req: Request, 518 + ) -> Result<Json<server::refresh_session::Output>> { 519 + // TODO: store hashes of refresh tokens and enforce single-use 520 + let auth_token = req 521 + .headers() 522 + .get(axum::http::header::AUTHORIZATION) 523 + .context("no authorization header provided")? 524 + .to_str() 525 + .ok() 526 + .and_then(|auth| auth.strip_prefix("Bearer ")) 527 + .context("invalid authentication token")?; 528 + 529 + let (typ, claims) = 530 + auth::verify(&skey.did(), auth_token).context("failed to verify refresh token")?; 531 + if typ != "refresh+jwt" { 532 + return Err(Error::with_status( 533 + StatusCode::UNAUTHORIZED, 534 + anyhow!("invalid refresh token"), 535 + )); 536 + } 537 + if claims 538 + .get("exp") 539 + .and_then(serde_json::Value::as_i64) 540 + .context("failed to get `exp`")? 541 + < chrono::Utc::now().timestamp() 542 + { 543 + return Err(Error::with_status( 544 + StatusCode::UNAUTHORIZED, 545 + anyhow!("refresh token expired"), 546 + )); 547 + } 548 + if claims 549 + .get("aud") 550 + .and_then(|audience| audience.as_str()) 551 + .context("invalid jwt")? 552 + != format!("did:web:{}", config.host_name) 553 + { 554 + return Err(Error::with_status( 555 + StatusCode::UNAUTHORIZED, 556 + anyhow!("invalid audience"), 557 + )); 558 + } 559 + 560 + let did = claims 561 + .get("sub") 562 + .and_then(|subject| subject.as_str()) 563 + .context("invalid jwt")?; 564 + 565 + let user = sqlx::query!( 566 + r#" 567 + SELECT a.status, h.handle 568 + FROM accounts a 569 + JOIN handles h ON a.did = h.did 570 + WHERE a.did = ? 571 + ORDER BY h.created_at ASC 572 + LIMIT 1 573 + "#, 574 + did 575 + ) 576 + .fetch_one(&db) 577 + .await 578 + .context("failed to fetch user account")?; 579 + 580 + let token = auth::sign( 581 + &skey, 582 + "at+jwt", 583 + &serde_json::json!({ 584 + "scope": "com.atproto.access", 585 + "sub": did, 586 + "iat": chrono::Utc::now().timestamp(), 587 + "exp": chrono::Utc::now().checked_add_signed(chrono::Duration::hours(4)).context("should be valid time")?.timestamp(), 588 + "aud": format!("did:web:{}", config.host_name) 589 + }), 590 + ) 591 + .context("failed to sign jwt")?; 592 + 593 + let refresh_token = auth::sign( 594 + &skey, 595 + "refresh+jwt", 596 + &serde_json::json!({ 597 + "scope": "com.atproto.refresh", 598 + "sub": did, 599 + "iat": chrono::Utc::now().timestamp(), 600 + "exp": chrono::Utc::now().checked_add_days(chrono::Days::new(90)).context("should be valid time")?.timestamp(), 601 + "aud": format!("did:web:{}", config.host_name) 602 + }), 603 + ) 604 + .context("failed to sign refresh jwt")?; 605 + 606 + let active = user.status == "active"; 607 + let status = if active { None } else { Some(user.status) }; 608 + 609 + Ok(Json( 610 + server::refresh_session::OutputData { 611 + access_jwt: token, 612 + refresh_jwt: refresh_token, 613 + 614 + active: Some(active), // TODO? 615 + did: Did::new(did.to_owned()).expect("should be valid DID format"), 616 + did_doc: None, 617 + handle: Handle::new(user.handle).expect("should be valid handle"), 618 + status, 619 + } 620 + .into(), 621 + )) 622 + } 623 + 624 + /// Get a signed token on behalf of the requesting DID for the requested service. 625 + /// - GET /xrpc/com.atproto.server.getServiceAuth 626 + /// ### Request Query Parameters 627 + /// - `aud`: string - The DID of the service that the token will be used to authenticate with 628 + /// - `exp`: integer (optional) - The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope. 629 + /// - `lxm`: string (optional) - Lexicon (XRPC) method to bind the requested token to 630 + /// ### Responses 631 + /// - 200 OK: {token: string} 632 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `BadExpiration`]} 633 + /// - 401 Unauthorized 634 + async fn get_service_auth( 635 + user: AuthenticatedUser, 636 + State(skey): State<SigningKey>, 637 + Query(input): Query<server::get_service_auth::ParametersData>, 638 + ) -> Result<Json<server::get_service_auth::Output>> { 639 + let user_did = user.did(); 640 + let aud = input.aud.as_str(); 641 + 642 + let exp = (chrono::Utc::now().checked_add_signed(chrono::Duration::minutes(1))) 643 + .context("should be valid expiration datetime")? 644 + .timestamp(); 645 + let jti = rand::thread_rng() 646 + .sample_iter(rand::distributions::Alphanumeric) 647 + .take(10) 648 + .map(char::from) 649 + .collect::<String>(); 650 + 651 + let mut claims = serde_json::json!({ 652 + "iss": user_did.as_str(), 653 + "aud": aud, 654 + "exp": exp, 655 + "jti": jti, 656 + }); 657 + 658 + if let Some(ref lxm) = input.lxm { 659 + claims = claims 660 + .as_object_mut() 661 + .context("should be a valid object")? 662 + .insert("lxm".to_owned(), serde_json::Value::String(lxm.to_string())) 663 + .context("should be able to insert lxm into claims")?; 664 + } 665 + 666 + // Mint a bearer token by signing a JSON web token. 667 + let token = auth::sign(&skey, "JWT", &claims).context("failed to sign jwt")?; 668 + 669 + Ok(Json(server::get_service_auth::OutputData { token }.into())) 670 + } 671 + 672 + /// Get information about the current auth session. Requires auth. 673 + /// - GET /xrpc/com.atproto.server.getSession 674 + /// ### Responses 675 + /// - 200 OK: {"handle": "string","did": "string","email": "string","emailConfirmed": true,"emailAuthFactor": true,"didDoc": {},"active": true,"status": "takendown"} 676 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 677 + /// - 401 Unauthorized 678 + async fn get_session( 679 + user: AuthenticatedUser, 680 + State(db): State<Db>, 681 + ) -> Result<Json<server::get_session::Output>> { 682 + let did = user.did(); 683 + #[expect(clippy::shadow_unrelated, reason = "is related")] 684 + if let Some(user) = sqlx::query!( 685 + r#" 686 + SELECT a.email, a.status, ( 687 + SELECT h.handle 688 + FROM handles h 689 + WHERE h.did = a.did 690 + ORDER BY h.created_at ASC 691 + LIMIT 1 692 + ) AS handle 693 + FROM accounts a 694 + WHERE a.did = ? 695 + "#, 696 + did 697 + ) 698 + .fetch_optional(&db) 699 + .await 700 + .context("failed to fetch session")? 701 + { 702 + let active = user.status == "active"; 703 + let status = if active { None } else { Some(user.status) }; 704 + 705 + Ok(Json( 706 + server::get_session::OutputData { 707 + active: Some(active), 708 + did: Did::from_str(&did).expect("should be valid DID format"), 709 + did_doc: None, 710 + email: Some(user.email), 711 + email_auth_factor: None, 712 + email_confirmed: None, 713 + handle: Handle::new(user.handle).expect("should be valid handle"), 714 + status, 715 + } 716 + .into(), 717 + )) 718 + } else { 719 + Err(Error::with_status( 720 + StatusCode::UNAUTHORIZED, 721 + anyhow!("user not found"), 722 + )) 723 + } 724 + } 725 + 726 + /// Describes the server's account creation requirements and capabilities. Implemented by PDS. 727 + /// - GET /xrpc/com.atproto.server.describeServer 728 + /// ### Responses 729 + /// - 200 OK: {"inviteCodeRequired": true,"phoneVerificationRequired": true,"availableUserDomains": [`string`],"links": {"privacyPolicy": "string","termsOfService": "string"},"contact": {"email": "string"},"did": "string"} 730 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 731 + /// - 401 Unauthorized 732 + async fn describe_server( 733 + State(config): State<AppConfig>, 734 + ) -> Result<Json<server::describe_server::Output>> { 735 + Ok(Json( 736 + server::describe_server::OutputData { 737 + available_user_domains: vec![], 738 + contact: None, 739 + did: Did::from_str(&format!("did:web:{}", config.host_name)) 740 + .expect("should be valid DID format"), 741 + invite_code_required: Some(true), 742 + links: None, 743 + phone_verification_required: Some(false), // email verification 744 + } 745 + .into(), 746 + )) 747 + } 748 + 749 + async fn todo() -> Result<()> { 750 + Err(Error::unimplemented(anyhow!("not implemented"))) 751 + } 752 + 753 + #[rustfmt::skip] 754 + /// These endpoints are part of the atproto PDS server and account management APIs. \ 755 + /// Requests often require authentication and are made directly to the user's own PDS instance. 756 + /// ### Routes 757 + /// - `POST /xrpc/com.atproto.server.createAccount` -> [`create_account`] 758 + /// - `POST /xrpc/com.atproto.server.createInviteCode` -> [`create_invite_code`] 759 + /// - `POST /xrpc/com.atproto.server.createSession` -> [`create_session`] 760 + /// - `GET /xrpc/com.atproto.server.describeServer` -> [`describe_server`] 761 + /// - `GET /xrpc/com.atproto.server.getServiceAuth` -> [`get_service_auth`] 762 + /// - `GET /xrpc/com.atproto.server.getSession` -> [`get_session`] 763 + /// - `POST /xrpc/com.atproto.server.refreshSession` -> [`refresh_session`] 764 + pub(super) fn routes() -> Router<AppState> { 765 + Router::new() 766 + .route(concat!("/", server::activate_account::NSID), post(todo)) 767 + .route(concat!("/", server::check_account_status::NSID), post(todo)) 768 + .route(concat!("/", server::confirm_email::NSID), post(todo)) 769 + .route(concat!("/", server::create_account::NSID), post(create_account)) 770 + .route(concat!("/", server::create_app_password::NSID), post(todo)) 771 + .route(concat!("/", server::create_invite_code::NSID), post(create_invite_code)) 772 + .route(concat!("/", server::create_invite_codes::NSID), post(todo)) 773 + .route(concat!("/", server::create_session::NSID), post(create_session)) 774 + .route(concat!("/", server::deactivate_account::NSID), post(todo)) 775 + .route(concat!("/", server::delete_account::NSID), post(todo)) 776 + .route(concat!("/", server::delete_session::NSID), post(todo)) 777 + .route(concat!("/", server::describe_server::NSID), get(describe_server)) 778 + .route(concat!("/", server::get_account_invite_codes::NSID), post(todo)) 779 + .route(concat!("/", server::get_service_auth::NSID), get(get_service_auth)) 780 + .route(concat!("/", server::get_session::NSID), get(get_session)) 781 + .route(concat!("/", server::list_app_passwords::NSID), post(todo)) 782 + .route(concat!("/", server::refresh_session::NSID), post(refresh_session)) 783 + .route(concat!("/", server::request_account_delete::NSID), post(todo)) 784 + .route(concat!("/", server::request_email_confirmation::NSID), post(todo)) 785 + .route(concat!("/", server::request_email_update::NSID), post(todo)) 786 + .route(concat!("/", server::request_password_reset::NSID), post(todo)) 787 + .route(concat!("/", server::reserve_signing_key::NSID), post(todo)) 788 + .route(concat!("/", server::reset_password::NSID), post(todo)) 789 + .route(concat!("/", server::revoke_app_password::NSID), post(todo)) 790 + .route(concat!("/", server::update_email::NSID), post(todo)) 791 + }
+428
src/apis/com/atproto/sync/sync.rs
··· 1 + //! Endpoints for the `ATProto` sync API. (/xrpc/com.atproto.sync.*) 2 + use std::str::FromStr as _; 3 + 4 + use anyhow::{Context as _, anyhow}; 5 + use atrium_api::{ 6 + com::atproto::sync, 7 + types::{LimitedNonZeroU16, string::Did}, 8 + }; 9 + use atrium_repo::{ 10 + Cid, 11 + blockstore::{ 12 + AsyncBlockStoreRead as _, AsyncBlockStoreWrite as _, CarStore, DAG_CBOR, SHA2_256, 13 + }, 14 + }; 15 + use axum::{ 16 + Json, Router, 17 + body::Body, 18 + extract::{Query, State, WebSocketUpgrade}, 19 + http::{self, Response, StatusCode}, 20 + response::IntoResponse, 21 + routing::get, 22 + }; 23 + use constcat::concat; 24 + use futures::stream::TryStreamExt as _; 25 + use tokio_util::io::ReaderStream; 26 + 27 + use crate::{ 28 + AppState, Db, Error, Result, 29 + config::AppConfig, 30 + firehose::FirehoseProducer, 31 + storage::{open_repo_db, open_store}, 32 + }; 33 + 34 + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 35 + #[serde(rename_all = "camelCase")] 36 + /// Parameters for `/xrpc/com.atproto.sync.listBlobs` \ 37 + /// HACK: `limit` may be passed as a string, so we must treat it as one. 38 + pub(super) struct ListBlobsParameters { 39 + #[serde(skip_serializing_if = "core::option::Option::is_none")] 40 + /// Optional cursor to paginate through blobs. 41 + pub cursor: Option<String>, 42 + ///The DID of the repo. 43 + pub did: Did, 44 + #[serde(skip_serializing_if = "core::option::Option::is_none")] 45 + /// Optional limit of blobs to return. 46 + pub limit: Option<String>, 47 + ///Optional revision of the repo to list blobs since. 48 + #[serde(skip_serializing_if = "core::option::Option::is_none")] 49 + pub since: Option<String>, 50 + } 51 + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 52 + #[serde(rename_all = "camelCase")] 53 + /// Parameters for `/xrpc/com.atproto.sync.listRepos` \ 54 + /// HACK: `limit` may be passed as a string, so we must treat it as one. 55 + pub(super) struct ListReposParameters { 56 + #[serde(skip_serializing_if = "core::option::Option::is_none")] 57 + /// Optional cursor to paginate through repos. 58 + pub cursor: Option<String>, 59 + #[serde(skip_serializing_if = "core::option::Option::is_none")] 60 + /// Optional limit of repos to return. 61 + pub limit: Option<String>, 62 + } 63 + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 64 + #[serde(rename_all = "camelCase")] 65 + /// Parameters for `/xrpc/com.atproto.sync.subscribeRepos` \ 66 + /// HACK: `cursor` may be passed as a string, so we must treat it as one. 67 + pub(super) struct SubscribeReposParametersData { 68 + ///The last known event seq number to backfill from. 69 + #[serde(skip_serializing_if = "core::option::Option::is_none")] 70 + pub cursor: Option<String>, 71 + } 72 + 73 + async fn get_blob( 74 + State(config): State<AppConfig>, 75 + Query(input): Query<sync::get_blob::ParametersData>, 76 + ) -> Result<Response<Body>> { 77 + let blob = config 78 + .blob 79 + .path 80 + .join(format!("{}.blob", input.cid.as_ref())); 81 + 82 + let f = tokio::fs::File::open(blob) 83 + .await 84 + .context("blob not found")?; 85 + let len = f 86 + .metadata() 87 + .await 88 + .context("failed to query file metadata")? 89 + .len(); 90 + 91 + let s = ReaderStream::new(f); 92 + 93 + Ok(Response::builder() 94 + .header(http::header::CONTENT_LENGTH, format!("{len}")) 95 + .body(Body::from_stream(s)) 96 + .context("failed to construct response")?) 97 + } 98 + 99 + /// Enumerates which accounts the requesting account is currently blocking. Requires auth. 100 + /// - GET /xrpc/com.atproto.sync.getBlocks 101 + /// ### Query Parameters 102 + /// - `limit`: integer, optional, default: 50, >=1 and <=100 103 + /// - `cursor`: string, optional 104 + /// ### Responses 105 + /// - 200 OK: ... 106 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 107 + /// - 401 Unauthorized 108 + async fn get_blocks( 109 + State(config): State<AppConfig>, 110 + Query(input): Query<sync::get_blocks::ParametersData>, 111 + ) -> Result<Response<Body>> { 112 + let mut repo = open_store(&config.repo, input.did.as_str()) 113 + .await 114 + .context("failed to open repository")?; 115 + 116 + let mut mem = Vec::new(); 117 + let mut store = CarStore::create(std::io::Cursor::new(&mut mem)) 118 + .await 119 + .context("failed to create intermediate carstore")?; 120 + 121 + for cid in &input.cids { 122 + // SEC: This can potentially fetch stale blocks from a repository (e.g. those that were deleted). 123 + // We'll want to prevent accesses to stale blocks eventually just to respect a user's right to be forgotten. 124 + _ = store 125 + .write_block( 126 + DAG_CBOR, 127 + SHA2_256, 128 + &repo 129 + .read_block(*cid.as_ref()) 130 + .await 131 + .context("failed to read block")?, 132 + ) 133 + .await 134 + .context("failed to write block")?; 135 + } 136 + 137 + Ok(Response::builder() 138 + .header(http::header::CONTENT_TYPE, "application/vnd.ipld.car") 139 + .body(Body::from(mem)) 140 + .context("failed to construct response")?) 141 + } 142 + 143 + /// Get the current commit CID & revision of the specified repo. Does not require auth. 144 + /// ### Query Parameters 145 + /// - `did`: The DID of the repo. 146 + /// ### Responses 147 + /// - 200 OK: {"cid": "string","rev": "string"} 148 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoTakendown`, `RepoSuspended`, `RepoDeactivated`]} 149 + async fn get_latest_commit( 150 + State(config): State<AppConfig>, 151 + State(db): State<Db>, 152 + Query(input): Query<sync::get_latest_commit::ParametersData>, 153 + ) -> Result<Json<sync::get_latest_commit::Output>> { 154 + let repo = open_repo_db(&config.repo, &db, input.did.as_str()) 155 + .await 156 + .context("failed to open repository")?; 157 + 158 + let cid = repo.root(); 159 + let commit = repo.commit(); 160 + 161 + Ok(Json( 162 + sync::get_latest_commit::OutputData { 163 + cid: atrium_api::types::string::Cid::new(cid), 164 + rev: commit.rev(), 165 + } 166 + .into(), 167 + )) 168 + } 169 + 170 + /// Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth. 171 + /// ### Query Parameters 172 + /// - `did`: The DID of the repo. 173 + /// - `collection`: nsid 174 + /// - `rkey`: record-key 175 + /// ### Responses 176 + /// - 200 OK: ... 177 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RecordNotFound`, `RepoNotFound`, `RepoTakendown`, 178 + /// `RepoSuspended`, `RepoDeactivated`]} 179 + async fn get_record( 180 + State(config): State<AppConfig>, 181 + State(db): State<Db>, 182 + Query(input): Query<sync::get_record::ParametersData>, 183 + ) -> Result<Response<Body>> { 184 + let mut repo = open_repo_db(&config.repo, &db, input.did.as_str()) 185 + .await 186 + .context("failed to open repo")?; 187 + 188 + let key = format!("{}/{}", input.collection.as_str(), input.rkey.as_str()); 189 + 190 + let mut contents = Vec::new(); 191 + let mut ret_store = 192 + CarStore::create_with_roots(std::io::Cursor::new(&mut contents), [repo.root()]) 193 + .await 194 + .context("failed to create car store")?; 195 + 196 + repo.extract_raw_into(&key, &mut ret_store) 197 + .await 198 + .context("failed to extract records")?; 199 + 200 + Ok(Response::builder() 201 + .header(http::header::CONTENT_TYPE, "application/vnd.ipld.car") 202 + .body(Body::from(contents)) 203 + .context("failed to construct response")?) 204 + } 205 + 206 + /// Get the hosting status for a repository, on this server. Expected to be implemented by PDS and Relay. 207 + /// ### Query Parameters 208 + /// - `did`: The DID of the repo. 209 + /// ### Responses 210 + /// - 200 OK: {"did": "string","active": true,"status": "takendown","rev": "string"} 211 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`]} 212 + async fn get_repo_status( 213 + State(db): State<Db>, 214 + Query(input): Query<sync::get_repo::ParametersData>, 215 + ) -> Result<Json<sync::get_repo_status::Output>> { 216 + let did = input.did.as_str(); 217 + let r = sqlx::query!(r#"SELECT rev, status FROM accounts WHERE did = ?"#, did) 218 + .fetch_optional(&db) 219 + .await 220 + .context("failed to execute query")?; 221 + 222 + let Some(r) = r else { 223 + return Err(Error::with_status( 224 + StatusCode::NOT_FOUND, 225 + anyhow!("account not found"), 226 + )); 227 + }; 228 + 229 + let active = r.status == "active"; 230 + let status = if active { None } else { Some(r.status) }; 231 + 232 + Ok(Json( 233 + sync::get_repo_status::OutputData { 234 + active, 235 + status, 236 + did: input.did.clone(), 237 + rev: Some( 238 + atrium_api::types::string::Tid::new(r.rev).expect("should be able to convert Tid"), 239 + ), 240 + } 241 + .into(), 242 + )) 243 + } 244 + 245 + /// Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. 246 + /// Does not require auth; implemented by PDS. 247 + /// ### Query Parameters 248 + /// - `did`: The DID of the repo. 249 + /// - `since`: The revision ('rev') of the repo to create a diff from. 250 + /// ### Responses 251 + /// - 200 OK: ... 252 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`, 253 + /// `RepoTakendown`, `RepoSuspended`, `RepoDeactivated`]} 254 + async fn get_repo( 255 + State(config): State<AppConfig>, 256 + State(db): State<Db>, 257 + Query(input): Query<sync::get_repo::ParametersData>, 258 + ) -> Result<Response<Body>> { 259 + let mut repo = open_repo_db(&config.repo, &db, input.did.as_str()) 260 + .await 261 + .context("failed to open repo")?; 262 + 263 + let mut contents = Vec::new(); 264 + let mut store = CarStore::create_with_roots(std::io::Cursor::new(&mut contents), [repo.root()]) 265 + .await 266 + .context("failed to create car store")?; 267 + 268 + repo.export_into(&mut store) 269 + .await 270 + .context("failed to extract records")?; 271 + 272 + Ok(Response::builder() 273 + .header(http::header::CONTENT_TYPE, "application/vnd.ipld.car") 274 + .body(Body::from(contents)) 275 + .context("failed to construct response")?) 276 + } 277 + 278 + /// List blob CIDs for an account, since some repo revision. Does not require auth; implemented by PDS. 279 + /// ### Query Parameters 280 + /// - `did`: The DID of the repo. Required. 281 + /// - `since`: Optional revision of the repo to list blobs since. 282 + /// - `limit`: >= 1 and <= 1000, default 500 283 + /// - `cursor`: string 284 + /// ### Responses 285 + /// - 200 OK: {"cursor": "string","cids": [string]} 286 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`, `RepoTakendown`, 287 + /// `RepoSuspended`, `RepoDeactivated`]} 288 + async fn list_blobs( 289 + State(db): State<Db>, 290 + Query(input): Query<sync::list_blobs::ParametersData>, 291 + ) -> Result<Json<sync::list_blobs::Output>> { 292 + let did_str = input.did.as_str(); 293 + 294 + // TODO: `input.since` 295 + // TODO: `input.limit` 296 + // TODO: `input.cursor` 297 + 298 + let cids = sqlx::query_scalar!(r#"SELECT cid FROM blob_ref WHERE did = ?"#, did_str) 299 + .fetch_all(&db) 300 + .await 301 + .context("failed to query blobs")?; 302 + 303 + let cids = cids 304 + .into_iter() 305 + .map(|c| { 306 + Cid::from_str(&c) 307 + .map(atrium_api::types::string::Cid::new) 308 + .map_err(anyhow::Error::new) 309 + }) 310 + .collect::<anyhow::Result<Vec<_>>>() 311 + .context("failed to convert cids")?; 312 + 313 + Ok(Json( 314 + sync::list_blobs::OutputData { cursor: None, cids }.into(), 315 + )) 316 + } 317 + 318 + /// Enumerates all the DID, rev, and commit CID for all repos hosted by this service. 319 + /// Does not require auth; implemented by PDS and Relay. 320 + /// ### Query Parameters 321 + /// - `limit`: >= 1 and <= 1000, default 500 322 + /// - `cursor`: string 323 + /// ### Responses 324 + /// - 200 OK: {"cursor": "string","repos": [{"did": "string","head": "string","rev": "string","active": true,"status": "takendown"}]} 325 + /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 326 + async fn list_repos( 327 + State(db): State<Db>, 328 + Query(input): Query<sync::list_repos::ParametersData>, 329 + ) -> Result<Json<sync::list_repos::Output>> { 330 + struct Record { 331 + /// The DID of the repo. 332 + did: String, 333 + /// The commit CID of the repo. 334 + rev: String, 335 + /// The root CID of the repo. 336 + root: String, 337 + } 338 + 339 + let limit: u16 = input.limit.unwrap_or(LimitedNonZeroU16::MAX).into(); 340 + 341 + let r = if let Some(ref cursor) = input.cursor { 342 + let r = sqlx::query_as!( 343 + Record, 344 + r#"SELECT did, root, rev FROM accounts WHERE did > ? LIMIT ?"#, 345 + cursor, 346 + limit 347 + ) 348 + .fetch(&db); 349 + 350 + r.try_collect::<Vec<_>>() 351 + .await 352 + .context("failed to fetch profiles")? 353 + } else { 354 + let r = sqlx::query_as!( 355 + Record, 356 + r#"SELECT did, root, rev FROM accounts LIMIT ?"#, 357 + limit 358 + ) 359 + .fetch(&db); 360 + 361 + r.try_collect::<Vec<_>>() 362 + .await 363 + .context("failed to fetch profiles")? 364 + }; 365 + 366 + let cursor = r.last().map(|r| r.did.clone()); 367 + let repos = r 368 + .into_iter() 369 + .map(|r| { 370 + sync::list_repos::RepoData { 371 + active: Some(true), 372 + did: Did::new(r.did).expect("should be a valid DID"), 373 + head: atrium_api::types::string::Cid::new( 374 + Cid::from_str(&r.root).expect("should be a valid CID"), 375 + ), 376 + rev: atrium_api::types::string::Tid::new(r.rev) 377 + .expect("should be able to convert Tid"), 378 + status: None, 379 + } 380 + .into() 381 + }) 382 + .collect::<Vec<_>>(); 383 + 384 + Ok(Json(sync::list_repos::OutputData { cursor, repos }.into())) 385 + } 386 + 387 + /// Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events, 388 + /// for all repositories on the current server. See the atproto specifications for details around stream sequencing, 389 + /// repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay. 390 + /// ### Query Parameters 391 + /// - `cursor`: The last known event seq number to backfill from. 392 + /// ### Responses 393 + /// - 200 OK: ... 394 + async fn subscribe_repos( 395 + ws_up: WebSocketUpgrade, 396 + State(fh): State<FirehoseProducer>, 397 + Query(input): Query<sync::subscribe_repos::ParametersData>, 398 + ) -> impl IntoResponse { 399 + ws_up.on_upgrade(async move |ws| { 400 + fh.client_connection(ws, input.cursor).await; 401 + }) 402 + } 403 + 404 + #[rustfmt::skip] 405 + /// These endpoints are part of the atproto repository synchronization APIs. Requests usually do not require authentication, 406 + /// and can be made to PDS intances or Relay instances. 407 + /// ### Routes 408 + /// - `GET /xrpc/com.atproto.sync.getBlob` -> [`get_blob`] 409 + /// - `GET /xrpc/com.atproto.sync.getBlocks` -> [`get_blocks`] 410 + /// - `GET /xrpc/com.atproto.sync.getLatestCommit` -> [`get_latest_commit`] 411 + /// - `GET /xrpc/com.atproto.sync.getRecord` -> [`get_record`] 412 + /// - `GET /xrpc/com.atproto.sync.getRepoStatus` -> [`get_repo_status`] 413 + /// - `GET /xrpc/com.atproto.sync.getRepo` -> [`get_repo`] 414 + /// - `GET /xrpc/com.atproto.sync.listBlobs` -> [`list_blobs`] 415 + /// - `GET /xrpc/com.atproto.sync.listRepos` -> [`list_repos`] 416 + /// - `GET /xrpc/com.atproto.sync.subscribeRepos` -> [`subscribe_repos`] 417 + pub(super) fn routes() -> Router<AppState> { 418 + Router::new() 419 + .route(concat!("/", sync::get_blob::NSID), get(get_blob)) 420 + .route(concat!("/", sync::get_blocks::NSID), get(get_blocks)) 421 + .route(concat!("/", sync::get_latest_commit::NSID), get(get_latest_commit)) 422 + .route(concat!("/", sync::get_record::NSID), get(get_record)) 423 + .route(concat!("/", sync::get_repo_status::NSID), get(get_repo_status)) 424 + .route(concat!("/", sync::get_repo::NSID), get(get_repo)) 425 + .route(concat!("/", sync::list_blobs::NSID), get(list_blobs)) 426 + .route(concat!("/", sync::list_repos::NSID), get(list_repos)) 427 + .route(concat!("/", sync::subscribe_repos::NSID), get(subscribe_repos)) 428 + }
+1
src/apis/com/mod.rs
··· 1 + pub mod atproto;
+27
src/apis/mod.rs
··· 1 + //! Root module for all endpoints. 2 + // mod identity; 3 + mod com; 4 + // mod server; 5 + // mod sync; 6 + 7 + use axum::{Json, Router, routing::get}; 8 + use serde_json::json; 9 + 10 + use crate::serve::{AppState, Result}; 11 + 12 + /// Health check endpoint. Returns name and version of the service. 13 + pub(crate) async fn health() -> Result<Json<serde_json::Value>> { 14 + Ok(Json(json!({ 15 + "version": concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")), 16 + }))) 17 + } 18 + 19 + /// Register all root routes. 20 + pub(crate) fn routes() -> Router<AppState> { 21 + Router::new() 22 + .route("/_health", get(health)) 23 + // .merge(identity::routes()) // com.atproto.identity 24 + .merge(com::atproto::repo::routes()) // com.atproto.repo 25 + // .merge(server::routes()) // com.atproto.server 26 + // .merge(sync::routes()) // com.atproto.sync 27 + }
+4 -1
src/auth.rs
··· 8 8 use diesel::prelude::*; 9 9 use sha2::{Digest as _, Sha256}; 10 10 11 - use crate::{AppState, Error, error::ErrorMessage}; 11 + use crate::{ 12 + error::{Error, ErrorMessage}, 13 + serve::AppState, 14 + }; 12 15 13 16 /// Request extractor for authenticated users. 14 17 /// If specified in an API endpoint, this guarantees the API can only be called
+1 -1
src/did.rs
··· 5 5 use serde::{Deserialize, Serialize}; 6 6 use url::Url; 7 7 8 - use crate::Client; 8 + use crate::serve::Client; 9 9 10 10 /// URL whitelist for DID document resolution. 11 11 const ALLOWED_URLS: &[&str] = &["bsky.app", "bsky.chat"];
-245
src/endpoints/identity.rs
··· 1 - //! Identity endpoints (/xrpc/com.atproto.identity.*) 2 - use std::collections::HashMap; 3 - 4 - use anyhow::{Context as _, anyhow}; 5 - use atrium_api::{ 6 - com::atproto::identity, 7 - types::string::{Datetime, Handle}, 8 - }; 9 - use atrium_crypto::keypair::Did as _; 10 - use atrium_repo::blockstore::{AsyncBlockStoreWrite as _, CarStore, DAG_CBOR, SHA2_256}; 11 - use axum::{ 12 - Json, Router, 13 - extract::{Query, State}, 14 - http::StatusCode, 15 - routing::{get, post}, 16 - }; 17 - use constcat::concat; 18 - 19 - use crate::{ 20 - AppState, Client, Db, Error, Result, RotationKey, SigningKey, 21 - auth::AuthenticatedUser, 22 - config::AppConfig, 23 - did, 24 - firehose::FirehoseProducer, 25 - plc::{self, PlcOperation, PlcService}, 26 - }; 27 - 28 - /// (GET) Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document. 29 - /// ### Query Parameters 30 - /// - handle: The handle to resolve. 31 - /// ### Responses 32 - /// - 200 OK: {did: did} 33 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `HandleNotFound`]} 34 - /// - 401 Unauthorized 35 - async fn resolve_handle( 36 - State(db): State<Db>, 37 - State(client): State<Client>, 38 - Query(input): Query<identity::resolve_handle::ParametersData>, 39 - ) -> Result<Json<identity::resolve_handle::Output>> { 40 - let handle = input.handle.as_str(); 41 - if let Ok(did) = sqlx::query_scalar!(r#"SELECT did FROM handles WHERE handle = ?"#, handle) 42 - .fetch_one(&db) 43 - .await 44 - { 45 - return Ok(Json( 46 - identity::resolve_handle::OutputData { 47 - did: atrium_api::types::string::Did::new(did).expect("should be valid DID format"), 48 - } 49 - .into(), 50 - )); 51 - } 52 - 53 - // HACK: Query bsky to see if they have this handle cached. 54 - let response = client 55 - .get(format!( 56 - "https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={handle}" 57 - )) 58 - .send() 59 - .await 60 - .context("failed to query upstream server")? 61 - .json() 62 - .await 63 - .context("failed to decode response as JSON")?; 64 - 65 - Ok(Json(response)) 66 - } 67 - 68 - #[expect(unused_variables, clippy::todo, reason = "Not yet implemented")] 69 - /// Request an email with a code to in order to request a signed PLC operation. Requires Auth. 70 - /// - POST /xrpc/com.atproto.identity.requestPlcOperationSignature 71 - /// ### Responses 72 - /// - 200 OK 73 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 74 - /// - 401 Unauthorized 75 - async fn request_plc_operation_signature(user: AuthenticatedUser) -> Result<()> { 76 - todo!() 77 - } 78 - 79 - #[expect(unused_variables, clippy::todo, reason = "Not yet implemented")] 80 - /// Signs a PLC operation to update some value(s) in the requesting DID's document. 81 - /// - POST /xrpc/com.atproto.identity.signPlcOperation 82 - /// ### Request Body 83 - /// - token: string // A token received through com.atproto.identity.requestPlcOperationSignature 84 - /// - rotationKeys: string[] 85 - /// - alsoKnownAs: string[] 86 - /// - verificationMethods: services 87 - /// ### Responses 88 - /// - 200 OK: {operation: string} 89 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 90 - /// - 401 Unauthorized 91 - async fn sign_plc_operation( 92 - user: AuthenticatedUser, 93 - State(skey): State<SigningKey>, 94 - State(rkey): State<RotationKey>, 95 - State(config): State<AppConfig>, 96 - Json(input): Json<identity::sign_plc_operation::Input>, 97 - ) -> Result<Json<identity::sign_plc_operation::Output>> { 98 - todo!() 99 - } 100 - 101 - #[expect( 102 - clippy::too_many_arguments, 103 - reason = "Many parameters are required for this endpoint" 104 - )] 105 - /// Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth. 106 - /// - POST /xrpc/com.atproto.identity.updateHandle 107 - /// ### Query Parameters 108 - /// - handle: handle // The new handle. 109 - /// ### Responses 110 - /// - 200 OK 111 - /// ## Errors 112 - /// - If the handle is already in use. 113 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 114 - /// - 401 Unauthorized 115 - /// ## Panics 116 - /// - If the handle is not valid. 117 - async fn update_handle( 118 - user: AuthenticatedUser, 119 - State(skey): State<SigningKey>, 120 - State(rkey): State<RotationKey>, 121 - State(client): State<Client>, 122 - State(config): State<AppConfig>, 123 - State(db): State<Db>, 124 - State(fhp): State<FirehoseProducer>, 125 - Json(input): Json<identity::update_handle::Input>, 126 - ) -> Result<()> { 127 - let handle = input.handle.as_str(); 128 - let did_str = user.did(); 129 - let did = atrium_api::types::string::Did::new(user.did()).expect("should be valid DID format"); 130 - 131 - if let Some(existing_did) = 132 - sqlx::query_scalar!(r#"SELECT did FROM handles WHERE handle = ?"#, handle) 133 - .fetch_optional(&db) 134 - .await 135 - .context("failed to query did count")? 136 - { 137 - if existing_did != did_str { 138 - return Err(Error::with_status( 139 - StatusCode::BAD_REQUEST, 140 - anyhow!("attempted to update handle to one that is already in use"), 141 - )); 142 - } 143 - } 144 - 145 - // Ensure the existing DID is resolvable. 146 - // If not, we need to register the original handle. 147 - let _did = did::resolve(&client, did.clone()) 148 - .await 149 - .with_context(|| format!("failed to resolve DID for {did_str}")) 150 - .context("should be able to resolve DID")?; 151 - 152 - let op = plc::sign_op( 153 - &rkey, 154 - PlcOperation { 155 - typ: "plc_operation".to_owned(), 156 - rotation_keys: vec![rkey.did()], 157 - verification_methods: HashMap::from([("atproto".to_owned(), skey.did())]), 158 - also_known_as: vec![input.handle.as_str().to_owned()], 159 - services: HashMap::from([( 160 - "atproto_pds".to_owned(), 161 - PlcService::Pds { 162 - endpoint: config.host_name.clone(), 163 - }, 164 - )]), 165 - prev: Some( 166 - sqlx::query_scalar!(r#"SELECT plc_root FROM accounts WHERE did = ?"#, did_str) 167 - .fetch_one(&db) 168 - .await 169 - .context("failed to fetch user PLC root")?, 170 - ), 171 - }, 172 - ) 173 - .context("failed to sign plc op")?; 174 - 175 - if !config.test { 176 - plc::submit(&client, did.as_str(), &op) 177 - .await 178 - .context("failed to submit PLC operation")?; 179 - } 180 - 181 - // FIXME: Properly abstract these implementation details. 182 - let did_hash = did_str 183 - .strip_prefix("did:plc:") 184 - .context("should be valid DID format")?; 185 - let doc = tokio::fs::File::options() 186 - .read(true) 187 - .write(true) 188 - .open(config.plc.path.join(format!("{did_hash}.car"))) 189 - .await 190 - .context("failed to open did doc")?; 191 - 192 - let op_bytes = serde_ipld_dagcbor::to_vec(&op).context("failed to encode plc op")?; 193 - 194 - let plc_cid = CarStore::open(doc) 195 - .await 196 - .context("failed to open did carstore")? 197 - .write_block(DAG_CBOR, SHA2_256, &op_bytes) 198 - .await 199 - .context("failed to write genesis commit")?; 200 - 201 - let cid_str = plc_cid.to_string(); 202 - 203 - _ = sqlx::query!( 204 - r#"UPDATE accounts SET plc_root = ? WHERE did = ?"#, 205 - cid_str, 206 - did_str 207 - ) 208 - .execute(&db) 209 - .await 210 - .context("failed to update account PLC root")?; 211 - 212 - // Broadcast the identity event now that the new identity is resolvable on the public directory. 213 - fhp.identity( 214 - atrium_api::com::atproto::sync::subscribe_repos::IdentityData { 215 - did: did.clone(), 216 - handle: Some(Handle::new(handle.to_owned()).expect("should be valid handle")), 217 - seq: 0, // Filled by firehose later. 218 - time: Datetime::now(), 219 - }, 220 - ) 221 - .await; 222 - 223 - Ok(()) 224 - } 225 - 226 - async fn todo() -> Result<()> { 227 - Err(Error::unimplemented(anyhow!("not implemented"))) 228 - } 229 - 230 - #[rustfmt::skip] 231 - /// Identity endpoints (/xrpc/com.atproto.identity.*) 232 - /// ### Routes 233 - /// - AP /xrpc/com.atproto.identity.updateHandle -> [`update_handle`] 234 - /// - AP /xrpc/com.atproto.identity.requestPlcOperationSignature -> [`request_plc_operation_signature`] 235 - /// - AP /xrpc/com.atproto.identity.signPlcOperation -> [`sign_plc_operation`] 236 - /// - UG /xrpc/com.atproto.identity.resolveHandle -> [`resolve_handle`] 237 - pub(super) fn routes() -> Router<AppState> { 238 - Router::new() 239 - .route(concat!("/", identity::get_recommended_did_credentials::NSID), get(todo)) 240 - .route(concat!("/", identity::request_plc_operation_signature::NSID), post(request_plc_operation_signature)) 241 - .route(concat!("/", identity::resolve_handle::NSID), get(resolve_handle)) 242 - .route(concat!("/", identity::sign_plc_operation::NSID), post(sign_plc_operation)) 243 - .route(concat!("/", identity::submit_plc_operation::NSID), post(todo)) 244 - .route(concat!("/", identity::update_handle::NSID), post(update_handle)) 245 - }
-26
src/endpoints/mod.rs
··· 1 - //! Root module for all endpoints. 2 - // mod identity; 3 - // mod repo; 4 - // mod server; 5 - // mod sync; 6 - 7 - use axum::{Json, Router, routing::get}; 8 - use serde_json::json; 9 - 10 - use crate::{AppState, Result}; 11 - 12 - /// Health check endpoint. Returns name and version of the service. 13 - pub(crate) async fn health() -> Result<Json<serde_json::Value>> { 14 - Ok(Json(json!({ 15 - "version": concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")), 16 - }))) 17 - } 18 - 19 - /// Register all root routes. 20 - pub(crate) fn routes() -> Router<AppState> { 21 - Router::new().route("/_health", get(health)) 22 - // .merge(identity::routes()) // com.atproto.identity 23 - // .merge(repo::routes()) // com.atproto.repo 24 - // .merge(server::routes()) // com.atproto.server 25 - // .merge(sync::routes()) // com.atproto.sync 26 - }
-195
src/endpoints/repo/apply_writes.rs
··· 1 - //! Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS. 2 - use crate::{ 3 - ActorPools, AppState, Db, Error, Result, SigningKey, 4 - actor_store::{ActorStore, sql_blob::BlobStoreSql}, 5 - auth::AuthenticatedUser, 6 - config::AppConfig, 7 - error::ErrorMessage, 8 - firehose::{self, FirehoseProducer, RepoOp}, 9 - metrics::{REPO_COMMITS, REPO_OP_CREATE, REPO_OP_DELETE, REPO_OP_UPDATE}, 10 - storage, 11 - }; 12 - use anyhow::bail; 13 - use anyhow::{Context as _, anyhow}; 14 - use atrium_api::com::atproto::repo::apply_writes::{self, InputWritesItem, OutputResultsItem}; 15 - use atrium_api::{ 16 - com::atproto::repo::{self, defs::CommitMetaData}, 17 - types::{ 18 - LimitedU32, Object, TryFromUnknown as _, TryIntoUnknown as _, Unknown, 19 - string::{AtIdentifier, Nsid, Tid}, 20 - }, 21 - }; 22 - use atrium_repo::blockstore::CarStore; 23 - use axum::{ 24 - Json, Router, 25 - body::Body, 26 - extract::{Query, Request, State}, 27 - http::{self, StatusCode}, 28 - routing::{get, post}, 29 - }; 30 - use cidv10::Cid; 31 - use constcat::concat; 32 - use futures::TryStreamExt as _; 33 - use futures::stream::{self, StreamExt}; 34 - use metrics::counter; 35 - use rsky_lexicon::com::atproto::repo::{ApplyWritesInput, ApplyWritesInputRefWrite}; 36 - use rsky_pds::SharedSequencer; 37 - use rsky_pds::account_manager::AccountManager; 38 - use rsky_pds::account_manager::helpers::account::AvailabilityFlags; 39 - use rsky_pds::apis::ApiError; 40 - use rsky_pds::auth_verifier::AccessStandardIncludeChecks; 41 - use rsky_pds::repo::prepare::{ 42 - PrepareCreateOpts, PrepareDeleteOpts, PrepareUpdateOpts, prepare_create, prepare_delete, 43 - prepare_update, 44 - }; 45 - use rsky_repo::types::PreparedWrite; 46 - use rsky_syntax::aturi::AtUri; 47 - use serde::Deserialize; 48 - use std::{collections::HashSet, str::FromStr}; 49 - use tokio::io::AsyncWriteExt as _; 50 - 51 - use super::resolve_did; 52 - 53 - /// Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS. 54 - /// - POST /xrpc/com.atproto.repo.applyWrites 55 - /// ### Request Body 56 - /// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account). 57 - /// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons. 58 - /// - `writes`: `object[]` // One of: 59 - /// - - com.atproto.repo.applyWrites.create 60 - /// - - com.atproto.repo.applyWrites.update 61 - /// - - com.atproto.repo.applyWrites.delete 62 - /// - `swap_commit`: `cid` // If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations. 63 - pub(crate) async fn apply_writes( 64 - user: AuthenticatedUser, 65 - State(skey): State<SigningKey>, 66 - State(config): State<AppConfig>, 67 - State(db): State<Db>, 68 - State(db_actors): State<std::collections::HashMap<String, ActorPools>>, 69 - State(fhp): State<FirehoseProducer>, 70 - Json(input): Json<ApplyWritesInput>, 71 - ) -> Result<Json<repo::apply_writes::Output>> { 72 - let tx: ApplyWritesInput = input; 73 - let ApplyWritesInput { 74 - repo, 75 - validate, 76 - swap_commit, 77 - .. 78 - } = tx; 79 - let account = account_manager 80 - .get_account( 81 - &repo, 82 - Some(AvailabilityFlags { 83 - include_deactivated: Some(true), 84 - include_taken_down: None, 85 - }), 86 - ) 87 - .await?; 88 - 89 - if let Some(account) = account { 90 - if account.deactivated_at.is_some() { 91 - return Err(Error::with_message( 92 - StatusCode::FORBIDDEN, 93 - anyhow!("Account is deactivated"), 94 - ErrorMessage::new("AccountDeactivated", "Account is deactivated"), 95 - )); 96 - } 97 - let did = account.did; 98 - if did != user.did() { 99 - return Err(Error::with_message( 100 - StatusCode::FORBIDDEN, 101 - anyhow!("AuthRequiredError"), 102 - ErrorMessage::new("AuthRequiredError", "Auth required"), 103 - )); 104 - } 105 - let did: &String = &did; 106 - if tx.writes.len() > 200 { 107 - return Err(Error::with_message( 108 - StatusCode::BAD_REQUEST, 109 - anyhow!("Too many writes. Max: 200"), 110 - ErrorMessage::new("TooManyWrites", "Too many writes. Max: 200"), 111 - )); 112 - } 113 - 114 - let writes: Vec<PreparedWrite> = stream::iter(tx.writes) 115 - .then(|write| async move { 116 - Ok::<PreparedWrite, anyhow::Error>(match write { 117 - ApplyWritesInputRefWrite::Create(write) => PreparedWrite::Create( 118 - prepare_create(PrepareCreateOpts { 119 - did: did.clone(), 120 - collection: write.collection, 121 - rkey: write.rkey, 122 - swap_cid: None, 123 - record: serde_json::from_value(write.value)?, 124 - validate, 125 - }) 126 - .await?, 127 - ), 128 - ApplyWritesInputRefWrite::Update(write) => PreparedWrite::Update( 129 - prepare_update(PrepareUpdateOpts { 130 - did: did.clone(), 131 - collection: write.collection, 132 - rkey: write.rkey, 133 - swap_cid: None, 134 - record: serde_json::from_value(write.value)?, 135 - validate, 136 - }) 137 - .await?, 138 - ), 139 - ApplyWritesInputRefWrite::Delete(write) => { 140 - PreparedWrite::Delete(prepare_delete(PrepareDeleteOpts { 141 - did: did.clone(), 142 - collection: write.collection, 143 - rkey: write.rkey, 144 - swap_cid: None, 145 - })?) 146 - } 147 - }) 148 - }) 149 - .collect::<Vec<_>>() 150 - .await 151 - .into_iter() 152 - .collect::<Result<Vec<PreparedWrite>, _>>()?; 153 - 154 - let swap_commit_cid = match swap_commit { 155 - Some(swap_commit) => Some(Cid::from_str(&swap_commit)?), 156 - None => None, 157 - }; 158 - 159 - let actor_db = db_actors 160 - .get(did) 161 - .ok_or_else(|| anyhow!("Actor DB not found"))?; 162 - let conn = actor_db 163 - .repo 164 - .get() 165 - .await 166 - .context("Failed to get actor db connection")?; 167 - let mut actor_store = ActorStore::new( 168 - did.clone(), 169 - BlobStoreSql::new(did.clone(), actor_db.blob), 170 - actor_db.repo, 171 - conn, 172 - ); 173 - 174 - let commit = actor_store 175 - .process_writes(writes.clone(), swap_commit_cid) 176 - .await?; 177 - 178 - let mut lock = sequencer.sequencer.write().await; 179 - lock.sequence_commit(did.clone(), commit.clone()).await?; 180 - account_manager 181 - .update_repo_root( 182 - did.to_string(), 183 - commit.commit_data.cid, 184 - commit.commit_data.rev, 185 - ) 186 - .await?; 187 - Ok(()) 188 - } else { 189 - Err(Error::with_message( 190 - StatusCode::NOT_FOUND, 191 - anyhow!("Could not find repo: `{repo}`"), 192 - ErrorMessage::new("RepoNotFound", "Could not find repo"), 193 - )) 194 - } 195 - }
-514
src/endpoints/repo.rs
··· 1 - //! PDS repository endpoints /xrpc/com.atproto.repo.*) 2 - mod apply_writes; 3 - pub(crate) use apply_writes::apply_writes; 4 - 5 - use std::{collections::HashSet, str::FromStr}; 6 - 7 - use anyhow::{Context as _, anyhow}; 8 - use atrium_api::com::atproto::repo::apply_writes::{ 9 - self as atrium_apply_writes, InputWritesItem, OutputResultsItem, 10 - }; 11 - use atrium_api::{ 12 - com::atproto::repo::{self, defs::CommitMetaData}, 13 - types::{ 14 - LimitedU32, Object, TryFromUnknown as _, TryIntoUnknown as _, Unknown, 15 - string::{AtIdentifier, Nsid, Tid}, 16 - }, 17 - }; 18 - use atrium_repo::{Cid, blockstore::CarStore}; 19 - use axum::{ 20 - Json, Router, 21 - body::Body, 22 - extract::{Query, Request, State}, 23 - http::{self, StatusCode}, 24 - routing::{get, post}, 25 - }; 26 - use constcat::concat; 27 - use futures::TryStreamExt as _; 28 - use metrics::counter; 29 - use rsky_syntax::aturi::AtUri; 30 - use serde::Deserialize; 31 - use tokio::io::AsyncWriteExt as _; 32 - 33 - use crate::repo::block_map::cid_for_cbor; 34 - use crate::repo::types::PreparedCreateOrUpdate; 35 - use crate::{ 36 - AppState, Db, Error, Result, SigningKey, 37 - actor_store::{ActorStoreTransactor, ActorStoreWriter}, 38 - auth::AuthenticatedUser, 39 - config::AppConfig, 40 - error::ErrorMessage, 41 - firehose::{self, FirehoseProducer, RepoOp}, 42 - metrics::{REPO_COMMITS, REPO_OP_CREATE, REPO_OP_DELETE, REPO_OP_UPDATE}, 43 - repo::types::{PreparedWrite, WriteOpAction}, 44 - storage, 45 - }; 46 - 47 - #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 48 - #[serde(rename_all = "camelCase")] 49 - /// Parameters for [`list_records`]. 50 - pub(super) struct ListRecordsParameters { 51 - ///The NSID of the record type. 52 - pub collection: Nsid, 53 - /// The cursor to start from. 54 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 55 - pub cursor: Option<String>, 56 - ///The number of records to return. 57 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 58 - pub limit: Option<String>, 59 - ///The handle or DID of the repo. 60 - pub repo: AtIdentifier, 61 - ///Flag to reverse the order of the returned records. 62 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 63 - pub reverse: Option<bool>, 64 - ///DEPRECATED: The highest sort-ordered rkey to stop at (exclusive) 65 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 66 - pub rkey_end: Option<String>, 67 - ///DEPRECATED: The lowest sort-ordered rkey to start from (exclusive) 68 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 69 - pub rkey_start: Option<String>, 70 - } 71 - 72 - /// Resolve DID to DID document. Does not bi-directionally verify handle. 73 - /// - GET /xrpc/com.atproto.repo.resolveDid 74 - /// ### Query Parameters 75 - /// - `did`: DID to resolve. 76 - /// ### Responses 77 - /// - 200 OK: {`did_doc`: `did_doc`} 78 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `DidNotFound`, `DidDeactivated`]} 79 - async fn resolve_did( 80 - db: &Db, 81 - identifier: &AtIdentifier, 82 - ) -> anyhow::Result<( 83 - atrium_api::types::string::Did, 84 - atrium_api::types::string::Handle, 85 - )> { 86 - let (handle, did) = match *identifier { 87 - AtIdentifier::Handle(ref handle) => { 88 - let handle_as_str = &handle.as_str(); 89 - ( 90 - &handle.to_owned(), 91 - &atrium_api::types::string::Did::new( 92 - sqlx::query_scalar!( 93 - r#"SELECT did FROM handles WHERE handle = ?"#, 94 - handle_as_str 95 - ) 96 - .fetch_one(db) 97 - .await 98 - .context("failed to query did")?, 99 - ) 100 - .expect("should be valid DID"), 101 - ) 102 - } 103 - AtIdentifier::Did(ref did) => { 104 - let did_as_str = &did.as_str(); 105 - ( 106 - &atrium_api::types::string::Handle::new( 107 - sqlx::query_scalar!(r#"SELECT handle FROM handles WHERE did = ?"#, did_as_str) 108 - .fetch_one(db) 109 - .await 110 - .context("failed to query did")?, 111 - ) 112 - .expect("should be valid handle"), 113 - &did.to_owned(), 114 - ) 115 - } 116 - }; 117 - 118 - Ok((did.to_owned(), handle.to_owned())) 119 - } 120 - 121 - /// Create a single new repository record. Requires auth, implemented by PDS. 122 - /// - POST /xrpc/com.atproto.repo.createRecord 123 - /// ### Request Body 124 - /// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account). 125 - /// - `collection`: `nsid` // The NSID of the record collection. 126 - /// - `rkey`: `string` // The record key. <= 512 characters. 127 - /// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons. 128 - /// - `record` 129 - /// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID. 130 - /// ### Responses 131 - /// - 200 OK: {`cid`: `cid`, `uri`: `at-uri`, `commit`: {`cid`: `cid`, `rev`: `tid`}, `validation_status`: [`valid`, `unknown`]} 132 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidSwap`]} 133 - /// - 401 Unauthorized 134 - async fn create_record( 135 - user: AuthenticatedUser, 136 - State(actor_store): State<ActorStore>, 137 - State(skey): State<SigningKey>, 138 - State(config): State<AppConfig>, 139 - State(db): State<Db>, 140 - State(fhp): State<FirehoseProducer>, 141 - Json(input): Json<repo::create_record::Input>, 142 - ) -> Result<Json<repo::create_record::Output>> { 143 - todo!(); 144 - // let write_result = apply_writes::apply_writes( 145 - // user, 146 - // State(actor_store), 147 - // State(skey), 148 - // State(config), 149 - // State(db), 150 - // State(fhp), 151 - // Json( 152 - // repo::apply_writes::InputData { 153 - // repo: input.repo.clone(), 154 - // validate: input.validate, 155 - // swap_commit: input.swap_commit.clone(), 156 - // writes: vec![repo::apply_writes::InputWritesItem::Create(Box::new( 157 - // repo::apply_writes::CreateData { 158 - // collection: input.collection.clone(), 159 - // rkey: input.rkey.clone(), 160 - // value: input.record.clone(), 161 - // } 162 - // .into(), 163 - // ))], 164 - // } 165 - // .into(), 166 - // ), 167 - // ) 168 - // .await 169 - // .context("failed to apply writes")?; 170 - 171 - // let create_result = if let repo::apply_writes::OutputResultsItem::CreateResult(create_result) = 172 - // write_result 173 - // .results 174 - // .clone() 175 - // .and_then(|result| result.first().cloned()) 176 - // .context("unexpected output from apply_writes")? 177 - // { 178 - // Some(create_result) 179 - // } else { 180 - // None 181 - // } 182 - // .context("unexpected result from apply_writes")?; 183 - 184 - // Ok(Json( 185 - // repo::create_record::OutputData { 186 - // cid: create_result.cid.clone(), 187 - // commit: write_result.commit.clone(), 188 - // uri: create_result.uri.clone(), 189 - // validation_status: Some("unknown".to_owned()), 190 - // } 191 - // .into(), 192 - // )) 193 - } 194 - 195 - /// Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS. 196 - /// - POST /xrpc/com.atproto.repo.putRecord 197 - /// ### Request Body 198 - /// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account). 199 - /// - `collection`: `nsid` // The NSID of the record collection. 200 - /// - `rkey`: `string` // The record key. <= 512 characters. 201 - /// - `validate`: `boolean` // Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons. 202 - /// - `record` 203 - /// - `swap_record`: `boolean` // Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation 204 - /// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID. 205 - /// ### Responses 206 - /// - 200 OK: {"uri": "string","cid": "string","commit": {"cid": "string","rev": "string"},"validationStatus": "valid | unknown"} 207 - /// - 400 Bad Request: {error:"`InvalidRequest` | `ExpiredToken` | `InvalidToken` | `InvalidSwap`"} 208 - /// - 401 Unauthorized 209 - async fn put_record( 210 - user: AuthenticatedUser, 211 - State(actor_store): State<ActorStore>, 212 - State(skey): State<SigningKey>, 213 - State(config): State<AppConfig>, 214 - State(db): State<Db>, 215 - State(fhp): State<FirehoseProducer>, 216 - Json(input): Json<repo::put_record::Input>, 217 - ) -> Result<Json<repo::put_record::Output>> { 218 - todo!(); 219 - // // TODO: `input.swap_record` 220 - // // FIXME: "put" implies that we will create the record if it does not exist. 221 - // // We currently only update existing records and/or throw an error if one doesn't exist. 222 - // let input = (*input).clone(); 223 - // let input = repo::apply_writes::InputData { 224 - // repo: input.repo, 225 - // validate: input.validate, 226 - // swap_commit: input.swap_commit, 227 - // writes: vec![repo::apply_writes::InputWritesItem::Update(Box::new( 228 - // repo::apply_writes::UpdateData { 229 - // collection: input.collection, 230 - // rkey: input.rkey, 231 - // value: input.record, 232 - // } 233 - // .into(), 234 - // ))], 235 - // } 236 - // .into(); 237 - 238 - // let write_result = apply_writes::apply_writes( 239 - // user, 240 - // State(actor_store), 241 - // State(skey), 242 - // State(config), 243 - // State(db), 244 - // State(fhp), 245 - // Json(input), 246 - // ) 247 - // .await 248 - // .context("failed to apply writes")?; 249 - 250 - // let update_result = write_result 251 - // .results 252 - // .clone() 253 - // .and_then(|result| result.first().cloned()) 254 - // .context("unexpected output from apply_writes")?; 255 - // let (cid, uri) = match update_result { 256 - // repo::apply_writes::OutputResultsItem::CreateResult(create_result) => ( 257 - // Some(create_result.cid.clone()), 258 - // Some(create_result.uri.clone()), 259 - // ), 260 - // repo::apply_writes::OutputResultsItem::UpdateResult(update_result) => ( 261 - // Some(update_result.cid.clone()), 262 - // Some(update_result.uri.clone()), 263 - // ), 264 - // repo::apply_writes::OutputResultsItem::DeleteResult(_) => (None, None), 265 - // }; 266 - // Ok(Json( 267 - // repo::put_record::OutputData { 268 - // cid: cid.context("missing cid")?, 269 - // commit: write_result.commit.clone(), 270 - // uri: uri.context("missing uri")?, 271 - // validation_status: Some("unknown".to_owned()), 272 - // } 273 - // .into(), 274 - // )) 275 - } 276 - 277 - /// Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS. 278 - /// - POST /xrpc/com.atproto.repo.deleteRecord 279 - /// ### Request Body 280 - /// - `repo`: `at-identifier` // The handle or DID of the repo (aka, current account). 281 - /// - `collection`: `nsid` // The NSID of the record collection. 282 - /// - `rkey`: `string` // The record key. <= 512 characters. 283 - /// - `swap_record`: `boolean` // Compare and swap with the previous record by CID. 284 - /// - `swap_commit`: `cid` // Compare and swap with the previous commit by CID. 285 - /// ### Responses 286 - /// - 200 OK: {"commit": {"cid": "string","rev": "string"}} 287 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidSwap`]} 288 - /// - 401 Unauthorized 289 - async fn delete_record( 290 - user: AuthenticatedUser, 291 - State(actor_store): State<ActorStore>, 292 - State(skey): State<SigningKey>, 293 - State(config): State<AppConfig>, 294 - State(db): State<Db>, 295 - State(fhp): State<FirehoseProducer>, 296 - Json(input): Json<repo::delete_record::Input>, 297 - ) -> Result<Json<repo::delete_record::Output>> { 298 - todo!(); 299 - // // TODO: `input.swap_record` 300 - 301 - // Ok(Json( 302 - // repo::delete_record::OutputData { 303 - // commit: apply_writes::apply_writes( 304 - // user, 305 - // State(actor_store), 306 - // State(skey), 307 - // State(config), 308 - // State(db), 309 - // State(fhp), 310 - // Json( 311 - // repo::apply_writes::InputData { 312 - // repo: input.repo.clone(), 313 - // swap_commit: input.swap_commit.clone(), 314 - // validate: None, 315 - // writes: vec![repo::apply_writes::InputWritesItem::Delete(Box::new( 316 - // repo::apply_writes::DeleteData { 317 - // collection: input.collection.clone(), 318 - // rkey: input.rkey.clone(), 319 - // } 320 - // .into(), 321 - // ))], 322 - // } 323 - // .into(), 324 - // ), 325 - // ) 326 - // .await 327 - // .context("failed to apply writes")? 328 - // .commit 329 - // .clone(), 330 - // } 331 - // .into(), 332 - // )) 333 - } 334 - 335 - /// Get information about an account and repository, including the list of collections. Does not require auth. 336 - /// - GET /xrpc/com.atproto.repo.describeRepo 337 - /// ### Query Parameters 338 - /// - `repo`: `at-identifier` // The handle or DID of the repo. 339 - /// ### Responses 340 - /// - 200 OK: {"handle": "string","did": "string","didDoc": {},"collections": [string],"handleIsCorrect": true} \ 341 - /// handeIsCorrect - boolean - Indicates if handle is currently valid (resolves bi-directionally) 342 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 343 - /// - 401 Unauthorized 344 - async fn describe_repo( 345 - State(actor_store): State<ActorStore>, 346 - State(config): State<AppConfig>, 347 - State(db): State<Db>, 348 - Query(input): Query<repo::describe_repo::ParametersData>, 349 - ) -> Result<Json<repo::describe_repo::Output>> { 350 - // Lookup the DID by the provided handle. 351 - let (did, handle) = resolve_did(&db, &input.repo) 352 - .await 353 - .context("failed to resolve handle")?; 354 - 355 - // Use Actor Store to get the collections 356 - todo!(); 357 - } 358 - 359 - /// Get a single record from a repository. Does not require auth. 360 - /// - GET /xrpc/com.atproto.repo.getRecord 361 - /// ### Query Parameters 362 - /// - `repo`: `at-identifier` // The handle or DID of the repo. 363 - /// - `collection`: `nsid` // The NSID of the record collection. 364 - /// - `rkey`: `string` // The record key. <= 512 characters. 365 - /// - `cid`: `cid` // The CID of the version of the record. If not specified, then return the most recent version. 366 - /// ### Responses 367 - /// - 200 OK: {"uri": "string","cid": "string","value": {}} 368 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RecordNotFound`]} 369 - /// - 401 Unauthorized 370 - async fn get_record( 371 - State(actor_store): State<ActorStore>, 372 - State(config): State<AppConfig>, 373 - State(db): State<Db>, 374 - Query(input): Query<repo::get_record::ParametersData>, 375 - ) -> Result<Json<repo::get_record::Output>> { 376 - if input.cid.is_some() { 377 - return Err(Error::unimplemented(anyhow!( 378 - "looking up old records is unsupported" 379 - ))); 380 - } 381 - 382 - // Lookup the DID by the provided handle. 383 - let (did, _handle) = resolve_did(&db, &input.repo) 384 - .await 385 - .context("failed to resolve handle")?; 386 - 387 - // Create a URI from the parameters 388 - let uri = format!( 389 - "at://{}/{}/{}", 390 - did.as_str(), 391 - input.collection.as_str(), 392 - input.rkey.as_str() 393 - ); 394 - 395 - // Use Actor Store to get the record 396 - todo!(); 397 - } 398 - 399 - /// List a range of records in a repository, matching a specific collection. Does not require auth. 400 - /// - GET /xrpc/com.atproto.repo.listRecords 401 - /// ### Query Parameters 402 - /// - `repo`: `at-identifier` // The handle or DID of the repo. 403 - /// - `collection`: `nsid` // The NSID of the record type. 404 - /// - `limit`: `integer` // The maximum number of records to return. Default 50, >=1 and <=100. 405 - /// - `cursor`: `string` 406 - /// - `reverse`: `boolean` // Flag to reverse the order of the returned records. 407 - /// ### Responses 408 - /// - 200 OK: {"cursor": "string","records": [{"uri": "string","cid": "string","value": {}}]} 409 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 410 - /// - 401 Unauthorized 411 - async fn list_records( 412 - State(actor_store): State<ActorStore>, 413 - State(config): State<AppConfig>, 414 - State(db): State<Db>, 415 - Query(input): Query<Object<ListRecordsParameters>>, 416 - ) -> Result<Json<repo::list_records::Output>> { 417 - // Lookup the DID by the provided handle. 418 - let (did, _handle) = resolve_did(&db, &input.repo) 419 - .await 420 - .context("failed to resolve handle")?; 421 - 422 - // Use Actor Store to list records for the collection 423 - todo!(); 424 - } 425 - 426 - /// Upload a new blob, to be referenced from a repository record. \ 427 - /// The blob will be deleted if it is not referenced within a time window (eg, minutes). \ 428 - /// Blob restrictions (mimetype, size, etc) are enforced when the reference is created. \ 429 - /// Requires auth, implemented by PDS. 430 - /// - POST /xrpc/com.atproto.repo.uploadBlob 431 - /// ### Request Body 432 - /// ### Responses 433 - /// - 200 OK: {"blob": "binary"} 434 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 435 - /// - 401 Unauthorized 436 - async fn upload_blob( 437 - user: AuthenticatedUser, 438 - State(actor_store): State<ActorStore>, 439 - State(config): State<AppConfig>, 440 - State(db): State<Db>, 441 - request: Request<Body>, 442 - ) -> Result<Json<repo::upload_blob::Output>> { 443 - let length = request 444 - .headers() 445 - .get(http::header::CONTENT_LENGTH) 446 - .context("no content length provided")? 447 - .to_str() 448 - .map_err(anyhow::Error::from) 449 - .and_then(|content_length| content_length.parse::<u64>().map_err(anyhow::Error::from)) 450 - .context("invalid content-length header")?; 451 - let mime = request 452 - .headers() 453 - .get(http::header::CONTENT_TYPE) 454 - .context("no content-type provided")? 455 - .to_str() 456 - .context("invalid content-type provided")? 457 - .to_owned(); 458 - 459 - if length > config.blob.limit { 460 - return Err(Error::with_status( 461 - StatusCode::PAYLOAD_TOO_LARGE, 462 - anyhow!("size {} above limit {}", length, config.blob.limit), 463 - )); 464 - } 465 - 466 - // Read the blob data 467 - let mut body_data = Vec::new(); 468 - let mut stream = request.into_body().into_data_stream(); 469 - while let Some(bytes) = stream.try_next().await.context("failed to receive file")? { 470 - body_data.extend_from_slice(&bytes); 471 - 472 - // Check size limit incrementally 473 - if body_data.len() as u64 > config.blob.limit { 474 - return Err(Error::with_status( 475 - StatusCode::PAYLOAD_TOO_LARGE, 476 - anyhow!("size above limit and content-length header was wrong"), 477 - )); 478 - } 479 - } 480 - 481 - // Use Actor Store to upload the blob 482 - todo!(); 483 - } 484 - 485 - async fn todo() -> Result<()> { 486 - Err(Error::unimplemented(anyhow!("not implemented"))) 487 - } 488 - 489 - /// These endpoints are part of the atproto PDS repository management APIs. \ 490 - /// Requests usually require authentication (unlike the com.atproto.sync.* endpoints), and are made directly to the user's own PDS instance. 491 - /// ### Routes 492 - /// - AP /xrpc/com.atproto.repo.applyWrites -> [`apply_writes`] 493 - /// - AP /xrpc/com.atproto.repo.createRecord -> [`create_record`] 494 - /// - AP /xrpc/com.atproto.repo.putRecord -> [`put_record`] 495 - /// - AP /xrpc/com.atproto.repo.deleteRecord -> [`delete_record`] 496 - /// - AP /xrpc/com.atproto.repo.uploadBlob -> [`upload_blob`] 497 - /// - UG /xrpc/com.atproto.repo.describeRepo -> [`describe_repo`] 498 - /// - UG /xrpc/com.atproto.repo.getRecord -> [`get_record`] 499 - /// - UG /xrpc/com.atproto.repo.listRecords -> [`list_records`] 500 - /// - [ ] xx /xrpc/com.atproto.repo.importRepo 501 - // - [ ] xx /xrpc/com.atproto.repo.listMissingBlobs 502 - pub(super) fn routes() -> Router<AppState> { 503 - Router::new() 504 - .route(concat!("/", repo::apply_writes::NSID), post(apply_writes)) 505 - // .route(concat!("/", repo::create_record::NSID), post(create_record)) 506 - // .route(concat!("/", repo::put_record::NSID), post(put_record)) 507 - // .route(concat!("/", repo::delete_record::NSID), post(delete_record)) 508 - // .route(concat!("/", repo::upload_blob::NSID), post(upload_blob)) 509 - // .route(concat!("/", repo::describe_repo::NSID), get(describe_repo)) 510 - // .route(concat!("/", repo::get_record::NSID), get(get_record)) 511 - .route(concat!("/", repo::import_repo::NSID), post(todo)) 512 - .route(concat!("/", repo::list_missing_blobs::NSID), get(todo)) 513 - // .route(concat!("/", repo::list_records::NSID), get(list_records)) 514 - }
-791
src/endpoints/server.rs
··· 1 - //! Server endpoints. (/xrpc/com.atproto.server.*) 2 - use std::{collections::HashMap, str::FromStr as _}; 3 - 4 - use anyhow::{Context as _, anyhow}; 5 - use argon2::{ 6 - Argon2, PasswordHash, PasswordHasher as _, PasswordVerifier as _, password_hash::SaltString, 7 - }; 8 - use atrium_api::{ 9 - com::atproto::server, 10 - types::string::{Datetime, Did, Handle, Tid}, 11 - }; 12 - use atrium_crypto::keypair::Did as _; 13 - use atrium_repo::{ 14 - Cid, Repository, 15 - blockstore::{AsyncBlockStoreWrite as _, CarStore, DAG_CBOR, SHA2_256}, 16 - }; 17 - use axum::{ 18 - Json, Router, 19 - extract::{Query, Request, State}, 20 - http::StatusCode, 21 - routing::{get, post}, 22 - }; 23 - use constcat::concat; 24 - use metrics::counter; 25 - use rand::Rng as _; 26 - use sha2::Digest as _; 27 - use uuid::Uuid; 28 - 29 - use crate::{ 30 - AppState, Client, Db, Error, Result, RotationKey, SigningKey, 31 - auth::{self, AuthenticatedUser}, 32 - config::AppConfig, 33 - firehose::{Commit, FirehoseProducer}, 34 - metrics::AUTH_FAILED, 35 - plc::{self, PlcOperation, PlcService}, 36 - storage, 37 - }; 38 - 39 - /// This is a dummy password that can be used in absence of a real password. 40 - const DUMMY_PASSWORD: &str = "$argon2id$v=19$m=19456,t=2,p=1$En2LAfHjeO0SZD5IUU1Abg$RpS8nHhhqY4qco2uyd41p9Y/1C+Lvi214MAWukzKQMI"; 41 - 42 - /// Create an invite code. 43 - /// - POST /xrpc/com.atproto.server.createInviteCode 44 - /// ### Request Body 45 - /// - `useCount`: integer 46 - /// - `forAccount`: string (optional) 47 - /// ### Responses 48 - /// - 200 OK: {code: string} 49 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 50 - /// - 401 Unauthorized 51 - async fn create_invite_code( 52 - _user: AuthenticatedUser, 53 - State(db): State<Db>, 54 - Json(input): Json<server::create_invite_code::Input>, 55 - ) -> Result<Json<server::create_invite_code::Output>> { 56 - let uuid = Uuid::new_v4().to_string(); 57 - let did = input.for_account.as_deref(); 58 - let count = std::cmp::min(input.use_count, 100); // Maximum of 100 uses for any code. 59 - 60 - if count <= 0 { 61 - return Err(anyhow!("use_count must be greater than 0").into()); 62 - } 63 - 64 - Ok(Json( 65 - server::create_invite_code::OutputData { 66 - code: sqlx::query_scalar!( 67 - r#" 68 - INSERT INTO invites (id, did, count, created_at) 69 - VALUES (?, ?, ?, datetime('now')) 70 - RETURNING id 71 - "#, 72 - uuid, 73 - did, 74 - count, 75 - ) 76 - .fetch_one(&db) 77 - .await 78 - .context("failed to create new invite code")?, 79 - } 80 - .into(), 81 - )) 82 - } 83 - 84 - #[expect(clippy::too_many_lines, reason = "TODO: refactor")] 85 - /// Create an account. Implemented by PDS. 86 - /// - POST /xrpc/com.atproto.server.createAccount 87 - /// ### Request Body 88 - /// - `email`: string 89 - /// - `handle`: string (required) 90 - /// - `did`: string - Pre-existing atproto DID, being imported to a new account. 91 - /// - `inviteCode`: string 92 - /// - `verificationCode`: string 93 - /// - `verificationPhone`: string 94 - /// - `password`: string - Initial account password. May need to meet instance-specific password strength requirements. 95 - /// - `recoveryKey`: string - DID PLC rotation key (aka, recovery key) to be included in PLC creation operation. 96 - /// - `plcOp`: object 97 - /// ## Responses 98 - /// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {}} 99 - /// - 400 Bad Request: {error: [`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `InvalidHandle`, `InvalidPassword`, \ 100 - /// `InvalidInviteCode`, `HandleNotAvailable`, `UnsupportedDomain`, `UnresolvableDid`, `IncompatibleDidDoc`)} 101 - /// - 401 Unauthorized 102 - async fn create_account( 103 - State(db): State<Db>, 104 - State(skey): State<SigningKey>, 105 - State(rkey): State<RotationKey>, 106 - State(client): State<Client>, 107 - State(config): State<AppConfig>, 108 - State(fhp): State<FirehoseProducer>, 109 - Json(input): Json<server::create_account::Input>, 110 - ) -> Result<Json<server::create_account::Output>> { 111 - let email = input.email.as_deref().context("no email provided")?; 112 - // Hash the user's password. 113 - let pass = Argon2::default() 114 - .hash_password( 115 - input 116 - .password 117 - .as_deref() 118 - .context("no password provided")? 119 - .as_bytes(), 120 - SaltString::generate(&mut rand::thread_rng()).as_salt(), 121 - ) 122 - .context("failed to hash password")? 123 - .to_string(); 124 - let handle = input.handle.as_str().to_owned(); 125 - 126 - // TODO: Handle the account migration flow. 127 - // Users will hit this endpoint with a service-level authentication token. 128 - // 129 - // https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md 130 - 131 - // TODO: `input.plc_op` 132 - if input.plc_op.is_some() { 133 - return Err(Error::unimplemented(anyhow!("plc_op"))); 134 - } 135 - 136 - let recovery_keys = if let Some(ref key) = input.recovery_key { 137 - // Ensure the provided recovery key is valid. 138 - if let Err(error) = atrium_crypto::did::parse_did_key(key) { 139 - return Err(Error::with_status( 140 - StatusCode::BAD_REQUEST, 141 - anyhow::Error::new(error).context("provided recovery key is in invalid format"), 142 - )); 143 - } 144 - 145 - // Enroll the user-provided recovery key at a higher priority than our own. 146 - vec![key.clone(), rkey.did()] 147 - } else { 148 - vec![rkey.did()] 149 - }; 150 - 151 - // Begin a new transaction to actually create the user's profile. 152 - // Unless committed, the transaction will be automatically rolled back. 153 - let mut tx = db.begin().await.context("failed to begin transaction")?; 154 - 155 - // TODO: Make this its own toggle instead of tied to test mode 156 - if !config.test { 157 - let _invite = match input.invite_code { 158 - Some(ref code) => { 159 - let invite: Option<String> = sqlx::query_scalar!( 160 - r#" 161 - UPDATE invites 162 - SET count = count - 1 163 - WHERE id = ? 164 - AND count > 0 165 - RETURNING id 166 - "#, 167 - code 168 - ) 169 - .fetch_optional(&mut *tx) 170 - .await 171 - .context("failed to check invite code")?; 172 - 173 - invite.context("invalid invite code")? 174 - } 175 - None => { 176 - return Err(anyhow!("invite code required").into()); 177 - } 178 - }; 179 - } 180 - 181 - // Account can be created. Synthesize a new DID for the user. 182 - // https://github.com/did-method-plc/did-method-plc?tab=readme-ov-file#did-creation 183 - let op = plc::sign_op( 184 - &rkey, 185 - PlcOperation { 186 - typ: "plc_operation".to_owned(), 187 - rotation_keys: recovery_keys, 188 - verification_methods: HashMap::from([("atproto".to_owned(), skey.did())]), 189 - also_known_as: vec![format!("at://{}", input.handle.as_str())], 190 - services: HashMap::from([( 191 - "atproto_pds".to_owned(), 192 - PlcService::Pds { 193 - endpoint: format!("https://{}", config.host_name), 194 - }, 195 - )]), 196 - prev: None, 197 - }, 198 - ) 199 - .context("failed to sign genesis op")?; 200 - let op_bytes = serde_ipld_dagcbor::to_vec(&op).context("failed to encode genesis op")?; 201 - 202 - let did_hash = { 203 - let digest = base32::encode( 204 - base32::Alphabet::Rfc4648Lower { padding: false }, 205 - sha2::Sha256::digest(&op_bytes).as_slice(), 206 - ); 207 - if digest.len() < 24 { 208 - return Err(anyhow!("digest too short").into()); 209 - } 210 - #[expect(clippy::string_slice, reason = "digest length confirmed")] 211 - digest[..24].to_owned() 212 - }; 213 - let did = format!("did:plc:{did_hash}"); 214 - 215 - let doc = tokio::fs::File::create(config.plc.path.join(format!("{did_hash}.car"))) 216 - .await 217 - .context("failed to create did doc")?; 218 - 219 - let mut plc_doc = CarStore::create(doc) 220 - .await 221 - .context("failed to create did doc")?; 222 - 223 - let plc_cid = plc_doc 224 - .write_block(DAG_CBOR, SHA2_256, &op_bytes) 225 - .await 226 - .context("failed to write genesis commit")? 227 - .to_string(); 228 - 229 - if !config.test { 230 - // Send the new account's data to the PLC directory. 231 - plc::submit(&client, &did, &op) 232 - .await 233 - .context("failed to submit PLC operation to directory")?; 234 - } 235 - 236 - // Write out an initial commit for the user. 237 - // https://atproto.com/guides/account-lifecycle 238 - let (cid, rev, store) = async { 239 - let store = storage::create_storage_for_did(&config.repo, &did_hash) 240 - .await 241 - .context("failed to create storage")?; 242 - 243 - // Initialize the repository with the storage 244 - let repo_builder = Repository::create( 245 - store, 246 - Did::from_str(&did).expect("should be valid DID format"), 247 - ) 248 - .await 249 - .context("failed to initialize user repo")?; 250 - 251 - // Sign the root commit. 252 - let sig = skey 253 - .sign(&repo_builder.bytes()) 254 - .context("failed to sign root commit")?; 255 - let mut repo = repo_builder 256 - .finalize(sig) 257 - .await 258 - .context("failed to attach signature to root commit")?; 259 - 260 - let root = repo.root(); 261 - let rev = repo.commit().rev(); 262 - 263 - // Create a temporary CAR store for firehose events 264 - let mut mem = Vec::new(); 265 - let mut firehose_store = 266 - CarStore::create_with_roots(std::io::Cursor::new(&mut mem), [repo.root()]) 267 - .await 268 - .context("failed to create temp carstore")?; 269 - 270 - repo.export_into(&mut firehose_store) 271 - .await 272 - .context("failed to export repository")?; 273 - 274 - Ok::<(Cid, Tid, Vec<u8>), anyhow::Error>((root, rev, mem)) 275 - } 276 - .await 277 - .context("failed to create user repo")?; 278 - 279 - let cid_str = cid.to_string(); 280 - let rev_str = rev.as_str(); 281 - 282 - _ = sqlx::query!( 283 - r#" 284 - INSERT INTO accounts (did, email, password, root, plc_root, rev, created_at) 285 - VALUES (?, ?, ?, ?, ?, ?, datetime('now')); 286 - 287 - INSERT INTO handles (did, handle, created_at) 288 - VALUES (?, ?, datetime('now')); 289 - 290 - -- Cleanup stale invite codes 291 - DELETE FROM invites 292 - WHERE count <= 0; 293 - "#, 294 - did, 295 - email, 296 - pass, 297 - cid_str, 298 - plc_cid, 299 - rev_str, 300 - did, 301 - handle 302 - ) 303 - .execute(&mut *tx) 304 - .await 305 - .context("failed to create new account")?; 306 - 307 - // The account is fully created. Commit the SQL transaction to the database. 308 - tx.commit().await.context("failed to commit transaction")?; 309 - 310 - // Broadcast the identity event now that the new identity is resolvable on the public directory. 311 - fhp.identity( 312 - atrium_api::com::atproto::sync::subscribe_repos::IdentityData { 313 - did: Did::from_str(&did).expect("should be valid DID format"), 314 - handle: Some(Handle::new(handle).expect("should be valid handle")), 315 - seq: 0, // Filled by firehose later. 316 - time: Datetime::now(), 317 - }, 318 - ) 319 - .await; 320 - 321 - // The new account is now active on this PDS, so we can broadcast the account firehose event. 322 - fhp.account( 323 - atrium_api::com::atproto::sync::subscribe_repos::AccountData { 324 - active: true, 325 - did: Did::from_str(&did).expect("should be valid DID format"), 326 - seq: 0, // Filled by firehose later. 327 - status: None, // "takedown" / "suspended" / "deactivated" 328 - time: Datetime::now(), 329 - }, 330 - ) 331 - .await; 332 - 333 - let did = Did::from_str(&did).expect("should be valid DID format"); 334 - 335 - fhp.commit(Commit { 336 - car: store, 337 - ops: Vec::new(), 338 - cid, 339 - rev: rev.to_string(), 340 - did: did.clone(), 341 - pcid: None, 342 - blobs: Vec::new(), 343 - }) 344 - .await; 345 - 346 - // Finally, sign some authentication tokens for the new user. 347 - let token = auth::sign( 348 - &skey, 349 - "at+jwt", 350 - &serde_json::json!({ 351 - "scope": "com.atproto.access", 352 - "sub": did, 353 - "iat": chrono::Utc::now().timestamp(), 354 - "exp": chrono::Utc::now().checked_add_signed(chrono::Duration::hours(4)).context("should be valid time")?.timestamp(), 355 - "aud": format!("did:web:{}", config.host_name) 356 - }), 357 - ) 358 - .context("failed to sign jwt")?; 359 - 360 - let refresh_token = auth::sign( 361 - &skey, 362 - "refresh+jwt", 363 - &serde_json::json!({ 364 - "scope": "com.atproto.refresh", 365 - "sub": did, 366 - "iat": chrono::Utc::now().timestamp(), 367 - "exp": chrono::Utc::now().checked_add_days(chrono::Days::new(90)).context("should be valid time")?.timestamp(), 368 - "aud": format!("did:web:{}", config.host_name) 369 - }), 370 - ) 371 - .context("failed to sign refresh jwt")?; 372 - 373 - Ok(Json( 374 - server::create_account::OutputData { 375 - access_jwt: token, 376 - did, 377 - did_doc: None, 378 - handle: input.handle.clone(), 379 - refresh_jwt: refresh_token, 380 - } 381 - .into(), 382 - )) 383 - } 384 - 385 - /// Create an authentication session. 386 - /// - POST /xrpc/com.atproto.server.createSession 387 - /// ### Request Body 388 - /// - `identifier`: string - Handle or other identifier supported by the server for the authenticating user. 389 - /// - `password`: string - Password for the authenticating user. 390 - /// - `authFactorToken` - string (optional) 391 - /// - `allowTakedown` - boolean (optional) - When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned 392 - /// ### Responses 393 - /// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {},"email": "string","emailConfirmed": true,"emailAuthFactor": true,"active": true,"status": "takendown"} 394 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `AccountTakedown`, `AuthFactorTokenRequired`]} 395 - /// - 401 Unauthorized 396 - async fn create_session( 397 - State(db): State<Db>, 398 - State(skey): State<SigningKey>, 399 - State(config): State<AppConfig>, 400 - Json(input): Json<server::create_session::Input>, 401 - ) -> Result<Json<server::create_session::Output>> { 402 - let handle = &input.identifier; 403 - let password = &input.password; 404 - 405 - // TODO: `input.allow_takedown` 406 - // TODO: `input.auth_factor_token` 407 - 408 - let Some(account) = sqlx::query!( 409 - r#" 410 - WITH LatestHandles AS ( 411 - SELECT did, handle 412 - FROM handles 413 - WHERE (did, created_at) IN ( 414 - SELECT did, MAX(created_at) AS max_created_at 415 - FROM handles 416 - GROUP BY did 417 - ) 418 - ) 419 - SELECT a.did, a.password, h.handle 420 - FROM accounts a 421 - LEFT JOIN LatestHandles h ON a.did = h.did 422 - WHERE h.handle = ? 423 - "#, 424 - handle 425 - ) 426 - .fetch_optional(&db) 427 - .await 428 - .context("failed to authenticate")? 429 - else { 430 - counter!(AUTH_FAILED).increment(1); 431 - 432 - // SEC: Call argon2's `verify_password` to simulate password verification and discard the result. 433 - // We do this to avoid exposing a timing attack where attackers can measure the response time to 434 - // determine whether or not an account exists. 435 - _ = Argon2::default().verify_password( 436 - password.as_bytes(), 437 - &PasswordHash::new(DUMMY_PASSWORD).context("should be valid password hash")?, 438 - ); 439 - 440 - return Err(Error::with_status( 441 - StatusCode::UNAUTHORIZED, 442 - anyhow!("failed to validate credentials"), 443 - )); 444 - }; 445 - 446 - match Argon2::default().verify_password( 447 - password.as_bytes(), 448 - &PasswordHash::new(account.password.as_str()).context("invalid password hash in db")?, 449 - ) { 450 - Ok(()) => {} 451 - Err(_e) => { 452 - counter!(AUTH_FAILED).increment(1); 453 - 454 - return Err(Error::with_status( 455 - StatusCode::UNAUTHORIZED, 456 - anyhow!("failed to validate credentials"), 457 - )); 458 - } 459 - } 460 - 461 - let did = account.did; 462 - 463 - let token = auth::sign( 464 - &skey, 465 - "at+jwt", 466 - &serde_json::json!({ 467 - "scope": "com.atproto.access", 468 - "sub": did, 469 - "iat": chrono::Utc::now().timestamp(), 470 - "exp": chrono::Utc::now().checked_add_signed(chrono::Duration::hours(4)).context("should be valid time")?.timestamp(), 471 - "aud": format!("did:web:{}", config.host_name) 472 - }), 473 - ) 474 - .context("failed to sign jwt")?; 475 - 476 - let refresh_token = auth::sign( 477 - &skey, 478 - "refresh+jwt", 479 - &serde_json::json!({ 480 - "scope": "com.atproto.refresh", 481 - "sub": did, 482 - "iat": chrono::Utc::now().timestamp(), 483 - "exp": chrono::Utc::now().checked_add_days(chrono::Days::new(90)).context("should be valid time")?.timestamp(), 484 - "aud": format!("did:web:{}", config.host_name) 485 - }), 486 - ) 487 - .context("failed to sign refresh jwt")?; 488 - 489 - Ok(Json( 490 - server::create_session::OutputData { 491 - access_jwt: token, 492 - refresh_jwt: refresh_token, 493 - 494 - active: Some(true), 495 - did: Did::from_str(&did).expect("should be valid DID format"), 496 - did_doc: None, 497 - email: None, 498 - email_auth_factor: None, 499 - email_confirmed: None, 500 - handle: Handle::new(account.handle).expect("should be valid handle"), 501 - status: None, 502 - } 503 - .into(), 504 - )) 505 - } 506 - 507 - /// Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt'). 508 - /// - POST /xrpc/com.atproto.server.refreshSession 509 - /// ### Responses 510 - /// - 200 OK: {"accessJwt": "string","refreshJwt": "string","handle": "string","did": "string","didDoc": {},"active": true,"status": "takendown"} 511 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `AccountTakedown`]} 512 - /// - 401 Unauthorized 513 - async fn refresh_session( 514 - State(db): State<Db>, 515 - State(skey): State<SigningKey>, 516 - State(config): State<AppConfig>, 517 - req: Request, 518 - ) -> Result<Json<server::refresh_session::Output>> { 519 - // TODO: store hashes of refresh tokens and enforce single-use 520 - let auth_token = req 521 - .headers() 522 - .get(axum::http::header::AUTHORIZATION) 523 - .context("no authorization header provided")? 524 - .to_str() 525 - .ok() 526 - .and_then(|auth| auth.strip_prefix("Bearer ")) 527 - .context("invalid authentication token")?; 528 - 529 - let (typ, claims) = 530 - auth::verify(&skey.did(), auth_token).context("failed to verify refresh token")?; 531 - if typ != "refresh+jwt" { 532 - return Err(Error::with_status( 533 - StatusCode::UNAUTHORIZED, 534 - anyhow!("invalid refresh token"), 535 - )); 536 - } 537 - if claims 538 - .get("exp") 539 - .and_then(serde_json::Value::as_i64) 540 - .context("failed to get `exp`")? 541 - < chrono::Utc::now().timestamp() 542 - { 543 - return Err(Error::with_status( 544 - StatusCode::UNAUTHORIZED, 545 - anyhow!("refresh token expired"), 546 - )); 547 - } 548 - if claims 549 - .get("aud") 550 - .and_then(|audience| audience.as_str()) 551 - .context("invalid jwt")? 552 - != format!("did:web:{}", config.host_name) 553 - { 554 - return Err(Error::with_status( 555 - StatusCode::UNAUTHORIZED, 556 - anyhow!("invalid audience"), 557 - )); 558 - } 559 - 560 - let did = claims 561 - .get("sub") 562 - .and_then(|subject| subject.as_str()) 563 - .context("invalid jwt")?; 564 - 565 - let user = sqlx::query!( 566 - r#" 567 - SELECT a.status, h.handle 568 - FROM accounts a 569 - JOIN handles h ON a.did = h.did 570 - WHERE a.did = ? 571 - ORDER BY h.created_at ASC 572 - LIMIT 1 573 - "#, 574 - did 575 - ) 576 - .fetch_one(&db) 577 - .await 578 - .context("failed to fetch user account")?; 579 - 580 - let token = auth::sign( 581 - &skey, 582 - "at+jwt", 583 - &serde_json::json!({ 584 - "scope": "com.atproto.access", 585 - "sub": did, 586 - "iat": chrono::Utc::now().timestamp(), 587 - "exp": chrono::Utc::now().checked_add_signed(chrono::Duration::hours(4)).context("should be valid time")?.timestamp(), 588 - "aud": format!("did:web:{}", config.host_name) 589 - }), 590 - ) 591 - .context("failed to sign jwt")?; 592 - 593 - let refresh_token = auth::sign( 594 - &skey, 595 - "refresh+jwt", 596 - &serde_json::json!({ 597 - "scope": "com.atproto.refresh", 598 - "sub": did, 599 - "iat": chrono::Utc::now().timestamp(), 600 - "exp": chrono::Utc::now().checked_add_days(chrono::Days::new(90)).context("should be valid time")?.timestamp(), 601 - "aud": format!("did:web:{}", config.host_name) 602 - }), 603 - ) 604 - .context("failed to sign refresh jwt")?; 605 - 606 - let active = user.status == "active"; 607 - let status = if active { None } else { Some(user.status) }; 608 - 609 - Ok(Json( 610 - server::refresh_session::OutputData { 611 - access_jwt: token, 612 - refresh_jwt: refresh_token, 613 - 614 - active: Some(active), // TODO? 615 - did: Did::new(did.to_owned()).expect("should be valid DID format"), 616 - did_doc: None, 617 - handle: Handle::new(user.handle).expect("should be valid handle"), 618 - status, 619 - } 620 - .into(), 621 - )) 622 - } 623 - 624 - /// Get a signed token on behalf of the requesting DID for the requested service. 625 - /// - GET /xrpc/com.atproto.server.getServiceAuth 626 - /// ### Request Query Parameters 627 - /// - `aud`: string - The DID of the service that the token will be used to authenticate with 628 - /// - `exp`: integer (optional) - The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope. 629 - /// - `lxm`: string (optional) - Lexicon (XRPC) method to bind the requested token to 630 - /// ### Responses 631 - /// - 200 OK: {token: string} 632 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `BadExpiration`]} 633 - /// - 401 Unauthorized 634 - async fn get_service_auth( 635 - user: AuthenticatedUser, 636 - State(skey): State<SigningKey>, 637 - Query(input): Query<server::get_service_auth::ParametersData>, 638 - ) -> Result<Json<server::get_service_auth::Output>> { 639 - let user_did = user.did(); 640 - let aud = input.aud.as_str(); 641 - 642 - let exp = (chrono::Utc::now().checked_add_signed(chrono::Duration::minutes(1))) 643 - .context("should be valid expiration datetime")? 644 - .timestamp(); 645 - let jti = rand::thread_rng() 646 - .sample_iter(rand::distributions::Alphanumeric) 647 - .take(10) 648 - .map(char::from) 649 - .collect::<String>(); 650 - 651 - let mut claims = serde_json::json!({ 652 - "iss": user_did.as_str(), 653 - "aud": aud, 654 - "exp": exp, 655 - "jti": jti, 656 - }); 657 - 658 - if let Some(ref lxm) = input.lxm { 659 - claims = claims 660 - .as_object_mut() 661 - .context("should be a valid object")? 662 - .insert("lxm".to_owned(), serde_json::Value::String(lxm.to_string())) 663 - .context("should be able to insert lxm into claims")?; 664 - } 665 - 666 - // Mint a bearer token by signing a JSON web token. 667 - let token = auth::sign(&skey, "JWT", &claims).context("failed to sign jwt")?; 668 - 669 - Ok(Json(server::get_service_auth::OutputData { token }.into())) 670 - } 671 - 672 - /// Get information about the current auth session. Requires auth. 673 - /// - GET /xrpc/com.atproto.server.getSession 674 - /// ### Responses 675 - /// - 200 OK: {"handle": "string","did": "string","email": "string","emailConfirmed": true,"emailAuthFactor": true,"didDoc": {},"active": true,"status": "takendown"} 676 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 677 - /// - 401 Unauthorized 678 - async fn get_session( 679 - user: AuthenticatedUser, 680 - State(db): State<Db>, 681 - ) -> Result<Json<server::get_session::Output>> { 682 - let did = user.did(); 683 - #[expect(clippy::shadow_unrelated, reason = "is related")] 684 - if let Some(user) = sqlx::query!( 685 - r#" 686 - SELECT a.email, a.status, ( 687 - SELECT h.handle 688 - FROM handles h 689 - WHERE h.did = a.did 690 - ORDER BY h.created_at ASC 691 - LIMIT 1 692 - ) AS handle 693 - FROM accounts a 694 - WHERE a.did = ? 695 - "#, 696 - did 697 - ) 698 - .fetch_optional(&db) 699 - .await 700 - .context("failed to fetch session")? 701 - { 702 - let active = user.status == "active"; 703 - let status = if active { None } else { Some(user.status) }; 704 - 705 - Ok(Json( 706 - server::get_session::OutputData { 707 - active: Some(active), 708 - did: Did::from_str(&did).expect("should be valid DID format"), 709 - did_doc: None, 710 - email: Some(user.email), 711 - email_auth_factor: None, 712 - email_confirmed: None, 713 - handle: Handle::new(user.handle).expect("should be valid handle"), 714 - status, 715 - } 716 - .into(), 717 - )) 718 - } else { 719 - Err(Error::with_status( 720 - StatusCode::UNAUTHORIZED, 721 - anyhow!("user not found"), 722 - )) 723 - } 724 - } 725 - 726 - /// Describes the server's account creation requirements and capabilities. Implemented by PDS. 727 - /// - GET /xrpc/com.atproto.server.describeServer 728 - /// ### Responses 729 - /// - 200 OK: {"inviteCodeRequired": true,"phoneVerificationRequired": true,"availableUserDomains": [`string`],"links": {"privacyPolicy": "string","termsOfService": "string"},"contact": {"email": "string"},"did": "string"} 730 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 731 - /// - 401 Unauthorized 732 - async fn describe_server( 733 - State(config): State<AppConfig>, 734 - ) -> Result<Json<server::describe_server::Output>> { 735 - Ok(Json( 736 - server::describe_server::OutputData { 737 - available_user_domains: vec![], 738 - contact: None, 739 - did: Did::from_str(&format!("did:web:{}", config.host_name)) 740 - .expect("should be valid DID format"), 741 - invite_code_required: Some(true), 742 - links: None, 743 - phone_verification_required: Some(false), // email verification 744 - } 745 - .into(), 746 - )) 747 - } 748 - 749 - async fn todo() -> Result<()> { 750 - Err(Error::unimplemented(anyhow!("not implemented"))) 751 - } 752 - 753 - #[rustfmt::skip] 754 - /// These endpoints are part of the atproto PDS server and account management APIs. \ 755 - /// Requests often require authentication and are made directly to the user's own PDS instance. 756 - /// ### Routes 757 - /// - `POST /xrpc/com.atproto.server.createAccount` -> [`create_account`] 758 - /// - `POST /xrpc/com.atproto.server.createInviteCode` -> [`create_invite_code`] 759 - /// - `POST /xrpc/com.atproto.server.createSession` -> [`create_session`] 760 - /// - `GET /xrpc/com.atproto.server.describeServer` -> [`describe_server`] 761 - /// - `GET /xrpc/com.atproto.server.getServiceAuth` -> [`get_service_auth`] 762 - /// - `GET /xrpc/com.atproto.server.getSession` -> [`get_session`] 763 - /// - `POST /xrpc/com.atproto.server.refreshSession` -> [`refresh_session`] 764 - pub(super) fn routes() -> Router<AppState> { 765 - Router::new() 766 - .route(concat!("/", server::activate_account::NSID), post(todo)) 767 - .route(concat!("/", server::check_account_status::NSID), post(todo)) 768 - .route(concat!("/", server::confirm_email::NSID), post(todo)) 769 - .route(concat!("/", server::create_account::NSID), post(create_account)) 770 - .route(concat!("/", server::create_app_password::NSID), post(todo)) 771 - .route(concat!("/", server::create_invite_code::NSID), post(create_invite_code)) 772 - .route(concat!("/", server::create_invite_codes::NSID), post(todo)) 773 - .route(concat!("/", server::create_session::NSID), post(create_session)) 774 - .route(concat!("/", server::deactivate_account::NSID), post(todo)) 775 - .route(concat!("/", server::delete_account::NSID), post(todo)) 776 - .route(concat!("/", server::delete_session::NSID), post(todo)) 777 - .route(concat!("/", server::describe_server::NSID), get(describe_server)) 778 - .route(concat!("/", server::get_account_invite_codes::NSID), post(todo)) 779 - .route(concat!("/", server::get_service_auth::NSID), get(get_service_auth)) 780 - .route(concat!("/", server::get_session::NSID), get(get_session)) 781 - .route(concat!("/", server::list_app_passwords::NSID), post(todo)) 782 - .route(concat!("/", server::refresh_session::NSID), post(refresh_session)) 783 - .route(concat!("/", server::request_account_delete::NSID), post(todo)) 784 - .route(concat!("/", server::request_email_confirmation::NSID), post(todo)) 785 - .route(concat!("/", server::request_email_update::NSID), post(todo)) 786 - .route(concat!("/", server::request_password_reset::NSID), post(todo)) 787 - .route(concat!("/", server::reserve_signing_key::NSID), post(todo)) 788 - .route(concat!("/", server::reset_password::NSID), post(todo)) 789 - .route(concat!("/", server::revoke_app_password::NSID), post(todo)) 790 - .route(concat!("/", server::update_email::NSID), post(todo)) 791 - }
-428
src/endpoints/sync.rs
··· 1 - //! Endpoints for the `ATProto` sync API. (/xrpc/com.atproto.sync.*) 2 - use std::str::FromStr as _; 3 - 4 - use anyhow::{Context as _, anyhow}; 5 - use atrium_api::{ 6 - com::atproto::sync, 7 - types::{LimitedNonZeroU16, string::Did}, 8 - }; 9 - use atrium_repo::{ 10 - Cid, 11 - blockstore::{ 12 - AsyncBlockStoreRead as _, AsyncBlockStoreWrite as _, CarStore, DAG_CBOR, SHA2_256, 13 - }, 14 - }; 15 - use axum::{ 16 - Json, Router, 17 - body::Body, 18 - extract::{Query, State, WebSocketUpgrade}, 19 - http::{self, Response, StatusCode}, 20 - response::IntoResponse, 21 - routing::get, 22 - }; 23 - use constcat::concat; 24 - use futures::stream::TryStreamExt as _; 25 - use tokio_util::io::ReaderStream; 26 - 27 - use crate::{ 28 - AppState, Db, Error, Result, 29 - config::AppConfig, 30 - firehose::FirehoseProducer, 31 - storage::{open_repo_db, open_store}, 32 - }; 33 - 34 - #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 35 - #[serde(rename_all = "camelCase")] 36 - /// Parameters for `/xrpc/com.atproto.sync.listBlobs` \ 37 - /// HACK: `limit` may be passed as a string, so we must treat it as one. 38 - pub(super) struct ListBlobsParameters { 39 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 40 - /// Optional cursor to paginate through blobs. 41 - pub cursor: Option<String>, 42 - ///The DID of the repo. 43 - pub did: Did, 44 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 45 - /// Optional limit of blobs to return. 46 - pub limit: Option<String>, 47 - ///Optional revision of the repo to list blobs since. 48 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 49 - pub since: Option<String>, 50 - } 51 - #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 52 - #[serde(rename_all = "camelCase")] 53 - /// Parameters for `/xrpc/com.atproto.sync.listRepos` \ 54 - /// HACK: `limit` may be passed as a string, so we must treat it as one. 55 - pub(super) struct ListReposParameters { 56 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 57 - /// Optional cursor to paginate through repos. 58 - pub cursor: Option<String>, 59 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 60 - /// Optional limit of repos to return. 61 - pub limit: Option<String>, 62 - } 63 - #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 64 - #[serde(rename_all = "camelCase")] 65 - /// Parameters for `/xrpc/com.atproto.sync.subscribeRepos` \ 66 - /// HACK: `cursor` may be passed as a string, so we must treat it as one. 67 - pub(super) struct SubscribeReposParametersData { 68 - ///The last known event seq number to backfill from. 69 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 70 - pub cursor: Option<String>, 71 - } 72 - 73 - async fn get_blob( 74 - State(config): State<AppConfig>, 75 - Query(input): Query<sync::get_blob::ParametersData>, 76 - ) -> Result<Response<Body>> { 77 - let blob = config 78 - .blob 79 - .path 80 - .join(format!("{}.blob", input.cid.as_ref())); 81 - 82 - let f = tokio::fs::File::open(blob) 83 - .await 84 - .context("blob not found")?; 85 - let len = f 86 - .metadata() 87 - .await 88 - .context("failed to query file metadata")? 89 - .len(); 90 - 91 - let s = ReaderStream::new(f); 92 - 93 - Ok(Response::builder() 94 - .header(http::header::CONTENT_LENGTH, format!("{len}")) 95 - .body(Body::from_stream(s)) 96 - .context("failed to construct response")?) 97 - } 98 - 99 - /// Enumerates which accounts the requesting account is currently blocking. Requires auth. 100 - /// - GET /xrpc/com.atproto.sync.getBlocks 101 - /// ### Query Parameters 102 - /// - `limit`: integer, optional, default: 50, >=1 and <=100 103 - /// - `cursor`: string, optional 104 - /// ### Responses 105 - /// - 200 OK: ... 106 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 107 - /// - 401 Unauthorized 108 - async fn get_blocks( 109 - State(config): State<AppConfig>, 110 - Query(input): Query<sync::get_blocks::ParametersData>, 111 - ) -> Result<Response<Body>> { 112 - let mut repo = open_store(&config.repo, input.did.as_str()) 113 - .await 114 - .context("failed to open repository")?; 115 - 116 - let mut mem = Vec::new(); 117 - let mut store = CarStore::create(std::io::Cursor::new(&mut mem)) 118 - .await 119 - .context("failed to create intermediate carstore")?; 120 - 121 - for cid in &input.cids { 122 - // SEC: This can potentially fetch stale blocks from a repository (e.g. those that were deleted). 123 - // We'll want to prevent accesses to stale blocks eventually just to respect a user's right to be forgotten. 124 - _ = store 125 - .write_block( 126 - DAG_CBOR, 127 - SHA2_256, 128 - &repo 129 - .read_block(*cid.as_ref()) 130 - .await 131 - .context("failed to read block")?, 132 - ) 133 - .await 134 - .context("failed to write block")?; 135 - } 136 - 137 - Ok(Response::builder() 138 - .header(http::header::CONTENT_TYPE, "application/vnd.ipld.car") 139 - .body(Body::from(mem)) 140 - .context("failed to construct response")?) 141 - } 142 - 143 - /// Get the current commit CID & revision of the specified repo. Does not require auth. 144 - /// ### Query Parameters 145 - /// - `did`: The DID of the repo. 146 - /// ### Responses 147 - /// - 200 OK: {"cid": "string","rev": "string"} 148 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoTakendown`, `RepoSuspended`, `RepoDeactivated`]} 149 - async fn get_latest_commit( 150 - State(config): State<AppConfig>, 151 - State(db): State<Db>, 152 - Query(input): Query<sync::get_latest_commit::ParametersData>, 153 - ) -> Result<Json<sync::get_latest_commit::Output>> { 154 - let repo = open_repo_db(&config.repo, &db, input.did.as_str()) 155 - .await 156 - .context("failed to open repository")?; 157 - 158 - let cid = repo.root(); 159 - let commit = repo.commit(); 160 - 161 - Ok(Json( 162 - sync::get_latest_commit::OutputData { 163 - cid: atrium_api::types::string::Cid::new(cid), 164 - rev: commit.rev(), 165 - } 166 - .into(), 167 - )) 168 - } 169 - 170 - /// Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth. 171 - /// ### Query Parameters 172 - /// - `did`: The DID of the repo. 173 - /// - `collection`: nsid 174 - /// - `rkey`: record-key 175 - /// ### Responses 176 - /// - 200 OK: ... 177 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RecordNotFound`, `RepoNotFound`, `RepoTakendown`, 178 - /// `RepoSuspended`, `RepoDeactivated`]} 179 - async fn get_record( 180 - State(config): State<AppConfig>, 181 - State(db): State<Db>, 182 - Query(input): Query<sync::get_record::ParametersData>, 183 - ) -> Result<Response<Body>> { 184 - let mut repo = open_repo_db(&config.repo, &db, input.did.as_str()) 185 - .await 186 - .context("failed to open repo")?; 187 - 188 - let key = format!("{}/{}", input.collection.as_str(), input.rkey.as_str()); 189 - 190 - let mut contents = Vec::new(); 191 - let mut ret_store = 192 - CarStore::create_with_roots(std::io::Cursor::new(&mut contents), [repo.root()]) 193 - .await 194 - .context("failed to create car store")?; 195 - 196 - repo.extract_raw_into(&key, &mut ret_store) 197 - .await 198 - .context("failed to extract records")?; 199 - 200 - Ok(Response::builder() 201 - .header(http::header::CONTENT_TYPE, "application/vnd.ipld.car") 202 - .body(Body::from(contents)) 203 - .context("failed to construct response")?) 204 - } 205 - 206 - /// Get the hosting status for a repository, on this server. Expected to be implemented by PDS and Relay. 207 - /// ### Query Parameters 208 - /// - `did`: The DID of the repo. 209 - /// ### Responses 210 - /// - 200 OK: {"did": "string","active": true,"status": "takendown","rev": "string"} 211 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`]} 212 - async fn get_repo_status( 213 - State(db): State<Db>, 214 - Query(input): Query<sync::get_repo::ParametersData>, 215 - ) -> Result<Json<sync::get_repo_status::Output>> { 216 - let did = input.did.as_str(); 217 - let r = sqlx::query!(r#"SELECT rev, status FROM accounts WHERE did = ?"#, did) 218 - .fetch_optional(&db) 219 - .await 220 - .context("failed to execute query")?; 221 - 222 - let Some(r) = r else { 223 - return Err(Error::with_status( 224 - StatusCode::NOT_FOUND, 225 - anyhow!("account not found"), 226 - )); 227 - }; 228 - 229 - let active = r.status == "active"; 230 - let status = if active { None } else { Some(r.status) }; 231 - 232 - Ok(Json( 233 - sync::get_repo_status::OutputData { 234 - active, 235 - status, 236 - did: input.did.clone(), 237 - rev: Some( 238 - atrium_api::types::string::Tid::new(r.rev).expect("should be able to convert Tid"), 239 - ), 240 - } 241 - .into(), 242 - )) 243 - } 244 - 245 - /// Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. 246 - /// Does not require auth; implemented by PDS. 247 - /// ### Query Parameters 248 - /// - `did`: The DID of the repo. 249 - /// - `since`: The revision ('rev') of the repo to create a diff from. 250 - /// ### Responses 251 - /// - 200 OK: ... 252 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`, 253 - /// `RepoTakendown`, `RepoSuspended`, `RepoDeactivated`]} 254 - async fn get_repo( 255 - State(config): State<AppConfig>, 256 - State(db): State<Db>, 257 - Query(input): Query<sync::get_repo::ParametersData>, 258 - ) -> Result<Response<Body>> { 259 - let mut repo = open_repo_db(&config.repo, &db, input.did.as_str()) 260 - .await 261 - .context("failed to open repo")?; 262 - 263 - let mut contents = Vec::new(); 264 - let mut store = CarStore::create_with_roots(std::io::Cursor::new(&mut contents), [repo.root()]) 265 - .await 266 - .context("failed to create car store")?; 267 - 268 - repo.export_into(&mut store) 269 - .await 270 - .context("failed to extract records")?; 271 - 272 - Ok(Response::builder() 273 - .header(http::header::CONTENT_TYPE, "application/vnd.ipld.car") 274 - .body(Body::from(contents)) 275 - .context("failed to construct response")?) 276 - } 277 - 278 - /// List blob CIDs for an account, since some repo revision. Does not require auth; implemented by PDS. 279 - /// ### Query Parameters 280 - /// - `did`: The DID of the repo. Required. 281 - /// - `since`: Optional revision of the repo to list blobs since. 282 - /// - `limit`: >= 1 and <= 1000, default 500 283 - /// - `cursor`: string 284 - /// ### Responses 285 - /// - 200 OK: {"cursor": "string","cids": [string]} 286 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`, `RepoNotFound`, `RepoTakendown`, 287 - /// `RepoSuspended`, `RepoDeactivated`]} 288 - async fn list_blobs( 289 - State(db): State<Db>, 290 - Query(input): Query<sync::list_blobs::ParametersData>, 291 - ) -> Result<Json<sync::list_blobs::Output>> { 292 - let did_str = input.did.as_str(); 293 - 294 - // TODO: `input.since` 295 - // TODO: `input.limit` 296 - // TODO: `input.cursor` 297 - 298 - let cids = sqlx::query_scalar!(r#"SELECT cid FROM blob_ref WHERE did = ?"#, did_str) 299 - .fetch_all(&db) 300 - .await 301 - .context("failed to query blobs")?; 302 - 303 - let cids = cids 304 - .into_iter() 305 - .map(|c| { 306 - Cid::from_str(&c) 307 - .map(atrium_api::types::string::Cid::new) 308 - .map_err(anyhow::Error::new) 309 - }) 310 - .collect::<anyhow::Result<Vec<_>>>() 311 - .context("failed to convert cids")?; 312 - 313 - Ok(Json( 314 - sync::list_blobs::OutputData { cursor: None, cids }.into(), 315 - )) 316 - } 317 - 318 - /// Enumerates all the DID, rev, and commit CID for all repos hosted by this service. 319 - /// Does not require auth; implemented by PDS and Relay. 320 - /// ### Query Parameters 321 - /// - `limit`: >= 1 and <= 1000, default 500 322 - /// - `cursor`: string 323 - /// ### Responses 324 - /// - 200 OK: {"cursor": "string","repos": [{"did": "string","head": "string","rev": "string","active": true,"status": "takendown"}]} 325 - /// - 400 Bad Request: {error:[`InvalidRequest`, `ExpiredToken`, `InvalidToken`]} 326 - async fn list_repos( 327 - State(db): State<Db>, 328 - Query(input): Query<sync::list_repos::ParametersData>, 329 - ) -> Result<Json<sync::list_repos::Output>> { 330 - struct Record { 331 - /// The DID of the repo. 332 - did: String, 333 - /// The commit CID of the repo. 334 - rev: String, 335 - /// The root CID of the repo. 336 - root: String, 337 - } 338 - 339 - let limit: u16 = input.limit.unwrap_or(LimitedNonZeroU16::MAX).into(); 340 - 341 - let r = if let Some(ref cursor) = input.cursor { 342 - let r = sqlx::query_as!( 343 - Record, 344 - r#"SELECT did, root, rev FROM accounts WHERE did > ? LIMIT ?"#, 345 - cursor, 346 - limit 347 - ) 348 - .fetch(&db); 349 - 350 - r.try_collect::<Vec<_>>() 351 - .await 352 - .context("failed to fetch profiles")? 353 - } else { 354 - let r = sqlx::query_as!( 355 - Record, 356 - r#"SELECT did, root, rev FROM accounts LIMIT ?"#, 357 - limit 358 - ) 359 - .fetch(&db); 360 - 361 - r.try_collect::<Vec<_>>() 362 - .await 363 - .context("failed to fetch profiles")? 364 - }; 365 - 366 - let cursor = r.last().map(|r| r.did.clone()); 367 - let repos = r 368 - .into_iter() 369 - .map(|r| { 370 - sync::list_repos::RepoData { 371 - active: Some(true), 372 - did: Did::new(r.did).expect("should be a valid DID"), 373 - head: atrium_api::types::string::Cid::new( 374 - Cid::from_str(&r.root).expect("should be a valid CID"), 375 - ), 376 - rev: atrium_api::types::string::Tid::new(r.rev) 377 - .expect("should be able to convert Tid"), 378 - status: None, 379 - } 380 - .into() 381 - }) 382 - .collect::<Vec<_>>(); 383 - 384 - Ok(Json(sync::list_repos::OutputData { cursor, repos }.into())) 385 - } 386 - 387 - /// Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events, 388 - /// for all repositories on the current server. See the atproto specifications for details around stream sequencing, 389 - /// repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay. 390 - /// ### Query Parameters 391 - /// - `cursor`: The last known event seq number to backfill from. 392 - /// ### Responses 393 - /// - 200 OK: ... 394 - async fn subscribe_repos( 395 - ws_up: WebSocketUpgrade, 396 - State(fh): State<FirehoseProducer>, 397 - Query(input): Query<sync::subscribe_repos::ParametersData>, 398 - ) -> impl IntoResponse { 399 - ws_up.on_upgrade(async move |ws| { 400 - fh.client_connection(ws, input.cursor).await; 401 - }) 402 - } 403 - 404 - #[rustfmt::skip] 405 - /// These endpoints are part of the atproto repository synchronization APIs. Requests usually do not require authentication, 406 - /// and can be made to PDS intances or Relay instances. 407 - /// ### Routes 408 - /// - `GET /xrpc/com.atproto.sync.getBlob` -> [`get_blob`] 409 - /// - `GET /xrpc/com.atproto.sync.getBlocks` -> [`get_blocks`] 410 - /// - `GET /xrpc/com.atproto.sync.getLatestCommit` -> [`get_latest_commit`] 411 - /// - `GET /xrpc/com.atproto.sync.getRecord` -> [`get_record`] 412 - /// - `GET /xrpc/com.atproto.sync.getRepoStatus` -> [`get_repo_status`] 413 - /// - `GET /xrpc/com.atproto.sync.getRepo` -> [`get_repo`] 414 - /// - `GET /xrpc/com.atproto.sync.listBlobs` -> [`list_blobs`] 415 - /// - `GET /xrpc/com.atproto.sync.listRepos` -> [`list_repos`] 416 - /// - `GET /xrpc/com.atproto.sync.subscribeRepos` -> [`subscribe_repos`] 417 - pub(super) fn routes() -> Router<AppState> { 418 - Router::new() 419 - .route(concat!("/", sync::get_blob::NSID), get(get_blob)) 420 - .route(concat!("/", sync::get_blocks::NSID), get(get_blocks)) 421 - .route(concat!("/", sync::get_latest_commit::NSID), get(get_latest_commit)) 422 - .route(concat!("/", sync::get_record::NSID), get(get_record)) 423 - .route(concat!("/", sync::get_repo_status::NSID), get(get_repo_status)) 424 - .route(concat!("/", sync::get_repo::NSID), get(get_repo)) 425 - .route(concat!("/", sync::list_blobs::NSID), get(list_blobs)) 426 - .route(concat!("/", sync::list_repos::NSID), get(list_repos)) 427 - .route(concat!("/", sync::subscribe_repos::NSID), get(subscribe_repos)) 428 - }
+151
src/error.rs
··· 4 4 http::StatusCode, 5 5 response::{IntoResponse, Response}, 6 6 }; 7 + use rsky_pds::handle::{self, errors::ErrorKind}; 7 8 use thiserror::Error; 8 9 use tracing::error; 9 10 ··· 118 119 } 119 120 } 120 121 } 122 + 123 + /// API error types that can be returned to clients 124 + #[derive(Clone, Debug)] 125 + pub enum ApiError { 126 + RuntimeError, 127 + InvalidLogin, 128 + AccountTakendown, 129 + InvalidRequest(String), 130 + ExpiredToken, 131 + InvalidToken, 132 + RecordNotFound, 133 + InvalidHandle, 134 + InvalidEmail, 135 + InvalidPassword, 136 + InvalidInviteCode, 137 + HandleNotAvailable, 138 + EmailNotAvailable, 139 + UnsupportedDomain, 140 + UnresolvableDid, 141 + IncompatibleDidDoc, 142 + WellKnownNotFound, 143 + AccountNotFound, 144 + BlobNotFound, 145 + BadRequest(String, String), 146 + AuthRequiredError(String), 147 + } 148 + 149 + impl ApiError { 150 + /// Get the appropriate HTTP status code for this error 151 + const fn status_code(&self) -> StatusCode { 152 + match self { 153 + Self::RuntimeError => StatusCode::INTERNAL_SERVER_ERROR, 154 + Self::InvalidLogin 155 + | Self::ExpiredToken 156 + | Self::InvalidToken 157 + | Self::AuthRequiredError(_) => StatusCode::UNAUTHORIZED, 158 + Self::AccountTakendown => StatusCode::FORBIDDEN, 159 + Self::RecordNotFound 160 + | Self::WellKnownNotFound 161 + | Self::AccountNotFound 162 + | Self::BlobNotFound => StatusCode::NOT_FOUND, 163 + // All bad requests grouped together 164 + _ => StatusCode::BAD_REQUEST, 165 + } 166 + } 167 + 168 + /// Get the error type string for API responses 169 + fn error_type(&self) -> String { 170 + match self { 171 + Self::RuntimeError => "InternalServerError", 172 + Self::InvalidLogin => "InvalidLogin", 173 + Self::AccountTakendown => "AccountTakendown", 174 + Self::InvalidRequest(_) => "InvalidRequest", 175 + Self::ExpiredToken => "ExpiredToken", 176 + Self::InvalidToken => "InvalidToken", 177 + Self::RecordNotFound => "RecordNotFound", 178 + Self::InvalidHandle => "InvalidHandle", 179 + Self::InvalidEmail => "InvalidEmail", 180 + Self::InvalidPassword => "InvalidPassword", 181 + Self::InvalidInviteCode => "InvalidInviteCode", 182 + Self::HandleNotAvailable => "HandleNotAvailable", 183 + Self::EmailNotAvailable => "EmailNotAvailable", 184 + Self::UnsupportedDomain => "UnsupportedDomain", 185 + Self::UnresolvableDid => "UnresolvableDid", 186 + Self::IncompatibleDidDoc => "IncompatibleDidDoc", 187 + Self::WellKnownNotFound => "WellKnownNotFound", 188 + Self::AccountNotFound => "AccountNotFound", 189 + Self::BlobNotFound => "BlobNotFound", 190 + Self::BadRequest(error, _) => error, 191 + Self::AuthRequiredError(_) => "AuthRequiredError", 192 + } 193 + .to_owned() 194 + } 195 + 196 + /// Get the user-facing error message 197 + fn message(&self) -> String { 198 + match self { 199 + Self::RuntimeError => "Something went wrong", 200 + Self::InvalidLogin => "Invalid identifier or password", 201 + Self::AccountTakendown => "Account has been taken down", 202 + Self::InvalidRequest(msg) => msg, 203 + Self::ExpiredToken => "Token is expired", 204 + Self::InvalidToken => "Token is invalid", 205 + Self::RecordNotFound => "Record could not be found", 206 + Self::InvalidHandle => "Handle is invalid", 207 + Self::InvalidEmail => "Invalid email", 208 + Self::InvalidPassword => "Invalid Password", 209 + Self::InvalidInviteCode => "Invalid invite code", 210 + Self::HandleNotAvailable => "Handle not available", 211 + Self::EmailNotAvailable => "Email not available", 212 + Self::UnsupportedDomain => "Unsupported domain", 213 + Self::UnresolvableDid => "Unresolved Did", 214 + Self::IncompatibleDidDoc => "IncompatibleDidDoc", 215 + Self::WellKnownNotFound => "User not found", 216 + Self::AccountNotFound => "Account could not be found", 217 + Self::BlobNotFound => "Blob could not be found", 218 + Self::BadRequest(_, msg) => msg, 219 + Self::AuthRequiredError(msg) => msg, 220 + } 221 + .to_owned() 222 + } 223 + } 224 + 225 + impl From<Error> for ApiError { 226 + fn from(_value: Error) -> Self { 227 + Self::RuntimeError 228 + } 229 + } 230 + 231 + impl From<anyhow::Error> for ApiError { 232 + fn from(_value: anyhow::Error) -> Self { 233 + Self::RuntimeError 234 + } 235 + } 236 + 237 + impl From<handle::errors::Error> for ApiError { 238 + fn from(value: handle::errors::Error) -> Self { 239 + match value.kind { 240 + ErrorKind::InvalidHandle => Self::InvalidHandle, 241 + ErrorKind::HandleNotAvailable => Self::HandleNotAvailable, 242 + ErrorKind::UnsupportedDomain => Self::UnsupportedDomain, 243 + ErrorKind::InternalError => Self::RuntimeError, 244 + } 245 + } 246 + } 247 + 248 + impl IntoResponse for ApiError { 249 + fn into_response(self) -> Response { 250 + let status = self.status_code(); 251 + let error_type = self.error_type(); 252 + let message = self.message(); 253 + 254 + if cfg!(debug_assertions) { 255 + error!("API Error: {}: {}", error_type, message); 256 + } 257 + 258 + // Create the error message and serialize to JSON 259 + let error_message = ErrorMessage::new(error_type, message); 260 + let body = serde_json::to_string(&error_message).unwrap_or_else(|_| { 261 + r#"{"error":"InternalServerError","message":"Error serializing response"}"#.to_owned() 262 + }); 263 + 264 + // Build the response 265 + Response::builder() 266 + .status(status) 267 + .header("Content-Type", "application/json") 268 + .body(Body::new(body)) 269 + .expect("should be a valid response") 270 + } 271 + }
-426
src/firehose.rs
··· 1 - //! The firehose module. 2 - use std::{collections::VecDeque, time::Duration}; 3 - 4 - use anyhow::{Result, bail}; 5 - use atrium_api::{ 6 - com::atproto::sync::{self}, 7 - types::string::{Datetime, Did, Tid}, 8 - }; 9 - use atrium_repo::Cid; 10 - use axum::extract::ws::{Message, WebSocket}; 11 - use metrics::{counter, gauge}; 12 - use rand::Rng as _; 13 - use serde::{Serialize, ser::SerializeMap as _}; 14 - use tracing::{debug, error, info, warn}; 15 - 16 - use crate::{ 17 - Client, 18 - config::AppConfig, 19 - metrics::{FIREHOSE_HISTORY, FIREHOSE_LISTENERS, FIREHOSE_MESSAGES, FIREHOSE_SEQUENCE}, 20 - }; 21 - 22 - enum FirehoseMessage { 23 - Broadcast(sync::subscribe_repos::Message), 24 - Connect(Box<(WebSocket, Option<i64>)>), 25 - } 26 - 27 - enum FrameHeader { 28 - Error, 29 - Message(String), 30 - } 31 - 32 - impl Serialize for FrameHeader { 33 - #[expect(clippy::question_mark_used, reason = "returns a Result")] 34 - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 35 - where 36 - S: serde::Serializer, 37 - { 38 - let mut map = serializer.serialize_map(None)?; 39 - 40 - match *self { 41 - Self::Message(ref s) => { 42 - map.serialize_key("op")?; 43 - map.serialize_value(&1_i32)?; 44 - map.serialize_key("t")?; 45 - map.serialize_value(s.as_str())?; 46 - } 47 - Self::Error => { 48 - map.serialize_key("op")?; 49 - map.serialize_value(&-1_i32)?; 50 - } 51 - } 52 - 53 - map.end() 54 - } 55 - } 56 - 57 - /// A repository operation. 58 - pub(crate) enum RepoOp { 59 - /// Create a new record. 60 - Create { 61 - /// The CID of the record. 62 - cid: Cid, 63 - /// The path of the record. 64 - path: String, 65 - }, 66 - /// Delete an existing record. 67 - Delete { 68 - /// The path of the record. 69 - path: String, 70 - /// The previous CID of the record. 71 - prev: Cid, 72 - }, 73 - /// Update an existing record. 74 - Update { 75 - /// The CID of the record. 76 - cid: Cid, 77 - /// The path of the record. 78 - path: String, 79 - /// The previous CID of the record. 80 - prev: Cid, 81 - }, 82 - } 83 - 84 - impl From<RepoOp> for sync::subscribe_repos::RepoOp { 85 - fn from(val: RepoOp) -> Self { 86 - let (action, cid, prev, path) = match val { 87 - RepoOp::Create { cid, path } => ("create", Some(cid), None, path), 88 - RepoOp::Update { cid, path, prev } => ("update", Some(cid), Some(prev), path), 89 - RepoOp::Delete { path, prev } => ("delete", None, Some(prev), path), 90 - }; 91 - 92 - sync::subscribe_repos::RepoOpData { 93 - action: action.to_owned(), 94 - cid: cid.map(atrium_api::types::CidLink), 95 - prev: prev.map(atrium_api::types::CidLink), 96 - path, 97 - } 98 - .into() 99 - } 100 - } 101 - 102 - /// A commit to the repository. 103 - pub(crate) struct Commit { 104 - /// Blobs that were created in this commit. 105 - pub blobs: Vec<Cid>, 106 - /// The car file containing the commit blocks. 107 - pub car: Vec<u8>, 108 - /// The CID of the commit. 109 - pub cid: Cid, 110 - /// The DID of the repository changed. 111 - pub did: Did, 112 - /// The operations performed in this commit. 113 - pub ops: Vec<RepoOp>, 114 - /// The previous commit's CID (if applicable). 115 - pub pcid: Option<Cid>, 116 - /// The revision of the commit. 117 - pub rev: String, 118 - } 119 - 120 - impl From<Commit> for sync::subscribe_repos::Commit { 121 - fn from(val: Commit) -> Self { 122 - sync::subscribe_repos::CommitData { 123 - blobs: val 124 - .blobs 125 - .into_iter() 126 - .map(atrium_api::types::CidLink) 127 - .collect::<Vec<_>>(), 128 - blocks: val.car, 129 - commit: atrium_api::types::CidLink(val.cid), 130 - ops: val.ops.into_iter().map(Into::into).collect::<Vec<_>>(), 131 - prev_data: val.pcid.map(atrium_api::types::CidLink), 132 - rebase: false, 133 - repo: val.did, 134 - rev: Tid::new(val.rev).expect("should be valid revision"), 135 - seq: 0, 136 - since: None, 137 - time: Datetime::now(), 138 - too_big: false, 139 - } 140 - .into() 141 - } 142 - } 143 - 144 - /// A firehose producer. This is used to transmit messages to the firehose for broadcast. 145 - #[derive(Clone, Debug)] 146 - pub(crate) struct FirehoseProducer { 147 - /// The channel to send messages to the firehose. 148 - tx: tokio::sync::mpsc::Sender<FirehoseMessage>, 149 - } 150 - 151 - impl FirehoseProducer { 152 - /// Broadcast an `#account` event. 153 - pub(crate) async fn account(&self, account: impl Into<sync::subscribe_repos::Account>) { 154 - drop( 155 - self.tx 156 - .send(FirehoseMessage::Broadcast( 157 - sync::subscribe_repos::Message::Account(Box::new(account.into())), 158 - )) 159 - .await, 160 - ); 161 - } 162 - /// Handle client connection. 163 - pub(crate) async fn client_connection(&self, ws: WebSocket, cursor: Option<i64>) { 164 - drop( 165 - self.tx 166 - .send(FirehoseMessage::Connect(Box::new((ws, cursor)))) 167 - .await, 168 - ); 169 - } 170 - /// Broadcast a `#commit` event. 171 - pub(crate) async fn commit(&self, commit: impl Into<sync::subscribe_repos::Commit>) { 172 - drop( 173 - self.tx 174 - .send(FirehoseMessage::Broadcast( 175 - sync::subscribe_repos::Message::Commit(Box::new(commit.into())), 176 - )) 177 - .await, 178 - ); 179 - } 180 - /// Broadcast an `#identity` event. 181 - pub(crate) async fn identity(&self, identity: impl Into<sync::subscribe_repos::Identity>) { 182 - drop( 183 - self.tx 184 - .send(FirehoseMessage::Broadcast( 185 - sync::subscribe_repos::Message::Identity(Box::new(identity.into())), 186 - )) 187 - .await, 188 - ); 189 - } 190 - } 191 - 192 - #[expect( 193 - clippy::as_conversions, 194 - clippy::cast_possible_truncation, 195 - clippy::cast_sign_loss, 196 - clippy::cast_precision_loss, 197 - clippy::arithmetic_side_effects 198 - )] 199 - /// Convert a `usize` to a `f64`. 200 - const fn convert_usize_f64(x: usize) -> Result<f64, &'static str> { 201 - let result = x as f64; 202 - if result as usize - x > 0 { 203 - return Err("cannot convert"); 204 - } 205 - Ok(result) 206 - } 207 - 208 - /// Serialize a message. 209 - fn serialize_message(seq: u64, mut msg: sync::subscribe_repos::Message) -> (&'static str, Vec<u8>) { 210 - let mut dummy_seq = 0_i64; 211 - #[expect(clippy::pattern_type_mismatch)] 212 - let (ty, nseq) = match &mut msg { 213 - sync::subscribe_repos::Message::Account(m) => ("#account", &mut m.seq), 214 - sync::subscribe_repos::Message::Commit(m) => ("#commit", &mut m.seq), 215 - sync::subscribe_repos::Message::Identity(m) => ("#identity", &mut m.seq), 216 - sync::subscribe_repos::Message::Sync(m) => ("#sync", &mut m.seq), 217 - sync::subscribe_repos::Message::Info(_m) => ("#info", &mut dummy_seq), 218 - }; 219 - // Set the sequence number. 220 - *nseq = i64::try_from(seq).expect("should find seq"); 221 - 222 - let hdr = FrameHeader::Message(ty.to_owned()); 223 - 224 - let mut frame = Vec::new(); 225 - serde_ipld_dagcbor::to_writer(&mut frame, &hdr).expect("should serialize header"); 226 - serde_ipld_dagcbor::to_writer(&mut frame, &msg).expect("should serialize message"); 227 - 228 - (ty, frame) 229 - } 230 - 231 - /// Broadcast a message out to all clients. 232 - async fn broadcast_message(clients: &mut Vec<WebSocket>, msg: Message) -> Result<()> { 233 - counter!(FIREHOSE_MESSAGES).increment(1); 234 - 235 - for i in (0..clients.len()).rev() { 236 - let client = clients.get_mut(i).expect("should find client"); 237 - if let Err(e) = client.send(msg.clone()).await { 238 - debug!("Firehose client disconnected: {e}"); 239 - drop(clients.remove(i)); 240 - } 241 - } 242 - 243 - gauge!(FIREHOSE_LISTENERS) 244 - .set(convert_usize_f64(clients.len()).expect("should find clients length")); 245 - Ok(()) 246 - } 247 - 248 - /// Handle a new connection from a websocket client created by subscribeRepos. 249 - async fn handle_connect( 250 - mut ws: WebSocket, 251 - seq: u64, 252 - history: &VecDeque<(u64, &str, sync::subscribe_repos::Message)>, 253 - cursor: Option<i64>, 254 - ) -> Result<WebSocket> { 255 - if let Some(cursor) = cursor { 256 - let mut frame = Vec::new(); 257 - let cursor = u64::try_from(cursor); 258 - if cursor.is_err() { 259 - tracing::warn!("cursor is not a valid u64"); 260 - return Ok(ws); 261 - } 262 - let cursor = cursor.expect("should be valid u64"); 263 - // Cursor specified; attempt to backfill the consumer. 264 - if cursor > seq { 265 - let hdr = FrameHeader::Error; 266 - let msg = sync::subscribe_repos::Error::FutureCursor(Some(format!( 267 - "cursor {cursor} is greater than the current sequence number {seq}" 268 - ))); 269 - serde_ipld_dagcbor::to_writer(&mut frame, &hdr).expect("should serialize header"); 270 - serde_ipld_dagcbor::to_writer(&mut frame, &msg).expect("should serialize message"); 271 - // Drop the connection. 272 - drop(ws.send(Message::binary(frame)).await); 273 - bail!( 274 - "connection dropped: cursor {cursor} is greater than the current sequence number {seq}" 275 - ); 276 - } 277 - 278 - for &(historical_seq, ty, ref msg) in history { 279 - if cursor > historical_seq { 280 - continue; 281 - } 282 - let hdr = FrameHeader::Message(ty.to_owned()); 283 - serde_ipld_dagcbor::to_writer(&mut frame, &hdr).expect("should serialize header"); 284 - serde_ipld_dagcbor::to_writer(&mut frame, msg).expect("should serialize message"); 285 - if let Err(e) = ws.send(Message::binary(frame.clone())).await { 286 - debug!("Firehose client disconnected during backfill: {e}"); 287 - break; 288 - } 289 - // Clear out the frame to begin a new one. 290 - frame.clear(); 291 - } 292 - } 293 - 294 - Ok(ws) 295 - } 296 - 297 - /// Reconnect to upstream relays. 298 - pub(crate) async fn reconnect_relays(client: &Client, config: &AppConfig) { 299 - // Avoid connecting to upstream relays in test mode. 300 - if config.test { 301 - return; 302 - } 303 - 304 - info!("attempting to reconnect to upstream relays"); 305 - for relay in &config.firehose.relays { 306 - let Some(host) = relay.host_str() else { 307 - warn!("relay {} has no host specified", relay); 308 - continue; 309 - }; 310 - 311 - let r = client 312 - .post(format!("https://{host}/xrpc/com.atproto.sync.requestCrawl")) 313 - .json(&serde_json::json!({ 314 - "hostname": format!("https://{}", config.host_name) 315 - })) 316 - .send() 317 - .await; 318 - 319 - let r = match r { 320 - Ok(r) => r, 321 - Err(e) => { 322 - error!("failed to hit upstream relay {host}: {e}"); 323 - continue; 324 - } 325 - }; 326 - 327 - let s = r.status(); 328 - if let Err(e) = r.error_for_status_ref() { 329 - error!("failed to hit upstream relay {host}: {e}"); 330 - } 331 - 332 - let b = r.json::<serde_json::Value>().await; 333 - if let Ok(b) = b { 334 - info!("relay {host}: {} {}", s, b); 335 - } else { 336 - info!("relay {host}: {}", s); 337 - } 338 - } 339 - } 340 - 341 - /// The main entrypoint for the firehose. 342 - /// 343 - /// This will broadcast all updates in this PDS out to anyone who is listening. 344 - /// 345 - /// Reference: <https://atproto.com/specs/sync> 346 - pub(crate) fn spawn( 347 - client: Client, 348 - config: AppConfig, 349 - ) -> (tokio::task::JoinHandle<()>, FirehoseProducer) { 350 - let (tx, mut rx) = tokio::sync::mpsc::channel(1000); 351 - let handle = tokio::spawn(async move { 352 - fn time_since_inception() -> u64 { 353 - chrono::Utc::now() 354 - .timestamp_micros() 355 - .checked_sub(1_743_442_000_000_000) 356 - .expect("should not wrap") 357 - .unsigned_abs() 358 - } 359 - let mut clients: Vec<WebSocket> = Vec::new(); 360 - let mut history = VecDeque::with_capacity(1000); 361 - let mut seq = time_since_inception(); 362 - 363 - loop { 364 - if let Ok(msg) = tokio::time::timeout(Duration::from_secs(30), rx.recv()).await { 365 - match msg { 366 - Some(FirehoseMessage::Broadcast(msg)) => { 367 - let (ty, by) = serialize_message(seq, msg.clone()); 368 - 369 - history.push_back((seq, ty, msg)); 370 - gauge!(FIREHOSE_HISTORY).set( 371 - convert_usize_f64(history.len()).expect("should find history length"), 372 - ); 373 - 374 - info!( 375 - "Broadcasting message {} {} to {} clients", 376 - seq, 377 - ty, 378 - clients.len() 379 - ); 380 - 381 - counter!(FIREHOSE_SEQUENCE).absolute(seq); 382 - let now = time_since_inception(); 383 - if now > seq { 384 - seq = now; 385 - } else { 386 - seq = seq.checked_add(1).expect("should not wrap"); 387 - } 388 - 389 - drop(broadcast_message(&mut clients, Message::binary(by)).await); 390 - } 391 - Some(FirehoseMessage::Connect(ws_cursor)) => { 392 - let (ws, cursor) = *ws_cursor; 393 - match handle_connect(ws, seq, &history, cursor).await { 394 - Ok(r) => { 395 - gauge!(FIREHOSE_LISTENERS).increment(1_i32); 396 - clients.push(r); 397 - } 398 - Err(e) => { 399 - error!("failed to connect new client: {e}"); 400 - } 401 - } 402 - } 403 - // All producers have been destroyed. 404 - None => break, 405 - } 406 - } else { 407 - if clients.is_empty() { 408 - reconnect_relays(&client, &config).await; 409 - } 410 - 411 - let contents = rand::thread_rng() 412 - .sample_iter(rand::distributions::Alphanumeric) 413 - .take(15) 414 - .map(char::from) 415 - .collect::<String>(); 416 - 417 - // Send a websocket ping message. 418 - // Reference: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets 419 - let message = Message::Ping(axum::body::Bytes::from_owner(contents)); 420 - drop(broadcast_message(&mut clients, message).await); 421 - } 422 - } 423 - }); 424 - 425 - (handle, FirehoseProducer { tx }) 426 - }
+6 -424
src/lib.rs
··· 2 2 mod account_manager; 3 3 mod actor_endpoints; 4 4 mod actor_store; 5 + mod apis; 5 6 mod auth; 6 7 mod config; 7 8 mod db; 8 9 mod did; 9 - mod endpoints; 10 10 pub mod error; 11 - mod firehose; 12 11 mod metrics; 13 - mod mmap; 12 + mod models; 14 13 mod oauth; 15 - mod plc; 14 + mod pipethrough; 16 15 mod schema; 16 + mod serve; 17 17 mod service_proxy; 18 - #[cfg(test)] 19 - mod tests; 20 - 21 - use anyhow::{Context as _, anyhow}; 22 - use atrium_api::types::string::Did; 23 - use atrium_crypto::keypair::{Export as _, Secp256k1Keypair}; 24 - use auth::AuthenticatedUser; 25 - use axum::{ 26 - Router, 27 - body::Body, 28 - extract::{FromRef, Request, State}, 29 - http::{self, HeaderMap, Response, StatusCode, Uri}, 30 - response::IntoResponse, 31 - routing::get, 32 - }; 33 - use azure_core::credentials::TokenCredential; 34 - use clap::Parser; 35 - use clap_verbosity_flag::{InfoLevel, Verbosity, log::LevelFilter}; 36 - use config::AppConfig; 37 - use db::establish_pool; 38 - use deadpool_diesel::sqlite::Pool; 39 - use diesel::prelude::*; 40 - use diesel_migrations::{EmbeddedMigrations, embed_migrations}; 41 - pub use error::Error; 42 - use figment::{Figment, providers::Format as _}; 43 - use firehose::FirehoseProducer; 44 - use http_cache_reqwest::{CacheMode, HttpCacheOptions, MokaManager}; 45 - use rand::Rng as _; 46 - use serde::{Deserialize, Serialize}; 47 - use service_proxy::service_proxy; 48 - use std::{ 49 - net::{IpAddr, Ipv4Addr, SocketAddr}, 50 - path::PathBuf, 51 - str::FromStr as _, 52 - sync::Arc, 53 - }; 54 - use tokio::net::TcpListener; 55 - use tower_http::{cors::CorsLayer, trace::TraceLayer}; 56 - use tracing::{info, warn}; 57 - use uuid::Uuid; 58 - 59 - /// The application user agent. Concatenates the package name and version. e.g. `bluepds/0.0.0`. 60 - pub const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); 61 - 62 - /// Embedded migrations 63 - pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); 64 18 65 - /// The application-wide result type. 66 - pub type Result<T> = std::result::Result<T, Error>; 67 - /// The reqwest client type with middleware. 68 - pub type Client = reqwest_middleware::ClientWithMiddleware; 69 - /// The Azure credential type. 70 - pub type Cred = Arc<dyn TokenCredential>; 71 - 72 - #[expect( 73 - clippy::arbitrary_source_item_ordering, 74 - reason = "serialized data might be structured" 75 - )] 76 - #[derive(Serialize, Deserialize, Debug, Clone)] 77 - /// The key data structure. 78 - struct KeyData { 79 - /// Primary signing key for all repo operations. 80 - skey: Vec<u8>, 81 - /// Primary signing (rotation) key for all PLC operations. 82 - rkey: Vec<u8>, 83 - } 84 - 85 - // FIXME: We should use P256Keypair instead. SecP256K1 is primarily used for cryptocurrencies, 86 - // and the implementations of this algorithm are much more limited as compared to P256. 87 - // 88 - // Reference: https://soatok.blog/2022/05/19/guidance-for-choosing-an-elliptic-curve-signature-algorithm-in-2022/ 89 - #[derive(Clone)] 90 - /// The signing key for PLC/DID operations. 91 - pub struct SigningKey(Arc<Secp256k1Keypair>); 92 - #[derive(Clone)] 93 - /// The rotation key for PLC operations. 94 - pub struct RotationKey(Arc<Secp256k1Keypair>); 95 - 96 - impl std::ops::Deref for SigningKey { 97 - type Target = Secp256k1Keypair; 98 - 99 - fn deref(&self) -> &Self::Target { 100 - &self.0 101 - } 102 - } 103 - 104 - impl SigningKey { 105 - /// Import from a private key. 106 - pub fn import(key: &[u8]) -> Result<Self> { 107 - let key = Secp256k1Keypair::import(key).context("failed to import signing key")?; 108 - Ok(Self(Arc::new(key))) 109 - } 110 - } 111 - 112 - impl std::ops::Deref for RotationKey { 113 - type Target = Secp256k1Keypair; 114 - 115 - fn deref(&self) -> &Self::Target { 116 - &self.0 117 - } 118 - } 119 - 120 - #[derive(Parser, Debug, Clone)] 121 - /// Command line arguments. 122 - pub struct Args { 123 - /// Path to the configuration file 124 - #[arg(short, long, default_value = "default.toml")] 125 - pub config: PathBuf, 126 - /// The verbosity level. 127 - #[command(flatten)] 128 - pub verbosity: Verbosity<InfoLevel>, 129 - } 130 - 131 - pub struct ActorPools { 132 - pub repo: Pool, 133 - pub blob: Pool, 134 - } 135 - 136 - impl Clone for ActorPools { 137 - fn clone(&self) -> Self { 138 - Self { 139 - repo: self.repo.clone(), 140 - blob: self.blob.clone(), 141 - } 142 - } 143 - } 144 - 145 - #[expect(clippy::arbitrary_source_item_ordering, reason = "arbitrary")] 146 - #[derive(Clone, FromRef)] 147 - pub struct AppState { 148 - /// The application configuration. 149 - pub config: AppConfig, 150 - /// The Azure credential. 151 - pub cred: Cred, 152 - /// The main database connection pool. Used for common PDS data, like invite codes. 153 - pub db: Pool, 154 - /// Actor-specific database connection pools. Hashed by DID. 155 - pub db_actors: std::collections::HashMap<String, ActorPools>, 156 - 157 - /// The HTTP client with middleware. 158 - pub client: Client, 159 - /// The simple HTTP client. 160 - pub simple_client: reqwest::Client, 161 - /// The firehose producer. 162 - pub firehose: FirehoseProducer, 163 - 164 - /// The signing key. 165 - pub signing_key: SigningKey, 166 - /// The rotation key. 167 - pub rotation_key: RotationKey, 168 - } 19 + pub use serve::run; 169 20 170 21 /// The index (/) route. 171 - async fn index() -> impl IntoResponse { 22 + async fn index() -> impl axum::response::IntoResponse { 172 23 r" 173 24 __ __ 174 25 /\ \__ /\ \__ ··· 189 40 Protocol: https://atproto.com 190 41 " 191 42 } 192 - 193 - /// The main application entry point. 194 - #[expect( 195 - clippy::cognitive_complexity, 196 - clippy::too_many_lines, 197 - unused_qualifications, 198 - reason = "main function has high complexity" 199 - )] 200 - pub async fn run() -> anyhow::Result<()> { 201 - let args = Args::parse(); 202 - 203 - // Set up trace logging to console and account for the user-provided verbosity flag. 204 - if args.verbosity.log_level_filter() != LevelFilter::Off { 205 - let lvl = match args.verbosity.log_level_filter() { 206 - LevelFilter::Error => tracing::Level::ERROR, 207 - LevelFilter::Warn => tracing::Level::WARN, 208 - LevelFilter::Info | LevelFilter::Off => tracing::Level::INFO, 209 - LevelFilter::Debug => tracing::Level::DEBUG, 210 - LevelFilter::Trace => tracing::Level::TRACE, 211 - }; 212 - tracing_subscriber::fmt().with_max_level(lvl).init(); 213 - } 214 - 215 - if !args.config.exists() { 216 - // Throw up a warning if the config file does not exist. 217 - // 218 - // This is not fatal because users can specify all configuration settings via 219 - // the environment, but the most likely scenario here is that a user accidentally 220 - // omitted the config file for some reason (e.g. forgot to mount it into Docker). 221 - warn!( 222 - "configuration file {} does not exist", 223 - args.config.display() 224 - ); 225 - } 226 - 227 - // Read and parse the user-provided configuration. 228 - let config: AppConfig = Figment::new() 229 - .admerge(figment::providers::Toml::file(args.config)) 230 - .admerge(figment::providers::Env::prefixed("BLUEPDS_")) 231 - .extract() 232 - .context("failed to load configuration")?; 233 - 234 - if config.test { 235 - warn!("BluePDS starting up in TEST mode."); 236 - warn!("This means the application will not federate with the rest of the network."); 237 - warn!( 238 - "If you want to turn this off, either set `test` to false in the config or define `BLUEPDS_TEST = false`" 239 - ); 240 - } 241 - 242 - // Initialize metrics reporting. 243 - metrics::setup(config.metrics.as_ref()).context("failed to set up metrics exporter")?; 244 - 245 - // Create a reqwest client that will be used for all outbound requests. 246 - let simple_client = reqwest::Client::builder() 247 - .user_agent(APP_USER_AGENT) 248 - .build() 249 - .context("failed to build requester client")?; 250 - let client = reqwest_middleware::ClientBuilder::new(simple_client.clone()) 251 - .with(http_cache_reqwest::Cache(http_cache_reqwest::HttpCache { 252 - mode: CacheMode::Default, 253 - manager: MokaManager::default(), 254 - options: HttpCacheOptions::default(), 255 - })) 256 - .build(); 257 - 258 - tokio::fs::create_dir_all(&config.key.parent().context("should have parent")?) 259 - .await 260 - .context("failed to create key directory")?; 261 - 262 - // Check if crypto keys exist. If not, create new ones. 263 - let (skey, rkey) = if let Ok(f) = std::fs::File::open(&config.key) { 264 - let keys: KeyData = serde_ipld_dagcbor::from_reader(std::io::BufReader::new(f)) 265 - .context("failed to deserialize crypto keys")?; 266 - 267 - let skey = Secp256k1Keypair::import(&keys.skey).context("failed to import signing key")?; 268 - let rkey = Secp256k1Keypair::import(&keys.rkey).context("failed to import rotation key")?; 269 - 270 - (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey))) 271 - } else { 272 - info!("signing keys not found, generating new ones"); 273 - 274 - let skey = Secp256k1Keypair::create(&mut rand::thread_rng()); 275 - let rkey = Secp256k1Keypair::create(&mut rand::thread_rng()); 276 - 277 - let keys = KeyData { 278 - skey: skey.export(), 279 - rkey: rkey.export(), 280 - }; 281 - 282 - let mut f = std::fs::File::create(&config.key).context("failed to create key file")?; 283 - serde_ipld_dagcbor::to_writer(&mut f, &keys).context("failed to serialize crypto keys")?; 284 - 285 - (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey))) 286 - }; 287 - 288 - tokio::fs::create_dir_all(&config.repo.path).await?; 289 - tokio::fs::create_dir_all(&config.plc.path).await?; 290 - tokio::fs::create_dir_all(&config.blob.path).await?; 291 - 292 - let cred = azure_identity::DefaultAzureCredential::new() 293 - .context("failed to create Azure credential")?; 294 - 295 - // Create a database connection manager and pool for the main database. 296 - let pool = 297 - establish_pool(&config.db).context("failed to establish database connection pool")?; 298 - // Create a dictionary of database connection pools for each actor. 299 - let mut actor_pools = std::collections::HashMap::new(); 300 - // let mut actor_blob_pools = std::collections::HashMap::new(); 301 - // We'll determine actors by looking in the data/repo dir for .db files. 302 - let mut actor_dbs = tokio::fs::read_dir(&config.repo.path) 303 - .await 304 - .context("failed to read repo directory")?; 305 - while let Some(entry) = actor_dbs 306 - .next_entry() 307 - .await 308 - .context("failed to read repo dir")? 309 - { 310 - let path = entry.path(); 311 - if path.extension().and_then(|s| s.to_str()) == Some("db") { 312 - let did_path = path 313 - .file_stem() 314 - .and_then(|s| s.to_str()) 315 - .context("failed to get actor DID")?; 316 - let did = Did::from_str(&format!("did:plc:{}", did_path)) 317 - .expect("should be able to parse actor DID"); 318 - 319 - // Create a new database connection manager and pool for the actor. 320 - // The path for the SQLite connection needs to look like "sqlite://data/repo/<actor>.db" 321 - let path_repo = format!("sqlite://{}", did_path); 322 - let actor_repo_pool = 323 - establish_pool(&path_repo).context("failed to create database connection pool")?; 324 - // Create a new database connection manager and pool for the actor blobs. 325 - // The path for the SQLite connection needs to look like "sqlite://data/blob/<actor>.db" 326 - let path_blob = path_repo.replace("repo", "blob"); 327 - let actor_blob_pool = 328 - establish_pool(&path_blob).context("failed to create database connection pool")?; 329 - drop(actor_pools.insert( 330 - did.to_string(), 331 - ActorPools { 332 - repo: actor_repo_pool, 333 - blob: actor_blob_pool, 334 - }, 335 - )); 336 - } 337 - } 338 - // Apply pending migrations 339 - // let conn = pool.get().await?; 340 - // conn.run_pending_migrations(MIGRATIONS) 341 - // .expect("should be able to run migrations"); 342 - 343 - let (_fh, fhp) = firehose::spawn(client.clone(), config.clone()); 344 - 345 - let addr = config 346 - .listen_address 347 - .unwrap_or(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8000)); 348 - 349 - let app = Router::new() 350 - .route("/", get(index)) 351 - .merge(oauth::routes()) 352 - .nest( 353 - "/xrpc", 354 - endpoints::routes() 355 - .merge(actor_endpoints::routes()) 356 - .fallback(service_proxy), 357 - ) 358 - // .layer(RateLimitLayer::new(30, Duration::from_secs(30))) 359 - .layer(CorsLayer::permissive()) 360 - .layer(TraceLayer::new_for_http()) 361 - .with_state(AppState { 362 - cred, 363 - config: config.clone(), 364 - db: pool.clone(), 365 - db_actors: actor_pools.clone(), 366 - client: client.clone(), 367 - simple_client, 368 - firehose: fhp, 369 - signing_key: skey, 370 - rotation_key: rkey, 371 - }); 372 - 373 - info!("listening on {addr}"); 374 - info!("connect to: http://127.0.0.1:{}", addr.port()); 375 - 376 - // Determine whether or not this was the first startup (i.e. no accounts exist and no invite codes were created). 377 - // If so, create an invite code and share it via the console. 378 - let conn = pool.get().await.context("failed to get db connection")?; 379 - 380 - #[derive(QueryableByName)] 381 - struct TotalCount { 382 - #[diesel(sql_type = diesel::sql_types::Integer)] 383 - total_count: i32, 384 - } 385 - 386 - let result = conn.interact(move |conn| { 387 - diesel::sql_query( 388 - "SELECT (SELECT COUNT(*) FROM accounts) + (SELECT COUNT(*) FROM invites) AS total_count", 389 - ) 390 - .get_result::<TotalCount>(conn) 391 - }) 392 - .await 393 - .expect("should be able to query database")?; 394 - 395 - let c = result.total_count; 396 - 397 - #[expect(clippy::print_stdout)] 398 - if c == 0 { 399 - let uuid = Uuid::new_v4().to_string(); 400 - 401 - let uuid_clone = uuid.clone(); 402 - _ = conn 403 - .interact(move |conn| { 404 - diesel::sql_query( 405 - "INSERT INTO invites (id, did, count, created_at) VALUES (?, NULL, 1, datetime('now'))", 406 - ) 407 - .bind::<diesel::sql_types::Text, _>(uuid_clone) 408 - .execute(conn) 409 - .context("failed to create new invite code") 410 - .expect("should be able to create invite code") 411 - }) 412 - .await 413 - .expect("should be able to create invite code"); 414 - 415 - // N.B: This is a sensitive message, so we're bypassing `tracing` here and 416 - // logging it directly to console. 417 - println!("====================================="); 418 - println!(" FIRST STARTUP "); 419 - println!("====================================="); 420 - println!("Use this code to create an account:"); 421 - println!("{uuid}"); 422 - println!("====================================="); 423 - } 424 - 425 - let listener = TcpListener::bind(&addr) 426 - .await 427 - .context("failed to bind address")?; 428 - 429 - // Serve the app, and request crawling from upstream relays. 430 - let serve = tokio::spawn(async move { 431 - axum::serve(listener, app.into_make_service()) 432 - .await 433 - .context("failed to serve app") 434 - }); 435 - 436 - // Now that the app is live, request a crawl from upstream relays. 437 - firehose::reconnect_relays(&client, &config).await; 438 - 439 - serve 440 - .await 441 - .map_err(Into::into) 442 - .and_then(|r| r) 443 - .context("failed to serve app") 444 - } 445 - 446 - /// Creates an app router with the provided AppState. 447 - pub fn create_app(state: AppState) -> Router { 448 - Router::new() 449 - .route("/", get(index)) 450 - .merge(oauth::routes()) 451 - .nest( 452 - "/xrpc", 453 - endpoints::routes() 454 - .merge(actor_endpoints::routes()) 455 - .fallback(service_proxy), 456 - ) 457 - .layer(CorsLayer::permissive()) 458 - .layer(TraceLayer::new_for_http()) 459 - .with_state(state) 460 - }
+1 -3
src/main.rs
··· 1 1 //! BluePDS binary entry point. 2 2 3 3 use anyhow::Context as _; 4 - use clap::Parser; 5 4 6 5 #[tokio::main(flavor = "multi_thread")] 7 6 async fn main() -> anyhow::Result<()> { 8 - // Parse command line arguments and call into the library's run function 9 7 bluepds::run().await.context("failed to run application") 10 - } 8 + }
-274
src/mmap.rs
··· 1 - #![allow(clippy::arbitrary_source_item_ordering)] 2 - use std::io::{ErrorKind, Read as _, Seek as _, Write as _}; 3 - 4 - #[cfg(unix)] 5 - use std::os::fd::AsRawFd as _; 6 - #[cfg(windows)] 7 - use std::os::windows::io::AsRawHandle; 8 - 9 - use memmap2::{MmapMut, MmapOptions}; 10 - 11 - pub(crate) struct MappedFile { 12 - /// The underlying file handle. 13 - file: std::fs::File, 14 - /// The length of the file. 15 - len: u64, 16 - /// The mapped memory region. 17 - map: MmapMut, 18 - /// Our current offset into the file. 19 - off: u64, 20 - } 21 - 22 - impl MappedFile { 23 - pub(crate) fn new(mut f: std::fs::File) -> std::io::Result<Self> { 24 - let len = f.seek(std::io::SeekFrom::End(0))?; 25 - 26 - #[cfg(windows)] 27 - let raw = f.as_raw_handle(); 28 - #[cfg(unix)] 29 - let raw = f.as_raw_fd(); 30 - 31 - #[expect(unsafe_code)] 32 - Ok(Self { 33 - // SAFETY: 34 - // All file-backed memory map constructors are marked \ 35 - // unsafe because of the potential for Undefined Behavior (UB) \ 36 - // using the map if the underlying file is subsequently modified, in or out of process. 37 - map: unsafe { MmapOptions::new().map_mut(raw)? }, 38 - file: f, 39 - len, 40 - off: 0, 41 - }) 42 - } 43 - 44 - /// Resize the memory-mapped file. This will reallocate the memory mapping. 45 - #[expect(unsafe_code)] 46 - fn resize(&mut self, len: u64) -> std::io::Result<()> { 47 - // Resize the file. 48 - self.file.set_len(len)?; 49 - 50 - #[cfg(windows)] 51 - let raw = self.file.as_raw_handle(); 52 - #[cfg(unix)] 53 - let raw = self.file.as_raw_fd(); 54 - 55 - // SAFETY: 56 - // All file-backed memory map constructors are marked \ 57 - // unsafe because of the potential for Undefined Behavior (UB) \ 58 - // using the map if the underlying file is subsequently modified, in or out of process. 59 - self.map = unsafe { MmapOptions::new().map_mut(raw)? }; 60 - self.len = len; 61 - 62 - Ok(()) 63 - } 64 - } 65 - 66 - impl std::io::Read for MappedFile { 67 - fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { 68 - if self.off == self.len { 69 - // If we're at EOF, return an EOF error code. `Ok(0)` tends to trip up some implementations. 70 - return Err(std::io::Error::new(ErrorKind::UnexpectedEof, "eof")); 71 - } 72 - 73 - // Calculate the number of bytes we're going to read. 74 - let remaining_bytes = self.len.saturating_sub(self.off); 75 - let buf_len = u64::try_from(buf.len()).unwrap_or(u64::MAX); 76 - let len = usize::try_from(std::cmp::min(remaining_bytes, buf_len)).unwrap_or(usize::MAX); 77 - 78 - let off = usize::try_from(self.off).map_err(|e| { 79 - std::io::Error::new( 80 - ErrorKind::InvalidInput, 81 - format!("offset too large for this platform: {e}"), 82 - ) 83 - })?; 84 - 85 - if let (Some(dest), Some(src)) = ( 86 - buf.get_mut(..len), 87 - self.map.get(off..off.saturating_add(len)), 88 - ) { 89 - dest.copy_from_slice(src); 90 - self.off = self.off.saturating_add(u64::try_from(len).unwrap_or(0)); 91 - Ok(len) 92 - } else { 93 - Err(std::io::Error::new( 94 - ErrorKind::InvalidInput, 95 - "invalid buffer range", 96 - )) 97 - } 98 - } 99 - } 100 - 101 - impl std::io::Write for MappedFile { 102 - fn flush(&mut self) -> std::io::Result<()> { 103 - // This is done by the system. 104 - Ok(()) 105 - } 106 - fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { 107 - // Determine if we need to resize the file. 108 - let buf_len = u64::try_from(buf.len()).map_err(|e| { 109 - std::io::Error::new( 110 - ErrorKind::InvalidInput, 111 - format!("buffer length too large for this platform: {e}"), 112 - ) 113 - })?; 114 - 115 - if self.off.saturating_add(buf_len) >= self.len { 116 - self.resize(self.off.saturating_add(buf_len))?; 117 - } 118 - 119 - let off = usize::try_from(self.off).map_err(|e| { 120 - std::io::Error::new( 121 - ErrorKind::InvalidInput, 122 - format!("offset too large for this platform: {e}"), 123 - ) 124 - })?; 125 - let len = buf.len(); 126 - 127 - if let Some(dest) = self.map.get_mut(off..off.saturating_add(len)) { 128 - dest.copy_from_slice(buf); 129 - self.off = self.off.saturating_add(buf_len); 130 - Ok(len) 131 - } else { 132 - Err(std::io::Error::new( 133 - ErrorKind::InvalidInput, 134 - "invalid buffer range", 135 - )) 136 - } 137 - } 138 - } 139 - 140 - impl std::io::Seek for MappedFile { 141 - fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> { 142 - let off = match pos { 143 - std::io::SeekFrom::Start(i) => i, 144 - std::io::SeekFrom::End(i) => { 145 - if i <= 0 { 146 - // If i is negative or zero, we're seeking backwards from the end 147 - // or exactly at the end 148 - self.len.saturating_sub(i.unsigned_abs()) 149 - } else { 150 - // If i is positive, we're seeking beyond the end, which is allowed 151 - // but requires extending the file 152 - self.len.saturating_add(i.unsigned_abs()) 153 - } 154 - } 155 - std::io::SeekFrom::Current(i) => { 156 - if i >= 0 { 157 - self.off.saturating_add(i.unsigned_abs()) 158 - } else { 159 - self.off.saturating_sub(i.unsigned_abs()) 160 - } 161 - } 162 - }; 163 - 164 - // If the offset is beyond EOF, extend the file to the new size. 165 - if off > self.len { 166 - self.resize(off)?; 167 - } 168 - 169 - self.off = off; 170 - Ok(off) 171 - } 172 - } 173 - 174 - impl tokio::io::AsyncRead for MappedFile { 175 - fn poll_read( 176 - mut self: std::pin::Pin<&mut Self>, 177 - _cx: &mut std::task::Context<'_>, 178 - buf: &mut tokio::io::ReadBuf<'_>, 179 - ) -> std::task::Poll<std::io::Result<()>> { 180 - let wbuf = buf.initialize_unfilled(); 181 - let len = wbuf.len(); 182 - 183 - std::task::Poll::Ready(match self.read(wbuf) { 184 - Ok(_) => { 185 - buf.advance(len); 186 - Ok(()) 187 - } 188 - Err(e) => Err(e), 189 - }) 190 - } 191 - } 192 - 193 - impl tokio::io::AsyncWrite for MappedFile { 194 - fn poll_flush( 195 - self: std::pin::Pin<&mut Self>, 196 - _cx: &mut std::task::Context<'_>, 197 - ) -> std::task::Poll<Result<(), std::io::Error>> { 198 - std::task::Poll::Ready(Ok(())) 199 - } 200 - 201 - fn poll_shutdown( 202 - self: std::pin::Pin<&mut Self>, 203 - _cx: &mut std::task::Context<'_>, 204 - ) -> std::task::Poll<Result<(), std::io::Error>> { 205 - std::task::Poll::Ready(Ok(())) 206 - } 207 - 208 - fn poll_write( 209 - mut self: std::pin::Pin<&mut Self>, 210 - _cx: &mut std::task::Context<'_>, 211 - buf: &[u8], 212 - ) -> std::task::Poll<Result<usize, std::io::Error>> { 213 - std::task::Poll::Ready(self.write(buf)) 214 - } 215 - } 216 - 217 - impl tokio::io::AsyncSeek for MappedFile { 218 - fn poll_complete( 219 - self: std::pin::Pin<&mut Self>, 220 - _cx: &mut std::task::Context<'_>, 221 - ) -> std::task::Poll<std::io::Result<u64>> { 222 - std::task::Poll::Ready(Ok(self.off)) 223 - } 224 - 225 - fn start_seek( 226 - mut self: std::pin::Pin<&mut Self>, 227 - position: std::io::SeekFrom, 228 - ) -> std::io::Result<()> { 229 - self.seek(position).map(|_p| ()) 230 - } 231 - } 232 - 233 - #[cfg(test)] 234 - mod test { 235 - use rand::Rng as _; 236 - use std::io::Write as _; 237 - 238 - use super::*; 239 - 240 - #[test] 241 - fn basic_rw() { 242 - let tmp = std::env::temp_dir().join( 243 - rand::thread_rng() 244 - .sample_iter(rand::distributions::Alphanumeric) 245 - .take(10) 246 - .map(char::from) 247 - .collect::<String>(), 248 - ); 249 - 250 - let mut m = MappedFile::new( 251 - std::fs::File::options() 252 - .create(true) 253 - .truncate(true) 254 - .read(true) 255 - .write(true) 256 - .open(&tmp) 257 - .expect("Failed to open temporary file"), 258 - ) 259 - .expect("Failed to create MappedFile"); 260 - 261 - m.write_all(b"abcd123").expect("Failed to write data"); 262 - let _: u64 = m 263 - .seek(std::io::SeekFrom::Start(0)) 264 - .expect("Failed to seek to start"); 265 - 266 - let mut buf = [0_u8; 7]; 267 - m.read_exact(&mut buf).expect("Failed to read data"); 268 - 269 - assert_eq!(&buf, b"abcd123"); 270 - 271 - drop(m); 272 - std::fs::remove_file(tmp).expect("Failed to remove temporary file"); 273 - } 274 - }
+809
src/models.rs
··· 1 + // Generated by diesel_ext 2 + 3 + #![allow(unused, non_snake_case)] 4 + #![allow(clippy::all)] 5 + 6 + pub mod pds { 7 + 8 + #![allow(unnameable_types, unused_qualifications)] 9 + use anyhow::{Result, bail}; 10 + use chrono::DateTime; 11 + use chrono::offset::Utc; 12 + use diesel::backend::Backend; 13 + use diesel::deserialize::FromSql; 14 + use diesel::prelude::*; 15 + use diesel::serialize::{Output, ToSql}; 16 + use diesel::sql_types::Text; 17 + use diesel::sqlite::Sqlite; 18 + use diesel::*; 19 + use serde::{Deserialize, Serialize}; 20 + 21 + #[derive( 22 + Queryable, 23 + Identifiable, 24 + Selectable, 25 + Clone, 26 + Debug, 27 + PartialEq, 28 + Default, 29 + Serialize, 30 + Deserialize, 31 + )] 32 + #[diesel(primary_key(request_uri))] 33 + #[diesel(table_name = crate::schema::pds::oauth_par_requests)] 34 + #[diesel(check_for_backend(Sqlite))] 35 + pub struct OauthParRequest { 36 + pub request_uri: String, 37 + pub client_id: String, 38 + pub response_type: String, 39 + pub code_challenge: String, 40 + pub code_challenge_method: String, 41 + pub state: Option<String>, 42 + pub login_hint: Option<String>, 43 + pub scope: Option<String>, 44 + pub redirect_uri: Option<String>, 45 + pub response_mode: Option<String>, 46 + pub display: Option<String>, 47 + pub created_at: i64, 48 + pub expires_at: i64, 49 + } 50 + 51 + #[derive( 52 + Queryable, 53 + Identifiable, 54 + Selectable, 55 + Clone, 56 + Debug, 57 + PartialEq, 58 + Default, 59 + Serialize, 60 + Deserialize, 61 + )] 62 + #[diesel(primary_key(code))] 63 + #[diesel(table_name = crate::schema::pds::oauth_authorization_codes)] 64 + #[diesel(check_for_backend(Sqlite))] 65 + pub struct OauthAuthorizationCode { 66 + pub code: String, 67 + pub client_id: String, 68 + pub subject: String, 69 + pub code_challenge: String, 70 + pub code_challenge_method: String, 71 + pub redirect_uri: String, 72 + pub scope: Option<String>, 73 + pub created_at: i64, 74 + pub expires_at: i64, 75 + pub used: bool, 76 + } 77 + 78 + #[derive( 79 + Queryable, 80 + Identifiable, 81 + Selectable, 82 + Clone, 83 + Debug, 84 + PartialEq, 85 + Default, 86 + Serialize, 87 + Deserialize, 88 + )] 89 + #[diesel(primary_key(token))] 90 + #[diesel(table_name = crate::schema::pds::oauth_refresh_tokens)] 91 + #[diesel(check_for_backend(Sqlite))] 92 + pub struct OauthRefreshToken { 93 + pub token: String, 94 + pub client_id: String, 95 + pub subject: String, 96 + pub dpop_thumbprint: String, 97 + pub scope: Option<String>, 98 + pub created_at: i64, 99 + pub expires_at: i64, 100 + pub revoked: bool, 101 + } 102 + 103 + #[derive( 104 + Queryable, 105 + Identifiable, 106 + Selectable, 107 + Clone, 108 + Debug, 109 + PartialEq, 110 + Default, 111 + Serialize, 112 + Deserialize, 113 + )] 114 + #[diesel(primary_key(jti))] 115 + #[diesel(table_name = crate::schema::pds::oauth_used_jtis)] 116 + #[diesel(check_for_backend(Sqlite))] 117 + pub struct OauthUsedJti { 118 + pub jti: String, 119 + pub issuer: String, 120 + pub created_at: i64, 121 + pub expires_at: i64, 122 + } 123 + 124 + #[derive( 125 + Queryable, 126 + Identifiable, 127 + Selectable, 128 + Clone, 129 + Debug, 130 + PartialEq, 131 + Default, 132 + Serialize, 133 + Deserialize, 134 + )] 135 + #[diesel(primary_key(did))] 136 + #[diesel(table_name = crate::schema::pds::account)] 137 + #[diesel(check_for_backend(Sqlite))] 138 + pub struct Account { 139 + pub did: String, 140 + pub email: String, 141 + #[diesel(column_name = recoveryKey)] 142 + #[serde(rename = "recoveryKey")] 143 + pub recovery_key: Option<String>, 144 + pub password: String, 145 + #[diesel(column_name = createdAt)] 146 + #[serde(rename = "createdAt")] 147 + pub created_at: String, 148 + #[diesel(column_name = invitesDisabled)] 149 + #[serde(rename = "invitesDisabled")] 150 + pub invites_disabled: i16, 151 + #[diesel(column_name = emailConfirmedAt)] 152 + #[serde(rename = "emailConfirmedAt")] 153 + pub email_confirmed_at: Option<String>, 154 + } 155 + 156 + #[derive( 157 + Queryable, 158 + Identifiable, 159 + Selectable, 160 + Clone, 161 + Debug, 162 + PartialEq, 163 + Default, 164 + Serialize, 165 + Deserialize, 166 + )] 167 + #[diesel(primary_key(did))] 168 + #[diesel(table_name = crate::schema::pds::actor)] 169 + #[diesel(check_for_backend(Sqlite))] 170 + pub struct Actor { 171 + pub did: String, 172 + pub handle: Option<String>, 173 + #[diesel(column_name = createdAt)] 174 + #[serde(rename = "createdAt")] 175 + pub created_at: String, 176 + #[diesel(column_name = takedownRef)] 177 + #[serde(rename = "takedownRef")] 178 + pub takedown_ref: Option<String>, 179 + #[diesel(column_name = deactivatedAt)] 180 + #[serde(rename = "deactivatedAt")] 181 + pub deactivated_at: Option<String>, 182 + #[diesel(column_name = deleteAfter)] 183 + #[serde(rename = "deleteAfter")] 184 + pub delete_after: Option<String>, 185 + } 186 + 187 + #[derive( 188 + Queryable, 189 + Identifiable, 190 + Selectable, 191 + Clone, 192 + Debug, 193 + PartialEq, 194 + Default, 195 + Serialize, 196 + Deserialize, 197 + )] 198 + #[diesel(primary_key(did, name))] 199 + #[diesel(table_name = crate::schema::pds::app_password)] 200 + #[diesel(check_for_backend(Sqlite))] 201 + pub struct AppPassword { 202 + pub did: String, 203 + pub name: String, 204 + pub password: String, 205 + #[diesel(column_name = createdAt)] 206 + #[serde(rename = "createdAt")] 207 + pub created_at: String, 208 + } 209 + 210 + #[derive( 211 + Queryable, 212 + Identifiable, 213 + Selectable, 214 + Clone, 215 + Debug, 216 + PartialEq, 217 + Default, 218 + Serialize, 219 + Deserialize, 220 + )] 221 + #[diesel(primary_key(did))] 222 + #[diesel(table_name = crate::schema::pds::did_doc)] 223 + #[diesel(check_for_backend(Sqlite))] 224 + pub struct DidDoc { 225 + pub did: String, 226 + pub doc: String, 227 + #[diesel(column_name = updatedAt)] 228 + #[serde(rename = "updatedAt")] 229 + pub updated_at: i64, 230 + } 231 + 232 + #[derive( 233 + Clone, Copy, Debug, PartialEq, Eq, Hash, Default, Serialize, Deserialize, AsExpression, 234 + )] 235 + #[diesel(sql_type = Text)] 236 + pub enum EmailTokenPurpose { 237 + #[default] 238 + ConfirmEmail, 239 + UpdateEmail, 240 + ResetPassword, 241 + DeleteAccount, 242 + PlcOperation, 243 + } 244 + 245 + impl EmailTokenPurpose { 246 + pub fn as_str(&self) -> &'static str { 247 + match self { 248 + EmailTokenPurpose::ConfirmEmail => "confirm_email", 249 + EmailTokenPurpose::UpdateEmail => "update_email", 250 + EmailTokenPurpose::ResetPassword => "reset_password", 251 + EmailTokenPurpose::DeleteAccount => "delete_account", 252 + EmailTokenPurpose::PlcOperation => "plc_operation", 253 + } 254 + } 255 + 256 + pub fn from_str(s: &str) -> Result<Self> { 257 + match s { 258 + "confirm_email" => Ok(EmailTokenPurpose::ConfirmEmail), 259 + "update_email" => Ok(EmailTokenPurpose::UpdateEmail), 260 + "reset_password" => Ok(EmailTokenPurpose::ResetPassword), 261 + "delete_account" => Ok(EmailTokenPurpose::DeleteAccount), 262 + "plc_operation" => Ok(EmailTokenPurpose::PlcOperation), 263 + _ => bail!("Unable to parse as EmailTokenPurpose: `{s:?}`"), 264 + } 265 + } 266 + } 267 + 268 + impl<DB> Queryable<sql_types::Text, DB> for EmailTokenPurpose 269 + where 270 + DB: backend::Backend, 271 + String: deserialize::FromSql<sql_types::Text, DB>, 272 + { 273 + type Row = String; 274 + 275 + fn build(s: String) -> deserialize::Result<Self> { 276 + Ok(Self::from_str(&s)?) 277 + } 278 + } 279 + 280 + impl serialize::ToSql<sql_types::Text, sqlite::Sqlite> for EmailTokenPurpose 281 + where 282 + String: serialize::ToSql<sql_types::Text, sqlite::Sqlite>, 283 + { 284 + fn to_sql<'lifetime>( 285 + &'lifetime self, 286 + out: &mut serialize::Output<'lifetime, '_, sqlite::Sqlite>, 287 + ) -> serialize::Result { 288 + serialize::ToSql::<sql_types::Text, sqlite::Sqlite>::to_sql( 289 + match self { 290 + Self::ConfirmEmail => "confirm_email", 291 + Self::UpdateEmail => "update_email", 292 + Self::ResetPassword => "reset_password", 293 + Self::DeleteAccount => "delete_account", 294 + Self::PlcOperation => "plc_operation", 295 + }, 296 + out, 297 + ) 298 + } 299 + } 300 + 301 + #[derive( 302 + Queryable, 303 + Identifiable, 304 + Selectable, 305 + Clone, 306 + Debug, 307 + PartialEq, 308 + Default, 309 + Serialize, 310 + Deserialize, 311 + )] 312 + #[diesel(primary_key(purpose, did))] 313 + #[diesel(table_name = crate::schema::pds::email_token)] 314 + #[diesel(check_for_backend(Sqlite))] 315 + pub struct EmailToken { 316 + pub purpose: EmailTokenPurpose, 317 + pub did: String, 318 + pub token: String, 319 + #[diesel(column_name = requestedAt)] 320 + #[serde(rename = "requestedAt")] 321 + pub requested_at: String, 322 + } 323 + 324 + #[derive( 325 + Queryable, 326 + Identifiable, 327 + Insertable, 328 + Selectable, 329 + Clone, 330 + Debug, 331 + PartialEq, 332 + Default, 333 + Serialize, 334 + Deserialize, 335 + )] 336 + #[diesel(primary_key(code))] 337 + #[diesel(table_name = crate::schema::pds::invite_code)] 338 + #[diesel(check_for_backend(Sqlite))] 339 + pub struct InviteCode { 340 + pub code: String, 341 + #[diesel(column_name = availableUses)] 342 + #[serde(rename = "availableUses")] 343 + pub available_uses: i32, 344 + pub disabled: i16, 345 + #[diesel(column_name = forAccount)] 346 + #[serde(rename = "forAccount")] 347 + pub for_account: String, 348 + #[diesel(column_name = createdBy)] 349 + #[serde(rename = "createdBy")] 350 + pub created_by: String, 351 + #[diesel(column_name = createdAt)] 352 + #[serde(rename = "createdAt")] 353 + pub created_at: String, 354 + } 355 + 356 + #[derive( 357 + Queryable, 358 + Identifiable, 359 + Selectable, 360 + Clone, 361 + Debug, 362 + PartialEq, 363 + Default, 364 + Serialize, 365 + Deserialize, 366 + )] 367 + #[diesel(primary_key(code, usedBy))] 368 + #[diesel(table_name = crate::schema::pds::invite_code_use)] 369 + #[diesel(check_for_backend(Sqlite))] 370 + pub struct InviteCodeUse { 371 + pub code: String, 372 + #[diesel(column_name = usedBy)] 373 + #[serde(rename = "usedBy")] 374 + pub used_by: String, 375 + #[diesel(column_name = usedAt)] 376 + #[serde(rename = "usedAt")] 377 + pub used_at: String, 378 + } 379 + 380 + #[derive( 381 + Queryable, 382 + Identifiable, 383 + Selectable, 384 + Clone, 385 + Debug, 386 + PartialEq, 387 + Default, 388 + Serialize, 389 + Deserialize, 390 + )] 391 + #[diesel(table_name = crate::schema::pds::refresh_token)] 392 + #[diesel(check_for_backend(Sqlite))] 393 + pub struct RefreshToken { 394 + pub id: String, 395 + pub did: String, 396 + #[diesel(column_name = expiresAt)] 397 + #[serde(rename = "expiresAt")] 398 + pub expires_at: String, 399 + #[diesel(column_name = nextId)] 400 + #[serde(rename = "nextId")] 401 + pub next_id: Option<String>, 402 + #[diesel(column_name = appPasswordName)] 403 + #[serde(rename = "appPasswordName")] 404 + pub app_password_name: Option<String>, 405 + } 406 + 407 + #[derive( 408 + Queryable, 409 + Identifiable, 410 + Selectable, 411 + Insertable, 412 + Clone, 413 + Debug, 414 + PartialEq, 415 + Default, 416 + Serialize, 417 + Deserialize, 418 + )] 419 + #[diesel(primary_key(seq))] 420 + #[diesel(table_name = crate::schema::pds::repo_seq)] 421 + #[diesel(check_for_backend(Sqlite))] 422 + pub struct RepoSeq { 423 + #[diesel(deserialize_as = i64)] 424 + pub seq: Option<i64>, 425 + pub did: String, 426 + #[diesel(column_name = eventType)] 427 + #[serde(rename = "eventType")] 428 + pub event_type: String, 429 + #[diesel(sql_type = Bytea)] 430 + pub event: Vec<u8>, 431 + #[diesel(deserialize_as = i16)] 432 + pub invalidated: Option<i16>, 433 + #[diesel(column_name = sequencedAt)] 434 + #[serde(rename = "sequencedAt")] 435 + pub sequenced_at: String, 436 + } 437 + 438 + impl RepoSeq { 439 + pub fn new(did: String, event_type: String, event: Vec<u8>, sequenced_at: String) -> Self { 440 + RepoSeq { 441 + did, 442 + event_type, 443 + event, 444 + sequenced_at, 445 + invalidated: None, // default values used on insert 446 + seq: None, // default values used on insert 447 + } 448 + } 449 + } 450 + 451 + #[derive( 452 + Queryable, 453 + Identifiable, 454 + Insertable, 455 + Selectable, 456 + Clone, 457 + Debug, 458 + PartialEq, 459 + Default, 460 + Serialize, 461 + Deserialize, 462 + )] 463 + #[diesel(primary_key(id))] 464 + #[diesel(table_name = crate::schema::pds::token)] 465 + #[diesel(check_for_backend(Sqlite))] 466 + pub struct Token { 467 + pub id: String, 468 + pub did: String, 469 + #[diesel(column_name = tokenId)] 470 + #[serde(rename = "tokenId")] 471 + pub token_id: String, 472 + #[diesel(column_name = createdAt)] 473 + #[serde(rename = "createdAt")] 474 + pub created_at: DateTime<Utc>, 475 + #[diesel(column_name = updatedAt)] 476 + #[serde(rename = "updatedAt")] 477 + pub updated_at: DateTime<Utc>, 478 + #[diesel(column_name = expiresAt)] 479 + #[serde(rename = "expiresAt")] 480 + pub expires_at: DateTime<Utc>, 481 + #[diesel(column_name = clientId)] 482 + #[serde(rename = "clientId")] 483 + pub client_id: String, 484 + #[diesel(column_name = clientAuth)] 485 + #[serde(rename = "clientAuth")] 486 + pub client_auth: String, 487 + #[diesel(column_name = deviceId)] 488 + #[serde(rename = "deviceId")] 489 + pub device_id: Option<String>, 490 + pub parameters: String, 491 + pub details: Option<String>, 492 + pub code: Option<String>, 493 + #[diesel(column_name = currentRefreshToken)] 494 + #[serde(rename = "currentRefreshToken")] 495 + pub current_refresh_token: Option<String>, 496 + } 497 + 498 + #[derive( 499 + Queryable, 500 + Identifiable, 501 + Insertable, 502 + Selectable, 503 + Clone, 504 + Debug, 505 + PartialEq, 506 + Default, 507 + Serialize, 508 + Deserialize, 509 + )] 510 + #[diesel(primary_key(id))] 511 + #[diesel(table_name = crate::schema::pds::device)] 512 + #[diesel(check_for_backend(Sqlite))] 513 + pub struct Device { 514 + pub id: String, 515 + #[diesel(column_name = sessionId)] 516 + #[serde(rename = "sessionId")] 517 + pub session_id: Option<String>, 518 + #[diesel(column_name = userAgent)] 519 + #[serde(rename = "userAgent")] 520 + pub user_agent: Option<String>, 521 + #[diesel(column_name = ipAddress)] 522 + #[serde(rename = "ipAddress")] 523 + pub ip_address: String, 524 + #[diesel(column_name = lastSeenAt)] 525 + #[serde(rename = "lastSeenAt")] 526 + pub last_seen_at: DateTime<Utc>, 527 + } 528 + 529 + #[derive( 530 + Queryable, 531 + Identifiable, 532 + Insertable, 533 + Selectable, 534 + Clone, 535 + Debug, 536 + PartialEq, 537 + Default, 538 + Serialize, 539 + Deserialize, 540 + )] 541 + #[diesel(primary_key(did))] 542 + #[diesel(table_name = crate::schema::pds::device_account)] 543 + #[diesel(check_for_backend(Sqlite))] 544 + pub struct DeviceAccount { 545 + pub did: String, 546 + #[diesel(column_name = deviceId)] 547 + #[serde(rename = "deviceId")] 548 + pub device_id: String, 549 + #[diesel(column_name = authenticatedAt)] 550 + #[serde(rename = "authenticatedAt")] 551 + pub authenticated_at: DateTime<Utc>, 552 + pub remember: bool, 553 + #[diesel(column_name = authorizedClients)] 554 + #[serde(rename = "authorizedClients")] 555 + pub authorized_clients: String, 556 + } 557 + 558 + #[derive( 559 + Queryable, 560 + Identifiable, 561 + Insertable, 562 + Selectable, 563 + Clone, 564 + Debug, 565 + PartialEq, 566 + Default, 567 + Serialize, 568 + Deserialize, 569 + )] 570 + #[diesel(primary_key(id))] 571 + #[diesel(table_name = crate::schema::pds::authorization_request)] 572 + #[diesel(check_for_backend(Sqlite))] 573 + pub struct AuthorizationRequest { 574 + pub id: String, 575 + pub did: Option<String>, 576 + #[diesel(column_name = deviceId)] 577 + #[serde(rename = "deviceId")] 578 + pub device_id: Option<String>, 579 + #[diesel(column_name = clientId)] 580 + #[serde(rename = "clientId")] 581 + pub client_id: String, 582 + #[diesel(column_name = clientAuth)] 583 + #[serde(rename = "clientAuth")] 584 + pub client_auth: String, 585 + pub parameters: String, 586 + #[diesel(column_name = expiresAt)] 587 + #[serde(rename = "expiresAt")] 588 + pub expires_at: DateTime<Utc>, 589 + pub code: Option<String>, 590 + } 591 + 592 + #[derive( 593 + Queryable, Insertable, Selectable, Clone, Debug, PartialEq, Default, Serialize, Deserialize, 594 + )] 595 + #[diesel(table_name = crate::schema::pds::used_refresh_token)] 596 + #[diesel(check_for_backend(Sqlite))] 597 + pub struct UsedRefreshToken { 598 + #[diesel(column_name = tokenId)] 599 + #[serde(rename = "tokenId")] 600 + pub token_id: String, 601 + #[diesel(column_name = refreshToken)] 602 + #[serde(rename = "refreshToken")] 603 + pub refresh_token: String, 604 + } 605 + } 606 + 607 + pub mod actor_store { 608 + 609 + #![allow(unnameable_types, unused_qualifications)] 610 + use anyhow::{Result, bail}; 611 + use chrono::DateTime; 612 + use chrono::offset::Utc; 613 + use diesel::backend::Backend; 614 + use diesel::deserialize::FromSql; 615 + use diesel::prelude::*; 616 + use diesel::serialize::{Output, ToSql}; 617 + use diesel::sql_types::Text; 618 + use diesel::sqlite::Sqlite; 619 + use diesel::*; 620 + use serde::{Deserialize, Serialize}; 621 + 622 + #[derive( 623 + Queryable, 624 + Identifiable, 625 + Insertable, 626 + Selectable, 627 + Clone, 628 + Debug, 629 + PartialEq, 630 + Default, 631 + Serialize, 632 + Deserialize, 633 + )] 634 + #[diesel(table_name = crate::schema::actor_store::account_pref)] 635 + #[diesel(check_for_backend(Sqlite))] 636 + pub struct AccountPref { 637 + pub id: i32, 638 + pub name: String, 639 + #[diesel(column_name = valueJson)] 640 + #[serde(rename = "valueJson")] 641 + pub value_json: Option<String>, 642 + } 643 + 644 + #[derive( 645 + Queryable, 646 + Identifiable, 647 + Insertable, 648 + Selectable, 649 + Clone, 650 + Debug, 651 + PartialEq, 652 + Default, 653 + Serialize, 654 + Deserialize, 655 + )] 656 + #[diesel(primary_key(uri, path))] 657 + #[diesel(table_name = crate::schema::actor_store::backlink)] 658 + #[diesel(check_for_backend(Sqlite))] 659 + pub struct Backlink { 660 + pub uri: String, 661 + pub path: String, 662 + #[diesel(column_name = linkTo)] 663 + #[serde(rename = "linkTo")] 664 + pub link_to: String, 665 + } 666 + 667 + #[derive( 668 + Queryable, 669 + Identifiable, 670 + Selectable, 671 + Clone, 672 + Debug, 673 + PartialEq, 674 + Default, 675 + Serialize, 676 + Deserialize, 677 + )] 678 + #[diesel(treat_none_as_null = true)] 679 + #[diesel(primary_key(cid))] 680 + #[diesel(table_name = crate::schema::actor_store::blob)] 681 + #[diesel(check_for_backend(Sqlite))] 682 + pub struct Blob { 683 + pub cid: String, 684 + pub did: String, 685 + #[diesel(column_name = mimeType)] 686 + #[serde(rename = "mimeType")] 687 + pub mime_type: String, 688 + pub size: i32, 689 + #[diesel(column_name = tempKey)] 690 + #[serde(rename = "tempKey")] 691 + pub temp_key: Option<String>, 692 + pub width: Option<i32>, 693 + pub height: Option<i32>, 694 + #[diesel(column_name = createdAt)] 695 + #[serde(rename = "createdAt")] 696 + pub created_at: String, 697 + #[diesel(column_name = takedownRef)] 698 + #[serde(rename = "takedownRef")] 699 + pub takedown_ref: Option<String>, 700 + } 701 + 702 + #[derive( 703 + Queryable, 704 + Identifiable, 705 + Insertable, 706 + Selectable, 707 + Clone, 708 + Debug, 709 + PartialEq, 710 + Default, 711 + Serialize, 712 + Deserialize, 713 + )] 714 + #[diesel(primary_key(uri))] 715 + #[diesel(table_name = crate::schema::actor_store::record)] 716 + #[diesel(check_for_backend(Sqlite))] 717 + pub struct Record { 718 + pub uri: String, 719 + pub cid: String, 720 + pub did: String, 721 + pub collection: String, 722 + pub rkey: String, 723 + #[diesel(column_name = repoRev)] 724 + #[serde(rename = "repoRev")] 725 + pub repo_rev: Option<String>, 726 + #[diesel(column_name = indexedAt)] 727 + #[serde(rename = "indexedAt")] 728 + pub indexed_at: String, 729 + #[diesel(column_name = takedownRef)] 730 + #[serde(rename = "takedownRef")] 731 + pub takedown_ref: Option<String>, 732 + } 733 + 734 + #[derive( 735 + QueryableByName, 736 + Queryable, 737 + Identifiable, 738 + Selectable, 739 + Clone, 740 + Debug, 741 + PartialEq, 742 + Default, 743 + Serialize, 744 + Deserialize, 745 + )] 746 + #[diesel(primary_key(blobCid, recordUri))] 747 + #[diesel(table_name = crate::schema::actor_store::record_blob)] 748 + #[diesel(check_for_backend(Sqlite))] 749 + pub struct RecordBlob { 750 + #[diesel(column_name = blobCid, sql_type = Text)] 751 + #[serde(rename = "blobCid")] 752 + pub blob_cid: String, 753 + #[diesel(column_name = recordUri, sql_type = Text)] 754 + #[serde(rename = "recordUri")] 755 + pub record_uri: String, 756 + #[diesel(sql_type = Text)] 757 + pub did: String, 758 + } 759 + 760 + #[derive( 761 + Queryable, 762 + Identifiable, 763 + Selectable, 764 + Insertable, 765 + Clone, 766 + Debug, 767 + PartialEq, 768 + Default, 769 + Serialize, 770 + Deserialize, 771 + )] 772 + #[diesel(primary_key(cid))] 773 + #[diesel(table_name = crate::schema::actor_store::repo_block)] 774 + #[diesel(check_for_backend(Sqlite))] 775 + pub struct RepoBlock { 776 + #[diesel(sql_type = Text)] 777 + pub cid: String, 778 + pub did: String, 779 + #[diesel(column_name = repoRev)] 780 + #[serde(rename = "repoRev")] 781 + pub repo_rev: String, 782 + pub size: i32, 783 + #[diesel(sql_type = Bytea)] 784 + pub content: Vec<u8>, 785 + } 786 + 787 + #[derive( 788 + Queryable, 789 + Identifiable, 790 + Selectable, 791 + Clone, 792 + Debug, 793 + PartialEq, 794 + Default, 795 + Serialize, 796 + Deserialize, 797 + )] 798 + #[diesel(primary_key(did))] 799 + #[diesel(table_name = crate::schema::actor_store::repo_root)] 800 + #[diesel(check_for_backend(Sqlite))] 801 + pub struct RepoRoot { 802 + pub did: String, 803 + pub cid: String, 804 + pub rev: String, 805 + #[diesel(column_name = indexedAt)] 806 + #[serde(rename = "indexedAt")] 807 + pub indexed_at: String, 808 + } 809 + }
+4 -10
src/oauth.rs
··· 1 1 //! OAuth endpoints 2 2 #![allow(unnameable_types, unused_qualifications)] 3 + use crate::config::AppConfig; 4 + use crate::error::Error; 3 5 use crate::metrics::AUTH_FAILED; 4 - use crate::{AppConfig, AppState, Client, Error, Result, SigningKey}; 6 + use crate::serve::{AppState, Client, Result, SigningKey}; 5 7 use anyhow::{Context as _, anyhow}; 6 8 use argon2::{Argon2, PasswordHash, PasswordVerifier as _}; 7 9 use atrium_crypto::keypair::Did as _; ··· 365 367 let response_type = response_type.to_owned(); 366 368 let code_challenge = code_challenge.to_owned(); 367 369 let code_challenge_method = code_challenge_method.to_owned(); 368 - let state = state.map(|s| s.to_owned()); 369 - let login_hint = login_hint.map(|s| s.to_owned()); 370 - let scope = scope.map(|s| s.to_owned()); 371 - let redirect_uri = redirect_uri.map(|s| s.to_owned()); 372 - let response_mode = response_mode.map(|s| s.to_owned()); 373 - let display = display.map(|s| s.to_owned()); 374 - let created_at = created_at; 375 - let expires_at = expires_at; 376 370 _ = db 377 371 .get() 378 372 .await ··· 587 581 .interact(move |conn| { 588 582 AccountSchema::account 589 583 .filter(AccountSchema::email.eq(username_clone)) 590 - .first::<rsky_pds::models::Account>(conn) 584 + .first::<crate::models::pds::Account>(conn) 591 585 .optional() 592 586 }) 593 587 .await
+606
src/pipethrough.rs
··· 1 + //! Based on https://github.com/blacksky-algorithms/rsky/blob/main/rsky-pds/src/pipethrough.rs 2 + //! blacksky-algorithms/rsky is licensed under the Apache License 2.0 3 + //! 4 + //! Modified for Axum instead of Rocket 5 + 6 + use anyhow::{Result, bail}; 7 + use axum::extract::{FromRequestParts, State}; 8 + use rsky_identity::IdResolver; 9 + use rsky_pds::apis::ApiError; 10 + use rsky_pds::auth_verifier::{AccessOutput, AccessStandard}; 11 + use rsky_pds::config::{ServerConfig, ServiceConfig, env_to_cfg}; 12 + use rsky_pds::pipethrough::{OverrideOpts, ProxyHeader, UrlAndAud}; 13 + use rsky_pds::xrpc_server::types::{HandlerPipeThrough, InvalidRequestError, XRPCError}; 14 + use rsky_pds::{APP_USER_AGENT, SharedIdResolver, context}; 15 + // use lazy_static::lazy_static; 16 + use reqwest::header::{CONTENT_TYPE, HeaderValue}; 17 + use reqwest::{Client, Method, RequestBuilder, Response}; 18 + // use rocket::data::ToByteUnit; 19 + // use rocket::http::{Method, Status}; 20 + // use rocket::request::{FromRequest, Outcome, Request}; 21 + // use rocket::{Data, State}; 22 + use axum::{ 23 + body::Bytes, 24 + http::{self, HeaderMap}, 25 + }; 26 + use rsky_common::{GetServiceEndpointOpts, get_service_endpoint}; 27 + use rsky_repo::types::Ids; 28 + use serde::de::DeserializeOwned; 29 + use serde_json::Value as JsonValue; 30 + use std::collections::{BTreeMap, HashSet}; 31 + use std::str::FromStr; 32 + use std::sync::Arc; 33 + use std::time::Duration; 34 + use ubyte::ToByteUnit as _; 35 + use url::Url; 36 + 37 + use crate::serve::AppState; 38 + 39 + // pub struct OverrideOpts { 40 + // pub aud: Option<String>, 41 + // pub lxm: Option<String>, 42 + // } 43 + 44 + // pub struct UrlAndAud { 45 + // pub url: Url, 46 + // pub aud: String, 47 + // pub lxm: String, 48 + // } 49 + 50 + // pub struct ProxyHeader { 51 + // pub did: String, 52 + // pub service_url: String, 53 + // } 54 + 55 + pub struct ProxyRequest { 56 + pub headers: BTreeMap<String, String>, 57 + pub query: Option<String>, 58 + pub path: String, 59 + pub method: Method, 60 + pub id_resolver: Arc<tokio::sync::RwLock<rsky_identity::IdResolver>>, 61 + pub cfg: ServerConfig, 62 + } 63 + impl FromRequestParts<AppState> for ProxyRequest { 64 + // type Rejection = ApiError; 65 + type Rejection = axum::response::Response; 66 + 67 + async fn from_request_parts( 68 + parts: &mut axum::http::request::Parts, 69 + state: &AppState, 70 + ) -> Result<Self, Self::Rejection> { 71 + let headers = parts 72 + .headers 73 + .iter() 74 + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) 75 + .collect::<BTreeMap<String, String>>(); 76 + let query = parts.uri.query().map(|s| s.to_string()); 77 + let path = parts.uri.path().to_string(); 78 + let method = parts.method.clone(); 79 + let id_resolver = state.id_resolver.clone(); 80 + // let cfg = state.cfg.clone(); 81 + let cfg = env_to_cfg(); // TODO: use state.cfg.clone(); 82 + 83 + Ok(Self { 84 + headers, 85 + query, 86 + path, 87 + method, 88 + id_resolver, 89 + cfg, 90 + }) 91 + } 92 + } 93 + 94 + // #[rocket::async_trait] 95 + // impl<'r> FromRequest<'r> for HandlerPipeThrough { 96 + // type Error = anyhow::Error; 97 + 98 + // #[tracing::instrument(skip_all)] 99 + // async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { 100 + // match AccessStandard::from_request(req).await { 101 + // Outcome::Success(output) => { 102 + // let AccessOutput { credentials, .. } = output.access; 103 + // let requester: Option<String> = match credentials { 104 + // None => None, 105 + // Some(credentials) => credentials.did, 106 + // }; 107 + // let headers = req.headers().clone().into_iter().fold( 108 + // BTreeMap::new(), 109 + // |mut acc: BTreeMap<String, String>, cur| { 110 + // let _ = acc.insert(cur.name().to_string(), cur.value().to_string()); 111 + // acc 112 + // }, 113 + // ); 114 + // let proxy_req = ProxyRequest { 115 + // headers, 116 + // query: match req.uri().query() { 117 + // None => None, 118 + // Some(query) => Some(query.to_string()), 119 + // }, 120 + // path: req.uri().path().to_string(), 121 + // method: req.method(), 122 + // id_resolver: req.guard::<&State<SharedIdResolver>>().await.unwrap(), 123 + // cfg: req.guard::<&State<ServerConfig>>().await.unwrap(), 124 + // }; 125 + // match pipethrough( 126 + // &proxy_req, 127 + // requester, 128 + // OverrideOpts { 129 + // aud: None, 130 + // lxm: None, 131 + // }, 132 + // ) 133 + // .await 134 + // { 135 + // Ok(res) => Outcome::Success(res), 136 + // Err(error) => match error.downcast_ref() { 137 + // Some(InvalidRequestError::XRPCError(xrpc)) => { 138 + // if let XRPCError::FailedResponse { 139 + // status, 140 + // error, 141 + // message, 142 + // headers, 143 + // } = xrpc 144 + // { 145 + // tracing::error!( 146 + // "@LOG: XRPC ERROR Status:{status}; Message: {message:?}; Error: {error:?}; Headers: {headers:?}" 147 + // ); 148 + // } 149 + // req.local_cache(|| Some(ApiError::InvalidRequest(error.to_string()))); 150 + // Outcome::Error((Status::BadRequest, error)) 151 + // } 152 + // _ => { 153 + // req.local_cache(|| Some(ApiError::InvalidRequest(error.to_string()))); 154 + // Outcome::Error((Status::BadRequest, error)) 155 + // } 156 + // }, 157 + // } 158 + // } 159 + // Outcome::Error(err) => { 160 + // req.local_cache(|| Some(ApiError::RuntimeError)); 161 + // Outcome::Error(( 162 + // Status::BadRequest, 163 + // anyhow::Error::new(InvalidRequestError::AuthError(err.1)), 164 + // )) 165 + // } 166 + // _ => panic!("Unexpected outcome during Pipethrough"), 167 + // } 168 + // } 169 + // } 170 + 171 + // #[rocket::async_trait] 172 + // impl<'r> FromRequest<'r> for ProxyRequest<'r> { 173 + // type Error = anyhow::Error; 174 + 175 + // async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { 176 + // let headers = req.headers().clone().into_iter().fold( 177 + // BTreeMap::new(), 178 + // |mut acc: BTreeMap<String, String>, cur| { 179 + // let _ = acc.insert(cur.name().to_string(), cur.value().to_string()); 180 + // acc 181 + // }, 182 + // ); 183 + // Outcome::Success(Self { 184 + // headers, 185 + // query: match req.uri().query() { 186 + // None => None, 187 + // Some(query) => Some(query.to_string()), 188 + // }, 189 + // path: req.uri().path().to_string(), 190 + // method: req.method(), 191 + // id_resolver: req.guard::<&State<SharedIdResolver>>().await.unwrap(), 192 + // cfg: req.guard::<&State<ServerConfig>>().await.unwrap(), 193 + // }) 194 + // } 195 + // } 196 + 197 + pub async fn pipethrough( 198 + req: &ProxyRequest, 199 + requester: Option<String>, 200 + override_opts: OverrideOpts, 201 + ) -> Result<HandlerPipeThrough> { 202 + let UrlAndAud { 203 + url, 204 + aud, 205 + lxm: nsid, 206 + } = format_url_and_aud(req, override_opts.aud).await?; 207 + let lxm = override_opts.lxm.unwrap_or(nsid); 208 + let headers = format_headers(req, aud, lxm, requester).await?; 209 + let req_init = format_req_init(req, url, headers, None)?; 210 + let res = make_request(req_init).await?; 211 + parse_proxy_res(res).await 212 + } 213 + 214 + pub async fn pipethrough_procedure<T: serde::Serialize>( 215 + req: &ProxyRequest, 216 + requester: Option<String>, 217 + body: Option<T>, 218 + ) -> Result<HandlerPipeThrough> { 219 + let UrlAndAud { 220 + url, 221 + aud, 222 + lxm: nsid, 223 + } = format_url_and_aud(req, None).await?; 224 + let headers = format_headers(req, aud, nsid, requester).await?; 225 + let encoded_body: Option<Vec<u8>> = match body { 226 + None => None, 227 + Some(body) => Some(serde_json::to_string(&body)?.into_bytes()), 228 + }; 229 + let req_init = format_req_init(req, url, headers, encoded_body)?; 230 + let res = make_request(req_init).await?; 231 + parse_proxy_res(res).await 232 + } 233 + 234 + #[tracing::instrument(skip_all)] 235 + pub async fn pipethrough_procedure_post( 236 + req: &ProxyRequest, 237 + requester: Option<String>, 238 + body: Option<Bytes>, 239 + ) -> Result<HandlerPipeThrough, ApiError> { 240 + let UrlAndAud { 241 + url, 242 + aud, 243 + lxm: nsid, 244 + } = format_url_and_aud(req, None).await?; 245 + let headers = format_headers(req, aud, nsid, requester).await?; 246 + let encoded_body: Option<JsonValue>; 247 + match body { 248 + None => encoded_body = None, 249 + Some(body) => { 250 + // let res = match body.open(50.megabytes()).into_string().await { 251 + // Ok(res1) => { 252 + // tracing::info!(res1.value); 253 + // res1.value 254 + // } 255 + // Err(error) => { 256 + // tracing::error!("{error}"); 257 + // return Err(ApiError::RuntimeError); 258 + // } 259 + // }; 260 + let res = String::from_utf8(body.to_vec()).expect("Invalid UTF-8"); 261 + 262 + match serde_json::from_str(res.as_str()) { 263 + Ok(res) => { 264 + encoded_body = Some(res); 265 + } 266 + Err(error) => { 267 + tracing::error!("{error}"); 268 + return Err(ApiError::RuntimeError); 269 + } 270 + } 271 + } 272 + }; 273 + let req_init = format_req_init_with_value(req, url, headers, encoded_body)?; 274 + let res = make_request(req_init).await?; 275 + Ok(parse_proxy_res(res).await?) 276 + } 277 + 278 + // Request setup/formatting 279 + // ------------------- 280 + 281 + const REQ_HEADERS_TO_FORWARD: [&str; 4] = [ 282 + "accept-language", 283 + "content-type", 284 + "atproto-accept-labelers", 285 + "x-bsky-topics", 286 + ]; 287 + 288 + #[tracing::instrument(skip_all)] 289 + pub async fn format_url_and_aud( 290 + req: &ProxyRequest, 291 + aud_override: Option<String>, 292 + ) -> Result<UrlAndAud> { 293 + let proxy_to = parse_proxy_header(req).await?; 294 + let nsid = parse_req_nsid(req); 295 + let default_proxy = default_service(req, &nsid).await; 296 + let service_url = match proxy_to { 297 + Some(ref proxy_to) => { 298 + tracing::info!( 299 + "@LOG: format_url_and_aud() proxy_to: {:?}", 300 + proxy_to.service_url 301 + ); 302 + Some(proxy_to.service_url.clone()) 303 + } 304 + None => match default_proxy { 305 + Some(ref default_proxy) => Some(default_proxy.url.clone()), 306 + None => None, 307 + }, 308 + }; 309 + let aud = match aud_override { 310 + Some(_) => aud_override, 311 + None => match proxy_to { 312 + Some(proxy_to) => Some(proxy_to.did), 313 + None => match default_proxy { 314 + Some(default_proxy) => Some(default_proxy.did), 315 + None => None, 316 + }, 317 + }, 318 + }; 319 + match (service_url, aud) { 320 + (Some(service_url), Some(aud)) => { 321 + let mut url = Url::parse(format!("{0}{1}", service_url, req.path).as_str())?; 322 + if let Some(ref params) = req.query { 323 + url.set_query(Some(params.as_str())); 324 + } 325 + if !req.cfg.service.dev_mode && !is_safe_url(url.clone()) { 326 + bail!(InvalidRequestError::InvalidServiceUrl(url.to_string())); 327 + } 328 + Ok(UrlAndAud { 329 + url, 330 + aud, 331 + lxm: nsid, 332 + }) 333 + } 334 + _ => bail!(InvalidRequestError::NoServiceConfigured(req.path.clone())), 335 + } 336 + } 337 + 338 + pub async fn format_headers( 339 + req: &ProxyRequest, 340 + aud: String, 341 + lxm: String, 342 + requester: Option<String>, 343 + ) -> Result<HeaderMap> { 344 + let mut headers: HeaderMap = match requester { 345 + Some(requester) => context::service_auth_headers(&requester, &aud, &lxm).await?, 346 + None => HeaderMap::new(), 347 + }; 348 + // forward select headers to upstream services 349 + for header in REQ_HEADERS_TO_FORWARD { 350 + let val = req.headers.get(header); 351 + if let Some(val) = val { 352 + headers.insert(header, HeaderValue::from_str(val)?); 353 + } 354 + } 355 + Ok(headers) 356 + } 357 + 358 + pub fn format_req_init( 359 + req: &ProxyRequest, 360 + url: Url, 361 + headers: HeaderMap, 362 + body: Option<Vec<u8>>, 363 + ) -> Result<RequestBuilder> { 364 + match req.method { 365 + Method::GET => { 366 + let client = Client::builder() 367 + .user_agent(APP_USER_AGENT) 368 + .http2_keep_alive_while_idle(true) 369 + .http2_keep_alive_timeout(Duration::from_secs(5)) 370 + .default_headers(headers) 371 + .build()?; 372 + Ok(client.get(url)) 373 + } 374 + Method::HEAD => { 375 + let client = Client::builder() 376 + .user_agent(APP_USER_AGENT) 377 + .http2_keep_alive_while_idle(true) 378 + .http2_keep_alive_timeout(Duration::from_secs(5)) 379 + .default_headers(headers) 380 + .build()?; 381 + Ok(client.head(url)) 382 + } 383 + Method::POST => { 384 + let client = Client::builder() 385 + .user_agent(APP_USER_AGENT) 386 + .http2_keep_alive_while_idle(true) 387 + .http2_keep_alive_timeout(Duration::from_secs(5)) 388 + .default_headers(headers) 389 + .build()?; 390 + Ok(client.post(url).body(body.unwrap())) 391 + } 392 + _ => bail!(InvalidRequestError::MethodNotFound), 393 + } 394 + } 395 + 396 + pub fn format_req_init_with_value( 397 + req: &ProxyRequest, 398 + url: Url, 399 + headers: HeaderMap, 400 + body: Option<JsonValue>, 401 + ) -> Result<RequestBuilder> { 402 + match req.method { 403 + Method::GET => { 404 + let client = Client::builder() 405 + .user_agent(APP_USER_AGENT) 406 + .http2_keep_alive_while_idle(true) 407 + .http2_keep_alive_timeout(Duration::from_secs(5)) 408 + .default_headers(headers) 409 + .build()?; 410 + Ok(client.get(url)) 411 + } 412 + Method::HEAD => { 413 + let client = Client::builder() 414 + .user_agent(APP_USER_AGENT) 415 + .http2_keep_alive_while_idle(true) 416 + .http2_keep_alive_timeout(Duration::from_secs(5)) 417 + .default_headers(headers) 418 + .build()?; 419 + Ok(client.head(url)) 420 + } 421 + Method::POST => { 422 + let client = Client::builder() 423 + .user_agent(APP_USER_AGENT) 424 + .http2_keep_alive_while_idle(true) 425 + .http2_keep_alive_timeout(Duration::from_secs(5)) 426 + .default_headers(headers) 427 + .build()?; 428 + Ok(client.post(url).json(&body.unwrap())) 429 + } 430 + _ => bail!(InvalidRequestError::MethodNotFound), 431 + } 432 + } 433 + 434 + pub async fn parse_proxy_header(req: &ProxyRequest) -> Result<Option<ProxyHeader>> { 435 + let headers = &req.headers; 436 + let proxy_to: Option<&String> = headers.get("atproto-proxy"); 437 + match proxy_to { 438 + None => Ok(None), 439 + Some(proxy_to) => { 440 + let parts: Vec<&str> = proxy_to.split("#").collect::<Vec<&str>>(); 441 + match (parts.get(0), parts.get(1), parts.get(2)) { 442 + (Some(did), Some(service_id), None) => { 443 + let did = did.to_string(); 444 + let mut lock = req.id_resolver.write().await; 445 + match lock.did.resolve(did.clone(), None).await? { 446 + None => bail!(InvalidRequestError::CannotResolveProxyDid), 447 + Some(did_doc) => { 448 + match get_service_endpoint( 449 + did_doc, 450 + GetServiceEndpointOpts { 451 + id: format!("#{service_id}"), 452 + r#type: None, 453 + }, 454 + ) { 455 + None => bail!(InvalidRequestError::CannotResolveServiceUrl), 456 + Some(service_url) => Ok(Some(ProxyHeader { did, service_url })), 457 + } 458 + } 459 + } 460 + } 461 + (_, None, _) => bail!(InvalidRequestError::NoServiceId), 462 + _ => bail!("error parsing atproto-proxy header"), 463 + } 464 + } 465 + } 466 + } 467 + 468 + pub fn parse_req_nsid(req: &ProxyRequest) -> String { 469 + let nsid = req.path.as_str().replace("/xrpc/", ""); 470 + match nsid.ends_with("/") { 471 + false => nsid, 472 + true => nsid 473 + .trim_end_matches(|c| c == nsid.chars().last().unwrap()) 474 + .to_string(), 475 + } 476 + } 477 + 478 + // Sending request 479 + // ------------------- 480 + #[tracing::instrument(skip_all)] 481 + pub async fn make_request(req_init: RequestBuilder) -> Result<Response> { 482 + let res = req_init.send().await; 483 + match res { 484 + Err(e) => { 485 + tracing::error!("@LOG WARN: pipethrough network error {}", e.to_string()); 486 + bail!(InvalidRequestError::XRPCError(XRPCError::UpstreamFailure)) 487 + } 488 + Ok(res) => match res.error_for_status_ref() { 489 + Ok(_) => Ok(res), 490 + Err(_) => { 491 + let status = res.status().to_string(); 492 + let headers = res.headers().clone(); 493 + let error_body = res.json::<JsonValue>().await?; 494 + bail!(InvalidRequestError::XRPCError(XRPCError::FailedResponse { 495 + status, 496 + headers, 497 + error: match error_body["error"].as_str() { 498 + None => None, 499 + Some(error_body_error) => Some(error_body_error.to_string()), 500 + }, 501 + message: match error_body["message"].as_str() { 502 + None => None, 503 + Some(error_body_message) => Some(error_body_message.to_string()), 504 + } 505 + })) 506 + } 507 + }, 508 + } 509 + } 510 + 511 + // Response parsing/forwarding 512 + // ------------------- 513 + 514 + const RES_HEADERS_TO_FORWARD: [&str; 4] = [ 515 + "content-type", 516 + "content-language", 517 + "atproto-repo-rev", 518 + "atproto-content-labelers", 519 + ]; 520 + 521 + pub async fn parse_proxy_res(res: Response) -> Result<HandlerPipeThrough> { 522 + let encoding = match res.headers().get(CONTENT_TYPE) { 523 + Some(content_type) => content_type.to_str()?, 524 + None => "application/json", 525 + }; 526 + // Release borrow 527 + let encoding = encoding.to_string(); 528 + let res_headers = RES_HEADERS_TO_FORWARD.into_iter().fold( 529 + BTreeMap::new(), 530 + |mut acc: BTreeMap<String, String>, cur| { 531 + let _ = match res.headers().get(cur) { 532 + Some(res_header_val) => acc.insert( 533 + cur.to_string(), 534 + res_header_val.clone().to_str().unwrap().to_string(), 535 + ), 536 + None => None, 537 + }; 538 + acc 539 + }, 540 + ); 541 + let buffer = read_array_buffer_res(res).await?; 542 + Ok(HandlerPipeThrough { 543 + encoding, 544 + buffer, 545 + headers: Some(res_headers), 546 + }) 547 + } 548 + 549 + // Utils 550 + // ------------------- 551 + 552 + pub async fn default_service(req: &ProxyRequest, nsid: &str) -> Option<ServiceConfig> { 553 + let cfg = req.cfg.clone(); 554 + match Ids::from_str(nsid) { 555 + Ok(Ids::ToolsOzoneTeamAddMember) => cfg.mod_service, 556 + Ok(Ids::ToolsOzoneTeamDeleteMember) => cfg.mod_service, 557 + Ok(Ids::ToolsOzoneTeamUpdateMember) => cfg.mod_service, 558 + Ok(Ids::ToolsOzoneTeamListMembers) => cfg.mod_service, 559 + Ok(Ids::ToolsOzoneCommunicationCreateTemplate) => cfg.mod_service, 560 + Ok(Ids::ToolsOzoneCommunicationDeleteTemplate) => cfg.mod_service, 561 + Ok(Ids::ToolsOzoneCommunicationUpdateTemplate) => cfg.mod_service, 562 + Ok(Ids::ToolsOzoneCommunicationListTemplates) => cfg.mod_service, 563 + Ok(Ids::ToolsOzoneModerationEmitEvent) => cfg.mod_service, 564 + Ok(Ids::ToolsOzoneModerationGetEvent) => cfg.mod_service, 565 + Ok(Ids::ToolsOzoneModerationGetRecord) => cfg.mod_service, 566 + Ok(Ids::ToolsOzoneModerationGetRepo) => cfg.mod_service, 567 + Ok(Ids::ToolsOzoneModerationQueryEvents) => cfg.mod_service, 568 + Ok(Ids::ToolsOzoneModerationQueryStatuses) => cfg.mod_service, 569 + Ok(Ids::ToolsOzoneModerationSearchRepos) => cfg.mod_service, 570 + Ok(Ids::ComAtprotoModerationCreateReport) => cfg.report_service, 571 + _ => cfg.bsky_app_view, 572 + } 573 + } 574 + 575 + pub fn parse_res<T: DeserializeOwned>(_nsid: String, res: HandlerPipeThrough) -> Result<T> { 576 + let buffer = res.buffer; 577 + let record = serde_json::from_slice::<T>(buffer.as_slice())?; 578 + Ok(record) 579 + } 580 + 581 + #[tracing::instrument(skip_all)] 582 + pub async fn read_array_buffer_res(res: Response) -> Result<Vec<u8>> { 583 + match res.bytes().await { 584 + Ok(bytes) => Ok(bytes.to_vec()), 585 + Err(err) => { 586 + tracing::error!("@LOG WARN: pipethrough network error {}", err.to_string()); 587 + bail!("UpstreamFailure") 588 + } 589 + } 590 + } 591 + 592 + pub fn is_safe_url(url: Url) -> bool { 593 + if url.scheme() != "https" { 594 + return false; 595 + } 596 + match url.host_str() { 597 + None => false, 598 + Some(hostname) if hostname == "localhost" => false, 599 + Some(hostname) => { 600 + if std::net::IpAddr::from_str(hostname).is_ok() { 601 + return false; 602 + } 603 + true 604 + } 605 + } 606 + }
-114
src/plc.rs
··· 1 - //! PLC operations. 2 - use std::collections::HashMap; 3 - 4 - use anyhow::{Context as _, bail}; 5 - use base64::Engine as _; 6 - use serde::{Deserialize, Serialize}; 7 - use tracing::debug; 8 - 9 - use crate::{Client, RotationKey}; 10 - 11 - /// The URL of the public PLC directory. 12 - const PLC_DIRECTORY: &str = "https://plc.directory/"; 13 - 14 - #[derive(Debug, Deserialize, Serialize, Clone)] 15 - #[serde(rename_all = "camelCase", tag = "type")] 16 - /// A PLC service. 17 - pub(crate) enum PlcService { 18 - #[serde(rename = "AtprotoPersonalDataServer")] 19 - /// A personal data server. 20 - Pds { 21 - /// The URL of the PDS. 22 - endpoint: String, 23 - }, 24 - } 25 - 26 - #[expect( 27 - clippy::arbitrary_source_item_ordering, 28 - reason = "serialized data might be structured" 29 - )] 30 - #[derive(Debug, Deserialize, Serialize, Clone)] 31 - #[serde(rename_all = "camelCase")] 32 - pub(crate) struct PlcOperation { 33 - #[serde(rename = "type")] 34 - pub typ: String, 35 - pub rotation_keys: Vec<String>, 36 - pub verification_methods: HashMap<String, String>, 37 - pub also_known_as: Vec<String>, 38 - pub services: HashMap<String, PlcService>, 39 - pub prev: Option<String>, 40 - } 41 - 42 - impl PlcOperation { 43 - /// Sign an operation with the provided signature. 44 - pub(crate) fn sign(self, sig: Vec<u8>) -> SignedPlcOperation { 45 - SignedPlcOperation { 46 - typ: self.typ, 47 - rotation_keys: self.rotation_keys, 48 - verification_methods: self.verification_methods, 49 - also_known_as: self.also_known_as, 50 - services: self.services, 51 - prev: self.prev, 52 - sig: base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(sig), 53 - } 54 - } 55 - } 56 - 57 - #[expect( 58 - clippy::arbitrary_source_item_ordering, 59 - reason = "serialized data might be structured" 60 - )] 61 - #[derive(Debug, Deserialize, Serialize, Clone)] 62 - #[serde(rename_all = "camelCase")] 63 - /// A signed PLC operation. 64 - pub(crate) struct SignedPlcOperation { 65 - #[serde(rename = "type")] 66 - pub typ: String, 67 - pub rotation_keys: Vec<String>, 68 - pub verification_methods: HashMap<String, String>, 69 - pub also_known_as: Vec<String>, 70 - pub services: HashMap<String, PlcService>, 71 - pub prev: Option<String>, 72 - pub sig: String, 73 - } 74 - 75 - pub(crate) fn sign_op(rkey: &RotationKey, op: PlcOperation) -> anyhow::Result<SignedPlcOperation> { 76 - let bytes = serde_ipld_dagcbor::to_vec(&op).context("failed to encode op")?; 77 - let bytes = rkey.sign(&bytes).context("failed to sign op")?; 78 - 79 - Ok(op.sign(bytes)) 80 - } 81 - 82 - /// Submit a PLC operation to the public directory. 83 - pub(crate) async fn submit( 84 - client: &Client, 85 - did: &str, 86 - op: &SignedPlcOperation, 87 - ) -> anyhow::Result<()> { 88 - debug!( 89 - "submitting {} {}", 90 - did, 91 - serde_json::to_string(&op).context("should serialize")? 92 - ); 93 - 94 - let res = client 95 - .post(format!("{PLC_DIRECTORY}{did}")) 96 - .json(&op) 97 - .send() 98 - .await 99 - .context("failed to send directory request")?; 100 - 101 - if res.status().is_success() { 102 - Ok(()) 103 - } else { 104 - let e = res 105 - .json::<serde_json::Value>() 106 - .await 107 - .context("failed to read error response")?; 108 - 109 - bail!( 110 - "error from PLC directory: {}", 111 - serde_json::to_string(&e).context("should serialize")? 112 - ); 113 - } 114 - }
+96 -84
src/schema.rs
··· 70 70 } 71 71 72 72 diesel::table! { 73 - account_pref (id) { 74 - id -> Int4, 75 - did -> Varchar, 76 - name -> Varchar, 77 - valueJson -> Nullable<Text>, 78 - } 79 - } 80 - 81 - diesel::table! { 82 73 actor (did) { 83 74 did -> Varchar, 84 75 handle -> Nullable<Varchar>, ··· 106 97 clientId -> Varchar, 107 98 clientAuth -> Varchar, 108 99 parameters -> Varchar, 109 - expiresAt -> Timestamptz, 100 + expiresAt -> TimestamptzSqlite, 110 101 code -> Nullable<Varchar>, 111 102 } 112 103 } 113 104 114 105 diesel::table! { 115 - backlink (uri, path) { 116 - uri -> Varchar, 117 - path -> Varchar, 118 - linkTo -> Varchar, 119 - } 120 - } 121 - 122 - diesel::table! { 123 - blob (cid, did) { 124 - cid -> Varchar, 125 - did -> Varchar, 126 - mimeType -> Varchar, 127 - size -> Int4, 128 - tempKey -> Nullable<Varchar>, 129 - width -> Nullable<Int4>, 130 - height -> Nullable<Int4>, 131 - createdAt -> Varchar, 132 - takedownRef -> Nullable<Varchar>, 133 - } 134 - } 135 - 136 - diesel::table! { 137 106 device (id) { 138 107 id -> Varchar, 139 108 sessionId -> Nullable<Varchar>, 140 109 userAgent -> Nullable<Varchar>, 141 110 ipAddress -> Varchar, 142 - lastSeenAt -> Timestamptz, 111 + lastSeenAt -> TimestamptzSqlite, 143 112 } 144 113 } 145 114 ··· 147 116 device_account (deviceId, did) { 148 117 did -> Varchar, 149 118 deviceId -> Varchar, 150 - authenticatedAt -> Timestamptz, 119 + authenticatedAt -> TimestamptzSqlite, 151 120 remember -> Bool, 152 121 authorizedClients -> Varchar, 153 122 } ··· 190 159 } 191 160 192 161 diesel::table! { 193 - record (uri) { 194 - uri -> Varchar, 195 - cid -> Varchar, 196 - did -> Varchar, 197 - collection -> Varchar, 198 - rkey -> Varchar, 199 - repoRev -> Nullable<Varchar>, 200 - indexedAt -> Varchar, 201 - takedownRef -> Nullable<Varchar>, 202 - } 203 - } 204 - 205 - diesel::table! { 206 - record_blob (blobCid, recordUri) { 207 - blobCid -> Varchar, 208 - recordUri -> Varchar, 209 - did -> Varchar, 210 - } 211 - } 212 - 213 - diesel::table! { 214 162 refresh_token (id) { 215 163 id -> Varchar, 216 164 did -> Varchar, ··· 221 169 } 222 170 223 171 diesel::table! { 224 - repo_block (cid, did) { 225 - cid -> Varchar, 226 - did -> Varchar, 227 - repoRev -> Varchar, 228 - size -> Int4, 229 - content -> Bytea, 230 - } 231 - } 232 - 233 - diesel::table! { 234 - repo_root (did) { 235 - did -> Varchar, 236 - cid -> Varchar, 237 - rev -> Varchar, 238 - indexedAt -> Varchar, 239 - } 240 - } 241 - 242 - diesel::table! { 243 172 repo_seq (seq) { 244 173 seq -> Int8, 245 174 did -> Varchar, ··· 255 184 id -> Varchar, 256 185 did -> Varchar, 257 186 tokenId -> Varchar, 258 - createdAt -> Timestamptz, 259 - updatedAt -> Timestamptz, 260 - expiresAt -> Timestamptz, 187 + createdAt -> TimestamptzSqlite, 188 + updatedAt -> TimestamptzSqlite, 189 + expiresAt -> TimestamptzSqlite, 261 190 clientId -> Varchar, 262 191 clientAuth -> Varchar, 263 192 deviceId -> Nullable<Varchar>, ··· 277 206 278 207 diesel::allow_tables_to_appear_in_same_query!( 279 208 account, 280 - account_pref, 281 209 actor, 282 210 app_password, 283 211 authorization_request, 284 - backlink, 285 - blob, 286 212 device, 287 213 device_account, 288 214 did_doc, 289 215 email_token, 290 216 invite_code, 291 217 invite_code_use, 292 - record, 293 - record_blob, 294 218 refresh_token, 295 - repo_block, 296 - repo_root, 297 219 repo_seq, 298 220 token, 299 221 used_refresh_token, 300 222 ); 301 223 } 224 + 225 + pub mod actor_store { 226 + // Actor Store 227 + 228 + // Blob 229 + diesel::table! { 230 + blob (cid, did) { 231 + cid -> Varchar, 232 + did -> Varchar, 233 + mimeType -> Varchar, 234 + size -> Int4, 235 + tempKey -> Nullable<Varchar>, 236 + width -> Nullable<Int4>, 237 + height -> Nullable<Int4>, 238 + createdAt -> Varchar, 239 + takedownRef -> Nullable<Varchar>, 240 + } 241 + } 242 + 243 + diesel::table! { 244 + record_blob (blobCid, recordUri) { 245 + blobCid -> Varchar, 246 + recordUri -> Varchar, 247 + did -> Varchar, 248 + } 249 + } 250 + 251 + // Preference 252 + 253 + diesel::table! { 254 + account_pref (id) { 255 + id -> Int4, 256 + did -> Varchar, 257 + name -> Varchar, 258 + valueJson -> Nullable<Text>, 259 + } 260 + } 261 + // Record 262 + 263 + diesel::table! { 264 + record (uri) { 265 + uri -> Varchar, 266 + cid -> Varchar, 267 + did -> Varchar, 268 + collection -> Varchar, 269 + rkey -> Varchar, 270 + repoRev -> Nullable<Varchar>, 271 + indexedAt -> Varchar, 272 + takedownRef -> Nullable<Varchar>, 273 + } 274 + } 275 + 276 + diesel::table! { 277 + repo_block (cid, did) { 278 + cid -> Varchar, 279 + did -> Varchar, 280 + repoRev -> Varchar, 281 + size -> Int4, 282 + content -> Bytea, 283 + } 284 + } 285 + 286 + diesel::table! { 287 + backlink (uri, path) { 288 + uri -> Varchar, 289 + path -> Varchar, 290 + linkTo -> Varchar, 291 + } 292 + } 293 + // sql_repo 294 + 295 + diesel::table! { 296 + repo_root (did) { 297 + did -> Varchar, 298 + cid -> Varchar, 299 + rev -> Varchar, 300 + indexedAt -> Varchar, 301 + } 302 + } 303 + 304 + diesel::allow_tables_to_appear_in_same_query!( 305 + account_pref, 306 + backlink, 307 + blob, 308 + record, 309 + record_blob, 310 + repo_block, 311 + repo_root, 312 + ); 313 + }
+429
src/serve.rs
··· 1 + use super::account_manager::AccountManager; 2 + use super::config::AppConfig; 3 + use super::db::establish_pool; 4 + pub use super::error::Error; 5 + use super::service_proxy::service_proxy; 6 + use anyhow::Context as _; 7 + use atrium_api::types::string::Did; 8 + use atrium_crypto::keypair::{Export as _, Secp256k1Keypair}; 9 + use axum::{Router, extract::FromRef, routing::get}; 10 + use clap::Parser; 11 + use clap_verbosity_flag::{InfoLevel, Verbosity, log::LevelFilter}; 12 + use deadpool_diesel::sqlite::Pool; 13 + use diesel::prelude::*; 14 + use diesel_migrations::{EmbeddedMigrations, embed_migrations}; 15 + use figment::{Figment, providers::Format as _}; 16 + use http_cache_reqwest::{CacheMode, HttpCacheOptions, MokaManager}; 17 + use rsky_common::env::env_list; 18 + use rsky_identity::IdResolver; 19 + use rsky_identity::types::{DidCache, IdentityResolverOpts}; 20 + use rsky_pds::{crawlers::Crawlers, sequencer::Sequencer}; 21 + use serde::{Deserialize, Serialize}; 22 + use std::env; 23 + use std::{ 24 + net::{IpAddr, Ipv4Addr, SocketAddr}, 25 + path::PathBuf, 26 + str::FromStr as _, 27 + sync::Arc, 28 + }; 29 + use tokio::{net::TcpListener, sync::RwLock}; 30 + use tower_http::{cors::CorsLayer, trace::TraceLayer}; 31 + use tracing::{info, warn}; 32 + use uuid::Uuid; 33 + 34 + /// The application user agent. Concatenates the package name and version. e.g. `bluepds/0.0.0`. 35 + pub const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); 36 + 37 + /// Embedded migrations 38 + pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); 39 + pub const MIGRATIONS_ACTOR: EmbeddedMigrations = embed_migrations!("./migrations_actor"); 40 + 41 + /// The application-wide result type. 42 + pub type Result<T> = std::result::Result<T, Error>; 43 + /// The reqwest client type with middleware. 44 + pub type Client = reqwest_middleware::ClientWithMiddleware; 45 + 46 + #[expect( 47 + clippy::arbitrary_source_item_ordering, 48 + reason = "serialized data might be structured" 49 + )] 50 + #[derive(Serialize, Deserialize, Debug, Clone)] 51 + /// The key data structure. 52 + struct KeyData { 53 + /// Primary signing key for all repo operations. 54 + skey: Vec<u8>, 55 + /// Primary signing (rotation) key for all PLC operations. 56 + rkey: Vec<u8>, 57 + } 58 + 59 + // FIXME: We should use P256Keypair instead. SecP256K1 is primarily used for cryptocurrencies, 60 + // and the implementations of this algorithm are much more limited as compared to P256. 61 + // 62 + // Reference: https://soatok.blog/2022/05/19/guidance-for-choosing-an-elliptic-curve-signature-algorithm-in-2022/ 63 + #[derive(Clone)] 64 + /// The signing key for PLC/DID operations. 65 + pub struct SigningKey(Arc<Secp256k1Keypair>); 66 + #[derive(Clone)] 67 + /// The rotation key for PLC operations. 68 + pub struct RotationKey(Arc<Secp256k1Keypair>); 69 + 70 + impl std::ops::Deref for SigningKey { 71 + type Target = Secp256k1Keypair; 72 + 73 + fn deref(&self) -> &Self::Target { 74 + &self.0 75 + } 76 + } 77 + 78 + impl SigningKey { 79 + /// Import from a private key. 80 + pub fn import(key: &[u8]) -> Result<Self> { 81 + let key = Secp256k1Keypair::import(key).context("failed to import signing key")?; 82 + Ok(Self(Arc::new(key))) 83 + } 84 + } 85 + 86 + impl std::ops::Deref for RotationKey { 87 + type Target = Secp256k1Keypair; 88 + 89 + fn deref(&self) -> &Self::Target { 90 + &self.0 91 + } 92 + } 93 + 94 + #[derive(Parser, Debug, Clone)] 95 + /// Command line arguments. 96 + pub struct Args { 97 + /// Path to the configuration file 98 + #[arg(short, long, default_value = "default.toml")] 99 + pub config: PathBuf, 100 + /// The verbosity level. 101 + #[command(flatten)] 102 + pub verbosity: Verbosity<InfoLevel>, 103 + } 104 + 105 + /// The actor pools for the database connections. 106 + pub struct ActorStorage { 107 + /// The database connection pool for the actor's repository. 108 + pub repo: Pool, 109 + /// The file storage path for the actor's blobs. 110 + pub blob: PathBuf, 111 + } 112 + 113 + impl Clone for ActorStorage { 114 + fn clone(&self) -> Self { 115 + Self { 116 + repo: self.repo.clone(), 117 + blob: self.blob.clone(), 118 + } 119 + } 120 + } 121 + 122 + #[expect(clippy::arbitrary_source_item_ordering, reason = "arbitrary")] 123 + #[derive(Clone, FromRef)] 124 + /// The application state, shared across all routes. 125 + pub struct AppState { 126 + /// The application configuration. 127 + pub(crate) config: AppConfig, 128 + /// The main database connection pool. Used for common PDS data, like invite codes. 129 + pub db: Pool, 130 + /// Actor-specific database connection pools. Hashed by DID. 131 + pub db_actors: std::collections::HashMap<String, ActorStorage>, 132 + 133 + /// The HTTP client with middleware. 134 + pub client: Client, 135 + /// The simple HTTP client. 136 + pub simple_client: reqwest::Client, 137 + /// The firehose producer. 138 + pub sequencer: Arc<RwLock<Sequencer>>, 139 + /// The account manager. 140 + pub account_manager: Arc<RwLock<AccountManager>>, 141 + /// The ID resolver. 142 + pub id_resolver: Arc<RwLock<IdResolver>>, 143 + 144 + /// The signing key. 145 + pub signing_key: SigningKey, 146 + /// The rotation key. 147 + pub rotation_key: RotationKey, 148 + } 149 + 150 + /// The main application entry point. 151 + #[expect( 152 + clippy::cognitive_complexity, 153 + clippy::too_many_lines, 154 + unused_qualifications, 155 + reason = "main function has high complexity" 156 + )] 157 + pub async fn run() -> anyhow::Result<()> { 158 + let args = Args::parse(); 159 + 160 + // Set up trace logging to console and account for the user-provided verbosity flag. 161 + if args.verbosity.log_level_filter() != LevelFilter::Off { 162 + let lvl = match args.verbosity.log_level_filter() { 163 + LevelFilter::Error => tracing::Level::ERROR, 164 + LevelFilter::Warn => tracing::Level::WARN, 165 + LevelFilter::Info | LevelFilter::Off => tracing::Level::INFO, 166 + LevelFilter::Debug => tracing::Level::DEBUG, 167 + LevelFilter::Trace => tracing::Level::TRACE, 168 + }; 169 + tracing_subscriber::fmt().with_max_level(lvl).init(); 170 + } 171 + 172 + if !args.config.exists() { 173 + // Throw up a warning if the config file does not exist. 174 + // 175 + // This is not fatal because users can specify all configuration settings via 176 + // the environment, but the most likely scenario here is that a user accidentally 177 + // omitted the config file for some reason (e.g. forgot to mount it into Docker). 178 + warn!( 179 + "configuration file {} does not exist", 180 + args.config.display() 181 + ); 182 + } 183 + 184 + // Read and parse the user-provided configuration. 185 + let config: AppConfig = Figment::new() 186 + .admerge(figment::providers::Toml::file(args.config)) 187 + .admerge(figment::providers::Env::prefixed("BLUEPDS_")) 188 + .extract() 189 + .context("failed to load configuration")?; 190 + 191 + if config.test { 192 + warn!("BluePDS starting up in TEST mode."); 193 + warn!("This means the application will not federate with the rest of the network."); 194 + warn!( 195 + "If you want to turn this off, either set `test` to false in the config or define `BLUEPDS_TEST = false`" 196 + ); 197 + } 198 + 199 + // Initialize metrics reporting. 200 + super::metrics::setup(config.metrics.as_ref()).context("failed to set up metrics exporter")?; 201 + 202 + // Create a reqwest client that will be used for all outbound requests. 203 + let simple_client = reqwest::Client::builder() 204 + .user_agent(APP_USER_AGENT) 205 + .build() 206 + .context("failed to build requester client")?; 207 + let client = reqwest_middleware::ClientBuilder::new(simple_client.clone()) 208 + .with(http_cache_reqwest::Cache(http_cache_reqwest::HttpCache { 209 + mode: CacheMode::Default, 210 + manager: MokaManager::default(), 211 + options: HttpCacheOptions::default(), 212 + })) 213 + .build(); 214 + 215 + tokio::fs::create_dir_all(&config.key.parent().context("should have parent")?) 216 + .await 217 + .context("failed to create key directory")?; 218 + 219 + // Check if crypto keys exist. If not, create new ones. 220 + let (skey, rkey) = if let Ok(f) = std::fs::File::open(&config.key) { 221 + let keys: KeyData = serde_ipld_dagcbor::from_reader(std::io::BufReader::new(f)) 222 + .context("failed to deserialize crypto keys")?; 223 + 224 + let skey = Secp256k1Keypair::import(&keys.skey).context("failed to import signing key")?; 225 + let rkey = Secp256k1Keypair::import(&keys.rkey).context("failed to import rotation key")?; 226 + 227 + (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey))) 228 + } else { 229 + info!("signing keys not found, generating new ones"); 230 + 231 + let skey = Secp256k1Keypair::create(&mut rand::thread_rng()); 232 + let rkey = Secp256k1Keypair::create(&mut rand::thread_rng()); 233 + 234 + let keys = KeyData { 235 + skey: skey.export(), 236 + rkey: rkey.export(), 237 + }; 238 + 239 + let mut f = std::fs::File::create(&config.key).context("failed to create key file")?; 240 + serde_ipld_dagcbor::to_writer(&mut f, &keys).context("failed to serialize crypto keys")?; 241 + 242 + (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey))) 243 + }; 244 + 245 + tokio::fs::create_dir_all(&config.repo.path).await?; 246 + tokio::fs::create_dir_all(&config.plc.path).await?; 247 + tokio::fs::create_dir_all(&config.blob.path).await?; 248 + 249 + // Create a database connection manager and pool for the main database. 250 + let pool = 251 + establish_pool(&config.db).context("failed to establish database connection pool")?; 252 + 253 + // Create a dictionary of database connection pools for each actor. 254 + let mut actor_pools = std::collections::HashMap::new(); 255 + // We'll determine actors by looking in the data/repo dir for .db files. 256 + let mut actor_dbs = tokio::fs::read_dir(&config.repo.path) 257 + .await 258 + .context("failed to read repo directory")?; 259 + while let Some(entry) = actor_dbs 260 + .next_entry() 261 + .await 262 + .context("failed to read repo dir")? 263 + { 264 + let path = entry.path(); 265 + if path.extension().and_then(|s| s.to_str()) == Some("db") { 266 + let actor_repo_pool = establish_pool(&format!("sqlite://{}", path.display())) 267 + .context("failed to create database connection pool")?; 268 + 269 + let did = Did::from_str(&format!( 270 + "did:plc:{}", 271 + path.file_stem() 272 + .and_then(|s| s.to_str()) 273 + .context("failed to get actor DID")? 274 + )) 275 + .expect("should be able to parse actor DID") 276 + .to_string(); 277 + let blob_path = config.blob.path.to_path_buf(); 278 + let actor_storage = ActorStorage { 279 + repo: actor_repo_pool, 280 + blob: blob_path.clone(), 281 + }; 282 + drop(actor_pools.insert(did, actor_storage)); 283 + } 284 + } 285 + // Apply pending migrations 286 + // let conn = pool.get().await?; 287 + // conn.run_pending_migrations(MIGRATIONS) 288 + // .expect("should be able to run migrations"); 289 + 290 + let hostname = config.host_name.clone(); 291 + let crawlers: Vec<String> = config 292 + .firehose 293 + .relays 294 + .iter() 295 + .map(|s| s.to_string()) 296 + .collect(); 297 + let sequencer = Arc::new(RwLock::new(Sequencer::new( 298 + Crawlers::new(hostname, crawlers.clone()), 299 + None, 300 + ))); 301 + let account_manager = Arc::new(RwLock::new(AccountManager::new(pool.clone()))); 302 + let plc_url = if cfg!(debug_assertions) { 303 + "http://localhost:8000".to_owned() // dummy for debug 304 + } else { 305 + env::var("PDS_DID_PLC_URL").unwrap_or("https://plc.directory".to_owned()) // TODO: toml config 306 + }; 307 + let id_resolver = Arc::new(RwLock::new(IdResolver::new(IdentityResolverOpts { 308 + timeout: None, 309 + plc_url: Some(plc_url), 310 + did_cache: Some(DidCache::new(None, None)), 311 + backup_nameservers: Some(env_list("PDS_HANDLE_BACKUP_NAMESERVERS")), 312 + }))); 313 + 314 + let addr = config 315 + .listen_address 316 + .unwrap_or(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8000)); 317 + 318 + let app = Router::new() 319 + .route("/", get(super::index)) 320 + .merge(super::oauth::routes()) 321 + .nest( 322 + "/xrpc", 323 + super::apis::routes() 324 + .merge(super::actor_endpoints::routes()) 325 + .fallback(service_proxy), 326 + ) 327 + // .layer(RateLimitLayer::new(30, Duration::from_secs(30))) 328 + .layer(CorsLayer::permissive()) 329 + .layer(TraceLayer::new_for_http()) 330 + .with_state(AppState { 331 + config: config.clone(), 332 + db: pool.clone(), 333 + db_actors: actor_pools.clone(), 334 + client: client.clone(), 335 + simple_client, 336 + sequencer: sequencer.clone(), 337 + account_manager, 338 + id_resolver, 339 + signing_key: skey, 340 + rotation_key: rkey, 341 + }); 342 + 343 + info!("listening on {addr}"); 344 + info!("connect to: http://127.0.0.1:{}", addr.port()); 345 + 346 + // Determine whether or not this was the first startup (i.e. no accounts exist and no invite codes were created). 347 + // If so, create an invite code and share it via the console. 348 + let conn = pool.get().await.context("failed to get db connection")?; 349 + 350 + #[derive(QueryableByName)] 351 + struct TotalCount { 352 + #[diesel(sql_type = diesel::sql_types::Integer)] 353 + total_count: i32, 354 + } 355 + 356 + let result = conn.interact(move |conn| { 357 + diesel::sql_query( 358 + "SELECT (SELECT COUNT(*) FROM account) + (SELECT COUNT(*) FROM invite_code) AS total_count", 359 + ) 360 + .get_result::<TotalCount>(conn) 361 + }) 362 + .await 363 + .expect("should be able to query database")?; 364 + 365 + let c = result.total_count; 366 + 367 + #[expect(clippy::print_stdout)] 368 + if c == 0 { 369 + let uuid = Uuid::new_v4().to_string(); 370 + 371 + use crate::models::pds as models; 372 + use crate::schema::pds::invite_code::dsl as InviteCode; 373 + let uuid_clone = uuid.clone(); 374 + drop( 375 + conn.interact(move |conn| { 376 + diesel::insert_into(InviteCode::invite_code) 377 + .values(models::InviteCode { 378 + code: uuid_clone, 379 + available_uses: 1, 380 + disabled: 0, 381 + for_account: "None".to_owned(), 382 + created_by: "None".to_owned(), 383 + created_at: "None".to_owned(), 384 + }) 385 + .execute(conn) 386 + .context("failed to create new invite code") 387 + }) 388 + .await 389 + .expect("should be able to create invite code"), 390 + ); 391 + 392 + // N.B: This is a sensitive message, so we're bypassing `tracing` here and 393 + // logging it directly to console. 394 + println!("====================================="); 395 + println!(" FIRST STARTUP "); 396 + println!("====================================="); 397 + println!("Use this code to create an account:"); 398 + println!("{uuid}"); 399 + println!("====================================="); 400 + } 401 + 402 + let listener = TcpListener::bind(&addr) 403 + .await 404 + .context("failed to bind address")?; 405 + 406 + // Serve the app, and request crawling from upstream relays. 407 + let serve = tokio::spawn(async move { 408 + axum::serve(listener, app.into_make_service()) 409 + .await 410 + .context("failed to serve app") 411 + }); 412 + 413 + // Now that the app is live, request a crawl from upstream relays. 414 + if cfg!(debug_assertions) { 415 + info!("debug mode: not requesting crawl"); 416 + } else { 417 + info!("requesting crawl from upstream relays"); 418 + let mut background_sequencer = sequencer.write().await.clone(); 419 + drop(tokio::spawn( 420 + async move { background_sequencer.start().await }, 421 + )); 422 + } 423 + 424 + serve 425 + .await 426 + .map_err(Into::into) 427 + .and_then(|r| r) 428 + .context("failed to serve app") 429 + }
+6 -26
src/service_proxy.rs
··· 3 3 /// Reference: <https://atproto.com/specs/xrpc#service-proxying> 4 4 use anyhow::{Context as _, anyhow}; 5 5 use atrium_api::types::string::Did; 6 - use atrium_crypto::keypair::{Export as _, Secp256k1Keypair}; 7 6 use axum::{ 8 - Router, 9 7 body::Body, 10 - extract::{FromRef, Request, State}, 8 + extract::{Request, State}, 11 9 http::{self, HeaderMap, Response, StatusCode, Uri}, 12 - response::IntoResponse, 13 - routing::get, 14 10 }; 15 - use azure_core::credentials::TokenCredential; 16 - use clap::Parser; 17 - use clap_verbosity_flag::{InfoLevel, Verbosity, log::LevelFilter}; 18 - use deadpool_diesel::sqlite::Pool; 19 - use diesel::prelude::*; 20 - use diesel_migrations::{EmbeddedMigrations, embed_migrations}; 21 - use figment::{Figment, providers::Format as _}; 22 - use http_cache_reqwest::{CacheMode, HttpCacheOptions, MokaManager}; 23 11 use rand::Rng as _; 24 - use serde::{Deserialize, Serialize}; 25 - use std::{ 26 - net::{IpAddr, Ipv4Addr, SocketAddr}, 27 - path::PathBuf, 28 - str::FromStr as _, 29 - sync::Arc, 30 - }; 31 - use tokio::net::TcpListener; 32 - use tower_http::{cors::CorsLayer, trace::TraceLayer}; 33 - use tracing::{info, warn}; 34 - use uuid::Uuid; 12 + use std::str::FromStr as _; 35 13 36 - use super::{Client, Error, Result}; 37 - use crate::{AuthenticatedUser, SigningKey}; 14 + use super::{ 15 + auth::AuthenticatedUser, 16 + serve::{Client, Error, Result, SigningKey}, 17 + }; 38 18 39 19 pub(super) async fn service_proxy( 40 20 uri: Uri,
-459
src/tests.rs
··· 1 - //! Testing utilities for the PDS. 2 - #![expect(clippy::arbitrary_source_item_ordering)] 3 - use std::{ 4 - net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, 5 - path::PathBuf, 6 - time::{Duration, Instant}, 7 - }; 8 - 9 - use anyhow::Result; 10 - use atrium_api::{ 11 - com::atproto::server, 12 - types::string::{AtIdentifier, Did, Handle, Nsid, RecordKey}, 13 - }; 14 - use figment::{Figment, providers::Format as _}; 15 - use futures::future::join_all; 16 - use serde::{Deserialize, Serialize}; 17 - use tokio::sync::OnceCell; 18 - use uuid::Uuid; 19 - 20 - use crate::config::AppConfig; 21 - 22 - /// Global test state, created once for all tests. 23 - pub(crate) static TEST_STATE: OnceCell<TestState> = OnceCell::const_new(); 24 - 25 - /// A temporary test directory that will be cleaned up when the struct is dropped. 26 - struct TempDir { 27 - /// The path to the directory. 28 - path: PathBuf, 29 - } 30 - 31 - impl TempDir { 32 - /// Create a new temporary directory. 33 - fn new() -> Result<Self> { 34 - let path = std::env::temp_dir().join(format!("bluepds-test-{}", Uuid::new_v4())); 35 - std::fs::create_dir_all(&path)?; 36 - Ok(Self { path }) 37 - } 38 - 39 - /// Get the path to the directory. 40 - fn path(&self) -> &PathBuf { 41 - &self.path 42 - } 43 - } 44 - 45 - impl Drop for TempDir { 46 - fn drop(&mut self) { 47 - drop(std::fs::remove_dir_all(&self.path)); 48 - } 49 - } 50 - 51 - /// Test state for the application. 52 - pub(crate) struct TestState { 53 - /// The address the test server is listening on. 54 - address: SocketAddr, 55 - /// The HTTP client. 56 - client: reqwest::Client, 57 - /// The application configuration. 58 - config: AppConfig, 59 - /// The temporary directory for test data. 60 - #[expect(dead_code)] 61 - temp_dir: TempDir, 62 - } 63 - 64 - impl TestState { 65 - /// Get a base URL for the test server. 66 - pub(crate) fn base_url(&self) -> String { 67 - format!("http://{}", self.address) 68 - } 69 - 70 - /// Create a test account. 71 - pub(crate) async fn create_test_account(&self) -> Result<TestAccount> { 72 - // Create the account 73 - let handle = "test.handle"; 74 - let response = self 75 - .client 76 - .post(format!( 77 - "http://{}/xrpc/com.atproto.server.createAccount", 78 - self.address 79 - )) 80 - .json(&server::create_account::InputData { 81 - did: None, 82 - verification_code: None, 83 - verification_phone: None, 84 - email: Some(format!("{}@example.com", &handle)), 85 - handle: Handle::new(handle.to_owned()).expect("should be able to create handle"), 86 - password: Some("password123".to_owned()), 87 - invite_code: None, 88 - recovery_key: None, 89 - plc_op: None, 90 - }) 91 - .send() 92 - .await?; 93 - 94 - let account: server::create_account::Output = response.json().await?; 95 - 96 - Ok(TestAccount { 97 - handle: handle.to_owned(), 98 - did: account.did.to_string(), 99 - access_token: account.access_jwt.clone(), 100 - refresh_token: account.refresh_jwt.clone(), 101 - }) 102 - } 103 - 104 - /// Create a new test state. 105 - #[expect(clippy::unused_async)] 106 - async fn new() -> Result<Self> { 107 - // Configure the test app 108 - #[derive(Serialize, Deserialize)] 109 - struct TestConfigInput { 110 - db: Option<String>, 111 - host_name: Option<String>, 112 - key: Option<PathBuf>, 113 - listen_address: Option<SocketAddr>, 114 - test: Option<bool>, 115 - } 116 - // Create a temporary directory for test data 117 - let temp_dir = TempDir::new()?; 118 - 119 - // Find a free port 120 - let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))?; 121 - let address = listener.local_addr()?; 122 - drop(listener); 123 - 124 - let test_config = TestConfigInput { 125 - db: Some(format!("sqlite://{}/test.db", temp_dir.path().display())), 126 - host_name: Some(format!("localhost:{}", address.port())), 127 - key: Some(temp_dir.path().join("test.key")), 128 - listen_address: Some(address), 129 - test: Some(true), 130 - }; 131 - 132 - let config: AppConfig = Figment::new() 133 - .admerge(figment::providers::Toml::file("default.toml")) 134 - .admerge(figment::providers::Env::prefixed("BLUEPDS_")) 135 - .merge(figment::providers::Serialized::defaults(test_config)) 136 - .merge( 137 - figment::providers::Toml::string( 138 - r#" 139 - [firehose] 140 - relays = [] 141 - 142 - [repo] 143 - path = "repo" 144 - 145 - [plc] 146 - path = "plc" 147 - 148 - [blob] 149 - path = "blob" 150 - limit = 10485760 # 10 MB 151 - "#, 152 - ) 153 - .nested(), 154 - ) 155 - .extract()?; 156 - 157 - // Create directories 158 - std::fs::create_dir_all(temp_dir.path().join("repo"))?; 159 - std::fs::create_dir_all(temp_dir.path().join("plc"))?; 160 - std::fs::create_dir_all(temp_dir.path().join("blob"))?; 161 - 162 - // Create client 163 - let client = reqwest::Client::builder() 164 - .timeout(Duration::from_secs(30)) 165 - .build()?; 166 - 167 - Ok(Self { 168 - address, 169 - client, 170 - config, 171 - temp_dir, 172 - }) 173 - } 174 - 175 - /// Start the application in a background task. 176 - async fn start_app(&self) -> Result<()> { 177 - // // Get a reference to the config that can be moved into the task 178 - // let config = self.config.clone(); 179 - // let address = self.address; 180 - 181 - // // Start the application in a background task 182 - // let _handle = tokio::spawn(async move { 183 - // // Set up the application 184 - // use crate::*; 185 - 186 - // // Initialize metrics (noop in test mode) 187 - // drop(metrics::setup(None)); 188 - 189 - // // Create client 190 - // let simple_client = reqwest::Client::builder() 191 - // .user_agent(APP_USER_AGENT) 192 - // .build() 193 - // .context("failed to build requester client")?; 194 - // let client = reqwest_middleware::ClientBuilder::new(simple_client.clone()) 195 - // .with(http_cache_reqwest::Cache(http_cache_reqwest::HttpCache { 196 - // mode: CacheMode::Default, 197 - // manager: MokaManager::default(), 198 - // options: HttpCacheOptions::default(), 199 - // })) 200 - // .build(); 201 - 202 - // // Create a test keypair 203 - // std::fs::create_dir_all(config.key.parent().context("should have parent")?)?; 204 - // let (skey, rkey) = { 205 - // let skey = Secp256k1Keypair::create(&mut rand::thread_rng()); 206 - // let rkey = Secp256k1Keypair::create(&mut rand::thread_rng()); 207 - 208 - // let keys = KeyData { 209 - // skey: skey.export(), 210 - // rkey: rkey.export(), 211 - // }; 212 - 213 - // let mut f = 214 - // std::fs::File::create(&config.key).context("failed to create key file")?; 215 - // serde_ipld_dagcbor::to_writer(&mut f, &keys) 216 - // .context("failed to serialize crypto keys")?; 217 - 218 - // (SigningKey(Arc::new(skey)), RotationKey(Arc::new(rkey))) 219 - // }; 220 - 221 - // // Set up database 222 - // let opts = SqliteConnectOptions::from_str(&config.db) 223 - // .context("failed to parse database options")? 224 - // .create_if_missing(true); 225 - // let db = SqliteDbConn::connect_with(opts).await?; 226 - 227 - // sqlx::migrate!() 228 - // .run(&db) 229 - // .await 230 - // .context("failed to apply migrations")?; 231 - 232 - // // Create firehose 233 - // let (_fh, fhp) = firehose::spawn(client.clone(), config.clone()); 234 - 235 - // // Create the application state 236 - // let app_state = AppState { 237 - // cred: azure_identity::DefaultAzureCredential::new()?, 238 - // config: config.clone(), 239 - // db: db.clone(), 240 - // client: client.clone(), 241 - // simple_client, 242 - // firehose: fhp, 243 - // signing_key: skey, 244 - // rotation_key: rkey, 245 - // }; 246 - 247 - // // Create the router 248 - // let app = Router::new() 249 - // .route("/", get(index)) 250 - // .merge(oauth::routes()) 251 - // .nest( 252 - // "/xrpc", 253 - // endpoints::routes() 254 - // .merge(actor_endpoints::routes()) 255 - // .fallback(service_proxy), 256 - // ) 257 - // .layer(CorsLayer::permissive()) 258 - // .layer(TraceLayer::new_for_http()) 259 - // .with_state(app_state); 260 - 261 - // // Listen for connections 262 - // let listener = TcpListener::bind(&address) 263 - // .await 264 - // .context("failed to bind address")?; 265 - 266 - // axum::serve(listener, app.into_make_service()) 267 - // .await 268 - // .context("failed to serve app") 269 - // }); 270 - 271 - // // Give the server a moment to start 272 - // tokio::time::sleep(Duration::from_millis(500)).await; 273 - 274 - Ok(()) 275 - } 276 - } 277 - 278 - /// A test account that can be used for testing. 279 - pub(crate) struct TestAccount { 280 - /// The access token for the account. 281 - pub(crate) access_token: String, 282 - /// The account DID. 283 - pub(crate) did: String, 284 - /// The account handle. 285 - pub(crate) handle: String, 286 - /// The refresh token for the account. 287 - #[expect(dead_code)] 288 - pub(crate) refresh_token: String, 289 - } 290 - 291 - /// Initialize the test state. 292 - pub(crate) async fn init_test_state() -> Result<&'static TestState> { 293 - async fn init_test_state() -> std::result::Result<TestState, anyhow::Error> { 294 - let state = TestState::new().await?; 295 - state.start_app().await?; 296 - Ok(state) 297 - } 298 - TEST_STATE.get_or_try_init(init_test_state).await 299 - } 300 - 301 - /// Create a record benchmark that creates records and measures the time it takes. 302 - #[expect( 303 - clippy::arithmetic_side_effects, 304 - clippy::integer_division, 305 - clippy::integer_division_remainder_used, 306 - clippy::use_debug, 307 - clippy::print_stdout 308 - )] 309 - pub(crate) async fn create_record_benchmark(count: usize, concurrent: usize) -> Result<Duration> { 310 - // Initialize the test state 311 - let state = init_test_state().await?; 312 - 313 - // Create a test account 314 - let account = state.create_test_account().await?; 315 - 316 - // Create the client with authorization 317 - let client = reqwest::Client::builder() 318 - .timeout(Duration::from_secs(30)) 319 - .build()?; 320 - 321 - let start = Instant::now(); 322 - 323 - // Split the work into batches 324 - let mut handles = Vec::new(); 325 - for batch_idx in 0..concurrent { 326 - let batch_size = count / concurrent; 327 - let client = client.clone(); 328 - let base_url = state.base_url(); 329 - let account_did = account.did.clone(); 330 - let account_handle = account.handle.clone(); 331 - let access_token = account.access_token.clone(); 332 - 333 - let handle = tokio::spawn(async move { 334 - let mut results = Vec::new(); 335 - 336 - for i in 0..batch_size { 337 - let request_start = Instant::now(); 338 - let record_idx = batch_idx * batch_size + i; 339 - 340 - let result = client 341 - .post(format!("{base_url}/xrpc/com.atproto.repo.createRecord")) 342 - .header("Authorization", format!("Bearer {access_token}")) 343 - .json(&atrium_api::com::atproto::repo::create_record::InputData { 344 - repo: AtIdentifier::Did(Did::new(account_did.clone()).expect("valid DID")), 345 - collection: Nsid::new("app.bsky.feed.post".to_owned()).expect("valid NSID"), 346 - rkey: Some( 347 - RecordKey::new(format!("test-{record_idx}")).expect("valid record key"), 348 - ), 349 - validate: None, 350 - record: serde_json::from_str( 351 - &serde_json::json!({ 352 - "$type": "app.bsky.feed.post", 353 - "text": format!("Test post {record_idx} from {account_handle}"), 354 - "createdAt": chrono::Utc::now().to_rfc3339(), 355 - }) 356 - .to_string(), 357 - ) 358 - .expect("valid JSON record"), 359 - swap_commit: None, 360 - }) 361 - .send() 362 - .await; 363 - 364 - // Fetch the record we just created 365 - let get_response = client 366 - .get(format!( 367 - "{base_url}/xrpc/com.atproto.sync.getRecord?did={account_did}&collection=app.bsky.feed.post&rkey={record_idx}" 368 - )) 369 - .header("Authorization", format!("Bearer {access_token}")) 370 - .send() 371 - .await; 372 - if get_response.is_err() { 373 - println!("Failed to fetch record {record_idx}: {get_response:?}"); 374 - results.push(get_response); 375 - continue; 376 - } 377 - 378 - let request_duration = request_start.elapsed(); 379 - if record_idx % 10 == 0 { 380 - println!("Created record {record_idx} in {request_duration:?}"); 381 - } 382 - results.push(result); 383 - } 384 - 385 - results 386 - }); 387 - 388 - handles.push(handle); 389 - } 390 - 391 - // Wait for all batches to complete 392 - let results = join_all(handles).await; 393 - 394 - // Check for errors 395 - for batch_result in results { 396 - let batch_responses = batch_result?; 397 - for response_result in batch_responses { 398 - match response_result { 399 - Ok(response) => { 400 - if !response.status().is_success() { 401 - return Err(anyhow::anyhow!( 402 - "Failed to create record: {}", 403 - response.status() 404 - )); 405 - } 406 - } 407 - Err(err) => { 408 - return Err(anyhow::anyhow!("Failed to create record: {}", err)); 409 - } 410 - } 411 - } 412 - } 413 - 414 - let duration = start.elapsed(); 415 - Ok(duration) 416 - } 417 - 418 - #[cfg(test)] 419 - #[expect(clippy::module_inception, clippy::use_debug, clippy::print_stdout)] 420 - mod tests { 421 - use super::*; 422 - use anyhow::anyhow; 423 - 424 - #[tokio::test] 425 - async fn test_create_account() -> Result<()> { 426 - return Ok(()); 427 - #[expect(unreachable_code, reason = "Disabled")] 428 - let state = init_test_state().await?; 429 - let account = state.create_test_account().await?; 430 - 431 - println!("Created test account: {}", account.handle); 432 - if account.handle.is_empty() { 433 - return Err(anyhow::anyhow!("Account handle is empty")); 434 - } 435 - if account.did.is_empty() { 436 - return Err(anyhow::anyhow!("Account DID is empty")); 437 - } 438 - if account.access_token.is_empty() { 439 - return Err(anyhow::anyhow!("Account access token is empty")); 440 - } 441 - 442 - Ok(()) 443 - } 444 - 445 - #[tokio::test] 446 - async fn test_create_record_benchmark() -> Result<()> { 447 - return Ok(()); 448 - #[expect(unreachable_code, reason = "Disabled")] 449 - let duration = create_record_benchmark(100, 1).await?; 450 - 451 - println!("Created 100 records in {duration:?}"); 452 - 453 - if duration.as_secs() >= 10 { 454 - return Err(anyhow!("Benchmark took too long")); 455 - } 456 - 457 - Ok(()) 458 - } 459 - }