this repo has no description

Backups, adversarial migrations

lewis 25af771a 5d65902c

Changed files
+7563 -2872
.sqlx
frontend
migrations
scripts
src
tests
+28
.sqlx/query-017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, backup_enabled FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "backup_enabled", 14 + "type_info": "Bool" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e" 28 + }
-16
.sqlx/query-0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Timestamptz", 10 - "Text" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe" 16 - }
···
+52
.sqlx/query-2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "backup_enabled", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "deactivated_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "repo_root_cid", 29 + "type_info": "Text" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "repo_rev", 34 + "type_info": "Text" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Text" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + false, 46 + true, 47 + false, 48 + true 49 + ] 50 + }, 51 + "hash": "2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c" 52 + }
-14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1" 14 - }
···
+22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "email_verified", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4" 22 + }
+7 -7
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json .sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 4 "describe": { 5 "columns": [ 6 { ··· 10 }, 11 { 12 "ordinal": 1, 13 - "name": "migrated_to_pds", 14 "type_info": "Text" 15 }, 16 { 17 "ordinal": 2, 18 - "name": "handle", 19 - "type_info": "Text" 20 } 21 ], 22 "parameters": { ··· 26 }, 27 "nullable": [ 28 false, 29 - true, 30 - false 31 ] 32 }, 33 - "hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073" 34 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 4 "describe": { 5 "columns": [ 6 { ··· 10 }, 11 { 12 "ordinal": 1, 13 + "name": "handle", 14 "type_info": "Text" 15 }, 16 { 17 "ordinal": 2, 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 } 21 ], 22 "parameters": { ··· 26 }, 27 "nullable": [ 28 false, 29 + false, 30 + true 31 ] 32 }, 33 + "hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7" 34 }
+2 -2
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.json .sqlx/query-ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ", 4 "describe": { 5 "columns": [ 6 { ··· 26 false 27 ] 28 }, 29 - "hash": "6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115" 30 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ", 4 "describe": { 5 "columns": [ 6 { ··· 26 false 27 ] 28 }, 29 + "hash": "ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf" 30 }
+35
.sqlx/query-72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT ab.id, ab.storage_key, u.deactivated_at\n FROM account_backups ab\n JOIN users u ON u.id = ab.user_id\n WHERE ab.id = $1 AND u.did = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "storage_key", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Uuid", 25 + "Text" 26 + ] 27 + }, 28 + "nullable": [ 29 + false, 30 + false, 31 + true 32 + ] 33 + }, 34 + "hash": "72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d" 35 + }
-34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "migrated_to_pds", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "migrated_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - true, 30 - true 31 - ] 32 - }, 33 - "hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e" 34 - }
···
+19
.sqlx/query-7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Text", 11 + "Text", 12 + "Int4", 13 + "Int8" 14 + ] 15 + }, 16 + "nullable": [] 17 + }, 18 + "hash": "7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c" 19 + }
+29
.sqlx/query-95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT ab.storage_key, ab.repo_rev\n FROM account_backups ab\n JOIN users u ON u.id = ab.user_id\n WHERE ab.id = $1 AND u.did = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "storage_key", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "repo_rev", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid", 20 + "Text" 21 + ] 22 + }, 23 + "nullable": [ 24 + false, 25 + false 26 + ] 27 + }, 28 + "hash": "95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f" 29 + }
+29
.sqlx/query-a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, storage_key\n FROM account_backups\n WHERE user_id = $1\n ORDER BY created_at DESC\n OFFSET $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "storage_key", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid", 20 + "Int8" 21 + ] 22 + }, 23 + "nullable": [ 24 + false, 25 + false 26 + ] 27 + }, 28 + "hash": "a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144" 29 + }
+52
.sqlx/query-b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at\n FROM account_backups\n WHERE user_id = $1\n ORDER BY created_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "repo_rev", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "repo_root_cid", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "block_count", 24 + "type_info": "Int4" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "size_bytes", 29 + "type_info": "Int8" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "created_at", 34 + "type_info": "Timestamptz" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Uuid" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + false, 46 + false, 47 + false, 48 + false 49 + ] 50 + }, 51 + "hash": "b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4" 52 + }
+40
.sqlx/query-d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.backup_enabled = true\n AND u.deactivated_at IS NULL\n AND (\n NOT EXISTS (\n SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id\n )\n OR (\n SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id\n ) < NOW() - make_interval(secs => $1)\n )\n LIMIT 50\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "user_id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "repo_root_cid", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "repo_rev", 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Float8" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + false, 36 + true 37 + ] 38 + }, 39 + "hash": "d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab" 40 + }
+34
.sqlx/query-f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT DISTINCT b.cid, b.storage_key, b.mime_type\n FROM blobs b\n JOIN record_blobs rb ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "cid", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "storage_key", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "mime_type", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Uuid" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1" 34 + }
+27
.sqlx/query-f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Uuid", 15 + "Text", 16 + "Text", 17 + "Text", 18 + "Int4", 19 + "Int8" 20 + ] 21 + }, 22 + "nullable": [ 23 + false 24 + ] 25 + }, 26 + "hash": "f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183" 27 + }
+15
.sqlx/query-f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET backup_enabled = $1 WHERE did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Bool", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70" 15 + }
+14
.sqlx/query-f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM account_backups WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532" 14 + }
+64
Cargo.lock
··· 111 checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 112 113 [[package]] 114 name = "arc-swap" 115 version = "1.7.1" 116 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1621 ] 1622 1623 [[package]] 1624 name = "derive_more" 1625 version = "1.0.0" 1626 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1973 checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 1974 dependencies = [ 1975 "crc32fast", 1976 "miniz_oxide", 1977 ] 1978 ··· 3457 dependencies = [ 3458 "pkg-config", 3459 "vcpkg", 3460 ] 3461 3462 [[package]] ··· 6286 "ed25519-dalek", 6287 "futures", 6288 "governor", 6289 "hickory-resolver", 6290 "hkdf", 6291 "hmac", ··· 6329 "webauthn-rs", 6330 "webauthn-rs-proto", 6331 "wiremock", 6332 ] 6333 6334 [[package]] ··· 7289 "proc-macro2", 7290 "quote", 7291 "syn 2.0.111", 7292 ] 7293 7294 [[package]]
··· 111 checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 112 113 [[package]] 114 + name = "arbitrary" 115 + version = "1.4.2" 116 + source = "registry+https://github.com/rust-lang/crates.io-index" 117 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 118 + dependencies = [ 119 + "derive_arbitrary", 120 + ] 121 + 122 + [[package]] 123 name = "arc-swap" 124 version = "1.7.1" 125 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1630 ] 1631 1632 [[package]] 1633 + name = "derive_arbitrary" 1634 + version = "1.4.2" 1635 + source = "registry+https://github.com/rust-lang/crates.io-index" 1636 + checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" 1637 + dependencies = [ 1638 + "proc-macro2", 1639 + "quote", 1640 + "syn 2.0.111", 1641 + ] 1642 + 1643 + [[package]] 1644 name = "derive_more" 1645 version = "1.0.0" 1646 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1993 checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 1994 dependencies = [ 1995 "crc32fast", 1996 + "libz-rs-sys", 1997 "miniz_oxide", 1998 ] 1999 ··· 3478 dependencies = [ 3479 "pkg-config", 3480 "vcpkg", 3481 + ] 3482 + 3483 + [[package]] 3484 + name = "libz-rs-sys" 3485 + version = "0.5.5" 3486 + source = "registry+https://github.com/rust-lang/crates.io-index" 3487 + checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" 3488 + dependencies = [ 3489 + "zlib-rs", 3490 ] 3491 3492 [[package]] ··· 6316 "ed25519-dalek", 6317 "futures", 6318 "governor", 6319 + "hex", 6320 "hickory-resolver", 6321 "hkdf", 6322 "hmac", ··· 6360 "webauthn-rs", 6361 "webauthn-rs-proto", 6362 "wiremock", 6363 + "zip", 6364 ] 6365 6366 [[package]] ··· 7321 "proc-macro2", 7322 "quote", 7323 "syn 2.0.111", 7324 + ] 7325 + 7326 + [[package]] 7327 + name = "zip" 7328 + version = "7.0.0" 7329 + source = "registry+https://github.com/rust-lang/crates.io-index" 7330 + checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037" 7331 + dependencies = [ 7332 + "arbitrary", 7333 + "crc32fast", 7334 + "flate2", 7335 + "indexmap 2.12.1", 7336 + "memchr", 7337 + "zopfli", 7338 + ] 7339 + 7340 + [[package]] 7341 + name = "zlib-rs" 7342 + version = "0.5.5" 7343 + source = "registry+https://github.com/rust-lang/crates.io-index" 7344 + checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" 7345 + 7346 + [[package]] 7347 + name = "zopfli" 7348 + version = "0.8.3" 7349 + source = "registry+https://github.com/rust-lang/crates.io-index" 7350 + checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" 7351 + dependencies = [ 7352 + "bumpalo", 7353 + "crc32fast", 7354 + "log", 7355 + "simd-adler32", 7356 ] 7357 7358 [[package]]
+2
Cargo.toml
··· 19 dotenvy = "0.15.7" 20 futures = "0.3.30" 21 governor = "0.10" 22 hkdf = "0.12" 23 hmac = "0.12" 24 aes-gcm = "0.10" ··· 62 totp-rs = { version = "5", features = ["qr"] } 63 webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 64 webauthn-rs-proto = "0.5.4" 65 [features] 66 external-infra = [] 67 [dev-dependencies]
··· 19 dotenvy = "0.15.7" 20 futures = "0.3.30" 21 governor = "0.10" 22 + hex = "0.4" 23 hkdf = "0.12" 24 hmac = "0.12" 25 aes-gcm = "0.10" ··· 63 totp-rs = { version = "5", features = ["qr"] } 64 webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 65 webauthn-rs-proto = "0.5.4" 66 + zip = { version = "7.0.0", default-features = false, features = ["deflate"] } 67 [features] 68 external-infra = [] 69 [dev-dependencies]
+1 -1
README.md
··· 14 15 This software isn't an afterthought by a company with limited resources. 16 17 - It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 18 19 The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. 20
··· 14 15 This software isn't an afterthought by a company with limited resources. 16 17 + It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 18 19 The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. 20
+2 -15
TODO.md
··· 2 3 ## Active development 4 5 - ### Migration tool 6 - Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states. 7 - 8 - - [x] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow 9 - - [x] Support `createAccount` with existing DID + service auth token 10 - - [x] Progress tracking with resume capability 11 - - [ ] Scheduled automatic backups (CAR export) 12 - - [ ] One-click restore from backup 13 - 14 - Outbound migration wizard exists but is disabled. Rethinking the approach: instead of a managed flow with `migratingTo` state, pds-hosted did:web users should just have direct control over their DID document. They can independently update serviceEndpoint, add/remove keys, export their repo, deactivate their account. 15 - 16 - - [ ] Remove `migratingTo` field and related state machine 17 - - [ ] Let did:web users edit their DID doc fields (serviceEndpoint, keys) whenever 18 - - [ ] Repo export as standalone feature, not tied to migration wizard 19 - 20 ### Plugin system 21 Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language. 22 ··· 69 App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords. 70 71 Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
··· 2 3 ## Active development 4 5 ### Plugin system 6 Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language. 7 ··· 54 App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords. 55 56 Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices. 57 + 58 + Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management.
+94
frontend/deno.lock
··· 1 { 2 "version": "5", 3 "specifiers": { 4 "npm:@noble/secp256k1@^2.1.0": "2.3.0", 5 "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3", 6 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", ··· 30 "lru-cache" 31 ] 32 }, 33 "@babel/code-frame@7.27.1": { 34 "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", 35 "dependencies": [ ··· 43 }, 44 "@babel/runtime@7.28.4": { 45 "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" 46 }, 47 "@csstools/color-helpers@5.1.0": { 48 "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==" ··· 498 "@noble/secp256k1@2.3.0": { 499 "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" 500 }, 501 "@rollup/rollup-android-arm-eabi@4.53.3": { 502 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", 503 "os": ["android"], ··· 607 "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", 608 "os": ["win32"], 609 "cpu": ["x64"] 610 }, 611 "@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": { 612 "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", ··· 1545 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1546 "bin": true 1547 }, 1548 "vite-node@2.1.9": { 1549 "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", 1550 "dependencies": [ ··· 1671 "workspace": { 1672 "packageJson": { 1673 "dependencies": [ 1674 "npm:@noble/secp256k1@^2.1.0", 1675 "npm:@sveltejs/vite-plugin-svelte@5", 1676 "npm:@testing-library/jest-dom@^6.6.3",
··· 1 { 2 "version": "5", 3 "specifiers": { 4 + "npm:@atcute/cbor@^2.2.8": "2.2.8", 5 + "npm:@atcute/crypto@^2.3.0": "2.3.0", 6 + "npm:@atcute/did-plc@~0.3.1": "0.3.1", 7 + "npm:@atcute/multibase@^1.1.6": "1.1.6", 8 "npm:@noble/secp256k1@^2.1.0": "2.3.0", 9 "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3", 10 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", ··· 34 "lru-cache" 35 ] 36 }, 37 + "@atcute/cbor@2.2.8": { 38 + "integrity": "sha512-UzOAN9BuN6JCXgn0ryV8qZuRJUDrNqrbLd6EFM8jc6RYssjRyGRxNy6RZ1NU/07Hd8Tq/0pz8+nQiMu5Zai5uw==", 39 + "dependencies": [ 40 + "@atcute/cid", 41 + "@atcute/multibase", 42 + "@atcute/uint8array" 43 + ] 44 + }, 45 + "@atcute/cid@2.3.0": { 46 + "integrity": "sha512-1SRdkTuMs/l5arQ+7Ag0F7JAueZqtzYE0d2gmbkuzi8EPweNU1kYlQs0CE4dSd81YF8PMDTOQty0K2ATq9CW9g==", 47 + "dependencies": [ 48 + "@atcute/multibase", 49 + "@atcute/uint8array" 50 + ] 51 + }, 52 + "@atcute/crypto@2.3.0": { 53 + "integrity": "sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==", 54 + "dependencies": [ 55 + "@atcute/multibase", 56 + "@atcute/uint8array", 57 + "@noble/secp256k1@3.0.0" 58 + ] 59 + }, 60 + "@atcute/did-plc@0.3.1": { 61 + "integrity": "sha512-KsuVdRtaaIPMmlcCDcxZzLg6OWm7rajczquhIHfA3s57+c34PFQbdY4Lsc2BvDwZ0fUjmbwzvQI3Zio2VcZa7w==", 62 + "dependencies": [ 63 + "@atcute/cbor", 64 + "@atcute/cid", 65 + "@atcute/crypto", 66 + "@atcute/identity", 67 + "@atcute/lexicons", 68 + "@atcute/multibase", 69 + "@atcute/uint8array", 70 + "@atcute/util-fetch", 71 + "@badrap/valita" 72 + ] 73 + }, 74 + "@atcute/identity@1.1.3": { 75 + "integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==", 76 + "dependencies": [ 77 + "@atcute/lexicons", 78 + "@badrap/valita" 79 + ] 80 + }, 81 + "@atcute/lexicons@1.2.6": { 82 + "integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==", 83 + "dependencies": [ 84 + "@atcute/uint8array", 85 + "@atcute/util-text", 86 + "@standard-schema/spec", 87 + "esm-env" 88 + ] 89 + }, 90 + "@atcute/multibase@1.1.6": { 91 + "integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==", 92 + "dependencies": [ 93 + "@atcute/uint8array" 94 + ] 95 + }, 96 + "@atcute/uint8array@1.0.6": { 97 + "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==" 98 + }, 99 + "@atcute/util-fetch@1.0.4": { 100 + "integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==", 101 + "dependencies": [ 102 + "@badrap/valita" 103 + ] 104 + }, 105 + "@atcute/util-text@0.0.1": { 106 + "integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==", 107 + "dependencies": [ 108 + "unicode-segmenter" 109 + ] 110 + }, 111 "@babel/code-frame@7.27.1": { 112 "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", 113 "dependencies": [ ··· 121 }, 122 "@babel/runtime@7.28.4": { 123 "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" 124 + }, 125 + "@badrap/valita@0.4.6": { 126 + "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" 127 }, 128 "@csstools/color-helpers@5.1.0": { 129 "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==" ··· 579 "@noble/secp256k1@2.3.0": { 580 "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" 581 }, 582 + "@noble/secp256k1@3.0.0": { 583 + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" 584 + }, 585 "@rollup/rollup-android-arm-eabi@4.53.3": { 586 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", 587 "os": ["android"], ··· 691 "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", 692 "os": ["win32"], 693 "cpu": ["x64"] 694 + }, 695 + "@standard-schema/spec@1.1.0": { 696 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" 697 }, 698 "@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": { 699 "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", ··· 1632 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1633 "bin": true 1634 }, 1635 + "unicode-segmenter@0.14.4": { 1636 + "integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg==" 1637 + }, 1638 "vite-node@2.1.9": { 1639 "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", 1640 "dependencies": [ ··· 1761 "workspace": { 1762 "packageJson": { 1763 "dependencies": [ 1764 + "npm:@atcute/cbor@^2.2.8", 1765 + "npm:@atcute/crypto@^2.3.0", 1766 + "npm:@atcute/did-plc@~0.3.1", 1767 + "npm:@atcute/multibase@^1.1.6", 1768 "npm:@noble/secp256k1@^2.1.0", 1769 "npm:@sveltejs/vite-plugin-svelte@5", 1770 "npm:@testing-library/jest-dom@^6.6.3",
+4
frontend/package.json
··· 12 "test:coverage": "vitest run --coverage" 13 }, 14 "dependencies": { 15 "@noble/secp256k1": "^2.1.0", 16 "multiformats": "^13.3.1", 17 "svelte-i18n": "^4.0.1"
··· 12 "test:coverage": "vitest run --coverage" 13 }, 14 "dependencies": { 15 + "@atcute/cbor": "^2.2.8", 16 + "@atcute/crypto": "^2.3.0", 17 + "@atcute/did-plc": "^0.3.1", 18 + "@atcute/multibase": "^1.1.6", 19 "@noble/secp256k1": "^2.1.0", 20 "multiformats": "^13.3.1", 21 "svelte-i18n": "^4.0.1"
+2 -2
frontend/src/components/ReauthModal.svelte
··· 228 /> 229 </div> 230 <button type="submit" class="btn-primary" disabled={loading || !password}> 231 - {loading ? $_('reauth.verifying') : $_('reauth.verify')} 232 </button> 233 </form> 234 {:else if activeMethod === 'totp'} ··· 247 /> 248 </div> 249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 250 - {loading ? $_('reauth.verifying') : $_('reauth.verify')} 251 </button> 252 </form> 253 {:else if activeMethod === 'passkey'}
··· 228 /> 229 </div> 230 <button type="submit" class="btn-primary" disabled={loading || !password}> 231 + {loading ? $_('common.verifying') : $_('common.verify')} 232 </button> 233 </form> 234 {:else if activeMethod === 'totp'} ··· 247 /> 248 </div> 249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 250 + {loading ? $_('common.verifying') : $_('common.verify')} 251 </button> 252 </form> 253 {:else if activeMethod === 'passkey'}
+86
frontend/src/components/migration/AppPasswordStep.svelte
···
··· 1 + <script lang="ts"> 2 + import { _ } from '../../lib/i18n' 3 + 4 + interface Props { 5 + appPassword: string 6 + appPasswordName: string 7 + loading: boolean 8 + onContinue: () => void 9 + } 10 + 11 + let { 12 + appPassword, 13 + appPasswordName, 14 + loading, 15 + onContinue, 16 + }: Props = $props() 17 + 18 + let copied = $state(false) 19 + let acknowledged = $state(false) 20 + 21 + function copyPassword() { 22 + navigator.clipboard.writeText(appPassword) 23 + copied = true 24 + } 25 + </script> 26 + 27 + <div class="step-content"> 28 + <h2>{$_('migration.inbound.appPassword.title')}</h2> 29 + <p>{$_('migration.inbound.appPassword.desc')}</p> 30 + 31 + <div class="warning-box"> 32 + <strong>{$_('migration.inbound.appPassword.warning')}</strong> 33 + </div> 34 + 35 + <div class="app-password-display"> 36 + <div class="app-password-label"> 37 + {$_('migration.inbound.appPassword.label')}: <strong>{appPasswordName}</strong> 38 + </div> 39 + <code class="app-password-code">{appPassword}</code> 40 + <button type="button" class="copy-btn" onclick={copyPassword}> 41 + {copied ? $_('common.copied') : $_('common.copyToClipboard')} 42 + </button> 43 + </div> 44 + 45 + <label class="checkbox-label"> 46 + <input type="checkbox" bind:checked={acknowledged} /> 47 + <span>{$_('migration.inbound.appPassword.saved')}</span> 48 + </label> 49 + 50 + <div class="button-row"> 51 + <button onclick={onContinue} disabled={!acknowledged || loading}> 52 + {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')} 53 + </button> 54 + </div> 55 + </div> 56 + 57 + <style> 58 + .app-password-display { 59 + background: var(--bg-card); 60 + border: 2px solid var(--accent); 61 + border-radius: var(--radius-xl); 62 + padding: var(--space-6); 63 + text-align: center; 64 + margin: var(--space-4) 0; 65 + } 66 + .app-password-label { 67 + font-size: var(--text-sm); 68 + color: var(--text-secondary); 69 + margin-bottom: var(--space-4); 70 + } 71 + .app-password-code { 72 + display: block; 73 + font-size: var(--text-xl); 74 + font-family: ui-monospace, monospace; 75 + letter-spacing: 0.1em; 76 + padding: var(--space-5); 77 + background: var(--bg-input); 78 + border-radius: var(--radius-md); 79 + margin-bottom: var(--space-4); 80 + user-select: all; 81 + } 82 + .copy-btn { 83 + padding: var(--space-3) var(--space-5); 84 + font-size: var(--text-sm); 85 + } 86 + </style>
+185
frontend/src/components/migration/ChooseHandleStep.svelte
···
··· 1 + <script lang="ts"> 2 + import type { AuthMethod, ServerDescription } from '../../lib/migration/types' 3 + import { _ } from '../../lib/i18n' 4 + 5 + interface Props { 6 + handleInput: string 7 + selectedDomain: string 8 + handleAvailable: boolean | null 9 + checkingHandle: boolean 10 + email: string 11 + password: string 12 + authMethod: AuthMethod 13 + inviteCode: string 14 + serverInfo: ServerDescription | null 15 + migratingFromLabel: string 16 + migratingFromValue: string 17 + loading?: boolean 18 + onHandleChange: (handle: string) => void 19 + onDomainChange: (domain: string) => void 20 + onCheckHandle: () => void 21 + onEmailChange: (email: string) => void 22 + onPasswordChange: (password: string) => void 23 + onAuthMethodChange: (method: AuthMethod) => void 24 + onInviteCodeChange: (code: string) => void 25 + onBack: () => void 26 + onContinue: () => void 27 + } 28 + 29 + let { 30 + handleInput, 31 + selectedDomain, 32 + handleAvailable, 33 + checkingHandle, 34 + email, 35 + password, 36 + authMethod, 37 + inviteCode, 38 + serverInfo, 39 + migratingFromLabel, 40 + migratingFromValue, 41 + loading = false, 42 + onHandleChange, 43 + onDomainChange, 44 + onCheckHandle, 45 + onEmailChange, 46 + onPasswordChange, 47 + onAuthMethodChange, 48 + onInviteCodeChange, 49 + onBack, 50 + onContinue, 51 + }: Props = $props() 52 + 53 + const canContinue = $derived( 54 + handleInput.trim() && 55 + email && 56 + (authMethod === 'passkey' || password) && 57 + handleAvailable !== false 58 + ) 59 + </script> 60 + 61 + <div class="step-content"> 62 + <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 63 + <p>{$_('migration.inbound.chooseHandle.desc')}</p> 64 + 65 + <div class="current-info"> 66 + <span class="label">{migratingFromLabel}:</span> 67 + <span class="value">{migratingFromValue}</span> 68 + </div> 69 + 70 + <div class="field"> 71 + <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 72 + <div class="handle-input-group"> 73 + <input 74 + id="new-handle" 75 + type="text" 76 + placeholder="username" 77 + value={handleInput} 78 + oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)} 79 + onblur={onCheckHandle} 80 + /> 81 + {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 82 + <select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}> 83 + {#each serverInfo.availableUserDomains as domain} 84 + <option value={domain}>.{domain}</option> 85 + {/each} 86 + </select> 87 + {/if} 88 + </div> 89 + 90 + {#if checkingHandle} 91 + <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 92 + {:else if handleAvailable === true} 93 + <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 94 + {:else if handleAvailable === false} 95 + <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 96 + {:else} 97 + <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 98 + {/if} 99 + </div> 100 + 101 + <div class="field"> 102 + <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 103 + <input 104 + id="email" 105 + type="email" 106 + placeholder="you@example.com" 107 + value={email} 108 + oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)} 109 + required 110 + /> 111 + </div> 112 + 113 + <div class="field"> 114 + <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 115 + <div class="auth-method-options"> 116 + <label class="auth-option" class:selected={authMethod === 'password'}> 117 + <input 118 + type="radio" 119 + name="auth-method" 120 + value="password" 121 + checked={authMethod === 'password'} 122 + onchange={() => onAuthMethodChange('password')} 123 + /> 124 + <div class="auth-option-content"> 125 + <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong> 126 + <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span> 127 + </div> 128 + </label> 129 + <label class="auth-option" class:selected={authMethod === 'passkey'}> 130 + <input 131 + type="radio" 132 + name="auth-method" 133 + value="passkey" 134 + checked={authMethod === 'passkey'} 135 + onchange={() => onAuthMethodChange('passkey')} 136 + /> 137 + <div class="auth-option-content"> 138 + <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong> 139 + <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span> 140 + </div> 141 + </label> 142 + </div> 143 + </div> 144 + 145 + {#if authMethod === 'password'} 146 + <div class="field"> 147 + <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label> 148 + <input 149 + id="new-password" 150 + type="password" 151 + placeholder="Password for your new account" 152 + value={password} 153 + oninput={(e) => onPasswordChange((e.target as HTMLInputElement).value)} 154 + required 155 + minlength={8} 156 + /> 157 + <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p> 158 + </div> 159 + {:else} 160 + <div class="info-box"> 161 + <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p> 162 + </div> 163 + {/if} 164 + 165 + {#if serverInfo?.inviteCodeRequired} 166 + <div class="field"> 167 + <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 168 + <input 169 + id="invite" 170 + type="text" 171 + placeholder="Enter invite code" 172 + value={inviteCode} 173 + oninput={(e) => onInviteCodeChange((e.target as HTMLInputElement).value)} 174 + required 175 + /> 176 + </div> 177 + {/if} 178 + 179 + <div class="button-row"> 180 + <button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button> 181 + <button disabled={!canContinue || loading} onclick={onContinue}> 182 + {$_('migration.inbound.common.continue')} 183 + </button> 184 + </div> 185 + </div>
+64
frontend/src/components/migration/EmailVerifyStep.svelte
···
··· 1 + <script lang="ts"> 2 + import { _ } from '../../lib/i18n' 3 + 4 + interface Props { 5 + email: string 6 + token: string 7 + loading: boolean 8 + error: string | null 9 + onTokenChange: (token: string) => void 10 + onSubmit: (e: Event) => void 11 + onResend: () => void 12 + } 13 + 14 + let { 15 + email, 16 + token, 17 + loading, 18 + error, 19 + onTokenChange, 20 + onSubmit, 21 + onResend, 22 + }: Props = $props() 23 + </script> 24 + 25 + <div class="step-content"> 26 + <h2>{$_('migration.inbound.emailVerify.title')}</h2> 27 + <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${email}</strong>` } })}</p> 28 + 29 + <div class="info-box"> 30 + <p> 31 + {$_('migration.inbound.emailVerify.hint')} 32 + </p> 33 + </div> 34 + 35 + {#if error} 36 + <div class="message error"> 37 + {error} 38 + </div> 39 + {/if} 40 + 41 + <form onsubmit={onSubmit}> 42 + <div class="field"> 43 + <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 44 + <input 45 + id="email-verify-token" 46 + type="text" 47 + placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 48 + value={token} 49 + oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)} 50 + disabled={loading} 51 + required 52 + /> 53 + </div> 54 + 55 + <div class="button-row"> 56 + <button type="button" class="ghost" onclick={onResend} disabled={loading}> 57 + {$_('migration.inbound.emailVerify.resend')} 58 + </button> 59 + <button type="submit" disabled={loading || !token}> 60 + {loading ? $_('common.verifying') : $_('common.verify')} 61 + </button> 62 + </div> 63 + </form> 64 + </div>
+23
frontend/src/components/migration/ErrorStep.svelte
···
··· 1 + <script lang="ts"> 2 + import { _ } from '../../lib/i18n' 3 + 4 + interface Props { 5 + error: string | null 6 + onStartOver: () => void 7 + } 8 + 9 + let { error, onStartOver }: Props = $props() 10 + </script> 11 + 12 + <div class="step-content"> 13 + <h2>{$_('migration.inbound.error.title')}</h2> 14 + <p>{$_('migration.inbound.error.desc')}</p> 15 + 16 + <div class="message error"> 17 + {error || $_('migration.inbound.error.unknown')} 18 + </div> 19 + 20 + <div class="button-row"> 21 + <button class="ghost" onclick={onStartOver}>{$_('migration.inbound.error.startOver')}</button> 22 + </div> 23 + </div>
+64 -306
frontend/src/components/migration/InboundWizard.svelte
··· 5 import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 import { _ } from '../../lib/i18n' 7 import '../../styles/migration.css' 8 9 interface ResumeInfo { 10 - direction: 'inbound' | 'outbound' 11 sourceHandle: string 12 targetHandle: string 13 sourcePdsUrl: string ··· 37 let checkingHandle = $state(false) 38 let selectedAuthMethod = $state<AuthMethod>('password') 39 let passkeyName = $state('') 40 - let appPasswordCopied = $state(false) 41 - let appPasswordAcknowledged = $state(false) 42 43 const isResuming = $derived(flow.state.needsReauth === true) 44 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) ··· 234 } 235 } 236 237 - function copyAppPassword() { 238 - if (flow.state.generatedAppPassword) { 239 - navigator.clipboard.writeText(flow.state.generatedAppPassword) 240 - appPasswordCopied = true 241 - } 242 - } 243 - 244 async function handleProceedFromAppPassword() { 245 loading = true 246 try { ··· 352 </label> 353 354 <div class="button-row"> 355 - <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 356 - <button disabled={!understood} onclick={() => flow.setStep('source-handle')}> 357 {$_('migration.inbound.common.continue')} 358 </button> 359 </div> ··· 409 </div> 410 411 {:else if flow.state.step === 'choose-handle'} 412 - <div class="step-content"> 413 - <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 414 - <p>{$_('migration.inbound.chooseHandle.desc')}</p> 415 - 416 - <div class="current-info"> 417 - <span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span> 418 - <span class="value">{flow.state.sourceHandle}</span> 419 - </div> 420 - 421 - <div class="field"> 422 - <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 423 - <div class="handle-input-group"> 424 - <input 425 - id="new-handle" 426 - type="text" 427 - placeholder="username" 428 - bind:value={handleInput} 429 - onblur={checkHandle} 430 - /> 431 - {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 432 - <select bind:value={selectedDomain}> 433 - {#each serverInfo.availableUserDomains as domain} 434 - <option value={domain}>.{domain}</option> 435 - {/each} 436 - </select> 437 - {/if} 438 - </div> 439 - 440 - {#if checkingHandle} 441 - <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 442 - {:else if handleAvailable === true} 443 - <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 444 - {:else if handleAvailable === false} 445 - <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 446 - {:else} 447 - <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 448 - {/if} 449 - </div> 450 - 451 - <div class="field"> 452 - <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 453 - <input 454 - id="email" 455 - type="email" 456 - placeholder="you@example.com" 457 - bind:value={flow.state.targetEmail} 458 - oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 459 - required 460 - /> 461 - </div> 462 - 463 - <div class="field"> 464 - <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 465 - <div class="auth-method-options"> 466 - <label class="auth-option" class:selected={selectedAuthMethod === 'password'}> 467 - <input 468 - type="radio" 469 - name="auth-method" 470 - value="password" 471 - bind:group={selectedAuthMethod} 472 - /> 473 - <div class="auth-option-content"> 474 - <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong> 475 - <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span> 476 - </div> 477 - </label> 478 - <label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}> 479 - <input 480 - type="radio" 481 - name="auth-method" 482 - value="passkey" 483 - bind:group={selectedAuthMethod} 484 - /> 485 - <div class="auth-option-content"> 486 - <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong> 487 - <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span> 488 - </div> 489 - </label> 490 - </div> 491 - </div> 492 - 493 - {#if selectedAuthMethod === 'password'} 494 - <div class="field"> 495 - <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label> 496 - <input 497 - id="new-password" 498 - type="password" 499 - placeholder="Password for your new account" 500 - bind:value={flow.state.targetPassword} 501 - oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 502 - required 503 - minlength="8" 504 - /> 505 - <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p> 506 - </div> 507 - {:else} 508 - <div class="info-box"> 509 - <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p> 510 - </div> 511 - {/if} 512 - 513 - {#if serverInfo?.inviteCodeRequired} 514 - <div class="field"> 515 - <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 516 - <input 517 - id="invite" 518 - type="text" 519 - placeholder="Enter invite code" 520 - bind:value={flow.state.inviteCode} 521 - oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 522 - required 523 - /> 524 - </div> 525 - {/if} 526 - 527 - <div class="button-row"> 528 - <button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button> 529 - <button 530 - disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false} 531 - onclick={proceedToReviewWithAuth} 532 - > 533 - {$_('migration.inbound.common.continue')} 534 - </button> 535 - </div> 536 - </div> 537 538 {:else if flow.state.step === 'review'} 539 <div class="step-content"> ··· 620 </div> 621 622 {:else if flow.state.step === 'passkey-setup'} 623 - <div class="step-content"> 624 - <h2>{$_('migration.inbound.passkeySetup.title')}</h2> 625 - <p>{$_('migration.inbound.passkeySetup.desc')}</p> 626 - 627 - {#if flow.state.error} 628 - <div class="message error"> 629 - {flow.state.error} 630 - </div> 631 - {/if} 632 - 633 - <div class="field"> 634 - <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label> 635 - <input 636 - id="passkey-name" 637 - type="text" 638 - placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')} 639 - bind:value={passkeyName} 640 - disabled={loading} 641 - /> 642 - <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p> 643 - </div> 644 - 645 - <div class="passkey-section"> 646 - <p>{$_('migration.inbound.passkeySetup.instructions')}</p> 647 - <button class="primary" onclick={registerPasskey} disabled={loading}> 648 - {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')} 649 - </button> 650 - </div> 651 - </div> 652 653 {:else if flow.state.step === 'app-password'} 654 - <div class="step-content"> 655 - <h2>{$_('migration.inbound.appPassword.title')}</h2> 656 - <p>{$_('migration.inbound.appPassword.desc')}</p> 657 - 658 - <div class="warning-box"> 659 - <strong>{$_('migration.inbound.appPassword.warning')}</strong> 660 - </div> 661 - 662 - <div class="app-password-display"> 663 - <div class="app-password-label"> 664 - {$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong> 665 - </div> 666 - <code class="app-password-code">{flow.state.generatedAppPassword}</code> 667 - <button type="button" class="copy-btn" onclick={copyAppPassword}> 668 - {appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')} 669 - </button> 670 - </div> 671 - 672 - <label class="checkbox-label"> 673 - <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 674 - <span>{$_('migration.inbound.appPassword.saved')}</span> 675 - </label> 676 - 677 - <div class="button-row"> 678 - <button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}> 679 - {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')} 680 - </button> 681 - </div> 682 - </div> 683 684 {:else if flow.state.step === 'email-verify'} 685 - <div class="step-content"> 686 - <h2>{$_('migration.inbound.emailVerify.title')}</h2> 687 - <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p> 688 - 689 - <div class="info-box"> 690 - <p> 691 - {$_('migration.inbound.emailVerify.hint')} 692 - </p> 693 - </div> 694 - 695 - {#if flow.state.error} 696 - <div class="message error"> 697 - {flow.state.error} 698 - </div> 699 - {/if} 700 - 701 - <form onsubmit={submitEmailVerify}> 702 - <div class="field"> 703 - <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 704 - <input 705 - id="email-verify-token" 706 - type="text" 707 - placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 708 - bind:value={flow.state.emailVerifyToken} 709 - oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)} 710 - disabled={loading} 711 - required 712 - /> 713 - </div> 714 - 715 - <div class="button-row"> 716 - <button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}> 717 - {$_('migration.inbound.emailVerify.resend')} 718 - </button> 719 - <button type="submit" disabled={loading || !flow.state.emailVerifyToken}> 720 - {loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')} 721 - </button> 722 - </div> 723 - </form> 724 - </div> 725 726 {:else if flow.state.step === 'plc-token'} 727 <div class="step-content"> ··· 837 </div> 838 839 {:else if flow.state.step === 'success'} 840 - <div class="step-content success-content"> 841 - <div class="success-icon">✓</div> 842 - <h2>{$_('migration.inbound.success.title')}</h2> 843 - <p>{$_('migration.inbound.success.desc')}</p> 844 - 845 - <div class="success-details"> 846 - <div class="detail-row"> 847 - <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 848 - <span class="value">{flow.state.targetHandle}</span> 849 - </div> 850 - <div class="detail-row"> 851 - <span class="label">{$_('migration.inbound.success.did')}:</span> 852 - <span class="value mono">{flow.state.sourceDid}</span> 853 - </div> 854 - </div> 855 - 856 - {#if flow.state.progress.blobsFailed.length > 0} 857 - <div class="message warning"> 858 - {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 859 - </div> 860 - {/if} 861 - 862 - <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 863 - </div> 864 865 {:else if flow.state.step === 'error'} 866 - <div class="step-content"> 867 - <h2>{$_('migration.inbound.error.title')}</h2> 868 - <p>{$_('migration.inbound.error.desc')}</p> 869 - 870 - <div class="message error"> 871 - {flow.state.error || 'An unknown error occurred. Please check the browser console for details.'} 872 - </div> 873 - 874 - <div class="button-row"> 875 - <button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button> 876 - </div> 877 - </div> 878 {/if} 879 </div> 880 881 <style> 882 - .passkey-section { 883 - margin-top: 16px; 884 - } 885 - .passkey-section button { 886 - width: 100%; 887 - margin-top: 12px; 888 - } 889 - .app-password-display { 890 - background: var(--bg-card); 891 - border: 2px solid var(--accent); 892 - border-radius: var(--radius-xl); 893 - padding: var(--space-6); 894 - text-align: center; 895 - margin: var(--space-4) 0; 896 - } 897 - .app-password-label { 898 - font-size: var(--text-sm); 899 - color: var(--text-secondary); 900 - margin-bottom: var(--space-4); 901 - } 902 - .app-password-code { 903 - display: block; 904 - font-size: var(--text-xl); 905 - font-family: ui-monospace, monospace; 906 - letter-spacing: 0.1em; 907 - padding: var(--space-5); 908 - background: var(--bg-input); 909 - border-radius: var(--radius-md); 910 - margin-bottom: var(--space-4); 911 - user-select: all; 912 - } 913 - .copy-btn { 914 - padding: var(--space-3) var(--space-5); 915 - font-size: var(--text-sm); 916 - } 917 .resume-info { 918 margin-bottom: var(--space-5); 919 }
··· 5 import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 import { _ } from '../../lib/i18n' 7 import '../../styles/migration.css' 8 + import ErrorStep from './ErrorStep.svelte' 9 + import SuccessStep from './SuccessStep.svelte' 10 + import ChooseHandleStep from './ChooseHandleStep.svelte' 11 + import EmailVerifyStep from './EmailVerifyStep.svelte' 12 + import PasskeySetupStep from './PasskeySetupStep.svelte' 13 + import AppPasswordStep from './AppPasswordStep.svelte' 14 15 interface ResumeInfo { 16 + direction: 'inbound' 17 sourceHandle: string 18 targetHandle: string 19 sourcePdsUrl: string ··· 43 let checkingHandle = $state(false) 44 let selectedAuthMethod = $state<AuthMethod>('password') 45 let passkeyName = $state('') 46 47 const isResuming = $derived(flow.state.needsReauth === true) 48 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) ··· 238 } 239 } 240 241 async function handleProceedFromAppPassword() { 242 loading = true 243 try { ··· 349 </label> 350 351 <div class="button-row"> 352 + <button type="button" class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 353 + <button type="button" disabled={!understood} onclick={() => flow.setStep('source-handle')}> 354 {$_('migration.inbound.common.continue')} 355 </button> 356 </div> ··· 406 </div> 407 408 {:else if flow.state.step === 'choose-handle'} 409 + <ChooseHandleStep 410 + {handleInput} 411 + {selectedDomain} 412 + {handleAvailable} 413 + {checkingHandle} 414 + email={flow.state.targetEmail} 415 + password={flow.state.targetPassword} 416 + authMethod={selectedAuthMethod} 417 + inviteCode={flow.state.inviteCode} 418 + {serverInfo} 419 + migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')} 420 + migratingFromValue={flow.state.sourceHandle} 421 + {loading} 422 + onHandleChange={(h) => handleInput = h} 423 + onDomainChange={(d) => selectedDomain = d} 424 + onCheckHandle={checkHandle} 425 + onEmailChange={(e) => flow.updateField('targetEmail', e)} 426 + onPasswordChange={(p) => flow.updateField('targetPassword', p)} 427 + onAuthMethodChange={(m) => selectedAuthMethod = m} 428 + onInviteCodeChange={(c) => flow.updateField('inviteCode', c)} 429 + onBack={() => flow.setStep('source-handle')} 430 + onContinue={proceedToReviewWithAuth} 431 + /> 432 433 {:else if flow.state.step === 'review'} 434 <div class="step-content"> ··· 515 </div> 516 517 {:else if flow.state.step === 'passkey-setup'} 518 + <PasskeySetupStep 519 + {passkeyName} 520 + {loading} 521 + error={flow.state.error} 522 + onPasskeyNameChange={(n) => passkeyName = n} 523 + onRegister={registerPasskey} 524 + /> 525 526 {:else if flow.state.step === 'app-password'} 527 + <AppPasswordStep 528 + appPassword={flow.state.generatedAppPassword || ''} 529 + appPasswordName={flow.state.generatedAppPasswordName || ''} 530 + {loading} 531 + onContinue={handleProceedFromAppPassword} 532 + /> 533 534 {:else if flow.state.step === 'email-verify'} 535 + <EmailVerifyStep 536 + email={flow.state.targetEmail} 537 + token={flow.state.emailVerifyToken} 538 + {loading} 539 + error={flow.state.error} 540 + onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 541 + onSubmit={submitEmailVerify} 542 + onResend={resendEmailVerify} 543 + /> 544 545 {:else if flow.state.step === 'plc-token'} 546 <div class="step-content"> ··· 656 </div> 657 658 {:else if flow.state.step === 'success'} 659 + <SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}> 660 + {#snippet extraContent()} 661 + {#if flow.state.progress.blobsFailed.length > 0} 662 + <div class="message warning"> 663 + {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 664 + </div> 665 + {/if} 666 + {/snippet} 667 + </SuccessStep> 668 669 {:else if flow.state.step === 'error'} 670 + <ErrorStep error={flow.state.error} onStartOver={onBack} /> 671 {/if} 672 </div> 673 674 <style> 675 .resume-info { 676 margin-bottom: var(--space-5); 677 }
+591
frontend/src/components/migration/OfflineInboundWizard.svelte
···
··· 1 + <script lang="ts"> 2 + import type { OfflineInboundMigrationFlow } from '../../lib/migration' 3 + import type { AuthMethod, ServerDescription } from '../../lib/migration/types' 4 + import { getErrorMessage } from '../../lib/migration/types' 5 + import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 + import { _ } from '../../lib/i18n' 7 + import '../../styles/migration.css' 8 + import ErrorStep from './ErrorStep.svelte' 9 + import SuccessStep from './SuccessStep.svelte' 10 + import ChooseHandleStep from './ChooseHandleStep.svelte' 11 + import EmailVerifyStep from './EmailVerifyStep.svelte' 12 + import PasskeySetupStep from './PasskeySetupStep.svelte' 13 + import AppPasswordStep from './AppPasswordStep.svelte' 14 + 15 + interface Props { 16 + flow: OfflineInboundMigrationFlow 17 + onBack: () => void 18 + onComplete: () => void 19 + } 20 + 21 + let { flow, onBack, onComplete }: Props = $props() 22 + 23 + let serverInfo = $state<ServerDescription | null>(null) 24 + let loading = $state(false) 25 + let understood = $state(false) 26 + let handleInput = $state('') 27 + let selectedDomain = $state('') 28 + let handleAvailable = $state<boolean | null>(null) 29 + let checkingHandle = $state(false) 30 + let validatingKey = $state(false) 31 + let keyValid = $state<boolean | null>(null) 32 + let fileInputRef = $state<HTMLInputElement | null>(null) 33 + let selectedAuthMethod = $state<AuthMethod>('password') 34 + let passkeyName = $state('') 35 + 36 + let redirectTriggered = $state(false) 37 + 38 + $effect(() => { 39 + if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 40 + loadServerInfo() 41 + } 42 + if (flow.state.step === 'choose-handle') { 43 + handleInput = '' 44 + handleAvailable = null 45 + } 46 + }) 47 + 48 + $effect(() => { 49 + if (flow.state.step === 'success' && !redirectTriggered) { 50 + redirectTriggered = true 51 + setTimeout(() => { 52 + onComplete() 53 + }, 2000) 54 + } 55 + }) 56 + 57 + $effect(() => { 58 + if (flow.state.step === 'email-verify') { 59 + const interval = setInterval(async () => { 60 + if (flow.state.emailVerifyToken.trim()) return 61 + await flow.checkEmailVerifiedAndProceed() 62 + }, 3000) 63 + return () => clearInterval(interval) 64 + } 65 + }) 66 + 67 + async function loadServerInfo() { 68 + if (!serverInfo) { 69 + serverInfo = await flow.loadLocalServerInfo() 70 + if (serverInfo.availableUserDomains.length > 0) { 71 + selectedDomain = serverInfo.availableUserDomains[0] 72 + } 73 + } 74 + } 75 + 76 + function handleFileSelect(e: Event) { 77 + const input = e.target as HTMLInputElement 78 + const file = input.files?.[0] 79 + if (!file) return 80 + 81 + const reader = new FileReader() 82 + reader.onload = () => { 83 + const arrayBuffer = reader.result as ArrayBuffer 84 + flow.setCarFile(new Uint8Array(arrayBuffer), file.name) 85 + } 86 + reader.readAsArrayBuffer(file) 87 + } 88 + 89 + async function validateRotationKey() { 90 + if (!flow.state.rotationKey || !flow.state.userDid) return 91 + 92 + validatingKey = true 93 + keyValid = null 94 + 95 + try { 96 + const isValid = await flow.validateRotationKey() 97 + keyValid = isValid 98 + if (isValid) { 99 + flow.setStep('choose-handle') 100 + } 101 + } catch (err) { 102 + flow.setError(getErrorMessage(err)) 103 + keyValid = false 104 + } finally { 105 + validatingKey = false 106 + } 107 + } 108 + 109 + async function startMigration() { 110 + loading = true 111 + try { 112 + await flow.runMigration() 113 + } catch (err) { 114 + flow.setError(getErrorMessage(err)) 115 + } finally { 116 + loading = false 117 + } 118 + } 119 + 120 + const steps = $derived( 121 + flow.state.authMethod === 'passkey' 122 + ? ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Passkey', 'App Password', 'Complete'] 123 + : ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Complete'] 124 + ) 125 + 126 + function getCurrentStepIndex(): number { 127 + const isPasskey = flow.state.authMethod === 'passkey' 128 + switch (flow.state.step) { 129 + case 'welcome': return 0 130 + case 'provide-did': return 0 131 + case 'upload-car': return 1 132 + case 'provide-rotation-key': return 2 133 + case 'choose-handle': return 3 134 + case 'review': return 4 135 + case 'creating': 136 + case 'importing': return 5 137 + case 'migrating-blobs': return 6 138 + case 'email-verify': return 7 139 + case 'passkey-setup': return isPasskey ? 8 : 7 140 + case 'app-password': return 9 141 + case 'plc-signing': 142 + case 'finalizing': return isPasskey ? 10 : 8 143 + case 'success': return isPasskey ? 10 : 8 144 + default: return 0 145 + } 146 + } 147 + 148 + async function checkHandle() { 149 + if (!handleInput.trim()) return 150 + 151 + const fullHandle = handleInput.includes('.') 152 + ? handleInput 153 + : `${handleInput}.${selectedDomain}` 154 + 155 + checkingHandle = true 156 + handleAvailable = null 157 + 158 + try { 159 + handleAvailable = await flow.checkHandleAvailability(fullHandle) 160 + } catch { 161 + handleAvailable = true 162 + } finally { 163 + checkingHandle = false 164 + } 165 + } 166 + 167 + function proceedToReview() { 168 + const fullHandle = handleInput.includes('.') 169 + ? handleInput 170 + : `${handleInput}.${selectedDomain}` 171 + 172 + flow.setTargetHandle(fullHandle) 173 + flow.setAuthMethod(selectedAuthMethod) 174 + flow.setStep('review') 175 + } 176 + 177 + async function submitEmailVerify(e: Event) { 178 + e.preventDefault() 179 + loading = true 180 + try { 181 + await flow.submitEmailVerifyToken(flow.state.emailVerifyToken) 182 + } catch (err) { 183 + flow.setError(getErrorMessage(err)) 184 + } finally { 185 + loading = false 186 + } 187 + } 188 + 189 + async function resendEmailVerify() { 190 + loading = true 191 + try { 192 + await flow.resendEmailVerification() 193 + flow.setError(null) 194 + } catch (err) { 195 + flow.setError(getErrorMessage(err)) 196 + } finally { 197 + loading = false 198 + } 199 + } 200 + 201 + async function registerPasskey() { 202 + loading = true 203 + flow.setError(null) 204 + 205 + try { 206 + if (!window.PublicKeyCredential) { 207 + throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.') 208 + } 209 + 210 + await flow.registerPasskey(passkeyName || undefined) 211 + } catch (err) { 212 + const message = getErrorMessage(err) 213 + if (message.includes('cancelled') || message.includes('AbortError')) { 214 + flow.setError('Passkey registration was cancelled. Please try again.') 215 + } else { 216 + flow.setError(message) 217 + } 218 + } finally { 219 + loading = false 220 + } 221 + } 222 + 223 + async function handleProceedFromAppPassword() { 224 + loading = true 225 + try { 226 + await flow.proceedFromAppPassword() 227 + } catch (err) { 228 + flow.setError(getErrorMessage(err)) 229 + } finally { 230 + loading = false 231 + } 232 + } 233 + </script> 234 + 235 + <div class="migration-wizard"> 236 + <div class="step-indicator"> 237 + {#each steps as _, i} 238 + <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 239 + <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 240 + </div> 241 + {#if i < steps.length - 1} 242 + <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 243 + {/if} 244 + {/each} 245 + </div> 246 + <div class="current-step-label"> 247 + <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length} 248 + </div> 249 + 250 + {#if flow.state.error} 251 + <div class="message error">{flow.state.error}</div> 252 + {/if} 253 + 254 + {#if flow.state.step === 'welcome'} 255 + <div class="step-content"> 256 + <h2>{$_('migration.offline.welcome.title')}</h2> 257 + <p>{$_('migration.offline.welcome.desc')}</p> 258 + 259 + <div class="warning-box"> 260 + <strong>{$_('migration.offline.welcome.warningTitle')}</strong> 261 + <p>{$_('migration.offline.welcome.warningDesc')}</p> 262 + </div> 263 + 264 + <div class="info-box"> 265 + <h3>{$_('migration.offline.welcome.requirementsTitle')}</h3> 266 + <ul> 267 + <li>{$_('migration.offline.welcome.requirement1')}</li> 268 + <li>{$_('migration.offline.welcome.requirement2')}</li> 269 + <li>{$_('migration.offline.welcome.requirement3')}</li> 270 + </ul> 271 + </div> 272 + 273 + <label class="checkbox-label"> 274 + <input type="checkbox" bind:checked={understood} /> 275 + <span>{$_('migration.offline.welcome.understand')}</span> 276 + </label> 277 + 278 + <div class="button-row"> 279 + <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 280 + <button disabled={!understood} onclick={() => flow.setStep('provide-did')}> 281 + {$_('migration.inbound.common.continue')} 282 + </button> 283 + </div> 284 + </div> 285 + 286 + {:else if flow.state.step === 'provide-did'} 287 + <div class="step-content"> 288 + <h2>{$_('migration.offline.provideDid.title')}</h2> 289 + <p>{$_('migration.offline.provideDid.desc')}</p> 290 + 291 + <div class="field"> 292 + <label for="user-did">{$_('migration.offline.provideDid.label')}</label> 293 + <input 294 + id="user-did" 295 + type="text" 296 + placeholder="did:plc:abc123..." 297 + value={flow.state.userDid} 298 + oninput={(e) => flow.setUserDid((e.target as HTMLInputElement).value)} 299 + /> 300 + <p class="hint">{$_('migration.offline.provideDid.hint')}</p> 301 + </div> 302 + 303 + <div class="button-row"> 304 + <button class="ghost" onclick={() => flow.setStep('welcome')}>{$_('migration.inbound.common.back')}</button> 305 + <button disabled={!flow.state.userDid.startsWith('did:')} onclick={() => flow.setStep('upload-car')}> 306 + {$_('migration.inbound.common.continue')} 307 + </button> 308 + </div> 309 + </div> 310 + 311 + {:else if flow.state.step === 'upload-car'} 312 + <div class="step-content"> 313 + <h2>{$_('migration.offline.uploadCar.title')}</h2> 314 + <p>{$_('migration.offline.uploadCar.desc')}</p> 315 + 316 + {#if flow.state.carNeedsReupload} 317 + <div class="warning-box"> 318 + <strong>{$_('migration.offline.uploadCar.reuploadWarningTitle')}</strong> 319 + <p>{$_('migration.offline.uploadCar.reuploadWarning')}</p> 320 + {#if flow.state.carFileName} 321 + <p><strong>Previous file:</strong> {flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</p> 322 + {/if} 323 + </div> 324 + {/if} 325 + 326 + <div class="field"> 327 + <label for="car-file">{$_('migration.offline.uploadCar.label')}</label> 328 + <div class="file-input-container"> 329 + <input 330 + id="car-file" 331 + type="file" 332 + accept=".car" 333 + onchange={handleFileSelect} 334 + bind:this={fileInputRef} 335 + /> 336 + {#if flow.state.carFile && flow.state.carFileName} 337 + <div class="file-info"> 338 + <span class="file-name">{flow.state.carFileName}</span> 339 + <span class="file-size">({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 340 + </div> 341 + {/if} 342 + </div> 343 + <p class="hint">{$_('migration.offline.uploadCar.hint')}</p> 344 + </div> 345 + 346 + <div class="button-row"> 347 + <button class="ghost" onclick={() => flow.setStep('provide-did')}>{$_('migration.inbound.common.back')}</button> 348 + <button disabled={!flow.state.carFile} onclick={() => flow.setStep('provide-rotation-key')}> 349 + {$_('migration.inbound.common.continue')} 350 + </button> 351 + </div> 352 + </div> 353 + 354 + {:else if flow.state.step === 'provide-rotation-key'} 355 + <div class="step-content"> 356 + <h2>{$_('migration.offline.rotationKey.title')}</h2> 357 + <p>{$_('migration.offline.rotationKey.desc')}</p> 358 + 359 + <div class="warning-box"> 360 + <strong>{$_('migration.offline.rotationKey.securityWarningTitle')}</strong> 361 + <ul> 362 + <li>{$_('migration.offline.rotationKey.securityWarning1')}</li> 363 + <li>{$_('migration.offline.rotationKey.securityWarning2')}</li> 364 + <li>{$_('migration.offline.rotationKey.securityWarning3')}</li> 365 + </ul> 366 + </div> 367 + 368 + <div class="field"> 369 + <label for="rotation-key">{$_('migration.offline.rotationKey.label')}</label> 370 + <textarea 371 + id="rotation-key" 372 + rows={4} 373 + placeholder={$_('migration.offline.rotationKey.placeholder')} 374 + value={flow.state.rotationKey} 375 + oninput={(e) => { 376 + flow.setRotationKey((e.target as HTMLTextAreaElement).value) 377 + keyValid = null 378 + }} 379 + ></textarea> 380 + <p class="hint">{$_('migration.offline.rotationKey.hint')}</p> 381 + </div> 382 + 383 + {#if keyValid === true} 384 + <div class="message success">{$_('migration.offline.rotationKey.valid')}</div> 385 + {:else if keyValid === false} 386 + <div class="message error">{$_('migration.offline.rotationKey.invalid')}</div> 387 + {/if} 388 + 389 + <div class="button-row"> 390 + <button class="ghost" onclick={() => flow.setStep('upload-car')}>{$_('migration.inbound.common.back')}</button> 391 + <button 392 + disabled={!flow.state.rotationKey || validatingKey} 393 + onclick={validateRotationKey} 394 + > 395 + {validatingKey ? $_('migration.offline.rotationKey.validating') : $_('migration.offline.rotationKey.validate')} 396 + </button> 397 + </div> 398 + </div> 399 + 400 + {:else if flow.state.step === 'choose-handle'} 401 + <ChooseHandleStep 402 + {handleInput} 403 + {selectedDomain} 404 + {handleAvailable} 405 + {checkingHandle} 406 + email={flow.state.targetEmail} 407 + password={flow.state.targetPassword} 408 + authMethod={selectedAuthMethod} 409 + inviteCode={flow.state.inviteCode} 410 + {serverInfo} 411 + migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')} 412 + migratingFromValue={flow.state.userDid} 413 + {loading} 414 + onHandleChange={(h) => handleInput = h} 415 + onDomainChange={(d) => selectedDomain = d} 416 + onCheckHandle={checkHandle} 417 + onEmailChange={(e) => flow.setTargetEmail(e)} 418 + onPasswordChange={(p) => flow.setTargetPassword(p)} 419 + onAuthMethodChange={(m) => selectedAuthMethod = m} 420 + onInviteCodeChange={(c) => flow.setInviteCode(c)} 421 + onBack={() => flow.setStep('provide-rotation-key')} 422 + onContinue={proceedToReview} 423 + /> 424 + 425 + {:else if flow.state.step === 'review'} 426 + <div class="step-content"> 427 + <h2>{$_('migration.inbound.review.title')}</h2> 428 + <p>{$_('migration.offline.review.desc')}</p> 429 + 430 + <div class="review-card"> 431 + <div class="review-row"> 432 + <span class="label">{$_('migration.inbound.review.did')}:</span> 433 + <span class="value mono">{flow.state.userDid}</span> 434 + </div> 435 + <div class="review-row"> 436 + <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 437 + <span class="value">{flow.state.targetHandle}</span> 438 + </div> 439 + <div class="review-row"> 440 + <span class="label">{$_('migration.offline.review.carFile')}:</span> 441 + <span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 442 + </div> 443 + <div class="review-row"> 444 + <span class="label">{$_('migration.offline.review.rotationKey')}:</span> 445 + <span class="value mono">{flow.state.rotationKeyDidKey}</span> 446 + </div> 447 + <div class="review-row"> 448 + <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 449 + <span class="value">{window.location.origin}</span> 450 + </div> 451 + <div class="review-row"> 452 + <span class="label">{$_('migration.inbound.review.email')}:</span> 453 + <span class="value">{flow.state.targetEmail}</span> 454 + </div> 455 + <div class="review-row"> 456 + <span class="label">{$_('migration.inbound.review.authentication')}:</span> 457 + <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span> 458 + </div> 459 + </div> 460 + 461 + <div class="warning-box"> 462 + <strong>{$_('migration.offline.review.plcWarningTitle')}</strong> 463 + <p>{$_('migration.offline.review.plcWarning')}</p> 464 + </div> 465 + 466 + <div class="button-row"> 467 + <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 468 + <button onclick={startMigration} disabled={loading}> 469 + {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 470 + </button> 471 + </div> 472 + </div> 473 + 474 + {:else if flow.state.step === 'creating' || flow.state.step === 'importing'} 475 + <div class="step-content"> 476 + <h2>{$_('migration.offline.migrating.title')}</h2> 477 + <p>{$_('migration.offline.migrating.desc')}</p> 478 + 479 + <div class="progress-section"> 480 + <div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}> 481 + <span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span> 482 + <span>{$_('migration.offline.migrating.creating')}</span> 483 + </div> 484 + <div class="progress-item" class:active={flow.state.step === 'importing'}> 485 + <span class="icon">○</span> 486 + <span>{$_('migration.offline.migrating.importing')}</span> 487 + </div> 488 + </div> 489 + 490 + <p class="status-text">{flow.state.progress.currentOperation}</p> 491 + </div> 492 + 493 + {:else if flow.state.step === 'migrating-blobs'} 494 + <div class="step-content"> 495 + <h2>{$_('migration.offline.blobs.title')}</h2> 496 + <p>{$_('migration.offline.blobs.desc')}</p> 497 + 498 + <div class="progress-section"> 499 + <div class="progress-item completed"> 500 + <span class="icon">✓</span> 501 + <span>{$_('migration.offline.migrating.importing')}</span> 502 + </div> 503 + <div class="progress-item active"> 504 + <span class="icon">○</span> 505 + <span>{$_('migration.offline.blobs.migrating')}</span> 506 + </div> 507 + </div> 508 + 509 + {#if flow.state.progress.blobsTotal > 0} 510 + <div class="blob-progress"> 511 + <div class="blob-progress-bar"> 512 + <div 513 + class="blob-progress-fill" 514 + style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 515 + ></div> 516 + </div> 517 + <p class="blob-progress-text"> 518 + {flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs 519 + </p> 520 + </div> 521 + {/if} 522 + 523 + <p class="status-text">{flow.state.progress.currentOperation}</p> 524 + 525 + {#if flow.state.progress.blobsFailed.length > 0} 526 + <div class="warning-box"> 527 + <strong>{$_('migration.offline.blobs.failedTitle')}</strong> 528 + <p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p> 529 + </div> 530 + {/if} 531 + </div> 532 + 533 + {:else if flow.state.step === 'email-verify'} 534 + <EmailVerifyStep 535 + email={flow.state.targetEmail} 536 + token={flow.state.emailVerifyToken} 537 + {loading} 538 + error={flow.state.error} 539 + onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 540 + onSubmit={submitEmailVerify} 541 + onResend={resendEmailVerify} 542 + /> 543 + 544 + {:else if flow.state.step === 'passkey-setup'} 545 + <PasskeySetupStep 546 + {passkeyName} 547 + {loading} 548 + error={flow.state.error} 549 + onPasskeyNameChange={(n) => passkeyName = n} 550 + onRegister={registerPasskey} 551 + /> 552 + 553 + {:else if flow.state.step === 'app-password'} 554 + <AppPasswordStep 555 + appPassword={flow.state.generatedAppPassword || ''} 556 + appPasswordName={flow.state.generatedAppPasswordName || ''} 557 + {loading} 558 + onContinue={handleProceedFromAppPassword} 559 + /> 560 + 561 + {:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'} 562 + <div class="step-content"> 563 + <h2>{$_('migration.inbound.finalizing.title')}</h2> 564 + <p>{$_('migration.inbound.finalizing.desc')}</p> 565 + 566 + <div class="progress-section"> 567 + <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 568 + <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 569 + <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 570 + </div> 571 + <div class="progress-item" class:completed={flow.state.progress.activated}> 572 + <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 573 + <span>{$_('migration.inbound.finalizing.activating')}</span> 574 + </div> 575 + </div> 576 + 577 + <p class="status-text">{flow.state.progress.currentOperation}</p> 578 + </div> 579 + 580 + {:else if flow.state.step === 'success'} 581 + <SuccessStep 582 + handle={flow.state.targetHandle} 583 + did={flow.state.userDid} 584 + description={$_('migration.offline.success.desc')} 585 + /> 586 + 587 + {:else if flow.state.step === 'error'} 588 + <ErrorStep error={flow.state.error} onStartOver={onBack} /> 589 + {/if} 590 + </div> 591 +
-546
frontend/src/components/migration/OutboundWizard.svelte
··· 1 - <script lang="ts"> 2 - import type { OutboundMigrationFlow } from '../../lib/migration' 3 - import type { ServerDescription } from '../../lib/migration/types' 4 - import { getAuthState, logout } from '../../lib/auth.svelte' 5 - import '../../styles/migration.css' 6 - 7 - interface Props { 8 - flow: OutboundMigrationFlow 9 - onBack: () => void 10 - onComplete: () => void 11 - } 12 - 13 - let { flow, onBack, onComplete }: Props = $props() 14 - 15 - const auth = getAuthState() 16 - 17 - let loading = $state(false) 18 - let understood = $state(false) 19 - let pdsUrlInput = $state('') 20 - let handleInput = $state('') 21 - let selectedDomain = $state('') 22 - let confirmFinal = $state(false) 23 - 24 - $effect(() => { 25 - if (flow.state.step === 'success') { 26 - setTimeout(async () => { 27 - await logout() 28 - onComplete() 29 - }, 3000) 30 - } 31 - }) 32 - 33 - $effect(() => { 34 - if (flow.state.targetServerInfo?.availableUserDomains?.length) { 35 - selectedDomain = flow.state.targetServerInfo.availableUserDomains[0] 36 - } 37 - }) 38 - 39 - async function validatePds(e: Event) { 40 - e.preventDefault() 41 - loading = true 42 - flow.updateField('error', null) 43 - 44 - try { 45 - let url = pdsUrlInput.trim() 46 - if (!url.startsWith('http://') && !url.startsWith('https://')) { 47 - url = `https://${url}` 48 - } 49 - await flow.validateTargetPds(url) 50 - flow.setStep('new-account') 51 - } catch (err) { 52 - flow.setError((err as Error).message) 53 - } finally { 54 - loading = false 55 - } 56 - } 57 - 58 - function proceedToReview() { 59 - const fullHandle = handleInput.includes('.') 60 - ? handleInput 61 - : `${handleInput}.${selectedDomain}` 62 - 63 - flow.updateField('targetHandle', fullHandle) 64 - flow.setStep('review') 65 - } 66 - 67 - async function startMigration() { 68 - if (!auth.session) return 69 - loading = true 70 - try { 71 - await flow.startMigration(auth.session.did) 72 - } catch (err) { 73 - flow.setError((err as Error).message) 74 - } finally { 75 - loading = false 76 - } 77 - } 78 - 79 - async function submitPlcToken(e: Event) { 80 - e.preventDefault() 81 - loading = true 82 - try { 83 - await flow.submitPlcToken(flow.state.plcToken) 84 - } catch (err) { 85 - flow.setError((err as Error).message) 86 - } finally { 87 - loading = false 88 - } 89 - } 90 - 91 - async function resendToken() { 92 - loading = true 93 - try { 94 - await flow.resendPlcToken() 95 - flow.setError(null) 96 - } catch (err) { 97 - flow.setError((err as Error).message) 98 - } finally { 99 - loading = false 100 - } 101 - } 102 - 103 - function isDidWeb(): boolean { 104 - return auth.session?.did?.startsWith('did:web:') ?? false 105 - } 106 - 107 - const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete'] 108 - function getCurrentStepIndex(): number { 109 - switch (flow.state.step) { 110 - case 'welcome': return -1 111 - case 'target-pds': return 0 112 - case 'new-account': return 1 113 - case 'review': return 2 114 - case 'migrating': return 3 115 - case 'plc-token': 116 - case 'finalizing': return 4 117 - case 'success': return 5 118 - default: return 0 119 - } 120 - } 121 - </script> 122 - 123 - <div class="migration-wizard"> 124 - {#if flow.state.step !== 'welcome'} 125 - <div class="step-indicator"> 126 - {#each steps as stepName, i} 127 - <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 128 - <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 129 - <span class="step-label">{stepName}</span> 130 - </div> 131 - {#if i < steps.length - 1} 132 - <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 133 - {/if} 134 - {/each} 135 - </div> 136 - {/if} 137 - 138 - {#if flow.state.error} 139 - <div class="migration-message error">{flow.state.error}</div> 140 - {/if} 141 - 142 - {#if flow.state.step === 'welcome'} 143 - <div class="step-content"> 144 - <h2>Migrate Your Account Away</h2> 145 - <p>This wizard will help you move your AT Protocol account from this PDS to another one.</p> 146 - 147 - <div class="current-account"> 148 - <span class="label">Current account:</span> 149 - <span class="value">@{auth.session?.handle}</span> 150 - </div> 151 - 152 - {#if isDidWeb()} 153 - <div class="migration-warning-box"> 154 - <strong>did:web Migration Notice</strong> 155 - <p> 156 - Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will 157 - continue serving your DID document with an updated service endpoint pointing to your new PDS. 158 - </p> 159 - <p> 160 - You can return here anytime to update the forwarding if you migrate again in the future. 161 - </p> 162 - </div> 163 - {/if} 164 - 165 - <div class="migration-info-box"> 166 - <h3>What will happen:</h3> 167 - <ol> 168 - <li>Choose your new PDS</li> 169 - <li>Set up your account on the new server</li> 170 - <li>Your repository and blobs will be transferred</li> 171 - <li>Verify the migration via email</li> 172 - <li>Your identity will be updated to point to the new PDS</li> 173 - <li>Your account here will be deactivated</li> 174 - </ol> 175 - </div> 176 - 177 - <div class="migration-warning-box"> 178 - <strong>Before you proceed:</strong> 179 - <ul> 180 - <li>You need access to the email registered with this account</li> 181 - <li>You will lose access to this account on this PDS</li> 182 - <li>Make sure you trust the destination PDS</li> 183 - <li>Large accounts may take several minutes to transfer</li> 184 - </ul> 185 - </div> 186 - 187 - <label class="checkbox-label"> 188 - <input type="checkbox" bind:checked={understood} /> 189 - <span>I understand that my account will be moved and deactivated here</span> 190 - </label> 191 - 192 - <div class="button-row"> 193 - <button class="ghost" onclick={onBack}>Cancel</button> 194 - <button disabled={!understood} onclick={() => flow.setStep('target-pds')}> 195 - Continue 196 - </button> 197 - </div> 198 - </div> 199 - 200 - {:else if flow.state.step === 'target-pds'} 201 - <div class="step-content"> 202 - <h2>Choose Your New PDS</h2> 203 - <p>Enter the URL of the PDS you want to migrate to.</p> 204 - 205 - <form onsubmit={validatePds}> 206 - <div class="migration-field"> 207 - <label for="pds-url">PDS URL</label> 208 - <input 209 - id="pds-url" 210 - type="text" 211 - placeholder="pds.example.com" 212 - bind:value={pdsUrlInput} 213 - disabled={loading} 214 - required 215 - /> 216 - <p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p> 217 - </div> 218 - 219 - <div class="button-row"> 220 - <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button> 221 - <button type="submit" disabled={loading || !pdsUrlInput.trim()}> 222 - {loading ? 'Checking...' : 'Connect'} 223 - </button> 224 - </div> 225 - </form> 226 - 227 - {#if flow.state.targetServerInfo} 228 - <div class="server-info"> 229 - <h3>Connected to PDS</h3> 230 - <div class="info-row"> 231 - <span class="label">Server:</span> 232 - <span class="value">{flow.state.targetPdsUrl}</span> 233 - </div> 234 - {#if flow.state.targetServerInfo.availableUserDomains.length > 0} 235 - <div class="info-row"> 236 - <span class="label">Available domains:</span> 237 - <span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span> 238 - </div> 239 - {/if} 240 - <div class="info-row"> 241 - <span class="label">Invite required:</span> 242 - <span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span> 243 - </div> 244 - {#if flow.state.targetServerInfo.links?.termsOfService} 245 - <a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener"> 246 - Terms of Service 247 - </a> 248 - {/if} 249 - {#if flow.state.targetServerInfo.links?.privacyPolicy} 250 - <a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener"> 251 - Privacy Policy 252 - </a> 253 - {/if} 254 - </div> 255 - {/if} 256 - </div> 257 - 258 - {:else if flow.state.step === 'new-account'} 259 - <div class="step-content"> 260 - <h2>Set Up Your New Account</h2> 261 - <p>Configure your account details on the new PDS.</p> 262 - 263 - <div class="current-info"> 264 - <span class="label">Migrating to:</span> 265 - <span class="value">{flow.state.targetPdsUrl}</span> 266 - </div> 267 - 268 - <div class="migration-field"> 269 - <label for="new-handle">New Handle</label> 270 - <div class="handle-input-group"> 271 - <input 272 - id="new-handle" 273 - type="text" 274 - placeholder="username" 275 - bind:value={handleInput} 276 - /> 277 - {#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 278 - <select bind:value={selectedDomain}> 279 - {#each flow.state.targetServerInfo.availableUserDomains as domain} 280 - <option value={domain}>.{domain}</option> 281 - {/each} 282 - </select> 283 - {/if} 284 - </div> 285 - <p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 286 - </div> 287 - 288 - <div class="migration-field"> 289 - <label for="email">Email Address</label> 290 - <input 291 - id="email" 292 - type="email" 293 - placeholder="you@example.com" 294 - bind:value={flow.state.targetEmail} 295 - oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 296 - required 297 - /> 298 - </div> 299 - 300 - <div class="migration-field"> 301 - <label for="new-password">Password</label> 302 - <input 303 - id="new-password" 304 - type="password" 305 - placeholder="Password for your new account" 306 - bind:value={flow.state.targetPassword} 307 - oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 308 - required 309 - minlength="8" 310 - /> 311 - <p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p> 312 - </div> 313 - 314 - {#if flow.state.targetServerInfo?.inviteCodeRequired} 315 - <div class="migration-field"> 316 - <label for="invite">Invite Code</label> 317 - <input 318 - id="invite" 319 - type="text" 320 - placeholder="Enter invite code" 321 - bind:value={flow.state.inviteCode} 322 - oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 323 - required 324 - /> 325 - <p class="migration-hint">Required by this PDS to create an account</p> 326 - </div> 327 - {/if} 328 - 329 - <div class="button-row"> 330 - <button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button> 331 - <button 332 - disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword} 333 - onclick={proceedToReview} 334 - > 335 - Continue 336 - </button> 337 - </div> 338 - </div> 339 - 340 - {:else if flow.state.step === 'review'} 341 - <div class="step-content"> 342 - <h2>Review Migration</h2> 343 - <p>Please confirm the details of your migration.</p> 344 - 345 - <div class="review-card"> 346 - <div class="review-row"> 347 - <span class="label">Current Handle:</span> 348 - <span class="value">@{auth.session?.handle}</span> 349 - </div> 350 - <div class="review-row"> 351 - <span class="label">New Handle:</span> 352 - <span class="value">@{flow.state.targetHandle}</span> 353 - </div> 354 - <div class="review-row"> 355 - <span class="label">DID:</span> 356 - <span class="value mono">{auth.session?.did}</span> 357 - </div> 358 - <div class="review-row"> 359 - <span class="label">From PDS:</span> 360 - <span class="value">{window.location.origin}</span> 361 - </div> 362 - <div class="review-row"> 363 - <span class="label">To PDS:</span> 364 - <span class="value">{flow.state.targetPdsUrl}</span> 365 - </div> 366 - <div class="review-row"> 367 - <span class="label">New Email:</span> 368 - <span class="value">{flow.state.targetEmail}</span> 369 - </div> 370 - </div> 371 - 372 - <div class="migration-warning-box final-warning"> 373 - <strong>This action cannot be easily undone!</strong> 374 - <p> 375 - After migration completes, your account on this PDS will be deactivated. 376 - To return, you would need to migrate back from the new PDS. 377 - </p> 378 - </div> 379 - 380 - <label class="checkbox-label"> 381 - <input type="checkbox" bind:checked={confirmFinal} /> 382 - <span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span> 383 - </label> 384 - 385 - <div class="button-row"> 386 - <button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button> 387 - <button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}> 388 - {loading ? 'Starting...' : 'Start Migration'} 389 - </button> 390 - </div> 391 - </div> 392 - 393 - {:else if flow.state.step === 'migrating'} 394 - <div class="step-content"> 395 - <h2>Migration in Progress</h2> 396 - <p>Please wait while your account is being transferred...</p> 397 - 398 - <div class="progress-section"> 399 - <div class="progress-item" class:completed={flow.state.progress.repoExported}> 400 - <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 401 - <span>Export repository</span> 402 - </div> 403 - <div class="progress-item" class:completed={flow.state.progress.repoImported}> 404 - <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 405 - <span>Import repository to new PDS</span> 406 - </div> 407 - <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 408 - <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 409 - <span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 410 - </div> 411 - <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 412 - <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 413 - <span>Migrate preferences</span> 414 - </div> 415 - </div> 416 - 417 - {#if flow.state.progress.blobsTotal > 0} 418 - <div class="progress-bar"> 419 - <div 420 - class="progress-fill" 421 - style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 422 - ></div> 423 - </div> 424 - {/if} 425 - 426 - <p class="status-text">{flow.state.progress.currentOperation}</p> 427 - </div> 428 - 429 - {:else if flow.state.step === 'plc-token'} 430 - <div class="step-content"> 431 - <h2>Verify Migration</h2> 432 - <p>A verification code has been sent to your email ({auth.session?.email}).</p> 433 - 434 - <div class="migration-info-box"> 435 - <p> 436 - This code confirms you have access to the account and authorizes updating your identity 437 - to point to the new PDS. 438 - </p> 439 - </div> 440 - 441 - <form onsubmit={submitPlcToken}> 442 - <div class="migration-field"> 443 - <label for="plc-token">Verification Code</label> 444 - <input 445 - id="plc-token" 446 - type="text" 447 - placeholder="Enter code from email" 448 - bind:value={flow.state.plcToken} 449 - oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 450 - disabled={loading} 451 - required 452 - /> 453 - </div> 454 - 455 - <div class="button-row"> 456 - <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 457 - Resend Code 458 - </button> 459 - <button type="submit" disabled={loading || !flow.state.plcToken}> 460 - {loading ? 'Verifying...' : 'Complete Migration'} 461 - </button> 462 - </div> 463 - </form> 464 - </div> 465 - 466 - {:else if flow.state.step === 'finalizing'} 467 - <div class="step-content"> 468 - <h2>Finalizing Migration</h2> 469 - <p>Please wait while we complete the migration...</p> 470 - 471 - <div class="progress-section"> 472 - <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 473 - <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 474 - <span>Sign identity update</span> 475 - </div> 476 - <div class="progress-item" class:completed={flow.state.progress.activated}> 477 - <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 478 - <span>Activate account on new PDS</span> 479 - </div> 480 - <div class="progress-item" class:completed={flow.state.progress.deactivated}> 481 - <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 482 - <span>Deactivate account here</span> 483 - </div> 484 - </div> 485 - 486 - <p class="status-text">{flow.state.progress.currentOperation}</p> 487 - </div> 488 - 489 - {:else if flow.state.step === 'success'} 490 - <div class="step-content success-content"> 491 - <div class="success-icon">✓</div> 492 - <h2>Migration Complete!</h2> 493 - <p>Your account has been successfully migrated to your new PDS.</p> 494 - 495 - <div class="success-details"> 496 - <div class="detail-row"> 497 - <span class="label">Your new handle:</span> 498 - <span class="value">@{flow.state.targetHandle}</span> 499 - </div> 500 - <div class="detail-row"> 501 - <span class="label">New PDS:</span> 502 - <span class="value">{flow.state.targetPdsUrl}</span> 503 - </div> 504 - <div class="detail-row"> 505 - <span class="label">DID:</span> 506 - <span class="value mono">{auth.session?.did}</span> 507 - </div> 508 - </div> 509 - 510 - {#if flow.state.progress.blobsFailed.length > 0} 511 - <div class="migration-warning-box"> 512 - <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 513 - These may be images or other media that are no longer available. 514 - </div> 515 - {/if} 516 - 517 - <div class="next-steps"> 518 - <h3>Next Steps</h3> 519 - <ol> 520 - <li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li> 521 - <li>Log in with your new credentials</li> 522 - <li>Your followers and following will continue to work</li> 523 - </ol> 524 - </div> 525 - 526 - <p class="redirect-text">Logging out in a moment...</p> 527 - </div> 528 - 529 - {:else if flow.state.step === 'error'} 530 - <div class="step-content"> 531 - <h2>Migration Error</h2> 532 - <p>An error occurred during migration.</p> 533 - 534 - <div class="migration-error-box"> 535 - {flow.state.error} 536 - </div> 537 - 538 - <div class="button-row"> 539 - <button class="ghost" onclick={onBack}>Start Over</button> 540 - </div> 541 - </div> 542 - {/if} 543 - </div> 544 - 545 - <style> 546 - </style>
···
+60
frontend/src/components/migration/PasskeySetupStep.svelte
···
··· 1 + <script lang="ts"> 2 + import { _ } from '../../lib/i18n' 3 + 4 + interface Props { 5 + passkeyName: string 6 + loading: boolean 7 + error: string | null 8 + onPasskeyNameChange: (name: string) => void 9 + onRegister: () => void 10 + } 11 + 12 + let { 13 + passkeyName, 14 + loading, 15 + error, 16 + onPasskeyNameChange, 17 + onRegister, 18 + }: Props = $props() 19 + </script> 20 + 21 + <div class="step-content"> 22 + <h2>{$_('migration.inbound.passkeySetup.title')}</h2> 23 + <p>{$_('migration.inbound.passkeySetup.desc')}</p> 24 + 25 + {#if error} 26 + <div class="message error"> 27 + {error} 28 + </div> 29 + {/if} 30 + 31 + <div class="field"> 32 + <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label> 33 + <input 34 + id="passkey-name" 35 + type="text" 36 + placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')} 37 + value={passkeyName} 38 + oninput={(e) => onPasskeyNameChange((e.target as HTMLInputElement).value)} 39 + disabled={loading} 40 + /> 41 + <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p> 42 + </div> 43 + 44 + <div class="passkey-section"> 45 + <p>{$_('migration.inbound.passkeySetup.instructions')}</p> 46 + <button class="primary" onclick={onRegister} disabled={loading}> 47 + {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')} 48 + </button> 49 + </div> 50 + </div> 51 + 52 + <style> 53 + .passkey-section { 54 + margin-top: 16px; 55 + } 56 + .passkey-section button { 57 + width: 100%; 58 + margin-top: 12px; 59 + } 60 + </style>
+36
frontend/src/components/migration/SuccessStep.svelte
···
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + import { _ } from '../../lib/i18n' 4 + 5 + interface Props { 6 + handle: string 7 + did: string 8 + description?: string 9 + extraContent?: Snippet 10 + } 11 + 12 + let { handle, did, description, extraContent }: Props = $props() 13 + </script> 14 + 15 + <div class="step-content success-content"> 16 + <div class="success-icon">✓</div> 17 + <h2>{$_('migration.inbound.success.title')}</h2> 18 + <p>{description || $_('migration.inbound.success.desc')}</p> 19 + 20 + <div class="success-details"> 21 + <div class="detail-row"> 22 + <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 23 + <span class="value">{handle}</span> 24 + </div> 25 + <div class="detail-row"> 26 + <span class="label">{$_('migration.inbound.success.did')}:</span> 27 + <span class="value mono">{did}</span> 28 + </div> 29 + </div> 30 + 31 + {#if extraContent} 32 + {@render extraContent()} 33 + {/if} 34 + 35 + <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 36 + </div>
+155 -47
frontend/src/lib/api.ts
··· 205 return data; 206 }, 207 208 async confirmSignup( 209 did: string, 210 verificationCode: string, ··· 226 return xrpc("com.atproto.server.createSession", { 227 method: "POST", 228 body: { identifier, password }, 229 }); 230 }, 231 ··· 379 signalNumber: string | null; 380 signalVerified: boolean; 381 }> { 382 - return xrpc("com.tranquil.account.getNotificationPrefs", { token }); 383 }, 384 385 async updateNotificationPrefs(token: string, prefs: { ··· 388 telegramUsername?: string; 389 signalNumber?: string; 390 }): Promise<{ success: boolean }> { 391 - return xrpc("com.tranquil.account.updateNotificationPrefs", { 392 method: "POST", 393 token, 394 body: prefs, ··· 401 identifier: string, 402 code: string, 403 ): Promise<{ success: boolean }> { 404 - return xrpc("com.tranquil.account.confirmChannelVerification", { 405 method: "POST", 406 token, 407 body: { channel, identifier, code }, ··· 418 body: string; 419 }>; 420 }> { 421 - return xrpc("com.tranquil.account.getNotificationHistory", { token }); 422 }, 423 424 async getServerStats(token: string): Promise<{ ··· 427 recordCount: number; 428 blobStorageBytes: number; 429 }> { 430 - return xrpc("com.tranquil.admin.getServerStats", { token }); 431 }, 432 433 async getServerConfig(): Promise<{ ··· 438 secondaryColorDark: string | null; 439 logoCid: string | null; 440 }> { 441 - return xrpc("com.tranquil.server.getConfig"); 442 }, 443 444 async updateServerConfig( ··· 452 logoCid?: string; 453 }, 454 ): Promise<{ success: boolean }> { 455 - return xrpc("com.tranquil.admin.updateServerConfig", { 456 method: "POST", 457 token, 458 body: config, ··· 495 currentPassword: string, 496 newPassword: string, 497 ): Promise<void> { 498 - await xrpc("com.tranquil.account.changePassword", { 499 method: "POST", 500 token, 501 body: { currentPassword, newPassword }, ··· 503 }, 504 505 async removePassword(token: string): Promise<{ success: boolean }> { 506 - return xrpc("com.tranquil.account.removePassword", { 507 method: "POST", 508 token, 509 }); 510 }, 511 512 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 513 - return xrpc("com.tranquil.account.getPasswordStatus", { token }); 514 }, 515 516 async getLegacyLoginPreference( 517 token: string, 518 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 519 - return xrpc("com.tranquil.account.getLegacyLoginPreference", { token }); 520 }, 521 522 async updateLegacyLoginPreference( 523 token: string, 524 allowLegacyLogin: boolean, 525 ): Promise<{ allowLegacyLogin: boolean }> { 526 - return xrpc("com.tranquil.account.updateLegacyLoginPreference", { 527 method: "POST", 528 token, 529 body: { allowLegacyLogin }, ··· 534 token: string, 535 preferredLocale: string, 536 ): Promise<{ preferredLocale: string }> { 537 - return xrpc("com.tranquil.account.updateLocale", { 538 method: "POST", 539 token, 540 body: { preferredLocale }, ··· 551 isCurrent: boolean; 552 }>; 553 }> { 554 - return xrpc("com.tranquil.account.listSessions", { token }); 555 }, 556 557 async revokeSession(token: string, sessionId: string): Promise<void> { 558 - await xrpc("com.tranquil.account.revokeSession", { 559 method: "POST", 560 token, 561 body: { sessionId }, ··· 563 }, 564 565 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 566 - return xrpc("com.tranquil.account.revokeAllSessions", { 567 method: "POST", 568 token, 569 }); ··· 868 lastSeenAt: string; 869 }>; 870 }> { 871 - return xrpc("com.tranquil.account.listTrustedDevices", { token }); 872 }, 873 874 async revokeTrustedDevice( 875 token: string, 876 deviceId: string, 877 ): Promise<{ success: boolean }> { 878 - return xrpc("com.tranquil.account.revokeTrustedDevice", { 879 method: "POST", 880 token, 881 body: { deviceId }, ··· 887 deviceId: string, 888 friendlyName: string, 889 ): Promise<{ success: boolean }> { 890 - return xrpc("com.tranquil.account.updateTrustedDevice", { 891 method: "POST", 892 token, 893 body: { deviceId, friendlyName }, ··· 899 lastReauthAt: string | null; 900 availableMethods: string[]; 901 }> { 902 - return xrpc("com.tranquil.account.getReauthStatus", { token }); 903 }, 904 905 async reauthPassword( 906 token: string, 907 password: string, 908 ): Promise<{ success: boolean; reauthAt: string }> { 909 - return xrpc("com.tranquil.account.reauthPassword", { 910 method: "POST", 911 token, 912 body: { password }, ··· 917 token: string, 918 code: string, 919 ): Promise<{ success: boolean; reauthAt: string }> { 920 - return xrpc("com.tranquil.account.reauthTotp", { 921 method: "POST", 922 token, 923 body: { code }, ··· 925 }, 926 927 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 928 - return xrpc("com.tranquil.account.reauthPasskeyStart", { 929 method: "POST", 930 token, 931 }); ··· 935 token: string, 936 credential: unknown, 937 ): Promise<{ success: boolean; reauthAt: string }> { 938 - return xrpc("com.tranquil.account.reauthPasskeyFinish", { 939 method: "POST", 940 token, 941 body: { credential }, ··· 982 setupToken: string; 983 setupExpiresAt: string; 984 }> { 985 - const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`; 986 const headers: Record<string, string> = { 987 "Content-Type": "application/json", 988 }; ··· 1009 setupToken: string, 1010 friendlyName?: string, 1011 ): Promise<{ options: unknown }> { 1012 - return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 1013 method: "POST", 1014 body: { did, setupToken, friendlyName }, 1015 }); ··· 1026 appPassword: string; 1027 appPasswordName: string; 1028 }> { 1029 - return xrpc("com.tranquil.account.completePasskeySetup", { 1030 method: "POST", 1031 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 1032 }); 1033 }, 1034 1035 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1036 - return xrpc("com.tranquil.account.requestPasskeyRecovery", { 1037 method: "POST", 1038 body: { email }, 1039 }); ··· 1044 recoveryToken: string, 1045 newPassword: string, 1046 ): Promise<{ success: boolean }> { 1047 - return xrpc("com.tranquil.account.recoverPasskeyAccount", { 1048 method: "POST", 1049 body: { did, recoveryToken, newPassword }, 1050 }); ··· 1077 purpose: string; 1078 channel: string; 1079 }> { 1080 - return xrpc("com.tranquil.account.verifyToken", { 1081 method: "POST", 1082 body: { token, identifier }, 1083 token: accessToken, ··· 1085 }, 1086 1087 async getDidDocument(token: string): Promise<DidDocument> { 1088 - return xrpc("com.tranquil.account.getDidDocument", { token }); 1089 }, 1090 1091 async updateDidDocument( ··· 1096 serviceEndpoint?: string; 1097 }, 1098 ): Promise<{ success: boolean }> { 1099 - return xrpc("com.tranquil.account.updateDidDocument", { 1100 method: "POST", 1101 token, 1102 body: params, ··· 1106 async deactivateAccount( 1107 token: string, 1108 deleteAfter?: string, 1109 - migratingTo?: string, 1110 ): Promise<void> { 1111 await xrpc("com.atproto.server.deactivateAccount", { 1112 method: "POST", 1113 token, 1114 - body: { deleteAfter, migratingTo }, 1115 }); 1116 }, 1117 1118 - async getMigrationStatus(token: string): Promise<{ 1119 - migratedToPds?: string; 1120 - migratedAt?: string; 1121 - forwardingEnabled: boolean; 1122 }> { 1123 - return xrpc("com.tranquil.account.getMigrationStatus", { token }); 1124 }, 1125 1126 - async updateMigrationForwarding( 1127 - token: string, 1128 - forwardingPds?: string, 1129 - ): Promise<{ success: boolean }> { 1130 - return xrpc("com.tranquil.account.updateMigrationForwarding", { 1131 method: "POST", 1132 token, 1133 - body: { forwardingPds }, 1134 }); 1135 }, 1136 1137 - async clearMigrationForwarding(token: string): Promise<{ success: boolean }> { 1138 - return xrpc("com.tranquil.account.clearMigrationForwarding", { 1139 method: "POST", 1140 token, 1141 }); 1142 }, 1143 };
··· 205 return data; 206 }, 207 208 + async createAccountWithServiceAuth( 209 + serviceAuthToken: string, 210 + params: { 211 + did: string; 212 + handle: string; 213 + email: string; 214 + password: string; 215 + inviteCode?: string; 216 + }, 217 + ): Promise<Session> { 218 + const url = `${API_BASE}/com.atproto.server.createAccount`; 219 + const response = await fetch(url, { 220 + method: "POST", 221 + headers: { 222 + "Content-Type": "application/json", 223 + "Authorization": `Bearer ${serviceAuthToken}`, 224 + }, 225 + body: JSON.stringify({ 226 + did: params.did, 227 + handle: params.handle, 228 + email: params.email, 229 + password: params.password, 230 + inviteCode: params.inviteCode, 231 + }), 232 + }); 233 + const data = await response.json(); 234 + if (!response.ok) { 235 + throw new ApiError(response.status, data.error, data.message); 236 + } 237 + return data; 238 + }, 239 + 240 async confirmSignup( 241 did: string, 242 verificationCode: string, ··· 258 return xrpc("com.atproto.server.createSession", { 259 method: "POST", 260 body: { identifier, password }, 261 + }); 262 + }, 263 + 264 + async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 265 + return xrpc("_checkEmailVerified", { 266 + method: "POST", 267 + body: { identifier }, 268 }); 269 }, 270 ··· 418 signalNumber: string | null; 419 signalVerified: boolean; 420 }> { 421 + return xrpc("_account.getNotificationPrefs", { token }); 422 }, 423 424 async updateNotificationPrefs(token: string, prefs: { ··· 427 telegramUsername?: string; 428 signalNumber?: string; 429 }): Promise<{ success: boolean }> { 430 + return xrpc("_account.updateNotificationPrefs", { 431 method: "POST", 432 token, 433 body: prefs, ··· 440 identifier: string, 441 code: string, 442 ): Promise<{ success: boolean }> { 443 + return xrpc("_account.confirmChannelVerification", { 444 method: "POST", 445 token, 446 body: { channel, identifier, code }, ··· 457 body: string; 458 }>; 459 }> { 460 + return xrpc("_account.getNotificationHistory", { token }); 461 }, 462 463 async getServerStats(token: string): Promise<{ ··· 466 recordCount: number; 467 blobStorageBytes: number; 468 }> { 469 + return xrpc("_admin.getServerStats", { token }); 470 }, 471 472 async getServerConfig(): Promise<{ ··· 477 secondaryColorDark: string | null; 478 logoCid: string | null; 479 }> { 480 + return xrpc("_server.getConfig"); 481 }, 482 483 async updateServerConfig( ··· 491 logoCid?: string; 492 }, 493 ): Promise<{ success: boolean }> { 494 + return xrpc("_admin.updateServerConfig", { 495 method: "POST", 496 token, 497 body: config, ··· 534 currentPassword: string, 535 newPassword: string, 536 ): Promise<void> { 537 + await xrpc("_account.changePassword", { 538 method: "POST", 539 token, 540 body: { currentPassword, newPassword }, ··· 542 }, 543 544 async removePassword(token: string): Promise<{ success: boolean }> { 545 + return xrpc("_account.removePassword", { 546 method: "POST", 547 token, 548 }); 549 }, 550 551 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 552 + return xrpc("_account.getPasswordStatus", { token }); 553 }, 554 555 async getLegacyLoginPreference( 556 token: string, 557 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 558 + return xrpc("_account.getLegacyLoginPreference", { token }); 559 }, 560 561 async updateLegacyLoginPreference( 562 token: string, 563 allowLegacyLogin: boolean, 564 ): Promise<{ allowLegacyLogin: boolean }> { 565 + return xrpc("_account.updateLegacyLoginPreference", { 566 method: "POST", 567 token, 568 body: { allowLegacyLogin }, ··· 573 token: string, 574 preferredLocale: string, 575 ): Promise<{ preferredLocale: string }> { 576 + return xrpc("_account.updateLocale", { 577 method: "POST", 578 token, 579 body: { preferredLocale }, ··· 590 isCurrent: boolean; 591 }>; 592 }> { 593 + return xrpc("_account.listSessions", { token }); 594 }, 595 596 async revokeSession(token: string, sessionId: string): Promise<void> { 597 + await xrpc("_account.revokeSession", { 598 method: "POST", 599 token, 600 body: { sessionId }, ··· 602 }, 603 604 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 605 + return xrpc("_account.revokeAllSessions", { 606 method: "POST", 607 token, 608 }); ··· 907 lastSeenAt: string; 908 }>; 909 }> { 910 + return xrpc("_account.listTrustedDevices", { token }); 911 }, 912 913 async revokeTrustedDevice( 914 token: string, 915 deviceId: string, 916 ): Promise<{ success: boolean }> { 917 + return xrpc("_account.revokeTrustedDevice", { 918 method: "POST", 919 token, 920 body: { deviceId }, ··· 926 deviceId: string, 927 friendlyName: string, 928 ): Promise<{ success: boolean }> { 929 + return xrpc("_account.updateTrustedDevice", { 930 method: "POST", 931 token, 932 body: { deviceId, friendlyName }, ··· 938 lastReauthAt: string | null; 939 availableMethods: string[]; 940 }> { 941 + return xrpc("_account.getReauthStatus", { token }); 942 }, 943 944 async reauthPassword( 945 token: string, 946 password: string, 947 ): Promise<{ success: boolean; reauthAt: string }> { 948 + return xrpc("_account.reauthPassword", { 949 method: "POST", 950 token, 951 body: { password }, ··· 956 token: string, 957 code: string, 958 ): Promise<{ success: boolean; reauthAt: string }> { 959 + return xrpc("_account.reauthTotp", { 960 method: "POST", 961 token, 962 body: { code }, ··· 964 }, 965 966 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 967 + return xrpc("_account.reauthPasskeyStart", { 968 method: "POST", 969 token, 970 }); ··· 974 token: string, 975 credential: unknown, 976 ): Promise<{ success: boolean; reauthAt: string }> { 977 + return xrpc("_account.reauthPasskeyFinish", { 978 method: "POST", 979 token, 980 body: { credential }, ··· 1021 setupToken: string; 1022 setupExpiresAt: string; 1023 }> { 1024 + const url = `${API_BASE}/_account.createPasskeyAccount`; 1025 const headers: Record<string, string> = { 1026 "Content-Type": "application/json", 1027 }; ··· 1048 setupToken: string, 1049 friendlyName?: string, 1050 ): Promise<{ options: unknown }> { 1051 + return xrpc("_account.startPasskeyRegistrationForSetup", { 1052 method: "POST", 1053 body: { did, setupToken, friendlyName }, 1054 }); ··· 1065 appPassword: string; 1066 appPasswordName: string; 1067 }> { 1068 + return xrpc("_account.completePasskeySetup", { 1069 method: "POST", 1070 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 1071 }); 1072 }, 1073 1074 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1075 + return xrpc("_account.requestPasskeyRecovery", { 1076 method: "POST", 1077 body: { email }, 1078 }); ··· 1083 recoveryToken: string, 1084 newPassword: string, 1085 ): Promise<{ success: boolean }> { 1086 + return xrpc("_account.recoverPasskeyAccount", { 1087 method: "POST", 1088 body: { did, recoveryToken, newPassword }, 1089 }); ··· 1116 purpose: string; 1117 channel: string; 1118 }> { 1119 + return xrpc("_account.verifyToken", { 1120 method: "POST", 1121 body: { token, identifier }, 1122 token: accessToken, ··· 1124 }, 1125 1126 async getDidDocument(token: string): Promise<DidDocument> { 1127 + return xrpc("_account.getDidDocument", { token }); 1128 }, 1129 1130 async updateDidDocument( ··· 1135 serviceEndpoint?: string; 1136 }, 1137 ): Promise<{ success: boolean }> { 1138 + return xrpc("_account.updateDidDocument", { 1139 method: "POST", 1140 token, 1141 body: params, ··· 1145 async deactivateAccount( 1146 token: string, 1147 deleteAfter?: string, 1148 ): Promise<void> { 1149 await xrpc("com.atproto.server.deactivateAccount", { 1150 method: "POST", 1151 token, 1152 + body: { deleteAfter }, 1153 + }); 1154 + }, 1155 + 1156 + async getRepo(token: string, did: string): Promise<ArrayBuffer> { 1157 + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ 1158 + encodeURIComponent(did) 1159 + }`; 1160 + const res = await fetch(url, { 1161 + headers: { Authorization: `Bearer ${token}` }, 1162 + }); 1163 + if (!res.ok) { 1164 + const err = await res.json().catch(() => ({ 1165 + error: "Unknown", 1166 + message: res.statusText, 1167 + })); 1168 + throw new ApiError(res.status, err.error, err.message); 1169 + } 1170 + return res.arrayBuffer(); 1171 + }, 1172 + 1173 + async listBackups(token: string): Promise<{ 1174 + backups: Array<{ 1175 + id: string; 1176 + repoRev: string; 1177 + repoRootCid: string; 1178 + blockCount: number; 1179 + sizeBytes: number; 1180 + createdAt: string; 1181 + }>; 1182 + backupEnabled: boolean; 1183 + }> { 1184 + return xrpc("_backup.listBackups", { token }); 1185 + }, 1186 + 1187 + async getBackup(token: string, id: string): Promise<Blob> { 1188 + const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`; 1189 + const res = await fetch(url, { 1190 + headers: { Authorization: `Bearer ${token}` }, 1191 }); 1192 + if (!res.ok) { 1193 + const err = await res.json().catch(() => ({ 1194 + error: "Unknown", 1195 + message: res.statusText, 1196 + })); 1197 + throw new ApiError(res.status, err.error, err.message); 1198 + } 1199 + return res.blob(); 1200 }, 1201 1202 + async createBackup(token: string): Promise<{ 1203 + id: string; 1204 + repoRev: string; 1205 + sizeBytes: number; 1206 + blockCount: number; 1207 }> { 1208 + return xrpc("_backup.createBackup", { 1209 + method: "POST", 1210 + token, 1211 + }); 1212 }, 1213 1214 + async deleteBackup(token: string, id: string): Promise<void> { 1215 + await xrpc("_backup.deleteBackup", { 1216 method: "POST", 1217 token, 1218 + params: { id }, 1219 }); 1220 }, 1221 1222 + async setBackupEnabled( 1223 + token: string, 1224 + enabled: boolean, 1225 + ): Promise<{ enabled: boolean }> { 1226 + return xrpc("_backup.setEnabled", { 1227 method: "POST", 1228 token, 1229 + body: { enabled }, 1230 }); 1231 + }, 1232 + 1233 + async importRepo(token: string, car: Uint8Array): Promise<void> { 1234 + const url = `${API_BASE}/com.atproto.repo.importRepo`; 1235 + const res = await fetch(url, { 1236 + method: "POST", 1237 + headers: { 1238 + Authorization: `Bearer ${token}`, 1239 + "Content-Type": "application/vnd.ipld.car", 1240 + }, 1241 + body: car, 1242 + }); 1243 + if (!res.ok) { 1244 + const err = await res.json().catch(() => ({ 1245 + error: "Unknown", 1246 + message: res.statusText, 1247 + })); 1248 + throw new ApiError(res.status, err.error, err.message); 1249 + } 1250 }, 1251 };
+16 -42
frontend/src/lib/migration/atproto-client.ts
··· 372 ); 373 } 374 375 - async deactivateAccount(migratingTo?: string): Promise<void> { 376 apiLog( 377 "POST", 378 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, 379 - { 380 - migratingTo, 381 - }, 382 ); 383 const start = Date.now(); 384 try { 385 - const body: { migratingTo?: string } = {}; 386 - if (migratingTo) { 387 - body.migratingTo = migratingTo; 388 - } 389 await this.xrpc("com.atproto.server.deactivateAccount", { 390 httpMethod: "POST", 391 - body, 392 }); 393 apiLog( 394 "POST", ··· 396 { 397 durationMs: Date.now() - start, 398 success: true, 399 - migratingTo, 400 }, 401 ); 402 } catch (e) { ··· 409 error: err.message, 410 errorCode: err.error, 411 status: err.status, 412 - migratingTo, 413 }, 414 ); 415 throw e; ··· 420 return this.xrpc("com.atproto.server.checkAccountStatus"); 421 } 422 423 - async getMigrationStatus(): Promise<{ 424 - did: string; 425 - didType: string; 426 - migrated: boolean; 427 - migratedToPds?: string; 428 - migratedAt?: string; 429 - }> { 430 - return this.xrpc("com.tranquil.account.getMigrationStatus"); 431 - } 432 - 433 - async updateMigrationForwarding(pdsUrl: string): Promise<{ 434 - success: boolean; 435 - migratedToPds: string; 436 - migratedAt: string; 437 - }> { 438 - return this.xrpc("com.tranquil.account.updateMigrationForwarding", { 439 - httpMethod: "POST", 440 - body: { pdsUrl }, 441 - }); 442 - } 443 - 444 - async clearMigrationForwarding(): Promise<{ success: boolean }> { 445 - return this.xrpc("com.tranquil.account.clearMigrationForwarding", { 446 - httpMethod: "POST", 447 - }); 448 - } 449 - 450 async resolveHandle(handle: string): Promise<{ did: string }> { 451 return this.xrpc("com.atproto.identity.resolveHandle", { 452 params: { handle }, ··· 468 return session; 469 } 470 471 async verifyToken( 472 token: string, 473 identifier: string, 474 ): Promise< 475 { success: boolean; did: string; purpose: string; channel: string } 476 > { 477 - return this.xrpc("com.tranquil.account.verifyToken", { 478 httpMethod: "POST", 479 body: { token, identifier }, 480 }); ··· 498 } 499 500 const res = await fetch( 501 - `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`, 502 { 503 method: "POST", 504 headers, ··· 530 setupToken: string, 531 friendlyName?: string, 532 ): Promise<StartPasskeyRegistrationResponse> { 533 - return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 534 httpMethod: "POST", 535 body: { did, setupToken, friendlyName }, 536 }); ··· 542 passkeyCredential: unknown, 543 passkeyFriendlyName?: string, 544 ): Promise<CompletePasskeySetupResponse> { 545 - return this.xrpc("com.tranquil.account.completePasskeySetup", { 546 httpMethod: "POST", 547 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 548 });
··· 372 ); 373 } 374 375 + async deactivateAccount(): Promise<void> { 376 apiLog( 377 "POST", 378 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, 379 ); 380 const start = Date.now(); 381 try { 382 await this.xrpc("com.atproto.server.deactivateAccount", { 383 httpMethod: "POST", 384 }); 385 apiLog( 386 "POST", ··· 388 { 389 durationMs: Date.now() - start, 390 success: true, 391 }, 392 ); 393 } catch (e) { ··· 400 error: err.message, 401 errorCode: err.error, 402 status: err.status, 403 }, 404 ); 405 throw e; ··· 410 return this.xrpc("com.atproto.server.checkAccountStatus"); 411 } 412 413 async resolveHandle(handle: string): Promise<{ did: string }> { 414 return this.xrpc("com.atproto.identity.resolveHandle", { 415 params: { handle }, ··· 431 return session; 432 } 433 434 + async checkEmailVerified(identifier: string): Promise<boolean> { 435 + const result = await this.xrpc<{ verified: boolean }>( 436 + "_checkEmailVerified", 437 + { 438 + httpMethod: "POST", 439 + body: { identifier }, 440 + }, 441 + ); 442 + return result.verified; 443 + } 444 + 445 async verifyToken( 446 token: string, 447 identifier: string, 448 ): Promise< 449 { success: boolean; did: string; purpose: string; channel: string } 450 > { 451 + return this.xrpc("_account.verifyToken", { 452 httpMethod: "POST", 453 body: { token, identifier }, 454 }); ··· 472 } 473 474 const res = await fetch( 475 + `${this.baseUrl}/xrpc/_account.createPasskeyAccount`, 476 { 477 method: "POST", 478 headers, ··· 504 setupToken: string, 505 friendlyName?: string, 506 ): Promise<StartPasskeyRegistrationResponse> { 507 + return this.xrpc("_account.startPasskeyRegistrationForSetup", { 508 httpMethod: "POST", 509 body: { did, setupToken, friendlyName }, 510 }); ··· 516 passkeyCredential: unknown, 517 passkeyFriendlyName?: string, 518 ): Promise<CompletePasskeySetupResponse> { 519 + return this.xrpc("_account.completePasskeySetup", { 520 httpMethod: "POST", 521 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 522 });
+156
frontend/src/lib/migration/blob-migration.ts
···
··· 1 + import type { AtprotoClient } from "./atproto-client"; 2 + import type { MigrationProgress } from "./types"; 3 + 4 + export interface BlobMigrationResult { 5 + migrated: number; 6 + failed: string[]; 7 + total: number; 8 + sourceUnreachable: boolean; 9 + } 10 + 11 + export async function migrateBlobs( 12 + localClient: AtprotoClient, 13 + sourceClient: AtprotoClient | null, 14 + userDid: string, 15 + onProgress: (update: Partial<MigrationProgress>) => void, 16 + ): Promise<BlobMigrationResult> { 17 + const missingBlobs: string[] = []; 18 + let cursor: string | undefined; 19 + 20 + console.log("[blob-migration] Starting blob migration for", userDid); 21 + console.log( 22 + "[blob-migration] Source client:", 23 + sourceClient ? "available" : "NOT AVAILABLE", 24 + ); 25 + 26 + onProgress({ currentOperation: "Checking for missing blobs..." }); 27 + 28 + do { 29 + const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 30 + cursor, 31 + 100, 32 + ); 33 + console.log( 34 + "[blob-migration] listMissingBlobs returned", 35 + blobs.length, 36 + "blobs, cursor:", 37 + nextCursor, 38 + ); 39 + for (const blob of blobs) { 40 + missingBlobs.push(blob.cid); 41 + } 42 + cursor = nextCursor; 43 + } while (cursor); 44 + 45 + console.log("[blob-migration] Total missing blobs:", missingBlobs.length); 46 + onProgress({ blobsTotal: missingBlobs.length }); 47 + 48 + if (missingBlobs.length === 0) { 49 + console.log("[blob-migration] No blobs to migrate"); 50 + onProgress({ currentOperation: "No blobs to migrate" }); 51 + return { migrated: 0, failed: [], total: 0, sourceUnreachable: false }; 52 + } 53 + 54 + if (!sourceClient) { 55 + console.warn( 56 + "[blob-migration] No source client available, cannot fetch blobs", 57 + ); 58 + onProgress({ 59 + currentOperation: 60 + `${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`, 61 + }); 62 + return { 63 + migrated: 0, 64 + failed: missingBlobs, 65 + total: missingBlobs.length, 66 + sourceUnreachable: true, 67 + }; 68 + } 69 + 70 + onProgress({ currentOperation: `Migrating ${missingBlobs.length} blobs...` }); 71 + 72 + let migrated = 0; 73 + const failed: string[] = []; 74 + let sourceUnreachable = false; 75 + 76 + for (const cid of missingBlobs) { 77 + if (sourceUnreachable) { 78 + failed.push(cid); 79 + continue; 80 + } 81 + 82 + try { 83 + onProgress({ 84 + currentOperation: `Migrating blob ${ 85 + migrated + 1 86 + }/${missingBlobs.length}...`, 87 + }); 88 + 89 + console.log("[blob-migration] Fetching blob", cid, "from source"); 90 + const blobData = await sourceClient.getBlob(userDid, cid); 91 + console.log( 92 + "[blob-migration] Got blob", 93 + cid, 94 + "size:", 95 + blobData.byteLength, 96 + ); 97 + await localClient.uploadBlob(blobData, "application/octet-stream"); 98 + console.log("[blob-migration] Uploaded blob", cid); 99 + migrated++; 100 + onProgress({ blobsMigrated: migrated }); 101 + } catch (e) { 102 + const errorMessage = (e as Error).message || String(e); 103 + console.error( 104 + "[blob-migration] Failed to migrate blob", 105 + cid, 106 + ":", 107 + errorMessage, 108 + ); 109 + 110 + const isNetworkError = 111 + errorMessage.includes("fetch") || 112 + errorMessage.includes("network") || 113 + errorMessage.includes("CORS") || 114 + errorMessage.includes("Failed to fetch") || 115 + errorMessage.includes("NetworkError") || 116 + errorMessage.includes("blocked by CORS"); 117 + 118 + if (isNetworkError) { 119 + sourceUnreachable = true; 120 + console.warn( 121 + "[blob-migration] Source appears unreachable (likely CORS or network issue), skipping remaining blobs", 122 + ); 123 + const remaining = missingBlobs.length - migrated - 1; 124 + if (migrated > 0) { 125 + onProgress({ 126 + currentOperation: 127 + `Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`, 128 + }); 129 + } else { 130 + onProgress({ 131 + currentOperation: 132 + `Cannot reach source PDS (browser security restriction). This commonly happens when the old server has shut down or doesn't allow cross-origin requests. Your posts will work, but ${missingBlobs.length} media files couldn't be recovered.`, 133 + }); 134 + } 135 + } 136 + failed.push(cid); 137 + } 138 + } 139 + 140 + if (migrated === missingBlobs.length) { 141 + onProgress({ 142 + currentOperation: `All ${migrated} blobs migrated successfully`, 143 + }); 144 + } else if (migrated > 0) { 145 + onProgress({ 146 + currentOperation: 147 + `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`, 148 + }); 149 + } else { 150 + onProgress({ 151 + currentOperation: `Could not migrate blobs (${failed.length} missing)`, 152 + }); 153 + } 154 + 155 + return { migrated, failed, total: missingBlobs.length, sourceUnreachable }; 156 + }
+17 -318
frontend/src/lib/migration/flow.svelte.ts
··· 2 InboundMigrationState, 3 InboundStep, 4 MigrationProgress, 5 - OutboundMigrationState, 6 - OutboundStep, 7 PasskeyAccountSetup, 8 ServerDescription, 9 StoredMigrationState, ··· 30 updateProgress, 31 updateStep, 32 } from "./storage"; 33 34 function migrationLog(stage: string, data?: Record<string, unknown>) { 35 const timestamp = new Date().toISOString(); ··· 85 let sourceClient: AtprotoClient | null = null; 86 let localClient: AtprotoClient | null = null; 87 let localServerInfo: ServerDescription | null = null; 88 89 function setStep(step: InboundStep) { 90 state.step = step; 91 state.error = null; 92 - saveMigrationState(state); 93 - updateStep(step); 94 } 95 96 function setError(error: string) { ··· 458 async function migrateBlobs(): Promise<void> { 459 if (!sourceClient || !localClient) return; 460 461 - let cursor: string | undefined; 462 - let migrated = 0; 463 - 464 - do { 465 - const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 466 - cursor, 467 - 100, 468 - ); 469 - 470 - for (const blob of blobs) { 471 - try { 472 - setProgress({ 473 - currentOperation: `Migrating blob ${ 474 - migrated + 1 475 - }/${state.progress.blobsTotal}...`, 476 - }); 477 - 478 - const blobData = await sourceClient.getBlob( 479 - state.sourceDid, 480 - blob.cid, 481 - ); 482 - await localClient.uploadBlob(blobData, "application/octet-stream"); 483 - migrated++; 484 - setProgress({ blobsMigrated: migrated }); 485 - } catch { 486 - state.progress.blobsFailed.push(blob.cid); 487 - } 488 - } 489 490 - cursor = nextCursor; 491 - } while (cursor); 492 } 493 494 async function migratePreferences(): Promise<void> { ··· 578 579 checkingEmailVerification = true; 580 try { 581 await localClient.loginDeactivated( 582 state.targetEmail, 583 state.targetPassword, ··· 978 }; 979 } 980 981 - export function createOutboundMigrationFlow() { 982 - let state = $state<OutboundMigrationState>({ 983 - direction: "outbound", 984 - step: "welcome", 985 - localDid: "", 986 - localHandle: "", 987 - targetPdsUrl: "", 988 - targetPdsDid: "", 989 - targetHandle: "", 990 - targetEmail: "", 991 - targetPassword: "", 992 - inviteCode: "", 993 - targetAccessToken: null, 994 - targetRefreshToken: null, 995 - serviceAuthToken: null, 996 - plcToken: "", 997 - progress: createInitialProgress(), 998 - error: null, 999 - targetServerInfo: null, 1000 - }); 1001 - 1002 - let localClient: AtprotoClient | null = null; 1003 - let targetClient: AtprotoClient | null = null; 1004 - 1005 - function setStep(step: OutboundStep) { 1006 - state.step = step; 1007 - state.error = null; 1008 - saveMigrationState(state); 1009 - updateStep(step); 1010 - } 1011 - 1012 - function setError(error: string) { 1013 - state.error = error; 1014 - saveMigrationState(state); 1015 - } 1016 - 1017 - function setProgress(updates: Partial<MigrationProgress>) { 1018 - state.progress = { ...state.progress, ...updates }; 1019 - updateProgress(updates); 1020 - } 1021 - 1022 - async function validateTargetPds(url: string): Promise<ServerDescription> { 1023 - const normalizedUrl = url.replace(/\/$/, ""); 1024 - targetClient = new AtprotoClient(normalizedUrl); 1025 - 1026 - try { 1027 - const serverInfo = await targetClient.describeServer(); 1028 - state.targetPdsUrl = normalizedUrl; 1029 - state.targetPdsDid = serverInfo.did; 1030 - state.targetServerInfo = serverInfo; 1031 - return serverInfo; 1032 - } catch (e) { 1033 - throw new Error(`Could not connect to PDS: ${(e as Error).message}`); 1034 - } 1035 - } 1036 - 1037 - function initLocalClient( 1038 - accessToken: string, 1039 - did?: string, 1040 - handle?: string, 1041 - ): void { 1042 - localClient = createLocalClient(); 1043 - localClient.setAccessToken(accessToken); 1044 - if (did) { 1045 - state.localDid = did; 1046 - } 1047 - if (handle) { 1048 - state.localHandle = handle; 1049 - } 1050 - } 1051 - 1052 - async function startMigration(currentDid: string): Promise<void> { 1053 - if (!localClient || !targetClient) { 1054 - throw new Error("Not connected to PDSes"); 1055 - } 1056 - 1057 - setStep("migrating"); 1058 - setProgress({ currentOperation: "Getting service auth token..." }); 1059 - 1060 - try { 1061 - const { token } = await localClient.getServiceAuth( 1062 - state.targetPdsDid, 1063 - "com.atproto.server.createAccount", 1064 - ); 1065 - state.serviceAuthToken = token; 1066 - 1067 - setProgress({ currentOperation: "Creating account on new PDS..." }); 1068 - 1069 - const accountParams = { 1070 - did: currentDid, 1071 - handle: state.targetHandle, 1072 - email: state.targetEmail, 1073 - password: state.targetPassword, 1074 - inviteCode: state.inviteCode || undefined, 1075 - }; 1076 - 1077 - const session = await targetClient.createAccount(accountParams, token); 1078 - state.targetAccessToken = session.accessJwt; 1079 - state.targetRefreshToken = session.refreshJwt; 1080 - targetClient.setAccessToken(session.accessJwt); 1081 - 1082 - setProgress({ currentOperation: "Exporting repository..." }); 1083 - 1084 - const car = await localClient.getRepo(currentDid); 1085 - setProgress({ 1086 - repoExported: true, 1087 - currentOperation: "Importing repository...", 1088 - }); 1089 - 1090 - await targetClient.importRepo(car); 1091 - setProgress({ 1092 - repoImported: true, 1093 - currentOperation: "Counting blobs...", 1094 - }); 1095 - 1096 - const accountStatus = await targetClient.checkAccountStatus(); 1097 - setProgress({ 1098 - blobsTotal: accountStatus.expectedBlobs, 1099 - currentOperation: "Migrating blobs...", 1100 - }); 1101 - 1102 - await migrateBlobs(currentDid); 1103 - 1104 - setProgress({ currentOperation: "Migrating preferences..." }); 1105 - await migratePreferences(); 1106 - 1107 - setProgress({ currentOperation: "Requesting PLC operation token..." }); 1108 - await localClient.requestPlcOperationSignature(); 1109 - 1110 - setStep("plc-token"); 1111 - } catch (e) { 1112 - const err = e as Error & { error?: string; status?: number }; 1113 - const message = err.message || err.error || 1114 - `Unknown error (status ${err.status || "unknown"})`; 1115 - setError(message); 1116 - setStep("error"); 1117 - } 1118 - } 1119 - 1120 - async function migrateBlobs(did: string): Promise<void> { 1121 - if (!localClient || !targetClient) return; 1122 - 1123 - let cursor: string | undefined; 1124 - let migrated = 0; 1125 - 1126 - do { 1127 - const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs( 1128 - cursor, 1129 - 100, 1130 - ); 1131 - 1132 - for (const blob of blobs) { 1133 - try { 1134 - setProgress({ 1135 - currentOperation: `Migrating blob ${ 1136 - migrated + 1 1137 - }/${state.progress.blobsTotal}...`, 1138 - }); 1139 - 1140 - const blobData = await localClient.getBlob(did, blob.cid); 1141 - await targetClient.uploadBlob(blobData, "application/octet-stream"); 1142 - migrated++; 1143 - setProgress({ blobsMigrated: migrated }); 1144 - } catch { 1145 - state.progress.blobsFailed.push(blob.cid); 1146 - } 1147 - } 1148 - 1149 - cursor = nextCursor; 1150 - } while (cursor); 1151 - } 1152 - 1153 - async function migratePreferences(): Promise<void> { 1154 - if (!localClient || !targetClient) return; 1155 - 1156 - try { 1157 - const prefs = await localClient.getPreferences(); 1158 - await targetClient.putPreferences(prefs); 1159 - setProgress({ prefsMigrated: true }); 1160 - } catch { /* optional, best-effort */ } 1161 - } 1162 - 1163 - async function submitPlcToken(token: string): Promise<void> { 1164 - if (!localClient || !targetClient) { 1165 - throw new Error("Not connected to PDSes"); 1166 - } 1167 - 1168 - state.plcToken = token; 1169 - setStep("finalizing"); 1170 - setProgress({ currentOperation: "Signing PLC operation..." }); 1171 - 1172 - try { 1173 - const credentials = await targetClient.getRecommendedDidCredentials(); 1174 - 1175 - const { operation } = await localClient.signPlcOperation({ 1176 - token, 1177 - ...credentials, 1178 - }); 1179 - 1180 - setProgress({ 1181 - plcSigned: true, 1182 - currentOperation: "Submitting PLC operation...", 1183 - }); 1184 - 1185 - await targetClient.submitPlcOperation(operation); 1186 - 1187 - setProgress({ currentOperation: "Activating account on new PDS..." }); 1188 - await targetClient.activateAccount(); 1189 - setProgress({ activated: true }); 1190 - 1191 - setProgress({ currentOperation: "Deactivating old account..." }); 1192 - try { 1193 - await localClient.deactivateAccount(state.targetPdsUrl); 1194 - setProgress({ deactivated: true }); 1195 - } catch { /* optional, best-effort */ } 1196 - 1197 - setStep("success"); 1198 - clearMigrationState(); 1199 - } catch (e) { 1200 - const err = e as Error & { error?: string; status?: number }; 1201 - const message = err.message || err.error || 1202 - `Unknown error (status ${err.status || "unknown"})`; 1203 - setError(message); 1204 - setStep("plc-token"); 1205 - } 1206 - } 1207 - 1208 - async function resendPlcToken(): Promise<void> { 1209 - if (!localClient) { 1210 - throw new Error("Not connected to local PDS"); 1211 - } 1212 - await localClient.requestPlcOperationSignature(); 1213 - } 1214 - 1215 - function reset(): void { 1216 - state = { 1217 - direction: "outbound", 1218 - step: "welcome", 1219 - localDid: "", 1220 - localHandle: "", 1221 - targetPdsUrl: "", 1222 - targetPdsDid: "", 1223 - targetHandle: "", 1224 - targetEmail: "", 1225 - targetPassword: "", 1226 - inviteCode: "", 1227 - targetAccessToken: null, 1228 - targetRefreshToken: null, 1229 - serviceAuthToken: null, 1230 - plcToken: "", 1231 - progress: createInitialProgress(), 1232 - error: null, 1233 - targetServerInfo: null, 1234 - }; 1235 - localClient = null; 1236 - targetClient = null; 1237 - clearMigrationState(); 1238 - } 1239 - 1240 - return { 1241 - get state() { 1242 - return state; 1243 - }, 1244 - setStep, 1245 - setError, 1246 - validateTargetPds, 1247 - initLocalClient, 1248 - startMigration, 1249 - submitPlcToken, 1250 - resendPlcToken, 1251 - reset, 1252 - 1253 - updateField<K extends keyof OutboundMigrationState>( 1254 - field: K, 1255 - value: OutboundMigrationState[K], 1256 - ) { 1257 - state[field] = value; 1258 - }, 1259 - }; 1260 - } 1261 - 1262 export type InboundMigrationFlow = ReturnType< 1263 typeof createInboundMigrationFlow 1264 >; 1265 - export type OutboundMigrationFlow = ReturnType< 1266 - typeof createOutboundMigrationFlow 1267 - >;
··· 2 InboundMigrationState, 3 InboundStep, 4 MigrationProgress, 5 PasskeyAccountSetup, 6 ServerDescription, 7 StoredMigrationState, ··· 28 updateProgress, 29 updateStep, 30 } from "./storage"; 31 + import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 32 33 function migrationLog(stage: string, data?: Record<string, unknown>) { 34 const timestamp = new Date().toISOString(); ··· 84 let sourceClient: AtprotoClient | null = null; 85 let localClient: AtprotoClient | null = null; 86 let localServerInfo: ServerDescription | null = null; 87 + let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = 88 + null; 89 90 function setStep(step: InboundStep) { 91 state.step = step; 92 state.error = null; 93 + if (step !== "success") { 94 + saveMigrationState(state); 95 + updateStep(step); 96 + } 97 } 98 99 function setError(error: string) { ··· 461 async function migrateBlobs(): Promise<void> { 462 if (!sourceClient || !localClient) return; 463 464 + const result = await migrateBlobsUtil( 465 + localClient, 466 + sourceClient, 467 + state.sourceDid, 468 + setProgress, 469 + ); 470 471 + state.progress.blobsFailed = result.failed; 472 } 473 474 async function migratePreferences(): Promise<void> { ··· 558 559 checkingEmailVerification = true; 560 try { 561 + const verified = await localClient.checkEmailVerified(state.targetEmail); 562 + if (!verified) return false; 563 + 564 await localClient.loginDeactivated( 565 state.targetEmail, 566 state.targetPassword, ··· 961 }; 962 } 963 964 export type InboundMigrationFlow = ReturnType< 965 typeof createInboundMigrationFlow 966 >;
+8 -2
frontend/src/lib/migration/index.ts
··· 1 export * from "./types"; 2 export * from "./atproto-client"; 3 export * from "./storage"; 4 export { 5 createInboundMigrationFlow, 6 - createOutboundMigrationFlow, 7 type InboundMigrationFlow, 8 - type OutboundMigrationFlow, 9 } from "./flow.svelte";
··· 1 export * from "./types"; 2 export * from "./atproto-client"; 3 export * from "./storage"; 4 + export * from "./blob-migration"; 5 export { 6 createInboundMigrationFlow, 7 type InboundMigrationFlow, 8 } from "./flow.svelte"; 9 + export { 10 + clearOfflineState, 11 + createOfflineInboundMigrationFlow, 12 + getOfflineResumeInfo, 13 + hasPendingOfflineMigration, 14 + } from "./offline-flow.svelte"; 15 + export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte";
+765
frontend/src/lib/migration/offline-flow.svelte.ts
···
··· 1 + import type { 2 + AuthMethod, 3 + MigrationProgress, 4 + OfflineInboundMigrationState, 5 + OfflineInboundStep, 6 + ServerDescription, 7 + } from "./types"; 8 + import { 9 + AtprotoClient, 10 + base64UrlEncode, 11 + createLocalClient, 12 + prepareWebAuthnCreationOptions, 13 + } from "./atproto-client"; 14 + import { api } from "../api"; 15 + import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops"; 16 + import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 17 + import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; 18 + 19 + const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 20 + const MAX_AGE_MS = 24 * 60 * 60 * 1000; 21 + 22 + interface StoredOfflineMigrationState { 23 + version: number; 24 + step: OfflineInboundStep; 25 + startedAt: string; 26 + userDid: string; 27 + carFileName: string; 28 + carSizeBytes: number; 29 + rotationKeyDidKey: string; 30 + targetHandle: string; 31 + targetEmail: string; 32 + authMethod: AuthMethod; 33 + passkeySetupToken?: string; 34 + oldPdsUrl?: string; 35 + plcUpdatedTemporarily?: boolean; 36 + progress: { 37 + accountCreated: boolean; 38 + repoImported: boolean; 39 + plcSigned: boolean; 40 + activated: boolean; 41 + }; 42 + lastError?: string; 43 + } 44 + 45 + function saveOfflineState(state: OfflineInboundMigrationState): void { 46 + const stored: StoredOfflineMigrationState = { 47 + version: 1, 48 + step: state.step, 49 + startedAt: new Date().toISOString(), 50 + userDid: state.userDid, 51 + carFileName: state.carFileName, 52 + carSizeBytes: state.carSizeBytes, 53 + rotationKeyDidKey: state.rotationKeyDidKey, 54 + targetHandle: state.targetHandle, 55 + targetEmail: state.targetEmail, 56 + authMethod: state.authMethod, 57 + passkeySetupToken: state.passkeySetupToken ?? undefined, 58 + oldPdsUrl: state.oldPdsUrl ?? undefined, 59 + plcUpdatedTemporarily: state.plcUpdatedTemporarily || undefined, 60 + progress: { 61 + accountCreated: state.progress.repoExported, 62 + repoImported: state.progress.repoImported, 63 + plcSigned: state.progress.plcSigned, 64 + activated: state.progress.activated, 65 + }, 66 + lastError: state.error ?? undefined, 67 + }; 68 + try { 69 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(stored)); 70 + } catch { /* ignore localStorage errors */ } 71 + } 72 + 73 + function loadOfflineState(): StoredOfflineMigrationState | null { 74 + try { 75 + const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 76 + if (!stored) return null; 77 + const state = JSON.parse(stored) as StoredOfflineMigrationState; 78 + if (state.version !== 1) { 79 + clearOfflineState(); 80 + return null; 81 + } 82 + const startedAt = new Date(state.startedAt).getTime(); 83 + if (Date.now() - startedAt > MAX_AGE_MS) { 84 + clearOfflineState(); 85 + return null; 86 + } 87 + return state; 88 + } catch { 89 + /* ignore parse errors */ 90 + clearOfflineState(); 91 + return null; 92 + } 93 + } 94 + 95 + function clearOfflineState(): void { 96 + try { 97 + localStorage.removeItem(OFFLINE_STORAGE_KEY); 98 + } catch { /* ignore localStorage errors */ } 99 + } 100 + 101 + export function hasPendingOfflineMigration(): boolean { 102 + return loadOfflineState() !== null; 103 + } 104 + 105 + export function getOfflineResumeInfo(): { 106 + step: OfflineInboundStep; 107 + userDid: string; 108 + targetHandle: string; 109 + } | null { 110 + const state = loadOfflineState(); 111 + if (!state) return null; 112 + return { 113 + step: state.step, 114 + userDid: state.userDid, 115 + targetHandle: state.targetHandle, 116 + }; 117 + } 118 + 119 + export { clearOfflineState }; 120 + 121 + function createInitialProgress(): MigrationProgress { 122 + return { 123 + repoExported: false, 124 + repoImported: false, 125 + blobsTotal: 0, 126 + blobsMigrated: 0, 127 + blobsFailed: [], 128 + prefsMigrated: false, 129 + plcSigned: false, 130 + activated: false, 131 + deactivated: false, 132 + currentOperation: "", 133 + }; 134 + } 135 + 136 + export type OfflineInboundMigrationFlow = ReturnType< 137 + typeof createOfflineInboundMigrationFlow 138 + >; 139 + 140 + export function createOfflineInboundMigrationFlow() { 141 + let state = $state<OfflineInboundMigrationState>({ 142 + direction: "offline-inbound", 143 + step: "welcome", 144 + userDid: "", 145 + carFile: null, 146 + carFileName: "", 147 + carSizeBytes: 0, 148 + carNeedsReupload: false, 149 + rotationKey: "", 150 + rotationKeyDidKey: "", 151 + oldPdsUrl: null, 152 + targetHandle: "", 153 + targetEmail: "", 154 + targetPassword: "", 155 + inviteCode: "", 156 + authMethod: "password", 157 + localAccessToken: null, 158 + localRefreshToken: null, 159 + passkeySetupToken: null, 160 + generatedAppPassword: null, 161 + generatedAppPasswordName: null, 162 + emailVerifyToken: "", 163 + progress: createInitialProgress(), 164 + error: null, 165 + plcUpdatedTemporarily: false, 166 + }); 167 + 168 + let localServerInfo: ServerDescription | null = null; 169 + let userRotationKeypair: KeypairInfo | null = null; 170 + let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null; 171 + 172 + function setStep(step: OfflineInboundStep) { 173 + state.step = step; 174 + state.error = null; 175 + if (step !== "success") { 176 + saveOfflineState(state); 177 + } 178 + } 179 + 180 + function setError(error: string | null) { 181 + state.error = error; 182 + saveOfflineState(state); 183 + } 184 + 185 + function setProgress(updates: Partial<MigrationProgress>) { 186 + state.progress = { ...state.progress, ...updates }; 187 + saveOfflineState(state); 188 + } 189 + 190 + async function loadLocalServerInfo(): Promise<ServerDescription> { 191 + if (!localServerInfo) { 192 + const client = createLocalClient(); 193 + localServerInfo = await client.describeServer(); 194 + } 195 + return localServerInfo; 196 + } 197 + 198 + async function checkHandleAvailability(handle: string): Promise<boolean> { 199 + const client = createLocalClient(); 200 + try { 201 + await client.resolveHandle(handle); 202 + return false; 203 + } catch { 204 + return true; 205 + } 206 + } 207 + 208 + async function validateRotationKey(): Promise<boolean> { 209 + if (!state.userDid || !state.rotationKey) { 210 + throw new Error("DID and rotation key are required"); 211 + } 212 + 213 + try { 214 + userRotationKeypair = await plcOps.getKeyPair(state.rotationKey.trim()); 215 + const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid); 216 + const currentRotationKeys = lastOperation.rotationKeys || []; 217 + 218 + if (!currentRotationKeys.includes(userRotationKeypair.didPublicKey)) { 219 + state.rotationKeyDidKey = ""; 220 + return false; 221 + } 222 + 223 + state.rotationKeyDidKey = userRotationKeypair.didPublicKey; 224 + 225 + const pdsService = lastOperation.services?.atproto_pds; 226 + if (pdsService?.endpoint) { 227 + state.oldPdsUrl = pdsService.endpoint; 228 + console.log( 229 + "[offline-migration] Captured old PDS URL:", 230 + state.oldPdsUrl, 231 + ); 232 + } else { 233 + console.warn( 234 + "[offline-migration] No PDS service endpoint found in PLC document", 235 + ); 236 + console.log( 237 + "[offline-migration] PLC services:", 238 + JSON.stringify(lastOperation.services), 239 + ); 240 + } 241 + 242 + saveOfflineState(state); 243 + return true; 244 + } catch (e) { 245 + throw new Error(`Failed to parse rotation key: ${(e as Error).message}`); 246 + } 247 + } 248 + 249 + async function prepareTempCredentials(): Promise<string> { 250 + if (!userRotationKeypair) { 251 + throw new Error("Rotation key not validated"); 252 + } 253 + 254 + setProgress({ currentOperation: "Preparing temporary credentials..." }); 255 + 256 + tempVerificationKeypair = await Secp256k1PrivateKeyExportable 257 + .createKeypair(); 258 + const tempVerificationPublicKey = await tempVerificationKeypair 259 + .exportPublicKey("did"); 260 + 261 + const { lastOperation, base } = await plcOps.getLastPlcOpFromPlc( 262 + state.userDid, 263 + ); 264 + const prevCid = base.cid; 265 + 266 + setProgress({ currentOperation: "Updating DID document temporarily..." }); 267 + 268 + const localPdsUrl = globalThis.location.origin; 269 + await plcOps.signAndPublishNewOp( 270 + state.userDid, 271 + userRotationKeypair.keypair, 272 + lastOperation.alsoKnownAs || [], 273 + [userRotationKeypair.didPublicKey], 274 + localPdsUrl, 275 + tempVerificationPublicKey, 276 + prevCid, 277 + ); 278 + 279 + state.plcUpdatedTemporarily = true; 280 + saveOfflineState(state); 281 + 282 + const serverInfo = await loadLocalServerInfo(); 283 + const serviceAuthToken = await plcOps.createServiceAuthToken( 284 + state.userDid, 285 + serverInfo.did, 286 + tempVerificationKeypair as unknown as PrivateKey, 287 + "com.atproto.server.createAccount", 288 + ); 289 + 290 + return serviceAuthToken; 291 + } 292 + 293 + async function createPasswordAccount( 294 + serviceAuthToken: string, 295 + ): Promise<void> { 296 + setProgress({ currentOperation: "Creating account on new PDS..." }); 297 + 298 + const serverInfo = await loadLocalServerInfo(); 299 + const fullHandle = state.targetHandle.includes(".") 300 + ? state.targetHandle 301 + : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 302 + 303 + const createResult = await api.createAccountWithServiceAuth( 304 + serviceAuthToken, 305 + { 306 + did: state.userDid, 307 + handle: fullHandle, 308 + email: state.targetEmail, 309 + password: state.targetPassword, 310 + inviteCode: state.inviteCode || undefined, 311 + }, 312 + ); 313 + 314 + state.targetHandle = fullHandle; 315 + state.localAccessToken = createResult.accessJwt; 316 + state.localRefreshToken = createResult.refreshJwt; 317 + setProgress({ repoExported: true }); 318 + } 319 + 320 + async function createPasskeyAccount(serviceAuthToken: string): Promise<void> { 321 + setProgress({ currentOperation: "Creating passkey account on new PDS..." }); 322 + 323 + const serverInfo = await loadLocalServerInfo(); 324 + const fullHandle = state.targetHandle.includes(".") 325 + ? state.targetHandle 326 + : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 327 + 328 + const createResult = await api.createPasskeyAccount({ 329 + did: state.userDid, 330 + handle: fullHandle, 331 + email: state.targetEmail, 332 + inviteCode: state.inviteCode || undefined, 333 + }, serviceAuthToken); 334 + 335 + state.targetHandle = fullHandle; 336 + state.passkeySetupToken = createResult.setupToken; 337 + setProgress({ repoExported: true }); 338 + saveOfflineState(state); 339 + } 340 + 341 + async function signFinalPlcOperation(): Promise<void> { 342 + if (!userRotationKeypair || !state.localAccessToken) { 343 + throw new Error("Prerequisites not met for PLC signing"); 344 + } 345 + 346 + setProgress({ currentOperation: "Finalizing DID document..." }); 347 + 348 + const { base } = await plcOps.getLastPlcOpFromPlc(state.userDid); 349 + const prevCid = base.cid; 350 + 351 + const credentials = await api.getRecommendedDidCredentials( 352 + state.localAccessToken, 353 + ); 354 + 355 + await plcOps.signPlcOperationWithCredentials( 356 + state.userDid, 357 + userRotationKeypair.keypair, 358 + { 359 + rotationKeys: credentials.rotationKeys, 360 + alsoKnownAs: credentials.alsoKnownAs, 361 + verificationMethods: credentials.verificationMethods, 362 + services: credentials.services, 363 + }, 364 + [userRotationKeypair.didPublicKey], 365 + prevCid, 366 + ); 367 + 368 + setProgress({ plcSigned: true }); 369 + } 370 + 371 + async function importRepository(): Promise<void> { 372 + if (!state.carFile || !state.localAccessToken) { 373 + throw new Error("CAR file and access token are required"); 374 + } 375 + 376 + setProgress({ currentOperation: "Importing repository..." }); 377 + await api.importRepo(state.localAccessToken, state.carFile); 378 + setProgress({ repoImported: true }); 379 + } 380 + 381 + async function migrateBlobs(): Promise<void> { 382 + if (!state.localAccessToken) { 383 + throw new Error("Access token required"); 384 + } 385 + 386 + const localClient = createLocalClient(); 387 + localClient.setAccessToken(state.localAccessToken); 388 + 389 + if (state.oldPdsUrl) { 390 + setProgress({ 391 + currentOperation: `Will fetch blobs from ${state.oldPdsUrl}`, 392 + }); 393 + } else { 394 + setProgress({ 395 + currentOperation: "No source PDS URL available for blob migration", 396 + }); 397 + } 398 + 399 + const sourceClient = state.oldPdsUrl 400 + ? new AtprotoClient(state.oldPdsUrl) 401 + : null; 402 + 403 + const result = await migrateBlobsUtil( 404 + localClient, 405 + sourceClient, 406 + state.userDid, 407 + setProgress, 408 + ); 409 + 410 + state.progress.blobsFailed = result.failed; 411 + state.progress.blobsTotal = result.total; 412 + state.progress.blobsMigrated = result.migrated; 413 + 414 + if (result.total === 0) { 415 + setProgress({ currentOperation: "No blobs to migrate" }); 416 + } else if (result.sourceUnreachable) { 417 + setProgress({ 418 + currentOperation: 419 + `Source PDS unreachable. ${result.failed.length} blobs could not be migrated.`, 420 + }); 421 + } else if (result.failed.length > 0) { 422 + setProgress({ 423 + currentOperation: 424 + `${result.migrated}/${result.total} blobs migrated. ${result.failed.length} failed.`, 425 + }); 426 + } else { 427 + setProgress({ 428 + currentOperation: `All ${result.migrated} blobs migrated successfully`, 429 + }); 430 + } 431 + } 432 + 433 + async function activateAccount(): Promise<void> { 434 + if (!state.localAccessToken) { 435 + throw new Error("Access token required"); 436 + } 437 + 438 + setProgress({ currentOperation: "Activating account..." }); 439 + await api.activateAccount(state.localAccessToken); 440 + setProgress({ activated: true }); 441 + } 442 + 443 + async function submitEmailVerifyToken(token: string): Promise<void> { 444 + state.emailVerifyToken = token; 445 + setError(null); 446 + 447 + try { 448 + await api.verifyMigrationEmail(token, state.targetEmail); 449 + 450 + if (state.authMethod === "passkey") { 451 + setStep("passkey-setup"); 452 + } else { 453 + const session = await api.createSession( 454 + state.targetEmail, 455 + state.targetPassword, 456 + ); 457 + state.localAccessToken = session.accessJwt; 458 + state.localRefreshToken = session.refreshJwt; 459 + saveOfflineState(state); 460 + 461 + setStep("plc-signing"); 462 + await signFinalPlcOperation(); 463 + 464 + setStep("finalizing"); 465 + await activateAccount(); 466 + 467 + cleanup(); 468 + setStep("success"); 469 + } 470 + } catch (e) { 471 + const err = e as Error & { error?: string }; 472 + setError(err.message || err.error || "Email verification failed"); 473 + } 474 + } 475 + 476 + async function resendEmailVerification(): Promise<void> { 477 + await api.resendMigrationVerification(state.targetEmail); 478 + } 479 + 480 + let checkingEmailVerification = false; 481 + 482 + async function checkEmailVerifiedAndProceed(): Promise<boolean> { 483 + if (checkingEmailVerification) return false; 484 + if (state.authMethod === "passkey") return false; 485 + 486 + checkingEmailVerification = true; 487 + try { 488 + const { verified } = await api.checkEmailVerified(state.targetEmail); 489 + if (!verified) return false; 490 + 491 + const session = await api.createSession( 492 + state.targetEmail, 493 + state.targetPassword, 494 + ); 495 + state.localAccessToken = session.accessJwt; 496 + state.localRefreshToken = session.refreshJwt; 497 + saveOfflineState(state); 498 + 499 + setStep("plc-signing"); 500 + await signFinalPlcOperation(); 501 + 502 + setStep("finalizing"); 503 + await activateAccount(); 504 + 505 + cleanup(); 506 + setStep("success"); 507 + return true; 508 + } catch { 509 + return false; 510 + } finally { 511 + checkingEmailVerification = false; 512 + } 513 + } 514 + 515 + async function startPasskeyRegistration(): Promise<{ options: unknown }> { 516 + if (!state.passkeySetupToken) { 517 + throw new Error("No passkey setup token"); 518 + } 519 + 520 + return api.startPasskeyRegistrationForSetup( 521 + state.userDid, 522 + state.passkeySetupToken, 523 + ); 524 + } 525 + 526 + async function registerPasskey(passkeyName?: string): Promise<void> { 527 + if (!state.passkeySetupToken) { 528 + throw new Error("No passkey setup token"); 529 + } 530 + 531 + if (!globalThis.PublicKeyCredential) { 532 + throw new Error("Passkeys are not supported in this browser"); 533 + } 534 + 535 + const { options } = await startPasskeyRegistration(); 536 + 537 + const publicKeyOptions = prepareWebAuthnCreationOptions( 538 + options as { publicKey: Record<string, unknown> }, 539 + ); 540 + const credential = await navigator.credentials.create({ 541 + publicKey: publicKeyOptions, 542 + }); 543 + 544 + if (!credential) { 545 + throw new Error("Passkey creation was cancelled"); 546 + } 547 + 548 + const publicKeyCredential = credential as PublicKeyCredential; 549 + const response = publicKeyCredential 550 + .response as AuthenticatorAttestationResponse; 551 + 552 + const credentialData = { 553 + id: publicKeyCredential.id, 554 + rawId: base64UrlEncode(publicKeyCredential.rawId), 555 + type: publicKeyCredential.type, 556 + response: { 557 + clientDataJSON: base64UrlEncode(response.clientDataJSON), 558 + attestationObject: base64UrlEncode(response.attestationObject), 559 + }, 560 + }; 561 + 562 + const result = await api.completePasskeySetup( 563 + state.userDid, 564 + state.passkeySetupToken, 565 + credentialData, 566 + passkeyName, 567 + ); 568 + 569 + state.generatedAppPassword = result.appPassword; 570 + state.generatedAppPasswordName = result.appPasswordName; 571 + 572 + const session = await api.createSession( 573 + state.targetEmail, 574 + result.appPassword, 575 + ); 576 + state.localAccessToken = session.accessJwt; 577 + state.localRefreshToken = session.refreshJwt; 578 + saveOfflineState(state); 579 + 580 + setStep("app-password"); 581 + } 582 + 583 + async function proceedFromAppPassword(): Promise<void> { 584 + setStep("plc-signing"); 585 + await signFinalPlcOperation(); 586 + 587 + setStep("finalizing"); 588 + await activateAccount(); 589 + 590 + cleanup(); 591 + setStep("success"); 592 + } 593 + 594 + function cleanup(): void { 595 + clearOfflineState(); 596 + userRotationKeypair = null; 597 + tempVerificationKeypair = null; 598 + state.rotationKey = ""; 599 + } 600 + 601 + async function runMigration(): Promise<void> { 602 + try { 603 + setStep("creating"); 604 + 605 + const serviceAuthToken = await prepareTempCredentials(); 606 + 607 + if (state.authMethod === "passkey") { 608 + await createPasskeyAccount(serviceAuthToken); 609 + } else { 610 + await createPasswordAccount(serviceAuthToken); 611 + } 612 + 613 + setStep("importing"); 614 + await importRepository(); 615 + 616 + setStep("migrating-blobs"); 617 + await migrateBlobs(); 618 + 619 + if ( 620 + state.progress.blobsTotal > 0 || state.progress.blobsFailed.length > 0 621 + ) { 622 + await new Promise((resolve) => setTimeout(resolve, 3000)); 623 + } 624 + 625 + setStep("email-verify"); 626 + } catch (e) { 627 + setError((e as Error).message); 628 + setStep("error"); 629 + } 630 + } 631 + 632 + function reset() { 633 + clearOfflineState(); 634 + userRotationKeypair = null; 635 + tempVerificationKeypair = null; 636 + state = { 637 + direction: "offline-inbound", 638 + step: "welcome", 639 + userDid: "", 640 + carFile: null, 641 + carFileName: "", 642 + carSizeBytes: 0, 643 + carNeedsReupload: false, 644 + rotationKey: "", 645 + rotationKeyDidKey: "", 646 + oldPdsUrl: null, 647 + targetHandle: "", 648 + targetEmail: "", 649 + targetPassword: "", 650 + inviteCode: "", 651 + authMethod: "password", 652 + localAccessToken: null, 653 + localRefreshToken: null, 654 + passkeySetupToken: null, 655 + generatedAppPassword: null, 656 + generatedAppPasswordName: null, 657 + emailVerifyToken: "", 658 + progress: createInitialProgress(), 659 + error: null, 660 + plcUpdatedTemporarily: false, 661 + }; 662 + localServerInfo = null; 663 + } 664 + 665 + function tryResume(): boolean { 666 + const stored = loadOfflineState(); 667 + if (!stored) return false; 668 + 669 + state.userDid = stored.userDid; 670 + state.carFileName = stored.carFileName; 671 + state.carSizeBytes = stored.carSizeBytes; 672 + state.rotationKeyDidKey = stored.rotationKeyDidKey; 673 + state.targetHandle = stored.targetHandle; 674 + state.targetEmail = stored.targetEmail; 675 + state.authMethod = stored.authMethod ?? "password"; 676 + state.passkeySetupToken = stored.passkeySetupToken ?? null; 677 + state.oldPdsUrl = stored.oldPdsUrl ?? null; 678 + state.plcUpdatedTemporarily = stored.plcUpdatedTemporarily ?? false; 679 + state.step = stored.step; 680 + state.progress.repoExported = stored.progress.accountCreated; 681 + state.progress.repoImported = stored.progress.repoImported; 682 + state.progress.plcSigned = stored.progress.plcSigned; 683 + state.progress.activated = stored.progress.activated; 684 + state.error = stored.lastError ?? null; 685 + 686 + if (stored.carFileName && stored.carSizeBytes > 0) { 687 + state.carNeedsReupload = true; 688 + } 689 + 690 + return true; 691 + } 692 + 693 + function getLocalSession(): 694 + | { accessJwt: string; did: string; handle: string } 695 + | null { 696 + if (!state.localAccessToken) return null; 697 + return { 698 + accessJwt: state.localAccessToken, 699 + did: state.userDid, 700 + handle: state.targetHandle, 701 + }; 702 + } 703 + 704 + return { 705 + get state() { 706 + return state; 707 + }, 708 + getLocalSession, 709 + setStep, 710 + setError, 711 + setProgress, 712 + loadLocalServerInfo, 713 + checkHandleAvailability, 714 + validateRotationKey, 715 + runMigration, 716 + submitEmailVerifyToken, 717 + resendEmailVerification, 718 + checkEmailVerifiedAndProceed, 719 + startPasskeyRegistration, 720 + registerPasskey, 721 + proceedFromAppPassword, 722 + reset, 723 + tryResume, 724 + clearOfflineState, 725 + setUserDid(did: string) { 726 + state.userDid = did; 727 + saveOfflineState(state); 728 + }, 729 + setCarFile(file: Uint8Array, fileName: string) { 730 + state.carFile = file; 731 + state.carFileName = fileName; 732 + state.carSizeBytes = file.length; 733 + state.carNeedsReupload = false; 734 + saveOfflineState(state); 735 + }, 736 + setRotationKey(key: string) { 737 + state.rotationKey = key; 738 + }, 739 + setTargetHandle(handle: string) { 740 + state.targetHandle = handle; 741 + saveOfflineState(state); 742 + }, 743 + setTargetEmail(email: string) { 744 + state.targetEmail = email; 745 + saveOfflineState(state); 746 + }, 747 + setTargetPassword(password: string) { 748 + state.targetPassword = password; 749 + }, 750 + setInviteCode(code: string) { 751 + state.inviteCode = code; 752 + }, 753 + setAuthMethod(method: AuthMethod) { 754 + state.authMethod = method; 755 + saveOfflineState(state); 756 + }, 757 + updateField<K extends keyof OfflineInboundMigrationState>( 758 + field: K, 759 + value: OfflineInboundMigrationState[K], 760 + ) { 761 + state[field] = value; 762 + saveOfflineState(state); 763 + }, 764 + }; 765 + }
+281
frontend/src/lib/migration/plc-ops.ts
···
··· 1 + import { 2 + defs, 3 + type IndexedEntry, 4 + normalizeOp, 5 + type Operation, 6 + } from "@atcute/did-plc"; 7 + import { 8 + P256PrivateKey, 9 + parsePrivateMultikey, 10 + Secp256k1PrivateKey, 11 + Secp256k1PrivateKeyExportable, 12 + } from "@atcute/crypto"; 13 + import * as CBOR from "@atcute/cbor"; 14 + import { fromBase16, toBase64Url } from "@atcute/multibase"; 15 + 16 + export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey; 17 + 18 + export interface KeypairInfo { 19 + type: "private_key"; 20 + didPublicKey: `did:key:${string}`; 21 + keypair: PrivateKey; 22 + } 23 + 24 + export interface PlcService { 25 + type: string; 26 + endpoint: string; 27 + } 28 + 29 + export interface PlcOperationData { 30 + type: "plc_operation"; 31 + prev: string; 32 + alsoKnownAs: string[]; 33 + rotationKeys: string[]; 34 + services: Record<string, PlcService>; 35 + verificationMethods: Record<string, string>; 36 + sig?: string; 37 + } 38 + 39 + const jsonToB64Url = (obj: unknown): string => { 40 + const enc = new TextEncoder(); 41 + const json = JSON.stringify(obj); 42 + return toBase64Url(enc.encode(json)); 43 + }; 44 + 45 + export class PlcOps { 46 + private plcDirectoryUrl: string; 47 + 48 + constructor(plcDirectoryUrl = "https://plc.directory") { 49 + this.plcDirectoryUrl = plcDirectoryUrl; 50 + } 51 + 52 + async getPlcAuditLogs(did: string): Promise<IndexedEntry[]> { 53 + const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`); 54 + if (!response.ok) { 55 + throw new Error(`Failed to fetch PLC audit logs: ${response.status}`); 56 + } 57 + const json = await response.json(); 58 + return defs.indexedEntryLog.parse(json); 59 + } 60 + 61 + async getLastPlcOpFromPlc( 62 + did: string, 63 + ): Promise<{ lastOperation: Operation; base: IndexedEntry }> { 64 + const logs = await this.getPlcAuditLogs(did); 65 + const lastOp = logs.at(-1); 66 + if (!lastOp) { 67 + throw new Error("No PLC operations found for this DID"); 68 + } 69 + return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }; 70 + } 71 + 72 + async getCurrentRotationKeysForUser(did: string): Promise<string[]> { 73 + const { lastOperation } = await this.getLastPlcOpFromPlc(did); 74 + return lastOperation.rotationKeys || []; 75 + } 76 + 77 + async createNewSecp256k1Keypair(): Promise< 78 + { privateKey: string; publicKey: `did:key:${string}` } 79 + > { 80 + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); 81 + const publicKey = await keypair.exportPublicKey("did"); 82 + const privateKey = await keypair.exportPrivateKey("multikey"); 83 + return { privateKey, publicKey }; 84 + } 85 + 86 + async getKeyPair( 87 + privateKeyString: string, 88 + type: "secp256k1" | "p256" = "secp256k1", 89 + ): Promise<KeypairInfo> { 90 + const HEX_REGEX = /^[0-9a-f]+$/i; 91 + const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/; 92 + let keypair: PrivateKey | undefined; 93 + 94 + const trimmed = privateKeyString.trim(); 95 + 96 + if (HEX_REGEX.test(trimmed) && trimmed.length === 64) { 97 + const privateKeyBytes = fromBase16(trimmed); 98 + if (type === "p256") { 99 + keypair = await P256PrivateKey.importRaw(privateKeyBytes); 100 + } else { 101 + keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 102 + } 103 + } else if (MULTIKEY_REGEX.test(trimmed)) { 104 + const match = parsePrivateMultikey(trimmed); 105 + const privateKeyBytes = match.privateKeyBytes; 106 + if (match.type === "p256") { 107 + keypair = await P256PrivateKey.importRaw(privateKeyBytes); 108 + } else if (match.type === "secp256k1") { 109 + keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 110 + } else { 111 + throw new Error(`Unsupported key type: ${match.type}`); 112 + } 113 + } else { 114 + throw new Error( 115 + "Invalid key format. Expected 64-char hex or multikey format.", 116 + ); 117 + } 118 + 119 + if (!keypair) { 120 + throw new Error("Failed to parse private key"); 121 + } 122 + 123 + return { 124 + type: "private_key", 125 + didPublicKey: await keypair.exportPublicKey("did"), 126 + keypair, 127 + }; 128 + } 129 + 130 + async signAndPublishNewOp( 131 + did: string, 132 + signingRotationKey: PrivateKey, 133 + alsoKnownAs: string[], 134 + rotationKeys: string[], 135 + pds: string, 136 + verificationKey: string, 137 + prev: string, 138 + ): Promise<void> { 139 + const rotationKeysToUse = [...new Set(rotationKeys)]; 140 + if (rotationKeysToUse.length === 0) { 141 + throw new Error("No rotation keys provided"); 142 + } 143 + if (rotationKeysToUse.length > 5) { 144 + throw new Error("Maximum 5 rotation keys allowed"); 145 + } 146 + 147 + const operation: PlcOperationData = { 148 + type: "plc_operation", 149 + prev, 150 + alsoKnownAs, 151 + rotationKeys: rotationKeysToUse, 152 + services: { 153 + atproto_pds: { 154 + type: "AtprotoPersonalDataServer", 155 + endpoint: pds, 156 + }, 157 + }, 158 + verificationMethods: { 159 + atproto: verificationKey, 160 + }, 161 + }; 162 + 163 + const opBytes = CBOR.encode(operation); 164 + const sigBytes = await signingRotationKey.sign(opBytes); 165 + const signature = toBase64Url(sigBytes); 166 + 167 + const signedOperation = { 168 + ...operation, 169 + sig: signature, 170 + }; 171 + 172 + await this.pushPlcOperation(did, signedOperation); 173 + } 174 + 175 + async pushPlcOperation( 176 + did: string, 177 + operation: PlcOperationData, 178 + ): Promise<void> { 179 + const response = await fetch(`${this.plcDirectoryUrl}/${did}`, { 180 + method: "POST", 181 + headers: { 182 + "Content-Type": "application/json", 183 + }, 184 + body: JSON.stringify(operation), 185 + }); 186 + 187 + if (!response.ok) { 188 + const contentType = response.headers.get("content-type"); 189 + if (contentType?.includes("application/json")) { 190 + const json = await response.json(); 191 + if ( 192 + typeof json === "object" && json !== null && 193 + typeof json.message === "string" 194 + ) { 195 + throw new Error(json.message); 196 + } 197 + } 198 + throw new Error(`PLC directory returned HTTP ${response.status}`); 199 + } 200 + } 201 + 202 + async createServiceAuthToken( 203 + iss: string, 204 + aud: string, 205 + keypair: PrivateKey, 206 + lxm: string, 207 + ): Promise<string> { 208 + const iat = Math.floor(Date.now() / 1000); 209 + const exp = iat + 60; 210 + 211 + const jti = (() => { 212 + const bytes = new Uint8Array(16); 213 + crypto.getRandomValues(bytes); 214 + return Array.from(bytes) 215 + .map((b) => b.toString(16).padStart(2, "0")) 216 + .join(""); 217 + })(); 218 + 219 + const header = { typ: "JWT", alg: "ES256K" }; 220 + const payload = { iat, iss, aud, exp, lxm, jti }; 221 + 222 + const headerB64 = jsonToB64Url(header); 223 + const payloadB64 = jsonToB64Url(payload); 224 + const toSignStr = `${headerB64}.${payloadB64}`; 225 + 226 + const toSignBytes = new TextEncoder().encode(toSignStr); 227 + const sigBytes = await keypair.sign(toSignBytes); 228 + const sigB64 = toBase64Url(sigBytes); 229 + 230 + return `${toSignStr}.${sigB64}`; 231 + } 232 + 233 + async signPlcOperationWithCredentials( 234 + did: string, 235 + signingKey: PrivateKey, 236 + credentials: { 237 + rotationKeys?: string[]; 238 + alsoKnownAs?: string[]; 239 + verificationMethods?: Record<string, string>; 240 + services?: Record<string, PlcService>; 241 + }, 242 + additionalRotationKeys: string[], 243 + prevCid: string, 244 + ): Promise<void> { 245 + const rotationKeys = [ 246 + ...new Set([ 247 + ...(additionalRotationKeys || []), 248 + ...(credentials.rotationKeys || []), 249 + ]), 250 + ]; 251 + 252 + if (rotationKeys.length === 0) { 253 + throw new Error("No rotation keys provided"); 254 + } 255 + if (rotationKeys.length > 5) { 256 + throw new Error("Maximum 5 rotation keys allowed"); 257 + } 258 + 259 + const operation: PlcOperationData = { 260 + type: "plc_operation", 261 + prev: prevCid, 262 + alsoKnownAs: credentials.alsoKnownAs || [], 263 + rotationKeys, 264 + services: credentials.services || {}, 265 + verificationMethods: credentials.verificationMethods || {}, 266 + }; 267 + 268 + const opBytes = CBOR.encode(operation); 269 + const sigBytes = await signingKey.sign(opBytes); 270 + const signature = toBase64Url(sigBytes); 271 + 272 + const signedOperation = { 273 + ...operation, 274 + sig: signature, 275 + }; 276 + 277 + await this.pushPlcOperation(did, signedOperation); 278 + } 279 + } 280 + 281 + export const plcOps = new PlcOps();
+35 -21
frontend/src/lib/migration/types.ts
··· 13 | "success" 14 | "error"; 15 16 - export type AuthMethod = "password" | "passkey"; 17 - 18 - export type OutboundStep = 19 | "welcome" 20 - | "target-pds" 21 - | "new-account" 22 | "review" 23 - | "migrating" 24 - | "plc-token" 25 | "finalizing" 26 | "success" 27 | "error"; 28 29 - export type MigrationDirection = "inbound" | "outbound"; 30 31 export interface MigrationProgress { 32 repoExported: boolean; ··· 68 resumeToStep?: InboundStep; 69 } 70 71 - export interface OutboundMigrationState { 72 - direction: "outbound"; 73 - step: OutboundStep; 74 - localDid: string; 75 - localHandle: string; 76 - targetPdsUrl: string; 77 - targetPdsDid: string; 78 targetHandle: string; 79 targetEmail: string; 80 targetPassword: string; 81 inviteCode: string; 82 - targetAccessToken: string | null; 83 - targetRefreshToken: string | null; 84 - serviceAuthToken: string | null; 85 - plcToken: string; 86 progress: MigrationProgress; 87 error: string | null; 88 - targetServerInfo: ServerDescription | null; 89 } 90 91 - export type MigrationState = InboundMigrationState | OutboundMigrationState; 92 93 export interface StoredMigrationState { 94 version: 1;
··· 13 | "success" 14 | "error"; 15 16 + export type OfflineInboundStep = 17 | "welcome" 18 + | "provide-did" 19 + | "upload-car" 20 + | "provide-rotation-key" 21 + | "choose-handle" 22 | "review" 23 + | "creating" 24 + | "importing" 25 + | "migrating-blobs" 26 + | "plc-signing" 27 + | "email-verify" 28 + | "passkey-setup" 29 + | "app-password" 30 | "finalizing" 31 | "success" 32 | "error"; 33 34 + export type AuthMethod = "password" | "passkey"; 35 + 36 + export type MigrationDirection = "inbound"; 37 38 export interface MigrationProgress { 39 repoExported: boolean; ··· 75 resumeToStep?: InboundStep; 76 } 77 78 + export interface OfflineInboundMigrationState { 79 + direction: "offline-inbound"; 80 + step: OfflineInboundStep; 81 + userDid: string; 82 + carFile: Uint8Array | null; 83 + carFileName: string; 84 + carSizeBytes: number; 85 + carNeedsReupload: boolean; 86 + rotationKey: string; 87 + rotationKeyDidKey: string; 88 + oldPdsUrl: string | null; 89 targetHandle: string; 90 targetEmail: string; 91 targetPassword: string; 92 inviteCode: string; 93 + authMethod: AuthMethod; 94 + localAccessToken: string | null; 95 + localRefreshToken: string | null; 96 + passkeySetupToken: string | null; 97 + generatedAppPassword: string | null; 98 + generatedAppPasswordName: string | null; 99 + emailVerifyToken: string; 100 progress: MigrationProgress; 101 error: string | null; 102 + plcUpdatedTemporarily: boolean; 103 } 104 105 + export type MigrationState = InboundMigrationState; 106 107 export interface StoredMigrationState { 108 version: 1;
+152 -98
frontend/src/locales/en.json
··· 17 "dashboard": "Dashboard", 18 "backToDashboard": "← Dashboard", 19 "copied": "Copied!", 20 - "copyToClipboard": "Copy to Clipboard" 21 }, 22 "login": { 23 "title": "Sign In", ··· 49 "codeLabel": "Verification Code", 50 "codePlaceholder": "Enter 6-digit code", 51 "verifyButton": "Verify Account", 52 - "verifying": "Verifying...", 53 - "resendButton": "Resend Code", 54 - "resending": "Resending...", 55 - "resent": "Verification code resent!", 56 - "backToLogin": "Back to Login" 57 }, 58 "register": { 59 "title": "Create Account", ··· 124 "inviteCodePlaceholder": "Enter your invite code", 125 "inviteCodeRequired": "required", 126 "createButton": "Create Account", 127 - "creating": "Creating account...", 128 "alreadyHaveAccount": "Already have an account?", 129 "signIn": "Sign in", 130 "wantPasswordless": "Want passwordless security?", ··· 179 "navAdminDesc": "Server stats and admin operations", 180 "navDidDocument": "DID Document", 181 "navDidDocumentDesc": "Manage your DID document for external migrations", 182 "migrated": "Migrated", 183 "migratedTitle": "Account Migrated", 184 "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.", ··· 208 "serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.", 209 "currentPds": "Current PDS URL", 210 "save": "Save Changes", 211 - "saving": "Saving...", 212 "success": "DID document updated successfully", 213 "saveFailed": "Failed to save DID document", 214 "loadFailed": "Failed to load DID document", ··· 246 "yourDomain": "Your Domain", 247 "yourDomainPlaceholder": "example.com", 248 "verifyAndUpdate": "Verify & Update Handle", 249 - "verifying": "Verifying...", 250 "newHandle": "New Handle", 251 "newHandlePlaceholder": "yourhandle", 252 "changeHandleButton": "Change Handle", ··· 262 "exportData": "Export Data", 263 "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 264 "downloadRepo": "Download Repository", 265 "exporting": "Exporting...", 266 "deleteAccount": "Delete Account", 267 "deleteWarning": "This action is irreversible. All your data will be permanently deleted.", 268 "requestDeletion": "Request Account Deletion", ··· 291 "deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.", 292 "deletionFailed": "Failed to delete account", 293 "repoExported": "Repository exported successfully", 294 - "exportFailed": "Failed to export repository", 295 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 296 } 297 }, ··· 306 "noPasswords": "No app passwords yet", 307 "revoke": "Revoke", 308 "revoking": "Revoking...", 309 - "creating": "Creating...", 310 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 311 "saveWarningTitle": "Important: Save this app password!", 312 "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.", ··· 354 "used": "Used by @{handle}", 355 "disabled": "Disabled", 356 "usedBy": "Used by", 357 - "creating": "Creating...", 358 "disableConfirm": "Disable this invite code? It can no longer be used.", 359 "created": "Invite Code Created", 360 "copy": "Copy", ··· 482 "verifyButton": "Verify", 483 "verifyCodePlaceholder": "Enter verification code", 484 "submit": "Submit", 485 - "saving": "Saving...", 486 "savePreferences": "Save Preferences", 487 "preferencesSaved": "Communication preferences saved", 488 "verifiedSuccess": "{channel} verified successfully", ··· 521 "noCollectionsYet": "No collections yet. Create your first record to get started.", 522 "loadMore": "Load More", 523 "recordJson": "Record JSON", 524 - "saving": "Saving...", 525 "updateRecord": "Update Record", 526 "collectionNsid": "Collection (NSID)", 527 "recordKeyOptional": "Record Key (optional)", 528 "autoGenerated": "Auto-generated if empty (TID)", 529 "autoGeneratedHint": "Leave empty to auto-generate a TID-based key", 530 - "creating": "Creating...", 531 "demoPostText": "Hello from my PDS! This is my first post.", 532 "demoDisplayName": "Your Display Name", 533 "demoBio": "A short bio about yourself." ··· 551 "secondaryLight": "Secondary (Light Mode)", 552 "secondaryDark": "Secondary (Dark Mode)", 553 "configSaved": "Server configuration saved", 554 - "saving": "Saving...", 555 "saveConfig": "Save Configuration", 556 "serverStats": "Server Statistics", 557 "users": "Users", ··· 639 "title": "Two-Factor Authentication", 640 "subtitle": "Additional verification is required", 641 "usePasskey": "Use Passkey", 642 - "useTotp": "Use Authenticator App", 643 - "verifying": "Verifying..." 644 }, 645 "twoFactorCode": { 646 "title": "Two-Factor Authentication", 647 "subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.", 648 "codeLabel": "Verification Code", 649 "codePlaceholder": "Enter 6-digit code", 650 - "verify": "Verify", 651 - "verifying": "Verifying...", 652 "errors": { 653 "missingRequestUri": "Missing request_uri parameter", 654 "verificationFailed": "Verification failed", ··· 660 "title": "Enter Authenticator Code", 661 "subtitle": "Enter the 6-digit code from your authenticator app", 662 "codePlaceholder": "Enter 6-digit code", 663 - "verify": "Verify", 664 - "verifying": "Verifying...", 665 "useBackupCode": "Use backup code instead", 666 "backupCodePlaceholder": "Enter backup code", 667 "trustDevice": "Trust this device for 30 days", ··· 691 "codeLabel": "Verification Code", 692 "codeHelp": "Copy the entire code from your message, including dashes", 693 "verifyButton": "Verify Account", 694 - "verify": "Verify", 695 - "verifying": "Verifying...", 696 "pleaseWait": "Please wait...", 697 - "resendCode": "Resend Code", 698 - "resending": "Resending...", 699 - "sending": "Sending...", 700 "codeResent": "Verification code resent!", 701 "codeResentDetail": "Verification code sent! Check your inbox.", 702 - "backToLogin": "Back to Login", 703 - "backToSettings": "Back to Settings", 704 "verifyingAccount": "Verifying account: @{handle}", 705 "startOver": "Start over with a different account", 706 "noPending": "No pending verification found.", ··· 746 "resetButton": "Reset Password", 747 "resetting": "Resetting...", 748 "success": "Password reset successfully!", 749 - "backToLogin": "Back to Sign In", 750 "requestNewCode": "Request New Code", 751 "passwordsMismatch": "Passwords do not match", 752 "passwordLength": "Password must be at least 8 characters" ··· 790 "howItWorks": "How it works", 791 "howItWorksDetail": "We'll send a secure link to your registered notification channel. Click the link to set a temporary password. Then you can sign in and add a new passkey.", 792 "sendRecoveryLink": "Send Recovery Link", 793 - "sending": "Sending...", 794 - "backToLogin": "Back to Sign In" 795 }, 796 "registerPasskey": { 797 "title": "Create Passkey Account", ··· 814 "inviteCode": "Invite Code", 815 "inviteCodePlaceholder": "Enter your invite code", 816 "createButton": "Create Account", 817 - "creating": "Creating...", 818 "continue": "Continue", 819 "back": "Back", 820 "alreadyHaveAccount": "Already have an account?", ··· 911 "useTotp": "Use Authenticator", 912 "passwordPlaceholder": "Enter your password", 913 "totpPlaceholder": "Enter 6-digit code", 914 - "verify": "Verify", 915 - "verifying": "Verifying...", 916 "authenticating": "Authenticating...", 917 "passkeyPrompt": "Click the button below to authenticate with your passkey.", 918 "cancel": "Cancel" ··· 947 "handle": "Handle", 948 "emailOptional": "Email (optional)", 949 "yourAccessLevel": "Your Access Level", 950 - "creating": "Creating...", 951 "createAccount": "Create Account", 952 "createDelegatedAccountButton": "+ Create Delegated Account", 953 "accountCreated": "Created delegated account: {handle}", ··· 1059 "navDesc": "Move your account to or from another PDS", 1060 "migrateHere": "Migrate Here", 1061 "migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.", 1062 - "migrateAway": "Migrate Away", 1063 - "migrateAwayDesc": "Move your account from this PDS to another server.", 1064 - "loginRequired": "Login required", 1065 "bringDid": "Bring your DID and identity", 1066 "transferData": "Transfer all your data", 1067 "keepFollowers": "Keep your followers", 1068 - "exportRepo": "Export your repository", 1069 - "transferToPds": "Transfer to new PDS", 1070 - "updateIdentity": "Update your identity", 1071 "whatIsMigration": "What is account migration?", 1072 "whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.", 1073 "beforeMigrate": "Before you migrate", ··· 1077 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1078 "importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.", 1079 "learnMore": "Learn more about migration risks", 1080 - "comingSoon": "Coming soon", 1081 "oauthCompleting": "Completing authentication...", 1082 "oauthFailed": "Authentication Failed", 1083 "tryAgain": "Try Again", ··· 1086 "incomplete": "You have an incomplete migration in progress:", 1087 "direction": "Direction", 1088 "migratingHere": "Migrating here", 1089 - "migratingAway": "Migrating away", 1090 "from": "From", 1091 "to": "To", 1092 "progress": "Progress", ··· 1229 "error": { 1230 "title": "Migration Error", 1231 "desc": "An error occurred during migration.", 1232 - "startOver": "Start Over" 1233 }, 1234 "common": { 1235 "back": "Back", ··· 1247 "warning3": "Your old account will be deactivated after migration" 1248 } 1249 }, 1250 - "outbound": { 1251 "welcome": { 1252 - "title": "Migrate Away from This PDS", 1253 - "desc": "Move your account to another Personal Data Server.", 1254 - "warning": "After migration, your account here will be deactivated.", 1255 - "didWebNotice": "did:web Migration Notice", 1256 - "didWebNoticeDesc": "Your account uses a did:web identifier ({did}). After migrating, this PDS will continue to serve your DID document pointing to the new PDS. Your identity will remain functional as long as this server is online.", 1257 - "understand": "I understand the risks and want to proceed" 1258 }, 1259 - "targetPds": { 1260 - "title": "Choose Target PDS", 1261 - "desc": "Enter the URL of the PDS you want to migrate to.", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "Validate & Continue", 1265 "validating": "Validating...", 1266 - "connected": "Connected to {name}", 1267 - "inviteRequired": "Invite code required", 1268 - "privacyPolicy": "Privacy Policy", 1269 - "termsOfService": "Terms of Service" 1270 }, 1271 - "newAccount": { 1272 - "title": "New Account Details", 1273 - "desc": "Set up your account on the new PDS.", 1274 - "handle": "Handle", 1275 - "availableDomains": "Available domains", 1276 - "email": "Email", 1277 - "password": "Password", 1278 - "confirmPassword": "Confirm Password", 1279 - "inviteCode": "Invite Code" 1280 }, 1281 "review": { 1282 - "title": "Review Migration", 1283 - "desc": "Please review and confirm your migration details.", 1284 - "currentHandle": "Current Handle", 1285 - "newHandle": "New Handle", 1286 - "sourcePds": "This PDS", 1287 - "targetPds": "Target PDS", 1288 - "confirm": "I confirm I want to migrate my account", 1289 - "startMigration": "Start Migration" 1290 }, 1291 "migrating": { 1292 - "title": "Migrating Your Account", 1293 - "desc": "Please wait while we transfer your data..." 1294 - }, 1295 - "plcToken": { 1296 - "title": "Verify Your Identity", 1297 - "desc": "A verification code has been sent to your email." 1298 }, 1299 - "finalizing": { 1300 - "title": "Finalizing Migration", 1301 - "desc": "Please wait while we complete the migration...", 1302 - "updatingForwarding": "Updating DID document forwarding..." 1303 }, 1304 "success": { 1305 - "title": "Migration Complete!", 1306 - "desc": "Your account has been successfully migrated to your new PDS.", 1307 - "newHandle": "New Handle", 1308 - "newPds": "New PDS", 1309 - "nextSteps": "Next Steps", 1310 - "nextSteps1": "Sign in to your new PDS", 1311 - "nextSteps2": "Update any apps with your new credentials", 1312 - "nextSteps3": "Your followers will automatically see your new location", 1313 - "loggingOut": "Logging you out in {seconds} seconds..." 1314 } 1315 }, 1316 "progress": {
··· 17 "dashboard": "Dashboard", 18 "backToDashboard": "← Dashboard", 19 "copied": "Copied!", 20 + "copyToClipboard": "Copy to Clipboard", 21 + 22 + "verifying": "Verifying...", 23 + "saving": "Saving...", 24 + "creating": "Creating...", 25 + "updating": "Updating...", 26 + "sending": "Sending...", 27 + "authenticating": "Authenticating...", 28 + "checking": "Checking...", 29 + "redirecting": "Redirecting...", 30 + 31 + "signIn": "Sign In", 32 + "verify": "Verify", 33 + "remove": "Remove", 34 + "revoke": "Revoke", 35 + "resendCode": "Resend Code", 36 + "startOver": "Start Over", 37 + "tryAgain": "Try Again", 38 + 39 + "password": "Password", 40 + "email": "Email", 41 + "emailAddress": "Email Address", 42 + "handle": "Handle", 43 + "did": "DID", 44 + "verificationCode": "Verification Code", 45 + "inviteCode": "Invite Code", 46 + "newPassword": "New Password", 47 + "confirmPassword": "Confirm Password", 48 + 49 + "enterSixDigitCode": "Enter 6-digit code", 50 + "passwordHint": "At least 8 characters", 51 + "enterPassword": "Enter your password", 52 + "emailPlaceholder": "you@example.com", 53 + 54 + "verified": "Verified", 55 + "disabled": "Disabled", 56 + "available": "Available", 57 + "deactivated": "Deactivated", 58 + "unverified": "Unverified", 59 + 60 + "backToLogin": "Back to Login", 61 + "backToSettings": "Back to Settings", 62 + "alreadyHaveAccount": "Already have an account?", 63 + "createAccount": "Create account", 64 + 65 + "passwordsMismatch": "Passwords do not match", 66 + "passwordTooShort": "Password must be at least 8 characters" 67 }, 68 "login": { 69 "title": "Sign In", ··· 95 "codeLabel": "Verification Code", 96 "codePlaceholder": "Enter 6-digit code", 97 "verifyButton": "Verify Account", 98 + "resent": "Verification code resent!" 99 }, 100 "register": { 101 "title": "Create Account", ··· 166 "inviteCodePlaceholder": "Enter your invite code", 167 "inviteCodeRequired": "required", 168 "createButton": "Create Account", 169 "alreadyHaveAccount": "Already have an account?", 170 "signIn": "Sign in", 171 "wantPasswordless": "Want passwordless security?", ··· 220 "navAdminDesc": "Server stats and admin operations", 221 "navDidDocument": "DID Document", 222 "navDidDocumentDesc": "Manage your DID document for external migrations", 223 + "navDidDocumentDescActive": "Edit your DID document settings", 224 + "navBackup": "Download Backup", 225 + "navBackupDesc": "Download your repository as a CAR file", 226 + "downloadingBackup": "Downloading...", 227 + "backupFailed": "Failed to download backup", 228 "migrated": "Migrated", 229 "migratedTitle": "Account Migrated", 230 "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.", ··· 254 "serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.", 255 "currentPds": "Current PDS URL", 256 "save": "Save Changes", 257 "success": "DID document updated successfully", 258 "saveFailed": "Failed to save DID document", 259 "loadFailed": "Failed to load DID document", ··· 291 "yourDomain": "Your Domain", 292 "yourDomainPlaceholder": "example.com", 293 "verifyAndUpdate": "Verify & Update Handle", 294 "newHandle": "New Handle", 295 "newHandlePlaceholder": "yourhandle", 296 "changeHandleButton": "Change Handle", ··· 306 "exportData": "Export Data", 307 "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 308 "downloadRepo": "Download Repository", 309 + "downloadBlobs": "Download Media", 310 "exporting": "Exporting...", 311 + "backups": { 312 + "title": "Backups", 313 + "description": "Your repository is automatically backed up daily. You can also create manual backups or restore from a previous backup.", 314 + "enableAutomatic": "Enable automatic backups", 315 + "enabled": "Automatic backups enabled", 316 + "disabled": "Automatic backups disabled", 317 + "toggleFailed": "Failed to update backup setting", 318 + "noBackups": "No backups available yet.", 319 + "blocks": "blocks", 320 + "download": "Download", 321 + "delete": "Delete", 322 + "createNow": "Create Backup Now", 323 + "created": "Backup created successfully", 324 + "createFailed": "Failed to create backup", 325 + "downloadFailed": "Failed to download backup", 326 + "deleted": "Backup deleted", 327 + "deleteFailed": "Failed to delete backup", 328 + "restoreTitle": "Restore from Backup", 329 + "restoreDescription": "Upload a CAR file to restore your repository. This will overwrite your current data.", 330 + "selectFile": "Select CAR file", 331 + "selectedFile": "Selected file", 332 + "restore": "Restore", 333 + "restoring": "Restoring...", 334 + "restored": "Repository restored successfully", 335 + "restoreFailed": "Failed to restore repository" 336 + }, 337 "deleteAccount": "Delete Account", 338 "deleteWarning": "This action is irreversible. All your data will be permanently deleted.", 339 "requestDeletion": "Request Account Deletion", ··· 362 "deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.", 363 "deletionFailed": "Failed to delete account", 364 "repoExported": "Repository exported successfully", 365 + "blobsExported": "Media files exported successfully", 366 + "noBlobsToExport": "No media files to export", 367 + "exportFailed": "Failed to export", 368 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 369 } 370 }, ··· 379 "noPasswords": "No app passwords yet", 380 "revoke": "Revoke", 381 "revoking": "Revoking...", 382 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 383 "saveWarningTitle": "Important: Save this app password!", 384 "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.", ··· 426 "used": "Used by @{handle}", 427 "disabled": "Disabled", 428 "usedBy": "Used by", 429 "disableConfirm": "Disable this invite code? It can no longer be used.", 430 "created": "Invite Code Created", 431 "copy": "Copy", ··· 553 "verifyButton": "Verify", 554 "verifyCodePlaceholder": "Enter verification code", 555 "submit": "Submit", 556 "savePreferences": "Save Preferences", 557 "preferencesSaved": "Communication preferences saved", 558 "verifiedSuccess": "{channel} verified successfully", ··· 591 "noCollectionsYet": "No collections yet. Create your first record to get started.", 592 "loadMore": "Load More", 593 "recordJson": "Record JSON", 594 "updateRecord": "Update Record", 595 "collectionNsid": "Collection (NSID)", 596 "recordKeyOptional": "Record Key (optional)", 597 "autoGenerated": "Auto-generated if empty (TID)", 598 "autoGeneratedHint": "Leave empty to auto-generate a TID-based key", 599 "demoPostText": "Hello from my PDS! This is my first post.", 600 "demoDisplayName": "Your Display Name", 601 "demoBio": "A short bio about yourself." ··· 619 "secondaryLight": "Secondary (Light Mode)", 620 "secondaryDark": "Secondary (Dark Mode)", 621 "configSaved": "Server configuration saved", 622 "saveConfig": "Save Configuration", 623 "serverStats": "Server Statistics", 624 "users": "Users", ··· 706 "title": "Two-Factor Authentication", 707 "subtitle": "Additional verification is required", 708 "usePasskey": "Use Passkey", 709 + "useTotp": "Use Authenticator App" 710 }, 711 "twoFactorCode": { 712 "title": "Two-Factor Authentication", 713 "subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.", 714 "codeLabel": "Verification Code", 715 "codePlaceholder": "Enter 6-digit code", 716 "errors": { 717 "missingRequestUri": "Missing request_uri parameter", 718 "verificationFailed": "Verification failed", ··· 724 "title": "Enter Authenticator Code", 725 "subtitle": "Enter the 6-digit code from your authenticator app", 726 "codePlaceholder": "Enter 6-digit code", 727 "useBackupCode": "Use backup code instead", 728 "backupCodePlaceholder": "Enter backup code", 729 "trustDevice": "Trust this device for 30 days", ··· 753 "codeLabel": "Verification Code", 754 "codeHelp": "Copy the entire code from your message, including dashes", 755 "verifyButton": "Verify Account", 756 "pleaseWait": "Please wait...", 757 "codeResent": "Verification code resent!", 758 "codeResentDetail": "Verification code sent! Check your inbox.", 759 "verifyingAccount": "Verifying account: @{handle}", 760 "startOver": "Start over with a different account", 761 "noPending": "No pending verification found.", ··· 801 "resetButton": "Reset Password", 802 "resetting": "Resetting...", 803 "success": "Password reset successfully!", 804 "requestNewCode": "Request New Code", 805 "passwordsMismatch": "Passwords do not match", 806 "passwordLength": "Password must be at least 8 characters" ··· 844 "howItWorks": "How it works", 845 "howItWorksDetail": "We'll send a secure link to your registered notification channel. Click the link to set a temporary password. Then you can sign in and add a new passkey.", 846 "sendRecoveryLink": "Send Recovery Link", 847 + "sending": "Sending..." 848 }, 849 "registerPasskey": { 850 "title": "Create Passkey Account", ··· 867 "inviteCode": "Invite Code", 868 "inviteCodePlaceholder": "Enter your invite code", 869 "createButton": "Create Account", 870 "continue": "Continue", 871 "back": "Back", 872 "alreadyHaveAccount": "Already have an account?", ··· 963 "useTotp": "Use Authenticator", 964 "passwordPlaceholder": "Enter your password", 965 "totpPlaceholder": "Enter 6-digit code", 966 "authenticating": "Authenticating...", 967 "passkeyPrompt": "Click the button below to authenticate with your passkey.", 968 "cancel": "Cancel" ··· 997 "handle": "Handle", 998 "emailOptional": "Email (optional)", 999 "yourAccessLevel": "Your Access Level", 1000 "createAccount": "Create Account", 1001 "createDelegatedAccountButton": "+ Create Delegated Account", 1002 "accountCreated": "Created delegated account: {handle}", ··· 1108 "navDesc": "Move your account to or from another PDS", 1109 "migrateHere": "Migrate Here", 1110 "migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.", 1111 "bringDid": "Bring your DID and identity", 1112 "transferData": "Transfer all your data", 1113 "keepFollowers": "Keep your followers", 1114 "whatIsMigration": "What is account migration?", 1115 "whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.", 1116 "beforeMigrate": "Before you migrate", ··· 1120 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1121 "importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.", 1122 "learnMore": "Learn more about migration risks", 1123 + "offlineRestore": "Offline Restore", 1124 + "offlineRestoreDesc": "Restore from backup when your old PDS is unavailable.", 1125 + "offlineFeature1": "Use a CAR file backup", 1126 + "offlineFeature2": "Prove ownership with rotation key", 1127 + "offlineFeature3": "Recovery for shutdown servers", 1128 "oauthCompleting": "Completing authentication...", 1129 "oauthFailed": "Authentication Failed", 1130 "tryAgain": "Try Again", ··· 1133 "incomplete": "You have an incomplete migration in progress:", 1134 "direction": "Direction", 1135 "migratingHere": "Migrating here", 1136 "from": "From", 1137 "to": "To", 1138 "progress": "Progress", ··· 1275 "error": { 1276 "title": "Migration Error", 1277 "desc": "An error occurred during migration.", 1278 + "startOver": "Start Over", 1279 + "unknown": "An unknown error occurred." 1280 }, 1281 "common": { 1282 "back": "Back", ··· 1294 "warning3": "Your old account will be deactivated after migration" 1295 } 1296 }, 1297 + "offline": { 1298 "welcome": { 1299 + "title": "Offline Restore", 1300 + "desc": "Restore your account when your old PDS is unavailable. This is for disaster recovery when you cannot contact your previous server.", 1301 + "warningTitle": "Advanced Recovery Method", 1302 + "warningDesc": "This method requires your rotation key private key. Only use this if your previous PDS has shut down or you cannot access it.", 1303 + "requirementsTitle": "You will need:", 1304 + "requirement1": "Your DID (did:plc:...)", 1305 + "requirement2": "A CAR file backup of your repository", 1306 + "requirement3": "Your rotation key (private key in hex, base58, or JWK format)", 1307 + "understand": "I understand this is for offline recovery only" 1308 }, 1309 + "provideDid": { 1310 + "title": "Enter Your DID", 1311 + "desc": "Enter the DID of the account you want to restore.", 1312 + "label": "Your DID", 1313 + "hint": "Your decentralized identifier (e.g., did:plc:abc123...)" 1314 + }, 1315 + "uploadCar": { 1316 + "title": "Upload Repository Backup", 1317 + "desc": "Upload the CAR file containing your repository data.", 1318 + "label": "CAR File", 1319 + "hint": "This should be a .car file from a previous backup of your repository", 1320 + "reuploadWarningTitle": "CAR File Required", 1321 + "reuploadWarning": "Your session was restored, but you need to re-upload your CAR file. For security reasons, file contents are not stored between sessions." 1322 + }, 1323 + "rotationKey": { 1324 + "title": "Provide Rotation Key", 1325 + "desc": "Enter your rotation key to prove ownership of this DID.", 1326 + "securityWarningTitle": "Security Warning", 1327 + "securityWarning1": "Your rotation key is extremely sensitive - anyone with it can take over your identity", 1328 + "securityWarning2": "Only enter it on trusted devices and connections", 1329 + "securityWarning3": "The key will not be stored after migration", 1330 + "label": "Rotation Key", 1331 + "placeholder": "Paste your rotation key (hex, base58, or JWK)...", 1332 + "hint": "Supports 64-character hex, base58, or JWK format", 1333 + "valid": "Rotation key verified! You have control of this DID.", 1334 + "invalid": "This key is not a valid rotation key for this DID.", 1335 "validating": "Validating...", 1336 + "validate": "Validate Key" 1337 }, 1338 + "chooseHandle": { 1339 + "migratingDid": "Restoring DID" 1340 }, 1341 "review": { 1342 + "desc": "Please confirm the details of your offline restoration.", 1343 + "carFile": "CAR File", 1344 + "rotationKey": "Rotation Key", 1345 + "warning": "After you click \"Start Migration\", your repository will be imported and your DID will be updated to point to this PDS.", 1346 + "plcWarningTitle": "Point of No Return", 1347 + "plcWarning": "Once you start, your DID document will be updated to point to this PDS. If something goes wrong, you can use your rotation key to recover, but you should complete the migration to avoid a broken identity state." 1348 }, 1349 "migrating": { 1350 + "title": "Restoring Account", 1351 + "desc": "Please wait while your account is being restored...", 1352 + "creating": "Creating account", 1353 + "importing": "Importing repository", 1354 + "plcSigning": "Signing identity update", 1355 + "activating": "Activating account" 1356 }, 1357 + "blobs": { 1358 + "title": "Migrating Blobs", 1359 + "desc": "Attempting to recover images and media from your old PDS...", 1360 + "migrating": "Migrating blobs", 1361 + "failedTitle": "Some blobs could not be migrated", 1362 + "failedDesc": "{count} blobs could not be fetched from your old PDS. This may happen if the server is unreachable or the files were deleted.", 1363 + "sourceUnreachableTitle": "Source PDS Unreachable", 1364 + "sourceUnreachable": "Could not connect to your old PDS to fetch media files. This is common when migrating from a shut-down server. Your posts will work, but some images may be missing." 1365 }, 1366 "success": { 1367 + "desc": "Your account has been successfully restored to this PDS." 1368 } 1369 }, 1370 "progress": {
+154 -100
frontend/src/locales/fi.json
··· 17 "dashboard": "Hallintapaneeli", 18 "backToDashboard": "← Hallintapaneeli", 19 "copied": "Kopioitu!", 20 - "copyToClipboard": "Kopioi" 21 }, 22 "login": { 23 "title": "Kirjaudu sisään", ··· 49 "codeLabel": "Vahvistuskoodi", 50 "codePlaceholder": "Syötä 6-numeroinen koodi", 51 "verifyButton": "Vahvista tili", 52 - "verifying": "Vahvistetaan...", 53 - "resendButton": "Lähetä koodi uudelleen", 54 - "resending": "Lähetetään uudelleen...", 55 - "resent": "Vahvistuskoodi lähetetty uudelleen!", 56 - "backToLogin": "Takaisin kirjautumiseen" 57 }, 58 "register": { 59 "title": "Luo tili", ··· 124 "inviteCodePlaceholder": "Syötä kutsukoodisi", 125 "inviteCodeRequired": "vaaditaan", 126 "createButton": "Luo tili", 127 - "creating": "Luodaan tiliä...", 128 "alreadyHaveAccount": "Onko sinulla jo tili?", 129 "signIn": "Kirjaudu sisään", 130 "wantPasswordless": "Haluatko salasanattoman turvallisuuden?", ··· 179 "navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot", 180 "navDidDocument": "DID-dokumentti", 181 "navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten", 182 "migrated": "Siirretty", 183 "migratedTitle": "Tili siirretty", 184 "migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.", ··· 208 "serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.", 209 "currentPds": "Nykyinen PDS-URL", 210 "save": "Tallenna muutokset", 211 - "saving": "Tallennetaan...", 212 "success": "DID-dokumentti päivitetty onnistuneesti", 213 "saveFailed": "DID-dokumentin tallennus epäonnistui", 214 "loadFailed": "DID-dokumentin lataus epäonnistui", ··· 246 "yourDomain": "Verkkotunnuksesi", 247 "yourDomainPlaceholder": "esimerkki.fi", 248 "verifyAndUpdate": "Vahvista ja päivitä käyttäjänimi", 249 - "verifying": "Vahvistetaan...", 250 "newHandle": "Uusi käyttäjänimi", 251 "newHandlePlaceholder": "käyttäjänimesi", 252 "changeHandleButton": "Vaihda käyttäjänimi", ··· 262 "exportData": "Vie tiedot", 263 "exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.", 264 "downloadRepo": "Lataa tietovarasto", 265 "exporting": "Viedään...", 266 "deleteAccount": "Poista tili", 267 "deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.", 268 "requestDeletion": "Pyydä tilin poistoa", ··· 291 "deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.", 292 "deletionFailed": "Tilin poisto epäonnistui", 293 "repoExported": "Tietovarasto viety", 294 - "exportFailed": "Tietovaraston vienti epäonnistui", 295 "confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua." 296 } 297 }, ··· 306 "noPasswords": "Ei vielä sovellusten salasanoja", 307 "revoke": "Peruuta", 308 "revoking": "Peruutetaan...", 309 - "creating": "Luodaan...", 310 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.", 311 "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!", 312 "saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.", ··· 354 "used": "Käyttänyt @{handle}", 355 "disabled": "Poistettu käytöstä", 356 "usedBy": "Käyttänyt", 357 - "creating": "Luodaan...", 358 "disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.", 359 "created": "Kutsukoodi luotu", 360 "copy": "Kopioi", ··· 482 "verifyButton": "Vahvista", 483 "verifyCodePlaceholder": "Syötä vahvistuskoodi", 484 "submit": "Lähetä", 485 - "saving": "Tallennetaan...", 486 "savePreferences": "Tallenna asetukset", 487 "preferencesSaved": "Viestintäasetukset tallennettu", 488 "verifiedSuccess": "{channel} vahvistettu", ··· 521 "noCollectionsYet": "Ei vielä kokoelmia. Luo ensimmäinen tietueesi aloittaaksesi.", 522 "loadMore": "Lataa lisää", 523 "recordJson": "Tietueen JSON", 524 - "saving": "Tallennetaan...", 525 "updateRecord": "Päivitä tietue", 526 "collectionNsid": "Kokoelma (NSID)", 527 "recordKeyOptional": "Tietueavain (valinnainen)", 528 "autoGenerated": "Luodaan automaattisesti jos tyhjä (TID)", 529 "autoGeneratedHint": "Jätä tyhjäksi luodaksesi TID-pohjaisen avaimen automaattisesti", 530 - "creating": "Luodaan...", 531 "demoPostText": "Hei PDS:ltäni! Tämä on ensimmäinen julkaisuni.", 532 "demoDisplayName": "Näyttönimesi", 533 "demoBio": "Lyhyt kuvaus itsestäsi." ··· 548 "primaryLight": "Ensisijainen (vaalea tila)", 549 "primaryDark": "Ensisijainen (tumma tila)", 550 "configSaved": "Palvelinasetukset tallennettu", 551 - "saving": "Tallennetaan...", 552 "saveConfig": "Tallenna asetukset", 553 "serverStats": "Palvelintilastot", 554 "users": "Käyttäjät", ··· 639 "title": "Kaksivaiheinen tunnistautuminen", 640 "subtitle": "Lisävahvistus vaaditaan", 641 "usePasskey": "Käytä pääsyavainta", 642 - "useTotp": "Käytä todentajasovellusta", 643 - "verifying": "Vahvistetaan..." 644 }, 645 "twoFactorCode": { 646 "title": "Kaksivaiheinen tunnistautuminen", 647 "subtitle": "Vahvistuskoodi on lähetetty {channel}. Syötä koodi alla jatkaaksesi.", 648 "codeLabel": "Vahvistuskoodi", 649 "codePlaceholder": "Syötä 6-numeroinen koodi", 650 - "verify": "Vahvista", 651 - "verifying": "Vahvistetaan...", 652 "errors": { 653 "missingRequestUri": "Puuttuva request_uri-parametri", 654 "verificationFailed": "Vahvistus epäonnistui", ··· 660 "title": "Syötä todentajakoodi", 661 "subtitle": "Syötä 6-numeroinen koodi todentajasovelluksestasi", 662 "codePlaceholder": "Syötä 6-numeroinen koodi", 663 - "verify": "Vahvista", 664 - "verifying": "Vahvistetaan...", 665 "useBackupCode": "Käytä varakoodia sen sijaan", 666 "backupCodePlaceholder": "Syötä varakoodi", 667 "trustDevice": "Luota tähän laitteeseen 30 päivää", ··· 691 "codeLabel": "Vahvistuskoodi", 692 "codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat", 693 "verifyButton": "Vahvista tili", 694 - "verify": "Vahvista", 695 - "verifying": "Vahvistetaan...", 696 "pleaseWait": "Odota...", 697 - "sending": "Lähetetään...", 698 - "resendCode": "Lähetä koodi uudelleen", 699 - "resending": "Lähetetään uudelleen...", 700 "codeResent": "Vahvistuskoodi lähetetty uudelleen!", 701 "codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.", 702 "verified": "Vahvistettu!", ··· 706 "identifierLabel": "Sähköposti tai tunniste", 707 "identifierPlaceholder": "sinä@esimerkki.fi", 708 "identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin", 709 - "backToLogin": "Takaisin kirjautumiseen", 710 "verifyingAccount": "Vahvistetaan tiliä: @{handle}", 711 "startOver": "Aloita alusta toisella tilillä", 712 "noPending": "Odottavaa vahvistusta ei löytynyt.", 713 "noPendingInfo": "Jos loit tilin äskettäin ja sinun on vahvistettava se, sinun on ehkä luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisään.", 714 "createAccount": "Luo tili", 715 "signIn": "Kirjaudu sisään", 716 - "backToSettings": "Takaisin asetuksiin", 717 "emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi", 718 "emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui", 719 "emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.", ··· 746 "resetButton": "Palauta salasana", 747 "resetting": "Palautetaan...", 748 "success": "Salasana palautettu!", 749 - "backToLogin": "Takaisin kirjautumiseen", 750 "requestNewCode": "Pyydä uusi koodi", 751 "passwordsMismatch": "Salasanat eivät täsmää", 752 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä" ··· 790 "howItWorks": "Miten se toimii", 791 "howItWorksDetail": "Lähetämme suojatun linkin rekisteröityyn ilmoituskanavaasi. Klikkaa linkkiä asettaaksesi väliaikaisen salasanan. Sitten voit kirjautua sisään ja lisätä uuden pääsyavaimen.", 792 "sendRecoveryLink": "Lähetä palautuslinkki", 793 - "sending": "Lähetetään...", 794 - "backToLogin": "Takaisin kirjautumiseen" 795 }, 796 "registerPasskey": { 797 "title": "Luo pääsyavaintili", ··· 812 "externalDid": "Sinun did:web", 813 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 814 "createButton": "Luo tili", 815 - "creating": "Luodaan...", 816 "alreadyHaveAccount": "Onko sinulla jo tili?", 817 "signIn": "Kirjaudu sisään", 818 "wantPassword": "Haluatko käyttää salasanaa?", ··· 911 "useTotp": "Käytä todentajaa", 912 "passwordPlaceholder": "Syötä salasanasi", 913 "totpPlaceholder": "Syötä 6-numeroinen koodi", 914 - "verify": "Vahvista", 915 - "verifying": "Vahvistetaan...", 916 "authenticating": "Todennetaan...", 917 "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.", 918 "cancel": "Peruuta" ··· 967 "handle": "Käyttäjänimi", 968 "emailOptional": "Sähköposti (valinnainen)", 969 "yourAccessLevel": "Käyttöoikeustasosi", 970 - "creating": "Luodaan...", 971 "createAccount": "Luo tili", 972 "createDelegatedAccountButton": "+ Luo delegoitu tili", 973 "accountCreated": "Delegoitu tili luotu: {handle}", ··· 1059 "navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä", 1060 "migrateHere": "Siirrä tänne", 1061 "migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.", 1062 - "migrateAway": "Siirrä pois", 1063 - "migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.", 1064 - "loginRequired": "Kirjautuminen vaaditaan", 1065 "bringDid": "Tuo DID ja identiteettisi", 1066 "transferData": "Siirrä kaikki tietosi", 1067 "keepFollowers": "Säilytä seuraajasi", 1068 - "exportRepo": "Vie tietovarastosi", 1069 - "transferToPds": "Siirrä uuteen PDS:ään", 1070 - "updateIdentity": "Päivitä identiteettisi", 1071 "whatIsMigration": "Mikä on tilin siirto?", 1072 "whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.", 1073 "beforeMigrate": "Ennen siirtoa", ··· 1077 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1078 "importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.", 1079 "learnMore": "Lue lisää siirron riskeistä", 1080 - "comingSoon": "Tulossa pian", 1081 "oauthCompleting": "Viimeistellään todennusta...", 1082 "oauthFailed": "Todennus epäonnistui", 1083 "tryAgain": "Yritä uudelleen", ··· 1086 "incomplete": "Sinulla on keskeneräinen siirto:", 1087 "direction": "Suunta", 1088 "migratingHere": "Siirretään tänne", 1089 - "migratingAway": "Siirretään pois", 1090 "from": "Mistä", 1091 "to": "Minne", 1092 "progress": "Edistyminen", ··· 1229 "error": { 1230 "title": "Siirtovirhe", 1231 "desc": "Siirron aikana tapahtui virhe.", 1232 - "startOver": "Aloita alusta" 1233 }, 1234 "common": { 1235 "back": "Takaisin", ··· 1247 "warning3": "Vanha tilisi deaktivoidaan siirron jälkeen" 1248 } 1249 }, 1250 - "outbound": { 1251 "welcome": { 1252 - "title": "Siirrä pois tästä PDS:stä", 1253 - "desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.", 1254 - "warning": "Siirron jälkeen tilisi täällä deaktivoidaan.", 1255 - "didWebNotice": "did:web-siirtoilmoitus", 1256 - "didWebNoticeDesc": "Tilisi käyttää did:web-tunnistetta ({did}). Siirron jälkeen tämä PDS jatkaa DID-dokumenttisi tarjoamista osoittaen uuteen PDS:ään. Identiteettisi toimii niin kauan kuin tämä palvelin on päällä.", 1257 - "understand": "Ymmärrän riskit ja haluan jatkaa" 1258 }, 1259 - "targetPds": { 1260 - "title": "Valitse kohde-PDS", 1261 - "desc": "Syötä sen PDS:n URL, johon haluat siirtyä.", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "Vahvista ja jatka", 1265 - "validating": "Vahvistetaan...", 1266 - "connected": "Yhdistetty: {name}", 1267 - "inviteRequired": "Kutsukoodi vaaditaan", 1268 - "privacyPolicy": "Tietosuojakäytäntö", 1269 - "termsOfService": "Käyttöehdot" 1270 }, 1271 - "newAccount": { 1272 - "title": "Uuden tilin tiedot", 1273 - "desc": "Määritä tilisi uudessa PDS:ssä.", 1274 - "handle": "Käyttäjätunnus", 1275 - "availableDomains": "Käytettävissä olevat verkkotunnukset", 1276 - "email": "Sähköposti", 1277 - "password": "Salasana", 1278 - "confirmPassword": "Vahvista salasana", 1279 - "inviteCode": "Kutsukoodi" 1280 }, 1281 - "review": { 1282 - "title": "Tarkista siirto", 1283 - "desc": "Tarkista ja vahvista siirtotietosi.", 1284 - "currentHandle": "Nykyinen käyttäjätunnus", 1285 - "newHandle": "Uusi käyttäjätunnus", 1286 - "sourcePds": "Tämä PDS", 1287 - "targetPds": "Kohde-PDS", 1288 - "confirm": "Vahvistan haluavani siirtää tilini", 1289 - "startMigration": "Aloita siirto" 1290 }, 1291 - "migrating": { 1292 - "title": "Siirretään tiliäsi", 1293 - "desc": "Odota, kun siirrämme tietojasi..." 1294 }, 1295 - "plcToken": { 1296 - "title": "Vahvista henkilöllisyytesi", 1297 - "desc": "Vahvistuskoodi on lähetetty sähköpostiisi." 1298 }, 1299 - "finalizing": { 1300 - "title": "Viimeistellään siirtoa", 1301 - "desc": "Odota, kun viimeistelemme siirtoa...", 1302 - "updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..." 1303 }, 1304 "success": { 1305 - "title": "Siirto valmis!", 1306 - "desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.", 1307 - "newHandle": "Uusi käyttäjätunnus", 1308 - "newPds": "Uusi PDS", 1309 - "nextSteps": "Seuraavat vaiheet", 1310 - "nextSteps1": "Kirjaudu uuteen PDS:ääsi", 1311 - "nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi", 1312 - "nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi", 1313 - "loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..." 1314 } 1315 }, 1316 "progress": {
··· 17 "dashboard": "Hallintapaneeli", 18 "backToDashboard": "← Hallintapaneeli", 19 "copied": "Kopioitu!", 20 + "copyToClipboard": "Kopioi", 21 + 22 + "verifying": "Vahvistetaan...", 23 + "saving": "Tallennetaan...", 24 + "creating": "Luodaan...", 25 + "updating": "Päivitetään...", 26 + "sending": "Lähetetään...", 27 + "authenticating": "Todennetaan...", 28 + "checking": "Tarkistetaan...", 29 + "redirecting": "Ohjataan...", 30 + 31 + "signIn": "Kirjaudu sisään", 32 + "verify": "Vahvista", 33 + "remove": "Poista", 34 + "revoke": "Peruuta", 35 + "resendCode": "Lähetä koodi uudelleen", 36 + "startOver": "Aloita alusta", 37 + "tryAgain": "Yritä uudelleen", 38 + 39 + "password": "Salasana", 40 + "email": "Sähköposti", 41 + "emailAddress": "Sähköpostiosoite", 42 + "handle": "Käsittely", 43 + "did": "DID", 44 + "verificationCode": "Vahvistuskoodi", 45 + "inviteCode": "Kutsukoodi", 46 + "newPassword": "Uusi salasana", 47 + "confirmPassword": "Vahvista salasana", 48 + 49 + "enterSixDigitCode": "Syötä 6-numeroinen koodi", 50 + "passwordHint": "Vähintään 8 merkkiä", 51 + "enterPassword": "Syötä salasanasi", 52 + "emailPlaceholder": "sinä@esimerkki.com", 53 + 54 + "verified": "Vahvistettu", 55 + "disabled": "Poistettu käytöstä", 56 + "available": "Saatavilla", 57 + "deactivated": "Deaktivoitu", 58 + "unverified": "Vahvistamaton", 59 + 60 + "backToLogin": "Takaisin kirjautumiseen", 61 + "backToSettings": "Takaisin asetuksiin", 62 + "alreadyHaveAccount": "Onko sinulla jo tili?", 63 + "createAccount": "Luo tili", 64 + 65 + "passwordsMismatch": "Salasanat eivät täsmää", 66 + "passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä" 67 }, 68 "login": { 69 "title": "Kirjaudu sisään", ··· 95 "codeLabel": "Vahvistuskoodi", 96 "codePlaceholder": "Syötä 6-numeroinen koodi", 97 "verifyButton": "Vahvista tili", 98 + "resent": "Vahvistuskoodi lähetetty uudelleen!" 99 }, 100 "register": { 101 "title": "Luo tili", ··· 166 "inviteCodePlaceholder": "Syötä kutsukoodisi", 167 "inviteCodeRequired": "vaaditaan", 168 "createButton": "Luo tili", 169 "alreadyHaveAccount": "Onko sinulla jo tili?", 170 "signIn": "Kirjaudu sisään", 171 "wantPasswordless": "Haluatko salasanattoman turvallisuuden?", ··· 220 "navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot", 221 "navDidDocument": "DID-dokumentti", 222 "navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten", 223 + "navDidDocumentDescActive": "Muokkaa DID-dokumentin asetuksia", 224 + "navBackup": "Lataa varmuuskopio", 225 + "navBackupDesc": "Lataa tietovarastosi CAR-tiedostona", 226 + "downloadingBackup": "Ladataan...", 227 + "backupFailed": "Varmuuskopion lataus epäonnistui", 228 "migrated": "Siirretty", 229 "migratedTitle": "Tili siirretty", 230 "migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.", ··· 254 "serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.", 255 "currentPds": "Nykyinen PDS-URL", 256 "save": "Tallenna muutokset", 257 "success": "DID-dokumentti päivitetty onnistuneesti", 258 "saveFailed": "DID-dokumentin tallennus epäonnistui", 259 "loadFailed": "DID-dokumentin lataus epäonnistui", ··· 291 "yourDomain": "Verkkotunnuksesi", 292 "yourDomainPlaceholder": "esimerkki.fi", 293 "verifyAndUpdate": "Vahvista ja päivitä käyttäjänimi", 294 "newHandle": "Uusi käyttäjänimi", 295 "newHandlePlaceholder": "käyttäjänimesi", 296 "changeHandleButton": "Vaihda käyttäjänimi", ··· 306 "exportData": "Vie tiedot", 307 "exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.", 308 "downloadRepo": "Lataa tietovarasto", 309 + "downloadBlobs": "Lataa media", 310 "exporting": "Viedään...", 311 + "backups": { 312 + "title": "Varmuuskopiot", 313 + "description": "Tietovarastosi varmuuskopioidaan automaattisesti päivittäin. Voit myös luoda manuaalisia varmuuskopioita tai palauttaa aiemmasta varmuuskopiosta.", 314 + "enableAutomatic": "Ota automaattiset varmuuskopiot käyttöön", 315 + "enabled": "Automaattiset varmuuskopiot käytössä", 316 + "disabled": "Automaattiset varmuuskopiot pois käytöstä", 317 + "toggleFailed": "Varmuuskopioasetuksen päivitys epäonnistui", 318 + "noBackups": "Varmuuskopioita ei ole vielä saatavilla.", 319 + "blocks": "lohkoa", 320 + "download": "Lataa", 321 + "delete": "Poista", 322 + "createNow": "Luo varmuuskopio nyt", 323 + "created": "Varmuuskopio luotu onnistuneesti", 324 + "createFailed": "Varmuuskopion luonti epäonnistui", 325 + "downloadFailed": "Varmuuskopion lataus epäonnistui", 326 + "deleted": "Varmuuskopio poistettu", 327 + "deleteFailed": "Varmuuskopion poisto epäonnistui", 328 + "restoreTitle": "Palauta varmuuskopiosta", 329 + "restoreDescription": "Lataa CAR-tiedosto palauttaaksesi tietovarastosi. Tämä korvaa nykyiset tietosi.", 330 + "selectFile": "Valitse CAR-tiedosto", 331 + "selectedFile": "Valittu tiedosto", 332 + "restore": "Palauta", 333 + "restoring": "Palautetaan...", 334 + "restored": "Tietovarasto palautettu onnistuneesti", 335 + "restoreFailed": "Tietovaraston palautus epäonnistui" 336 + }, 337 "deleteAccount": "Poista tili", 338 "deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.", 339 "requestDeletion": "Pyydä tilin poistoa", ··· 362 "deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.", 363 "deletionFailed": "Tilin poisto epäonnistui", 364 "repoExported": "Tietovarasto viety", 365 + "blobsExported": "Mediatiedostot viety", 366 + "noBlobsToExport": "Ei vietäviä mediatiedostoja", 367 + "exportFailed": "Vienti epäonnistui", 368 "confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua." 369 } 370 }, ··· 379 "noPasswords": "Ei vielä sovellusten salasanoja", 380 "revoke": "Peruuta", 381 "revoking": "Peruutetaan...", 382 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.", 383 "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!", 384 "saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.", ··· 426 "used": "Käyttänyt @{handle}", 427 "disabled": "Poistettu käytöstä", 428 "usedBy": "Käyttänyt", 429 "disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.", 430 "created": "Kutsukoodi luotu", 431 "copy": "Kopioi", ··· 553 "verifyButton": "Vahvista", 554 "verifyCodePlaceholder": "Syötä vahvistuskoodi", 555 "submit": "Lähetä", 556 "savePreferences": "Tallenna asetukset", 557 "preferencesSaved": "Viestintäasetukset tallennettu", 558 "verifiedSuccess": "{channel} vahvistettu", ··· 591 "noCollectionsYet": "Ei vielä kokoelmia. Luo ensimmäinen tietueesi aloittaaksesi.", 592 "loadMore": "Lataa lisää", 593 "recordJson": "Tietueen JSON", 594 "updateRecord": "Päivitä tietue", 595 "collectionNsid": "Kokoelma (NSID)", 596 "recordKeyOptional": "Tietueavain (valinnainen)", 597 "autoGenerated": "Luodaan automaattisesti jos tyhjä (TID)", 598 "autoGeneratedHint": "Jätä tyhjäksi luodaksesi TID-pohjaisen avaimen automaattisesti", 599 "demoPostText": "Hei PDS:ltäni! Tämä on ensimmäinen julkaisuni.", 600 "demoDisplayName": "Näyttönimesi", 601 "demoBio": "Lyhyt kuvaus itsestäsi." ··· 616 "primaryLight": "Ensisijainen (vaalea tila)", 617 "primaryDark": "Ensisijainen (tumma tila)", 618 "configSaved": "Palvelinasetukset tallennettu", 619 "saveConfig": "Tallenna asetukset", 620 "serverStats": "Palvelintilastot", 621 "users": "Käyttäjät", ··· 706 "title": "Kaksivaiheinen tunnistautuminen", 707 "subtitle": "Lisävahvistus vaaditaan", 708 "usePasskey": "Käytä pääsyavainta", 709 + "useTotp": "Käytä todentajasovellusta" 710 }, 711 "twoFactorCode": { 712 "title": "Kaksivaiheinen tunnistautuminen", 713 "subtitle": "Vahvistuskoodi on lähetetty {channel}. Syötä koodi alla jatkaaksesi.", 714 "codeLabel": "Vahvistuskoodi", 715 "codePlaceholder": "Syötä 6-numeroinen koodi", 716 "errors": { 717 "missingRequestUri": "Puuttuva request_uri-parametri", 718 "verificationFailed": "Vahvistus epäonnistui", ··· 724 "title": "Syötä todentajakoodi", 725 "subtitle": "Syötä 6-numeroinen koodi todentajasovelluksestasi", 726 "codePlaceholder": "Syötä 6-numeroinen koodi", 727 "useBackupCode": "Käytä varakoodia sen sijaan", 728 "backupCodePlaceholder": "Syötä varakoodi", 729 "trustDevice": "Luota tähän laitteeseen 30 päivää", ··· 753 "codeLabel": "Vahvistuskoodi", 754 "codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat", 755 "verifyButton": "Vahvista tili", 756 "pleaseWait": "Odota...", 757 "codeResent": "Vahvistuskoodi lähetetty uudelleen!", 758 "codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.", 759 "verified": "Vahvistettu!", ··· 763 "identifierLabel": "Sähköposti tai tunniste", 764 "identifierPlaceholder": "sinä@esimerkki.fi", 765 "identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin", 766 "verifyingAccount": "Vahvistetaan tiliä: @{handle}", 767 "startOver": "Aloita alusta toisella tilillä", 768 "noPending": "Odottavaa vahvistusta ei löytynyt.", 769 "noPendingInfo": "Jos loit tilin äskettäin ja sinun on vahvistettava se, sinun on ehkä luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisään.", 770 "createAccount": "Luo tili", 771 "signIn": "Kirjaudu sisään", 772 "emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi", 773 "emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui", 774 "emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.", ··· 801 "resetButton": "Palauta salasana", 802 "resetting": "Palautetaan...", 803 "success": "Salasana palautettu!", 804 "requestNewCode": "Pyydä uusi koodi", 805 "passwordsMismatch": "Salasanat eivät täsmää", 806 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä" ··· 844 "howItWorks": "Miten se toimii", 845 "howItWorksDetail": "Lähetämme suojatun linkin rekisteröityyn ilmoituskanavaasi. Klikkaa linkkiä asettaaksesi väliaikaisen salasanan. Sitten voit kirjautua sisään ja lisätä uuden pääsyavaimen.", 846 "sendRecoveryLink": "Lähetä palautuslinkki", 847 + "sending": "Lähetetään..." 848 }, 849 "registerPasskey": { 850 "title": "Luo pääsyavaintili", ··· 865 "externalDid": "Sinun did:web", 866 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 867 "createButton": "Luo tili", 868 "alreadyHaveAccount": "Onko sinulla jo tili?", 869 "signIn": "Kirjaudu sisään", 870 "wantPassword": "Haluatko käyttää salasanaa?", ··· 963 "useTotp": "Käytä todentajaa", 964 "passwordPlaceholder": "Syötä salasanasi", 965 "totpPlaceholder": "Syötä 6-numeroinen koodi", 966 "authenticating": "Todennetaan...", 967 "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.", 968 "cancel": "Peruuta" ··· 1017 "handle": "Käyttäjänimi", 1018 "emailOptional": "Sähköposti (valinnainen)", 1019 "yourAccessLevel": "Käyttöoikeustasosi", 1020 "createAccount": "Luo tili", 1021 "createDelegatedAccountButton": "+ Luo delegoitu tili", 1022 "accountCreated": "Delegoitu tili luotu: {handle}", ··· 1108 "navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä", 1109 "migrateHere": "Siirrä tänne", 1110 "migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.", 1111 "bringDid": "Tuo DID ja identiteettisi", 1112 "transferData": "Siirrä kaikki tietosi", 1113 "keepFollowers": "Säilytä seuraajasi", 1114 "whatIsMigration": "Mikä on tilin siirto?", 1115 "whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.", 1116 "beforeMigrate": "Ennen siirtoa", ··· 1120 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1121 "importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.", 1122 "learnMore": "Lue lisää siirron riskeistä", 1123 + "offlineRestore": "Offline-palautus", 1124 + "offlineRestoreDesc": "Palauta varmuuskopiosta, kun vanha PDS ei ole käytettävissä.", 1125 + "offlineFeature1": "Käytä CAR-tiedoston varmuuskopiota", 1126 + "offlineFeature2": "Todista omistajuus rotaatioavaimella", 1127 + "offlineFeature3": "Palautus suljetuille palvelimille", 1128 "oauthCompleting": "Viimeistellään todennusta...", 1129 "oauthFailed": "Todennus epäonnistui", 1130 "tryAgain": "Yritä uudelleen", ··· 1133 "incomplete": "Sinulla on keskeneräinen siirto:", 1134 "direction": "Suunta", 1135 "migratingHere": "Siirretään tänne", 1136 "from": "Mistä", 1137 "to": "Minne", 1138 "progress": "Edistyminen", ··· 1275 "error": { 1276 "title": "Siirtovirhe", 1277 "desc": "Siirron aikana tapahtui virhe.", 1278 + "startOver": "Aloita alusta", 1279 + "unknown": "Tuntematon virhe tapahtui." 1280 }, 1281 "common": { 1282 "back": "Takaisin", ··· 1294 "warning3": "Vanha tilisi deaktivoidaan siirron jälkeen" 1295 } 1296 }, 1297 + "offline": { 1298 "welcome": { 1299 + "title": "Palauta varmuuskopiosta", 1300 + "desc": "Palauta tilisi CAR-tiedoston varmuuskopiolla ja rotaatioavaimella. Käytä tätä, kun edellinen PDS ei ole käytettävissä.", 1301 + "warningTitle": "Milloin käyttää tätä menetelmää", 1302 + "warningDesc": "Tämä offline-palautus on katastrofipalautukseen, kun vanha PDS on suljettu, tavoittamattomissa tai sinut on lukittu ulos. Jos vanha PDS on edelleen käytettävissä, käytä normaalia siirtoa.", 1303 + "requirementsTitle": "Tarvitset", 1304 + "requirement1": "CAR-tiedoston varmuuskopion tietovarastostasi", 1305 + "requirement2": "Rotaatioavaimesi (DID:n yksityinen avain)", 1306 + "requirement3": "DID:si (did:plc:xxx)", 1307 + "understand": "Ymmärrän ja haluan jatkaa" 1308 }, 1309 + "provideDid": { 1310 + "title": "Syötä DID:si", 1311 + "desc": "Syötä palautettavan tilin DID.", 1312 + "label": "DID:si", 1313 + "hint": "Hajautettu tunnistesi (esim. did:plc:abc123)" 1314 }, 1315 + "uploadCar": { 1316 + "title": "Lataa CAR-tiedosto", 1317 + "desc": "Lataa tietovaraston varmuuskopiotiedostosi.", 1318 + "label": "CAR-tiedosto", 1319 + "hint": "Valitse .car-tiedosto varmuuskopiostasi", 1320 + "reuploadWarningTitle": "CAR-tiedosto vaaditaan", 1321 + "reuploadWarning": "Istuntosi palautettiin, mutta sinun täytyy ladata CAR-tiedostosi uudelleen. Turvallisuussyistä tiedostosisältöä ei tallenneta istuntojen välillä." 1322 }, 1323 + "rotationKey": { 1324 + "title": "Anna rotaatioavain", 1325 + "desc": "Anna rotaatioavaimesi todistaaksesi tämän DID:n omistajuuden.", 1326 + "securityWarningTitle": "Turvallisuusvaroitus", 1327 + "securityWarning1": "Rotaatioavaimesi on erittäin arkaluonteinen - kohtele sitä kuten pääsalasanaa", 1328 + "securityWarning2": "Syötä se vain luotetuilla laitteilla ja verkoilla", 1329 + "securityWarning3": "Tätä avainta ei tallenneta siirron jälkeen", 1330 + "label": "Rotaatioavain", 1331 + "placeholder": "Syötä yksityinen avain (hex, base58 tai JWK)", 1332 + "hint": "Yksityinen avain, joka vastaa yhtä DID-dokumentin rotaatioavaimista", 1333 + "valid": "Avain on kelvollinen ja vastaa DID:si rotaatioavainta", 1334 + "invalid": "Avain ei vastaa mitään DID-dokumentin rotaatioavainta", 1335 + "validating": "Vahvistetaan avainta...", 1336 + "validate": "Vahvista avain" 1337 }, 1338 + "chooseHandle": { 1339 + "migratingDid": "Palautetaan DID" 1340 }, 1341 + "review": { 1342 + "desc": "Tarkista offline-palautuksen tiedot.", 1343 + "carFile": "CAR-tiedosto", 1344 + "rotationKey": "Rotaatioavain", 1345 + "warning": "Kun aloitat palautuksen, identiteettisi päivitetään osoittamaan tähän PDS:ään. Tätä ei voi helposti perua.", 1346 + "plcWarningTitle": "Ei paluuta", 1347 + "plcWarning": "Kun aloitat, DID-dokumenttisi päivitetään osoittamaan tähän PDS:ään. Jos jokin menee pieleen, voit käyttää rotaatioavaintasi palautumiseen, mutta sinun tulisi suorittaa siirto loppuun välttääksesi rikkinäisen identiteettitilan." 1348 }, 1349 + "migrating": { 1350 + "title": "Palautetaan tiliä", 1351 + "desc": "Odota, tiliäsi palautetaan...", 1352 + "creating": "Luodaan tili", 1353 + "importing": "Tuodaan tietovarastoa", 1354 + "plcSigning": "Päivitetään identiteettiä", 1355 + "activating": "Aktivoidaan tili" 1356 }, 1357 "success": { 1358 + "desc": "Tilisi on palautettu onnistuneesti tähän PDS:ään." 1359 + }, 1360 + "blobs": { 1361 + "title": "Siirretään blob-tiedostoja", 1362 + "desc": "Yritetään palauttaa kuvia ja mediaa vanhasta PDS:stäsi...", 1363 + "migrating": "Siirretään blob-tiedostoja", 1364 + "failedTitle": "Joitain blob-tiedostoja ei voitu siirtää", 1365 + "failedDesc": "{count} blob-tiedostoa ei voitu hakea vanhasta PDS:stäsi. Tämä voi tapahtua, jos palvelin ei ole tavoitettavissa tai tiedostot on poistettu.", 1366 + "sourceUnreachableTitle": "Lähde-PDS ei tavoitettavissa", 1367 + "sourceUnreachable": "Ei voitu yhdistää vanhaan PDS:ääsi mediatiedostojen hakemiseksi. Tämä on yleistä siirrettäessä suljetulta palvelimelta. Julkaisusi toimivat, mutta joitain kuvia saattaa puuttua." 1368 } 1369 }, 1370 "progress": {
+147 -100
frontend/src/locales/ja.json
··· 17 "dashboard": "ダッシュボード", 18 "backToDashboard": "← ダッシュボード", 19 "copied": "コピーしました!", 20 - "copyToClipboard": "クリップボードにコピー" 21 }, 22 "login": { 23 "title": "サインイン", ··· 49 "codeLabel": "確認コード", 50 "codePlaceholder": "6桁のコードを入力", 51 "verifyButton": "確認する", 52 - "verifying": "確認中...", 53 - "resendButton": "コードを再送信", 54 - "resending": "送信中...", 55 - "resent": "確認コードを再送信しました!", 56 - "backToLogin": "ログインに戻る" 57 }, 58 "register": { 59 "title": "アカウント作成", ··· 124 "inviteCodePlaceholder": "招待コードを入力", 125 "inviteCodeRequired": "必須", 126 "createButton": "アカウントを作成", 127 - "creating": "作成中...", 128 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 129 "signIn": "サインイン", 130 "wantPasswordless": "パスワードレスをご希望ですか?", ··· 179 "navAdminDesc": "サーバー統計と管理操作", 180 "navDidDocument": "DID ドキュメント", 181 "navDidDocumentDesc": "DID ドキュメントとキーを管理", 182 "migrated": "移行済み", 183 "migratedTitle": "アカウント移行済み", 184 "migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。", ··· 208 "serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。", 209 "currentPds": "現在のPDS URL", 210 "save": "変更を保存", 211 - "saving": "保存中...", 212 "success": "DID ドキュメントを更新しました", 213 "saveFailed": "DIDドキュメントの保存に失敗しました", 214 "loadFailed": "DIDドキュメントの読み込みに失敗しました", ··· 246 "yourDomain": "ドメイン", 247 "yourDomainPlaceholder": "example.com", 248 "verifyAndUpdate": "確認してハンドルを更新", 249 - "verifying": "確認中...", 250 "newHandle": "新しいハンドル", 251 "newHandlePlaceholder": "yourhandle", 252 "changeHandleButton": "ハンドルを変更", ··· 262 "exportData": "データエクスポート", 263 "exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。", 264 "downloadRepo": "リポジトリをダウンロード", 265 "exporting": "エクスポート中...", 266 "deleteAccount": "アカウント削除", 267 "deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。", 268 "requestDeletion": "アカウント削除をリクエスト", ··· 291 "deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。", 292 "deletionFailed": "アカウントの削除に失敗しました", 293 "repoExported": "リポジトリをエクスポートしました", 294 - "exportFailed": "リポジトリのエクスポートに失敗しました", 295 "confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。" 296 } 297 }, ··· 306 "noPasswords": "アプリパスワードはまだありません", 307 "revoke": "取り消す", 308 "revoking": "取り消し中...", 309 - "creating": "作成中...", 310 "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", 311 "saveWarningTitle": "重要: このアプリパスワードを保存してください!", 312 "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", ··· 354 "used": "@{handle} が使用済み", 355 "disabled": "無効", 356 "usedBy": "使用者", 357 - "creating": "作成中...", 358 "disableConfirm": "この招待コードを無効にしますか?使用できなくなります。", 359 "created": "招待コードを作成しました", 360 "copy": "コピー", ··· 482 "verifyButton": "確認", 483 "verifyCodePlaceholder": "確認コードを入力", 484 "submit": "送信", 485 - "saving": "保存中...", 486 "savePreferences": "設定を保存", 487 "preferencesSaved": "連絡設定を保存しました", 488 "verifiedSuccess": "{channel} を確認しました", ··· 521 "noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。", 522 "loadMore": "さらに読み込む", 523 "recordJson": "レコード JSON", 524 - "saving": "保存中...", 525 "updateRecord": "レコードを更新", 526 "collectionNsid": "コレクション (NSID)", 527 "recordKeyOptional": "レコードキー(任意)", 528 "autoGenerated": "空白で自動生成 (TID)", 529 "autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます", 530 - "creating": "作成中...", 531 "demoPostText": "こんにちは、私の PDS からの初投稿です!", 532 "demoDisplayName": "表示名", 533 "demoBio": "自己紹介を書いてください。" ··· 548 "primaryLight": "プライマリ(ライトモード)", 549 "primaryDark": "プライマリ(ダークモード)", 550 "configSaved": "サーバー設定を保存しました", 551 - "saving": "保存中...", 552 "saveConfig": "設定を保存", 553 "serverStats": "サーバー統計", 554 "users": "ユーザー", ··· 639 "title": "二要素認証", 640 "subtitle": "追加の確認が必要です", 641 "usePasskey": "パスキーを使用", 642 - "useTotp": "認証アプリを使用", 643 - "verifying": "確認中..." 644 }, 645 "twoFactorCode": { 646 "title": "二要素認証", 647 "subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。", 648 "codeLabel": "確認コード", 649 "codePlaceholder": "6桁のコードを入力", 650 - "verify": "確認", 651 - "verifying": "確認中...", 652 "errors": { 653 "missingRequestUri": "request_uri パラメータがありません", 654 "verificationFailed": "確認に失敗しました", ··· 660 "title": "認証コードを入力", 661 "subtitle": "認証アプリの6桁のコードを入力", 662 "codePlaceholder": "6桁のコードを入力", 663 - "verify": "確認", 664 - "verifying": "確認中...", 665 "useBackupCode": "バックアップコードを使用", 666 "backupCodePlaceholder": "バックアップコードを入力", 667 "trustDevice": "このデバイスを30日間信頼する", ··· 691 "codeLabel": "確認コード", 692 "codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください", 693 "verifyButton": "アカウントを確認", 694 - "verify": "確認", 695 - "verifying": "確認中...", 696 "pleaseWait": "お待ちください...", 697 - "sending": "送信中...", 698 - "resendCode": "コードを再送信", 699 - "resending": "送信中...", 700 "codeResent": "確認コードを再送信しました!", 701 "codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。", 702 "verified": "確認完了!", ··· 706 "identifierLabel": "メールまたは識別子", 707 "identifierPlaceholder": "you@example.com", 708 "identifierHelp": "コードが送信されたメールアドレスまたは識別子", 709 - "backToLogin": "ログインに戻る", 710 "verifyingAccount": "確認中のアカウント: @{handle}", 711 "startOver": "別のアカウントでやり直す", 712 "noPending": "保留中の確認が見つかりません。", 713 "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", 714 "createAccount": "アカウントを作成", 715 "signIn": "サインイン", 716 - "backToSettings": "設定に戻る", 717 "emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました", 718 "emailUpdateFailed": "メールアドレスの更新に失敗しました", 719 "emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。", ··· 746 "resetButton": "パスワードをリセット", 747 "resetting": "リセット中...", 748 "success": "パスワードをリセットしました!", 749 - "backToLogin": "サインインに戻る", 750 "requestNewCode": "新しいコードをリクエスト", 751 "passwordsMismatch": "パスワードが一致しません", 752 "passwordLength": "パスワードは8文字以上である必要があります" ··· 790 "howItWorks": "仕組み", 791 "howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。", 792 "sendRecoveryLink": "復旧リンクを送信", 793 - "sending": "送信中...", 794 - "backToLogin": "サインインに戻る" 795 }, 796 "registerPasskey": { 797 "title": "パスキーアカウントを作成", ··· 812 "externalDid": "あなたの did:web", 813 "externalDidPlaceholder": "did:web:yourdomain.com", 814 "createButton": "アカウントを作成", 815 - "creating": "作成中...", 816 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 817 "signIn": "サインイン", 818 "wantPassword": "パスワードを使用しますか?", ··· 911 "useTotp": "認証アプリを使用", 912 "passwordPlaceholder": "パスワードを入力", 913 "totpPlaceholder": "6桁のコードを入力", 914 - "verify": "確認", 915 - "verifying": "確認中...", 916 "authenticating": "認証中...", 917 "passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。", 918 "cancel": "キャンセル" ··· 985 "createAccount": "アカウントを作成", 986 "createDelegatedAccount": "委任アカウントを作成", 987 "createDelegatedAccountButton": "+ 委任アカウントを作成", 988 - "creating": "作成中...", 989 "emailOptional": "メール(任意)", 990 "failedToAddController": "コントローラーの追加に失敗しました", 991 "failedToCreateAccount": "委任アカウントの作成に失敗しました", ··· 1059 "navDesc": "別のPDSへ、または別のPDSからアカウントを移動", 1060 "migrateHere": "ここに移行", 1061 "migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。", 1062 - "migrateAway": "別の場所に移行", 1063 - "migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。", 1064 - "loginRequired": "ログインが必要です", 1065 "bringDid": "DIDとアイデンティティを持ち込む", 1066 "transferData": "すべてのデータを転送", 1067 "keepFollowers": "フォロワーを維持", 1068 - "exportRepo": "リポジトリをエクスポート", 1069 - "transferToPds": "新しいPDSに転送", 1070 - "updateIdentity": "アイデンティティを更新", 1071 "whatIsMigration": "アカウント移行とは?", 1072 "whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。", 1073 "beforeMigrate": "移行前の確認事項", ··· 1077 "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", 1078 "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", 1079 "learnMore": "移行のリスクについて詳しく", 1080 - "comingSoon": "近日公開", 1081 "oauthCompleting": "認証を完了しています...", 1082 "oauthFailed": "認証に失敗しました", 1083 "tryAgain": "再試行", ··· 1086 "incomplete": "未完了の移行があります:", 1087 "direction": "方向", 1088 "migratingHere": "ここに移行中", 1089 - "migratingAway": "別の場所に移行中", 1090 "from": "移行元", 1091 "to": "移行先", 1092 "progress": "進行状況", ··· 1229 "error": { 1230 "title": "移行エラー", 1231 "desc": "移行中にエラーが発生しました。", 1232 - "startOver": "最初からやり直す" 1233 }, 1234 "common": { 1235 "back": "戻る", ··· 1247 "warning3": "移行後、古いアカウントは無効化されます" 1248 } 1249 }, 1250 - "outbound": { 1251 "welcome": { 1252 - "title": "このPDSから移行", 1253 - "desc": "アカウントを別のパーソナルデータサーバーに移動します。", 1254 - "warning": "移行後、ここでのアカウントは無効化されます。", 1255 - "didWebNotice": "did:web移行のお知らせ", 1256 - "didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。", 1257 - "understand": "リスクを理解し、続行します" 1258 }, 1259 - "targetPds": { 1260 - "title": "移行先PDSを選択", 1261 - "desc": "移行先のPDSのURLを入力してください。", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "検証して続行", 1265 - "validating": "検証中...", 1266 - "connected": "{name}に接続しました", 1267 - "inviteRequired": "招待コードが必要です", 1268 - "privacyPolicy": "プライバシーポリシー", 1269 - "termsOfService": "利用規約" 1270 }, 1271 - "newAccount": { 1272 - "title": "新しいアカウントの詳細", 1273 - "desc": "新しいPDSでアカウントを設定します。", 1274 - "handle": "ハンドル", 1275 - "availableDomains": "利用可能なドメイン", 1276 - "email": "メール", 1277 - "password": "パスワード", 1278 - "confirmPassword": "パスワードを確認", 1279 - "inviteCode": "招待コード" 1280 }, 1281 - "review": { 1282 - "title": "移行の確認", 1283 - "desc": "移行の詳細を確認してください。", 1284 - "currentHandle": "現在のハンドル", 1285 - "newHandle": "新しいハンドル", 1286 - "sourcePds": "このPDS", 1287 - "targetPds": "移行先PDS", 1288 - "confirm": "アカウントを移行することを確認します", 1289 - "startMigration": "移行を開始" 1290 }, 1291 - "migrating": { 1292 - "title": "アカウントを移行中", 1293 - "desc": "データを転送しています..." 1294 }, 1295 - "plcToken": { 1296 - "title": "本人確認", 1297 - "desc": "確認コードがメールに送信されました。" 1298 }, 1299 - "finalizing": { 1300 - "title": "移行を完了中", 1301 - "desc": "移行を完了しています...", 1302 - "updatingForwarding": "DIDドキュメントの転送先を更新中..." 1303 }, 1304 "success": { 1305 - "title": "移行完了!", 1306 - "desc": "アカウントは新しいPDSに正常に移行されました。", 1307 - "newHandle": "新しいハンドル", 1308 - "newPds": "新しいPDS", 1309 - "nextSteps": "次のステップ", 1310 - "nextSteps1": "新しいPDSにサインイン", 1311 - "nextSteps2": "アプリの認証情報を更新", 1312 - "nextSteps3": "フォロワーは自動的に新しい場所を確認できます", 1313 - "loggingOut": "{seconds}秒後にログアウトします..." 1314 } 1315 }, 1316 "progress": {
··· 17 "dashboard": "ダッシュボード", 18 "backToDashboard": "← ダッシュボード", 19 "copied": "コピーしました!", 20 + "copyToClipboard": "クリップボードにコピー", 21 + "verifying": "確認中...", 22 + "saving": "保存中...", 23 + "creating": "作成中...", 24 + "updating": "更新中...", 25 + "sending": "送信中...", 26 + "authenticating": "認証中...", 27 + "checking": "確認中...", 28 + "redirecting": "リダイレクト中...", 29 + "signIn": "サインイン", 30 + "verify": "確認", 31 + "remove": "削除", 32 + "revoke": "取り消し", 33 + "resendCode": "コードを再送信", 34 + "startOver": "最初からやり直す", 35 + "tryAgain": "再試行", 36 + "password": "パスワード", 37 + "email": "メール", 38 + "emailAddress": "メールアドレス", 39 + "handle": "ハンドル", 40 + "did": "DID", 41 + "verificationCode": "確認コード", 42 + "inviteCode": "招待コード", 43 + "newPassword": "新しいパスワード", 44 + "confirmPassword": "パスワードを確認", 45 + "enterSixDigitCode": "6桁のコードを入力", 46 + "passwordHint": "8文字以上", 47 + "enterPassword": "パスワードを入力", 48 + "emailPlaceholder": "you@example.com", 49 + "verified": "確認済み", 50 + "disabled": "無効", 51 + "available": "利用可能", 52 + "deactivated": "非アクティブ", 53 + "unverified": "未確認", 54 + "backToLogin": "ログインに戻る", 55 + "backToSettings": "設定に戻る", 56 + "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 57 + "createAccount": "アカウントを作成", 58 + "passwordsMismatch": "パスワードが一致しません", 59 + "passwordTooShort": "パスワードは8文字以上必要です" 60 }, 61 "login": { 62 "title": "サインイン", ··· 88 "codeLabel": "確認コード", 89 "codePlaceholder": "6桁のコードを入力", 90 "verifyButton": "確認する", 91 + "resent": "確認コードを再送信しました!" 92 }, 93 "register": { 94 "title": "アカウント作成", ··· 159 "inviteCodePlaceholder": "招待コードを入力", 160 "inviteCodeRequired": "必須", 161 "createButton": "アカウントを作成", 162 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 163 "signIn": "サインイン", 164 "wantPasswordless": "パスワードレスをご希望ですか?", ··· 213 "navAdminDesc": "サーバー統計と管理操作", 214 "navDidDocument": "DID ドキュメント", 215 "navDidDocumentDesc": "DID ドキュメントとキーを管理", 216 + "navDidDocumentDescActive": "DID ドキュメント設定を編集", 217 + "navBackup": "バックアップをダウンロード", 218 + "navBackupDesc": "リポジトリを CAR ファイルとしてダウンロード", 219 + "downloadingBackup": "ダウンロード中...", 220 + "backupFailed": "バックアップのダウンロードに失敗しました", 221 "migrated": "移行済み", 222 "migratedTitle": "アカウント移行済み", 223 "migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。", ··· 247 "serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。", 248 "currentPds": "現在のPDS URL", 249 "save": "変更を保存", 250 "success": "DID ドキュメントを更新しました", 251 "saveFailed": "DIDドキュメントの保存に失敗しました", 252 "loadFailed": "DIDドキュメントの読み込みに失敗しました", ··· 284 "yourDomain": "ドメイン", 285 "yourDomainPlaceholder": "example.com", 286 "verifyAndUpdate": "確認してハンドルを更新", 287 "newHandle": "新しいハンドル", 288 "newHandlePlaceholder": "yourhandle", 289 "changeHandleButton": "ハンドルを変更", ··· 299 "exportData": "データエクスポート", 300 "exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。", 301 "downloadRepo": "リポジトリをダウンロード", 302 + "downloadBlobs": "メディアをダウンロード", 303 "exporting": "エクスポート中...", 304 + "backups": { 305 + "title": "バックアップ", 306 + "description": "リポジトリは毎日自動的にバックアップされます。手動でバックアップを作成したり、以前のバックアップから復元することもできます。", 307 + "enableAutomatic": "自動バックアップを有効にする", 308 + "enabled": "自動バックアップが有効です", 309 + "disabled": "自動バックアップが無効です", 310 + "toggleFailed": "バックアップ設定の更新に失敗しました", 311 + "noBackups": "バックアップはまだありません。", 312 + "blocks": "ブロック", 313 + "download": "ダウンロード", 314 + "delete": "削除", 315 + "createNow": "今すぐバックアップを作成", 316 + "created": "バックアップが正常に作成されました", 317 + "createFailed": "バックアップの作成に失敗しました", 318 + "downloadFailed": "バックアップのダウンロードに失敗しました", 319 + "deleted": "バックアップが削除されました", 320 + "deleteFailed": "バックアップの削除に失敗しました", 321 + "restoreTitle": "バックアップから復元", 322 + "restoreDescription": "CARファイルをアップロードしてリポジトリを復元します。現在のデータは上書きされます。", 323 + "selectFile": "CARファイルを選択", 324 + "selectedFile": "選択されたファイル", 325 + "restore": "復元", 326 + "restoring": "復元中...", 327 + "restored": "リポジトリが正常に復元されました", 328 + "restoreFailed": "リポジトリの復元に失敗しました" 329 + }, 330 "deleteAccount": "アカウント削除", 331 "deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。", 332 "requestDeletion": "アカウント削除をリクエスト", ··· 355 "deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。", 356 "deletionFailed": "アカウントの削除に失敗しました", 357 "repoExported": "リポジトリをエクスポートしました", 358 + "blobsExported": "メディアファイルをエクスポートしました", 359 + "noBlobsToExport": "エクスポートするメディアファイルがありません", 360 + "exportFailed": "エクスポートに失敗しました", 361 "confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。" 362 } 363 }, ··· 372 "noPasswords": "アプリパスワードはまだありません", 373 "revoke": "取り消す", 374 "revoking": "取り消し中...", 375 "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", 376 "saveWarningTitle": "重要: このアプリパスワードを保存してください!", 377 "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", ··· 419 "used": "@{handle} が使用済み", 420 "disabled": "無効", 421 "usedBy": "使用者", 422 "disableConfirm": "この招待コードを無効にしますか?使用できなくなります。", 423 "created": "招待コードを作成しました", 424 "copy": "コピー", ··· 546 "verifyButton": "確認", 547 "verifyCodePlaceholder": "確認コードを入力", 548 "submit": "送信", 549 "savePreferences": "設定を保存", 550 "preferencesSaved": "連絡設定を保存しました", 551 "verifiedSuccess": "{channel} を確認しました", ··· 584 "noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。", 585 "loadMore": "さらに読み込む", 586 "recordJson": "レコード JSON", 587 "updateRecord": "レコードを更新", 588 "collectionNsid": "コレクション (NSID)", 589 "recordKeyOptional": "レコードキー(任意)", 590 "autoGenerated": "空白で自動生成 (TID)", 591 "autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます", 592 "demoPostText": "こんにちは、私の PDS からの初投稿です!", 593 "demoDisplayName": "表示名", 594 "demoBio": "自己紹介を書いてください。" ··· 609 "primaryLight": "プライマリ(ライトモード)", 610 "primaryDark": "プライマリ(ダークモード)", 611 "configSaved": "サーバー設定を保存しました", 612 "saveConfig": "設定を保存", 613 "serverStats": "サーバー統計", 614 "users": "ユーザー", ··· 699 "title": "二要素認証", 700 "subtitle": "追加の確認が必要です", 701 "usePasskey": "パスキーを使用", 702 + "useTotp": "認証アプリを使用" 703 }, 704 "twoFactorCode": { 705 "title": "二要素認証", 706 "subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。", 707 "codeLabel": "確認コード", 708 "codePlaceholder": "6桁のコードを入力", 709 "errors": { 710 "missingRequestUri": "request_uri パラメータがありません", 711 "verificationFailed": "確認に失敗しました", ··· 717 "title": "認証コードを入力", 718 "subtitle": "認証アプリの6桁のコードを入力", 719 "codePlaceholder": "6桁のコードを入力", 720 "useBackupCode": "バックアップコードを使用", 721 "backupCodePlaceholder": "バックアップコードを入力", 722 "trustDevice": "このデバイスを30日間信頼する", ··· 746 "codeLabel": "確認コード", 747 "codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください", 748 "verifyButton": "アカウントを確認", 749 "pleaseWait": "お待ちください...", 750 "codeResent": "確認コードを再送信しました!", 751 "codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。", 752 "verified": "確認完了!", ··· 756 "identifierLabel": "メールまたは識別子", 757 "identifierPlaceholder": "you@example.com", 758 "identifierHelp": "コードが送信されたメールアドレスまたは識別子", 759 "verifyingAccount": "確認中のアカウント: @{handle}", 760 "startOver": "別のアカウントでやり直す", 761 "noPending": "保留中の確認が見つかりません。", 762 "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", 763 "createAccount": "アカウントを作成", 764 "signIn": "サインイン", 765 "emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました", 766 "emailUpdateFailed": "メールアドレスの更新に失敗しました", 767 "emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。", ··· 794 "resetButton": "パスワードをリセット", 795 "resetting": "リセット中...", 796 "success": "パスワードをリセットしました!", 797 "requestNewCode": "新しいコードをリクエスト", 798 "passwordsMismatch": "パスワードが一致しません", 799 "passwordLength": "パスワードは8文字以上である必要があります" ··· 837 "howItWorks": "仕組み", 838 "howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。", 839 "sendRecoveryLink": "復旧リンクを送信", 840 + "sending": "送信中..." 841 }, 842 "registerPasskey": { 843 "title": "パスキーアカウントを作成", ··· 858 "externalDid": "あなたの did:web", 859 "externalDidPlaceholder": "did:web:yourdomain.com", 860 "createButton": "アカウントを作成", 861 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 862 "signIn": "サインイン", 863 "wantPassword": "パスワードを使用しますか?", ··· 956 "useTotp": "認証アプリを使用", 957 "passwordPlaceholder": "パスワードを入力", 958 "totpPlaceholder": "6桁のコードを入力", 959 "authenticating": "認証中...", 960 "passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。", 961 "cancel": "キャンセル" ··· 1028 "createAccount": "アカウントを作成", 1029 "createDelegatedAccount": "委任アカウントを作成", 1030 "createDelegatedAccountButton": "+ 委任アカウントを作成", 1031 "emailOptional": "メール(任意)", 1032 "failedToAddController": "コントローラーの追加に失敗しました", 1033 "failedToCreateAccount": "委任アカウントの作成に失敗しました", ··· 1101 "navDesc": "別のPDSへ、または別のPDSからアカウントを移動", 1102 "migrateHere": "ここに移行", 1103 "migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。", 1104 "bringDid": "DIDとアイデンティティを持ち込む", 1105 "transferData": "すべてのデータを転送", 1106 "keepFollowers": "フォロワーを維持", 1107 "whatIsMigration": "アカウント移行とは?", 1108 "whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。", 1109 "beforeMigrate": "移行前の確認事項", ··· 1113 "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", 1114 "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", 1115 "learnMore": "移行のリスクについて詳しく", 1116 + "offlineRestore": "オフライン復元", 1117 + "offlineRestoreDesc": "旧PDSが利用できない場合にバックアップから復元します。", 1118 + "offlineFeature1": "CARファイルバックアップを使用", 1119 + "offlineFeature2": "ローテーションキーで所有権を証明", 1120 + "offlineFeature3": "シャットダウンしたサーバーの復旧", 1121 "oauthCompleting": "認証を完了しています...", 1122 "oauthFailed": "認証に失敗しました", 1123 "tryAgain": "再試行", ··· 1126 "incomplete": "未完了の移行があります:", 1127 "direction": "方向", 1128 "migratingHere": "ここに移行中", 1129 "from": "移行元", 1130 "to": "移行先", 1131 "progress": "進行状況", ··· 1268 "error": { 1269 "title": "移行エラー", 1270 "desc": "移行中にエラーが発生しました。", 1271 + "startOver": "最初からやり直す", 1272 + "unknown": "不明なエラーが発生しました。" 1273 }, 1274 "common": { 1275 "back": "戻る", ··· 1287 "warning3": "移行後、古いアカウントは無効化されます" 1288 } 1289 }, 1290 + "offline": { 1291 "welcome": { 1292 + "title": "バックアップから復元", 1293 + "desc": "CARファイルバックアップとローテーションキーを使用してアカウントを復元します。以前のPDSが利用できない場合に使用してください。", 1294 + "warningTitle": "この方法を使用するタイミング", 1295 + "warningDesc": "このオフライン復元は、古いPDSがシャットダウンした、アクセスできない、またはロックアウトされた場合の災害復旧用です。古いPDSがまだ利用可能な場合は、代わりに標準の移行を使用してください。", 1296 + "requirementsTitle": "必要なもの", 1297 + "requirement1": "リポジトリのCARファイルバックアップ", 1298 + "requirement2": "ローテーションキー(DIDの秘密鍵)", 1299 + "requirement3": "あなたのDID (did:plc:xxx)", 1300 + "understand": "理解し、続行します" 1301 }, 1302 + "provideDid": { 1303 + "title": "DIDを入力", 1304 + "desc": "復元するアカウントのDIDを入力してください。", 1305 + "label": "あなたのDID", 1306 + "hint": "分散型識別子(例:did:plc:abc123)" 1307 }, 1308 + "uploadCar": { 1309 + "title": "CARファイルをアップロード", 1310 + "desc": "リポジトリバックアップファイルをアップロードしてください。", 1311 + "label": "CARファイル", 1312 + "hint": "バックアップから.carファイルを選択", 1313 + "reuploadWarningTitle": "CARファイルが必要です", 1314 + "reuploadWarning": "セッションは復元されましたが、CARファイルを再アップロードする必要があります。セキュリティ上の理由から、ファイルの内容はセッション間で保存されません。" 1315 }, 1316 + "rotationKey": { 1317 + "title": "ローテーションキーを提供", 1318 + "desc": "このDIDの所有権を証明するためにローテーションキーを入力してください。", 1319 + "securityWarningTitle": "セキュリティ警告", 1320 + "securityWarning1": "ローテーションキーは非常に機密性が高いです - マスターパスワードのように扱ってください", 1321 + "securityWarning2": "信頼できるデバイスとネットワークでのみ入力してください", 1322 + "securityWarning3": "このキーは移行完了後に保存されません", 1323 + "label": "ローテーションキー", 1324 + "placeholder": "秘密鍵を入力(hex、base58、またはJWK)", 1325 + "hint": "DIDドキュメントのローテーションキーの1つに対応する秘密鍵", 1326 + "valid": "キーは有効で、DIDのローテーションキーと一致します", 1327 + "invalid": "キーはDIDドキュメントのどのローテーションキーとも一致しません", 1328 + "validating": "キーを検証中...", 1329 + "validate": "キーを検証" 1330 }, 1331 + "chooseHandle": { 1332 + "migratingDid": "DIDを復元中" 1333 }, 1334 + "review": { 1335 + "desc": "オフライン復元の詳細を確認してください。", 1336 + "carFile": "CARファイル", 1337 + "rotationKey": "ローテーションキー", 1338 + "warning": "復元を開始すると、アイデンティティがこのPDSを指すように更新されます。これは簡単に元に戻すことができません。", 1339 + "plcWarningTitle": "引き返せないポイント", 1340 + "plcWarning": "開始すると、DIDドキュメントがこのPDSを指すように更新されます。問題が発生した場合はローテーションキーを使用して回復できますが、壊れたアイデンティティ状態を避けるために移行を完了する必要があります。" 1341 }, 1342 + "migrating": { 1343 + "title": "アカウントを復元中", 1344 + "desc": "アカウントを復元しています...", 1345 + "creating": "アカウントを作成中", 1346 + "importing": "リポジトリをインポート中", 1347 + "plcSigning": "アイデンティティを更新中", 1348 + "activating": "アカウントをアクティベート中" 1349 }, 1350 "success": { 1351 + "desc": "アカウントはこのPDSに正常に復元されました。" 1352 + }, 1353 + "blobs": { 1354 + "title": "Blobを移行中", 1355 + "desc": "古いPDSから画像とメディアの復元を試みています...", 1356 + "migrating": "Blobを移行中", 1357 + "failedTitle": "一部のBlobを移行できませんでした", 1358 + "failedDesc": "{count}個のBlobを古いPDSから取得できませんでした。サーバーに接続できないか、ファイルが削除された可能性があります。", 1359 + "sourceUnreachableTitle": "ソースPDSに接続できません", 1360 + "sourceUnreachable": "古いPDSに接続してメディアファイルを取得できませんでした。シャットダウンしたサーバーからの移行ではよくあることです。投稿は機能しますが、一部の画像が欠落する可能性があります。" 1361 } 1362 }, 1363 "progress": {
+147 -100
frontend/src/locales/ko.json
··· 17 "dashboard": "대시보드", 18 "backToDashboard": "← 대시보드", 19 "copied": "복사됨!", 20 - "copyToClipboard": "클립보드에 복사" 21 }, 22 "login": { 23 "title": "로그인", ··· 49 "codeLabel": "인증 코드", 50 "codePlaceholder": "6자리 코드 입력", 51 "verifyButton": "계정 인증", 52 - "verifying": "인증 중...", 53 - "resendButton": "코드 다시 보내기", 54 - "resending": "전송 중...", 55 - "resent": "인증 코드를 다시 보냈습니다!", 56 - "backToLogin": "로그인으로 돌아가기" 57 }, 58 "register": { 59 "title": "계정 만들기", ··· 124 "inviteCodePlaceholder": "초대 코드 입력", 125 "inviteCodeRequired": "필수", 126 "createButton": "계정 만들기", 127 - "creating": "계정 생성 중...", 128 "alreadyHaveAccount": "이미 계정이 있으신가요?", 129 "signIn": "로그인", 130 "wantPasswordless": "비밀번호 없는 보안을 원하시나요?", ··· 179 "navAdminDesc": "서버 통계 및 관리 작업", 180 "navDidDocument": "DID 문서", 181 "navDidDocumentDesc": "DID 문서 및 키 관리", 182 "migrated": "마이그레이션됨", 183 "migratedTitle": "계정 마이그레이션됨", 184 "migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.", ··· 208 "serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.", 209 "currentPds": "현재 PDS URL", 210 "save": "변경사항 저장", 211 - "saving": "저장 중...", 212 "success": "DID 문서가 업데이트되었습니다", 213 "saveFailed": "DID 문서 저장에 실패했습니다", 214 "loadFailed": "DID 문서 로드에 실패했습니다", ··· 246 "yourDomain": "도메인", 247 "yourDomainPlaceholder": "example.com", 248 "verifyAndUpdate": "확인 후 핸들 업데이트", 249 - "verifying": "확인 중...", 250 "newHandle": "새 핸들", 251 "newHandlePlaceholder": "yourhandle", 252 "changeHandleButton": "핸들 변경", ··· 262 "exportData": "데이터 내보내기", 263 "exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.", 264 "downloadRepo": "저장소 다운로드", 265 "exporting": "내보내기 중...", 266 "deleteAccount": "계정 삭제", 267 "deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.", 268 "requestDeletion": "계정 삭제 요청", ··· 291 "deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 292 "deletionFailed": "계정 삭제에 실패했습니다", 293 "repoExported": "저장소를 내보냈습니다", 294 - "exportFailed": "저장소 내보내기에 실패했습니다", 295 "confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." 296 } 297 }, ··· 306 "noPasswords": "앱 비밀번호가 아직 없습니다", 307 "revoke": "취소", 308 "revoking": "취소 중...", 309 - "creating": "생성 중...", 310 "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", 311 "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", 312 "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", ··· 354 "used": "@{handle}이(가) 사용함", 355 "disabled": "비활성화됨", 356 "usedBy": "사용자", 357 - "creating": "생성 중...", 358 "disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.", 359 "created": "초대 코드가 생성되었습니다", 360 "copy": "복사", ··· 482 "verifyButton": "인증", 483 "verifyCodePlaceholder": "인증 코드 입력", 484 "submit": "제출", 485 - "saving": "저장 중...", 486 "savePreferences": "설정 저장", 487 "preferencesSaved": "통신 설정이 저장되었습니다", 488 "verifiedSuccess": "{channel} 인증 완료", ··· 521 "noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.", 522 "loadMore": "더 불러오기", 523 "recordJson": "레코드 JSON", 524 - "saving": "저장 중...", 525 "updateRecord": "레코드 업데이트", 526 "collectionNsid": "컬렉션 (NSID)", 527 "recordKeyOptional": "레코드 키 (선택사항)", 528 "autoGenerated": "비워두면 자동 생성 (TID)", 529 "autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다", 530 - "creating": "생성 중...", 531 "demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!", 532 "demoDisplayName": "표시 이름", 533 "demoBio": "간단한 자기소개를 작성하세요." ··· 548 "primaryLight": "기본 (라이트 모드)", 549 "primaryDark": "기본 (다크 모드)", 550 "configSaved": "서버 설정이 저장되었습니다", 551 - "saving": "저장 중...", 552 "saveConfig": "설정 저장", 553 "serverStats": "서버 통계", 554 "users": "사용자", ··· 639 "title": "2단계 인증", 640 "subtitle": "추가 확인이 필요합니다", 641 "usePasskey": "패스키 사용", 642 - "useTotp": "인증 앱 사용", 643 - "verifying": "확인 중..." 644 }, 645 "twoFactorCode": { 646 "title": "2단계 인증", 647 "subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.", 648 "codeLabel": "인증 코드", 649 "codePlaceholder": "6자리 코드 입력", 650 - "verify": "확인", 651 - "verifying": "확인 중...", 652 "errors": { 653 "missingRequestUri": "request_uri 매개변수가 없습니다", 654 "verificationFailed": "인증에 실패했습니다", ··· 660 "title": "인증 코드 입력", 661 "subtitle": "인증 앱의 6자리 코드를 입력하세요", 662 "codePlaceholder": "6자리 코드 입력", 663 - "verify": "확인", 664 - "verifying": "확인 중...", 665 "useBackupCode": "백업 코드 사용", 666 "backupCodePlaceholder": "백업 코드 입력", 667 "trustDevice": "이 기기를 30일간 신뢰", ··· 691 "codeLabel": "인증 코드", 692 "codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요", 693 "verifyButton": "계정 인증", 694 - "verify": "인증", 695 - "verifying": "인증 중...", 696 "pleaseWait": "잠시 기다려 주세요...", 697 - "sending": "전송 중...", 698 - "resendCode": "코드 다시 보내기", 699 - "resending": "전송 중...", 700 "codeResent": "인증 코드를 다시 보냈습니다!", 701 "codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.", 702 "verified": "인증 완료!", ··· 706 "identifierLabel": "이메일 또는 식별자", 707 "identifierPlaceholder": "you@example.com", 708 "identifierHelp": "코드가 전송된 이메일 주소 또는 식별자", 709 - "backToLogin": "로그인으로 돌아가기", 710 "verifyingAccount": "인증 중인 계정: @{handle}", 711 "startOver": "다른 계정으로 다시 시작", 712 "noPending": "보류 중인 인증이 없습니다.", 713 "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", 714 "createAccount": "계정 만들기", 715 "signIn": "로그인", 716 - "backToSettings": "설정으로 돌아가기", 717 "emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다", 718 "emailUpdateFailed": "이메일 주소 업데이트 실패", 719 "emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.", ··· 746 "resetButton": "비밀번호 재설정", 747 "resetting": "재설정 중...", 748 "success": "비밀번호가 재설정되었습니다!", 749 - "backToLogin": "로그인으로 돌아가기", 750 "requestNewCode": "새 코드 요청", 751 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 752 "passwordLength": "비밀번호는 8자 이상이어야 합니다" ··· 790 "howItWorks": "작동 방식", 791 "howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.", 792 "sendRecoveryLink": "복구 링크 보내기", 793 - "sending": "전송 중...", 794 - "backToLogin": "로그인으로 돌아가기" 795 }, 796 "registerPasskey": { 797 "title": "패스키 계정 만들기", ··· 812 "externalDid": "귀하의 did:web", 813 "externalDidPlaceholder": "did:web:yourdomain.com", 814 "createButton": "계정 만들기", 815 - "creating": "생성 중...", 816 "alreadyHaveAccount": "이미 계정이 있으신가요?", 817 "signIn": "로그인", 818 "wantPassword": "비밀번호를 사용하시겠습니까?", ··· 911 "useTotp": "인증 앱 사용", 912 "passwordPlaceholder": "비밀번호 입력", 913 "totpPlaceholder": "6자리 코드 입력", 914 - "verify": "확인", 915 - "verifying": "확인 중...", 916 "authenticating": "인증 중...", 917 "passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.", 918 "cancel": "취소" ··· 985 "createAccount": "계정 생성", 986 "createDelegatedAccount": "위임 계정 생성", 987 "createDelegatedAccountButton": "+ 위임 계정 생성", 988 - "creating": "생성 중...", 989 "emailOptional": "이메일 (선택사항)", 990 "failedToAddController": "컨트롤러 추가에 실패했습니다", 991 "failedToCreateAccount": "위임 계정 생성에 실패했습니다", ··· 1059 "navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동", 1060 "migrateHere": "여기로 마이그레이션", 1061 "migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.", 1062 - "migrateAway": "다른 곳으로 마이그레이션", 1063 - "migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.", 1064 - "loginRequired": "로그인 필요", 1065 "bringDid": "DID와 아이덴티티 가져오기", 1066 "transferData": "모든 데이터 전송", 1067 "keepFollowers": "팔로워 유지", 1068 - "exportRepo": "저장소 내보내기", 1069 - "transferToPds": "새 PDS로 전송", 1070 - "updateIdentity": "아이덴티티 업데이트", 1071 "whatIsMigration": "계정 마이그레이션이란?", 1072 "whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.", 1073 "beforeMigrate": "마이그레이션 전 확인사항", ··· 1077 "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", 1078 "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", 1079 "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", 1080 - "comingSoon": "곧 출시 예정", 1081 "oauthCompleting": "인증 완료 중...", 1082 "oauthFailed": "인증 실패", 1083 "tryAgain": "다시 시도", ··· 1086 "incomplete": "완료되지 않은 마이그레이션이 있습니다:", 1087 "direction": "방향", 1088 "migratingHere": "여기로 마이그레이션 중", 1089 - "migratingAway": "다른 곳으로 마이그레이션 중", 1090 "from": "출발지", 1091 "to": "목적지", 1092 "progress": "진행 상황", ··· 1229 "error": { 1230 "title": "마이그레이션 오류", 1231 "desc": "마이그레이션 중 오류가 발생했습니다.", 1232 - "startOver": "처음부터 다시 시작" 1233 }, 1234 "common": { 1235 "back": "뒤로", ··· 1247 "warning3": "마이그레이션 후 이전 계정은 비활성화됩니다" 1248 } 1249 }, 1250 - "outbound": { 1251 "welcome": { 1252 - "title": "이 PDS에서 마이그레이션", 1253 - "desc": "계정을 다른 개인 데이터 서버로 이동합니다.", 1254 - "warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.", 1255 - "didWebNotice": "did:web 마이그레이션 알림", 1256 - "didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.", 1257 - "understand": "위험을 이해하고 계속 진행합니다" 1258 }, 1259 - "targetPds": { 1260 - "title": "대상 PDS 선택", 1261 - "desc": "마이그레이션할 PDS의 URL을 입력하세요.", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "확인 및 계속", 1265 - "validating": "확인 중...", 1266 - "connected": "{name}에 연결됨", 1267 - "inviteRequired": "초대 코드 필요", 1268 - "privacyPolicy": "개인정보 처리방침", 1269 - "termsOfService": "서비스 약관" 1270 }, 1271 - "newAccount": { 1272 - "title": "새 계정 세부 정보", 1273 - "desc": "새 PDS에서 계정을 설정합니다.", 1274 - "handle": "핸들", 1275 - "availableDomains": "사용 가능한 도메인", 1276 - "email": "이메일", 1277 - "password": "비밀번호", 1278 - "confirmPassword": "비밀번호 확인", 1279 - "inviteCode": "초대 코드" 1280 }, 1281 - "review": { 1282 - "title": "마이그레이션 검토", 1283 - "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", 1284 - "currentHandle": "현재 핸들", 1285 - "newHandle": "새 핸들", 1286 - "sourcePds": "이 PDS", 1287 - "targetPds": "대상 PDS", 1288 - "confirm": "계정 마이그레이션을 확인합니다", 1289 - "startMigration": "마이그레이션 시작" 1290 }, 1291 - "migrating": { 1292 - "title": "계정 마이그레이션 중", 1293 - "desc": "데이터를 전송하는 중입니다..." 1294 }, 1295 - "plcToken": { 1296 - "title": "신원 확인", 1297 - "desc": "이메일로 인증 코드가 전송되었습니다." 1298 }, 1299 - "finalizing": { 1300 - "title": "마이그레이션 완료 중", 1301 - "desc": "마이그레이션을 완료하는 중입니다...", 1302 - "updatingForwarding": "DID 문서 포워딩 업데이트 중..." 1303 }, 1304 "success": { 1305 - "title": "마이그레이션 완료!", 1306 - "desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.", 1307 - "newHandle": "새 핸들", 1308 - "newPds": "새 PDS", 1309 - "nextSteps": "다음 단계", 1310 - "nextSteps1": "새 PDS에 로그인", 1311 - "nextSteps2": "새 인증 정보로 앱 업데이트", 1312 - "nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다", 1313 - "loggingOut": "{seconds}초 후 로그아웃됩니다..." 1314 } 1315 }, 1316 "progress": {
··· 17 "dashboard": "대시보드", 18 "backToDashboard": "← 대시보드", 19 "copied": "복사됨!", 20 + "copyToClipboard": "클립보드에 복사", 21 + "verifying": "확인 중...", 22 + "saving": "저장 중...", 23 + "creating": "생성 중...", 24 + "updating": "업데이트 중...", 25 + "sending": "전송 중...", 26 + "authenticating": "인증 중...", 27 + "checking": "확인 중...", 28 + "redirecting": "리디렉션 중...", 29 + "signIn": "로그인", 30 + "verify": "확인", 31 + "remove": "삭제", 32 + "revoke": "취소", 33 + "resendCode": "코드 재전송", 34 + "startOver": "처음부터 다시", 35 + "tryAgain": "다시 시도", 36 + "password": "비밀번호", 37 + "email": "이메일", 38 + "emailAddress": "이메일 주소", 39 + "handle": "핸들", 40 + "did": "DID", 41 + "verificationCode": "인증 코드", 42 + "inviteCode": "초대 코드", 43 + "newPassword": "새 비밀번호", 44 + "confirmPassword": "비밀번호 확인", 45 + "enterSixDigitCode": "6자리 코드 입력", 46 + "passwordHint": "8자 이상", 47 + "enterPassword": "비밀번호를 입력하세요", 48 + "emailPlaceholder": "you@example.com", 49 + "verified": "인증됨", 50 + "disabled": "비활성화됨", 51 + "available": "사용 가능", 52 + "deactivated": "비활성화됨", 53 + "unverified": "미인증", 54 + "backToLogin": "로그인으로 돌아가기", 55 + "backToSettings": "설정으로 돌아가기", 56 + "alreadyHaveAccount": "이미 계정이 있으신가요?", 57 + "createAccount": "계정 만들기", 58 + "passwordsMismatch": "비밀번호가 일치하지 않습니다", 59 + "passwordTooShort": "비밀번호는 8자 이상이어야 합니다" 60 }, 61 "login": { 62 "title": "로그인", ··· 88 "codeLabel": "인증 코드", 89 "codePlaceholder": "6자리 코드 입력", 90 "verifyButton": "계정 인증", 91 + "resent": "인증 코드를 다시 보냈습니다!" 92 }, 93 "register": { 94 "title": "계정 만들기", ··· 159 "inviteCodePlaceholder": "초대 코드 입력", 160 "inviteCodeRequired": "필수", 161 "createButton": "계정 만들기", 162 "alreadyHaveAccount": "이미 계정이 있으신가요?", 163 "signIn": "로그인", 164 "wantPasswordless": "비밀번호 없는 보안을 원하시나요?", ··· 213 "navAdminDesc": "서버 통계 및 관리 작업", 214 "navDidDocument": "DID 문서", 215 "navDidDocumentDesc": "DID 문서 및 키 관리", 216 + "navDidDocumentDescActive": "DID 문서 설정 편집", 217 + "navBackup": "백업 다운로드", 218 + "navBackupDesc": "저장소를 CAR 파일로 다운로드", 219 + "downloadingBackup": "다운로드 중...", 220 + "backupFailed": "백업 다운로드 실패", 221 "migrated": "마이그레이션됨", 222 "migratedTitle": "계정 마이그레이션됨", 223 "migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.", ··· 247 "serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.", 248 "currentPds": "현재 PDS URL", 249 "save": "변경사항 저장", 250 "success": "DID 문서가 업데이트되었습니다", 251 "saveFailed": "DID 문서 저장에 실패했습니다", 252 "loadFailed": "DID 문서 로드에 실패했습니다", ··· 284 "yourDomain": "도메인", 285 "yourDomainPlaceholder": "example.com", 286 "verifyAndUpdate": "확인 후 핸들 업데이트", 287 "newHandle": "새 핸들", 288 "newHandlePlaceholder": "yourhandle", 289 "changeHandleButton": "핸들 변경", ··· 299 "exportData": "데이터 내보내기", 300 "exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.", 301 "downloadRepo": "저장소 다운로드", 302 + "downloadBlobs": "미디어 다운로드", 303 "exporting": "내보내기 중...", 304 + "backups": { 305 + "title": "백업", 306 + "description": "자동 백업을 관리하고 계정 데이터를 복원하세요. 백업에는 모든 기록과 blob이 포함됩니다.", 307 + "enableAutomatic": "자동 백업", 308 + "enabled": "활성화됨", 309 + "disabled": "비활성화됨", 310 + "toggleFailed": "백업 설정 변경 실패", 311 + "noBackups": "아직 백업이 없습니다", 312 + "blocks": "블록", 313 + "download": "다운로드", 314 + "delete": "삭제", 315 + "createNow": "지금 백업 생성", 316 + "created": "백업이 생성되었습니다", 317 + "createFailed": "백업 생성 실패", 318 + "downloadFailed": "백업 다운로드 실패", 319 + "deleted": "백업이 삭제되었습니다", 320 + "deleteFailed": "백업 삭제 실패", 321 + "restoreTitle": "백업에서 복원", 322 + "restoreDescription": "이전에 내보낸 CAR 파일에서 계정 데이터를 복원합니다. 이렇게 하면 현재 저장소가 업로드한 백업으로 교체됩니다.", 323 + "selectFile": "CAR 파일 선택", 324 + "selectedFile": "선택된 파일", 325 + "restore": "백업 복원", 326 + "restoring": "복원 중...", 327 + "restored": "백업이 성공적으로 복원되었습니다", 328 + "restoreFailed": "백업 복원 실패" 329 + }, 330 "deleteAccount": "계정 삭제", 331 "deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.", 332 "requestDeletion": "계정 삭제 요청", ··· 355 "deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 356 "deletionFailed": "계정 삭제에 실패했습니다", 357 "repoExported": "저장소를 내보냈습니다", 358 + "blobsExported": "미디어 파일을 내보냈습니다", 359 + "noBlobsToExport": "내보낼 미디어 파일이 없습니다", 360 + "exportFailed": "내보내기에 실패했습니다", 361 "confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." 362 } 363 }, ··· 372 "noPasswords": "앱 비밀번호가 아직 없습니다", 373 "revoke": "취소", 374 "revoking": "취소 중...", 375 "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", 376 "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", 377 "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", ··· 419 "used": "@{handle}이(가) 사용함", 420 "disabled": "비활성화됨", 421 "usedBy": "사용자", 422 "disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.", 423 "created": "초대 코드가 생성되었습니다", 424 "copy": "복사", ··· 546 "verifyButton": "인증", 547 "verifyCodePlaceholder": "인증 코드 입력", 548 "submit": "제출", 549 "savePreferences": "설정 저장", 550 "preferencesSaved": "통신 설정이 저장되었습니다", 551 "verifiedSuccess": "{channel} 인증 완료", ··· 584 "noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.", 585 "loadMore": "더 불러오기", 586 "recordJson": "레코드 JSON", 587 "updateRecord": "레코드 업데이트", 588 "collectionNsid": "컬렉션 (NSID)", 589 "recordKeyOptional": "레코드 키 (선택사항)", 590 "autoGenerated": "비워두면 자동 생성 (TID)", 591 "autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다", 592 "demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!", 593 "demoDisplayName": "표시 이름", 594 "demoBio": "간단한 자기소개를 작성하세요." ··· 609 "primaryLight": "기본 (라이트 모드)", 610 "primaryDark": "기본 (다크 모드)", 611 "configSaved": "서버 설정이 저장되었습니다", 612 "saveConfig": "설정 저장", 613 "serverStats": "서버 통계", 614 "users": "사용자", ··· 699 "title": "2단계 인증", 700 "subtitle": "추가 확인이 필요합니다", 701 "usePasskey": "패스키 사용", 702 + "useTotp": "인증 앱 사용" 703 }, 704 "twoFactorCode": { 705 "title": "2단계 인증", 706 "subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.", 707 "codeLabel": "인증 코드", 708 "codePlaceholder": "6자리 코드 입력", 709 "errors": { 710 "missingRequestUri": "request_uri 매개변수가 없습니다", 711 "verificationFailed": "인증에 실패했습니다", ··· 717 "title": "인증 코드 입력", 718 "subtitle": "인증 앱의 6자리 코드를 입력하세요", 719 "codePlaceholder": "6자리 코드 입력", 720 "useBackupCode": "백업 코드 사용", 721 "backupCodePlaceholder": "백업 코드 입력", 722 "trustDevice": "이 기기를 30일간 신뢰", ··· 746 "codeLabel": "인증 코드", 747 "codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요", 748 "verifyButton": "계정 인증", 749 "pleaseWait": "잠시 기다려 주세요...", 750 "codeResent": "인증 코드를 다시 보냈습니다!", 751 "codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.", 752 "verified": "인증 완료!", ··· 756 "identifierLabel": "이메일 또는 식별자", 757 "identifierPlaceholder": "you@example.com", 758 "identifierHelp": "코드가 전송된 이메일 주소 또는 식별자", 759 "verifyingAccount": "인증 중인 계정: @{handle}", 760 "startOver": "다른 계정으로 다시 시작", 761 "noPending": "보류 중인 인증이 없습니다.", 762 "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", 763 "createAccount": "계정 만들기", 764 "signIn": "로그인", 765 "emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다", 766 "emailUpdateFailed": "이메일 주소 업데이트 실패", 767 "emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.", ··· 794 "resetButton": "비밀번호 재설정", 795 "resetting": "재설정 중...", 796 "success": "비밀번호가 재설정되었습니다!", 797 "requestNewCode": "새 코드 요청", 798 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 799 "passwordLength": "비밀번호는 8자 이상이어야 합니다" ··· 837 "howItWorks": "작동 방식", 838 "howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.", 839 "sendRecoveryLink": "복구 링크 보내기", 840 + "sending": "전송 중..." 841 }, 842 "registerPasskey": { 843 "title": "패스키 계정 만들기", ··· 858 "externalDid": "귀하의 did:web", 859 "externalDidPlaceholder": "did:web:yourdomain.com", 860 "createButton": "계정 만들기", 861 "alreadyHaveAccount": "이미 계정이 있으신가요?", 862 "signIn": "로그인", 863 "wantPassword": "비밀번호를 사용하시겠습니까?", ··· 956 "useTotp": "인증 앱 사용", 957 "passwordPlaceholder": "비밀번호 입력", 958 "totpPlaceholder": "6자리 코드 입력", 959 "authenticating": "인증 중...", 960 "passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.", 961 "cancel": "취소" ··· 1028 "createAccount": "계정 생성", 1029 "createDelegatedAccount": "위임 계정 생성", 1030 "createDelegatedAccountButton": "+ 위임 계정 생성", 1031 "emailOptional": "이메일 (선택사항)", 1032 "failedToAddController": "컨트롤러 추가에 실패했습니다", 1033 "failedToCreateAccount": "위임 계정 생성에 실패했습니다", ··· 1101 "navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동", 1102 "migrateHere": "여기로 마이그레이션", 1103 "migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.", 1104 "bringDid": "DID와 아이덴티티 가져오기", 1105 "transferData": "모든 데이터 전송", 1106 "keepFollowers": "팔로워 유지", 1107 "whatIsMigration": "계정 마이그레이션이란?", 1108 "whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.", 1109 "beforeMigrate": "마이그레이션 전 확인사항", ··· 1113 "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", 1114 "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", 1115 "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", 1116 + "offlineRestore": "오프라인 복원", 1117 + "offlineRestoreDesc": "이전 PDS를 사용할 수 없을 때 백업에서 복원합니다.", 1118 + "offlineFeature1": "CAR 파일 백업 사용", 1119 + "offlineFeature2": "회전 키로 소유권 증명", 1120 + "offlineFeature3": "종료된 서버 복구", 1121 "oauthCompleting": "인증 완료 중...", 1122 "oauthFailed": "인증 실패", 1123 "tryAgain": "다시 시도", ··· 1126 "incomplete": "완료되지 않은 마이그레이션이 있습니다:", 1127 "direction": "방향", 1128 "migratingHere": "여기로 마이그레이션 중", 1129 "from": "출발지", 1130 "to": "목적지", 1131 "progress": "진행 상황", ··· 1268 "error": { 1269 "title": "마이그레이션 오류", 1270 "desc": "마이그레이션 중 오류가 발생했습니다.", 1271 + "startOver": "처음부터 다시 시작", 1272 + "unknown": "알 수 없는 오류가 발생했습니다." 1273 }, 1274 "common": { 1275 "back": "뒤로", ··· 1287 "warning3": "마이그레이션 후 이전 계정은 비활성화됩니다" 1288 } 1289 }, 1290 + "offline": { 1291 "welcome": { 1292 + "title": "백업에서 복원", 1293 + "desc": "CAR 파일 백업과 회전 키를 사용하여 계정을 복원합니다. 이전 PDS를 사용할 수 없을 때 사용하세요.", 1294 + "warningTitle": "이 방법을 사용해야 할 때", 1295 + "warningDesc": "이 오프라인 복원은 이전 PDS가 종료되었거나, 접근할 수 없거나, 잠긴 경우의 재해 복구용입니다. 이전 PDS가 여전히 사용 가능하면 표준 마이그레이션을 사용하세요.", 1296 + "requirementsTitle": "필요한 것", 1297 + "requirement1": "저장소의 CAR 파일 백업", 1298 + "requirement2": "회전 키 (DID의 개인 키)", 1299 + "requirement3": "당신의 DID (did:plc:xxx)", 1300 + "understand": "이해하고 계속 진행합니다" 1301 }, 1302 + "provideDid": { 1303 + "title": "DID 입력", 1304 + "desc": "복원할 계정의 DID를 입력하세요.", 1305 + "label": "당신의 DID", 1306 + "hint": "분산 식별자 (예: did:plc:abc123)" 1307 }, 1308 + "uploadCar": { 1309 + "title": "CAR 파일 업로드", 1310 + "desc": "저장소 백업 파일을 업로드하세요.", 1311 + "label": "CAR 파일", 1312 + "hint": "백업에서 .car 파일을 선택하세요", 1313 + "reuploadWarningTitle": "CAR 파일 필요", 1314 + "reuploadWarning": "세션이 복원되었지만 CAR 파일을 다시 업로드해야 합니다. 보안상의 이유로 파일 내용은 세션 간에 저장되지 않습니다." 1315 }, 1316 + "rotationKey": { 1317 + "title": "회전 키 제공", 1318 + "desc": "이 DID의 소유권을 증명하기 위해 회전 키를 입력하세요.", 1319 + "securityWarningTitle": "보안 경고", 1320 + "securityWarning1": "회전 키는 매우 민감합니다 - 마스터 비밀번호처럼 취급하세요", 1321 + "securityWarning2": "신뢰할 수 있는 장치와 네트워크에서만 입력하세요", 1322 + "securityWarning3": "이 키는 마이그레이션 완료 후 저장되지 않습니다", 1323 + "label": "회전 키", 1324 + "placeholder": "개인 키 입력 (hex, base58 또는 JWK)", 1325 + "hint": "DID 문서의 회전 키 중 하나에 해당하는 개인 키", 1326 + "valid": "키가 유효하고 DID의 회전 키와 일치합니다", 1327 + "invalid": "키가 DID 문서의 어떤 회전 키와도 일치하지 않습니다", 1328 + "validating": "키 검증 중...", 1329 + "validate": "키 검증" 1330 }, 1331 + "chooseHandle": { 1332 + "migratingDid": "DID 복원 중" 1333 }, 1334 + "review": { 1335 + "desc": "오프라인 복원 세부 정보를 확인하세요.", 1336 + "carFile": "CAR 파일", 1337 + "rotationKey": "회전 키", 1338 + "warning": "복원을 시작하면 아이덴티티가 이 PDS를 가리키도록 업데이트됩니다. 이것은 쉽게 되돌릴 수 없습니다.", 1339 + "plcWarningTitle": "되돌릴 수 없는 지점", 1340 + "plcWarning": "시작하면 DID 문서가 이 PDS를 가리키도록 업데이트됩니다. 문제가 발생하면 회전 키를 사용하여 복구할 수 있지만, 손상된 아이덴티티 상태를 피하려면 마이그레이션을 완료해야 합니다." 1341 }, 1342 + "migrating": { 1343 + "title": "계정 복원 중", 1344 + "desc": "계정을 복원하는 중입니다...", 1345 + "creating": "계정 생성 중", 1346 + "importing": "저장소 가져오는 중", 1347 + "plcSigning": "아이덴티티 업데이트 중", 1348 + "activating": "계정 활성화 중" 1349 }, 1350 "success": { 1351 + "desc": "계정이 이 PDS에 성공적으로 복원되었습니다." 1352 + }, 1353 + "blobs": { 1354 + "title": "Blob 마이그레이션 중", 1355 + "desc": "이전 PDS에서 이미지와 미디어를 복구하는 중...", 1356 + "migrating": "Blob 마이그레이션 중", 1357 + "failedTitle": "일부 Blob을 마이그레이션할 수 없음", 1358 + "failedDesc": "{count}개의 Blob을 이전 PDS에서 가져올 수 없습니다. 서버에 연결할 수 없거나 파일이 삭제되었을 수 있습니다.", 1359 + "sourceUnreachableTitle": "원본 PDS에 연결할 수 없음", 1360 + "sourceUnreachable": "이전 PDS에 연결하여 미디어 파일을 가져올 수 없습니다. 종료된 서버에서 마이그레이션할 때 흔히 발생합니다. 게시물은 작동하지만 일부 이미지가 누락될 수 있습니다." 1361 } 1362 }, 1363 "progress": {
+147 -100
frontend/src/locales/sv.json
··· 17 "dashboard": "Kontrollpanel", 18 "backToDashboard": "← Kontrollpanel", 19 "copied": "Kopierat!", 20 - "copyToClipboard": "Kopiera" 21 }, 22 "login": { 23 "title": "Logga in", ··· 49 "codeLabel": "Verifieringskod", 50 "codePlaceholder": "Ange 6-siffrig kod", 51 "verifyButton": "Verifiera konto", 52 - "verifying": "Verifierar...", 53 - "resendButton": "Skicka kod igen", 54 - "resending": "Skickar igen...", 55 - "resent": "Verifieringskod skickad igen!", 56 - "backToLogin": "Tillbaka till inloggning" 57 }, 58 "register": { 59 "title": "Skapa konto", ··· 124 "inviteCodePlaceholder": "Ange din inbjudningskod", 125 "inviteCodeRequired": "krävs", 126 "createButton": "Skapa konto", 127 - "creating": "Skapar konto...", 128 "alreadyHaveAccount": "Har du redan ett konto?", 129 "signIn": "Logga in", 130 "wantPasswordless": "Vill du ha lösenordsfri säkerhet?", ··· 179 "navAdminDesc": "Serverstatistik och administratörsoperationer", 180 "navDidDocument": "DID-dokument", 181 "navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar", 182 "migrated": "Flyttad", 183 "migratedTitle": "Konto flyttat", 184 "migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.", ··· 208 "serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.", 209 "currentPds": "Nuvarande PDS-URL", 210 "save": "Spara ändringar", 211 - "saving": "Sparar...", 212 "success": "DID-dokumentet har uppdaterats", 213 "saveFailed": "Kunde inte spara DID-dokument", 214 "loadFailed": "Kunde inte ladda DID-dokument", ··· 246 "yourDomain": "Din domän", 247 "yourDomainPlaceholder": "exempel.se", 248 "verifyAndUpdate": "Verifiera och uppdatera användarnamn", 249 - "verifying": "Verifierar...", 250 "newHandle": "Nytt användarnamn", 251 "newHandlePlaceholder": "dittanvändarnamn", 252 "changeHandleButton": "Ändra användarnamn", ··· 262 "exportData": "Exportera data", 263 "exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.", 264 "downloadRepo": "Ladda ner arkiv", 265 "exporting": "Exporterar...", 266 "deleteAccount": "Radera konto", 267 "deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.", 268 "requestDeletion": "Begär kontoradering", ··· 291 "deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.", 292 "deletionFailed": "Kunde inte radera kontot", 293 "repoExported": "Arkiv exporterat", 294 - "exportFailed": "Kunde inte exportera arkiv", 295 "confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras." 296 } 297 }, ··· 306 "noPasswords": "Inga applösenord ännu", 307 "revoke": "Återkalla", 308 "revoking": "Återkallar...", 309 - "creating": "Skapar...", 310 "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.", 311 "saveWarningTitle": "Viktigt: Spara detta applösenord!", 312 "saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.", ··· 354 "used": "Använd av @{handle}", 355 "disabled": "Inaktiverad", 356 "usedBy": "Använd av", 357 - "creating": "Skapar...", 358 "disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.", 359 "created": "Inbjudningskod skapad", 360 "copy": "Kopiera", ··· 482 "verifyButton": "Verifiera", 483 "verifyCodePlaceholder": "Ange verifieringskod", 484 "submit": "Skicka", 485 - "saving": "Sparar...", 486 "savePreferences": "Spara inställningar", 487 "preferencesSaved": "Kommunikationsinställningar sparade", 488 "verifiedSuccess": "{channel} verifierad", ··· 521 "noCollectionsYet": "Inga samlingar ännu. Skapa din första post för att komma igång.", 522 "loadMore": "Ladda fler", 523 "recordJson": "Post-JSON", 524 - "saving": "Sparar...", 525 "updateRecord": "Uppdatera post", 526 "collectionNsid": "Samling (NSID)", 527 "recordKeyOptional": "Postnyckel (valfri)", 528 "autoGenerated": "Genereras automatiskt om tom (TID)", 529 "autoGeneratedHint": "Lämna tom för att automatiskt generera en TID-baserad nyckel", 530 - "creating": "Skapar...", 531 "demoPostText": "Hej från min PDS! Detta är mitt första inlägg.", 532 "demoDisplayName": "Ditt visningsnamn", 533 "demoBio": "En kort presentation om dig själv." ··· 548 "primaryLight": "Primär (ljust läge)", 549 "primaryDark": "Primär (mörkt läge)", 550 "configSaved": "Serverkonfiguration sparad", 551 - "saving": "Sparar...", 552 "saveConfig": "Spara konfiguration", 553 "serverStats": "Serverstatistik", 554 "users": "Användare", ··· 639 "title": "Tvåfaktorsautentisering", 640 "subtitle": "Ytterligare verifiering krävs", 641 "usePasskey": "Använd nyckel", 642 - "useTotp": "Använd autentiseringsapp", 643 - "verifying": "Verifierar..." 644 }, 645 "twoFactorCode": { 646 "title": "Tvåfaktorsautentisering", 647 "subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan för att fortsätta.", 648 "codeLabel": "Verifieringskod", 649 "codePlaceholder": "Ange 6-siffrig kod", 650 - "verify": "Verifiera", 651 - "verifying": "Verifierar...", 652 "errors": { 653 "missingRequestUri": "Saknar request_uri-parameter", 654 "verificationFailed": "Verifiering misslyckades", ··· 660 "title": "Ange autentiseringskod", 661 "subtitle": "Ange den 6-siffriga koden från din autentiseringsapp", 662 "codePlaceholder": "Ange 6-siffrig kod", 663 - "verify": "Verifiera", 664 - "verifying": "Verifierar...", 665 "useBackupCode": "Använd reservkod istället", 666 "backupCodePlaceholder": "Ange reservkod", 667 "trustDevice": "Lita på denna enhet i 30 dagar", ··· 691 "codeLabel": "Verifieringskod", 692 "codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck", 693 "verifyButton": "Verifiera konto", 694 - "verify": "Verifiera", 695 - "verifying": "Verifierar...", 696 "pleaseWait": "Vänta...", 697 - "sending": "Skickar...", 698 - "resendCode": "Skicka kod igen", 699 - "resending": "Skickar igen...", 700 "codeResent": "Verifieringskod skickad igen!", 701 "codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.", 702 "verified": "Verifierad!", ··· 706 "identifierLabel": "E-post eller identifierare", 707 "identifierPlaceholder": "du@exempel.se", 708 "identifierHelp": "E-postadressen eller identifieraren koden skickades till", 709 - "backToLogin": "Tillbaka till inloggning", 710 "verifyingAccount": "Verifierar konto: @{handle}", 711 "startOver": "Börja om med ett annat konto", 712 "noPending": "Ingen väntande verifiering hittades.", 713 "noPendingInfo": "Om du nyligen skapade ett konto och behöver verifiera det kan du behöva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.", 714 "createAccount": "Skapa konto", 715 "signIn": "Logga in", 716 - "backToSettings": "Tillbaka till inställningar", 717 "emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress", 718 "emailUpdateFailed": "Kunde inte uppdatera e-postadress", 719 "emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.", ··· 746 "resetButton": "Återställ lösenord", 747 "resetting": "Återställer...", 748 "success": "Lösenord återställt!", 749 - "backToLogin": "Tillbaka till inloggning", 750 "requestNewCode": "Begär ny kod", 751 "passwordsMismatch": "Lösenorden matchar inte", 752 "passwordLength": "Lösenordet måste vara minst 8 tecken" ··· 790 "howItWorks": "Så fungerar det", 791 "howItWorksDetail": "Vi skickar en säker länk till din registrerade meddelandekanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.", 792 "sendRecoveryLink": "Skicka återställningslänk", 793 - "sending": "Skickar...", 794 - "backToLogin": "Tillbaka till inloggning" 795 }, 796 "registerPasskey": { 797 "title": "Skapa nyckelkonto", ··· 812 "externalDid": "Din did:web", 813 "externalDidPlaceholder": "did:web:dindomän.se", 814 "createButton": "Skapa konto", 815 - "creating": "Skapar...", 816 "alreadyHaveAccount": "Har du redan ett konto?", 817 "signIn": "Logga in", 818 "wantPassword": "Vill du använda ett lösenord?", ··· 911 "useTotp": "Använd autentiserare", 912 "passwordPlaceholder": "Ange ditt lösenord", 913 "totpPlaceholder": "Ange 6-siffrig kod", 914 - "verify": "Verifiera", 915 - "verifying": "Verifierar...", 916 "authenticating": "Autentiserar...", 917 "passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.", 918 "cancel": "Avbryt" ··· 985 "createAccount": "Skapa konto", 986 "createDelegatedAccount": "Skapa delegerat konto", 987 "createDelegatedAccountButton": "+ Skapa delegerat konto", 988 - "creating": "Skapar...", 989 "emailOptional": "E-post (valfritt)", 990 "failedToAddController": "Kunde inte lägga till kontrollant", 991 "failedToCreateAccount": "Kunde inte skapa delegerat konto", ··· 1059 "navDesc": "Flytta ditt konto till eller från en annan PDS", 1060 "migrateHere": "Flytta hit", 1061 "migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.", 1062 - "migrateAway": "Flytta bort", 1063 - "migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.", 1064 - "loginRequired": "Inloggning krävs", 1065 "bringDid": "Ta med din DID och identitet", 1066 "transferData": "Överför all din data", 1067 "keepFollowers": "Behåll dina följare", 1068 - "exportRepo": "Exportera ditt arkiv", 1069 - "transferToPds": "Överför till ny PDS", 1070 - "updateIdentity": "Uppdatera din identitet", 1071 "whatIsMigration": "Vad är kontoflyttning?", 1072 "whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.", 1073 "beforeMigrate": "Innan du flyttar", ··· 1077 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1078 "importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.", 1079 "learnMore": "Läs mer om flyttningsrisker", 1080 - "comingSoon": "Kommer snart", 1081 "oauthCompleting": "Slutför autentisering...", 1082 "oauthFailed": "Autentisering misslyckades", 1083 "tryAgain": "Försök igen", ··· 1086 "incomplete": "Du har en ofullständig flytt pågående:", 1087 "direction": "Riktning", 1088 "migratingHere": "Flyttar hit", 1089 - "migratingAway": "Flyttar bort", 1090 "from": "Från", 1091 "to": "Till", 1092 "progress": "Framsteg", ··· 1229 "error": { 1230 "title": "Flyttfel", 1231 "desc": "Ett fel uppstod under flytten.", 1232 - "startOver": "Börja om" 1233 }, 1234 "common": { 1235 "back": "Tillbaka", ··· 1247 "warning3": "Ditt gamla konto kommer att inaktiveras efter flytten" 1248 } 1249 }, 1250 - "outbound": { 1251 "welcome": { 1252 - "title": "Flytta från denna PDS", 1253 - "desc": "Flytta ditt konto till en annan personlig dataserver.", 1254 - "warning": "Efter flytten kommer ditt konto här att inaktiveras.", 1255 - "didWebNotice": "did:web-flyttmeddelande", 1256 - "didWebNoticeDesc": "Ditt konto använder en did:web-identifierare ({did}). Efter flytten kommer denna PDS att fortsätta servera ditt DID-dokument som pekar till den nya PDS. Din identitet kommer att fungera så länge denna server är online.", 1257 - "understand": "Jag förstår riskerna och vill fortsätta" 1258 }, 1259 - "targetPds": { 1260 - "title": "Välj mål-PDS", 1261 - "desc": "Ange URL:en för PDS du vill flytta till.", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "Validera och fortsätt", 1265 - "validating": "Validerar...", 1266 - "connected": "Ansluten till {name}", 1267 - "inviteRequired": "Inbjudningskod krävs", 1268 - "privacyPolicy": "Integritetspolicy", 1269 - "termsOfService": "Användarvillkor" 1270 }, 1271 - "newAccount": { 1272 - "title": "Nya kontouppgifter", 1273 - "desc": "Konfigurera ditt konto på den nya PDS.", 1274 - "handle": "Användarnamn", 1275 - "availableDomains": "Tillgängliga domäner", 1276 - "email": "E-post", 1277 - "password": "Lösenord", 1278 - "confirmPassword": "Bekräfta lösenord", 1279 - "inviteCode": "Inbjudningskod" 1280 }, 1281 - "review": { 1282 - "title": "Granska flytt", 1283 - "desc": "Granska och bekräfta dina flyttdetaljer.", 1284 - "currentHandle": "Nuvarande användarnamn", 1285 - "newHandle": "Nytt användarnamn", 1286 - "sourcePds": "Denna PDS", 1287 - "targetPds": "Mål-PDS", 1288 - "confirm": "Jag bekräftar att jag vill flytta mitt konto", 1289 - "startMigration": "Starta flytt" 1290 }, 1291 - "migrating": { 1292 - "title": "Flyttar ditt konto", 1293 - "desc": "Vänta medan vi överför din data..." 1294 }, 1295 - "plcToken": { 1296 - "title": "Verifiera din identitet", 1297 - "desc": "En verifieringskod har skickats till din e-post." 1298 }, 1299 - "finalizing": { 1300 - "title": "Slutför flytt", 1301 - "desc": "Vänta medan vi slutför flytten...", 1302 - "updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..." 1303 }, 1304 "success": { 1305 - "title": "Flytt klar!", 1306 - "desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.", 1307 - "newHandle": "Nytt användarnamn", 1308 - "newPds": "Ny PDS", 1309 - "nextSteps": "Nästa steg", 1310 - "nextSteps1": "Logga in på din nya PDS", 1311 - "nextSteps2": "Uppdatera dina appar med nya uppgifter", 1312 - "nextSteps3": "Dina följare kommer automatiskt se din nya plats", 1313 - "loggingOut": "Loggar ut om {seconds} sekunder..." 1314 } 1315 }, 1316 "progress": {
··· 17 "dashboard": "Kontrollpanel", 18 "backToDashboard": "← Kontrollpanel", 19 "copied": "Kopierat!", 20 + "copyToClipboard": "Kopiera", 21 + "verifying": "Verifierar...", 22 + "saving": "Sparar...", 23 + "creating": "Skapar...", 24 + "updating": "Uppdaterar...", 25 + "sending": "Skickar...", 26 + "authenticating": "Autentiserar...", 27 + "checking": "Kontrollerar...", 28 + "redirecting": "Omdirigerar...", 29 + "signIn": "Logga in", 30 + "verify": "Verifiera", 31 + "remove": "Ta bort", 32 + "revoke": "Återkalla", 33 + "resendCode": "Skicka kod igen", 34 + "startOver": "Börja om", 35 + "tryAgain": "Försök igen", 36 + "password": "Lösenord", 37 + "email": "E-post", 38 + "emailAddress": "E-postadress", 39 + "handle": "Användarnamn", 40 + "did": "DID", 41 + "verificationCode": "Verifieringskod", 42 + "inviteCode": "Inbjudningskod", 43 + "newPassword": "Nytt lösenord", 44 + "confirmPassword": "Bekräfta lösenord", 45 + "enterSixDigitCode": "Ange 6-siffrig kod", 46 + "passwordHint": "Minst 8 tecken", 47 + "enterPassword": "Ange ditt lösenord", 48 + "emailPlaceholder": "du@exempel.se", 49 + "verified": "Verifierad", 50 + "disabled": "Inaktiverad", 51 + "available": "Tillgänglig", 52 + "deactivated": "Avaktiverad", 53 + "unverified": "Overifierad", 54 + "backToLogin": "Tillbaka till inloggning", 55 + "backToSettings": "Tillbaka till inställningar", 56 + "alreadyHaveAccount": "Har du redan ett konto?", 57 + "createAccount": "Skapa konto", 58 + "passwordsMismatch": "Lösenorden matchar inte", 59 + "passwordTooShort": "Lösenordet måste vara minst 8 tecken" 60 }, 61 "login": { 62 "title": "Logga in", ··· 88 "codeLabel": "Verifieringskod", 89 "codePlaceholder": "Ange 6-siffrig kod", 90 "verifyButton": "Verifiera konto", 91 + "resent": "Verifieringskod skickad igen!" 92 }, 93 "register": { 94 "title": "Skapa konto", ··· 159 "inviteCodePlaceholder": "Ange din inbjudningskod", 160 "inviteCodeRequired": "krävs", 161 "createButton": "Skapa konto", 162 "alreadyHaveAccount": "Har du redan ett konto?", 163 "signIn": "Logga in", 164 "wantPasswordless": "Vill du ha lösenordsfri säkerhet?", ··· 213 "navAdminDesc": "Serverstatistik och administratörsoperationer", 214 "navDidDocument": "DID-dokument", 215 "navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar", 216 + "navDidDocumentDescActive": "Redigera dina DID-dokumentinställningar", 217 + "navBackup": "Ladda ner säkerhetskopia", 218 + "navBackupDesc": "Ladda ner ditt dataförvar som en CAR-fil", 219 + "downloadingBackup": "Laddar ner...", 220 + "backupFailed": "Kunde inte ladda ner säkerhetskopia", 221 "migrated": "Flyttad", 222 "migratedTitle": "Konto flyttat", 223 "migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.", ··· 247 "serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.", 248 "currentPds": "Nuvarande PDS-URL", 249 "save": "Spara ändringar", 250 "success": "DID-dokumentet har uppdaterats", 251 "saveFailed": "Kunde inte spara DID-dokument", 252 "loadFailed": "Kunde inte ladda DID-dokument", ··· 284 "yourDomain": "Din domän", 285 "yourDomainPlaceholder": "exempel.se", 286 "verifyAndUpdate": "Verifiera och uppdatera användarnamn", 287 "newHandle": "Nytt användarnamn", 288 "newHandlePlaceholder": "dittanvändarnamn", 289 "changeHandleButton": "Ändra användarnamn", ··· 299 "exportData": "Exportera data", 300 "exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.", 301 "downloadRepo": "Ladda ner arkiv", 302 + "downloadBlobs": "Ladda ner media", 303 "exporting": "Exporterar...", 304 + "backups": { 305 + "title": "Säkerhetskopior", 306 + "description": "Hantera automatiska säkerhetskopior och återställ din kontodata. Säkerhetskopior inkluderar alla poster och blobbar.", 307 + "enableAutomatic": "Automatiska säkerhetskopior", 308 + "enabled": "Aktiverad", 309 + "disabled": "Inaktiverad", 310 + "toggleFailed": "Kunde inte ändra säkerhetskopieringsinställning", 311 + "noBackups": "Inga säkerhetskopior ännu", 312 + "blocks": "block", 313 + "download": "Ladda ner", 314 + "delete": "Radera", 315 + "createNow": "Skapa säkerhetskopia nu", 316 + "created": "Säkerhetskopia skapad", 317 + "createFailed": "Kunde inte skapa säkerhetskopia", 318 + "downloadFailed": "Kunde inte ladda ner säkerhetskopia", 319 + "deleted": "Säkerhetskopia raderad", 320 + "deleteFailed": "Kunde inte radera säkerhetskopia", 321 + "restoreTitle": "Återställ från säkerhetskopia", 322 + "restoreDescription": "Återställ din kontodata från en tidigare exporterad CAR-fil. Detta ersätter ditt nuvarande dataförvar med den uppladdade säkerhetskopian.", 323 + "selectFile": "Välj CAR-fil", 324 + "selectedFile": "Vald fil", 325 + "restore": "Återställ säkerhetskopia", 326 + "restoring": "Återställer...", 327 + "restored": "Säkerhetskopia återställd", 328 + "restoreFailed": "Kunde inte återställa säkerhetskopia" 329 + }, 330 "deleteAccount": "Radera konto", 331 "deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.", 332 "requestDeletion": "Begär kontoradering", ··· 355 "deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.", 356 "deletionFailed": "Kunde inte radera kontot", 357 "repoExported": "Arkiv exporterat", 358 + "blobsExported": "Mediafiler exporterade", 359 + "noBlobsToExport": "Inga mediafiler att exportera", 360 + "exportFailed": "Export misslyckades", 361 "confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras." 362 } 363 }, ··· 372 "noPasswords": "Inga applösenord ännu", 373 "revoke": "Återkalla", 374 "revoking": "Återkallar...", 375 "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.", 376 "saveWarningTitle": "Viktigt: Spara detta applösenord!", 377 "saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.", ··· 419 "used": "Använd av @{handle}", 420 "disabled": "Inaktiverad", 421 "usedBy": "Använd av", 422 "disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.", 423 "created": "Inbjudningskod skapad", 424 "copy": "Kopiera", ··· 546 "verifyButton": "Verifiera", 547 "verifyCodePlaceholder": "Ange verifieringskod", 548 "submit": "Skicka", 549 "savePreferences": "Spara inställningar", 550 "preferencesSaved": "Kommunikationsinställningar sparade", 551 "verifiedSuccess": "{channel} verifierad", ··· 584 "noCollectionsYet": "Inga samlingar ännu. Skapa din första post för att komma igång.", 585 "loadMore": "Ladda fler", 586 "recordJson": "Post-JSON", 587 "updateRecord": "Uppdatera post", 588 "collectionNsid": "Samling (NSID)", 589 "recordKeyOptional": "Postnyckel (valfri)", 590 "autoGenerated": "Genereras automatiskt om tom (TID)", 591 "autoGeneratedHint": "Lämna tom för att automatiskt generera en TID-baserad nyckel", 592 "demoPostText": "Hej från min PDS! Detta är mitt första inlägg.", 593 "demoDisplayName": "Ditt visningsnamn", 594 "demoBio": "En kort presentation om dig själv." ··· 609 "primaryLight": "Primär (ljust läge)", 610 "primaryDark": "Primär (mörkt läge)", 611 "configSaved": "Serverkonfiguration sparad", 612 "saveConfig": "Spara konfiguration", 613 "serverStats": "Serverstatistik", 614 "users": "Användare", ··· 699 "title": "Tvåfaktorsautentisering", 700 "subtitle": "Ytterligare verifiering krävs", 701 "usePasskey": "Använd nyckel", 702 + "useTotp": "Använd autentiseringsapp" 703 }, 704 "twoFactorCode": { 705 "title": "Tvåfaktorsautentisering", 706 "subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan för att fortsätta.", 707 "codeLabel": "Verifieringskod", 708 "codePlaceholder": "Ange 6-siffrig kod", 709 "errors": { 710 "missingRequestUri": "Saknar request_uri-parameter", 711 "verificationFailed": "Verifiering misslyckades", ··· 717 "title": "Ange autentiseringskod", 718 "subtitle": "Ange den 6-siffriga koden från din autentiseringsapp", 719 "codePlaceholder": "Ange 6-siffrig kod", 720 "useBackupCode": "Använd reservkod istället", 721 "backupCodePlaceholder": "Ange reservkod", 722 "trustDevice": "Lita på denna enhet i 30 dagar", ··· 746 "codeLabel": "Verifieringskod", 747 "codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck", 748 "verifyButton": "Verifiera konto", 749 "pleaseWait": "Vänta...", 750 "codeResent": "Verifieringskod skickad igen!", 751 "codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.", 752 "verified": "Verifierad!", ··· 756 "identifierLabel": "E-post eller identifierare", 757 "identifierPlaceholder": "du@exempel.se", 758 "identifierHelp": "E-postadressen eller identifieraren koden skickades till", 759 "verifyingAccount": "Verifierar konto: @{handle}", 760 "startOver": "Börja om med ett annat konto", 761 "noPending": "Ingen väntande verifiering hittades.", 762 "noPendingInfo": "Om du nyligen skapade ett konto och behöver verifiera det kan du behöva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.", 763 "createAccount": "Skapa konto", 764 "signIn": "Logga in", 765 "emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress", 766 "emailUpdateFailed": "Kunde inte uppdatera e-postadress", 767 "emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.", ··· 794 "resetButton": "Återställ lösenord", 795 "resetting": "Återställer...", 796 "success": "Lösenord återställt!", 797 "requestNewCode": "Begär ny kod", 798 "passwordsMismatch": "Lösenorden matchar inte", 799 "passwordLength": "Lösenordet måste vara minst 8 tecken" ··· 837 "howItWorks": "Så fungerar det", 838 "howItWorksDetail": "Vi skickar en säker länk till din registrerade meddelandekanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.", 839 "sendRecoveryLink": "Skicka återställningslänk", 840 + "sending": "Skickar..." 841 }, 842 "registerPasskey": { 843 "title": "Skapa nyckelkonto", ··· 858 "externalDid": "Din did:web", 859 "externalDidPlaceholder": "did:web:dindomän.se", 860 "createButton": "Skapa konto", 861 "alreadyHaveAccount": "Har du redan ett konto?", 862 "signIn": "Logga in", 863 "wantPassword": "Vill du använda ett lösenord?", ··· 956 "useTotp": "Använd autentiserare", 957 "passwordPlaceholder": "Ange ditt lösenord", 958 "totpPlaceholder": "Ange 6-siffrig kod", 959 "authenticating": "Autentiserar...", 960 "passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.", 961 "cancel": "Avbryt" ··· 1028 "createAccount": "Skapa konto", 1029 "createDelegatedAccount": "Skapa delegerat konto", 1030 "createDelegatedAccountButton": "+ Skapa delegerat konto", 1031 "emailOptional": "E-post (valfritt)", 1032 "failedToAddController": "Kunde inte lägga till kontrollant", 1033 "failedToCreateAccount": "Kunde inte skapa delegerat konto", ··· 1101 "navDesc": "Flytta ditt konto till eller från en annan PDS", 1102 "migrateHere": "Flytta hit", 1103 "migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.", 1104 "bringDid": "Ta med din DID och identitet", 1105 "transferData": "Överför all din data", 1106 "keepFollowers": "Behåll dina följare", 1107 "whatIsMigration": "Vad är kontoflyttning?", 1108 "whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.", 1109 "beforeMigrate": "Innan du flyttar", ··· 1113 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1114 "importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.", 1115 "learnMore": "Läs mer om flyttningsrisker", 1116 + "offlineRestore": "Offline-återställning", 1117 + "offlineRestoreDesc": "Återställ från backup när din gamla PDS inte är tillgänglig.", 1118 + "offlineFeature1": "Använd en CAR-fil backup", 1119 + "offlineFeature2": "Bevisa ägande med rotationsnyckel", 1120 + "offlineFeature3": "Återställning för nedstängda servrar", 1121 "oauthCompleting": "Slutför autentisering...", 1122 "oauthFailed": "Autentisering misslyckades", 1123 "tryAgain": "Försök igen", ··· 1126 "incomplete": "Du har en ofullständig flytt pågående:", 1127 "direction": "Riktning", 1128 "migratingHere": "Flyttar hit", 1129 "from": "Från", 1130 "to": "Till", 1131 "progress": "Framsteg", ··· 1268 "error": { 1269 "title": "Flyttfel", 1270 "desc": "Ett fel uppstod under flytten.", 1271 + "startOver": "Börja om", 1272 + "unknown": "Ett okänt fel uppstod." 1273 }, 1274 "common": { 1275 "back": "Tillbaka", ··· 1287 "warning3": "Ditt gamla konto kommer att inaktiveras efter flytten" 1288 } 1289 }, 1290 + "offline": { 1291 "welcome": { 1292 + "title": "Återställ från backup", 1293 + "desc": "Återställ ditt konto med en CAR-fil backup och rotationsnyckel. Använd detta när din tidigare PDS inte är tillgänglig.", 1294 + "warningTitle": "När du ska använda denna metod", 1295 + "warningDesc": "Denna offline-återställning är för katastrofåterställning när din gamla PDS har stängts ner, är oåtkomlig eller du blev utelåst. Om din gamla PDS fortfarande är tillgänglig, använd standardflytten istället.", 1296 + "requirementsTitle": "Du behöver", 1297 + "requirement1": "En CAR-fil backup av ditt arkiv", 1298 + "requirement2": "Din rotationsnyckel (privat nyckel för ditt DID)", 1299 + "requirement3": "Ditt DID (did:plc:xxx)", 1300 + "understand": "Jag förstår och vill fortsätta" 1301 }, 1302 + "provideDid": { 1303 + "title": "Ange ditt DID", 1304 + "desc": "Ange DID för kontot du vill återställa.", 1305 + "label": "Ditt DID", 1306 + "hint": "Din decentraliserade identifierare (t.ex. did:plc:abc123)" 1307 }, 1308 + "uploadCar": { 1309 + "title": "Ladda upp CAR-fil", 1310 + "desc": "Ladda upp din arkiv-backupfil.", 1311 + "label": "CAR-fil", 1312 + "hint": "Välj .car-filen från din backup", 1313 + "reuploadWarningTitle": "CAR-fil krävs", 1314 + "reuploadWarning": "Din session har återställts, men du måste ladda upp din CAR-fil igen. Av säkerhetsskäl lagras inte filinnehåll mellan sessioner." 1315 }, 1316 + "rotationKey": { 1317 + "title": "Ange rotationsnyckel", 1318 + "desc": "Ange din rotationsnyckel för att bevisa ägande av detta DID.", 1319 + "securityWarningTitle": "Säkerhetsvarning", 1320 + "securityWarning1": "Din rotationsnyckel är extremt känslig - behandla den som ett huvudlösenord", 1321 + "securityWarning2": "Ange den endast på betrodda enheter och nätverk", 1322 + "securityWarning3": "Denna nyckel kommer inte att lagras efter att flytten slutförts", 1323 + "label": "Rotationsnyckel", 1324 + "placeholder": "Ange privat nyckel (hex, base58 eller JWK)", 1325 + "hint": "Den privata nyckeln som motsvarar en av rotationsnycklarna i ditt DID-dokument", 1326 + "valid": "Nyckeln är giltig och matchar en rotationsnyckel i ditt DID", 1327 + "invalid": "Nyckeln matchar inte någon rotationsnyckel i ditt DID-dokument", 1328 + "validating": "Validerar nyckel...", 1329 + "validate": "Validera nyckel" 1330 }, 1331 + "chooseHandle": { 1332 + "migratingDid": "Återställer DID" 1333 }, 1334 + "review": { 1335 + "desc": "Granska dina offline-återställningsuppgifter.", 1336 + "carFile": "CAR-fil", 1337 + "rotationKey": "Rotationsnyckel", 1338 + "warning": "När du startar återställningen kommer din identitet att uppdateras för att peka på denna PDS. Detta kan inte enkelt ångras.", 1339 + "plcWarningTitle": "Ingen återvändo", 1340 + "plcWarning": "När du startar kommer ditt DID-dokument att uppdateras för att peka på denna PDS. Om något går fel kan du använda din rotationsnyckel för att återställa, men du bör slutföra flytten för att undvika ett trasigt identitetstillstånd." 1341 }, 1342 + "migrating": { 1343 + "title": "Återställer konto", 1344 + "desc": "Vänta medan ditt konto återställs...", 1345 + "creating": "Skapar konto", 1346 + "importing": "Importerar arkiv", 1347 + "plcSigning": "Uppdaterar identitet", 1348 + "activating": "Aktiverar konto" 1349 }, 1350 "success": { 1351 + "desc": "Ditt konto har framgångsrikt återställts till denna PDS." 1352 + }, 1353 + "blobs": { 1354 + "title": "Flyttar blobbar", 1355 + "desc": "Försöker återställa bilder och media från din gamla PDS...", 1356 + "migrating": "Flyttar blobbar", 1357 + "failedTitle": "Vissa blobbar kunde inte flyttas", 1358 + "failedDesc": "{count} blobbar kunde inte hämtas från din gamla PDS. Detta kan hända om servern är otillgänglig eller om filerna raderades.", 1359 + "sourceUnreachableTitle": "Käll-PDS otillgänglig", 1360 + "sourceUnreachable": "Kunde inte ansluta till din gamla PDS för att hämta mediafiler. Detta är vanligt vid flytt från en nedstängd server. Dina inlägg kommer att fungera, men vissa bilder kan saknas." 1361 } 1362 }, 1363 "progress": {
+147 -100
frontend/src/locales/zh.json
··· 17 "dashboard": "控制台", 18 "backToDashboard": "← 返回控制台", 19 "copied": "已复制!", 20 - "copyToClipboard": "复制" 21 }, 22 "login": { 23 "title": "登录", ··· 49 "codeLabel": "验证码", 50 "codePlaceholder": "输入6位验证码", 51 "verifyButton": "验证账户", 52 - "verifying": "验证中...", 53 - "resendButton": "重新发送验证码", 54 - "resending": "发送中...", 55 - "resent": "验证码已重新发送!", 56 - "backToLogin": "返回登录" 57 }, 58 "register": { 59 "title": "创建账户", ··· 124 "inviteCodePlaceholder": "输入您的邀请码", 125 "inviteCodeRequired": "必填", 126 "createButton": "创建账户", 127 - "creating": "正在创建...", 128 "alreadyHaveAccount": "已有账户?", 129 "signIn": "立即登录", 130 "wantPasswordless": "想要无密码登录?", ··· 179 "navAdminDesc": "服务器统计和管理操作", 180 "navDidDocument": "DID 文档", 181 "navDidDocumentDesc": "管理您的 DID 文档和密钥", 182 "migrated": "已迁移", 183 "migratedTitle": "账户已迁移", 184 "migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。", ··· 208 "serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。", 209 "currentPds": "当前 PDS URL", 210 "save": "保存更改", 211 - "saving": "保存中...", 212 "success": "DID 文档已更新", 213 "saveFailed": "保存 DID 文档失败", 214 "loadFailed": "加载 DID 文档失败", ··· 246 "yourDomain": "您的域名", 247 "yourDomainPlaceholder": "example.com", 248 "verifyAndUpdate": "验证并更新用户名", 249 - "verifying": "验证中...", 250 "newHandle": "新用户名", 251 "newHandlePlaceholder": "yourhandle", 252 "changeHandleButton": "更改用户名", ··· 262 "exportData": "导出数据", 263 "exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。", 264 "downloadRepo": "下载数据", 265 "exporting": "导出中...", 266 "deleteAccount": "删除账户", 267 "deleteWarning": "此操作不可逆。您的所有数据将被永久删除。", 268 "requestDeletion": "请求删除账户", ··· 291 "deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。", 292 "deletionFailed": "账户删除失败", 293 "repoExported": "数据导出成功", 294 - "exportFailed": "数据导出失败", 295 "confirmDelete": "您确定要删除账户吗?此操作无法撤销。" 296 } 297 }, ··· 306 "noPasswords": "暂无应用专用密码", 307 "revoke": "撤销", 308 "revoking": "撤销中...", 309 - "creating": "创建中...", 310 "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", 311 "saveWarningTitle": "重要:请保存此应用专用密码!", 312 "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", ··· 354 "used": "已被 @{handle} 使用", 355 "disabled": "已禁用", 356 "usedBy": "使用者", 357 - "creating": "创建中...", 358 "disableConfirm": "禁用此邀请码?它将无法再被使用。", 359 "created": "邀请码已创建", 360 "copy": "复制", ··· 482 "verifyButton": "验证", 483 "verifyCodePlaceholder": "输入验证码", 484 "submit": "提交", 485 - "saving": "保存中...", 486 "savePreferences": "保存偏好设置", 487 "preferencesSaved": "通讯偏好已保存", 488 "verifiedSuccess": "{channel} 验证成功", ··· 521 "noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。", 522 "loadMore": "加载更多", 523 "recordJson": "记录 JSON", 524 - "saving": "保存中...", 525 "updateRecord": "更新记录", 526 "collectionNsid": "集合 (NSID)", 527 "recordKeyOptional": "记录键(可选)", 528 "autoGenerated": "留空自动生成 (TID)", 529 "autoGeneratedHint": "留空将自动生成基于 TID 的键", 530 - "creating": "创建中...", 531 "demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。", 532 "demoDisplayName": "你的显示名称", 533 "demoBio": "写一段简短的自我介绍。" ··· 551 "secondaryLight": "副色(浅色模式)", 552 "secondaryDark": "副色(深色模式)", 553 "configSaved": "服务器配置已保存", 554 - "saving": "保存中...", 555 "saveConfig": "保存配置", 556 "serverStats": "服务器统计", 557 "users": "用户", ··· 639 "title": "双重身份验证", 640 "subtitle": "需要额外验证", 641 "usePasskey": "使用通行密钥", 642 - "useTotp": "使用身份验证器", 643 - "verifying": "验证中..." 644 }, 645 "twoFactorCode": { 646 "title": "双重身份验证", 647 "subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。", 648 "codeLabel": "验证码", 649 "codePlaceholder": "输入6位验证码", 650 - "verify": "验证", 651 - "verifying": "验证中...", 652 "errors": { 653 "missingRequestUri": "缺少 request_uri 参数", 654 "verificationFailed": "验证失败", ··· 660 "title": "输入验证码", 661 "subtitle": "请输入身份验证器应用中的6位验证码", 662 "codePlaceholder": "输入6位验证码", 663 - "verify": "验证", 664 - "verifying": "验证中...", 665 "useBackupCode": "使用备用验证码", 666 "backupCodePlaceholder": "输入备用验证码", 667 "trustDevice": "信任此设备30天", ··· 691 "codeLabel": "验证码", 692 "codeHelp": "复制消息中的完整验证码,包括横线", 693 "verifyButton": "验证账户", 694 - "verify": "验证", 695 - "verifying": "验证中...", 696 "pleaseWait": "请稍候...", 697 - "resendCode": "重新发送验证码", 698 - "resending": "发送中...", 699 - "sending": "发送中...", 700 "codeResent": "验证码已重新发送!", 701 "codeResentDetail": "验证码已发送!请查收。", 702 - "backToLogin": "返回登录", 703 "verifyingAccount": "正在验证账户:@{handle}", 704 "startOver": "使用其他账户重新开始", 705 "noPending": "未找到待验证的账户", ··· 713 "identifierLabel": "邮箱或标识符", 714 "identifierPlaceholder": "you@example.com", 715 "identifierHelp": "接收验证码的邮箱地址或标识符", 716 - "backToSettings": "返回设置", 717 "emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址", 718 "emailUpdateFailed": "更新邮箱地址失败", 719 "emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。", ··· 746 "resetButton": "重置密码", 747 "resetting": "重置中...", 748 "success": "密码重置成功!", 749 - "backToLogin": "返回登录", 750 "requestNewCode": "重新获取验证码", 751 "passwordsMismatch": "两次输入的密码不一致", 752 "passwordLength": "密码至少需要8位字符" ··· 790 "howItWorks": "如何恢复", 791 "howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。", 792 "sendRecoveryLink": "发送恢复链接", 793 - "sending": "发送中...", 794 - "backToLogin": "返回登录" 795 }, 796 "registerPasskey": { 797 "title": "创建通行密钥账户", ··· 814 "inviteCode": "邀请码", 815 "inviteCodePlaceholder": "输入您的邀请码", 816 "createButton": "创建账户", 817 - "creating": "创建中...", 818 "continue": "继续", 819 "back": "返回", 820 "alreadyHaveAccount": "已有账户?", ··· 911 "useTotp": "使用身份验证器", 912 "passwordPlaceholder": "输入您的密码", 913 "totpPlaceholder": "输入6位验证码", 914 - "verify": "验证", 915 - "verifying": "验证中...", 916 "authenticating": "正在验证...", 917 "passkeyPrompt": "点击下方按钮使用通行密钥进行验证。", 918 "cancel": "取消" ··· 986 "createAccount": "创建账户", 987 "createDelegatedAccount": "创建委托账户", 988 "createDelegatedAccountButton": "+ 创建委托账户", 989 - "creating": "创建中...", 990 "emailOptional": "邮箱(可选)", 991 "failedToAddController": "添加控制者失败", 992 "failedToCreateAccount": "创建委托账户失败", ··· 1059 "navDesc": "将您的账户移至其他PDS或从其他PDS移入", 1060 "migrateHere": "迁移到此处", 1061 "migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。", 1062 - "migrateAway": "迁移离开", 1063 - "migrateAwayDesc": "将您的账户从此PDS移至其他服务器。", 1064 - "loginRequired": "需要登录", 1065 "bringDid": "携带您的DID和身份", 1066 "transferData": "转移所有数据", 1067 "keepFollowers": "保留您的关注者", 1068 - "exportRepo": "导出您的存储库", 1069 - "transferToPds": "转移到新PDS", 1070 - "updateIdentity": "更新您的身份", 1071 "whatIsMigration": "什么是账户迁移?", 1072 "whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。", 1073 "beforeMigrate": "迁移前须知", ··· 1077 "beforeMigrate4": "您的旧PDS将收到账户停用通知", 1078 "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", 1079 "learnMore": "了解更多迁移风险", 1080 - "comingSoon": "即将推出", 1081 "oauthCompleting": "正在完成身份验证...", 1082 "oauthFailed": "身份验证失败", 1083 "tryAgain": "重试", ··· 1086 "incomplete": "您有一个未完成的迁移:", 1087 "direction": "方向", 1088 "migratingHere": "正在迁移到此处", 1089 - "migratingAway": "正在迁移离开", 1090 "from": "从", 1091 "to": "到", 1092 "progress": "进度", ··· 1229 "error": { 1230 "title": "迁移错误", 1231 "desc": "迁移过程中发生错误。", 1232 - "startOver": "重新开始" 1233 }, 1234 "common": { 1235 "back": "返回", ··· 1247 "warning3": "迁移后您的旧账户将被停用" 1248 } 1249 }, 1250 - "outbound": { 1251 "welcome": { 1252 - "title": "从此PDS迁移离开", 1253 - "desc": "将您的账户移至另一个个人数据服务器。", 1254 - "warning": "迁移后,您在此处的账户将被停用。", 1255 - "didWebNotice": "did:web迁移通知", 1256 - "didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。", 1257 - "understand": "我了解风险并希望继续" 1258 }, 1259 - "targetPds": { 1260 - "title": "选择目标PDS", 1261 - "desc": "输入您要迁移到的PDS的URL。", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "验证并继续", 1265 - "validating": "验证中...", 1266 - "connected": "已连接到 {name}", 1267 - "inviteRequired": "需要邀请码", 1268 - "privacyPolicy": "隐私政策", 1269 - "termsOfService": "服务条款" 1270 }, 1271 - "newAccount": { 1272 - "title": "新账户详情", 1273 - "desc": "在新PDS上设置您的账户。", 1274 - "handle": "用户名", 1275 - "availableDomains": "可用域名", 1276 - "email": "邮箱", 1277 - "password": "密码", 1278 - "confirmPassword": "确认密码", 1279 - "inviteCode": "邀请码" 1280 }, 1281 - "review": { 1282 - "title": "检查迁移", 1283 - "desc": "请检查并确认您的迁移详情。", 1284 - "currentHandle": "当前用户名", 1285 - "newHandle": "新用户名", 1286 - "sourcePds": "此PDS", 1287 - "targetPds": "目标PDS", 1288 - "confirm": "我确认要迁移我的账户", 1289 - "startMigration": "开始迁移" 1290 }, 1291 - "migrating": { 1292 - "title": "正在迁移您的账户", 1293 - "desc": "请稍候,正在转移您的数据..." 1294 }, 1295 - "plcToken": { 1296 - "title": "验证您的身份", 1297 - "desc": "验证码已发送到您的邮箱。" 1298 }, 1299 - "finalizing": { 1300 - "title": "正在完成迁移", 1301 - "desc": "请稍候,正在完成迁移...", 1302 - "updatingForwarding": "正在更新DID文档转发..." 1303 }, 1304 "success": { 1305 - "title": "迁移完成!", 1306 - "desc": "您的账户已成功迁移到新PDS。", 1307 - "newHandle": "新用户名", 1308 - "newPds": "新PDS", 1309 - "nextSteps": "后续步骤", 1310 - "nextSteps1": "登录到您的新PDS", 1311 - "nextSteps2": "使用新凭据更新您的应用", 1312 - "nextSteps3": "您的关注者将自动看到您的新位置", 1313 - "loggingOut": "{seconds}秒后退出登录..." 1314 } 1315 }, 1316 "progress": {
··· 17 "dashboard": "控制台", 18 "backToDashboard": "← 返回控制台", 19 "copied": "已复制!", 20 + "copyToClipboard": "复制", 21 + "verifying": "验证中...", 22 + "saving": "保存中...", 23 + "creating": "创建中...", 24 + "updating": "更新中...", 25 + "sending": "发送中...", 26 + "authenticating": "认证中...", 27 + "checking": "检查中...", 28 + "redirecting": "跳转中...", 29 + "signIn": "登录", 30 + "verify": "验证", 31 + "remove": "移除", 32 + "revoke": "撤销", 33 + "resendCode": "重新发送验证码", 34 + "startOver": "重新开始", 35 + "tryAgain": "重试", 36 + "password": "密码", 37 + "email": "邮箱", 38 + "emailAddress": "邮箱地址", 39 + "handle": "用户名", 40 + "did": "DID", 41 + "verificationCode": "验证码", 42 + "inviteCode": "邀请码", 43 + "newPassword": "新密码", 44 + "confirmPassword": "确认密码", 45 + "enterSixDigitCode": "输入6位验证码", 46 + "passwordHint": "至少8个字符", 47 + "enterPassword": "请输入密码", 48 + "emailPlaceholder": "you@example.com", 49 + "verified": "已验证", 50 + "disabled": "已禁用", 51 + "available": "可用", 52 + "deactivated": "已停用", 53 + "unverified": "未验证", 54 + "backToLogin": "返回登录", 55 + "backToSettings": "返回设置", 56 + "alreadyHaveAccount": "已有账户?", 57 + "createAccount": "立即注册", 58 + "passwordsMismatch": "密码不匹配", 59 + "passwordTooShort": "密码至少需要8个字符" 60 }, 61 "login": { 62 "title": "登录", ··· 88 "codeLabel": "验证码", 89 "codePlaceholder": "输入6位验证码", 90 "verifyButton": "验证账户", 91 + "resent": "验证码已重新发送!" 92 }, 93 "register": { 94 "title": "创建账户", ··· 159 "inviteCodePlaceholder": "输入您的邀请码", 160 "inviteCodeRequired": "必填", 161 "createButton": "创建账户", 162 "alreadyHaveAccount": "已有账户?", 163 "signIn": "立即登录", 164 "wantPasswordless": "想要无密码登录?", ··· 213 "navAdminDesc": "服务器统计和管理操作", 214 "navDidDocument": "DID 文档", 215 "navDidDocumentDesc": "管理您的 DID 文档和密钥", 216 + "navDidDocumentDescActive": "编辑您的 DID 文档设置", 217 + "navBackup": "下载备份", 218 + "navBackupDesc": "将您的存储库下载为 CAR 文件", 219 + "downloadingBackup": "下载中...", 220 + "backupFailed": "下载备份失败", 221 "migrated": "已迁移", 222 "migratedTitle": "账户已迁移", 223 "migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。", ··· 247 "serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。", 248 "currentPds": "当前 PDS URL", 249 "save": "保存更改", 250 "success": "DID 文档已更新", 251 "saveFailed": "保存 DID 文档失败", 252 "loadFailed": "加载 DID 文档失败", ··· 284 "yourDomain": "您的域名", 285 "yourDomainPlaceholder": "example.com", 286 "verifyAndUpdate": "验证并更新用户名", 287 "newHandle": "新用户名", 288 "newHandlePlaceholder": "yourhandle", 289 "changeHandleButton": "更改用户名", ··· 299 "exportData": "导出数据", 300 "exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。", 301 "downloadRepo": "下载数据", 302 + "downloadBlobs": "下载媒体文件", 303 "exporting": "导出中...", 304 + "backups": { 305 + "title": "备份", 306 + "description": "管理自动备份并恢复账户数据。备份包括所有记录和文件。", 307 + "enableAutomatic": "自动备份", 308 + "enabled": "已启用", 309 + "disabled": "已禁用", 310 + "toggleFailed": "更改备份设置失败", 311 + "noBackups": "暂无备份", 312 + "blocks": "块", 313 + "download": "下载", 314 + "delete": "删除", 315 + "createNow": "立即创建备份", 316 + "created": "备份已创建", 317 + "createFailed": "创建备份失败", 318 + "downloadFailed": "下载备份失败", 319 + "deleted": "备份已删除", 320 + "deleteFailed": "删除备份失败", 321 + "restoreTitle": "从备份恢复", 322 + "restoreDescription": "从之前导出的 CAR 文件恢复账户数据。这将用上传的备份替换当前的存储库。", 323 + "selectFile": "选择 CAR 文件", 324 + "selectedFile": "已选文件", 325 + "restore": "恢复备份", 326 + "restoring": "恢复中...", 327 + "restored": "备份恢复成功", 328 + "restoreFailed": "备份恢复失败" 329 + }, 330 "deleteAccount": "删除账户", 331 "deleteWarning": "此操作不可逆。您的所有数据将被永久删除。", 332 "requestDeletion": "请求删除账户", ··· 355 "deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。", 356 "deletionFailed": "账户删除失败", 357 "repoExported": "数据导出成功", 358 + "blobsExported": "媒体文件导出成功", 359 + "noBlobsToExport": "没有可导出的媒体文件", 360 + "exportFailed": "导出失败", 361 "confirmDelete": "您确定要删除账户吗?此操作无法撤销。" 362 } 363 }, ··· 372 "noPasswords": "暂无应用专用密码", 373 "revoke": "撤销", 374 "revoking": "撤销中...", 375 "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", 376 "saveWarningTitle": "重要:请保存此应用专用密码!", 377 "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", ··· 419 "used": "已被 @{handle} 使用", 420 "disabled": "已禁用", 421 "usedBy": "使用者", 422 "disableConfirm": "禁用此邀请码?它将无法再被使用。", 423 "created": "邀请码已创建", 424 "copy": "复制", ··· 546 "verifyButton": "验证", 547 "verifyCodePlaceholder": "输入验证码", 548 "submit": "提交", 549 "savePreferences": "保存偏好设置", 550 "preferencesSaved": "通讯偏好已保存", 551 "verifiedSuccess": "{channel} 验证成功", ··· 584 "noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。", 585 "loadMore": "加载更多", 586 "recordJson": "记录 JSON", 587 "updateRecord": "更新记录", 588 "collectionNsid": "集合 (NSID)", 589 "recordKeyOptional": "记录键(可选)", 590 "autoGenerated": "留空自动生成 (TID)", 591 "autoGeneratedHint": "留空将自动生成基于 TID 的键", 592 "demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。", 593 "demoDisplayName": "你的显示名称", 594 "demoBio": "写一段简短的自我介绍。" ··· 612 "secondaryLight": "副色(浅色模式)", 613 "secondaryDark": "副色(深色模式)", 614 "configSaved": "服务器配置已保存", 615 "saveConfig": "保存配置", 616 "serverStats": "服务器统计", 617 "users": "用户", ··· 699 "title": "双重身份验证", 700 "subtitle": "需要额外验证", 701 "usePasskey": "使用通行密钥", 702 + "useTotp": "使用身份验证器" 703 }, 704 "twoFactorCode": { 705 "title": "双重身份验证", 706 "subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。", 707 "codeLabel": "验证码", 708 "codePlaceholder": "输入6位验证码", 709 "errors": { 710 "missingRequestUri": "缺少 request_uri 参数", 711 "verificationFailed": "验证失败", ··· 717 "title": "输入验证码", 718 "subtitle": "请输入身份验证器应用中的6位验证码", 719 "codePlaceholder": "输入6位验证码", 720 "useBackupCode": "使用备用验证码", 721 "backupCodePlaceholder": "输入备用验证码", 722 "trustDevice": "信任此设备30天", ··· 746 "codeLabel": "验证码", 747 "codeHelp": "复制消息中的完整验证码,包括横线", 748 "verifyButton": "验证账户", 749 "pleaseWait": "请稍候...", 750 "codeResent": "验证码已重新发送!", 751 "codeResentDetail": "验证码已发送!请查收。", 752 "verifyingAccount": "正在验证账户:@{handle}", 753 "startOver": "使用其他账户重新开始", 754 "noPending": "未找到待验证的账户", ··· 762 "identifierLabel": "邮箱或标识符", 763 "identifierPlaceholder": "you@example.com", 764 "identifierHelp": "接收验证码的邮箱地址或标识符", 765 "emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址", 766 "emailUpdateFailed": "更新邮箱地址失败", 767 "emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。", ··· 794 "resetButton": "重置密码", 795 "resetting": "重置中...", 796 "success": "密码重置成功!", 797 "requestNewCode": "重新获取验证码", 798 "passwordsMismatch": "两次输入的密码不一致", 799 "passwordLength": "密码至少需要8位字符" ··· 837 "howItWorks": "如何恢复", 838 "howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。", 839 "sendRecoveryLink": "发送恢复链接", 840 + "sending": "发送中..." 841 }, 842 "registerPasskey": { 843 "title": "创建通行密钥账户", ··· 860 "inviteCode": "邀请码", 861 "inviteCodePlaceholder": "输入您的邀请码", 862 "createButton": "创建账户", 863 "continue": "继续", 864 "back": "返回", 865 "alreadyHaveAccount": "已有账户?", ··· 956 "useTotp": "使用身份验证器", 957 "passwordPlaceholder": "输入您的密码", 958 "totpPlaceholder": "输入6位验证码", 959 "authenticating": "正在验证...", 960 "passkeyPrompt": "点击下方按钮使用通行密钥进行验证。", 961 "cancel": "取消" ··· 1029 "createAccount": "创建账户", 1030 "createDelegatedAccount": "创建委托账户", 1031 "createDelegatedAccountButton": "+ 创建委托账户", 1032 "emailOptional": "邮箱(可选)", 1033 "failedToAddController": "添加控制者失败", 1034 "failedToCreateAccount": "创建委托账户失败", ··· 1101 "navDesc": "将您的账户移至其他PDS或从其他PDS移入", 1102 "migrateHere": "迁移到此处", 1103 "migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。", 1104 "bringDid": "携带您的DID和身份", 1105 "transferData": "转移所有数据", 1106 "keepFollowers": "保留您的关注者", 1107 "whatIsMigration": "什么是账户迁移?", 1108 "whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。", 1109 "beforeMigrate": "迁移前须知", ··· 1113 "beforeMigrate4": "您的旧PDS将收到账户停用通知", 1114 "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", 1115 "learnMore": "了解更多迁移风险", 1116 + "offlineRestore": "离线恢复", 1117 + "offlineRestoreDesc": "当旧 PDS 不可用时从备份恢复。", 1118 + "offlineFeature1": "使用 CAR 文件备份", 1119 + "offlineFeature2": "使用轮换密钥证明所有权", 1120 + "offlineFeature3": "用于已关闭服务器的恢复", 1121 "oauthCompleting": "正在完成身份验证...", 1122 "oauthFailed": "身份验证失败", 1123 "tryAgain": "重试", ··· 1126 "incomplete": "您有一个未完成的迁移:", 1127 "direction": "方向", 1128 "migratingHere": "正在迁移到此处", 1129 "from": "从", 1130 "to": "到", 1131 "progress": "进度", ··· 1268 "error": { 1269 "title": "迁移错误", 1270 "desc": "迁移过程中发生错误。", 1271 + "startOver": "重新开始", 1272 + "unknown": "发生未知错误。" 1273 }, 1274 "common": { 1275 "back": "返回", ··· 1287 "warning3": "迁移后您的旧账户将被停用" 1288 } 1289 }, 1290 + "offline": { 1291 "welcome": { 1292 + "title": "从备份恢复", 1293 + "desc": "使用 CAR 文件备份和轮换密钥恢复您的账户。当您的旧 PDS 不可用时使用此方法。", 1294 + "warningTitle": "何时使用此方法", 1295 + "warningDesc": "此离线恢复用于灾难恢复,当您的旧 PDS 已关闭、无法访问或您被锁定时使用。如果您的旧 PDS 仍然可用,请使用标准迁移。", 1296 + "requirementsTitle": "您需要", 1297 + "requirement1": "您的存储库的 CAR 文件备份", 1298 + "requirement2": "您的轮换密钥(DID 的私钥)", 1299 + "requirement3": "您的 DID (did:plc:xxx)", 1300 + "understand": "我了解并希望继续" 1301 }, 1302 + "provideDid": { 1303 + "title": "输入您的 DID", 1304 + "desc": "输入您要恢复的账户的 DID。", 1305 + "label": "您的 DID", 1306 + "hint": "您的去中心化标识符(例如 did:plc:abc123)" 1307 }, 1308 + "uploadCar": { 1309 + "title": "上传 CAR 文件", 1310 + "desc": "上传您的存储库备份文件。", 1311 + "label": "CAR 文件", 1312 + "hint": "从您的备份中选择 .car 文件", 1313 + "reuploadWarningTitle": "需要 CAR 文件", 1314 + "reuploadWarning": "您的会话已恢复,但您需要重新上传 CAR 文件。出于安全原因,文件内容不会在会话之间保存。" 1315 }, 1316 + "rotationKey": { 1317 + "title": "提供轮换密钥", 1318 + "desc": "输入您的轮换密钥以证明此 DID 的所有权。", 1319 + "securityWarningTitle": "安全警告", 1320 + "securityWarning1": "您的轮换密钥极为敏感 - 请像对待主密码一样对待它", 1321 + "securityWarning2": "仅在受信任的设备和网络上输入", 1322 + "securityWarning3": "迁移完成后此密钥不会被存储", 1323 + "label": "轮换密钥", 1324 + "placeholder": "输入私钥(hex、base58 或 JWK)", 1325 + "hint": "与您的 DID 文档中的轮换密钥之一对应的私钥", 1326 + "valid": "密钥有效并匹配您的 DID 中的轮换密钥", 1327 + "invalid": "密钥与您的 DID 文档中的任何轮换密钥都不匹配", 1328 + "validating": "验证密钥...", 1329 + "validate": "验证密钥" 1330 }, 1331 + "chooseHandle": { 1332 + "migratingDid": "恢复 DID" 1333 }, 1334 + "review": { 1335 + "desc": "检查您的离线恢复详情。", 1336 + "carFile": "CAR 文件", 1337 + "rotationKey": "轮换密钥", 1338 + "warning": "开始恢复后,您的身份将更新为指向此 PDS。此操作无法轻易撤销。", 1339 + "plcWarningTitle": "不可逆转点", 1340 + "plcWarning": "一旦开始,您的 DID 文档将更新为指向此 PDS。如果出现问题,您可以使用轮换密钥恢复,但您应该完成迁移以避免身份状态损坏。" 1341 }, 1342 + "migrating": { 1343 + "title": "恢复账户", 1344 + "desc": "请稍候,正在恢复您的账户...", 1345 + "creating": "创建账户", 1346 + "importing": "导入存储库", 1347 + "plcSigning": "更新身份", 1348 + "activating": "激活账户" 1349 }, 1350 "success": { 1351 + "desc": "您的账户已成功恢复到此 PDS。" 1352 + }, 1353 + "blobs": { 1354 + "title": "迁移 Blob", 1355 + "desc": "正在尝试从您的旧 PDS 恢复图片和媒体...", 1356 + "migrating": "正在迁移 blob", 1357 + "failedTitle": "部分 blob 无法迁移", 1358 + "failedDesc": "{count} 个 blob 无法从您的旧 PDS 获取。这可能是因为服务器无法访问或文件已被删除。", 1359 + "sourceUnreachableTitle": "源 PDS 无法访问", 1360 + "sourceUnreachable": "无法连接到您的旧 PDS 来获取媒体文件。从已关闭的服务器迁移时这很常见。您的帖子将正常工作,但部分图片可能会丢失。" 1361 } 1362 }, 1363 "progress": {
+1 -1
frontend/src/routes/ActAs.svelte
··· 37 38 try { 39 const response = await fetch( 40 - `/xrpc/com.tranquil.delegation.listControlledAccounts`, 41 { 42 headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` } 43 }
··· 37 38 try { 39 const response = await fetch( 40 + `/xrpc/_delegation.listControlledAccounts`, 41 { 42 headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` } 43 }
+1 -1
frontend/src/routes/Admin.svelte
··· 435 <div class="message success">{$_('admin.configSaved')}</div> 436 {/if} 437 <button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}> 438 - {serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')} 439 </button> 440 </form> 441 </section>
··· 435 <div class="message success">{$_('admin.configSaved')}</div> 436 {/if} 437 <button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}> 438 + {serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')} 439 </button> 440 </form> 441 </section>
+1 -1
frontend/src/routes/AppPasswords.svelte
··· 155 </div> 156 </div> 157 <button type="submit" disabled={creating || !newPasswordName.trim()}> 158 - {creating ? $_('appPasswords.creating') : $_('common.create')} 159 </button> 160 </form> 161 </section>
··· 155 </div> 156 </div> 157 <button type="submit" disabled={creating || !newPasswordName.trim()}> 158 + {creating ? $_('common.creating') : $_('common.create')} 159 </button> 160 </form> 161 </section>
+1 -1
frontend/src/routes/Comms.svelte
··· 341 342 <div class="actions"> 343 <button type="submit" disabled={saving}> 344 - {saving ? $_('comms.saving') : $_('comms.savePreferences')} 345 </button> 346 </div> 347 </form>
··· 341 342 <div class="actions"> 343 <button type="submit" disabled={saving}> 344 + {saving ? $_('common.saving') : $_('comms.savePreferences')} 345 </button> 346 </div> 347 </form>
+7 -7
frontend/src/routes/Controllers.svelte
··· 75 async function loadControllers() { 76 if (!auth.session) return 77 try { 78 - const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', { 79 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 80 }) 81 if (response.ok) { ··· 90 async function loadControlledAccounts() { 91 if (!auth.session) return 92 try { 93 - const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', { 94 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 95 }) 96 if (response.ok) { ··· 104 105 async function loadScopePresets() { 106 try { 107 - const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets') 108 if (response.ok) { 109 const data = await response.json() 110 scopePresets = data.presets || [] ··· 121 success = null 122 123 try { 124 - const response = await fetch('/xrpc/com.tranquil.delegation.addController', { 125 method: 'POST', 126 headers: { 127 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 159 success = null 160 161 try { 162 - const response = await fetch('/xrpc/com.tranquil.delegation.removeController', { 163 method: 'POST', 164 headers: { 165 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 188 success = null 189 190 try { 191 - const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', { 192 method: 'POST', 193 headers: { 194 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 407 {$_('common.cancel')} 408 </button> 409 <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 410 - {creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')} 411 </button> 412 </div> 413 </div>
··· 75 async function loadControllers() { 76 if (!auth.session) return 77 try { 78 + const response = await fetch('/xrpc/_delegation.listControllers', { 79 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 80 }) 81 if (response.ok) { ··· 90 async function loadControlledAccounts() { 91 if (!auth.session) return 92 try { 93 + const response = await fetch('/xrpc/_delegation.listControlledAccounts', { 94 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 95 }) 96 if (response.ok) { ··· 104 105 async function loadScopePresets() { 106 try { 107 + const response = await fetch('/xrpc/_delegation.getScopePresets') 108 if (response.ok) { 109 const data = await response.json() 110 scopePresets = data.presets || [] ··· 121 success = null 122 123 try { 124 + const response = await fetch('/xrpc/_delegation.addController', { 125 method: 'POST', 126 headers: { 127 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 159 success = null 160 161 try { 162 + const response = await fetch('/xrpc/_delegation.removeController', { 163 method: 'POST', 164 headers: { 165 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 188 success = null 189 190 try { 191 + const response = await fetch('/xrpc/_delegation.createDelegatedAccount', { 192 method: 'POST', 193 headers: { 194 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 407 {$_('common.cancel')} 408 </button> 409 <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 410 + {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} 411 </button> 412 </div> 413 </div>
+21
frontend/src/routes/Dashboard.svelte
··· 10 let switching = $state(false) 11 let inviteCodesEnabled = $state(false) 12 13 onMount(async () => { 14 try { 15 const serverInfo = await api.describeServer() ··· 176 <h3>{$_('dashboard.navSecurity')}</h3> 177 <p>{$_('dashboard.navSecurityDesc')}</p> 178 </a> 179 <a href="#/migrate" class="nav-card"> 180 <h3>{$_('dashboard.navMigrateAgain')}</h3> 181 <p>{$_('dashboard.navMigrateAgainDesc')}</p> ··· 215 <h3>{$_('dashboard.navDelegation')}</h3> 216 <p>{$_('dashboard.navDelegationDesc')}</p> 217 </a> 218 <a href="#/migrate" class="nav-card"> 219 <h3>{$_('migration.navTitle')}</h3> 220 <p>{$_('migration.navDesc')}</p> ··· 503 504 .nav-card.migrated-card h3 { 505 color: var(--info-text, #0369a1); 506 } 507 </style>
··· 10 let switching = $state(false) 11 let inviteCodesEnabled = $state(false) 12 13 + const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false) 14 + 15 onMount(async () => { 16 try { 17 const serverInfo = await api.describeServer() ··· 178 <h3>{$_('dashboard.navSecurity')}</h3> 179 <p>{$_('dashboard.navSecurityDesc')}</p> 180 </a> 181 + <a href="#/settings" class="nav-card"> 182 + <h3>{$_('dashboard.navSettings')}</h3> 183 + <p>{$_('dashboard.navSettingsDesc')}</p> 184 + </a> 185 <a href="#/migrate" class="nav-card"> 186 <h3>{$_('dashboard.navMigrateAgain')}</h3> 187 <p>{$_('dashboard.navMigrateAgainDesc')}</p> ··· 221 <h3>{$_('dashboard.navDelegation')}</h3> 222 <p>{$_('dashboard.navDelegationDesc')}</p> 223 </a> 224 + {#if isDidWeb} 225 + <a href="#/did-document" class="nav-card did-web-card"> 226 + <h3>{$_('dashboard.navDidDocument')}</h3> 227 + <p>{$_('dashboard.navDidDocumentDescActive')}</p> 228 + </a> 229 + {/if} 230 <a href="#/migrate" class="nav-card"> 231 <h3>{$_('migration.navTitle')}</h3> 232 <p>{$_('migration.navDesc')}</p> ··· 515 516 .nav-card.migrated-card h3 { 517 color: var(--info-text, #0369a1); 518 + } 519 + 520 + .nav-card.did-web-card { 521 + border-color: var(--accent); 522 + background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%); 523 + } 524 + 525 + .nav-card.did-web-card:hover { 526 + box-shadow: 0 2px 12px var(--accent-muted); 527 } 528 </style>
+1 -1
frontend/src/routes/DelegationAudit.svelte
··· 41 42 try { 43 const response = await fetch( 44 - `/xrpc/com.tranquil.delegation.getAuditLog?limit=${limit}&offset=${offset}`, 45 { 46 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 47 }
··· 41 42 try { 43 const response = await fetch( 44 + `/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`, 45 { 46 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 47 }
+1 -1
frontend/src/routes/DidDocumentEditor.svelte
··· 230 231 <div class="actions"> 232 <button onclick={handleSave} disabled={saving}> 233 - {saving ? $_('didEditor.saving') : $_('didEditor.save')} 234 </button> 235 </div> 236 {/if}
··· 230 231 <div class="actions"> 232 <button onclick={handleSave} disabled={saving}> 233 + {saving ? $_('common.saving') : $_('common.save')} 234 </button> 235 </div> 236 {/if}
+5
frontend/src/routes/Home.svelte
··· 183 <h3>Delegate without sharing passwords</h3> 184 <p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p> 185 </div> 186 </div> 187 188 <h2>Everything in one place</h2>
··· 183 <h3>Delegate without sharing passwords</h3> 184 <p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p> 185 </div> 186 + 187 + <div class="feature"> 188 + <h3>Automatic backups</h3> 189 + <p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p> 190 + </div> 191 </div> 192 193 <h2>Everything in one place</h2>
+1 -1
frontend/src/routes/InviteCodes.svelte
··· 111 {#if auth.session?.isAdmin} 112 <section class="create-section"> 113 <button onclick={handleCreate} disabled={creating}> 114 - {creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')} 115 </button> 116 </section> 117 {/if}
··· 111 {#if auth.session?.isAdmin} 112 <section class="create-section"> 113 <button onclick={handleCreate} disabled={creating}> 114 + {creating ? $_('common.creating') : $_('inviteCodes.createNew')} 115 </button> 116 </section> 117 {/if}
+3 -3
frontend/src/routes/Login.svelte
··· 107 </div> 108 <div class="actions"> 109 <button type="submit" disabled={submitting || !verificationCode.trim()}> 110 - {submitting ? $_('verification.verifying') : $_('verification.verifyButton')} 111 </button> 112 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 113 - {resendingCode ? $_('verification.resending') : $_('verification.resendButton')} 114 </button> 115 <button type="button" class="tertiary" onclick={backToLogin}> 116 - {$_('verification.backToLogin')} 117 </button> 118 </div> 119 </form>
··· 107 </div> 108 <div class="actions"> 109 <button type="submit" disabled={submitting || !verificationCode.trim()}> 110 + {submitting ? $_('common.verifying') : $_('common.verify')} 111 </button> 112 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 113 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 114 </button> 115 <button type="button" class="tertiary" onclick={backToLogin}> 116 + {$_('common.backToLogin')} 117 </button> 118 </div> 119 </form>
+63 -69
frontend/src/routes/Migration.svelte
··· 1 <script lang="ts"> 2 - import { getAuthState, logout, setSession } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { _ } from '../lib/i18n' 5 import { 6 createInboundMigrationFlow, 7 - createOutboundMigrationFlow, 8 hasPendingMigration, 9 getResumeInfo, 10 clearMigrationState, 11 loadMigrationState, 12 } from '../lib/migration' 13 import InboundWizard from '../components/migration/InboundWizard.svelte' 14 - import OutboundWizard from '../components/migration/OutboundWizard.svelte' 15 16 - const auth = getAuthState() 17 - 18 - type Direction = 'select' | 'inbound' | 'outbound' 19 let direction = $state<Direction>('select') 20 let showResumeModal = $state(false) 21 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) ··· 23 let oauthLoading = $state(false) 24 25 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 26 - let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 27 let oauthCallbackProcessed = $state(false) 28 29 $effect(() => { ··· 66 const urlParams = new URLSearchParams(window.location.search) 67 const hasOAuthCallback = urlParams.has('code') || urlParams.has('error') 68 69 - if (!hasOAuthCallback && hasPendingMigration()) { 70 - resumeInfo = getResumeInfo() 71 - if (resumeInfo) { 72 - const stored = loadMigrationState() 73 - if (stored) { 74 - if (stored.direction === 'inbound') { 75 - direction = 'inbound' 76 - inboundFlow = createInboundMigrationFlow() 77 - inboundFlow.resumeFromState(stored) 78 } else { 79 - direction = 'outbound' 80 - outboundFlow = createOutboundMigrationFlow() 81 } 82 } 83 } 84 } 85 ··· 88 inboundFlow = createInboundMigrationFlow() 89 } 90 91 - function selectOutbound() { 92 - if (!auth.session) { 93 - navigate('/login') 94 - return 95 - } 96 - direction = 'outbound' 97 - outboundFlow = createOutboundMigrationFlow() 98 - outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle) 99 } 100 101 function handleResume() { ··· 108 direction = 'inbound' 109 inboundFlow = createInboundMigrationFlow() 110 inboundFlow.resumeFromState(stored) 111 - } else { 112 - if (!auth.session) { 113 - navigate('/login') 114 - return 115 - } 116 - direction = 'outbound' 117 - outboundFlow = createOutboundMigrationFlow() 118 - outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle) 119 } 120 } 121 ··· 130 inboundFlow.reset() 131 inboundFlow = null 132 } 133 - if (outboundFlow) { 134 - outboundFlow.reset() 135 - outboundFlow = null 136 } 137 direction = 'select' 138 } ··· 150 navigate('/dashboard') 151 } 152 153 - async function handleOutboundComplete() { 154 - await logout() 155 - navigate('/login') 156 } 157 </script> 158 ··· 165 <div class="resume-details"> 166 <div class="detail-row"> 167 <span class="label">{$_('migration.resume.direction')}:</span> 168 - <span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span> 169 </div> 170 {#if resumeInfo.sourceHandle} 171 <div class="detail-row"> ··· 212 213 <div class="direction-cards"> 214 <button class="direction-card ghost" onclick={selectInbound}> 215 - <div class="card-icon">↓</div> 216 <h2>{$_('migration.migrateHere')}</h2> 217 <p>{$_('migration.migrateHereDesc')}</p> 218 <ul class="features"> ··· 222 </ul> 223 </button> 224 225 - <button class="direction-card ghost" onclick={selectOutbound} disabled> 226 - <div class="card-icon">↑</div> 227 - <h2>{$_('migration.migrateAway')}</h2> 228 - <p>{$_('migration.migrateAwayDesc')}</p> 229 <ul class="features"> 230 - <li>{$_('migration.exportRepo')}</li> 231 - <li>{$_('migration.transferToPds')}</li> 232 - <li>{$_('migration.updateIdentity')}</li> 233 </ul> 234 - <p class="login-required">{$_('migration.comingSoon')}</p> 235 </button> 236 </div> 237 ··· 263 onComplete={handleInboundComplete} 264 /> 265 266 - {:else if direction === 'outbound' && outboundFlow} 267 - <OutboundWizard 268 - flow={outboundFlow} 269 onBack={handleBack} 270 - onComplete={handleOutboundComplete} 271 /> 272 {/if} 273 </div> ··· 302 } 303 304 .direction-card { 305 background: var(--bg-secondary); 306 border: 1px solid var(--border); 307 border-radius: var(--radius-xl); ··· 322 cursor: not-allowed; 323 } 324 325 - .card-icon { 326 - font-size: var(--text-3xl); 327 - margin-bottom: var(--space-4); 328 - color: var(--accent); 329 - } 330 - 331 .direction-card h2 { 332 margin: 0 0 var(--space-3) 0; 333 font-size: var(--text-xl); ··· 349 350 .features li { 351 margin-bottom: var(--space-2); 352 - } 353 - 354 - .login-required { 355 - color: var(--warning-text); 356 - font-weight: var(--font-medium); 357 - margin-top: var(--space-4); 358 } 359 360 .info-section { ··· 402 } 403 404 .warning-box a { 405 - display: block; 406 - margin-top: var(--space-3); 407 - color: var(--accent); 408 } 409 410 .modal-overlay {
··· 1 <script lang="ts"> 2 + import { setSession } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { _ } from '../lib/i18n' 5 import { 6 createInboundMigrationFlow, 7 + createOfflineInboundMigrationFlow, 8 hasPendingMigration, 9 + hasPendingOfflineMigration, 10 getResumeInfo, 11 + getOfflineResumeInfo, 12 clearMigrationState, 13 + clearOfflineState, 14 loadMigrationState, 15 } from '../lib/migration' 16 import InboundWizard from '../components/migration/InboundWizard.svelte' 17 + import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte' 18 19 + type Direction = 'select' | 'inbound' | 'offline-inbound' 20 let direction = $state<Direction>('select') 21 let showResumeModal = $state(false) 22 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) ··· 24 let oauthLoading = $state(false) 25 26 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 27 + let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null) 28 let oauthCallbackProcessed = $state(false) 29 30 $effect(() => { ··· 67 const urlParams = new URLSearchParams(window.location.search) 68 const hasOAuthCallback = urlParams.has('code') || urlParams.has('error') 69 70 + if (!hasOAuthCallback) { 71 + if (hasPendingMigration()) { 72 + resumeInfo = getResumeInfo() 73 + if (resumeInfo) { 74 + if (resumeInfo.step === 'success') { 75 + clearMigrationState() 76 + resumeInfo = null 77 } else { 78 + const stored = loadMigrationState() 79 + if (stored && stored.direction === 'inbound') { 80 + direction = 'inbound' 81 + inboundFlow = createInboundMigrationFlow() 82 + inboundFlow.resumeFromState(stored) 83 + } 84 } 85 } 86 + } else if (hasPendingOfflineMigration()) { 87 + const offlineInfo = getOfflineResumeInfo() 88 + if (offlineInfo && offlineInfo.step === 'success') { 89 + clearOfflineState() 90 + } else { 91 + direction = 'offline-inbound' 92 + offlineFlow = createOfflineInboundMigrationFlow() 93 + offlineFlow.tryResume() 94 + } 95 } 96 } 97 ··· 100 inboundFlow = createInboundMigrationFlow() 101 } 102 103 + function selectOfflineInbound() { 104 + direction = 'offline-inbound' 105 + offlineFlow = createOfflineInboundMigrationFlow() 106 } 107 108 function handleResume() { ··· 115 direction = 'inbound' 116 inboundFlow = createInboundMigrationFlow() 117 inboundFlow.resumeFromState(stored) 118 } 119 } 120 ··· 129 inboundFlow.reset() 130 inboundFlow = null 131 } 132 + if (offlineFlow) { 133 + offlineFlow.reset() 134 + offlineFlow = null 135 } 136 direction = 'select' 137 } ··· 149 navigate('/dashboard') 150 } 151 152 + function handleOfflineComplete() { 153 + const session = offlineFlow?.getLocalSession() 154 + if (session) { 155 + setSession({ 156 + did: session.did, 157 + handle: session.handle, 158 + accessJwt: session.accessJwt, 159 + refreshJwt: '', 160 + }) 161 + } 162 + navigate('/dashboard') 163 } 164 </script> 165 ··· 172 <div class="resume-details"> 173 <div class="detail-row"> 174 <span class="label">{$_('migration.resume.direction')}:</span> 175 + <span class="value">{$_('migration.resume.migratingHere')}</span> 176 </div> 177 {#if resumeInfo.sourceHandle} 178 <div class="detail-row"> ··· 219 220 <div class="direction-cards"> 221 <button class="direction-card ghost" onclick={selectInbound}> 222 <h2>{$_('migration.migrateHere')}</h2> 223 <p>{$_('migration.migrateHereDesc')}</p> 224 <ul class="features"> ··· 228 </ul> 229 </button> 230 231 + <button class="direction-card ghost offline-card" onclick={selectOfflineInbound}> 232 + <h2>{$_('migration.offlineRestore')}</h2> 233 + <p>{$_('migration.offlineRestoreDesc')}</p> 234 <ul class="features"> 235 + <li>{$_('migration.offlineFeature1')}</li> 236 + <li>{$_('migration.offlineFeature2')}</li> 237 + <li>{$_('migration.offlineFeature3')}</li> 238 </ul> 239 </button> 240 </div> 241 ··· 267 onComplete={handleInboundComplete} 268 /> 269 270 + {:else if direction === 'offline-inbound' && offlineFlow} 271 + <OfflineInboundWizard 272 + flow={offlineFlow} 273 onBack={handleBack} 274 + onComplete={handleOfflineComplete} 275 /> 276 {/if} 277 </div> ··· 306 } 307 308 .direction-card { 309 + display: flex; 310 + flex-direction: column; 311 + align-items: stretch; 312 background: var(--bg-secondary); 313 border: 1px solid var(--border); 314 border-radius: var(--radius-xl); ··· 329 cursor: not-allowed; 330 } 331 332 .direction-card h2 { 333 margin: 0 0 var(--space-3) 0; 334 font-size: var(--text-xl); ··· 350 351 .features li { 352 margin-bottom: var(--space-2); 353 } 354 355 .info-section { ··· 397 } 398 399 .warning-box a { 400 + display: inline; 401 + margin-top: var(--space-2); 402 } 403 404 .modal-overlay {
+1 -1
frontend/src/routes/OAuth2FA.svelte
··· 105 {$_('common.cancel')} 106 </button> 107 <button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}> 108 - {submitting ? $_('oauth.twoFactorCode.verifying') : $_('oauth.twoFactorCode.verify')} 109 </button> 110 </div> 111 </form>
··· 105 {$_('common.cancel')} 106 </button> 107 <button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}> 108 + {submitting ? $_('common.verifying') : $_('common.verify')} 109 </button> 110 </div> 111 </form>
+1 -1
frontend/src/routes/OAuthConsent.svelte
··· 171 <h1>{$_('oauth.error.title')}</h1> 172 <div class="error">{error}</div> 173 <button type="button" onclick={() => navigate('/login')}> 174 - {$_('verify.backToLogin')} 175 </button> 176 </div> 177 {:else if consentData}
··· 171 <h1>{$_('oauth.error.title')}</h1> 172 <div class="error">{error}</div> 173 <button type="button" onclick={() => navigate('/login')}> 174 + {$_('common.backToLogin')} 175 </button> 176 </div> 177 {:else if consentData}
+1 -1
frontend/src/routes/OAuthTotp.svelte
··· 121 {$_('common.cancel')} 122 </button> 123 <button type="submit" class="submit-btn" disabled={submitting || !canSubmit}> 124 - {submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.verify')} 125 </button> 126 </div> 127 </form>
··· 121 {$_('common.cancel')} 122 </button> 123 <button type="submit" class="submit-btn" disabled={submitting || !canSubmit}> 124 + {submitting ? $_('common.verifying') : $_('common.verify')} 125 </button> 126 </div> 127 </form>
+3 -3
frontend/src/routes/Register.svelte
··· 145 case 'info': return $_('register.subtitle') 146 case 'key-choice': return $_('register.subtitleKeyChoice') 147 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 148 - case 'creating': return $_('register.creating') 149 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 150 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 151 case 'activating': return $_('register.subtitleActivating') ··· 375 {/if} 376 377 <button type="submit" disabled={flow.state.submitting}> 378 - {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 379 </button> 380 </form> 381 ··· 413 /> 414 415 {:else if flow.state.step === 'creating'} 416 - <p class="loading">{$_('register.creating')}</p> 417 418 {:else if flow.state.step === 'verify'} 419 <VerificationStep {flow} />
··· 145 case 'info': return $_('register.subtitle') 146 case 'key-choice': return $_('register.subtitleKeyChoice') 147 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 148 + case 'creating': return $_('common.creating') 149 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 150 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 151 case 'activating': return $_('register.subtitleActivating') ··· 375 {/if} 376 377 <button type="submit" disabled={flow.state.submitting}> 378 + {flow.state.submitting ? $_('common.creating') : $_('register.createButton')} 379 </button> 380 </form> 381 ··· 413 /> 414 415 {:else if flow.state.step === 'creating'} 416 + <p class="loading">{$_('common.creating')}</p> 417 418 {:else if flow.state.step === 'verify'} 419 <VerificationStep {flow} />
+1 -1
frontend/src/routes/RegisterPasskey.svelte
··· 408 </div> 409 410 <button type="submit" disabled={flow.state.submitting}> 411 - {flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')} 412 </button> 413 </form> 414
··· 408 </div> 409 410 <button type="submit" disabled={flow.state.submitting}> 411 + {flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')} 412 </button> 413 </form> 414
+2 -2
frontend/src/routes/RepoExplorer.svelte
··· 417 </div> 418 <div class="actions"> 419 <button type="submit" class="primary" disabled={saving || !!jsonError}> 420 - {saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')} 421 </button> 422 <button type="button" class="danger" onclick={handleDelete} disabled={saving}> 423 {$_('common.delete')} ··· 464 </div> 465 <div class="actions"> 466 <button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}> 467 - {saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')} 468 </button> 469 <button type="button" class="secondary" onclick={goBack}> 470 {$_('common.cancel')}
··· 417 </div> 418 <div class="actions"> 419 <button type="submit" class="primary" disabled={saving || !!jsonError}> 420 + {saving ? $_('common.saving') : $_('repoExplorer.updateRecord')} 421 </button> 422 <button type="button" class="danger" onclick={handleDelete} disabled={saving}> 423 {$_('common.delete')} ··· 464 </div> 465 <div class="actions"> 466 <button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}> 467 + {saving ? $_('common.creating') : $_('repoExplorer.createRecord')} 468 </button> 469 <button type="button" class="secondary" onclick={goBack}> 470 {$_('common.cancel')}
+2 -2
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 36 <h1>{$_('requestPasskeyRecovery.successTitle')}</h1> 37 <p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p> 38 <p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p> 39 - <button onclick={() => navigate('/login')}>{$_('requestPasskeyRecovery.backToLogin')}</button> 40 </div> 41 {:else} 42 <h1>{$_('requestPasskeyRecovery.title')}</h1> ··· 71 {/if} 72 73 <p class="link-text"> 74 - <a href="#/login">{$_('requestPasskeyRecovery.backToLogin')}</a> 75 </p> 76 </div> 77
··· 36 <h1>{$_('requestPasskeyRecovery.successTitle')}</h1> 37 <p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p> 38 <p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p> 39 + <button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button> 40 </div> 41 {:else} 42 <h1>{$_('requestPasskeyRecovery.title')}</h1> ··· 71 {/if} 72 73 <p class="link-text"> 74 + <a href="#/login">{$_('common.backToLogin')}</a> 75 </p> 76 </div> 77
+1 -1
frontend/src/routes/ResetPassword.svelte
··· 141 {/if} 142 143 <p class="link-text"> 144 - <a href="#/login">{$_('resetPassword.backToLogin')}</a> 145 </p> 146 </div> 147
··· 141 {/if} 142 143 <p class="link-text"> 144 + <a href="#/login">{$_('common.backToLogin')}</a> 145 </p> 146 </div> 147
+341 -3
frontend/src/routes/Settings.svelte
··· 40 let deleteToken = $state('') 41 let deleteTokenSent = $state(false) 42 let exportLoading = $state(false) 43 let passwordLoading = $state(false) 44 let currentPassword = $state('') 45 let newPassword = $state('') ··· 173 exportLoading = false 174 } 175 } 176 async function handleChangePassword(e: Event) { 177 e.preventDefault() 178 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return ··· 323 /> 324 </div> 325 <button type="submit" disabled={handleLoading || !newHandle}> 326 - {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')} 327 </button> 328 </form> 329 </div> ··· 394 <section> 395 <h2>{$_('settings.exportData')}</h2> 396 <p class="description">{$_('settings.exportDataDescription')}</p> 397 - <button onclick={handleExportRepo} disabled={exportLoading}> 398 - {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 399 </button> 400 </section> 401 </div> 402 <section class="danger-zone"> ··· 658 white-space: nowrap; 659 border-left: 1px solid var(--border-color); 660 background: var(--bg-card); 661 } 662 </style>
··· 40 let deleteToken = $state('') 41 let deleteTokenSent = $state(false) 42 let exportLoading = $state(false) 43 + let exportBlobsLoading = $state(false) 44 let passwordLoading = $state(false) 45 let currentPassword = $state('') 46 let newPassword = $state('') ··· 174 exportLoading = false 175 } 176 } 177 + async function handleExportBlobs() { 178 + if (!auth.session) return 179 + exportBlobsLoading = true 180 + message = null 181 + try { 182 + const response = await fetch('/xrpc/_backup.exportBlobs', { 183 + headers: { 184 + 'Authorization': `Bearer ${auth.session.accessJwt}` 185 + } 186 + }) 187 + if (!response.ok) { 188 + const err = await response.json().catch(() => ({ message: 'Export failed' })) 189 + throw new Error(err.message || 'Export failed') 190 + } 191 + const blob = await response.blob() 192 + if (blob.size === 0) { 193 + showMessage('success', $_('settings.messages.noBlobsToExport')) 194 + return 195 + } 196 + const url = URL.createObjectURL(blob) 197 + const a = document.createElement('a') 198 + a.href = url 199 + a.download = `${auth.session.handle}-blobs.zip` 200 + document.body.appendChild(a) 201 + a.click() 202 + document.body.removeChild(a) 203 + URL.revokeObjectURL(url) 204 + showMessage('success', $_('settings.messages.blobsExported')) 205 + } catch (e) { 206 + showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 207 + } finally { 208 + exportBlobsLoading = false 209 + } 210 + } 211 + 212 + interface BackupInfo { 213 + id: string 214 + repoRev: string 215 + repoRootCid: string 216 + blockCount: number 217 + sizeBytes: number 218 + createdAt: string 219 + } 220 + let backups = $state<BackupInfo[]>([]) 221 + let backupEnabled = $state(true) 222 + let backupsLoading = $state(false) 223 + let createBackupLoading = $state(false) 224 + let restoreFile = $state<File | null>(null) 225 + let restoreLoading = $state(false) 226 + 227 + async function loadBackups() { 228 + if (!auth.session) return 229 + backupsLoading = true 230 + try { 231 + const result = await api.listBackups(auth.session.accessJwt) 232 + backups = result.backups 233 + backupEnabled = result.backupEnabled 234 + } catch (e) { 235 + console.error('Failed to load backups:', e) 236 + } finally { 237 + backupsLoading = false 238 + } 239 + } 240 + 241 + onMount(() => { 242 + loadBackups() 243 + }) 244 + 245 + async function handleToggleBackup() { 246 + if (!auth.session) return 247 + const newEnabled = !backupEnabled 248 + backupsLoading = true 249 + try { 250 + await api.setBackupEnabled(auth.session.accessJwt, newEnabled) 251 + backupEnabled = newEnabled 252 + showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled')) 253 + } catch (e) { 254 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed')) 255 + } finally { 256 + backupsLoading = false 257 + } 258 + } 259 + 260 + async function handleCreateBackup() { 261 + if (!auth.session) return 262 + createBackupLoading = true 263 + message = null 264 + try { 265 + await api.createBackup(auth.session.accessJwt) 266 + await loadBackups() 267 + showMessage('success', $_('settings.backups.created')) 268 + } catch (e) { 269 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed')) 270 + } finally { 271 + createBackupLoading = false 272 + } 273 + } 274 + 275 + async function handleDownloadBackup(id: string, rev: string) { 276 + if (!auth.session) return 277 + try { 278 + const blob = await api.getBackup(auth.session.accessJwt, id) 279 + const url = URL.createObjectURL(blob) 280 + const a = document.createElement('a') 281 + a.href = url 282 + a.download = `${auth.session.handle}-${rev}.car` 283 + document.body.appendChild(a) 284 + a.click() 285 + document.body.removeChild(a) 286 + URL.revokeObjectURL(url) 287 + } catch (e) { 288 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed')) 289 + } 290 + } 291 + 292 + async function handleDeleteBackup(id: string) { 293 + if (!auth.session) return 294 + try { 295 + await api.deleteBackup(auth.session.accessJwt, id) 296 + await loadBackups() 297 + showMessage('success', $_('settings.backups.deleted')) 298 + } catch (e) { 299 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed')) 300 + } 301 + } 302 + 303 + function handleFileSelect(e: Event) { 304 + const input = e.target as HTMLInputElement 305 + if (input.files && input.files.length > 0) { 306 + restoreFile = input.files[0] 307 + } 308 + } 309 + 310 + async function handleRestore() { 311 + if (!auth.session || !restoreFile) return 312 + restoreLoading = true 313 + message = null 314 + try { 315 + const buffer = await restoreFile.arrayBuffer() 316 + const car = new Uint8Array(buffer) 317 + await api.importRepo(auth.session.accessJwt, car) 318 + showMessage('success', $_('settings.backups.restored')) 319 + restoreFile = null 320 + } catch (e) { 321 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed')) 322 + } finally { 323 + restoreLoading = false 324 + } 325 + } 326 + 327 + function formatBytes(bytes: number): string { 328 + if (bytes < 1024) return `${bytes} B` 329 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` 330 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 331 + } 332 + 333 + function formatDate(iso: string): string { 334 + return new Date(iso).toLocaleDateString(undefined, { 335 + year: 'numeric', 336 + month: 'short', 337 + day: 'numeric', 338 + hour: '2-digit', 339 + minute: '2-digit' 340 + }) 341 + } 342 + 343 async function handleChangePassword(e: Event) { 344 e.preventDefault() 345 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return ··· 490 /> 491 </div> 492 <button type="submit" disabled={handleLoading || !newHandle}> 493 + {handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')} 494 </button> 495 </form> 496 </div> ··· 561 <section> 562 <h2>{$_('settings.exportData')}</h2> 563 <p class="description">{$_('settings.exportDataDescription')}</p> 564 + <div class="export-buttons"> 565 + <button onclick={handleExportRepo} disabled={exportLoading}> 566 + {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 567 + </button> 568 + <button onclick={handleExportBlobs} disabled={exportBlobsLoading} class="secondary"> 569 + {exportBlobsLoading ? $_('settings.exporting') : $_('settings.downloadBlobs')} 570 + </button> 571 + </div> 572 + </section> 573 + <section class="backups-section"> 574 + <h2>{$_('settings.backups.title')}</h2> 575 + <p class="description">{$_('settings.backups.description')}</p> 576 + 577 + <label class="checkbox-label"> 578 + <input type="checkbox" checked={backupEnabled} onchange={handleToggleBackup} disabled={backupsLoading} /> 579 + <span>{$_('settings.backups.enableAutomatic')}</span> 580 + </label> 581 + 582 + {#if backupsLoading} 583 + <p class="loading">{$_('common.loading')}</p> 584 + {:else if backups.length > 0} 585 + <ul class="backup-list"> 586 + {#each backups as backup} 587 + <li class="backup-item"> 588 + <div class="backup-info"> 589 + <span class="backup-date">{formatDate(backup.createdAt)}</span> 590 + <span class="backup-size">{formatBytes(backup.sizeBytes)}</span> 591 + <span class="backup-blocks">{backup.blockCount} {$_('settings.backups.blocks')}</span> 592 + </div> 593 + <div class="backup-actions"> 594 + <button class="small" onclick={() => handleDownloadBackup(backup.id, backup.repoRev)}> 595 + {$_('settings.backups.download')} 596 + </button> 597 + <button class="small danger" onclick={() => handleDeleteBackup(backup.id)}> 598 + {$_('settings.backups.delete')} 599 + </button> 600 + </div> 601 + </li> 602 + {/each} 603 + </ul> 604 + {:else} 605 + <p class="empty">{$_('settings.backups.noBackups')}</p> 606 + {/if} 607 + 608 + <button onclick={handleCreateBackup} disabled={createBackupLoading || !backupEnabled}> 609 + {createBackupLoading ? $_('common.creating') : $_('settings.backups.createNow')} 610 </button> 611 + </section> 612 + <section class="restore-section"> 613 + <h2>{$_('settings.backups.restoreTitle')}</h2> 614 + <p class="description">{$_('settings.backups.restoreDescription')}</p> 615 + 616 + <div class="field"> 617 + <label for="restore-file">{$_('settings.backups.selectFile')}</label> 618 + <input 619 + id="restore-file" 620 + type="file" 621 + accept=".car" 622 + onchange={handleFileSelect} 623 + disabled={restoreLoading} 624 + /> 625 + </div> 626 + 627 + {#if restoreFile} 628 + <div class="restore-preview"> 629 + <p>{$_('settings.backups.selectedFile')}: {restoreFile.name} ({formatBytes(restoreFile.size)})</p> 630 + <button onclick={handleRestore} disabled={restoreLoading} class="danger"> 631 + {restoreLoading ? $_('settings.backups.restoring') : $_('settings.backups.restore')} 632 + </button> 633 + </div> 634 + {/if} 635 </section> 636 </div> 637 <section class="danger-zone"> ··· 893 white-space: nowrap; 894 border-left: 1px solid var(--border-color); 895 background: var(--bg-card); 896 + } 897 + 898 + .checkbox-label { 899 + display: flex; 900 + align-items: center; 901 + gap: var(--space-2); 902 + cursor: pointer; 903 + margin-bottom: var(--space-4); 904 + } 905 + 906 + .checkbox-label input[type="checkbox"] { 907 + width: 18px; 908 + height: 18px; 909 + cursor: pointer; 910 + } 911 + 912 + .backup-list { 913 + list-style: none; 914 + padding: 0; 915 + margin: 0 0 var(--space-4) 0; 916 + display: flex; 917 + flex-direction: column; 918 + gap: var(--space-2); 919 + } 920 + 921 + .backup-item { 922 + display: flex; 923 + justify-content: space-between; 924 + align-items: center; 925 + padding: var(--space-3); 926 + background: var(--bg-card); 927 + border: 1px solid var(--border-color); 928 + border-radius: var(--radius-md); 929 + gap: var(--space-4); 930 + } 931 + 932 + .backup-info { 933 + display: flex; 934 + gap: var(--space-4); 935 + font-size: var(--text-sm); 936 + flex-wrap: wrap; 937 + } 938 + 939 + .backup-date { 940 + font-weight: 500; 941 + } 942 + 943 + .backup-size, 944 + .backup-blocks { 945 + color: var(--text-secondary); 946 + } 947 + 948 + .backup-actions { 949 + display: flex; 950 + gap: var(--space-2); 951 + flex-shrink: 0; 952 + } 953 + 954 + button.small { 955 + padding: var(--space-1) var(--space-2); 956 + font-size: var(--text-xs); 957 + } 958 + 959 + .empty, 960 + .loading { 961 + color: var(--text-secondary); 962 + font-size: var(--text-sm); 963 + margin-bottom: var(--space-4); 964 + } 965 + 966 + .restore-preview { 967 + background: var(--bg-card); 968 + border: 1px solid var(--border-color); 969 + border-radius: var(--radius-md); 970 + padding: var(--space-4); 971 + margin-top: var(--space-3); 972 + } 973 + 974 + .restore-preview p { 975 + margin: 0 0 var(--space-3) 0; 976 + font-size: var(--text-sm); 977 + } 978 + 979 + .export-buttons { 980 + display: flex; 981 + gap: var(--space-2); 982 + flex-wrap: wrap; 983 + } 984 + 985 + @media (max-width: 640px) { 986 + .backup-item { 987 + flex-direction: column; 988 + align-items: flex-start; 989 + } 990 + 991 + .backup-actions { 992 + width: 100%; 993 + margin-top: var(--space-2); 994 + } 995 + 996 + .backup-actions button { 997 + flex: 1; 998 + } 999 } 1000 </style>
+8 -8
frontend/src/routes/Verify.svelte
··· 225 <div class="verify-page"> 226 {#if autoSubmitting} 227 <div class="loading-container"> 228 - <h1>{$_('verify.verifying')}</h1> 229 <p class="subtitle">{$_('verify.pleaseWait')}</p> 230 </div> 231 {:else if success} ··· 235 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 <div class="actions"> 238 - <a href="#/settings" class="btn">{$_('verify.backToSettings')}</a> 239 </div> 240 {:else if successPurpose === 'migration' || successPurpose === 'signup'} 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> ··· 301 </form> 302 303 <p class="link-text"> 304 - <a href="#/settings">{$_('verify.backToSettings')}</a> 305 </p> 306 {/if} 307 {:else if mode === 'token'} ··· 347 </div> 348 349 <button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}> 350 - {submitting ? $_('verify.verifying') : $_('verify.verify')} 351 </button> 352 353 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}> 354 - {resendingCode ? $_('verify.sending') : $_('verify.resendCode')} 355 </button> 356 </form> 357 358 <p class="link-text"> 359 - <a href="#/login">{$_('verify.backToLogin')}</a> 360 </p> 361 {:else if pendingVerification} 362 <h1>{$_('verify.title')}</h1> ··· 390 </div> 391 392 <button type="submit" disabled={submitting || !verificationCode.trim()}> 393 - {submitting ? $_('verify.verifying') : $_('verify.verifyButton')} 394 </button> 395 396 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 397 - {resendingCode ? $_('verify.resending') : $_('verify.resendCode')} 398 </button> 399 </form> 400
··· 225 <div class="verify-page"> 226 {#if autoSubmitting} 227 <div class="loading-container"> 228 + <h1>{$_('common.verifying')}</h1> 229 <p class="subtitle">{$_('verify.pleaseWait')}</p> 230 </div> 231 {:else if success} ··· 235 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 <div class="actions"> 238 + <a href="#/settings" class="btn">{$_('common.backToSettings')}</a> 239 </div> 240 {:else if successPurpose === 'migration' || successPurpose === 'signup'} 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> ··· 301 </form> 302 303 <p class="link-text"> 304 + <a href="#/settings">{$_('common.backToSettings')}</a> 305 </p> 306 {/if} 307 {:else if mode === 'token'} ··· 347 </div> 348 349 <button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}> 350 + {submitting ? $_('common.verifying') : $_('common.verify')} 351 </button> 352 353 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}> 354 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 355 </button> 356 </form> 357 358 <p class="link-text"> 359 + <a href="#/login">{$_('common.backToLogin')}</a> 360 </p> 361 {:else if pendingVerification} 362 <h1>{$_('verify.title')}</h1> ··· 390 </div> 391 392 <button type="submit" disabled={submitting || !verificationCode.trim()}> 393 + {submitting ? $_('common.verifying') : $_('common.verify')} 394 </button> 395 396 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 397 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 398 </button> 399 </form> 400
+5 -5
frontend/src/styles/base.css
··· 54 } 55 56 a { 57 - color: var(--secondary); 58 - text-decoration: none; 59 - transition: color 0.3s ease; 60 } 61 62 a:hover { 63 - color: var(--secondary-hover); 64 - text-decoration: none; 65 } 66 67 ::selection { ··· 372 color: var(--text-secondary); 373 font-size: var(--text-sm); 374 margin-bottom: var(--space-3); 375 } 376 377 .back-link:hover {
··· 54 } 55 56 a { 57 + color: var(--accent); 58 + text-decoration: underline; 59 + text-underline-offset: 2px; 60 } 61 62 a:hover { 63 + color: var(--accent-hover); 64 } 65 66 ::selection { ··· 371 color: var(--text-secondary); 372 font-size: var(--text-sm); 373 margin-bottom: var(--space-3); 374 + text-decoration: none; 375 } 376 377 .back-link:hover {
+90
frontend/src/styles/migration.css
··· 190 191 .current-info .value { 192 font-weight: var(--font-medium); 193 } 194 195 .review-card { ··· 268 text-align: center; 269 color: var(--text-secondary); 270 font-size: var(--text-sm); 271 } 272 273 .success-content { ··· 567 font-size: var(--text-sm); 568 font-style: italic; 569 }
··· 190 191 .current-info .value { 192 font-weight: var(--font-medium); 193 + word-break: break-all; 194 + } 195 + 196 + .current-info .value.mono { 197 + font-family: var(--font-mono); 198 + font-size: var(--text-sm); 199 } 200 201 .review-card { ··· 274 text-align: center; 275 color: var(--text-secondary); 276 font-size: var(--text-sm); 277 + } 278 + 279 + .blob-progress { 280 + margin: var(--space-4) 0; 281 + } 282 + 283 + .blob-progress-bar { 284 + height: 8px; 285 + background: var(--bg-primary); 286 + border-radius: var(--radius-md); 287 + overflow: hidden; 288 + margin-bottom: var(--space-2); 289 + } 290 + 291 + .blob-progress-fill { 292 + height: 100%; 293 + background: var(--accent); 294 + transition: width var(--transition-slow); 295 + } 296 + 297 + .blob-progress-text { 298 + text-align: center; 299 + color: var(--text-secondary); 300 + font-size: var(--text-sm); 301 + margin: 0; 302 } 303 304 .success-content { ··· 598 font-size: var(--text-sm); 599 font-style: italic; 600 } 601 + 602 + .file-input-container { 603 + display: flex; 604 + flex-direction: column; 605 + gap: var(--space-3); 606 + } 607 + 608 + .file-info { 609 + display: flex; 610 + gap: var(--space-2); 611 + align-items: center; 612 + padding: var(--space-3); 613 + background: var(--bg-primary); 614 + border-radius: var(--radius-md); 615 + } 616 + 617 + .file-name { 618 + font-weight: var(--font-medium); 619 + } 620 + 621 + .file-size { 622 + color: var(--text-secondary); 623 + font-size: var(--text-sm); 624 + } 625 + 626 + .step-content textarea { 627 + width: 100%; 628 + font-family: var(--font-mono); 629 + font-size: var(--text-sm); 630 + padding: var(--space-3); 631 + border: 1px solid var(--border-color); 632 + border-radius: var(--radius-md); 633 + background: var(--bg-input); 634 + color: var(--text-primary); 635 + resize: vertical; 636 + } 637 + 638 + .step-content textarea:focus { 639 + outline: none; 640 + border-color: var(--accent); 641 + } 642 + 643 + .message { 644 + padding: var(--space-4); 645 + border-radius: var(--radius-lg); 646 + margin-bottom: var(--space-4); 647 + } 648 + 649 + .message.success { 650 + background: var(--success-bg); 651 + color: var(--success-text); 652 + border: 1px solid var(--success-border); 653 + } 654 + 655 + .message.error { 656 + background: var(--error-bg); 657 + color: var(--error-text); 658 + border: 1px solid var(--error-border); 659 + }
+35 -35
frontend/src/tests/Comms.test.ts
··· 29 beforeEach(() => { 30 setupAuthenticatedUser(); 31 mockEndpoint( 32 - "com.tranquil.account.getNotificationPrefs", 33 () => jsonResponse(mockData.notificationPrefs()), 34 ); 35 mockEndpoint( ··· 37 () => jsonResponse(mockData.describeServer()), 38 ); 39 mockEndpoint( 40 - "com.tranquil.account.getNotificationHistory", 41 () => jsonResponse({ notifications: [] }), 42 ); 43 }); ··· 67 () => jsonResponse(mockData.describeServer()), 68 ); 69 mockEndpoint( 70 - "com.tranquil.account.getNotificationHistory", 71 () => jsonResponse({ notifications: [] }), 72 ); 73 }); 74 it("shows loading text while fetching preferences", async () => { 75 - mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => { 76 await new Promise((resolve) => setTimeout(resolve, 100)); 77 return jsonResponse(mockData.notificationPrefs()); 78 }); ··· 88 () => jsonResponse(mockData.describeServer()), 89 ); 90 mockEndpoint( 91 - "com.tranquil.account.getNotificationHistory", 92 () => jsonResponse({ notifications: [] }), 93 ); 94 }); 95 it("displays all four channel options", async () => { 96 mockEndpoint( 97 - "com.tranquil.account.getNotificationPrefs", 98 () => jsonResponse(mockData.notificationPrefs()), 99 ); 100 render(Comms); ··· 111 }); 112 it("email channel is always selectable", async () => { 113 mockEndpoint( 114 - "com.tranquil.account.getNotificationPrefs", 115 () => jsonResponse(mockData.notificationPrefs()), 116 ); 117 render(Comms); ··· 122 }); 123 it("discord channel is disabled when not configured", async () => { 124 mockEndpoint( 125 - "com.tranquil.account.getNotificationPrefs", 126 () => jsonResponse(mockData.notificationPrefs({ discordId: null })), 127 ); 128 render(Comms); ··· 133 }); 134 it("discord channel is enabled when configured", async () => { 135 mockEndpoint( 136 - "com.tranquil.account.getNotificationPrefs", 137 () => 138 jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })), 139 ); ··· 145 }); 146 it("shows hint for disabled channels", async () => { 147 mockEndpoint( 148 - "com.tranquil.account.getNotificationPrefs", 149 () => jsonResponse(mockData.notificationPrefs()), 150 ); 151 render(Comms); ··· 156 }); 157 it("selects current preferred channel", async () => { 158 mockEndpoint( 159 - "com.tranquil.account.getNotificationPrefs", 160 () => 161 jsonResponse( 162 mockData.notificationPrefs({ preferredChannel: "email" }), ··· 179 () => jsonResponse(mockData.describeServer()), 180 ); 181 mockEndpoint( 182 - "com.tranquil.account.getNotificationHistory", 183 () => jsonResponse({ notifications: [] }), 184 ); 185 }); 186 it("displays email as readonly with current value", async () => { 187 mockEndpoint( 188 - "com.tranquil.account.getNotificationPrefs", 189 () => jsonResponse(mockData.notificationPrefs()), 190 ); 191 render(Comms); ··· 199 }); 200 it("displays all channel inputs with current values", async () => { 201 mockEndpoint( 202 - "com.tranquil.account.getNotificationPrefs", 203 () => 204 jsonResponse(mockData.notificationPrefs({ 205 discordId: "123456789", ··· 231 () => jsonResponse(mockData.describeServer()), 232 ); 233 mockEndpoint( 234 - "com.tranquil.account.getNotificationHistory", 235 () => jsonResponse({ notifications: [] }), 236 ); 237 }); 238 it("shows Primary badge for email", async () => { 239 mockEndpoint( 240 - "com.tranquil.account.getNotificationPrefs", 241 () => jsonResponse(mockData.notificationPrefs()), 242 ); 243 render(Comms); ··· 247 }); 248 it("shows Verified badge for verified discord", async () => { 249 mockEndpoint( 250 - "com.tranquil.account.getNotificationPrefs", 251 () => 252 jsonResponse(mockData.notificationPrefs({ 253 discordId: "123456789", ··· 262 }); 263 it("shows Not verified badge for unverified discord", async () => { 264 mockEndpoint( 265 - "com.tranquil.account.getNotificationPrefs", 266 () => 267 jsonResponse(mockData.notificationPrefs({ 268 discordId: "123456789", ··· 276 }); 277 it("does not show badge when channel not configured", async () => { 278 mockEndpoint( 279 - "com.tranquil.account.getNotificationPrefs", 280 () => jsonResponse(mockData.notificationPrefs()), 281 ); 282 render(Comms); ··· 294 () => jsonResponse(mockData.describeServer()), 295 ); 296 mockEndpoint( 297 - "com.tranquil.account.getNotificationHistory", 298 () => jsonResponse({ notifications: [] }), 299 ); 300 }); 301 it("calls updateNotificationPrefs with correct data", async () => { 302 let capturedBody: Record<string, unknown> | null = null; 303 mockEndpoint( 304 - "com.tranquil.account.getNotificationPrefs", 305 () => jsonResponse(mockData.notificationPrefs()), 306 ); 307 mockEndpoint( 308 - "com.tranquil.account.updateNotificationPrefs", 309 (_url, options) => { 310 capturedBody = JSON.parse((options?.body as string) || "{}"); 311 return jsonResponse({ success: true }); ··· 329 }); 330 it("shows loading state while saving", async () => { 331 mockEndpoint( 332 - "com.tranquil.account.getNotificationPrefs", 333 () => jsonResponse(mockData.notificationPrefs()), 334 ); 335 - mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => { 336 await new Promise((resolve) => setTimeout(resolve, 100)); 337 return jsonResponse({ success: true }); 338 }); ··· 350 }); 351 it("shows success message after saving", async () => { 352 mockEndpoint( 353 - "com.tranquil.account.getNotificationPrefs", 354 () => jsonResponse(mockData.notificationPrefs()), 355 ); 356 mockEndpoint( 357 - "com.tranquil.account.updateNotificationPrefs", 358 () => jsonResponse({ success: true }), 359 ); 360 render(Comms); ··· 372 }); 373 it("shows error when save fails", async () => { 374 mockEndpoint( 375 - "com.tranquil.account.getNotificationPrefs", 376 () => jsonResponse(mockData.notificationPrefs()), 377 ); 378 mockEndpoint( 379 - "com.tranquil.account.updateNotificationPrefs", 380 () => 381 errorResponse("InvalidRequest", "Invalid channel configuration", 400), 382 ); ··· 400 }); 401 it("reloads preferences after successful save", async () => { 402 let loadCount = 0; 403 - mockEndpoint("com.tranquil.account.getNotificationPrefs", () => { 404 loadCount++; 405 return jsonResponse(mockData.notificationPrefs()); 406 }); 407 mockEndpoint( 408 - "com.tranquil.account.updateNotificationPrefs", 409 () => jsonResponse({ success: true }), 410 ); 411 render(Comms); ··· 430 () => jsonResponse(mockData.describeServer()), 431 ); 432 mockEndpoint( 433 - "com.tranquil.account.getNotificationHistory", 434 () => jsonResponse({ notifications: [] }), 435 ); 436 }); 437 it("enables discord channel after entering discord ID", async () => { 438 mockEndpoint( 439 - "com.tranquil.account.getNotificationPrefs", 440 () => jsonResponse(mockData.notificationPrefs()), 441 ); 442 render(Comms); ··· 453 }); 454 it("allows selecting a configured channel", async () => { 455 mockEndpoint( 456 - "com.tranquil.account.getNotificationPrefs", 457 () => 458 jsonResponse(mockData.notificationPrefs({ 459 discordId: "123456789", ··· 480 () => jsonResponse(mockData.describeServer()), 481 ); 482 mockEndpoint( 483 - "com.tranquil.account.getNotificationHistory", 484 () => jsonResponse({ notifications: [] }), 485 ); 486 }); 487 it("shows error when loading preferences fails", async () => { 488 mockEndpoint( 489 - "com.tranquil.account.getNotificationPrefs", 490 () => errorResponse("InternalError", "Database connection failed", 500), 491 ); 492 render(Comms);
··· 29 beforeEach(() => { 30 setupAuthenticatedUser(); 31 mockEndpoint( 32 + "_account.getNotificationPrefs", 33 () => jsonResponse(mockData.notificationPrefs()), 34 ); 35 mockEndpoint( ··· 37 () => jsonResponse(mockData.describeServer()), 38 ); 39 mockEndpoint( 40 + "_account.getNotificationHistory", 41 () => jsonResponse({ notifications: [] }), 42 ); 43 }); ··· 67 () => jsonResponse(mockData.describeServer()), 68 ); 69 mockEndpoint( 70 + "_account.getNotificationHistory", 71 () => jsonResponse({ notifications: [] }), 72 ); 73 }); 74 it("shows loading text while fetching preferences", async () => { 75 + mockEndpoint("_account.getNotificationPrefs", async () => { 76 await new Promise((resolve) => setTimeout(resolve, 100)); 77 return jsonResponse(mockData.notificationPrefs()); 78 }); ··· 88 () => jsonResponse(mockData.describeServer()), 89 ); 90 mockEndpoint( 91 + "_account.getNotificationHistory", 92 () => jsonResponse({ notifications: [] }), 93 ); 94 }); 95 it("displays all four channel options", async () => { 96 mockEndpoint( 97 + "_account.getNotificationPrefs", 98 () => jsonResponse(mockData.notificationPrefs()), 99 ); 100 render(Comms); ··· 111 }); 112 it("email channel is always selectable", async () => { 113 mockEndpoint( 114 + "_account.getNotificationPrefs", 115 () => jsonResponse(mockData.notificationPrefs()), 116 ); 117 render(Comms); ··· 122 }); 123 it("discord channel is disabled when not configured", async () => { 124 mockEndpoint( 125 + "_account.getNotificationPrefs", 126 () => jsonResponse(mockData.notificationPrefs({ discordId: null })), 127 ); 128 render(Comms); ··· 133 }); 134 it("discord channel is enabled when configured", async () => { 135 mockEndpoint( 136 + "_account.getNotificationPrefs", 137 () => 138 jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })), 139 ); ··· 145 }); 146 it("shows hint for disabled channels", async () => { 147 mockEndpoint( 148 + "_account.getNotificationPrefs", 149 () => jsonResponse(mockData.notificationPrefs()), 150 ); 151 render(Comms); ··· 156 }); 157 it("selects current preferred channel", async () => { 158 mockEndpoint( 159 + "_account.getNotificationPrefs", 160 () => 161 jsonResponse( 162 mockData.notificationPrefs({ preferredChannel: "email" }), ··· 179 () => jsonResponse(mockData.describeServer()), 180 ); 181 mockEndpoint( 182 + "_account.getNotificationHistory", 183 () => jsonResponse({ notifications: [] }), 184 ); 185 }); 186 it("displays email as readonly with current value", async () => { 187 mockEndpoint( 188 + "_account.getNotificationPrefs", 189 () => jsonResponse(mockData.notificationPrefs()), 190 ); 191 render(Comms); ··· 199 }); 200 it("displays all channel inputs with current values", async () => { 201 mockEndpoint( 202 + "_account.getNotificationPrefs", 203 () => 204 jsonResponse(mockData.notificationPrefs({ 205 discordId: "123456789", ··· 231 () => jsonResponse(mockData.describeServer()), 232 ); 233 mockEndpoint( 234 + "_account.getNotificationHistory", 235 () => jsonResponse({ notifications: [] }), 236 ); 237 }); 238 it("shows Primary badge for email", async () => { 239 mockEndpoint( 240 + "_account.getNotificationPrefs", 241 () => jsonResponse(mockData.notificationPrefs()), 242 ); 243 render(Comms); ··· 247 }); 248 it("shows Verified badge for verified discord", async () => { 249 mockEndpoint( 250 + "_account.getNotificationPrefs", 251 () => 252 jsonResponse(mockData.notificationPrefs({ 253 discordId: "123456789", ··· 262 }); 263 it("shows Not verified badge for unverified discord", async () => { 264 mockEndpoint( 265 + "_account.getNotificationPrefs", 266 () => 267 jsonResponse(mockData.notificationPrefs({ 268 discordId: "123456789", ··· 276 }); 277 it("does not show badge when channel not configured", async () => { 278 mockEndpoint( 279 + "_account.getNotificationPrefs", 280 () => jsonResponse(mockData.notificationPrefs()), 281 ); 282 render(Comms); ··· 294 () => jsonResponse(mockData.describeServer()), 295 ); 296 mockEndpoint( 297 + "_account.getNotificationHistory", 298 () => jsonResponse({ notifications: [] }), 299 ); 300 }); 301 it("calls updateNotificationPrefs with correct data", async () => { 302 let capturedBody: Record<string, unknown> | null = null; 303 mockEndpoint( 304 + "_account.getNotificationPrefs", 305 () => jsonResponse(mockData.notificationPrefs()), 306 ); 307 mockEndpoint( 308 + "_account.updateNotificationPrefs", 309 (_url, options) => { 310 capturedBody = JSON.parse((options?.body as string) || "{}"); 311 return jsonResponse({ success: true }); ··· 329 }); 330 it("shows loading state while saving", async () => { 331 mockEndpoint( 332 + "_account.getNotificationPrefs", 333 () => jsonResponse(mockData.notificationPrefs()), 334 ); 335 + mockEndpoint("_account.updateNotificationPrefs", async () => { 336 await new Promise((resolve) => setTimeout(resolve, 100)); 337 return jsonResponse({ success: true }); 338 }); ··· 350 }); 351 it("shows success message after saving", async () => { 352 mockEndpoint( 353 + "_account.getNotificationPrefs", 354 () => jsonResponse(mockData.notificationPrefs()), 355 ); 356 mockEndpoint( 357 + "_account.updateNotificationPrefs", 358 () => jsonResponse({ success: true }), 359 ); 360 render(Comms); ··· 372 }); 373 it("shows error when save fails", async () => { 374 mockEndpoint( 375 + "_account.getNotificationPrefs", 376 () => jsonResponse(mockData.notificationPrefs()), 377 ); 378 mockEndpoint( 379 + "_account.updateNotificationPrefs", 380 () => 381 errorResponse("InvalidRequest", "Invalid channel configuration", 400), 382 ); ··· 400 }); 401 it("reloads preferences after successful save", async () => { 402 let loadCount = 0; 403 + mockEndpoint("_account.getNotificationPrefs", () => { 404 loadCount++; 405 return jsonResponse(mockData.notificationPrefs()); 406 }); 407 mockEndpoint( 408 + "_account.updateNotificationPrefs", 409 () => jsonResponse({ success: true }), 410 ); 411 render(Comms); ··· 430 () => jsonResponse(mockData.describeServer()), 431 ); 432 mockEndpoint( 433 + "_account.getNotificationHistory", 434 () => jsonResponse({ notifications: [] }), 435 ); 436 }); 437 it("enables discord channel after entering discord ID", async () => { 438 mockEndpoint( 439 + "_account.getNotificationPrefs", 440 () => jsonResponse(mockData.notificationPrefs()), 441 ); 442 render(Comms); ··· 453 }); 454 it("allows selecting a configured channel", async () => { 455 mockEndpoint( 456 + "_account.getNotificationPrefs", 457 () => 458 jsonResponse(mockData.notificationPrefs({ 459 discordId: "123456789", ··· 480 () => jsonResponse(mockData.describeServer()), 481 ); 482 mockEndpoint( 483 + "_account.getNotificationHistory", 484 () => jsonResponse({ notifications: [] }), 485 ); 486 }); 487 it("shows error when loading preferences fails", async () => { 488 mockEndpoint( 489 + "_account.getNotificationPrefs", 490 () => errorResponse("InternalError", "Database connection failed", 500), 491 ); 492 render(Comms);
+2 -2
frontend/src/tests/Settings.test.ts
··· 8 mockData, 9 mockEndpoint, 10 setupAuthenticatedUser, 11 - setupFetchMock, 12 setupUnauthenticatedUser, 13 } from "./mocks"; 14 describe("Settings", () => { 15 beforeEach(() => { 16 clearMocks(); 17 - setupFetchMock(); 18 globalThis.confirm = vi.fn(() => true); 19 }); 20 describe("authentication guard", () => {
··· 8 mockData, 9 mockEndpoint, 10 setupAuthenticatedUser, 11 + setupDefaultMocks, 12 setupUnauthenticatedUser, 13 } from "./mocks"; 14 describe("Settings", () => { 15 beforeEach(() => { 16 clearMocks(); 17 + setupDefaultMocks(); 18 globalThis.confirm = vi.fn(() => true); 19 }); 20 describe("authentication guard", () => {
+491
frontend/src/tests/migration/offline-flow.test.ts
···
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte"; 3 + 4 + const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 5 + 6 + describe("migration/offline-flow", () => { 7 + beforeEach(() => { 8 + localStorage.removeItem(OFFLINE_STORAGE_KEY); 9 + vi.restoreAllMocks(); 10 + }); 11 + 12 + describe("createOfflineInboundMigrationFlow", () => { 13 + it("creates flow with initial state", () => { 14 + const flow = createOfflineInboundMigrationFlow(); 15 + 16 + expect(flow.state.direction).toBe("offline-inbound"); 17 + expect(flow.state.step).toBe("welcome"); 18 + expect(flow.state.userDid).toBe(""); 19 + expect(flow.state.carFile).toBeNull(); 20 + expect(flow.state.carFileName).toBe(""); 21 + expect(flow.state.carSizeBytes).toBe(0); 22 + expect(flow.state.rotationKey).toBe(""); 23 + expect(flow.state.rotationKeyDidKey).toBe(""); 24 + expect(flow.state.targetHandle).toBe(""); 25 + expect(flow.state.targetEmail).toBe(""); 26 + expect(flow.state.targetPassword).toBe(""); 27 + expect(flow.state.inviteCode).toBe(""); 28 + expect(flow.state.localAccessToken).toBeNull(); 29 + expect(flow.state.localRefreshToken).toBeNull(); 30 + expect(flow.state.error).toBeNull(); 31 + }); 32 + 33 + it("initializes progress correctly", () => { 34 + const flow = createOfflineInboundMigrationFlow(); 35 + 36 + expect(flow.state.progress.repoExported).toBe(false); 37 + expect(flow.state.progress.repoImported).toBe(false); 38 + expect(flow.state.progress.blobsTotal).toBe(0); 39 + expect(flow.state.progress.blobsMigrated).toBe(0); 40 + expect(flow.state.progress.blobsFailed).toEqual([]); 41 + expect(flow.state.progress.prefsMigrated).toBe(false); 42 + expect(flow.state.progress.plcSigned).toBe(false); 43 + expect(flow.state.progress.activated).toBe(false); 44 + expect(flow.state.progress.deactivated).toBe(false); 45 + expect(flow.state.progress.currentOperation).toBe(""); 46 + }); 47 + }); 48 + 49 + describe("setUserDid", () => { 50 + it("sets the user DID", () => { 51 + const flow = createOfflineInboundMigrationFlow(); 52 + 53 + flow.setUserDid("did:plc:abc123"); 54 + 55 + expect(flow.state.userDid).toBe("did:plc:abc123"); 56 + }); 57 + 58 + it("saves state to localStorage", () => { 59 + const flow = createOfflineInboundMigrationFlow(); 60 + 61 + flow.setUserDid("did:plc:xyz789"); 62 + 63 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 64 + expect(stored.userDid).toBe("did:plc:xyz789"); 65 + }); 66 + }); 67 + 68 + describe("setCarFile", () => { 69 + it("sets CAR file data", () => { 70 + const flow = createOfflineInboundMigrationFlow(); 71 + const carData = new Uint8Array([1, 2, 3, 4, 5]); 72 + 73 + flow.setCarFile(carData, "repo.car"); 74 + 75 + expect(flow.state.carFile).toEqual(carData); 76 + expect(flow.state.carFileName).toBe("repo.car"); 77 + expect(flow.state.carSizeBytes).toBe(5); 78 + }); 79 + 80 + it("saves file metadata to localStorage (not file content)", () => { 81 + const flow = createOfflineInboundMigrationFlow(); 82 + const carData = new Uint8Array([1, 2, 3, 4, 5]); 83 + 84 + flow.setCarFile(carData, "backup.car"); 85 + 86 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 87 + expect(stored.carFileName).toBe("backup.car"); 88 + expect(stored.carSizeBytes).toBe(5); 89 + }); 90 + }); 91 + 92 + describe("setRotationKey", () => { 93 + it("sets the rotation key", () => { 94 + const flow = createOfflineInboundMigrationFlow(); 95 + 96 + flow.setRotationKey("abc123privatekey"); 97 + 98 + expect(flow.state.rotationKey).toBe("abc123privatekey"); 99 + }); 100 + 101 + it("does not save rotation key to localStorage (security)", () => { 102 + const flow = createOfflineInboundMigrationFlow(); 103 + 104 + flow.setRotationKey("supersecretkey"); 105 + 106 + const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 107 + if (stored) { 108 + const parsed = JSON.parse(stored); 109 + expect(parsed.rotationKey).toBeUndefined(); 110 + } 111 + }); 112 + }); 113 + 114 + describe("setTargetHandle", () => { 115 + it("sets the target handle", () => { 116 + const flow = createOfflineInboundMigrationFlow(); 117 + 118 + flow.setTargetHandle("alice.example.com"); 119 + 120 + expect(flow.state.targetHandle).toBe("alice.example.com"); 121 + }); 122 + 123 + it("saves to localStorage", () => { 124 + const flow = createOfflineInboundMigrationFlow(); 125 + 126 + flow.setTargetHandle("bob.example.com"); 127 + 128 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 129 + expect(stored.targetHandle).toBe("bob.example.com"); 130 + }); 131 + }); 132 + 133 + describe("setTargetEmail", () => { 134 + it("sets the target email", () => { 135 + const flow = createOfflineInboundMigrationFlow(); 136 + 137 + flow.setTargetEmail("alice@example.com"); 138 + 139 + expect(flow.state.targetEmail).toBe("alice@example.com"); 140 + }); 141 + 142 + it("saves to localStorage", () => { 143 + const flow = createOfflineInboundMigrationFlow(); 144 + 145 + flow.setTargetEmail("bob@example.com"); 146 + 147 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 148 + expect(stored.targetEmail).toBe("bob@example.com"); 149 + }); 150 + }); 151 + 152 + describe("setTargetPassword", () => { 153 + it("sets the target password", () => { 154 + const flow = createOfflineInboundMigrationFlow(); 155 + 156 + flow.setTargetPassword("securepassword123"); 157 + 158 + expect(flow.state.targetPassword).toBe("securepassword123"); 159 + }); 160 + 161 + it("does not save password to localStorage (security)", () => { 162 + const flow = createOfflineInboundMigrationFlow(); 163 + flow.setUserDid("did:plc:test"); 164 + 165 + flow.setTargetPassword("mypassword"); 166 + 167 + const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 168 + if (stored) { 169 + const parsed = JSON.parse(stored); 170 + expect(parsed.targetPassword).toBeUndefined(); 171 + } 172 + }); 173 + }); 174 + 175 + describe("setInviteCode", () => { 176 + it("sets the invite code", () => { 177 + const flow = createOfflineInboundMigrationFlow(); 178 + 179 + flow.setInviteCode("invite-abc123"); 180 + 181 + expect(flow.state.inviteCode).toBe("invite-abc123"); 182 + }); 183 + }); 184 + 185 + describe("setStep", () => { 186 + it("changes the current step", () => { 187 + const flow = createOfflineInboundMigrationFlow(); 188 + 189 + flow.setStep("provide-did"); 190 + 191 + expect(flow.state.step).toBe("provide-did"); 192 + }); 193 + 194 + it("clears error when changing step", () => { 195 + const flow = createOfflineInboundMigrationFlow(); 196 + flow.setError("Previous error"); 197 + 198 + flow.setStep("upload-car"); 199 + 200 + expect(flow.state.error).toBeNull(); 201 + }); 202 + 203 + it("saves step to localStorage", () => { 204 + const flow = createOfflineInboundMigrationFlow(); 205 + 206 + flow.setStep("provide-rotation-key"); 207 + 208 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 209 + expect(stored.step).toBe("provide-rotation-key"); 210 + }); 211 + }); 212 + 213 + describe("setError", () => { 214 + it("sets the error message", () => { 215 + const flow = createOfflineInboundMigrationFlow(); 216 + 217 + flow.setError("Something went wrong"); 218 + 219 + expect(flow.state.error).toBe("Something went wrong"); 220 + }); 221 + 222 + it("saves error to localStorage", () => { 223 + const flow = createOfflineInboundMigrationFlow(); 224 + 225 + flow.setError("Connection failed"); 226 + 227 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 228 + expect(stored.lastError).toBe("Connection failed"); 229 + }); 230 + }); 231 + 232 + describe("setProgress", () => { 233 + it("updates progress fields", () => { 234 + const flow = createOfflineInboundMigrationFlow(); 235 + 236 + flow.setProgress({ 237 + repoImported: true, 238 + currentOperation: "Importing...", 239 + }); 240 + 241 + expect(flow.state.progress.repoImported).toBe(true); 242 + expect(flow.state.progress.currentOperation).toBe("Importing..."); 243 + }); 244 + 245 + it("preserves other progress fields", () => { 246 + const flow = createOfflineInboundMigrationFlow(); 247 + flow.setProgress({ repoExported: true }); 248 + 249 + flow.setProgress({ repoImported: true }); 250 + 251 + expect(flow.state.progress.repoExported).toBe(true); 252 + expect(flow.state.progress.repoImported).toBe(true); 253 + }); 254 + }); 255 + 256 + describe("reset", () => { 257 + it("resets state to initial values", () => { 258 + const flow = createOfflineInboundMigrationFlow(); 259 + flow.setUserDid("did:plc:abc123"); 260 + flow.setTargetHandle("alice.example.com"); 261 + flow.setStep("review"); 262 + 263 + flow.reset(); 264 + 265 + expect(flow.state.step).toBe("welcome"); 266 + expect(flow.state.userDid).toBe(""); 267 + expect(flow.state.targetHandle).toBe(""); 268 + }); 269 + 270 + it("clears localStorage", () => { 271 + const flow = createOfflineInboundMigrationFlow(); 272 + flow.setUserDid("did:plc:abc123"); 273 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull(); 274 + 275 + flow.reset(); 276 + 277 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 278 + }); 279 + }); 280 + 281 + describe("clearOfflineState", () => { 282 + it("removes state from localStorage", () => { 283 + const flow = createOfflineInboundMigrationFlow(); 284 + flow.setUserDid("did:plc:abc123"); 285 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull(); 286 + 287 + flow.clearOfflineState(); 288 + 289 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 290 + }); 291 + }); 292 + 293 + describe("tryResume", () => { 294 + it("returns false when no stored state", () => { 295 + const flow = createOfflineInboundMigrationFlow(); 296 + 297 + const result = flow.tryResume(); 298 + 299 + expect(result).toBe(false); 300 + }); 301 + 302 + it("restores state from localStorage", () => { 303 + const storedState = { 304 + version: 1, 305 + step: "choose-handle", 306 + startedAt: new Date().toISOString(), 307 + userDid: "did:plc:restored123", 308 + carFileName: "backup.car", 309 + carSizeBytes: 12345, 310 + rotationKeyDidKey: "did:key:z123abc", 311 + targetHandle: "restored.example.com", 312 + targetEmail: "restored@example.com", 313 + progress: { 314 + accountCreated: true, 315 + repoImported: false, 316 + plcSigned: false, 317 + activated: false, 318 + }, 319 + }; 320 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 321 + 322 + const flow = createOfflineInboundMigrationFlow(); 323 + const result = flow.tryResume(); 324 + 325 + expect(result).toBe(true); 326 + expect(flow.state.step).toBe("choose-handle"); 327 + expect(flow.state.userDid).toBe("did:plc:restored123"); 328 + expect(flow.state.carFileName).toBe("backup.car"); 329 + expect(flow.state.carSizeBytes).toBe(12345); 330 + expect(flow.state.rotationKeyDidKey).toBe("did:key:z123abc"); 331 + expect(flow.state.targetHandle).toBe("restored.example.com"); 332 + expect(flow.state.targetEmail).toBe("restored@example.com"); 333 + expect(flow.state.progress.repoExported).toBe(true); 334 + }); 335 + 336 + it("restores error from stored state", () => { 337 + const storedState = { 338 + version: 1, 339 + step: "error", 340 + startedAt: new Date().toISOString(), 341 + userDid: "did:plc:abc", 342 + carFileName: "", 343 + carSizeBytes: 0, 344 + rotationKeyDidKey: "", 345 + targetHandle: "", 346 + targetEmail: "", 347 + progress: { 348 + accountCreated: false, 349 + repoImported: false, 350 + plcSigned: false, 351 + activated: false, 352 + }, 353 + lastError: "Previous migration failed", 354 + }; 355 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 356 + 357 + const flow = createOfflineInboundMigrationFlow(); 358 + flow.tryResume(); 359 + 360 + expect(flow.state.error).toBe("Previous migration failed"); 361 + }); 362 + 363 + it("returns false and clears for incompatible version", () => { 364 + const storedState = { 365 + version: 999, 366 + step: "review", 367 + userDid: "did:plc:abc", 368 + }; 369 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 370 + 371 + const flow = createOfflineInboundMigrationFlow(); 372 + const result = flow.tryResume(); 373 + 374 + expect(result).toBe(false); 375 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 376 + }); 377 + 378 + it("returns false and clears for expired state (> 24 hours)", () => { 379 + const expiredState = { 380 + version: 1, 381 + step: "review", 382 + startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), 383 + userDid: "did:plc:expired", 384 + carFileName: "old.car", 385 + carSizeBytes: 100, 386 + rotationKeyDidKey: "", 387 + targetHandle: "old.example.com", 388 + targetEmail: "old@example.com", 389 + progress: { 390 + accountCreated: false, 391 + repoImported: false, 392 + plcSigned: false, 393 + activated: false, 394 + }, 395 + }; 396 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(expiredState)); 397 + 398 + const flow = createOfflineInboundMigrationFlow(); 399 + const result = flow.tryResume(); 400 + 401 + expect(result).toBe(false); 402 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 403 + }); 404 + 405 + it("returns false and clears for invalid JSON", () => { 406 + localStorage.setItem(OFFLINE_STORAGE_KEY, "not-valid-json"); 407 + 408 + const flow = createOfflineInboundMigrationFlow(); 409 + const result = flow.tryResume(); 410 + 411 + expect(result).toBe(false); 412 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 413 + }); 414 + 415 + it("accepts state within 24 hours", () => { 416 + const recentState = { 417 + version: 1, 418 + step: "review", 419 + startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(), 420 + userDid: "did:plc:recent", 421 + carFileName: "recent.car", 422 + carSizeBytes: 500, 423 + rotationKeyDidKey: "did:key:zRecent", 424 + targetHandle: "recent.example.com", 425 + targetEmail: "recent@example.com", 426 + progress: { 427 + accountCreated: true, 428 + repoImported: true, 429 + plcSigned: false, 430 + activated: false, 431 + }, 432 + }; 433 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(recentState)); 434 + 435 + const flow = createOfflineInboundMigrationFlow(); 436 + const result = flow.tryResume(); 437 + 438 + expect(result).toBe(true); 439 + expect(flow.state.userDid).toBe("did:plc:recent"); 440 + }); 441 + }); 442 + 443 + describe("loadLocalServerInfo", () => { 444 + function createMockResponse(data: unknown) { 445 + const jsonStr = JSON.stringify(data); 446 + return new Response(jsonStr, { 447 + status: 200, 448 + headers: { "Content-Type": "application/json" }, 449 + }); 450 + } 451 + 452 + it("fetches server description", async () => { 453 + const mockServerInfo = { 454 + did: "did:web:example.com", 455 + availableUserDomains: ["example.com"], 456 + inviteCodeRequired: false, 457 + }; 458 + 459 + globalThis.fetch = vi.fn().mockResolvedValue( 460 + createMockResponse(mockServerInfo), 461 + ); 462 + 463 + const flow = createOfflineInboundMigrationFlow(); 464 + const result = await flow.loadLocalServerInfo(); 465 + 466 + expect(result).toEqual(mockServerInfo); 467 + expect(fetch).toHaveBeenCalledWith( 468 + expect.stringContaining("com.atproto.server.describeServer"), 469 + expect.any(Object), 470 + ); 471 + }); 472 + 473 + it("caches server info", async () => { 474 + const mockServerInfo = { 475 + did: "did:web:example.com", 476 + availableUserDomains: ["example.com"], 477 + inviteCodeRequired: false, 478 + }; 479 + 480 + globalThis.fetch = vi.fn().mockResolvedValue( 481 + createMockResponse(mockServerInfo), 482 + ); 483 + 484 + const flow = createOfflineInboundMigrationFlow(); 485 + await flow.loadLocalServerInfo(); 486 + await flow.loadLocalServerInfo(); 487 + 488 + expect(fetch).toHaveBeenCalledTimes(1); 489 + }); 490 + }); 491 + });
+333
frontend/src/tests/migration/plc-ops.test.ts
···
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { PlcOps, plcOps } from "../../lib/migration/plc-ops"; 3 + 4 + describe("migration/plc-ops", () => { 5 + beforeEach(() => { 6 + vi.restoreAllMocks(); 7 + }); 8 + 9 + describe("PlcOps class", () => { 10 + it("uses default PLC directory URL", () => { 11 + const ops = new PlcOps(); 12 + expect(ops).toBeDefined(); 13 + }); 14 + 15 + it("accepts custom PLC directory URL", () => { 16 + const ops = new PlcOps("https://custom-plc.example.com"); 17 + expect(ops).toBeDefined(); 18 + }); 19 + }); 20 + 21 + describe("plcOps singleton", () => { 22 + it("exports a singleton instance", () => { 23 + expect(plcOps).toBeInstanceOf(PlcOps); 24 + }); 25 + }); 26 + 27 + describe("getPlcAuditLogs", () => { 28 + it("throws on HTTP error", async () => { 29 + globalThis.fetch = vi.fn().mockResolvedValue({ 30 + ok: false, 31 + status: 404, 32 + }); 33 + 34 + await expect(plcOps.getPlcAuditLogs("did:plc:notfound")).rejects.toThrow( 35 + "Failed to fetch PLC audit logs: 404", 36 + ); 37 + }); 38 + }); 39 + 40 + describe("getLastPlcOpFromPlc", () => { 41 + it("throws when empty array returned", async () => { 42 + globalThis.fetch = vi.fn().mockResolvedValue({ 43 + ok: true, 44 + json: () => Promise.resolve([]), 45 + }); 46 + 47 + await expect( 48 + plcOps.getLastPlcOpFromPlc("did:plc:empty"), 49 + ).rejects.toThrow(); 50 + }); 51 + }); 52 + 53 + describe("createNewSecp256k1Keypair", () => { 54 + it("generates a keypair with private and public keys", async () => { 55 + const result = await plcOps.createNewSecp256k1Keypair(); 56 + 57 + expect(result.privateKey).toBeDefined(); 58 + expect(result.publicKey).toBeDefined(); 59 + expect(result.publicKey.startsWith("did:key:")).toBe(true); 60 + }); 61 + 62 + it("generates different keypairs each time", async () => { 63 + const result1 = await plcOps.createNewSecp256k1Keypair(); 64 + const result2 = await plcOps.createNewSecp256k1Keypair(); 65 + 66 + expect(result1.privateKey).not.toBe(result2.privateKey); 67 + expect(result1.publicKey).not.toBe(result2.publicKey); 68 + }); 69 + }); 70 + 71 + describe("getKeyPair", () => { 72 + it("parses 64-character hex private key", async () => { 73 + const hexKey = "a".repeat(64); 74 + 75 + const result = await plcOps.getKeyPair(hexKey); 76 + 77 + expect(result.type).toBe("private_key"); 78 + expect(result.didPublicKey.startsWith("did:key:")).toBe(true); 79 + expect(result.keypair).toBeDefined(); 80 + }); 81 + 82 + it("handles whitespace in key input", async () => { 83 + const hexKey = " " + "b".repeat(64) + " "; 84 + 85 + const result = await plcOps.getKeyPair(hexKey); 86 + 87 + expect(result.type).toBe("private_key"); 88 + }); 89 + 90 + it("throws for invalid key format", async () => { 91 + await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow( 92 + "Invalid key format", 93 + ); 94 + }); 95 + 96 + it("throws for hex key with wrong length", async () => { 97 + await expect(plcOps.getKeyPair("abc123")).rejects.toThrow( 98 + "Invalid key format", 99 + ); 100 + }); 101 + }); 102 + 103 + describe("pushPlcOperation", () => { 104 + it("posts operation to PLC directory", async () => { 105 + globalThis.fetch = vi.fn().mockResolvedValue({ 106 + ok: true, 107 + }); 108 + 109 + const operation = { 110 + type: "plc_operation" as const, 111 + prev: "bafyreiabc", 112 + alsoKnownAs: ["at://alice.example.com"], 113 + rotationKeys: ["did:key:z123"], 114 + services: { 115 + atproto_pds: { 116 + type: "AtprotoPersonalDataServer", 117 + endpoint: "https://pds.example.com", 118 + }, 119 + }, 120 + verificationMethods: { 121 + atproto: "did:key:z456", 122 + }, 123 + sig: "test-signature", 124 + }; 125 + 126 + await plcOps.pushPlcOperation("did:plc:abc123", operation); 127 + 128 + expect(fetch).toHaveBeenCalledWith( 129 + "https://plc.directory/did:plc:abc123", 130 + expect.objectContaining({ 131 + method: "POST", 132 + headers: { "Content-Type": "application/json" }, 133 + body: JSON.stringify(operation), 134 + }), 135 + ); 136 + }); 137 + 138 + it("throws with error message from PLC directory", async () => { 139 + globalThis.fetch = vi.fn().mockResolvedValue({ 140 + ok: false, 141 + status: 400, 142 + headers: new Map([["content-type", "application/json"]]), 143 + json: () => Promise.resolve({ message: "Invalid signature" }), 144 + }); 145 + 146 + const operation = { 147 + type: "plc_operation" as const, 148 + prev: "bafyreiabc", 149 + alsoKnownAs: [], 150 + rotationKeys: ["did:key:z123"], 151 + services: {}, 152 + verificationMethods: {}, 153 + sig: "bad-sig", 154 + }; 155 + 156 + await expect( 157 + plcOps.pushPlcOperation("did:plc:abc123", operation), 158 + ).rejects.toThrow("Invalid signature"); 159 + }); 160 + 161 + it("throws generic error when no message in response", async () => { 162 + globalThis.fetch = vi.fn().mockResolvedValue({ 163 + ok: false, 164 + status: 500, 165 + headers: new Map([["content-type", "text/plain"]]), 166 + }); 167 + 168 + const operation = { 169 + type: "plc_operation" as const, 170 + prev: null, 171 + alsoKnownAs: [], 172 + rotationKeys: [], 173 + services: {}, 174 + verificationMethods: {}, 175 + }; 176 + 177 + await expect( 178 + plcOps.pushPlcOperation("did:plc:abc123", operation), 179 + ).rejects.toThrow("PLC directory returned HTTP 500"); 180 + }); 181 + }); 182 + 183 + describe("createServiceAuthToken", () => { 184 + it("creates a valid JWT", async () => { 185 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 186 + const keypair = await plcOps.getKeyPair(privateKey); 187 + 188 + const token = await plcOps.createServiceAuthToken( 189 + "did:plc:issuer", 190 + "did:web:audience.example.com", 191 + keypair.keypair, 192 + "com.atproto.server.createAccount", 193 + ); 194 + 195 + expect(token).toBeDefined(); 196 + const parts = token.split("."); 197 + expect(parts).toHaveLength(3); 198 + }); 199 + 200 + it("includes correct header", async () => { 201 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 202 + const keypair = await plcOps.getKeyPair(privateKey); 203 + 204 + const token = await plcOps.createServiceAuthToken( 205 + "did:plc:issuer", 206 + "did:web:audience", 207 + keypair.keypair, 208 + "com.atproto.server.createAccount", 209 + ); 210 + 211 + const headerB64 = token.split(".")[0]; 212 + const header = JSON.parse( 213 + atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")), 214 + ); 215 + expect(header.typ).toBe("JWT"); 216 + expect(header.alg).toBe("ES256K"); 217 + }); 218 + 219 + it("includes correct payload claims", async () => { 220 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 221 + const keypair = await plcOps.getKeyPair(privateKey); 222 + 223 + const before = Math.floor(Date.now() / 1000); 224 + const token = await plcOps.createServiceAuthToken( 225 + "did:plc:myissuer", 226 + "did:web:myaudience.com", 227 + keypair.keypair, 228 + "com.atproto.sync.getRepo", 229 + ); 230 + const after = Math.floor(Date.now() / 1000); 231 + 232 + const payloadB64 = token.split(".")[1]; 233 + const payload = JSON.parse( 234 + atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")), 235 + ); 236 + 237 + expect(payload.iss).toBe("did:plc:myissuer"); 238 + expect(payload.aud).toBe("did:web:myaudience.com"); 239 + expect(payload.lxm).toBe("com.atproto.sync.getRepo"); 240 + expect(payload.iat).toBeGreaterThanOrEqual(before); 241 + expect(payload.iat).toBeLessThanOrEqual(after); 242 + expect(payload.exp).toBe(payload.iat + 60); 243 + expect(payload.jti).toBeDefined(); 244 + }); 245 + }); 246 + 247 + describe("signAndPublishNewOp", () => { 248 + it("throws when no rotation keys provided", async () => { 249 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 250 + const keypair = await plcOps.getKeyPair(privateKey); 251 + 252 + await expect( 253 + plcOps.signAndPublishNewOp( 254 + "did:plc:test", 255 + keypair.keypair, 256 + ["at://alice.example.com"], 257 + [], 258 + "https://pds.example.com", 259 + "did:key:zVerify", 260 + "bafyreiprev", 261 + ), 262 + ).rejects.toThrow("No rotation keys provided"); 263 + }); 264 + 265 + it("throws when more than 5 unique rotation keys provided", async () => { 266 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 267 + const keypair = await plcOps.getKeyPair(privateKey); 268 + 269 + const tooManyKeys = [ 270 + "did:key:z1", 271 + "did:key:z2", 272 + "did:key:z3", 273 + "did:key:z4", 274 + "did:key:z5", 275 + "did:key:z6", 276 + ]; 277 + 278 + await expect( 279 + plcOps.signAndPublishNewOp( 280 + "did:plc:test", 281 + keypair.keypair, 282 + [], 283 + tooManyKeys, 284 + "https://pds.example.com", 285 + "did:key:zVerify", 286 + "bafyreiprev", 287 + ), 288 + ).rejects.toThrow("Maximum 5 rotation keys allowed"); 289 + }); 290 + }); 291 + 292 + describe("signPlcOperationWithCredentials", () => { 293 + it("throws when no rotation keys provided", async () => { 294 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 295 + const keypair = await plcOps.getKeyPair(privateKey); 296 + 297 + await expect( 298 + plcOps.signPlcOperationWithCredentials( 299 + "did:plc:test", 300 + keypair.keypair, 301 + { 302 + rotationKeys: [], 303 + alsoKnownAs: [], 304 + verificationMethods: {}, 305 + services: {}, 306 + }, 307 + [], 308 + "bafyreiprev", 309 + ), 310 + ).rejects.toThrow("No rotation keys provided"); 311 + }); 312 + 313 + it("throws when more than 5 rotation keys provided", async () => { 314 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 315 + const keypair = await plcOps.getKeyPair(privateKey); 316 + 317 + await expect( 318 + plcOps.signPlcOperationWithCredentials( 319 + "did:plc:test", 320 + keypair.keypair, 321 + { 322 + rotationKeys: ["did:key:z1", "did:key:z2", "did:key:z3"], 323 + alsoKnownAs: [], 324 + verificationMethods: {}, 325 + services: {}, 326 + }, 327 + ["did:key:z4", "did:key:z5", "did:key:z6"], 328 + "bafyreiprev", 329 + ), 330 + ).rejects.toThrow("Maximum 5 rotation keys allowed"); 331 + }); 332 + }); 333 + });
+7 -3
frontend/src/tests/mocks.ts
··· 206 () => jsonResponse({ code: "new-invite-" + Date.now() }), 207 ); 208 mockEndpoint( 209 - "com.tranquil.account.getNotificationPrefs", 210 () => jsonResponse(mockData.notificationPrefs()), 211 ); 212 mockEndpoint( 213 - "com.tranquil.account.updateNotificationPrefs", 214 () => jsonResponse({ success: true }), 215 ); 216 mockEndpoint( 217 - "com.tranquil.account.getNotificationHistory", 218 () => jsonResponse({ notifications: [] }), 219 ); 220 mockEndpoint( ··· 240 mockEndpoint( 241 "com.atproto.repo.listRecords", 242 () => jsonResponse({ records: [] }), 243 ); 244 } 245 export function setupAuthenticatedUser(
··· 206 () => jsonResponse({ code: "new-invite-" + Date.now() }), 207 ); 208 mockEndpoint( 209 + "_account.getNotificationPrefs", 210 () => jsonResponse(mockData.notificationPrefs()), 211 ); 212 mockEndpoint( 213 + "_account.updateNotificationPrefs", 214 () => jsonResponse({ success: true }), 215 ); 216 mockEndpoint( 217 + "_account.getNotificationHistory", 218 () => jsonResponse({ notifications: [] }), 219 ); 220 mockEndpoint( ··· 240 mockEndpoint( 241 "com.atproto.repo.listRecords", 242 () => jsonResponse({ records: [] }), 243 + ); 244 + mockEndpoint( 245 + "_backup.listBackups", 246 + () => jsonResponse({ backups: [] }), 247 ); 248 } 249 export function setupAuthenticatedUser(
+15
migrations/20260101_account_backups.sql
···
··· 1 + ALTER TABLE users ADD COLUMN backup_enabled BOOLEAN NOT NULL DEFAULT TRUE; 2 + 3 + CREATE TABLE account_backups ( 4 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 5 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 6 + storage_key TEXT NOT NULL, 7 + repo_root_cid TEXT NOT NULL, 8 + repo_rev TEXT NOT NULL, 9 + block_count INT NOT NULL, 10 + size_bytes BIGINT NOT NULL, 11 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 12 + ); 13 + 14 + CREATE INDEX idx_account_backups_user_id ON account_backups(user_id); 15 + CREATE INDEX idx_account_backups_created_at ON account_backups(created_at);
+6 -3
scripts/install-debian.sh
··· 44 sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true 45 sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true 46 47 - log_info "Removing minio bucket..." 48 if command -v mc &>/dev/null; then 49 mc rb local/pds-blobs --force 2>/dev/null || true 50 mc alias remove local 2>/dev/null || true 51 fi 52 systemctl stop minio 2>/dev/null || true ··· 78 echo " - PostgreSQL database 'pds' and all data" 79 echo " - All Tranquil PDS configuration and credentials" 80 echo " - All source code in /opt/tranquil-pds" 81 - echo " - MinIO bucket 'pds-blobs' and all blobs" 82 echo "" 83 read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE 84 if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then ··· 274 mc alias remove local 2>/dev/null || true 275 mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4 276 mc mb local/pds-blobs --ignore-existing 277 - log_success "minio bucket created" 278 279 log_info "Installing rust..." 280 if [[ -f "$HOME/.cargo/env" ]]; then ··· 382 S3_ENDPOINT=http://localhost:9000 383 AWS_REGION=us-east-1 384 S3_BUCKET=pds-blobs 385 AWS_ACCESS_KEY_ID=minioadmin 386 AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} 387 VALKEY_URL=redis://localhost:6379
··· 44 sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true 45 sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true 46 47 + log_info "Removing minio buckets..." 48 if command -v mc &>/dev/null; then 49 mc rb local/pds-blobs --force 2>/dev/null || true 50 + mc rb local/pds-backups --force 2>/dev/null || true 51 mc alias remove local 2>/dev/null || true 52 fi 53 systemctl stop minio 2>/dev/null || true ··· 79 echo " - PostgreSQL database 'pds' and all data" 80 echo " - All Tranquil PDS configuration and credentials" 81 echo " - All source code in /opt/tranquil-pds" 82 + echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data" 83 echo "" 84 read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE 85 if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then ··· 275 mc alias remove local 2>/dev/null || true 276 mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4 277 mc mb local/pds-blobs --ignore-existing 278 + mc mb local/pds-backups --ignore-existing 279 + log_success "minio buckets created" 280 281 log_info "Installing rust..." 282 if [[ -f "$HOME/.cargo/env" ]]; then ··· 384 S3_ENDPOINT=http://localhost:9000 385 AWS_REGION=us-east-1 386 S3_BUCKET=pds-blobs 387 + BACKUP_S3_BUCKET=pds-backups 388 AWS_ACCESS_KEY_ID=minioadmin 389 AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} 390 VALKEY_URL=redis://localhost:6379
+5 -1
scripts/test-infra.sh
··· 83 echo "Waiting for Valkey... ($i/30)" 84 sleep 1 85 done 86 - echo "Creating MinIO bucket..." 87 $CONTAINER_CMD run --rm --network host \ 88 -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 89 minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true 90 cat > "$INFRA_FILE" << EOF 91 export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" 92 export TEST_DB_PORT="${PG_PORT}" 93 export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" 94 export S3_BUCKET="test-bucket" 95 export AWS_ACCESS_KEY_ID="minioadmin" 96 export AWS_SECRET_ACCESS_KEY="minioadmin" 97 export AWS_REGION="us-east-1"
··· 83 echo "Waiting for Valkey... ($i/30)" 84 sleep 1 85 done 86 + echo "Creating MinIO buckets..." 87 $CONTAINER_CMD run --rm --network host \ 88 -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 89 minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true 90 + $CONTAINER_CMD run --rm --network host \ 91 + -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 92 + minio/mc:latest mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true 93 cat > "$INFRA_FILE" << EOF 94 export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" 95 export TEST_DB_PORT="${PG_PORT}" 96 export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" 97 export S3_BUCKET="test-bucket" 98 + export BACKUP_S3_BUCKET="test-backups" 99 export AWS_ACCESS_KEY_ID="minioadmin" 100 export AWS_SECRET_ACCESS_KEY="minioadmin" 101 export AWS_REGION="us-east-1"
+930
src/api/backup.rs
···
··· 1 + use crate::auth::BearerAuth; 2 + use crate::scheduled::generate_full_backup; 3 + use crate::state::AppState; 4 + use crate::storage::BackupStorage; 5 + use axum::{ 6 + Json, 7 + extract::{Query, State}, 8 + http::StatusCode, 9 + response::{IntoResponse, Response}, 10 + }; 11 + use cid::Cid; 12 + use serde::{Deserialize, Serialize}; 13 + use serde_json::json; 14 + use std::str::FromStr; 15 + use tracing::{error, info, warn}; 16 + 17 + #[derive(Serialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct BackupInfo { 20 + pub id: String, 21 + pub repo_rev: String, 22 + pub repo_root_cid: String, 23 + pub block_count: i32, 24 + pub size_bytes: i64, 25 + pub created_at: String, 26 + } 27 + 28 + #[derive(Serialize)] 29 + #[serde(rename_all = "camelCase")] 30 + pub struct ListBackupsOutput { 31 + pub backups: Vec<BackupInfo>, 32 + pub backup_enabled: bool, 33 + } 34 + 35 + pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response { 36 + let user = match sqlx::query!( 37 + "SELECT id, backup_enabled FROM users WHERE did = $1", 38 + auth.0.did 39 + ) 40 + .fetch_optional(&state.db) 41 + .await 42 + { 43 + Ok(Some(u)) => u, 44 + Ok(None) => { 45 + return ( 46 + StatusCode::NOT_FOUND, 47 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 48 + ) 49 + .into_response(); 50 + } 51 + Err(e) => { 52 + error!("DB error fetching user: {:?}", e); 53 + return ( 54 + StatusCode::INTERNAL_SERVER_ERROR, 55 + Json(json!({"error": "InternalError", "message": "Database error"})), 56 + ) 57 + .into_response(); 58 + } 59 + }; 60 + 61 + let backups = match sqlx::query!( 62 + r#" 63 + SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at 64 + FROM account_backups 65 + WHERE user_id = $1 66 + ORDER BY created_at DESC 67 + "#, 68 + user.id 69 + ) 70 + .fetch_all(&state.db) 71 + .await 72 + { 73 + Ok(rows) => rows, 74 + Err(e) => { 75 + error!("DB error fetching backups: {:?}", e); 76 + return ( 77 + StatusCode::INTERNAL_SERVER_ERROR, 78 + Json(json!({"error": "InternalError", "message": "Database error"})), 79 + ) 80 + .into_response(); 81 + } 82 + }; 83 + 84 + let backup_list: Vec<BackupInfo> = backups 85 + .into_iter() 86 + .map(|b| BackupInfo { 87 + id: b.id.to_string(), 88 + repo_rev: b.repo_rev, 89 + repo_root_cid: b.repo_root_cid, 90 + block_count: b.block_count, 91 + size_bytes: b.size_bytes, 92 + created_at: b.created_at.to_rfc3339(), 93 + }) 94 + .collect(); 95 + 96 + ( 97 + StatusCode::OK, 98 + Json(ListBackupsOutput { 99 + backups: backup_list, 100 + backup_enabled: user.backup_enabled, 101 + }), 102 + ) 103 + .into_response() 104 + } 105 + 106 + #[derive(Deserialize)] 107 + pub struct GetBackupQuery { 108 + pub id: String, 109 + } 110 + 111 + pub async fn get_backup( 112 + State(state): State<AppState>, 113 + auth: BearerAuth, 114 + Query(query): Query<GetBackupQuery>, 115 + ) -> Response { 116 + let backup_id = match uuid::Uuid::parse_str(&query.id) { 117 + Ok(id) => id, 118 + Err(_) => { 119 + return ( 120 + StatusCode::BAD_REQUEST, 121 + Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), 122 + ) 123 + .into_response(); 124 + } 125 + }; 126 + 127 + let backup = match sqlx::query!( 128 + r#" 129 + SELECT ab.storage_key, ab.repo_rev 130 + FROM account_backups ab 131 + JOIN users u ON u.id = ab.user_id 132 + WHERE ab.id = $1 AND u.did = $2 133 + "#, 134 + backup_id, 135 + auth.0.did 136 + ) 137 + .fetch_optional(&state.db) 138 + .await 139 + { 140 + Ok(Some(b)) => b, 141 + Ok(None) => { 142 + return ( 143 + StatusCode::NOT_FOUND, 144 + Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), 145 + ) 146 + .into_response(); 147 + } 148 + Err(e) => { 149 + error!("DB error fetching backup: {:?}", e); 150 + return ( 151 + StatusCode::INTERNAL_SERVER_ERROR, 152 + Json(json!({"error": "InternalError", "message": "Database error"})), 153 + ) 154 + .into_response(); 155 + } 156 + }; 157 + 158 + let backup_storage = match state.backup_storage.as_ref() { 159 + Some(storage) => storage, 160 + None => { 161 + return ( 162 + StatusCode::SERVICE_UNAVAILABLE, 163 + Json( 164 + json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), 165 + ), 166 + ) 167 + .into_response(); 168 + } 169 + }; 170 + 171 + let car_bytes = match backup_storage.get_backup(&backup.storage_key).await { 172 + Ok(bytes) => bytes, 173 + Err(e) => { 174 + error!("Failed to fetch backup from storage: {:?}", e); 175 + return ( 176 + StatusCode::INTERNAL_SERVER_ERROR, 177 + Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})), 178 + ) 179 + .into_response(); 180 + } 181 + }; 182 + 183 + ( 184 + StatusCode::OK, 185 + [ 186 + (axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"), 187 + ( 188 + axum::http::header::CONTENT_DISPOSITION, 189 + &format!("attachment; filename=\"{}.car\"", backup.repo_rev), 190 + ), 191 + ], 192 + car_bytes, 193 + ) 194 + .into_response() 195 + } 196 + 197 + #[derive(Serialize)] 198 + #[serde(rename_all = "camelCase")] 199 + pub struct CreateBackupOutput { 200 + pub id: String, 201 + pub repo_rev: String, 202 + pub size_bytes: i64, 203 + pub block_count: i32, 204 + } 205 + 206 + pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> Response { 207 + let backup_storage = match state.backup_storage.as_ref() { 208 + Some(storage) => storage, 209 + None => { 210 + return ( 211 + StatusCode::SERVICE_UNAVAILABLE, 212 + Json( 213 + json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), 214 + ), 215 + ) 216 + .into_response(); 217 + } 218 + }; 219 + 220 + let user = match sqlx::query!( 221 + r#" 222 + SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev 223 + FROM users u 224 + JOIN repos r ON r.user_id = u.id 225 + WHERE u.did = $1 226 + "#, 227 + auth.0.did 228 + ) 229 + .fetch_optional(&state.db) 230 + .await 231 + { 232 + Ok(Some(u)) => u, 233 + Ok(None) => { 234 + return ( 235 + StatusCode::NOT_FOUND, 236 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 237 + ) 238 + .into_response(); 239 + } 240 + Err(e) => { 241 + error!("DB error fetching user: {:?}", e); 242 + return ( 243 + StatusCode::INTERNAL_SERVER_ERROR, 244 + Json(json!({"error": "InternalError", "message": "Database error"})), 245 + ) 246 + .into_response(); 247 + } 248 + }; 249 + 250 + if user.deactivated_at.is_some() { 251 + return ( 252 + StatusCode::BAD_REQUEST, 253 + Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 254 + ) 255 + .into_response(); 256 + } 257 + 258 + let repo_rev = match &user.repo_rev { 259 + Some(rev) => rev.clone(), 260 + None => { 261 + return ( 262 + StatusCode::BAD_REQUEST, 263 + Json( 264 + json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}), 265 + ), 266 + ) 267 + .into_response(); 268 + } 269 + }; 270 + 271 + let head_cid = match Cid::from_str(&user.repo_root_cid) { 272 + Ok(c) => c, 273 + Err(_) => { 274 + return ( 275 + StatusCode::INTERNAL_SERVER_ERROR, 276 + Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})), 277 + ) 278 + .into_response(); 279 + } 280 + }; 281 + 282 + let car_bytes = match generate_full_backup(&state.block_store, &head_cid).await { 283 + Ok(bytes) => bytes, 284 + Err(e) => { 285 + error!("Failed to generate CAR: {:?}", e); 286 + return ( 287 + StatusCode::INTERNAL_SERVER_ERROR, 288 + Json(json!({"error": "InternalError", "message": "Failed to generate backup"})), 289 + ) 290 + .into_response(); 291 + } 292 + }; 293 + 294 + let block_count = crate::scheduled::count_car_blocks(&car_bytes); 295 + let size_bytes = car_bytes.len() as i64; 296 + 297 + let storage_key = match backup_storage 298 + .put_backup(&user.did, &repo_rev, &car_bytes) 299 + .await 300 + { 301 + Ok(key) => key, 302 + Err(e) => { 303 + error!("Failed to upload backup: {:?}", e); 304 + return ( 305 + StatusCode::INTERNAL_SERVER_ERROR, 306 + Json(json!({"error": "InternalError", "message": "Failed to store backup"})), 307 + ) 308 + .into_response(); 309 + } 310 + }; 311 + 312 + let backup_id = match sqlx::query_scalar!( 313 + r#" 314 + INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) 315 + VALUES ($1, $2, $3, $4, $5, $6) 316 + RETURNING id 317 + "#, 318 + user.id, 319 + storage_key, 320 + user.repo_root_cid, 321 + repo_rev, 322 + block_count, 323 + size_bytes 324 + ) 325 + .fetch_one(&state.db) 326 + .await 327 + { 328 + Ok(id) => id, 329 + Err(e) => { 330 + error!("DB error inserting backup: {:?}", e); 331 + if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await { 332 + error!( 333 + storage_key = %storage_key, 334 + error = %rollback_err, 335 + "Failed to rollback orphaned backup from S3" 336 + ); 337 + } 338 + return ( 339 + StatusCode::INTERNAL_SERVER_ERROR, 340 + Json(json!({"error": "InternalError", "message": "Failed to record backup"})), 341 + ) 342 + .into_response(); 343 + } 344 + }; 345 + 346 + info!( 347 + did = %user.did, 348 + rev = %repo_rev, 349 + size_bytes, 350 + "Created manual backup" 351 + ); 352 + 353 + let retention = BackupStorage::retention_count(); 354 + if let Err(e) = cleanup_old_backups(&state.db, backup_storage, user.id, retention).await { 355 + warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup"); 356 + } 357 + 358 + ( 359 + StatusCode::OK, 360 + Json(CreateBackupOutput { 361 + id: backup_id.to_string(), 362 + repo_rev, 363 + size_bytes, 364 + block_count, 365 + }), 366 + ) 367 + .into_response() 368 + } 369 + 370 + async fn cleanup_old_backups( 371 + db: &sqlx::PgPool, 372 + backup_storage: &BackupStorage, 373 + user_id: uuid::Uuid, 374 + retention_count: u32, 375 + ) -> Result<(), String> { 376 + let old_backups = sqlx::query!( 377 + r#" 378 + SELECT id, storage_key 379 + FROM account_backups 380 + WHERE user_id = $1 381 + ORDER BY created_at DESC 382 + OFFSET $2 383 + "#, 384 + user_id, 385 + retention_count as i64 386 + ) 387 + .fetch_all(db) 388 + .await 389 + .map_err(|e| format!("DB error fetching old backups: {}", e))?; 390 + 391 + for backup in old_backups { 392 + if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { 393 + warn!( 394 + storage_key = %backup.storage_key, 395 + error = %e, 396 + "Failed to delete old backup from storage, skipping DB cleanup to avoid orphan" 397 + ); 398 + continue; 399 + } 400 + 401 + sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 402 + .execute(db) 403 + .await 404 + .map_err(|e| format!("Failed to delete old backup record: {}", e))?; 405 + } 406 + 407 + Ok(()) 408 + } 409 + 410 + #[derive(Deserialize)] 411 + pub struct DeleteBackupQuery { 412 + pub id: String, 413 + } 414 + 415 + pub async fn delete_backup( 416 + State(state): State<AppState>, 417 + auth: BearerAuth, 418 + Query(query): Query<DeleteBackupQuery>, 419 + ) -> Response { 420 + let backup_id = match uuid::Uuid::parse_str(&query.id) { 421 + Ok(id) => id, 422 + Err(_) => { 423 + return ( 424 + StatusCode::BAD_REQUEST, 425 + Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), 426 + ) 427 + .into_response(); 428 + } 429 + }; 430 + 431 + let backup = match sqlx::query!( 432 + r#" 433 + SELECT ab.id, ab.storage_key, u.deactivated_at 434 + FROM account_backups ab 435 + JOIN users u ON u.id = ab.user_id 436 + WHERE ab.id = $1 AND u.did = $2 437 + "#, 438 + backup_id, 439 + auth.0.did 440 + ) 441 + .fetch_optional(&state.db) 442 + .await 443 + { 444 + Ok(Some(b)) => b, 445 + Ok(None) => { 446 + return ( 447 + StatusCode::NOT_FOUND, 448 + Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), 449 + ) 450 + .into_response(); 451 + } 452 + Err(e) => { 453 + error!("DB error fetching backup: {:?}", e); 454 + return ( 455 + StatusCode::INTERNAL_SERVER_ERROR, 456 + Json(json!({"error": "InternalError", "message": "Database error"})), 457 + ) 458 + .into_response(); 459 + } 460 + }; 461 + 462 + if backup.deactivated_at.is_some() { 463 + return ( 464 + StatusCode::BAD_REQUEST, 465 + Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 466 + ) 467 + .into_response(); 468 + } 469 + 470 + if let Some(backup_storage) = state.backup_storage.as_ref() 471 + && let Err(e) = backup_storage.delete_backup(&backup.storage_key).await 472 + { 473 + warn!( 474 + storage_key = %backup.storage_key, 475 + error = %e, 476 + "Failed to delete backup from storage (continuing anyway)" 477 + ); 478 + } 479 + 480 + if let Err(e) = sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 481 + .execute(&state.db) 482 + .await 483 + { 484 + error!("DB error deleting backup: {:?}", e); 485 + return ( 486 + StatusCode::INTERNAL_SERVER_ERROR, 487 + Json(json!({"error": "InternalError", "message": "Failed to delete backup"})), 488 + ) 489 + .into_response(); 490 + } 491 + 492 + info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup"); 493 + 494 + (StatusCode::OK, Json(json!({}))).into_response() 495 + } 496 + 497 + #[derive(Deserialize)] 498 + #[serde(rename_all = "camelCase")] 499 + pub struct SetBackupEnabledInput { 500 + pub enabled: bool, 501 + } 502 + 503 + pub async fn set_backup_enabled( 504 + State(state): State<AppState>, 505 + auth: BearerAuth, 506 + Json(input): Json<SetBackupEnabledInput>, 507 + ) -> Response { 508 + let user = match sqlx::query!( 509 + "SELECT deactivated_at FROM users WHERE did = $1", 510 + auth.0.did 511 + ) 512 + .fetch_optional(&state.db) 513 + .await 514 + { 515 + Ok(Some(u)) => u, 516 + Ok(None) => { 517 + return ( 518 + StatusCode::NOT_FOUND, 519 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 520 + ) 521 + .into_response(); 522 + } 523 + Err(e) => { 524 + error!("DB error fetching user: {:?}", e); 525 + return ( 526 + StatusCode::INTERNAL_SERVER_ERROR, 527 + Json(json!({"error": "InternalError", "message": "Database error"})), 528 + ) 529 + .into_response(); 530 + } 531 + }; 532 + 533 + if user.deactivated_at.is_some() { 534 + return ( 535 + StatusCode::BAD_REQUEST, 536 + Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 537 + ) 538 + .into_response(); 539 + } 540 + 541 + if let Err(e) = sqlx::query!( 542 + "UPDATE users SET backup_enabled = $1 WHERE did = $2", 543 + input.enabled, 544 + auth.0.did 545 + ) 546 + .execute(&state.db) 547 + .await 548 + { 549 + error!("DB error updating backup_enabled: {:?}", e); 550 + return ( 551 + StatusCode::INTERNAL_SERVER_ERROR, 552 + Json(json!({"error": "InternalError", "message": "Failed to update setting"})), 553 + ) 554 + .into_response(); 555 + } 556 + 557 + info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting"); 558 + 559 + (StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response() 560 + } 561 + 562 + pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response { 563 + let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did) 564 + .fetch_optional(&state.db) 565 + .await 566 + { 567 + Ok(Some(u)) => u, 568 + Ok(None) => { 569 + return ( 570 + StatusCode::NOT_FOUND, 571 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 572 + ) 573 + .into_response(); 574 + } 575 + Err(e) => { 576 + error!("DB error fetching user: {:?}", e); 577 + return ( 578 + StatusCode::INTERNAL_SERVER_ERROR, 579 + Json(json!({"error": "InternalError", "message": "Database error"})), 580 + ) 581 + .into_response(); 582 + } 583 + }; 584 + 585 + let blobs = match sqlx::query!( 586 + r#" 587 + SELECT DISTINCT b.cid, b.storage_key, b.mime_type 588 + FROM blobs b 589 + JOIN record_blobs rb ON rb.blob_cid = b.cid 590 + WHERE rb.repo_id = $1 591 + "#, 592 + user.id 593 + ) 594 + .fetch_all(&state.db) 595 + .await 596 + { 597 + Ok(rows) => rows, 598 + Err(e) => { 599 + error!("DB error fetching blobs: {:?}", e); 600 + return ( 601 + StatusCode::INTERNAL_SERVER_ERROR, 602 + Json(json!({"error": "InternalError", "message": "Database error"})), 603 + ) 604 + .into_response(); 605 + } 606 + }; 607 + 608 + if blobs.is_empty() { 609 + return ( 610 + StatusCode::OK, 611 + [ 612 + (axum::http::header::CONTENT_TYPE, "application/zip"), 613 + ( 614 + axum::http::header::CONTENT_DISPOSITION, 615 + "attachment; filename=\"blobs.zip\"", 616 + ), 617 + ], 618 + Vec::<u8>::new(), 619 + ) 620 + .into_response(); 621 + } 622 + 623 + let mut zip_buffer = std::io::Cursor::new(Vec::new()); 624 + { 625 + let mut zip = zip::ZipWriter::new(&mut zip_buffer); 626 + 627 + let options = zip::write::SimpleFileOptions::default() 628 + .compression_method(zip::CompressionMethod::Deflated); 629 + 630 + let mut exported: Vec<serde_json::Value> = Vec::new(); 631 + let mut skipped: Vec<serde_json::Value> = Vec::new(); 632 + 633 + for blob in &blobs { 634 + let blob_data = match state.blob_store.get(&blob.storage_key).await { 635 + Ok(data) => data, 636 + Err(e) => { 637 + warn!(cid = %blob.cid, error = %e, "Failed to fetch blob, skipping"); 638 + skipped.push(json!({ 639 + "cid": blob.cid, 640 + "mimeType": blob.mime_type, 641 + "reason": "fetch_failed" 642 + })); 643 + continue; 644 + } 645 + }; 646 + 647 + let extension = mime_to_extension(&blob.mime_type); 648 + let filename = format!("{}{}", blob.cid, extension); 649 + 650 + if let Err(e) = zip.start_file(&filename, options) { 651 + warn!(filename = %filename, error = %e, "Failed to start zip file entry"); 652 + skipped.push(json!({ 653 + "cid": blob.cid, 654 + "mimeType": blob.mime_type, 655 + "reason": "zip_entry_failed" 656 + })); 657 + continue; 658 + } 659 + 660 + if let Err(e) = std::io::Write::write_all(&mut zip, &blob_data) { 661 + warn!(filename = %filename, error = %e, "Failed to write blob to zip"); 662 + skipped.push(json!({ 663 + "cid": blob.cid, 664 + "mimeType": blob.mime_type, 665 + "reason": "write_failed" 666 + })); 667 + continue; 668 + } 669 + 670 + exported.push(json!({ 671 + "cid": blob.cid, 672 + "filename": filename, 673 + "mimeType": blob.mime_type, 674 + "sizeBytes": blob_data.len() 675 + })); 676 + } 677 + 678 + let manifest = json!({ 679 + "exportedAt": chrono::Utc::now().to_rfc3339(), 680 + "totalBlobs": blobs.len(), 681 + "exportedCount": exported.len(), 682 + "skippedCount": skipped.len(), 683 + "exported": exported, 684 + "skipped": skipped 685 + }); 686 + 687 + if zip.start_file("manifest.json", options).is_ok() { 688 + let _ = std::io::Write::write_all( 689 + &mut zip, 690 + serde_json::to_string_pretty(&manifest) 691 + .unwrap_or_else(|_| "{}".to_string()) 692 + .as_bytes(), 693 + ); 694 + } 695 + 696 + if let Err(e) = zip.finish() { 697 + error!("Failed to finish zip: {:?}", e); 698 + return ( 699 + StatusCode::INTERNAL_SERVER_ERROR, 700 + Json(json!({"error": "InternalError", "message": "Failed to create zip file"})), 701 + ) 702 + .into_response(); 703 + } 704 + } 705 + 706 + let zip_bytes = zip_buffer.into_inner(); 707 + 708 + info!(did = %auth.0.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs"); 709 + 710 + ( 711 + StatusCode::OK, 712 + [ 713 + (axum::http::header::CONTENT_TYPE, "application/zip"), 714 + ( 715 + axum::http::header::CONTENT_DISPOSITION, 716 + "attachment; filename=\"blobs.zip\"", 717 + ), 718 + ], 719 + zip_bytes, 720 + ) 721 + .into_response() 722 + } 723 + 724 + fn mime_to_extension(mime_type: &str) -> &'static str { 725 + match mime_type { 726 + "application/font-sfnt" => ".otf", 727 + "application/font-tdpfr" => ".pfr", 728 + "application/font-woff" => ".woff", 729 + "application/gzip" => ".gz", 730 + "application/json" => ".json", 731 + "application/json5" => ".json5", 732 + "application/jsonml+json" => ".jsonml", 733 + "application/octet-stream" => ".bin", 734 + "application/pdf" => ".pdf", 735 + "application/zip" => ".zip", 736 + "audio/aac" => ".aac", 737 + "audio/ac3" => ".ac3", 738 + "audio/aiff" => ".aiff", 739 + "audio/annodex" => ".axa", 740 + "audio/audible" => ".aa", 741 + "audio/basic" => ".au", 742 + "audio/flac" => ".flac", 743 + "audio/m4a" => ".m4a", 744 + "audio/m4b" => ".m4b", 745 + "audio/m4p" => ".m4p", 746 + "audio/mid" => ".mid", 747 + "audio/midi" => ".midi", 748 + "audio/mp4" => ".mp4a", 749 + "audio/mpeg" => ".mp3", 750 + "audio/ogg" => ".ogg", 751 + "audio/s3m" => ".s3m", 752 + "audio/scpls" => ".pls", 753 + "audio/silk" => ".sil", 754 + "audio/vnd.audible.aax" => ".aax", 755 + "audio/vnd.dece.audio" => ".uva", 756 + "audio/vnd.digital-winds" => ".eol", 757 + "audio/vnd.dlna.adts" => ".adt", 758 + "audio/vnd.dra" => ".dra", 759 + "audio/vnd.dts" => ".dts", 760 + "audio/vnd.dts.hd" => ".dtshd", 761 + "audio/vnd.lucent.voice" => ".lvp", 762 + "audio/vnd.ms-playready.media.pya" => ".pya", 763 + "audio/vnd.nuera.ecelp4800" => ".ecelp4800", 764 + "audio/vnd.nuera.ecelp7470" => ".ecelp7470", 765 + "audio/vnd.nuera.ecelp9600" => ".ecelp9600", 766 + "audio/vnd.rip" => ".rip", 767 + "audio/wav" => ".wav", 768 + "audio/webm" => ".weba", 769 + "audio/x-caf" => ".caf", 770 + "audio/x-gsm" => ".gsm", 771 + "audio/x-m4r" => ".m4r", 772 + "audio/x-matroska" => ".mka", 773 + "audio/x-mpegurl" => ".m3u", 774 + "audio/x-ms-wax" => ".wax", 775 + "audio/x-ms-wma" => ".wma", 776 + "audio/x-pn-realaudio" => ".ra", 777 + "audio/x-pn-realaudio-plugin" => ".rpm", 778 + "audio/x-sd2" => ".sd2", 779 + "audio/x-smd" => ".smd", 780 + "audio/xm" => ".xm", 781 + "font/collection" => ".ttc", 782 + "font/ttf" => ".ttf", 783 + "font/woff" => ".woff", 784 + "font/woff2" => ".woff2", 785 + "image/apng" => ".apng", 786 + "image/avif" => ".avif", 787 + "image/avif-sequence" => ".avifs", 788 + "image/bmp" => ".bmp", 789 + "image/cgm" => ".cgm", 790 + "image/cis-cod" => ".cod", 791 + "image/g3fax" => ".g3", 792 + "image/gif" => ".gif", 793 + "image/heic" => ".heic", 794 + "image/heic-sequence" => ".heics", 795 + "image/heif" => ".heif", 796 + "image/heif-sequence" => ".heifs", 797 + "image/ief" => ".ief", 798 + "image/jp2" => ".jp2", 799 + "image/jpeg" => ".jpg", 800 + "image/jpm" => ".jpm", 801 + "image/jpx" => ".jpf", 802 + "image/jxl" => ".jxl", 803 + "image/ktx" => ".ktx", 804 + "image/pict" => ".pct", 805 + "image/png" => ".png", 806 + "image/prs.btif" => ".btif", 807 + "image/qoi" => ".qoi", 808 + "image/sgi" => ".sgi", 809 + "image/svg+xml" => ".svg", 810 + "image/tiff" => ".tiff", 811 + "image/vnd.dece.graphic" => ".uvg", 812 + "image/vnd.djvu" => ".djv", 813 + "image/vnd.fastbidsheet" => ".fbs", 814 + "image/vnd.fpx" => ".fpx", 815 + "image/vnd.fst" => ".fst", 816 + "image/vnd.fujixerox.edmics-mmr" => ".mmr", 817 + "image/vnd.fujixerox.edmics-rlc" => ".rlc", 818 + "image/vnd.ms-modi" => ".mdi", 819 + "image/vnd.ms-photo" => ".wdp", 820 + "image/vnd.net-fpx" => ".npx", 821 + "image/vnd.radiance" => ".hdr", 822 + "image/vnd.rn-realflash" => ".rf", 823 + "image/vnd.wap.wbmp" => ".wbmp", 824 + "image/vnd.xiff" => ".xif", 825 + "image/webp" => ".webp", 826 + "image/x-3ds" => ".3ds", 827 + "image/x-adobe-dng" => ".dng", 828 + "image/x-canon-cr2" => ".cr2", 829 + "image/x-canon-cr3" => ".cr3", 830 + "image/x-canon-crw" => ".crw", 831 + "image/x-cmu-raster" => ".ras", 832 + "image/x-cmx" => ".cmx", 833 + "image/x-epson-erf" => ".erf", 834 + "image/x-freehand" => ".fh", 835 + "image/x-fuji-raf" => ".raf", 836 + "image/x-icon" => ".ico", 837 + "image/x-jg" => ".art", 838 + "image/x-jng" => ".jng", 839 + "image/x-kodak-dcr" => ".dcr", 840 + "image/x-kodak-k25" => ".k25", 841 + "image/x-kodak-kdc" => ".kdc", 842 + "image/x-macpaint" => ".mac", 843 + "image/x-minolta-mrw" => ".mrw", 844 + "image/x-mrsid-image" => ".sid", 845 + "image/x-nikon-nef" => ".nef", 846 + "image/x-nikon-nrw" => ".nrw", 847 + "image/x-olympus-orf" => ".orf", 848 + "image/x-panasonic-rw" => ".raw", 849 + "image/x-panasonic-rw2" => ".rw2", 850 + "image/x-pentax-pef" => ".pef", 851 + "image/x-portable-anymap" => ".pnm", 852 + "image/x-portable-bitmap" => ".pbm", 853 + "image/x-portable-graymap" => ".pgm", 854 + "image/x-portable-pixmap" => ".ppm", 855 + "image/x-qoi" => ".qoi", 856 + "image/x-quicktime" => ".qti", 857 + "image/x-rgb" => ".rgb", 858 + "image/x-sigma-x3f" => ".x3f", 859 + "image/x-sony-arw" => ".arw", 860 + "image/x-sony-sr2" => ".sr2", 861 + "image/x-sony-srf" => ".srf", 862 + "image/x-tga" => ".tga", 863 + "image/x-xbitmap" => ".xbm", 864 + "image/x-xcf" => ".xcf", 865 + "image/x-xpixmap" => ".xpm", 866 + "image/x-xwindowdump" => ".xwd", 867 + "model/gltf+json" => ".gltf", 868 + "model/gltf-binary" => ".glb", 869 + "model/iges" => ".igs", 870 + "model/mesh" => ".msh", 871 + "model/vnd.collada+xml" => ".dae", 872 + "model/vnd.gdl" => ".gdl", 873 + "model/vnd.gtw" => ".gtw", 874 + "model/vnd.vtu" => ".vtu", 875 + "model/vrml" => ".vrml", 876 + "model/x3d+binary" => ".x3db", 877 + "model/x3d+vrml" => ".x3dv", 878 + "model/x3d+xml" => ".x3d", 879 + "text/css" => ".css", 880 + "text/html" => ".html", 881 + "text/plain" => ".txt", 882 + "video/3gpp" => ".3gp", 883 + "video/3gpp2" => ".3g2", 884 + "video/annodex" => ".axv", 885 + "video/divx" => ".divx", 886 + "video/h261" => ".h261", 887 + "video/h263" => ".h263", 888 + "video/h264" => ".h264", 889 + "video/jpeg" => ".jpgv", 890 + "video/jpm" => ".jpgm", 891 + "video/mj2" => ".mj2", 892 + "video/mp4" => ".mp4", 893 + "video/mpeg" => ".mpg", 894 + "video/ogg" => ".ogv", 895 + "video/quicktime" => ".mov", 896 + "video/vnd.dece.hd" => ".uvh", 897 + "video/vnd.dece.mobile" => ".uvm", 898 + "video/vnd.dece.pd" => ".uvp", 899 + "video/vnd.dece.sd" => ".uvs", 900 + "video/vnd.dece.video" => ".uvv", 901 + "video/vnd.dlna.mpeg-tts" => ".ts", 902 + "video/vnd.dvb.file" => ".dvb", 903 + "video/vnd.fvt" => ".fvt", 904 + "video/vnd.mpegurl" => ".m4u", 905 + "video/vnd.ms-playready.media.pyv" => ".pyv", 906 + "video/vnd.uvvu.mp4" => ".uvu", 907 + "video/vnd.vivo" => ".viv", 908 + "video/webm" => ".webm", 909 + "video/x-dv" => ".dv", 910 + "video/x-f4v" => ".f4v", 911 + "video/x-fli" => ".fli", 912 + "video/x-flv" => ".flv", 913 + "video/x-ivf" => ".ivf", 914 + "video/x-la-asf" => ".lsf", 915 + "video/x-m4v" => ".m4v", 916 + "video/x-matroska" => ".mkv", 917 + "video/x-mng" => ".mng", 918 + "video/x-ms-asf" => ".asf", 919 + "video/x-ms-vob" => ".vob", 920 + "video/x-ms-wm" => ".wm", 921 + "video/x-ms-wmp" => ".wmp", 922 + "video/x-ms-wmv" => ".wmv", 923 + "video/x-ms-wmx" => ".wmx", 924 + "video/x-ms-wvx" => ".wvx", 925 + "video/x-msvideo" => ".avi", 926 + "video/x-sgi-movie" => ".movie", 927 + "video/x-smv" => ".smv", 928 + _ => ".bin", 929 + } 930 + }
+1
src/api/mod.rs
··· 1 pub mod actor; 2 pub mod admin; 3 pub mod age_assurance; 4 pub mod delegation; 5 pub mod error; 6 pub mod identity;
··· 1 pub mod actor; 2 pub mod admin; 3 pub mod age_assurance; 4 + pub mod backup; 5 pub mod delegation; 6 pub mod error; 7 pub mod identity;
+26 -7
src/api/notification_prefs.rs
··· 182 .into_response(), 183 }; 184 185 let notifications = rows 186 .iter() 187 - .map(|row| NotificationHistoryEntry { 188 - created_at: row.created_at.to_rfc3339(), 189 - channel: row.channel.clone(), 190 - comms_type: row.comms_type.clone(), 191 - status: row.status.clone(), 192 - subject: row.subject.clone(), 193 - body: row.body.clone(), 194 }) 195 .collect(); 196
··· 182 .into_response(), 183 }; 184 185 + let sensitive_types = [ 186 + "email_verification", 187 + "password_reset", 188 + "email_update", 189 + "two_factor_code", 190 + "passkey_recovery", 191 + "migration_verification", 192 + "plc_operation", 193 + "channel_verification", 194 + "signup_verification", 195 + ]; 196 + 197 let notifications = rows 198 .iter() 199 + .map(|row| { 200 + let body = if sensitive_types.contains(&row.comms_type.as_str()) { 201 + "[Code redacted for security]".to_string() 202 + } else { 203 + row.body.clone() 204 + }; 205 + NotificationHistoryEntry { 206 + created_at: row.created_at.to_rfc3339(), 207 + channel: row.channel.clone(), 208 + comms_type: row.comms_type.clone(), 209 + status: row.status.clone(), 210 + subject: row.subject.clone(), 211 + body, 212 + } 213 }) 214 .collect(); 215
+1 -1
src/api/repo/blob.rs
··· 312 r#" 313 SELECT rb.blob_cid, rb.record_uri 314 FROM record_blobs rb 315 - LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id 316 WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2 317 ORDER BY rb.blob_cid 318 LIMIT $3
··· 312 r#" 313 SELECT rb.blob_cid, rb.record_uri 314 FROM record_blobs rb 315 + LEFT JOIN blobs b ON rb.blob_cid = b.cid 316 WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2 317 ORDER BY rb.blob_cid 318 LIMIT $3
+4 -2
src/api/repo/record/batch.rs
··· 345 let rkey = rkey 346 .clone() 347 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 348 let mut record_bytes = Vec::new(); 349 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 350 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 351 } 352 let record_cid = match tracking_store.put(&record_bytes).await { ··· 409 } 410 }; 411 all_blob_cids.extend(extract_blob_cids(value)); 412 let mut record_bytes = Vec::new(); 413 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 414 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 415 } 416 let record_cid = match tracking_store.put(&record_bytes).await {
··· 345 let rkey = rkey 346 .clone() 347 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 348 + let record_ipld = crate::util::json_to_ipld(value); 349 let mut record_bytes = Vec::new(); 350 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 351 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 352 } 353 let record_cid = match tracking_store.put(&record_bytes).await { ··· 410 } 411 }; 412 all_blob_cids.extend(extract_blob_cids(value)); 413 + let record_ipld = crate::util::json_to_ipld(value); 414 let mut record_bytes = Vec::new(); 415 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 416 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 417 } 418 let record_cid = match tracking_store.put(&record_bytes).await {
+2 -1
src/api/repo/record/utils.rs
··· 382 let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes) 383 .map_err(|e| format!("Failed to parse commit: {:?}", e))?; 384 let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); 385 let mut record_bytes = Vec::new(); 386 - serde_ipld_dagcbor::to_writer(&mut record_bytes, record) 387 .map_err(|e| format!("Failed to serialize record: {:?}", e))?; 388 let record_cid = tracking_store 389 .put(&record_bytes)
··· 382 let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes) 383 .map_err(|e| format!("Failed to parse commit: {:?}", e))?; 384 let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); 385 + let record_ipld = crate::util::json_to_ipld(record); 386 let mut record_bytes = Vec::new(); 387 + serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld) 388 .map_err(|e| format!("Failed to serialize record: {:?}", e))?; 389 let record_cid = tracking_store 390 .put(&record_bytes)
+4 -2
src/api/repo/record/write.rs
··· 297 let rkey = input 298 .rkey 299 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 300 let mut record_bytes = Vec::new(); 301 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 302 return ( 303 StatusCode::BAD_REQUEST, 304 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), ··· 550 } 551 } 552 let existing_cid = mst.get(&key).await.ok().flatten(); 553 let mut record_bytes = Vec::new(); 554 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 555 return ( 556 StatusCode::BAD_REQUEST, 557 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
··· 297 let rkey = input 298 .rkey 299 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 300 + let record_ipld = crate::util::json_to_ipld(&input.record); 301 let mut record_bytes = Vec::new(); 302 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 303 return ( 304 StatusCode::BAD_REQUEST, 305 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), ··· 551 } 552 } 553 let existing_cid = mst.get(&key).await.ok().flatten(); 554 + let record_ipld = crate::util::json_to_ipld(&input.record); 555 let mut record_bytes = Vec::new(); 556 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 557 return ( 558 StatusCode::BAD_REQUEST, 559 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
+15 -44
src/api/server/account_status.rs
··· 567 #[serde(rename_all = "camelCase")] 568 pub struct DeactivateAccountInput { 569 pub delete_after: Option<String>, 570 - pub migrating_to: Option<String>, 571 } 572 573 pub async fn deactivate_account( ··· 618 619 let did = auth_user.did; 620 621 - let migrating_to = if let Some(ref url) = input.migrating_to { 622 - let url = url.trim().trim_end_matches('/'); 623 - if url.is_empty() || !did.starts_with("did:web:") { 624 - None 625 - } else { 626 - if !url.starts_with("https://") { 627 - return ApiError::InvalidRequest("migratingTo must start with https://".into()) 628 - .into_response(); 629 - } 630 - Some(url.to_string()) 631 - } 632 - } else { 633 - None 634 - }; 635 - 636 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 637 .fetch_optional(&state.db) 638 .await 639 .ok() 640 .flatten(); 641 642 - let result = if let Some(ref pds_url) = migrating_to { 643 - sqlx::query!( 644 - "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1", 645 - did, 646 - delete_after, 647 - pds_url 648 - ) 649 - .execute(&state.db) 650 - .await 651 - } else { 652 - sqlx::query!( 653 - "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1", 654 - did, 655 - delete_after 656 - ) 657 - .execute(&state.db) 658 - .await 659 - }; 660 - 661 - let status = if migrating_to.is_some() { 662 - "migrated" 663 - } else { 664 - "deactivated" 665 - }; 666 667 match result { 668 Ok(_) => { 669 if let Some(ref h) = handle { 670 let _ = state.cache.delete(&format!("handle:{}", h)).await; 671 } 672 - if let Err(e) = 673 - crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status)) 674 - .await 675 { 676 - warn!("Failed to sequence account {} event: {}", status, e); 677 } 678 (StatusCode::OK, Json(json!({}))).into_response() 679 }
··· 567 #[serde(rename_all = "camelCase")] 568 pub struct DeactivateAccountInput { 569 pub delete_after: Option<String>, 570 } 571 572 pub async fn deactivate_account( ··· 617 618 let did = auth_user.did; 619 620 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 621 .fetch_optional(&state.db) 622 .await 623 .ok() 624 .flatten(); 625 626 + let result = sqlx::query!( 627 + "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1", 628 + did, 629 + delete_after 630 + ) 631 + .execute(&state.db) 632 + .await; 633 634 match result { 635 Ok(_) => { 636 if let Some(ref h) = handle { 637 let _ = state.cache.delete(&format!("handle:{}", h)).await; 638 } 639 + if let Err(e) = crate::api::repo::record::sequence_account_event( 640 + &state, 641 + &did, 642 + false, 643 + Some("deactivated"), 644 + ) 645 + .await 646 { 647 + warn!("Failed to sequence account deactivated event: {}", e); 648 } 649 (StatusCode::OK, Json(json!({}))).into_response() 650 }
+54
src/api/server/email.rs
··· 476 info!("Email updated for user {}", user_id); 477 (StatusCode::OK, Json(json!({}))).into_response() 478 }
··· 476 info!("Email updated for user {}", user_id); 477 (StatusCode::OK, Json(json!({}))).into_response() 478 } 479 + 480 + #[derive(Deserialize)] 481 + pub struct CheckEmailVerifiedInput { 482 + pub identifier: String, 483 + } 484 + 485 + pub async fn check_email_verified( 486 + State(state): State<AppState>, 487 + headers: axum::http::HeaderMap, 488 + Json(input): Json<CheckEmailVerifiedInput>, 489 + ) -> Response { 490 + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 491 + if !state 492 + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) 493 + .await 494 + { 495 + return ( 496 + StatusCode::TOO_MANY_REQUESTS, 497 + Json(json!({ 498 + "error": "RateLimitExceeded", 499 + "message": "Too many requests. Please try again later." 500 + })), 501 + ) 502 + .into_response(); 503 + } 504 + 505 + let user = sqlx::query!( 506 + "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 507 + input.identifier 508 + ) 509 + .fetch_optional(&state.db) 510 + .await; 511 + 512 + match user { 513 + Ok(Some(row)) => ( 514 + StatusCode::OK, 515 + Json(json!({ "verified": row.email_verified })), 516 + ) 517 + .into_response(), 518 + Ok(None) => ( 519 + StatusCode::NOT_FOUND, 520 + Json(json!({ "error": "AccountNotFound", "message": "Account not found" })), 521 + ) 522 + .into_response(), 523 + Err(e) => { 524 + error!("DB error checking email verified: {:?}", e); 525 + ( 526 + StatusCode::INTERNAL_SERVER_ERROR, 527 + Json(json!({ "error": "InternalError" })), 528 + ) 529 + .into_response() 530 + } 531 + } 532 + }
+6 -241
src/api/server/migration.rs
··· 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; 9 - use chrono::{DateTime, Utc}; 10 use serde::{Deserialize, Serialize}; 11 use serde_json::json; 12 13 - #[derive(Serialize)] 14 - #[serde(rename_all = "camelCase")] 15 - pub struct GetMigrationStatusOutput { 16 - pub did: String, 17 - pub did_type: String, 18 - pub migrated: bool, 19 - #[serde(skip_serializing_if = "Option::is_none")] 20 - pub migrated_to_pds: Option<String>, 21 - #[serde(skip_serializing_if = "Option::is_none")] 22 - pub migrated_at: Option<DateTime<Utc>>, 23 - } 24 - 25 - pub async fn get_migration_status( 26 - State(state): State<AppState>, 27 - headers: axum::http::HeaderMap, 28 - ) -> Response { 29 - let extracted = match crate::auth::extract_auth_token_from_header( 30 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 31 - ) { 32 - Some(t) => t, 33 - None => return ApiError::AuthenticationRequired.into_response(), 34 - }; 35 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 36 - let http_uri = format!( 37 - "https://{}/xrpc/com.tranquil.account.getMigrationStatus", 38 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 39 - ); 40 - let auth_user = match crate::auth::validate_token_with_dpop( 41 - &state.db, 42 - &extracted.token, 43 - extracted.is_dpop, 44 - dpop_proof, 45 - "GET", 46 - &http_uri, 47 - true, 48 - ) 49 - .await 50 - { 51 - Ok(user) => user, 52 - Err(e) => return ApiError::from(e).into_response(), 53 - }; 54 - let user = match sqlx::query!( 55 - "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 56 - auth_user.did 57 - ) 58 - .fetch_optional(&state.db) 59 - .await 60 - { 61 - Ok(Some(row)) => row, 62 - Ok(None) => return ApiError::AccountNotFound.into_response(), 63 - Err(e) => { 64 - tracing::error!("DB error getting migration status: {:?}", e); 65 - return ApiError::InternalError.into_response(); 66 - } 67 - }; 68 - let did_type = if user.did.starts_with("did:plc:") { 69 - "plc" 70 - } else if user.did.starts_with("did:web:") { 71 - "web" 72 - } else { 73 - "unknown" 74 - }; 75 - let migrated = user.migrated_to_pds.is_some(); 76 - ( 77 - StatusCode::OK, 78 - Json(GetMigrationStatusOutput { 79 - did: user.did, 80 - did_type: did_type.to_string(), 81 - migrated, 82 - migrated_to_pds: user.migrated_to_pds, 83 - migrated_at: user.migrated_at, 84 - }), 85 - ) 86 - .into_response() 87 - } 88 - 89 - #[derive(Deserialize)] 90 - #[serde(rename_all = "camelCase")] 91 - pub struct UpdateMigrationForwardingInput { 92 - pub pds_url: String, 93 - } 94 - 95 - #[derive(Serialize)] 96 - #[serde(rename_all = "camelCase")] 97 - pub struct UpdateMigrationForwardingOutput { 98 - pub success: bool, 99 - pub migrated_to_pds: String, 100 - pub migrated_at: DateTime<Utc>, 101 - } 102 - 103 - pub async fn update_migration_forwarding( 104 - State(state): State<AppState>, 105 - headers: axum::http::HeaderMap, 106 - Json(input): Json<UpdateMigrationForwardingInput>, 107 - ) -> Response { 108 - let extracted = match crate::auth::extract_auth_token_from_header( 109 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 110 - ) { 111 - Some(t) => t, 112 - None => return ApiError::AuthenticationRequired.into_response(), 113 - }; 114 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 115 - let http_uri = format!( 116 - "https://{}/xrpc/com.tranquil.account.updateMigrationForwarding", 117 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 118 - ); 119 - let auth_user = match crate::auth::validate_token_with_dpop( 120 - &state.db, 121 - &extracted.token, 122 - extracted.is_dpop, 123 - dpop_proof, 124 - "POST", 125 - &http_uri, 126 - true, 127 - ) 128 - .await 129 - { 130 - Ok(user) => user, 131 - Err(e) => return ApiError::from(e).into_response(), 132 - }; 133 - if !auth_user.did.starts_with("did:web:") { 134 - return ( 135 - StatusCode::BAD_REQUEST, 136 - Json(json!({ 137 - "error": "InvalidRequest", 138 - "message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates." 139 - })), 140 - ) 141 - .into_response(); 142 - } 143 - let pds_url = input.pds_url.trim(); 144 - if pds_url.is_empty() { 145 - return ApiError::InvalidRequest("pds_url is required".into()).into_response(); 146 - } 147 - if !pds_url.starts_with("https://") { 148 - return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response(); 149 - } 150 - let pds_url_clean = pds_url.trim_end_matches('/'); 151 - let now = Utc::now(); 152 - let result = sqlx::query!( 153 - "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", 154 - pds_url_clean, 155 - now, 156 - auth_user.did 157 - ) 158 - .execute(&state.db) 159 - .await; 160 - match result { 161 - Ok(_) => { 162 - tracing::info!( 163 - "Updated migration forwarding for {} to {}", 164 - auth_user.did, 165 - pds_url_clean 166 - ); 167 - ( 168 - StatusCode::OK, 169 - Json(UpdateMigrationForwardingOutput { 170 - success: true, 171 - migrated_to_pds: pds_url_clean.to_string(), 172 - migrated_at: now, 173 - }), 174 - ) 175 - .into_response() 176 - } 177 - Err(e) => { 178 - tracing::error!("DB error updating migration forwarding: {:?}", e); 179 - ApiError::InternalError.into_response() 180 - } 181 - } 182 - } 183 - 184 - pub async fn clear_migration_forwarding( 185 - State(state): State<AppState>, 186 - headers: axum::http::HeaderMap, 187 - ) -> Response { 188 - let extracted = match crate::auth::extract_auth_token_from_header( 189 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 190 - ) { 191 - Some(t) => t, 192 - None => return ApiError::AuthenticationRequired.into_response(), 193 - }; 194 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 195 - let http_uri = format!( 196 - "https://{}/xrpc/com.tranquil.account.clearMigrationForwarding", 197 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 198 - ); 199 - let auth_user = match crate::auth::validate_token_with_dpop( 200 - &state.db, 201 - &extracted.token, 202 - extracted.is_dpop, 203 - dpop_proof, 204 - "POST", 205 - &http_uri, 206 - true, 207 - ) 208 - .await 209 - { 210 - Ok(user) => user, 211 - Err(e) => return ApiError::from(e).into_response(), 212 - }; 213 - if !auth_user.did.starts_with("did:web:") { 214 - return ( 215 - StatusCode::BAD_REQUEST, 216 - Json(json!({ 217 - "error": "InvalidRequest", 218 - "message": "Migration forwarding is only available for did:web accounts" 219 - })), 220 - ) 221 - .into_response(); 222 - } 223 - let result = sqlx::query!( 224 - "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 225 - auth_user.did 226 - ) 227 - .execute(&state.db) 228 - .await; 229 - match result { 230 - Ok(_) => { 231 - tracing::info!("Cleared migration forwarding for {}", auth_user.did); 232 - (StatusCode::OK, Json(json!({ "success": true }))).into_response() 233 - } 234 - Err(e) => { 235 - tracing::error!("DB error clearing migration forwarding: {:?}", e); 236 - ApiError::InternalError.into_response() 237 - } 238 - } 239 - } 240 - 241 #[derive(Debug, Clone, Serialize, Deserialize)] 242 #[serde(rename_all = "camelCase")] 243 pub struct VerificationMethod { ··· 275 }; 276 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 277 let http_uri = format!( 278 - "https://{}/xrpc/com.tranquil.account.updateDidDocument", 279 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 280 ); 281 let auth_user = match crate::auth::validate_token_with_dpop( ··· 305 } 306 307 let user = match sqlx::query!( 308 - "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 309 auth_user.did 310 ) 311 .fetch_optional(&state.db) ··· 319 } 320 }; 321 322 - if user.migrated_to_pds.is_none() { 323 - return ( 324 - StatusCode::BAD_REQUEST, 325 - Json(json!({ 326 - "error": "InvalidRequest", 327 - "message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first." 328 - })), 329 - ) 330 - .into_response(); 331 } 332 333 if let Some(ref methods) = input.verification_methods { ··· 452 }; 453 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 454 let http_uri = format!( 455 - "https://{}/xrpc/com.tranquil.account.getDidDocument", 456 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 457 ); 458 let auth_user = match crate::auth::validate_token_with_dpop(
··· 6 http::StatusCode, 7 response::{IntoResponse, Response}, 8 }; 9 + use chrono::Utc; 10 use serde::{Deserialize, Serialize}; 11 use serde_json::json; 12 13 #[derive(Debug, Clone, Serialize, Deserialize)] 14 #[serde(rename_all = "camelCase")] 15 pub struct VerificationMethod { ··· 47 }; 48 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 49 let http_uri = format!( 50 + "https://{}/xrpc/_account.updateDidDocument", 51 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 52 ); 53 let auth_user = match crate::auth::validate_token_with_dpop( ··· 77 } 78 79 let user = match sqlx::query!( 80 + "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 81 auth_user.did 82 ) 83 .fetch_optional(&state.db) ··· 91 } 92 }; 93 94 + if user.deactivated_at.is_some() { 95 + return ApiError::AccountDeactivated.into_response(); 96 } 97 98 if let Some(ref methods) = input.verification_methods { ··· 217 }; 218 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 219 let http_uri = format!( 220 + "https://{}/xrpc/_account.getDidDocument", 221 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 222 ); 223 let auth_user = match crate::auth::validate_token_with_dpop(
+2 -5
src/api/server/mod.rs
··· 22 request_account_delete, 23 }; 24 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 25 - pub use email::{confirm_email, request_email_update, update_email}; 26 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 27 pub use logo::get_logo; 28 pub use meta::{describe_server, health, robots_txt}; 29 - pub use migration::{ 30 - clear_migration_forwarding, get_did_document, get_migration_status, update_did_document, 31 - update_migration_forwarding, 32 - }; 33 pub use passkey_account::{ 34 complete_passkey_setup, create_passkey_account, recover_passkey_account, 35 request_passkey_recovery, start_passkey_registration_for_setup,
··· 22 request_account_delete, 23 }; 24 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 25 + pub use email::{check_email_verified, confirm_email, request_email_update, update_email}; 26 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 27 pub use logo::get_logo; 28 pub use meta::{describe_server, health, robots_txt}; 29 + pub use migration::{get_did_document, update_did_document}; 30 pub use passkey_account::{ 31 complete_passkey_setup, create_passkey_account, recover_passkey_account, 32 request_passkey_recovery, start_passkey_registration_for_setup,
+59 -55
src/lib.rs
··· 57 get(api::server::get_session), 58 ) 59 .route( 60 - "/xrpc/com.tranquil.account.listSessions", 61 get(api::server::list_sessions), 62 ) 63 .route( 64 - "/xrpc/com.tranquil.account.revokeSession", 65 post(api::server::revoke_session), 66 ) 67 .route( 68 - "/xrpc/com.tranquil.account.revokeAllSessions", 69 post(api::server::revoke_all_sessions), 70 ) 71 .route( ··· 208 post(api::server::reset_password), 209 ) 210 .route( 211 - "/xrpc/com.tranquil.account.changePassword", 212 post(api::server::change_password), 213 ) 214 .route( 215 - "/xrpc/com.tranquil.account.removePassword", 216 post(api::server::remove_password), 217 ) 218 .route( 219 - "/xrpc/com.tranquil.account.getPasswordStatus", 220 get(api::server::get_password_status), 221 ) 222 .route( 223 - "/xrpc/com.tranquil.account.getReauthStatus", 224 get(api::server::get_reauth_status), 225 ) 226 .route( 227 - "/xrpc/com.tranquil.account.reauthPassword", 228 post(api::server::reauth_password), 229 ) 230 - .route( 231 - "/xrpc/com.tranquil.account.reauthTotp", 232 - post(api::server::reauth_totp), 233 - ) 234 .route( 235 - "/xrpc/com.tranquil.account.reauthPasskeyStart", 236 post(api::server::reauth_passkey_start), 237 ) 238 .route( 239 - "/xrpc/com.tranquil.account.reauthPasskeyFinish", 240 post(api::server::reauth_passkey_finish), 241 ) 242 .route( 243 - "/xrpc/com.tranquil.account.getLegacyLoginPreference", 244 get(api::server::get_legacy_login_preference), 245 ) 246 .route( 247 - "/xrpc/com.tranquil.account.updateLegacyLoginPreference", 248 post(api::server::update_legacy_login_preference), 249 ) 250 .route( 251 - "/xrpc/com.tranquil.account.updateLocale", 252 post(api::server::update_locale), 253 ) 254 .route( 255 - "/xrpc/com.tranquil.account.listTrustedDevices", 256 get(api::server::list_trusted_devices), 257 ) 258 .route( 259 - "/xrpc/com.tranquil.account.revokeTrustedDevice", 260 post(api::server::revoke_trusted_device), 261 ) 262 .route( 263 - "/xrpc/com.tranquil.account.updateTrustedDevice", 264 post(api::server::update_trusted_device), 265 ) 266 .route( 267 - "/xrpc/com.tranquil.account.createPasskeyAccount", 268 post(api::server::create_passkey_account), 269 ) 270 .route( 271 - "/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup", 272 post(api::server::start_passkey_registration_for_setup), 273 ) 274 .route( 275 - "/xrpc/com.tranquil.account.completePasskeySetup", 276 post(api::server::complete_passkey_setup), 277 ) 278 .route( 279 - "/xrpc/com.tranquil.account.requestPasskeyRecovery", 280 post(api::server::request_passkey_recovery), 281 ) 282 .route( 283 - "/xrpc/com.tranquil.account.recoverPasskeyAccount", 284 post(api::server::recover_passkey_account), 285 ) 286 .route( 287 - "/xrpc/com.tranquil.account.getMigrationStatus", 288 - get(api::server::get_migration_status), 289 - ) 290 - .route( 291 - "/xrpc/com.tranquil.account.updateMigrationForwarding", 292 - post(api::server::update_migration_forwarding), 293 - ) 294 - .route( 295 - "/xrpc/com.tranquil.account.clearMigrationForwarding", 296 - post(api::server::clear_migration_forwarding), 297 - ) 298 - .route( 299 - "/xrpc/com.tranquil.account.updateDidDocument", 300 post(api::server::update_did_document), 301 ) 302 .route( 303 - "/xrpc/com.tranquil.account.getDidDocument", 304 get(api::server::get_did_document), 305 ) 306 .route( 307 "/xrpc/com.atproto.server.requestEmailUpdate", 308 post(api::server::request_email_update), 309 ) 310 .route( 311 "/xrpc/com.atproto.server.confirmEmail", ··· 432 get(api::admin::get_invite_codes), 433 ) 434 .route( 435 - "/xrpc/com.tranquil.admin.getServerStats", 436 get(api::admin::get_server_stats), 437 ) 438 .route( 439 - "/xrpc/com.tranquil.server.getConfig", 440 get(api::admin::get_server_config), 441 ) 442 .route( 443 - "/xrpc/com.tranquil.admin.updateServerConfig", 444 post(api::admin::update_server_config), 445 ) 446 .route( ··· 575 post(api::temp::dereference_scope), 576 ) 577 .route( 578 - "/xrpc/com.tranquil.account.getNotificationPrefs", 579 get(api::notification_prefs::get_notification_prefs), 580 ) 581 .route( 582 - "/xrpc/com.tranquil.account.updateNotificationPrefs", 583 post(api::notification_prefs::update_notification_prefs), 584 ) 585 .route( 586 - "/xrpc/com.tranquil.account.getNotificationHistory", 587 get(api::notification_prefs::get_notification_history), 588 ) 589 .route( 590 - "/xrpc/com.tranquil.account.confirmChannelVerification", 591 post(api::verification::confirm_channel_verification), 592 ) 593 .route( 594 - "/xrpc/com.tranquil.account.verifyToken", 595 post(api::server::verify_token), 596 ) 597 .route( 598 - "/xrpc/com.tranquil.delegation.listControllers", 599 get(api::delegation::list_controllers), 600 ) 601 .route( 602 - "/xrpc/com.tranquil.delegation.addController", 603 post(api::delegation::add_controller), 604 ) 605 .route( 606 - "/xrpc/com.tranquil.delegation.removeController", 607 post(api::delegation::remove_controller), 608 ) 609 .route( 610 - "/xrpc/com.tranquil.delegation.updateControllerScopes", 611 post(api::delegation::update_controller_scopes), 612 ) 613 .route( 614 - "/xrpc/com.tranquil.delegation.listControlledAccounts", 615 get(api::delegation::list_controlled_accounts), 616 ) 617 .route( 618 - "/xrpc/com.tranquil.delegation.getAuditLog", 619 get(api::delegation::get_audit_log), 620 ) 621 .route( 622 - "/xrpc/com.tranquil.delegation.getScopePresets", 623 get(api::delegation::get_scope_presets), 624 ) 625 .route( 626 - "/xrpc/com.tranquil.delegation.createDelegatedAccount", 627 post(api::delegation::create_delegated_account), 628 ) 629 .route( 630 "/xrpc/app.bsky.ageassurance.getState", 631 get(api::age_assurance::get_state),
··· 57 get(api::server::get_session), 58 ) 59 .route( 60 + "/xrpc/_account.listSessions", 61 get(api::server::list_sessions), 62 ) 63 .route( 64 + "/xrpc/_account.revokeSession", 65 post(api::server::revoke_session), 66 ) 67 .route( 68 + "/xrpc/_account.revokeAllSessions", 69 post(api::server::revoke_all_sessions), 70 ) 71 .route( ··· 208 post(api::server::reset_password), 209 ) 210 .route( 211 + "/xrpc/_account.changePassword", 212 post(api::server::change_password), 213 ) 214 .route( 215 + "/xrpc/_account.removePassword", 216 post(api::server::remove_password), 217 ) 218 .route( 219 + "/xrpc/_account.getPasswordStatus", 220 get(api::server::get_password_status), 221 ) 222 .route( 223 + "/xrpc/_account.getReauthStatus", 224 get(api::server::get_reauth_status), 225 ) 226 .route( 227 + "/xrpc/_account.reauthPassword", 228 post(api::server::reauth_password), 229 ) 230 + .route("/xrpc/_account.reauthTotp", post(api::server::reauth_totp)) 231 .route( 232 + "/xrpc/_account.reauthPasskeyStart", 233 post(api::server::reauth_passkey_start), 234 ) 235 .route( 236 + "/xrpc/_account.reauthPasskeyFinish", 237 post(api::server::reauth_passkey_finish), 238 ) 239 .route( 240 + "/xrpc/_account.getLegacyLoginPreference", 241 get(api::server::get_legacy_login_preference), 242 ) 243 .route( 244 + "/xrpc/_account.updateLegacyLoginPreference", 245 post(api::server::update_legacy_login_preference), 246 ) 247 .route( 248 + "/xrpc/_account.updateLocale", 249 post(api::server::update_locale), 250 ) 251 .route( 252 + "/xrpc/_account.listTrustedDevices", 253 get(api::server::list_trusted_devices), 254 ) 255 .route( 256 + "/xrpc/_account.revokeTrustedDevice", 257 post(api::server::revoke_trusted_device), 258 ) 259 .route( 260 + "/xrpc/_account.updateTrustedDevice", 261 post(api::server::update_trusted_device), 262 ) 263 .route( 264 + "/xrpc/_account.createPasskeyAccount", 265 post(api::server::create_passkey_account), 266 ) 267 .route( 268 + "/xrpc/_account.startPasskeyRegistrationForSetup", 269 post(api::server::start_passkey_registration_for_setup), 270 ) 271 .route( 272 + "/xrpc/_account.completePasskeySetup", 273 post(api::server::complete_passkey_setup), 274 ) 275 .route( 276 + "/xrpc/_account.requestPasskeyRecovery", 277 post(api::server::request_passkey_recovery), 278 ) 279 .route( 280 + "/xrpc/_account.recoverPasskeyAccount", 281 post(api::server::recover_passkey_account), 282 ) 283 .route( 284 + "/xrpc/_account.updateDidDocument", 285 post(api::server::update_did_document), 286 ) 287 .route( 288 + "/xrpc/_account.getDidDocument", 289 get(api::server::get_did_document), 290 ) 291 .route( 292 "/xrpc/com.atproto.server.requestEmailUpdate", 293 post(api::server::request_email_update), 294 + ) 295 + .route( 296 + "/xrpc/_checkEmailVerified", 297 + post(api::server::check_email_verified), 298 ) 299 .route( 300 "/xrpc/com.atproto.server.confirmEmail", ··· 421 get(api::admin::get_invite_codes), 422 ) 423 .route( 424 + "/xrpc/_admin.getServerStats", 425 get(api::admin::get_server_stats), 426 ) 427 .route( 428 + "/xrpc/_server.getConfig", 429 get(api::admin::get_server_config), 430 ) 431 .route( 432 + "/xrpc/_admin.updateServerConfig", 433 post(api::admin::update_server_config), 434 ) 435 .route( ··· 564 post(api::temp::dereference_scope), 565 ) 566 .route( 567 + "/xrpc/_account.getNotificationPrefs", 568 get(api::notification_prefs::get_notification_prefs), 569 ) 570 .route( 571 + "/xrpc/_account.updateNotificationPrefs", 572 post(api::notification_prefs::update_notification_prefs), 573 ) 574 .route( 575 + "/xrpc/_account.getNotificationHistory", 576 get(api::notification_prefs::get_notification_history), 577 ) 578 .route( 579 + "/xrpc/_account.confirmChannelVerification", 580 post(api::verification::confirm_channel_verification), 581 ) 582 .route( 583 + "/xrpc/_account.verifyToken", 584 post(api::server::verify_token), 585 ) 586 .route( 587 + "/xrpc/_delegation.listControllers", 588 get(api::delegation::list_controllers), 589 ) 590 .route( 591 + "/xrpc/_delegation.addController", 592 post(api::delegation::add_controller), 593 ) 594 .route( 595 + "/xrpc/_delegation.removeController", 596 post(api::delegation::remove_controller), 597 ) 598 .route( 599 + "/xrpc/_delegation.updateControllerScopes", 600 post(api::delegation::update_controller_scopes), 601 ) 602 .route( 603 + "/xrpc/_delegation.listControlledAccounts", 604 get(api::delegation::list_controlled_accounts), 605 ) 606 .route( 607 + "/xrpc/_delegation.getAuditLog", 608 get(api::delegation::get_audit_log), 609 ) 610 .route( 611 + "/xrpc/_delegation.getScopePresets", 612 get(api::delegation::get_scope_presets), 613 ) 614 .route( 615 + "/xrpc/_delegation.createDelegatedAccount", 616 post(api::delegation::create_delegated_account), 617 ) 618 + .route("/xrpc/_backup.listBackups", get(api::backup::list_backups)) 619 + .route("/xrpc/_backup.getBackup", get(api::backup::get_backup)) 620 + .route( 621 + "/xrpc/_backup.createBackup", 622 + post(api::backup::create_backup), 623 + ) 624 + .route( 625 + "/xrpc/_backup.deleteBackup", 626 + post(api::backup::delete_backup), 627 + ) 628 + .route( 629 + "/xrpc/_backup.setEnabled", 630 + post(api::backup::set_backup_enabled), 631 + ) 632 + .route("/xrpc/_backup.exportBlobs", get(api::backup::export_blobs)) 633 .route( 634 "/xrpc/app.bsky.ageassurance.getState", 635 get(api::age_assurance::get_state),
+18 -1
src/main.rs
··· 7 use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; 8 use tranquil_pds::scheduled::{ 9 backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, 10 - start_scheduled_tasks, 11 }; 12 use tranquil_pds::state::AppState; 13 ··· 83 None 84 }; 85 86 let scheduled_handle = tokio::spawn(start_scheduled_tasks( 87 state.db.clone(), 88 state.blob_store.clone(), ··· 114 comms_handle.await.ok(); 115 116 if let Some(handle) = crawlers_handle { 117 handle.await.ok(); 118 } 119
··· 7 use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; 8 use tranquil_pds::scheduled::{ 9 backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, 10 + start_backup_tasks, start_scheduled_tasks, 11 }; 12 use tranquil_pds::state::AppState; 13 ··· 83 None 84 }; 85 86 + let backup_handle = if let Some(backup_storage) = state.backup_storage.clone() { 87 + info!("Backup service enabled"); 88 + Some(tokio::spawn(start_backup_tasks( 89 + state.db.clone(), 90 + state.block_store.clone(), 91 + backup_storage, 92 + shutdown_rx.clone(), 93 + ))) 94 + } else { 95 + warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)"); 96 + None 97 + }; 98 + 99 let scheduled_handle = tokio::spawn(start_scheduled_tasks( 100 state.db.clone(), 101 state.blob_store.clone(), ··· 127 comms_handle.await.ok(); 128 129 if let Some(handle) = crawlers_handle { 130 + handle.await.ok(); 131 + } 132 + 133 + if let Some(handle) = backup_handle { 134 handle.await.ok(); 135 } 136
+4
src/rate_limit.rs
··· 32 pub totp_verify: Arc<KeyedRateLimiter>, 33 pub handle_update: Arc<KeyedRateLimiter>, 34 pub handle_update_daily: Arc<KeyedRateLimiter>, 35 } 36 37 impl Default for RateLimiters { ··· 91 .unwrap() 92 .allow_burst(NonZeroU32::new(50).unwrap()), 93 )), 94 } 95 } 96
··· 32 pub totp_verify: Arc<KeyedRateLimiter>, 33 pub handle_update: Arc<KeyedRateLimiter>, 34 pub handle_update_daily: Arc<KeyedRateLimiter>, 35 + pub verification_check: Arc<KeyedRateLimiter>, 36 } 37 38 impl Default for RateLimiters { ··· 92 .unwrap() 93 .allow_burst(NonZeroU32::new(50).unwrap()), 94 )), 95 + verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute( 96 + NonZeroU32::new(60).unwrap(), 97 + ))), 98 } 99 } 100
+311 -1
src/scheduled.rs
··· 11 use tracing::{debug, error, info, warn}; 12 13 use crate::repo::PostgresBlockStore; 14 - use crate::storage::BlobStorage; 15 16 pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) { 17 let broken_genesis_commits = match sqlx::query!( ··· 563 564 Ok(()) 565 }
··· 11 use tracing::{debug, error, info, warn}; 12 13 use crate::repo::PostgresBlockStore; 14 + use crate::storage::{BackupStorage, BlobStorage}; 15 + use crate::sync::car::encode_car_header; 16 17 pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) { 18 let broken_genesis_commits = match sqlx::query!( ··· 564 565 Ok(()) 566 } 567 + 568 + pub async fn start_backup_tasks( 569 + db: PgPool, 570 + block_store: PostgresBlockStore, 571 + backup_storage: Arc<BackupStorage>, 572 + mut shutdown_rx: watch::Receiver<bool>, 573 + ) { 574 + let backup_interval = Duration::from_secs(BackupStorage::interval_secs()); 575 + 576 + info!( 577 + interval_secs = backup_interval.as_secs(), 578 + retention_count = BackupStorage::retention_count(), 579 + "Starting backup service" 580 + ); 581 + 582 + let mut ticker = interval(backup_interval); 583 + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 584 + 585 + loop { 586 + tokio::select! { 587 + _ = shutdown_rx.changed() => { 588 + if *shutdown_rx.borrow() { 589 + info!("Backup service shutting down"); 590 + break; 591 + } 592 + } 593 + _ = ticker.tick() => { 594 + if let Err(e) = process_scheduled_backups(&db, &block_store, &backup_storage).await { 595 + error!("Error processing scheduled backups: {}", e); 596 + } 597 + } 598 + } 599 + } 600 + } 601 + 602 + async fn process_scheduled_backups( 603 + db: &PgPool, 604 + block_store: &PostgresBlockStore, 605 + backup_storage: &BackupStorage, 606 + ) -> Result<(), String> { 607 + let backup_interval_secs = BackupStorage::interval_secs() as i64; 608 + let retention_count = BackupStorage::retention_count(); 609 + 610 + let users_needing_backup = sqlx::query!( 611 + r#" 612 + SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev 613 + FROM users u 614 + JOIN repos r ON r.user_id = u.id 615 + WHERE u.backup_enabled = true 616 + AND u.deactivated_at IS NULL 617 + AND ( 618 + NOT EXISTS ( 619 + SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id 620 + ) 621 + OR ( 622 + SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id 623 + ) < NOW() - make_interval(secs => $1) 624 + ) 625 + LIMIT 50 626 + "#, 627 + backup_interval_secs as f64 628 + ) 629 + .fetch_all(db) 630 + .await 631 + .map_err(|e| format!("DB error fetching users for backup: {}", e))?; 632 + 633 + if users_needing_backup.is_empty() { 634 + debug!("No accounts need backup"); 635 + return Ok(()); 636 + } 637 + 638 + info!( 639 + count = users_needing_backup.len(), 640 + "Processing scheduled backups" 641 + ); 642 + 643 + for user in users_needing_backup { 644 + let repo_root_cid = user.repo_root_cid.clone(); 645 + 646 + let repo_rev = match &user.repo_rev { 647 + Some(rev) => rev.clone(), 648 + None => { 649 + warn!(did = %user.did, "User has no repo_rev, skipping backup"); 650 + continue; 651 + } 652 + }; 653 + 654 + let head_cid = match Cid::from_str(&repo_root_cid) { 655 + Ok(c) => c, 656 + Err(e) => { 657 + warn!(did = %user.did, error = %e, "Invalid repo_root_cid, skipping backup"); 658 + continue; 659 + } 660 + }; 661 + 662 + let car_result = generate_full_backup(block_store, &head_cid).await; 663 + let car_bytes = match car_result { 664 + Ok(bytes) => bytes, 665 + Err(e) => { 666 + warn!(did = %user.did, error = %e, "Failed to generate CAR for backup"); 667 + continue; 668 + } 669 + }; 670 + 671 + let block_count = count_car_blocks(&car_bytes); 672 + let size_bytes = car_bytes.len() as i64; 673 + 674 + let storage_key = match backup_storage 675 + .put_backup(&user.did, &repo_rev, &car_bytes) 676 + .await 677 + { 678 + Ok(key) => key, 679 + Err(e) => { 680 + warn!(did = %user.did, error = %e, "Failed to upload backup to storage"); 681 + continue; 682 + } 683 + }; 684 + 685 + if let Err(e) = sqlx::query!( 686 + r#" 687 + INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) 688 + VALUES ($1, $2, $3, $4, $5, $6) 689 + "#, 690 + user.user_id, 691 + storage_key, 692 + repo_root_cid, 693 + repo_rev, 694 + block_count, 695 + size_bytes 696 + ) 697 + .execute(db) 698 + .await 699 + { 700 + warn!(did = %user.did, error = %e, "Failed to insert backup record, rolling back S3 upload"); 701 + if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await { 702 + error!( 703 + did = %user.did, 704 + storage_key = %storage_key, 705 + error = %rollback_err, 706 + "Failed to rollback orphaned backup from S3" 707 + ); 708 + } 709 + continue; 710 + } 711 + 712 + info!( 713 + did = %user.did, 714 + rev = %repo_rev, 715 + size_bytes, 716 + block_count, 717 + "Created backup" 718 + ); 719 + 720 + if let Err(e) = cleanup_old_backups(db, backup_storage, user.user_id, retention_count).await 721 + { 722 + warn!(did = %user.did, error = %e, "Failed to cleanup old backups"); 723 + } 724 + } 725 + 726 + Ok(()) 727 + } 728 + 729 + pub async fn generate_repo_car( 730 + block_store: &PostgresBlockStore, 731 + head_cid: &Cid, 732 + ) -> Result<Vec<u8>, String> { 733 + use jacquard_repo::storage::BlockStore; 734 + use std::io::Write; 735 + 736 + let mut car_bytes = 737 + encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?; 738 + 739 + let mut stack = vec![*head_cid]; 740 + let mut visited = std::collections::HashSet::new(); 741 + 742 + while let Some(cid) = stack.pop() { 743 + if visited.contains(&cid) { 744 + continue; 745 + } 746 + visited.insert(cid); 747 + 748 + if let Ok(Some(block)) = block_store.get(&cid).await { 749 + let cid_bytes = cid.to_bytes(); 750 + let total_len = cid_bytes.len() + block.len(); 751 + let mut writer = Vec::new(); 752 + crate::sync::car::write_varint(&mut writer, total_len as u64) 753 + .expect("Writing to Vec<u8> should never fail"); 754 + writer 755 + .write_all(&cid_bytes) 756 + .expect("Writing to Vec<u8> should never fail"); 757 + writer 758 + .write_all(&block) 759 + .expect("Writing to Vec<u8> should never fail"); 760 + car_bytes.extend_from_slice(&writer); 761 + 762 + if let Ok(value) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) { 763 + extract_links(&value, &mut stack); 764 + } 765 + } 766 + } 767 + 768 + Ok(car_bytes) 769 + } 770 + 771 + pub async fn generate_full_backup( 772 + block_store: &PostgresBlockStore, 773 + head_cid: &Cid, 774 + ) -> Result<Vec<u8>, String> { 775 + generate_repo_car(block_store, head_cid).await 776 + } 777 + 778 + fn extract_links(value: &Ipld, stack: &mut Vec<Cid>) { 779 + match value { 780 + Ipld::Link(cid) => { 781 + stack.push(*cid); 782 + } 783 + Ipld::Map(map) => { 784 + for v in map.values() { 785 + extract_links(v, stack); 786 + } 787 + } 788 + Ipld::List(arr) => { 789 + for v in arr { 790 + extract_links(v, stack); 791 + } 792 + } 793 + _ => {} 794 + } 795 + } 796 + 797 + pub fn count_car_blocks(car_bytes: &[u8]) -> i32 { 798 + let mut count = 0; 799 + let mut pos = 0; 800 + 801 + if let Some((header_len, header_varint_len)) = read_varint(&car_bytes[pos..]) { 802 + pos += header_varint_len + header_len as usize; 803 + } else { 804 + return 0; 805 + } 806 + 807 + while pos < car_bytes.len() { 808 + if let Some((block_len, varint_len)) = read_varint(&car_bytes[pos..]) { 809 + pos += varint_len + block_len as usize; 810 + count += 1; 811 + } else { 812 + break; 813 + } 814 + } 815 + 816 + count 817 + } 818 + 819 + fn read_varint(data: &[u8]) -> Option<(u64, usize)> { 820 + let mut value: u64 = 0; 821 + let mut shift = 0; 822 + let mut pos = 0; 823 + 824 + while pos < data.len() && pos < 10 { 825 + let byte = data[pos]; 826 + value |= ((byte & 0x7f) as u64) << shift; 827 + pos += 1; 828 + if byte & 0x80 == 0 { 829 + return Some((value, pos)); 830 + } 831 + shift += 7; 832 + } 833 + 834 + None 835 + } 836 + 837 + async fn cleanup_old_backups( 838 + db: &PgPool, 839 + backup_storage: &BackupStorage, 840 + user_id: uuid::Uuid, 841 + retention_count: u32, 842 + ) -> Result<(), String> { 843 + let old_backups = sqlx::query!( 844 + r#" 845 + SELECT id, storage_key 846 + FROM account_backups 847 + WHERE user_id = $1 848 + ORDER BY created_at DESC 849 + OFFSET $2 850 + "#, 851 + user_id, 852 + retention_count as i64 853 + ) 854 + .fetch_all(db) 855 + .await 856 + .map_err(|e| format!("DB error fetching old backups: {}", e))?; 857 + 858 + for backup in old_backups { 859 + if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { 860 + warn!( 861 + storage_key = %backup.storage_key, 862 + error = %e, 863 + "Failed to delete old backup from storage, skipping DB cleanup to avoid orphan" 864 + ); 865 + continue; 866 + } 867 + 868 + sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 869 + .execute(db) 870 + .await 871 + .map_err(|e| format!("Failed to delete old backup record: {}", e))?; 872 + } 873 + 874 + Ok(()) 875 + }
+8 -1
src/state.rs
··· 4 use crate::config::AuthConfig; 5 use crate::rate_limit::RateLimiters; 6 use crate::repo::PostgresBlockStore; 7 - use crate::storage::{BlobStorage, S3BlobStorage}; 8 use crate::sync::firehose::SequencedEvent; 9 use sqlx::PgPool; 10 use std::error::Error; ··· 16 pub db: PgPool, 17 pub block_store: PostgresBlockStore, 18 pub blob_store: Arc<dyn BlobStorage>, 19 pub firehose_tx: broadcast::Sender<SequencedEvent>, 20 pub rate_limiters: Arc<RateLimiters>, 21 pub circuit_breakers: Arc<CircuitBreakers>, ··· 39 TotpVerify, 40 HandleUpdate, 41 HandleUpdateDaily, 42 } 43 44 impl RateLimitKind { ··· 58 Self::TotpVerify => "totp_verify", 59 Self::HandleUpdate => "handle_update", 60 Self::HandleUpdateDaily => "handle_update_daily", 61 } 62 } 63 ··· 77 Self::TotpVerify => (5, 300_000), 78 Self::HandleUpdate => (10, 300_000), 79 Self::HandleUpdateDaily => (50, 86_400_000), 80 } 81 } 82 } ··· 131 132 let block_store = PostgresBlockStore::new(db.clone()); 133 let blob_store = S3BlobStorage::new().await; 134 135 let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE") 136 .ok() ··· 147 db, 148 block_store, 149 blob_store: Arc::new(blob_store), 150 firehose_tx, 151 rate_limiters, 152 circuit_breakers, ··· 199 RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, 200 RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, 201 RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, 202 }; 203 204 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
··· 4 use crate::config::AuthConfig; 5 use crate::rate_limit::RateLimiters; 6 use crate::repo::PostgresBlockStore; 7 + use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage}; 8 use crate::sync::firehose::SequencedEvent; 9 use sqlx::PgPool; 10 use std::error::Error; ··· 16 pub db: PgPool, 17 pub block_store: PostgresBlockStore, 18 pub blob_store: Arc<dyn BlobStorage>, 19 + pub backup_storage: Option<Arc<BackupStorage>>, 20 pub firehose_tx: broadcast::Sender<SequencedEvent>, 21 pub rate_limiters: Arc<RateLimiters>, 22 pub circuit_breakers: Arc<CircuitBreakers>, ··· 40 TotpVerify, 41 HandleUpdate, 42 HandleUpdateDaily, 43 + VerificationCheck, 44 } 45 46 impl RateLimitKind { ··· 60 Self::TotpVerify => "totp_verify", 61 Self::HandleUpdate => "handle_update", 62 Self::HandleUpdateDaily => "handle_update_daily", 63 + Self::VerificationCheck => "verification_check", 64 } 65 } 66 ··· 80 Self::TotpVerify => (5, 300_000), 81 Self::HandleUpdate => (10, 300_000), 82 Self::HandleUpdateDaily => (50, 86_400_000), 83 + Self::VerificationCheck => (60, 60_000), 84 } 85 } 86 } ··· 135 136 let block_store = PostgresBlockStore::new(db.clone()); 137 let blob_store = S3BlobStorage::new().await; 138 + let backup_storage = BackupStorage::new().await.map(Arc::new); 139 140 let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE") 141 .ok() ··· 152 db, 153 block_store, 154 blob_store: Arc::new(blob_store), 155 + backup_storage, 156 firehose_tx, 157 rate_limiters, 158 circuit_breakers, ··· 205 RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, 206 RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, 207 RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, 208 + RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check, 209 }; 210 211 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+119 -16
src/storage/mod.rs
··· 32 33 impl S3BlobStorage { 34 pub async fn new() -> Self { 35 - let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); 36 37 - let config = aws_config::defaults(BehaviorVersion::latest()) 38 - .region(region_provider) 39 - .load() 40 - .await; 41 42 - let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 43 44 - let client = if let Ok(endpoint) = std::env::var("S3_ENDPOINT") { 45 - let s3_config = aws_sdk_s3::config::Builder::from(&config) 46 - .endpoint_url(endpoint) 47 - .force_path_style(true) 48 - .build(); 49 - Client::from_conf(s3_config) 50 - } else { 51 - Client::new(&config) 52 - }; 53 54 - Self { client, bucket } 55 } 56 } 57
··· 32 33 impl S3BlobStorage { 34 pub async fn new() -> Self { 35 + let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 36 + let client = create_s3_client().await; 37 + Self { client, bucket } 38 + } 39 + } 40 + 41 + async fn create_s3_client() -> Client { 42 + let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); 43 + 44 + let config = aws_config::defaults(BehaviorVersion::latest()) 45 + .region(region_provider) 46 + .load() 47 + .await; 48 + 49 + if let Ok(endpoint) = std::env::var("S3_ENDPOINT") { 50 + let s3_config = aws_sdk_s3::config::Builder::from(&config) 51 + .endpoint_url(endpoint) 52 + .force_path_style(true) 53 + .build(); 54 + Client::from_conf(s3_config) 55 + } else { 56 + Client::new(&config) 57 + } 58 + } 59 + 60 + pub struct BackupStorage { 61 + client: Client, 62 + bucket: String, 63 + } 64 + 65 + impl BackupStorage { 66 + pub async fn new() -> Option<Self> { 67 + let backup_enabled = std::env::var("BACKUP_ENABLED") 68 + .map(|v| v != "false" && v != "0") 69 + .unwrap_or(true); 70 + 71 + if !backup_enabled { 72 + return None; 73 + } 74 + 75 + let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?; 76 + let client = create_s3_client().await; 77 + Some(Self { client, bucket }) 78 + } 79 + 80 + pub fn retention_count() -> u32 { 81 + std::env::var("BACKUP_RETENTION_COUNT") 82 + .ok() 83 + .and_then(|v| v.parse().ok()) 84 + .unwrap_or(7) 85 + } 86 + 87 + pub fn interval_secs() -> u64 { 88 + std::env::var("BACKUP_INTERVAL_SECS") 89 + .ok() 90 + .and_then(|v| v.parse().ok()) 91 + .unwrap_or(86400) 92 + } 93 + 94 + pub async fn put_backup( 95 + &self, 96 + did: &str, 97 + rev: &str, 98 + data: &[u8], 99 + ) -> Result<String, StorageError> { 100 + let key = format!("{}/{}.car", did, rev); 101 + self.client 102 + .put_object() 103 + .bucket(&self.bucket) 104 + .key(&key) 105 + .body(ByteStream::from(Bytes::copy_from_slice(data))) 106 + .send() 107 + .await 108 + .map_err(|e| { 109 + crate::metrics::record_s3_operation("backup_put", "error"); 110 + StorageError::S3(e.to_string()) 111 + })?; 112 113 + crate::metrics::record_s3_operation("backup_put", "success"); 114 + Ok(key) 115 + } 116 + 117 + pub async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> { 118 + let resp = self 119 + .client 120 + .get_object() 121 + .bucket(&self.bucket) 122 + .key(storage_key) 123 + .send() 124 + .await 125 + .map_err(|e| { 126 + crate::metrics::record_s3_operation("backup_get", "error"); 127 + StorageError::S3(e.to_string()) 128 + })?; 129 + 130 + let data = resp 131 + .body 132 + .collect() 133 + .await 134 + .map_err(|e| { 135 + crate::metrics::record_s3_operation("backup_get", "error"); 136 + StorageError::S3(e.to_string()) 137 + })? 138 + .into_bytes(); 139 140 + crate::metrics::record_s3_operation("backup_get", "success"); 141 + Ok(data) 142 + } 143 144 + pub async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> { 145 + self.client 146 + .delete_object() 147 + .bucket(&self.bucket) 148 + .key(storage_key) 149 + .send() 150 + .await 151 + .map_err(|e| { 152 + crate::metrics::record_s3_operation("backup_delete", "error"); 153 + StorageError::S3(e.to_string()) 154 + })?; 155 156 + crate::metrics::record_s3_operation("backup_delete", "success"); 157 + Ok(()) 158 } 159 } 160
+23 -12
src/sync/import.rs
··· 77 Ipld::Map(obj) => { 78 if let Some(Ipld::String(type_str)) = obj.get("$type") 79 && type_str == "blob" 80 - && let Some(Ipld::Link(link_cid)) = obj.get("ref") 81 { 82 - let mime = obj.get("mimeType").and_then(|v| { 83 - if let Ipld::String(s) = v { 84 - Some(s.clone()) 85 - } else { 86 - None 87 - } 88 - }); 89 - return vec![BlobRef { 90 - cid: link_cid.to_string(), 91 - mime_type: mime, 92 - }]; 93 } 94 obj.values() 95 .flat_map(|v| find_blob_refs_ipld(v, depth + 1))
··· 77 Ipld::Map(obj) => { 78 if let Some(Ipld::String(type_str)) = obj.get("$type") 79 && type_str == "blob" 80 { 81 + let cid_str = if let Some(Ipld::Link(link_cid)) = obj.get("ref") { 82 + Some(link_cid.to_string()) 83 + } else if let Some(Ipld::Map(ref_obj)) = obj.get("ref") 84 + && let Some(Ipld::String(link)) = ref_obj.get("$link") 85 + { 86 + Some(link.clone()) 87 + } else { 88 + None 89 + }; 90 + 91 + if let Some(cid) = cid_str { 92 + let mime = obj.get("mimeType").and_then(|v| { 93 + if let Ipld::String(s) = v { 94 + Some(s.clone()) 95 + } else { 96 + None 97 + } 98 + }); 99 + return vec![BlobRef { 100 + cid, 101 + mime_type: mime, 102 + }]; 103 + } 104 } 105 obj.values() 106 .flat_map(|v| find_blob_refs_ipld(v, depth + 1))
+129
src/util.rs
··· 1 use axum::http::HeaderMap; 2 use rand::Rng; 3 use sqlx::PgPool; 4 use std::sync::OnceLock; 5 use uuid::Uuid; 6 ··· 150 format!("{}{}", pds_public_url(), path) 151 } 152 153 #[cfg(test)] 154 mod tests { 155 use super::*; ··· 223 for part in parts { 224 assert_eq!(part.len(), 4); 225 } 226 } 227 }
··· 1 use axum::http::HeaderMap; 2 + use cid::Cid; 3 + use ipld_core::ipld::Ipld; 4 use rand::Rng; 5 + use serde_json::Value as JsonValue; 6 use sqlx::PgPool; 7 + use std::collections::BTreeMap; 8 + use std::str::FromStr; 9 use std::sync::OnceLock; 10 use uuid::Uuid; 11 ··· 155 format!("{}{}", pds_public_url(), path) 156 } 157 158 + pub fn json_to_ipld(value: &JsonValue) -> Ipld { 159 + match value { 160 + JsonValue::Null => Ipld::Null, 161 + JsonValue::Bool(b) => Ipld::Bool(*b), 162 + JsonValue::Number(n) => { 163 + if let Some(i) = n.as_i64() { 164 + Ipld::Integer(i as i128) 165 + } else if let Some(f) = n.as_f64() { 166 + Ipld::Float(f) 167 + } else { 168 + Ipld::Null 169 + } 170 + } 171 + JsonValue::String(s) => Ipld::String(s.clone()), 172 + JsonValue::Array(arr) => Ipld::List(arr.iter().map(json_to_ipld).collect()), 173 + JsonValue::Object(obj) => { 174 + if let Some(JsonValue::String(link)) = obj.get("$link") 175 + && obj.len() == 1 176 + && let Ok(cid) = Cid::from_str(link) 177 + { 178 + return Ipld::Link(cid); 179 + } 180 + let map: BTreeMap<String, Ipld> = obj 181 + .iter() 182 + .map(|(k, v)| (k.clone(), json_to_ipld(v))) 183 + .collect(); 184 + Ipld::Map(map) 185 + } 186 + } 187 + } 188 + 189 #[cfg(test)] 190 mod tests { 191 use super::*; ··· 259 for part in parts { 260 assert_eq!(part.len(), 4); 261 } 262 + } 263 + 264 + #[test] 265 + fn test_json_to_ipld_cid_link() { 266 + let json = serde_json::json!({ 267 + "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 268 + }); 269 + let ipld = json_to_ipld(&json); 270 + match ipld { 271 + Ipld::Link(cid) => { 272 + assert_eq!( 273 + cid.to_string(), 274 + "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 275 + ); 276 + } 277 + _ => panic!("Expected Ipld::Link, got {:?}", ipld), 278 + } 279 + } 280 + 281 + #[test] 282 + fn test_json_to_ipld_blob_ref() { 283 + let json = serde_json::json!({ 284 + "$type": "blob", 285 + "ref": { 286 + "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 287 + }, 288 + "mimeType": "image/jpeg", 289 + "size": 12345 290 + }); 291 + let ipld = json_to_ipld(&json); 292 + match ipld { 293 + Ipld::Map(map) => { 294 + assert_eq!(map.get("$type"), Some(&Ipld::String("blob".to_string()))); 295 + assert_eq!( 296 + map.get("mimeType"), 297 + Some(&Ipld::String("image/jpeg".to_string())) 298 + ); 299 + assert_eq!(map.get("size"), Some(&Ipld::Integer(12345))); 300 + match map.get("ref") { 301 + Some(Ipld::Link(cid)) => { 302 + assert_eq!( 303 + cid.to_string(), 304 + "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 305 + ); 306 + } 307 + _ => panic!("Expected Ipld::Link in ref field, got {:?}", map.get("ref")), 308 + } 309 + } 310 + _ => panic!("Expected Ipld::Map, got {:?}", ipld), 311 + } 312 + } 313 + 314 + #[test] 315 + fn test_json_to_ipld_nested_blob_refs_serializes_correctly() { 316 + let record = serde_json::json!({ 317 + "$type": "app.bsky.feed.post", 318 + "text": "Hello world", 319 + "embed": { 320 + "$type": "app.bsky.embed.images", 321 + "images": [ 322 + { 323 + "alt": "Test image", 324 + "image": { 325 + "$type": "blob", 326 + "ref": { 327 + "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 328 + }, 329 + "mimeType": "image/jpeg", 330 + "size": 12345 331 + } 332 + } 333 + ] 334 + } 335 + }); 336 + let ipld = json_to_ipld(&record); 337 + let cbor_bytes = serde_ipld_dagcbor::to_vec(&ipld).expect("CBOR serialization failed"); 338 + assert!(!cbor_bytes.is_empty()); 339 + let parsed: Ipld = 340 + serde_ipld_dagcbor::from_slice(&cbor_bytes).expect("CBOR deserialization failed"); 341 + if let Ipld::Map(map) = &parsed 342 + && let Some(Ipld::Map(embed)) = map.get("embed") 343 + && let Some(Ipld::List(images)) = embed.get("images") 344 + && let Some(Ipld::Map(img)) = images.first() 345 + && let Some(Ipld::Map(blob)) = img.get("image") 346 + && let Some(Ipld::Link(cid)) = blob.get("ref") 347 + { 348 + assert_eq!( 349 + cid.to_string(), 350 + "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 351 + ); 352 + return; 353 + } 354 + panic!("Failed to find CID link in parsed CBOR"); 355 } 356 }
+10 -40
tests/account_notifications.rs
··· 27 } 28 29 let resp = client 30 - .get(format!( 31 - "{}/xrpc/com.tranquil.account.getNotificationHistory", 32 - base 33 - )) 34 .header("Authorization", format!("Bearer {}", token)) 35 .send() 36 .await ··· 56 "discordId": "123456789" 57 }); 58 let resp = client 59 - .post(format!( 60 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 61 - base 62 - )) 63 .header("Authorization", format!("Bearer {}", token)) 64 .json(&prefs) 65 .send() ··· 101 "code": code 102 }); 103 let resp = client 104 - .post(format!( 105 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 106 - base 107 - )) 108 .header("Authorization", format!("Bearer {}", token)) 109 .json(&input) 110 .send() ··· 113 assert_eq!(resp.status(), 200); 114 115 let resp = client 116 - .get(format!( 117 - "{}/xrpc/com.tranquil.account.getNotificationPrefs", 118 - base 119 - )) 120 .header("Authorization", format!("Bearer {}", token)) 121 .send() 122 .await ··· 136 "telegramUsername": "testuser" 137 }); 138 let resp = client 139 - .post(format!( 140 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 141 - base 142 - )) 143 .header("Authorization", format!("Bearer {}", token)) 144 .json(&prefs) 145 .send() ··· 153 "code": "XXXX-XXXX-XXXX-XXXX" 154 }); 155 let resp = client 156 - .post(format!( 157 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 158 - base 159 - )) 160 .header("Authorization", format!("Bearer {}", token)) 161 .json(&input) 162 .send() ··· 181 "code": "XXXX-XXXX-XXXX-XXXX" 182 }); 183 let resp = client 184 - .post(format!( 185 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 186 - base 187 - )) 188 .header("Authorization", format!("Bearer {}", token)) 189 .json(&input) 190 .send() ··· 209 "email": unique_email 210 }); 211 let resp = client 212 - .post(format!( 213 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 214 - base 215 - )) 216 .header("Authorization", format!("Bearer {}", token)) 217 .json(&prefs) 218 .send() ··· 263 "code": code 264 }); 265 let resp = client 266 - .post(format!( 267 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 268 - base 269 - )) 270 .header("Authorization", format!("Bearer {}", token)) 271 .json(&input) 272 .send() ··· 275 assert_eq!(resp.status(), 200); 276 277 let resp = client 278 - .get(format!( 279 - "{}/xrpc/com.tranquil.account.getNotificationPrefs", 280 - base 281 - )) 282 .header("Authorization", format!("Bearer {}", token)) 283 .send() 284 .await
··· 27 } 28 29 let resp = client 30 + .get(format!("{}/xrpc/_account.getNotificationHistory", base)) 31 .header("Authorization", format!("Bearer {}", token)) 32 .send() 33 .await ··· 53 "discordId": "123456789" 54 }); 55 let resp = client 56 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 57 .header("Authorization", format!("Bearer {}", token)) 58 .json(&prefs) 59 .send() ··· 95 "code": code 96 }); 97 let resp = client 98 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 99 .header("Authorization", format!("Bearer {}", token)) 100 .json(&input) 101 .send() ··· 104 assert_eq!(resp.status(), 200); 105 106 let resp = client 107 + .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 108 .header("Authorization", format!("Bearer {}", token)) 109 .send() 110 .await ··· 124 "telegramUsername": "testuser" 125 }); 126 let resp = client 127 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 128 .header("Authorization", format!("Bearer {}", token)) 129 .json(&prefs) 130 .send() ··· 138 "code": "XXXX-XXXX-XXXX-XXXX" 139 }); 140 let resp = client 141 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 142 .header("Authorization", format!("Bearer {}", token)) 143 .json(&input) 144 .send() ··· 163 "code": "XXXX-XXXX-XXXX-XXXX" 164 }); 165 let resp = client 166 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 167 .header("Authorization", format!("Bearer {}", token)) 168 .json(&input) 169 .send() ··· 188 "email": unique_email 189 }); 190 let resp = client 191 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 192 .header("Authorization", format!("Bearer {}", token)) 193 .json(&prefs) 194 .send() ··· 239 "code": code 240 }); 241 let resp = client 242 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 243 .header("Authorization", format!("Bearer {}", token)) 244 .json(&input) 245 .send() ··· 248 assert_eq!(resp.status(), 200); 249 250 let resp = client 251 + .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 252 .header("Authorization", format!("Bearer {}", token)) 253 .send() 254 .await
+2 -2
tests/admin_stats.rs
··· 11 let (_, _) = create_admin_account_and_login(&client).await; 12 13 let resp = client 14 - .get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base)) 15 .header("Authorization", format!("Bearer {}", token1)) 16 .send() 17 .await ··· 33 let client = client(); 34 let base = base_url().await; 35 let resp = client 36 - .get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base)) 37 .send() 38 .await 39 .unwrap();
··· 11 let (_, _) = create_admin_account_and_login(&client).await; 12 13 let resp = client 14 + .get(format!("{}/xrpc/_admin.getServerStats", base)) 15 .header("Authorization", format!("Bearer {}", token1)) 16 .send() 17 .await ··· 33 let client = client(); 34 let base = base_url().await; 35 let resp = client 36 + .get(format!("{}/xrpc/_admin.getServerStats", base)) 37 .send() 38 .await 39 .unwrap();
+325
tests/backup.rs
···
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use common::*; 5 + use reqwest::{StatusCode, header}; 6 + use serde_json::{Value, json}; 7 + 8 + #[tokio::test] 9 + async fn test_list_backups_empty() { 10 + let client = client(); 11 + let (token, _did) = create_account_and_login(&client).await; 12 + 13 + let res = client 14 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 15 + .bearer_auth(&token) 16 + .send() 17 + .await 18 + .expect("listBackups request failed"); 19 + 20 + assert_eq!(res.status(), StatusCode::OK); 21 + let body: Value = res.json().await.expect("Invalid JSON"); 22 + assert!(body["backups"].is_array()); 23 + assert_eq!(body["backups"].as_array().unwrap().len(), 0); 24 + assert!(body["backupEnabled"].as_bool().unwrap_or(false)); 25 + } 26 + 27 + #[tokio::test] 28 + async fn test_create_and_list_backup() { 29 + let client = client(); 30 + let (token, _did) = create_account_and_login(&client).await; 31 + 32 + let create_res = client 33 + .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 34 + .bearer_auth(&token) 35 + .send() 36 + .await 37 + .expect("createBackup request failed"); 38 + 39 + assert_eq!(create_res.status(), StatusCode::OK, "createBackup failed"); 40 + let create_body: Value = create_res.json().await.expect("Invalid JSON"); 41 + assert!(create_body["id"].is_string()); 42 + assert!(create_body["repoRev"].is_string()); 43 + assert!(create_body["sizeBytes"].is_i64()); 44 + assert!(create_body["blockCount"].is_i64()); 45 + 46 + let list_res = client 47 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 48 + .bearer_auth(&token) 49 + .send() 50 + .await 51 + .expect("listBackups request failed"); 52 + 53 + assert_eq!(list_res.status(), StatusCode::OK); 54 + let list_body: Value = list_res.json().await.expect("Invalid JSON"); 55 + let backups = list_body["backups"].as_array().unwrap(); 56 + assert!(backups.len() >= 1); 57 + } 58 + 59 + #[tokio::test] 60 + async fn test_download_backup() { 61 + let client = client(); 62 + let (token, _did) = create_account_and_login(&client).await; 63 + 64 + let create_res = client 65 + .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 66 + .bearer_auth(&token) 67 + .send() 68 + .await 69 + .expect("createBackup request failed"); 70 + 71 + assert_eq!(create_res.status(), StatusCode::OK); 72 + let create_body: Value = create_res.json().await.expect("Invalid JSON"); 73 + let backup_id = create_body["id"].as_str().unwrap(); 74 + 75 + let get_res = client 76 + .get(format!( 77 + "{}/xrpc/_backup.getBackup?id={}", 78 + base_url().await, 79 + backup_id 80 + )) 81 + .bearer_auth(&token) 82 + .send() 83 + .await 84 + .expect("getBackup request failed"); 85 + 86 + assert_eq!(get_res.status(), StatusCode::OK); 87 + let content_type = get_res.headers().get(header::CONTENT_TYPE).unwrap(); 88 + assert_eq!(content_type, "application/vnd.ipld.car"); 89 + 90 + let bytes = get_res.bytes().await.expect("Failed to read body"); 91 + assert!(bytes.len() > 100, "CAR file should have content"); 92 + assert_eq!( 93 + bytes[1], 0xa2, 94 + "CAR file should have valid header structure" 95 + ); 96 + } 97 + 98 + #[tokio::test] 99 + async fn test_delete_backup() { 100 + let client = client(); 101 + let (token, _did) = create_account_and_login(&client).await; 102 + 103 + let create_res = client 104 + .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 105 + .bearer_auth(&token) 106 + .send() 107 + .await 108 + .expect("createBackup request failed"); 109 + 110 + assert_eq!(create_res.status(), StatusCode::OK); 111 + let create_body: Value = create_res.json().await.expect("Invalid JSON"); 112 + let backup_id = create_body["id"].as_str().unwrap(); 113 + 114 + let delete_res = client 115 + .post(format!( 116 + "{}/xrpc/_backup.deleteBackup?id={}", 117 + base_url().await, 118 + backup_id 119 + )) 120 + .bearer_auth(&token) 121 + .send() 122 + .await 123 + .expect("deleteBackup request failed"); 124 + 125 + assert_eq!(delete_res.status(), StatusCode::OK); 126 + 127 + let get_res = client 128 + .get(format!( 129 + "{}/xrpc/_backup.getBackup?id={}", 130 + base_url().await, 131 + backup_id 132 + )) 133 + .bearer_auth(&token) 134 + .send() 135 + .await 136 + .expect("getBackup request failed"); 137 + 138 + assert_eq!(get_res.status(), StatusCode::NOT_FOUND); 139 + } 140 + 141 + #[tokio::test] 142 + async fn test_toggle_backup_enabled() { 143 + let client = client(); 144 + let (token, _did) = create_account_and_login(&client).await; 145 + 146 + let list_res = client 147 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 148 + .bearer_auth(&token) 149 + .send() 150 + .await 151 + .expect("listBackups request failed"); 152 + 153 + assert_eq!(list_res.status(), StatusCode::OK); 154 + let list_body: Value = list_res.json().await.expect("Invalid JSON"); 155 + assert!(list_body["backupEnabled"].as_bool().unwrap()); 156 + 157 + let disable_res = client 158 + .post(format!("{}/xrpc/_backup.setEnabled", base_url().await)) 159 + .bearer_auth(&token) 160 + .json(&json!({"enabled": false})) 161 + .send() 162 + .await 163 + .expect("setEnabled request failed"); 164 + 165 + assert_eq!(disable_res.status(), StatusCode::OK); 166 + let disable_body: Value = disable_res.json().await.expect("Invalid JSON"); 167 + assert!(!disable_body["enabled"].as_bool().unwrap()); 168 + 169 + let list_res2 = client 170 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 171 + .bearer_auth(&token) 172 + .send() 173 + .await 174 + .expect("listBackups request failed"); 175 + 176 + let list_body2: Value = list_res2.json().await.expect("Invalid JSON"); 177 + assert!(!list_body2["backupEnabled"].as_bool().unwrap()); 178 + 179 + let enable_res = client 180 + .post(format!("{}/xrpc/_backup.setEnabled", base_url().await)) 181 + .bearer_auth(&token) 182 + .json(&json!({"enabled": true})) 183 + .send() 184 + .await 185 + .expect("setEnabled request failed"); 186 + 187 + assert_eq!(enable_res.status(), StatusCode::OK); 188 + } 189 + 190 + #[tokio::test] 191 + async fn test_backup_includes_blobs() { 192 + let client = client(); 193 + let (token, did) = create_account_and_login(&client).await; 194 + 195 + let blob_data = b"Hello, this is test blob data for backup testing!"; 196 + let upload_res = client 197 + .post(format!( 198 + "{}/xrpc/com.atproto.repo.uploadBlob", 199 + base_url().await 200 + )) 201 + .header(header::CONTENT_TYPE, "text/plain") 202 + .bearer_auth(&token) 203 + .body(blob_data.to_vec()) 204 + .send() 205 + .await 206 + .expect("uploadBlob request failed"); 207 + 208 + assert_eq!(upload_res.status(), StatusCode::OK); 209 + let upload_body: Value = upload_res.json().await.expect("Invalid JSON"); 210 + let blob = &upload_body["blob"]; 211 + 212 + let record = json!({ 213 + "$type": "app.bsky.feed.post", 214 + "text": "Test post with blob", 215 + "createdAt": chrono::Utc::now().to_rfc3339(), 216 + "embed": { 217 + "$type": "app.bsky.embed.images", 218 + "images": [{ 219 + "alt": "test image", 220 + "image": blob 221 + }] 222 + } 223 + }); 224 + 225 + let create_record_res = client 226 + .post(format!( 227 + "{}/xrpc/com.atproto.repo.createRecord", 228 + base_url().await 229 + )) 230 + .bearer_auth(&token) 231 + .json(&json!({ 232 + "repo": did, 233 + "collection": "app.bsky.feed.post", 234 + "record": record 235 + })) 236 + .send() 237 + .await 238 + .expect("createRecord request failed"); 239 + 240 + assert_eq!(create_record_res.status(), StatusCode::OK); 241 + 242 + let create_backup_res = client 243 + .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 244 + .bearer_auth(&token) 245 + .send() 246 + .await 247 + .expect("createBackup request failed"); 248 + 249 + assert_eq!(create_backup_res.status(), StatusCode::OK); 250 + let backup_body: Value = create_backup_res.json().await.expect("Invalid JSON"); 251 + let backup_id = backup_body["id"].as_str().unwrap(); 252 + 253 + let get_backup_res = client 254 + .get(format!( 255 + "{}/xrpc/_backup.getBackup?id={}", 256 + base_url().await, 257 + backup_id 258 + )) 259 + .bearer_auth(&token) 260 + .send() 261 + .await 262 + .expect("getBackup request failed"); 263 + 264 + assert_eq!(get_backup_res.status(), StatusCode::OK); 265 + let car_bytes = get_backup_res.bytes().await.expect("Failed to read body"); 266 + 267 + let blob_cid = blob["ref"]["$link"].as_str().unwrap(); 268 + let blob_found = String::from_utf8_lossy(&car_bytes).contains("Hello, this is test blob data"); 269 + assert!( 270 + blob_found || car_bytes.len() > 500, 271 + "Backup should contain blob data (cid: {})", 272 + blob_cid 273 + ); 274 + } 275 + 276 + #[tokio::test] 277 + async fn test_backup_unauthorized() { 278 + let client = client(); 279 + 280 + let res = client 281 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 282 + .send() 283 + .await 284 + .expect("listBackups request failed"); 285 + 286 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 287 + } 288 + 289 + #[tokio::test] 290 + async fn test_get_nonexistent_backup() { 291 + let client = client(); 292 + let (token, _did) = create_account_and_login(&client).await; 293 + 294 + let fake_id = uuid::Uuid::new_v4(); 295 + let res = client 296 + .get(format!( 297 + "{}/xrpc/_backup.getBackup?id={}", 298 + base_url().await, 299 + fake_id 300 + )) 301 + .bearer_auth(&token) 302 + .send() 303 + .await 304 + .expect("getBackup request failed"); 305 + 306 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 307 + } 308 + 309 + #[tokio::test] 310 + async fn test_backup_invalid_id() { 311 + let client = client(); 312 + let (token, _did) = create_account_and_login(&client).await; 313 + 314 + let res = client 315 + .get(format!( 316 + "{}/xrpc/_backup.getBackup?id=not-a-uuid", 317 + base_url().await 318 + )) 319 + .bearer_auth(&token) 320 + .send() 321 + .await 322 + .expect("getBackup request failed"); 323 + 324 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 325 + }
+6 -24
tests/change_password.rs
··· 32 let did = create_body["did"].as_str().unwrap(); 33 let jwt = verify_new_account(&client, did).await; 34 let change_res = client 35 - .post(format!( 36 - "{}/xrpc/com.tranquil.account.changePassword", 37 - base_url().await 38 - )) 39 .bearer_auth(&jwt) 40 .json(&json!({ 41 "currentPassword": old_password, ··· 86 let client = client(); 87 let (_, jwt) = setup_new_user("change-pw-wrong").await; 88 let res = client 89 - .post(format!( 90 - "{}/xrpc/com.tranquil.account.changePassword", 91 - base_url().await 92 - )) 93 .bearer_auth(&jwt) 94 .json(&json!({ 95 "currentPassword": "Wrongpass999!", ··· 129 let did = create_body["did"].as_str().unwrap(); 130 let jwt = verify_new_account(&client, did).await; 131 let res = client 132 - .post(format!( 133 - "{}/xrpc/com.tranquil.account.changePassword", 134 - base_url().await 135 - )) 136 .bearer_auth(&jwt) 137 .json(&json!({ 138 "currentPassword": password, ··· 151 let client = client(); 152 let (_, jwt) = setup_new_user("change-pw-empty").await; 153 let res = client 154 - .post(format!( 155 - "{}/xrpc/com.tranquil.account.changePassword", 156 - base_url().await 157 - )) 158 .bearer_auth(&jwt) 159 .json(&json!({ 160 "currentPassword": "", ··· 171 let client = client(); 172 let (_, jwt) = setup_new_user("change-pw-emptynew").await; 173 let res = client 174 - .post(format!( 175 - "{}/xrpc/com.tranquil.account.changePassword", 176 - base_url().await 177 - )) 178 .bearer_auth(&jwt) 179 .json(&json!({ 180 "currentPassword": "E2epass123!", ··· 190 async fn test_change_password_requires_auth() { 191 let client = client(); 192 let res = client 193 - .post(format!( 194 - "{}/xrpc/com.tranquil.account.changePassword", 195 - base_url().await 196 - )) 197 .json(&json!({ 198 "currentPassword": "Oldpass123!", 199 "newPassword": "Newpass123!"
··· 32 let did = create_body["did"].as_str().unwrap(); 33 let jwt = verify_new_account(&client, did).await; 34 let change_res = client 35 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 36 .bearer_auth(&jwt) 37 .json(&json!({ 38 "currentPassword": old_password, ··· 83 let client = client(); 84 let (_, jwt) = setup_new_user("change-pw-wrong").await; 85 let res = client 86 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 87 .bearer_auth(&jwt) 88 .json(&json!({ 89 "currentPassword": "Wrongpass999!", ··· 123 let did = create_body["did"].as_str().unwrap(); 124 let jwt = verify_new_account(&client, did).await; 125 let res = client 126 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 127 .bearer_auth(&jwt) 128 .json(&json!({ 129 "currentPassword": password, ··· 142 let client = client(); 143 let (_, jwt) = setup_new_user("change-pw-empty").await; 144 let res = client 145 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 146 .bearer_auth(&jwt) 147 .json(&json!({ 148 "currentPassword": "", ··· 159 let client = client(); 160 let (_, jwt) = setup_new_user("change-pw-emptynew").await; 161 let res = client 162 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 163 .bearer_auth(&jwt) 164 .json(&json!({ 165 "currentPassword": "E2epass123!", ··· 175 async fn test_change_password_requires_auth() { 176 let client = client(); 177 let res = client 178 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 179 .json(&json!({ 180 "currentPassword": "Oldpass123!", 181 "newPassword": "Newpass123!"
+28 -247
tests/did_web.rs
··· 547 } 548 549 #[tokio::test] 550 - async fn test_deactivate_with_migrating_to() { 551 let client = client(); 552 let base = base_url().await; 553 - let handle = format!("mig{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 554 let payload = json!({ 555 "handle": handle, 556 "email": format!("{}@example.com", handle), ··· 567 let body: Value = res.json().await.expect("Response was not JSON"); 568 let did = body["did"].as_str().expect("No DID").to_string(); 569 let jwt = verify_new_account(&client, &did).await; 570 - let target_pds = "https://pds2.example.com"; 571 let res = client 572 - .post(format!( 573 - "{}/xrpc/com.atproto.server.deactivateAccount", 574 - base 575 - )) 576 .bearer_auth(&jwt) 577 - .json(&json!({ "migratingTo": target_pds })) 578 - .send() 579 - .await 580 - .expect("Failed to send request"); 581 - assert_eq!(res.status(), StatusCode::OK); 582 - let pool = get_test_db_pool().await; 583 - let row = sqlx::query!( 584 - r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#, 585 - &did 586 - ) 587 - .fetch_one(pool) 588 - .await 589 - .expect("Failed to query user"); 590 - assert_eq!( 591 - row.migrated_to_pds.as_deref(), 592 - Some(target_pds), 593 - "migrated_to_pds should be set to target PDS" 594 - ); 595 - assert!( 596 - row.deactivated_at.is_some(), 597 - "deactivated_at should be set for migrated account" 598 - ); 599 - } 600 - 601 - #[tokio::test] 602 - async fn test_migrated_account_blocked_from_repo_ops() { 603 - let client = client(); 604 - let base = base_url().await; 605 - let handle = format!("blk{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 606 - let payload = json!({ 607 - "handle": handle, 608 - "email": format!("{}@example.com", handle), 609 - "password": "Testpass123!", 610 - "didType": "web" 611 - }); 612 - let res = client 613 - .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 614 - .json(&payload) 615 .send() 616 .await 617 .expect("Failed to send request"); 618 assert_eq!(res.status(), StatusCode::OK); 619 let body: Value = res.json().await.expect("Response was not JSON"); 620 - let did = body["did"].as_str().expect("No DID").to_string(); 621 - let jwt = verify_new_account(&client, &did).await; 622 - let res = client 623 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 624 - .bearer_auth(&jwt) 625 - .json(&json!({ 626 - "repo": did, 627 - "collection": "app.bsky.feed.post", 628 - "record": { 629 - "$type": "app.bsky.feed.post", 630 - "text": "Pre-migration post", 631 - "createdAt": chrono::Utc::now().to_rfc3339() 632 - } 633 - })) 634 - .send() 635 - .await 636 - .expect("Failed to send request"); 637 - assert_eq!(res.status(), StatusCode::OK); 638 - let res = client 639 - .post(format!( 640 - "{}/xrpc/com.atproto.server.deactivateAccount", 641 - base 642 - )) 643 - .bearer_auth(&jwt) 644 - .json(&json!({ "migratingTo": "https://pds2.example.com" })) 645 - .send() 646 - .await 647 - .expect("Failed to send request"); 648 - assert_eq!(res.status(), StatusCode::OK); 649 - let res = client 650 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 651 - .bearer_auth(&jwt) 652 - .json(&json!({ 653 - "repo": did, 654 - "collection": "app.bsky.feed.post", 655 - "record": { 656 - "$type": "app.bsky.feed.post", 657 - "text": "Post-migration post - should fail", 658 - "createdAt": chrono::Utc::now().to_rfc3339() 659 - } 660 - })) 661 - .send() 662 - .await 663 - .expect("Failed to send request"); 664 assert!( 665 - res.status().is_client_error(), 666 - "createRecord should fail for migrated account: {}", 667 - res.status() 668 ); 669 - let res = client 670 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 671 - .bearer_auth(&jwt) 672 - .json(&json!({ 673 - "repo": did, 674 - "collection": "app.bsky.actor.profile", 675 - "rkey": "self", 676 - "record": { 677 - "$type": "app.bsky.actor.profile", 678 - "displayName": "Test" 679 - } 680 - })) 681 - .send() 682 - .await 683 - .expect("Failed to send request"); 684 - assert!( 685 - res.status().is_client_error(), 686 - "putRecord should fail for migrated account: {}", 687 - res.status() 688 ); 689 let res = client 690 - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) 691 .bearer_auth(&jwt) 692 .json(&json!({ 693 - "repo": did, 694 - "collection": "app.bsky.feed.post", 695 - "rkey": "test123" 696 })) 697 .send() 698 .await 699 .expect("Failed to send request"); 700 - assert!( 701 - res.status().is_client_error(), 702 - "deleteRecord should fail for migrated account: {}", 703 - res.status() 704 ); 705 - let res = client 706 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base)) 707 - .bearer_auth(&jwt) 708 - .json(&json!({ 709 - "repo": did, 710 - "writes": [{ 711 - "$type": "com.atproto.repo.applyWrites#create", 712 - "collection": "app.bsky.feed.post", 713 - "value": { 714 - "$type": "app.bsky.feed.post", 715 - "text": "Batch post", 716 - "createdAt": chrono::Utc::now().to_rfc3339() 717 - } 718 - }] 719 - })) 720 - .send() 721 - .await 722 - .expect("Failed to send request"); 723 assert!( 724 - res.status().is_client_error(), 725 - "applyWrites should fail for migrated account: {}", 726 - res.status() 727 - ); 728 - let res = client 729 - .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base)) 730 - .bearer_auth(&jwt) 731 - .header("Content-Type", "text/plain") 732 - .body("test blob content") 733 - .send() 734 - .await 735 - .expect("Failed to send request"); 736 - assert!( 737 - res.status().is_client_error(), 738 - "uploadBlob should fail for migrated account: {}", 739 - res.status() 740 ); 741 } 742 743 #[tokio::test] 744 - async fn test_migrated_session_status() { 745 let client = client(); 746 let base = base_url().await; 747 - let handle = format!("ses{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 748 let payload = json!({ 749 "handle": handle, 750 "email": format!("{}@example.com", handle), ··· 762 let did = body["did"].as_str().expect("No DID").to_string(); 763 let jwt = verify_new_account(&client, &did).await; 764 let res = client 765 - .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 766 - .bearer_auth(&jwt) 767 - .send() 768 - .await 769 - .expect("Failed to send request"); 770 - assert_eq!(res.status(), StatusCode::OK); 771 - let body: Value = res.json().await.expect("Response was not JSON"); 772 - assert_eq!(body["active"], true); 773 - assert!( 774 - body["status"].is_null() || body["status"] == "active", 775 - "Status should be null or 'active' for normal accounts" 776 - ); 777 - let target_pds = "https://pds3.example.com"; 778 - let res = client 779 .post(format!( 780 "{}/xrpc/com.atproto.server.deactivateAccount", 781 base 782 )) 783 .bearer_auth(&jwt) 784 - .json(&json!({ "migratingTo": target_pds })) 785 .send() 786 .await 787 .expect("Failed to send request"); ··· 794 .expect("Failed to send request"); 795 assert_eq!(res.status(), StatusCode::OK); 796 let body: Value = res.json().await.expect("Response was not JSON"); 797 - assert_eq!( 798 - body["active"], false, 799 - "Migrated account should not be active" 800 - ); 801 - assert_eq!( 802 - body["status"], "migrated", 803 - "Status should be 'migrated' after migration" 804 - ); 805 - assert_eq!( 806 - body["migratedToPds"], target_pds, 807 - "migratedToPds should be set to target PDS" 808 - ); 809 - } 810 - 811 - #[tokio::test] 812 - async fn test_migrating_to_ignored_for_did_plc() { 813 - let client = client(); 814 - let base = base_url().await; 815 - let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 816 - let payload = json!({ 817 - "handle": handle, 818 - "email": format!("{}@example.com", handle), 819 - "password": "Testpass123!", 820 - "didType": "plc" 821 - }); 822 - let res = client 823 - .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 824 - .json(&payload) 825 - .send() 826 - .await 827 - .expect("Failed to send request"); 828 - assert_eq!(res.status(), StatusCode::OK); 829 - let body: Value = res.json().await.expect("Response was not JSON"); 830 - let did = body["did"].as_str().expect("No DID").to_string(); 831 - assert!(did.starts_with("did:plc:"), "Should be did:plc account"); 832 - let jwt = verify_new_account(&client, &did).await; 833 - let res = client 834 - .post(format!( 835 - "{}/xrpc/com.atproto.server.deactivateAccount", 836 - base 837 - )) 838 - .bearer_auth(&jwt) 839 - .json(&json!({ "migratingTo": "https://pds2.example.com" })) 840 - .send() 841 - .await 842 - .expect("Failed to send request"); 843 - assert_eq!(res.status(), StatusCode::OK); 844 - let pool = get_test_db_pool().await; 845 - let row = sqlx::query!( 846 - r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#, 847 - &did 848 - ) 849 - .fetch_one(pool) 850 - .await 851 - .expect("Failed to query user"); 852 - assert!( 853 - row.migrated_to_pds.is_none(), 854 - "migrated_to_pds should NOT be set for did:plc accounts" 855 - ); 856 - assert!( 857 - row.deactivated_at.is_some(), 858 - "deactivated_at should still be set" 859 - ); 860 - let res = client 861 - .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 862 - .bearer_auth(&jwt) 863 - .send() 864 - .await 865 - .expect("Failed to send request"); 866 - assert_eq!(res.status(), StatusCode::OK); 867 - let body: Value = res.json().await.expect("Response was not JSON"); 868 - assert_eq!(body["active"], false); 869 assert_eq!( 870 body["status"], "deactivated", 871 - "Status should be 'deactivated' not 'migrated' for did:plc" 872 - ); 873 - assert!( 874 - body["migratedToPds"].is_null(), 875 - "migratedToPds should not be set for did:plc accounts" 876 ); 877 }
··· 547 } 548 549 #[tokio::test] 550 + async fn test_did_web_can_edit_did_document() { 551 let client = client(); 552 let base = base_url().await; 553 + let handle = format!("doc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 554 let payload = json!({ 555 "handle": handle, 556 "email": format!("{}@example.com", handle), ··· 567 let body: Value = res.json().await.expect("Response was not JSON"); 568 let did = body["did"].as_str().expect("No DID").to_string(); 569 let jwt = verify_new_account(&client, &did).await; 570 let res = client 571 + .get(format!("{}/xrpc/_account.getDidDocument", base)) 572 .bearer_auth(&jwt) 573 .send() 574 .await 575 .expect("Failed to send request"); 576 assert_eq!(res.status(), StatusCode::OK); 577 let body: Value = res.json().await.expect("Response was not JSON"); 578 assert!( 579 + body["didDocument"].is_object(), 580 + "Should return DID document" 581 ); 582 + assert_eq!( 583 + body["didDocument"]["id"], did, 584 + "DID document should have correct id" 585 ); 586 let res = client 587 + .post(format!("{}/xrpc/_account.updateDidDocument", base)) 588 .bearer_auth(&jwt) 589 .json(&json!({ 590 + "alsoKnownAs": ["at://custom.handle.test"] 591 })) 592 .send() 593 .await 594 .expect("Failed to send request"); 595 + assert_eq!( 596 + res.status(), 597 + StatusCode::OK, 598 + "Non-migrated did:web user should be able to update DID document" 599 ); 600 + let body: Value = res.json().await.expect("Response was not JSON"); 601 + assert!(body["success"].as_bool().unwrap_or(false)); 602 + let also_known_as = body["didDocument"]["alsoKnownAs"] 603 + .as_array() 604 + .expect("alsoKnownAs should be array"); 605 assert!( 606 + also_known_as 607 + .iter() 608 + .any(|v| v.as_str() == Some("at://custom.handle.test")), 609 + "alsoKnownAs should contain custom entry" 610 ); 611 } 612 613 #[tokio::test] 614 + async fn test_deactivate_account_basic() { 615 let client = client(); 616 let base = base_url().await; 617 + let handle = format!("dea{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 618 let payload = json!({ 619 "handle": handle, 620 "email": format!("{}@example.com", handle), ··· 632 let did = body["did"].as_str().expect("No DID").to_string(); 633 let jwt = verify_new_account(&client, &did).await; 634 let res = client 635 .post(format!( 636 "{}/xrpc/com.atproto.server.deactivateAccount", 637 base 638 )) 639 .bearer_auth(&jwt) 640 + .json(&json!({})) 641 .send() 642 .await 643 .expect("Failed to send request"); ··· 650 .expect("Failed to send request"); 651 assert_eq!(res.status(), StatusCode::OK); 652 let body: Value = res.json().await.expect("Response was not JSON"); 653 + assert_eq!(body["active"], false, "Account should be deactivated"); 654 assert_eq!( 655 body["status"], "deactivated", 656 + "Status should be 'deactivated'" 657 ); 658 }
-1
tests/oauth.rs
··· 1 mod common; 2 mod helpers; 3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 4 - use chrono::Utc; 5 use common::{base_url, client, get_test_db_pool}; 6 use helpers::verify_new_account; 7 use reqwest::{StatusCode, redirect};
··· 1 mod common; 2 mod helpers; 3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 4 use common::{base_url, client, get_test_db_pool}; 5 use helpers::verify_new_account; 6 use reqwest::{StatusCode, redirect};
+1 -4
tests/oauth_security.rs
··· 1116 1117 let delegated_handle = format!("dg{}", suffix); 1118 let delegated_res = http_client 1119 - .post(format!( 1120 - "{}/xrpc/com.tranquil.delegation.createDelegatedAccount", 1121 - url 1122 - )) 1123 .bearer_auth(controller_jwt) 1124 .json(&json!({ 1125 "handle": delegated_handle,
··· 1116 1117 let delegated_handle = format!("dg{}", suffix); 1118 let delegated_res = http_client 1119 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 1120 .bearer_auth(controller_jwt) 1121 .json(&json!({ 1122 "handle": delegated_handle,
+9 -36
tests/session_management.rs
··· 10 let client = client(); 11 let (did, jwt) = setup_new_user("list-sessions").await; 12 let res = client 13 - .get(format!( 14 - "{}/xrpc/com.tranquil.account.listSessions", 15 - base_url().await 16 - )) 17 .bearer_auth(&jwt) 18 .send() 19 .await ··· 83 let login_body: Value = login_res.json().await.unwrap(); 84 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 85 let list_res = client 86 - .get(format!( 87 - "{}/xrpc/com.tranquil.account.listSessions", 88 - base_url().await 89 - )) 90 .bearer_auth(jwt2) 91 .send() 92 .await ··· 106 async fn test_list_sessions_requires_auth() { 107 let client = client(); 108 let res = client 109 - .get(format!( 110 - "{}/xrpc/com.tranquil.account.listSessions", 111 - base_url().await 112 - )) 113 .send() 114 .await 115 .expect("Failed to send request"); ··· 158 let login_body: Value = login_res.json().await.unwrap(); 159 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 160 let list_res = client 161 - .get(format!( 162 - "{}/xrpc/com.tranquil.account.listSessions", 163 - base_url().await 164 - )) 165 .bearer_auth(jwt2) 166 .send() 167 .await ··· 177 ); 178 let session_id = other_session.unwrap()["id"].as_str().unwrap(); 179 let revoke_res = client 180 - .post(format!( 181 - "{}/xrpc/com.tranquil.account.revokeSession", 182 - base_url().await 183 - )) 184 .bearer_auth(jwt2) 185 .json(&json!({"sessionId": session_id})) 186 .send() ··· 188 .expect("Failed to revoke session"); 189 assert_eq!(revoke_res.status(), StatusCode::OK); 190 let list_after_res = client 191 - .get(format!( 192 - "{}/xrpc/com.tranquil.account.listSessions", 193 - base_url().await 194 - )) 195 .bearer_auth(jwt2) 196 .send() 197 .await ··· 213 let client = client(); 214 let (_, jwt) = setup_new_user("revoke-invalid").await; 215 let res = client 216 - .post(format!( 217 - "{}/xrpc/com.tranquil.account.revokeSession", 218 - base_url().await 219 - )) 220 .bearer_auth(&jwt) 221 .json(&json!({"sessionId": "not-a-number"})) 222 .send() ··· 230 let client = client(); 231 let (_, jwt) = setup_new_user("revoke-notfound").await; 232 let res = client 233 - .post(format!( 234 - "{}/xrpc/com.tranquil.account.revokeSession", 235 - base_url().await 236 - )) 237 .bearer_auth(&jwt) 238 .json(&json!({"sessionId": "jwt:999999999"})) 239 .send() ··· 246 async fn test_revoke_session_requires_auth() { 247 let client = client(); 248 let res = client 249 - .post(format!( 250 - "{}/xrpc/com.tranquil.account.revokeSession", 251 - base_url().await 252 - )) 253 .json(&json!({"sessionId": "1"})) 254 .send() 255 .await
··· 10 let client = client(); 11 let (did, jwt) = setup_new_user("list-sessions").await; 12 let res = client 13 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 14 .bearer_auth(&jwt) 15 .send() 16 .await ··· 80 let login_body: Value = login_res.json().await.unwrap(); 81 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 82 let list_res = client 83 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 84 .bearer_auth(jwt2) 85 .send() 86 .await ··· 100 async fn test_list_sessions_requires_auth() { 101 let client = client(); 102 let res = client 103 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 104 .send() 105 .await 106 .expect("Failed to send request"); ··· 149 let login_body: Value = login_res.json().await.unwrap(); 150 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 151 let list_res = client 152 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 153 .bearer_auth(jwt2) 154 .send() 155 .await ··· 165 ); 166 let session_id = other_session.unwrap()["id"].as_str().unwrap(); 167 let revoke_res = client 168 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 169 .bearer_auth(jwt2) 170 .json(&json!({"sessionId": session_id})) 171 .send() ··· 173 .expect("Failed to revoke session"); 174 assert_eq!(revoke_res.status(), StatusCode::OK); 175 let list_after_res = client 176 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 177 .bearer_auth(jwt2) 178 .send() 179 .await ··· 195 let client = client(); 196 let (_, jwt) = setup_new_user("revoke-invalid").await; 197 let res = client 198 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 199 .bearer_auth(&jwt) 200 .json(&json!({"sessionId": "not-a-number"})) 201 .send() ··· 209 let client = client(); 210 let (_, jwt) = setup_new_user("revoke-notfound").await; 211 let res = client 212 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 213 .bearer_auth(&jwt) 214 .json(&json!({"sessionId": "jwt:999999999"})) 215 .send() ··· 222 async fn test_revoke_session_requires_auth() { 223 let client = client(); 224 let res = client 225 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 226 .json(&json!({"sessionId": "1"})) 227 .send() 228 .await