From f6d7d65ef8c2b65c4e60a4626fba10162812a616 Mon Sep 17 00:00:00 2001 From: lewis Date: Sat, 27 Dec 2025 22:40:16 +0200 Subject: [PATCH] Three-quarter-done in-house migration flow --- ...9a11da30a55869128320de6d62693415953f5.json | 26 + ...8d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json | 28 + ...f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json | 14 + ...7bfe756f2631daf688e6646705725342ad67e.json | 34 + ...69174009b0b6bc86c5c649c5ce9927deb3d93.json | 16 + frontend/src/App.svelte | 3 + .../components/migration/InboundWizard.svelte | 1024 +++++++++++++++++ .../migration/OutboundWizard.svelte | 992 ++++++++++++++++ frontend/src/lib/migration/atproto-client.ts | 448 ++++++++ frontend/src/lib/migration/flow.svelte.ts | 734 ++++++++++++ frontend/src/lib/migration/index.ts | 9 + frontend/src/lib/migration/storage.ts | 138 +++ frontend/src/lib/migration/types.ts | 214 ++++ frontend/src/locales/en.json | 203 +++- frontend/src/locales/fi.json | 201 ++++ frontend/src/locales/ja.json | 201 ++++ frontend/src/locales/ko.json | 201 ++++ frontend/src/locales/sv.json | 201 ++++ frontend/src/locales/zh.json | 201 ++++ frontend/src/routes/Dashboard.svelte | 4 + frontend/src/routes/Migration.svelte | 413 +++++++ frontend/src/routes/Register.svelte | 2 +- src/api/identity/did.rs | 1 + src/api/repo/blob.rs | 2 +- src/api/repo/import.rs | 6 +- src/api/repo/record/batch.rs | 6 +- src/api/repo/record/delete.rs | 1 + src/api/repo/record/read.rs | 41 +- src/api/repo/record/utils.rs | 37 +- src/api/repo/record/write.rs | 6 +- src/api/server/account_status.rs | 211 +++- src/api/server/migration.rs | 239 ++++ src/api/server/mod.rs | 4 + src/lib.rs | 12 + src/sync/import.rs | 131 ++- src/sync/util.rs | 9 + 36 files changed, 5940 insertions(+), 73 deletions(-) create mode 100644 .sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json create mode 100644 .sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json create mode 100644 .sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json create mode 100644 .sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json create mode 100644 .sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json create mode 100644 frontend/src/components/migration/InboundWizard.svelte create mode 100644 frontend/src/components/migration/OutboundWizard.svelte create mode 100644 frontend/src/lib/migration/atproto-client.ts create mode 100644 frontend/src/lib/migration/flow.svelte.ts create mode 100644 frontend/src/lib/migration/index.ts create mode 100644 frontend/src/lib/migration/storage.ts create mode 100644 frontend/src/lib/migration/types.ts create mode 100644 frontend/src/routes/Migration.svelte create mode 100644 src/api/server/migration.rs diff --git a/.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json b/.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json new file mode 100644 index 0000000..77d0b01 --- /dev/null +++ b/.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT cid, data FROM blocks", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "cid", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "data", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5" +} diff --git a/.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json b/.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json new file mode 100644 index 0000000..b2375eb --- /dev/null +++ b/.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk JOIN users u ON uk.user_id = u.id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db" +} diff --git a/.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json b/.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json new file mode 100644 index 0000000..08185e9 --- /dev/null +++ b/.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1" +} diff --git a/.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json b/.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json new file mode 100644 index 0000000..ae8df54 --- /dev/null +++ b/.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "migrated_to_pds", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "migrated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true + ] + }, + "hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e" +} diff --git a/.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json b/.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json new file mode 100644 index 0000000..13a3011 --- /dev/null +++ b/.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93" +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index fca7ef4..c0ab1cd 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -32,6 +32,7 @@ import Controllers from './routes/Controllers.svelte' import DelegationAudit from './routes/DelegationAudit.svelte' import ActAs from './routes/ActAs.svelte' + import Migration from './routes/Migration.svelte' import Home from './routes/Home.svelte' initI18n() @@ -113,6 +114,8 @@ return DelegationAudit case '/act-as': return ActAs + case '/migrate': + return Migration default: return Home } diff --git a/frontend/src/components/migration/InboundWizard.svelte b/frontend/src/components/migration/InboundWizard.svelte new file mode 100644 index 0000000..84a8f75 --- /dev/null +++ b/frontend/src/components/migration/InboundWizard.svelte @@ -0,0 +1,1024 @@ + + +
+
+ {#each steps as stepName, i} +
+
{i < getCurrentStepIndex() ? '✓' : i + 1}
+ {stepName} +
+ {#if i < steps.length - 1} +
+ {/if} + {/each} +
+ + {#if flow.state.error} +
{flow.state.error}
+ {/if} + + {#if flow.state.step === 'welcome'} +
+

Migrate Your Account Here

+

This wizard will help you move your AT Protocol account from another PDS to this one.

+ +
+

What will happen:

+
    +
  1. Log in to your current PDS
  2. +
  3. Choose your new handle on this server
  4. +
  5. Your repository and blobs will be transferred
  6. +
  7. Verify the migration via email
  8. +
  9. Your identity will be updated to point here
  10. +
+
+ +
+ Before you proceed: +
    +
  • You need access to the email registered with your current account
  • +
  • Large accounts may take several minutes to transfer
  • +
  • Your old account will be deactivated after migration
  • +
+
+ + + +
+ + +
+
+ + {:else if flow.state.step === 'source-login'} +
+

{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}

+

{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}

+ + {#if isResumedMigration} +
+

Your migration was interrupted. Log in to both accounts to resume.

+

Migrating: {flow.state.sourceHandle}{flow.state.targetHandle}

+
+ {/if} + +
+
+ + +

Your current handle on your existing PDS

+
+ +
+ + +

Your account password (not an app password)

+
+ + {#if flow.state.requires2FA} +
+ + +

Check your email for the verification code

+
+ {/if} + + {#if isResumedMigration} +
+ +
+ + +

The password you set for your account on this PDS

+
+ {/if} + +
+ + +
+
+
+ + {:else if flow.state.step === 'choose-handle'} +
+

Choose Your New Handle

+

Select a handle for your account on this PDS.

+ +
+ Migrating from: + {flow.state.sourceHandle} +
+ +
+ +
+ + {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} + + {/if} +
+ + {#if checkingHandle} +

Checking availability...

+ {:else if handleAvailable === true} +

Handle is available!

+ {:else if handleAvailable === false} +

Handle is already taken

+ {:else} +

You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)

+ {/if} +
+ +
+ + flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} + required + /> +
+ +
+ + flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} + required + minlength="8" + /> +

At least 8 characters

+
+ + {#if serverInfo?.inviteCodeRequired} +
+ + flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} + required + /> +
+ {/if} + +
+ + +
+
+ + {:else if flow.state.step === 'review'} +
+

Review Migration

+

Please confirm the details of your migration.

+ +
+
+ Current Handle: + {flow.state.sourceHandle} +
+
+ New Handle: + {flow.state.targetHandle} +
+
+ DID: + {flow.state.sourceDid} +
+
+ From PDS: + {flow.state.sourcePdsUrl} +
+
+ To PDS: + {window.location.origin} +
+
+ Email: + {flow.state.targetEmail} +
+
+ +
+ Final confirmation: After you click "Start Migration", your repository and data will begin + transferring. This process cannot be easily undone. +
+ +
+ + +
+
+ + {:else if flow.state.step === 'migrating'} +
+

Migration in Progress

+

Please wait while your account is being transferred...

+ +
+
+ {flow.state.progress.repoExported ? '✓' : '○'} + Export repository +
+
+ {flow.state.progress.repoImported ? '✓' : '○'} + Import repository +
+
+ {flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'} + Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal}) +
+
+ {flow.state.progress.prefsMigrated ? '✓' : '○'} + Migrate preferences +
+
+ + {#if flow.state.progress.blobsTotal > 0} +
+
+
+ {/if} + +

{flow.state.progress.currentOperation}

+
+ + {:else if flow.state.step === 'email-verify'} +
+

{$_('migration.inbound.emailVerify.title')}

+

{@html $_('migration.inbound.emailVerify.desc', { values: { email: `${flow.state.targetEmail}` } })}

+ +
+

+ {$_('migration.inbound.emailVerify.hint')} +

+
+ + {#if flow.state.error} +
+ {flow.state.error} +
+ {/if} + +
+
+ + flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)} + disabled={loading} + required + /> +
+ +
+ + +
+
+
+ + {:else if flow.state.step === 'plc-token'} +
+

Verify Migration

+

A verification code has been sent to the email registered with your old account.

+ +
+

+ This code confirms you have access to the account and authorizes updating your identity + to point to this PDS. +

+
+ +
+
+ + flow.updateField('plcToken', (e.target as HTMLInputElement).value)} + disabled={loading} + required + /> +
+ +
+ + +
+
+
+ + {:else if flow.state.step === 'finalizing'} +
+

Finalizing Migration

+

Please wait while we complete the migration...

+ +
+
+ {flow.state.progress.plcSigned ? '✓' : '○'} + Sign identity update +
+
+ {flow.state.progress.activated ? '✓' : '○'} + Activate new account +
+
+ {flow.state.progress.deactivated ? '✓' : '○'} + Deactivate old account +
+
+ +

{flow.state.progress.currentOperation}

+
+ + {:else if flow.state.step === 'success'} +
+
+

Migration Complete!

+

Your account has been successfully migrated to this PDS.

+ +
+
+ Your new handle: + {flow.state.targetHandle} +
+
+ DID: + {flow.state.sourceDid} +
+
+ + {#if flow.state.progress.blobsFailed.length > 0} +
+ Note: {flow.state.progress.blobsFailed.length} blobs could not be migrated. + These may be images or other media that are no longer available. +
+ {/if} + +

Redirecting to dashboard...

+
+ + {:else if flow.state.step === 'error'} +
+

Migration Error

+

An error occurred during migration.

+ +
+ {flow.state.error} +
+ +
+ +
+
+ {/if} +
+ + diff --git a/frontend/src/components/migration/OutboundWizard.svelte b/frontend/src/components/migration/OutboundWizard.svelte new file mode 100644 index 0000000..6e6df48 --- /dev/null +++ b/frontend/src/components/migration/OutboundWizard.svelte @@ -0,0 +1,992 @@ + + +
+ {#if flow.state.step !== 'welcome'} +
+ {#each steps as stepName, i} +
+
{i < getCurrentStepIndex() ? '✓' : i + 1}
+ {stepName} +
+ {#if i < steps.length - 1} +
+ {/if} + {/each} +
+ {/if} + + {#if flow.state.error} +
{flow.state.error}
+ {/if} + + {#if flow.state.step === 'welcome'} +
+

Migrate Your Account Away

+

This wizard will help you move your AT Protocol account from this PDS to another one.

+ + + + {#if isDidWeb()} +
+ did:web Migration Notice +

+ Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will + continue serving your DID document with an updated service endpoint pointing to your new PDS. +

+

+ You can return here anytime to update the forwarding if you migrate again in the future. +

+
+ {/if} + +
+

What will happen:

+
    +
  1. Choose your new PDS
  2. +
  3. Set up your account on the new server
  4. +
  5. Your repository and blobs will be transferred
  6. +
  7. Verify the migration via email
  8. +
  9. Your identity will be updated to point to the new PDS
  10. +
  11. Your account here will be deactivated
  12. +
+
+ +
+ Before you proceed: +
    +
  • You need access to the email registered with this account
  • +
  • You will lose access to this account on this PDS
  • +
  • Make sure you trust the destination PDS
  • +
  • Large accounts may take several minutes to transfer
  • +
+
+ + + +
+ + +
+
+ + {:else if flow.state.step === 'target-pds'} +
+

Choose Your New PDS

+

Enter the URL of the PDS you want to migrate to.

+ +
+
+ + +

The server address of your new PDS (e.g., bsky.social, pds.example.com)

+
+ +
+ + +
+
+ + {#if flow.state.targetServerInfo} +
+

Connected to PDS

+
+ Server: + {flow.state.targetPdsUrl} +
+ {#if flow.state.targetServerInfo.availableUserDomains.length > 0} +
+ Available domains: + {flow.state.targetServerInfo.availableUserDomains.join(', ')} +
+ {/if} +
+ Invite required: + {flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'} +
+ {#if flow.state.targetServerInfo.links?.termsOfService} + + Terms of Service + + {/if} + {#if flow.state.targetServerInfo.links?.privacyPolicy} + + Privacy Policy + + {/if} +
+ {/if} +
+ + {:else if flow.state.step === 'new-account'} +
+

Set Up Your New Account

+

Configure your account details on the new PDS.

+ +
+ Migrating to: + {flow.state.targetPdsUrl} +
+ +
+ +
+ + {#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} + + {/if} +
+

You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)

+
+ +
+ + flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} + required + /> +
+ +
+ + flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} + required + minlength="8" + /> +

At least 8 characters. This will be your password on the new PDS.

+
+ + {#if flow.state.targetServerInfo?.inviteCodeRequired} +
+ + flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} + required + /> +

Required by this PDS to create an account

+
+ {/if} + +
+ + +
+
+ + {:else if flow.state.step === 'review'} +
+

Review Migration

+

Please confirm the details of your migration.

+ +
+
+ Current Handle: + @{auth.session?.handle} +
+
+ New Handle: + @{flow.state.targetHandle} +
+
+ DID: + {auth.session?.did} +
+
+ From PDS: + {window.location.origin} +
+
+ To PDS: + {flow.state.targetPdsUrl} +
+
+ New Email: + {flow.state.targetEmail} +
+
+ +
+ This action cannot be easily undone! +

+ After migration completes, your account on this PDS will be deactivated. + To return, you would need to migrate back from the new PDS. +

+
+ + + +
+ + +
+
+ + {:else if flow.state.step === 'migrating'} +
+

Migration in Progress

+

Please wait while your account is being transferred...

+ +
+
+ {flow.state.progress.repoExported ? '✓' : '○'} + Export repository +
+
+ {flow.state.progress.repoImported ? '✓' : '○'} + Import repository to new PDS +
+
+ {flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'} + Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal}) +
+
+ {flow.state.progress.prefsMigrated ? '✓' : '○'} + Migrate preferences +
+
+ + {#if flow.state.progress.blobsTotal > 0} +
+
+
+ {/if} + +

{flow.state.progress.currentOperation}

+
+ + {:else if flow.state.step === 'plc-token'} +
+

Verify Migration

+

A verification code has been sent to your email ({auth.session?.email}).

+ +
+

+ This code confirms you have access to the account and authorizes updating your identity + to point to the new PDS. +

+
+ +
+
+ + flow.updateField('plcToken', (e.target as HTMLInputElement).value)} + disabled={loading} + required + /> +
+ +
+ + +
+
+
+ + {:else if flow.state.step === 'finalizing'} +
+

Finalizing Migration

+

Please wait while we complete the migration...

+ +
+
+ {flow.state.progress.plcSigned ? '✓' : '○'} + Sign identity update +
+
+ {flow.state.progress.activated ? '✓' : '○'} + Activate account on new PDS +
+
+ {flow.state.progress.deactivated ? '✓' : '○'} + Deactivate account here +
+
+ +

{flow.state.progress.currentOperation}

+
+ + {:else if flow.state.step === 'success'} +
+
+

Migration Complete!

+

Your account has been successfully migrated to your new PDS.

+ +
+
+ Your new handle: + @{flow.state.targetHandle} +
+
+ New PDS: + {flow.state.targetPdsUrl} +
+
+ DID: + {auth.session?.did} +
+
+ + {#if flow.state.progress.blobsFailed.length > 0} +
+ Note: {flow.state.progress.blobsFailed.length} blobs could not be migrated. + These may be images or other media that are no longer available. +
+ {/if} + +
+

Next Steps

+
    +
  1. Visit your new PDS at {flow.state.targetPdsUrl}
  2. +
  3. Log in with your new credentials
  4. +
  5. Your followers and following will continue to work
  6. +
+
+ +

Logging out in a moment...

+
+ + {:else if flow.state.step === 'error'} +
+

Migration Error

+

An error occurred during migration.

+ +
+ {flow.state.error} +
+ +
+ +
+
+ {/if} +
+ + diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts new file mode 100644 index 0000000..2121352 --- /dev/null +++ b/frontend/src/lib/migration/atproto-client.ts @@ -0,0 +1,448 @@ +import type { + AccountStatus, + BlobRef, + CreateAccountParams, + DidCredentials, + DidDocument, + MigrationError, + PlcOperation, + Preferences, + ServerDescription, + Session, +} from "./types"; + +export class AtprotoClient { + private baseUrl: string; + private accessToken: string | null = null; + + constructor(pdsUrl: string) { + this.baseUrl = pdsUrl.replace(/\/$/, ""); + } + + setAccessToken(token: string | null) { + this.accessToken = token; + } + + getAccessToken(): string | null { + return this.accessToken; + } + + private async xrpc( + method: string, + options?: { + httpMethod?: "GET" | "POST"; + params?: Record; + body?: unknown; + authToken?: string; + rawBody?: Uint8Array | Blob; + contentType?: string; + }, + ): Promise { + const { + httpMethod = "GET", + params, + body, + authToken, + rawBody, + contentType, + } = options ?? {}; + + let url = `${this.baseUrl}/xrpc/${method}`; + if (params) { + const searchParams = new URLSearchParams(params); + url += `?${searchParams}`; + } + + const headers: Record = {}; + const token = authToken ?? this.accessToken; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + let requestBody: BodyInit | undefined; + if (rawBody) { + headers["Content-Type"] = contentType ?? "application/octet-stream"; + requestBody = rawBody; + } else if (body) { + headers["Content-Type"] = "application/json"; + requestBody = JSON.stringify(body); + } else if (httpMethod === "POST") { + headers["Content-Type"] = "application/json"; + } + + const res = await fetch(url, { + method: httpMethod, + headers, + body: requestBody, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ + error: "Unknown", + message: res.statusText, + })); + const error = new Error(err.message) as Error & { + status: number; + error: string; + }; + error.status = res.status; + error.error = err.error; + throw error; + } + + const responseContentType = res.headers.get("content-type") ?? ""; + if (responseContentType.includes("application/json")) { + return res.json(); + } + return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T; + } + + async login( + identifier: string, + password: string, + authFactorToken?: string, + ): Promise { + const body: Record = { identifier, password }; + if (authFactorToken) { + body.authFactorToken = authFactorToken; + } + + const session = await this.xrpc("com.atproto.server.createSession", { + httpMethod: "POST", + body, + }); + + this.accessToken = session.accessJwt; + return session; + } + + async refreshSession(refreshJwt: string): Promise { + const session = await this.xrpc( + "com.atproto.server.refreshSession", + { + httpMethod: "POST", + authToken: refreshJwt, + }, + ); + this.accessToken = session.accessJwt; + return session; + } + + async describeServer(): Promise { + return this.xrpc("com.atproto.server.describeServer"); + } + + async getServiceAuth( + aud: string, + lxm?: string, + ): Promise<{ token: string }> { + const params: Record = { aud }; + if (lxm) { + params.lxm = lxm; + } + return this.xrpc("com.atproto.server.getServiceAuth", { params }); + } + + async getRepo(did: string): Promise { + return this.xrpc("com.atproto.sync.getRepo", { + params: { did }, + }); + } + + async listBlobs( + did: string, + cursor?: string, + limit = 100, + ): Promise<{ cids: string[]; cursor?: string }> { + const params: Record = { did, limit: String(limit) }; + if (cursor) { + params.cursor = cursor; + } + return this.xrpc("com.atproto.sync.listBlobs", { params }); + } + + async getBlob(did: string, cid: string): Promise { + return this.xrpc("com.atproto.sync.getBlob", { + params: { did, cid }, + }); + } + + async uploadBlob( + data: Uint8Array, + mimeType: string, + ): Promise<{ blob: BlobRef }> { + return this.xrpc("com.atproto.repo.uploadBlob", { + httpMethod: "POST", + rawBody: data, + contentType: mimeType, + }); + } + + async getPreferences(): Promise { + return this.xrpc("app.bsky.actor.getPreferences"); + } + + async putPreferences(preferences: Preferences): Promise { + await this.xrpc("app.bsky.actor.putPreferences", { + httpMethod: "POST", + body: preferences, + }); + } + + async createAccount( + params: CreateAccountParams, + serviceToken?: string, + ): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (serviceToken) { + headers["Authorization"] = `Bearer ${serviceToken}`; + } + + const res = await fetch( + `${this.baseUrl}/xrpc/com.atproto.server.createAccount`, + { + method: "POST", + headers, + body: JSON.stringify(params), + }, + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({ + error: "Unknown", + message: res.statusText, + })); + const error = new Error(err.message) as Error & { + status: number; + error: string; + }; + error.status = res.status; + error.error = err.error; + throw error; + } + + const session = (await res.json()) as Session; + this.accessToken = session.accessJwt; + return session; + } + + async importRepo(car: Uint8Array): Promise { + await this.xrpc("com.atproto.repo.importRepo", { + httpMethod: "POST", + rawBody: car, + contentType: "application/vnd.ipld.car", + }); + } + + async listMissingBlobs( + cursor?: string, + limit = 100, + ): Promise<{ blobs: Array<{ cid: string; recordUri: string }>; cursor?: string }> { + const params: Record = { limit: String(limit) }; + if (cursor) { + params.cursor = cursor; + } + return this.xrpc("com.atproto.repo.listMissingBlobs", { params }); + } + + async requestPlcOperationSignature(): Promise { + await this.xrpc("com.atproto.identity.requestPlcOperationSignature", { + httpMethod: "POST", + }); + } + + async signPlcOperation(params: { + token?: string; + rotationKeys?: string[]; + alsoKnownAs?: string[]; + verificationMethods?: { atproto?: string }; + services?: { atproto_pds?: { type: string; endpoint: string } }; + }): Promise<{ operation: PlcOperation }> { + return this.xrpc("com.atproto.identity.signPlcOperation", { + httpMethod: "POST", + body: params, + }); + } + + async submitPlcOperation(operation: PlcOperation): Promise { + await this.xrpc("com.atproto.identity.submitPlcOperation", { + httpMethod: "POST", + body: { operation }, + }); + } + + async getRecommendedDidCredentials(): Promise { + return this.xrpc("com.atproto.identity.getRecommendedDidCredentials"); + } + + async activateAccount(): Promise { + await this.xrpc("com.atproto.server.activateAccount", { + httpMethod: "POST", + }); + } + + async deactivateAccount(): Promise { + await this.xrpc("com.atproto.server.deactivateAccount", { + httpMethod: "POST", + }); + } + + async checkAccountStatus(): Promise { + return this.xrpc("com.atproto.server.checkAccountStatus"); + } + + async getMigrationStatus(): Promise<{ + did: string; + didType: string; + migrated: boolean; + migratedToPds?: string; + migratedAt?: string; + }> { + return this.xrpc("com.tranquil.account.getMigrationStatus"); + } + + async updateMigrationForwarding(pdsUrl: string): Promise<{ + success: boolean; + migratedToPds: string; + migratedAt: string; + }> { + return this.xrpc("com.tranquil.account.updateMigrationForwarding", { + httpMethod: "POST", + body: { pdsUrl }, + }); + } + + async clearMigrationForwarding(): Promise<{ success: boolean }> { + return this.xrpc("com.tranquil.account.clearMigrationForwarding", { + httpMethod: "POST", + }); + } + + async resolveHandle(handle: string): Promise<{ did: string }> { + return this.xrpc("com.atproto.identity.resolveHandle", { + params: { handle }, + }); + } + + async loginDeactivated( + identifier: string, + password: string, + ): Promise { + const session = await this.xrpc("com.atproto.server.createSession", { + httpMethod: "POST", + body: { identifier, password, allowDeactivated: true }, + }); + this.accessToken = session.accessJwt; + return session; + } + + async verifyToken( + token: string, + identifier: string, + ): Promise<{ success: boolean; did: string; purpose: string; channel: string }> { + return this.xrpc("com.tranquil.account.verifyToken", { + httpMethod: "POST", + body: { token, identifier }, + }); + } + + async resendMigrationVerification(): Promise { + await this.xrpc("com.atproto.server.resendMigrationVerification", { + httpMethod: "POST", + }); + } +} + +export async function resolveDidDocument(did: string): Promise { + if (did.startsWith("did:plc:")) { + const res = await fetch(`https://plc.directory/${did}`); + if (!res.ok) { + throw new Error(`Failed to resolve DID: ${res.statusText}`); + } + return res.json(); + } + + if (did.startsWith("did:web:")) { + const domain = did.slice(8).replace(/%3A/g, ":"); + const url = domain.includes("/") + ? `https://${domain}/did.json` + : `https://${domain}/.well-known/did.json`; + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to resolve DID: ${res.statusText}`); + } + return res.json(); + } + + throw new Error(`Unsupported DID method: ${did}`); +} + +export async function resolvePdsUrl( + handleOrDid: string, +): Promise<{ did: string; pdsUrl: string }> { + let did: string; + + if (handleOrDid.startsWith("did:")) { + did = handleOrDid; + } else { + const handle = handleOrDid.replace(/^@/, ""); + + if (handle.endsWith(".bsky.social")) { + const res = await fetch( + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, + ); + if (!res.ok) { + throw new Error(`Failed to resolve handle: ${res.statusText}`); + } + const data = await res.json(); + did = data.did; + } else { + const dnsRes = await fetch( + `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`, + ); + if (dnsRes.ok) { + const dnsData = await dnsRes.json(); + const txtRecords = dnsData.Answer ?? []; + for (const record of txtRecords) { + const txt = record.data?.replace(/"/g, "") ?? ""; + if (txt.startsWith("did=")) { + did = txt.slice(4); + break; + } + } + } + + if (!did) { + const wellKnownRes = await fetch( + `https://${handle}/.well-known/atproto-did`, + ); + if (wellKnownRes.ok) { + did = (await wellKnownRes.text()).trim(); + } + } + + if (!did) { + throw new Error(`Could not resolve handle: ${handle}`); + } + } + } + + const didDoc = await resolveDidDocument(did); + + const pdsService = didDoc.service?.find( + (s: { type: string }) => s.type === "AtprotoPersonalDataServer", + ); + + if (!pdsService) { + throw new Error("No PDS service found in DID document"); + } + + return { did, pdsUrl: pdsService.serviceEndpoint }; +} + +export function createLocalClient(): AtprotoClient { + return new AtprotoClient(window.location.origin); +} diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts new file mode 100644 index 0000000..d6f895a --- /dev/null +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -0,0 +1,734 @@ +import type { + InboundMigrationState, + InboundStep, + MigrationProgress, + OutboundMigrationState, + OutboundStep, + ServerDescription, + StoredMigrationState, +} from "./types"; +import { + AtprotoClient, + createLocalClient, + resolvePdsUrl, +} from "./atproto-client"; +import { + clearMigrationState, + loadMigrationState, + saveMigrationState, + updateProgress, + updateStep, +} from "./storage"; + +function createInitialProgress(): MigrationProgress { + return { + repoExported: false, + repoImported: false, + blobsTotal: 0, + blobsMigrated: 0, + blobsFailed: [], + prefsMigrated: false, + plcSigned: false, + activated: false, + deactivated: false, + currentOperation: "", + }; +} + +export function createInboundMigrationFlow() { + let state = $state({ + direction: "inbound", + step: "welcome", + sourcePdsUrl: "", + sourceDid: "", + sourceHandle: "", + targetHandle: "", + targetEmail: "", + targetPassword: "", + inviteCode: "", + sourceAccessToken: null, + sourceRefreshToken: null, + serviceAuthToken: null, + emailVerifyToken: "", + plcToken: "", + progress: createInitialProgress(), + error: null, + requires2FA: false, + twoFactorCode: "", + }); + + let sourceClient: AtprotoClient | null = null; + let localClient: AtprotoClient | null = null; + let localServerInfo: ServerDescription | null = null; + + function setStep(step: InboundStep) { + state.step = step; + state.error = null; + saveMigrationState(state); + updateStep(step); + } + + function setError(error: string) { + state.error = error; + saveMigrationState(state); + } + + function setProgress(updates: Partial) { + state.progress = { ...state.progress, ...updates }; + updateProgress(updates); + } + + async function loadLocalServerInfo(): Promise { + if (!localClient) { + localClient = createLocalClient(); + } + if (!localServerInfo) { + localServerInfo = await localClient.describeServer(); + } + return localServerInfo; + } + + async function resolveSourcePds(handle: string): Promise { + try { + const { did, pdsUrl } = await resolvePdsUrl(handle); + state.sourcePdsUrl = pdsUrl; + state.sourceDid = did; + state.sourceHandle = handle; + sourceClient = new AtprotoClient(pdsUrl); + } catch (e) { + throw new Error(`Could not resolve handle: ${(e as Error).message}`); + } + } + + async function loginToSource( + handle: string, + password: string, + twoFactorCode?: string, + ): Promise { + if (!state.sourcePdsUrl) { + await resolveSourcePds(handle); + } + + if (!sourceClient) { + sourceClient = new AtprotoClient(state.sourcePdsUrl); + } + + try { + const session = await sourceClient.login(handle, password, twoFactorCode); + state.sourceAccessToken = session.accessJwt; + state.sourceRefreshToken = session.refreshJwt; + state.sourceDid = session.did; + state.sourceHandle = session.handle; + state.requires2FA = false; + saveMigrationState(state); + } catch (e) { + const err = e as Error & { error?: string }; + if (err.error === "AuthFactorTokenRequired") { + state.requires2FA = true; + throw new Error("Two-factor authentication required. Please enter the code sent to your email."); + } + throw e; + } + } + + async function checkHandleAvailability(handle: string): Promise { + if (!localClient) { + localClient = createLocalClient(); + } + try { + await localClient.resolveHandle(handle); + return false; + } catch { + return true; + } + } + + async function authenticateToLocal(email: string, password: string): Promise { + if (!localClient) { + localClient = createLocalClient(); + } + await localClient.loginDeactivated(email, password); + } + + async function startMigration(): Promise { + if (!sourceClient || !state.sourceAccessToken) { + throw new Error("Not logged in to source PDS"); + } + + if (!localClient) { + localClient = createLocalClient(); + } + + setStep("migrating"); + setProgress({ currentOperation: "Getting service auth token..." }); + + try { + const serverInfo = await loadLocalServerInfo(); + const { token } = await sourceClient.getServiceAuth( + serverInfo.did, + "com.atproto.server.createAccount", + ); + state.serviceAuthToken = token; + + setProgress({ currentOperation: "Creating account on new PDS..." }); + + const accountParams = { + did: state.sourceDid, + handle: state.targetHandle, + email: state.targetEmail, + password: state.targetPassword, + inviteCode: state.inviteCode || undefined, + }; + + const session = await localClient.createAccount(accountParams, token); + localClient.setAccessToken(session.accessJwt); + + setProgress({ currentOperation: "Exporting repository..." }); + + const car = await sourceClient.getRepo(state.sourceDid); + setProgress({ repoExported: true, currentOperation: "Importing repository..." }); + + await localClient.importRepo(car); + setProgress({ repoImported: true, currentOperation: "Counting blobs..." }); + + const accountStatus = await localClient.checkAccountStatus(); + setProgress({ + blobsTotal: accountStatus.expectedBlobs, + currentOperation: "Migrating blobs...", + }); + + await migrateBlobs(); + + setProgress({ currentOperation: "Migrating preferences..." }); + await migratePreferences(); + + setStep("email-verify"); + } catch (e) { + const err = e as Error & { error?: string; status?: number }; + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + setError(message); + setStep("error"); + } + } + + async function migrateBlobs(): Promise { + if (!sourceClient || !localClient) return; + + let cursor: string | undefined; + let migrated = 0; + + do { + const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( + cursor, + 100, + ); + + for (const blob of blobs) { + try { + setProgress({ + currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`, + }); + + const blobData = await sourceClient.getBlob(state.sourceDid, blob.cid); + await localClient.uploadBlob(blobData, "application/octet-stream"); + migrated++; + setProgress({ blobsMigrated: migrated }); + } catch (e) { + state.progress.blobsFailed.push(blob.cid); + } + } + + cursor = nextCursor; + } while (cursor); + } + + async function migratePreferences(): Promise { + if (!sourceClient || !localClient) return; + + try { + const prefs = await sourceClient.getPreferences(); + await localClient.putPreferences(prefs); + setProgress({ prefsMigrated: true }); + } catch { + } + } + + async function submitEmailVerifyToken(token: string, localPassword?: string): Promise { + if (!localClient) { + localClient = createLocalClient(); + } + + state.emailVerifyToken = token; + setError(null); + + try { + await localClient.verifyToken(token, state.targetEmail); + + if (!sourceClient) { + setStep("source-login"); + setError("Email verified! Please log in to your old account again to complete the migration."); + return; + } + + if (localPassword) { + setProgress({ currentOperation: "Authenticating to new PDS..." }); + await localClient.loginDeactivated(state.targetEmail, localPassword); + } + + if (!localClient.getAccessToken()) { + setError("Email verified! Please enter your password to continue."); + return; + } + + setProgress({ currentOperation: "Requesting PLC operation token..." }); + await sourceClient.requestPlcOperationSignature(); + setStep("plc-token"); + } catch (e) { + const err = e as Error & { error?: string; status?: number }; + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + setError(message); + } + } + + async function resendEmailVerification(): Promise { + if (!localClient) { + localClient = createLocalClient(); + } + await localClient.resendMigrationVerification(); + } + + let checkingEmailVerification = false; + + async function checkEmailVerifiedAndProceed(): Promise { + if (checkingEmailVerification) return false; + if (!sourceClient || !localClient) return false; + + checkingEmailVerification = true; + try { + await localClient.loginDeactivated(state.targetEmail, state.targetPassword); + await sourceClient.requestPlcOperationSignature(); + setStep("plc-token"); + return true; + } catch (e) { + const err = e as Error & { error?: string }; + if (err.error === "AccountNotVerified") { + return false; + } + return false; + } finally { + checkingEmailVerification = false; + } + } + + async function submitPlcToken(token: string): Promise { + if (!sourceClient || !localClient) { + throw new Error("Not connected to PDSes"); + } + + state.plcToken = token; + setStep("finalizing"); + setProgress({ currentOperation: "Signing PLC operation..." }); + + try { + const credentials = await localClient.getRecommendedDidCredentials(); + + const { operation } = await sourceClient.signPlcOperation({ + token, + ...credentials, + }); + + setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." }); + await localClient.submitPlcOperation(operation); + + setProgress({ currentOperation: "Activating account (waiting for DID propagation)..." }); + await localClient.activateAccount(); + setProgress({ activated: true }); + + setProgress({ currentOperation: "Deactivating old account..." }); + try { + await sourceClient.deactivateAccount(); + setProgress({ deactivated: true }); + } catch { + } + + setStep("success"); + clearMigrationState(); + } catch (e) { + const err = e as Error & { error?: string; status?: number }; + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + state.step = "plc-token"; + state.error = message; + saveMigrationState(state); + } + } + + async function requestPlcToken(): Promise { + if (!sourceClient) { + throw new Error("Not connected to source PDS"); + } + setProgress({ currentOperation: "Requesting PLC operation token..." }); + await sourceClient.requestPlcOperationSignature(); + } + + async function resendPlcToken(): Promise { + if (!sourceClient) { + throw new Error("Not connected to source PDS"); + } + await sourceClient.requestPlcOperationSignature(); + } + + function reset(): void { + state = { + direction: "inbound", + step: "welcome", + sourcePdsUrl: "", + sourceDid: "", + sourceHandle: "", + targetHandle: "", + targetEmail: "", + targetPassword: "", + inviteCode: "", + sourceAccessToken: null, + sourceRefreshToken: null, + serviceAuthToken: null, + emailVerifyToken: "", + plcToken: "", + progress: createInitialProgress(), + error: null, + requires2FA: false, + twoFactorCode: "", + }; + sourceClient = null; + clearMigrationState(); + } + + async function resumeFromState(stored: StoredMigrationState): Promise { + if (stored.direction !== "inbound") return; + + state.sourcePdsUrl = stored.sourcePdsUrl; + state.sourceDid = stored.sourceDid; + state.sourceHandle = stored.sourceHandle; + state.targetHandle = stored.targetHandle; + state.targetEmail = stored.targetEmail; + state.progress = { + ...createInitialProgress(), + ...stored.progress, + }; + + state.step = "source-login"; + } + + function getLocalSession(): { accessJwt: string; did: string; handle: string } | null { + if (!localClient) return null; + const token = localClient.getAccessToken(); + if (!token) return null; + return { + accessJwt: token, + did: state.sourceDid, + handle: state.targetHandle, + }; + } + + return { + get state() { return state; }, + setStep, + setError, + loadLocalServerInfo, + loginToSource, + authenticateToLocal, + checkHandleAvailability, + startMigration, + submitEmailVerifyToken, + resendEmailVerification, + checkEmailVerifiedAndProceed, + requestPlcToken, + submitPlcToken, + resendPlcToken, + reset, + resumeFromState, + getLocalSession, + + updateField( + field: K, + value: InboundMigrationState[K], + ) { + state[field] = value; + }, + }; +} + +export function createOutboundMigrationFlow() { + let state = $state({ + direction: "outbound", + step: "welcome", + localDid: "", + localHandle: "", + targetPdsUrl: "", + targetPdsDid: "", + targetHandle: "", + targetEmail: "", + targetPassword: "", + inviteCode: "", + targetAccessToken: null, + targetRefreshToken: null, + serviceAuthToken: null, + plcToken: "", + progress: createInitialProgress(), + error: null, + targetServerInfo: null, + }); + + let localClient: AtprotoClient | null = null; + let targetClient: AtprotoClient | null = null; + + function setStep(step: OutboundStep) { + state.step = step; + state.error = null; + saveMigrationState(state); + updateStep(step); + } + + function setError(error: string) { + state.error = error; + saveMigrationState(state); + } + + function setProgress(updates: Partial) { + state.progress = { ...state.progress, ...updates }; + updateProgress(updates); + } + + async function validateTargetPds(url: string): Promise { + const normalizedUrl = url.replace(/\/$/, ""); + targetClient = new AtprotoClient(normalizedUrl); + + try { + const serverInfo = await targetClient.describeServer(); + state.targetPdsUrl = normalizedUrl; + state.targetPdsDid = serverInfo.did; + state.targetServerInfo = serverInfo; + return serverInfo; + } catch (e) { + throw new Error(`Could not connect to PDS: ${(e as Error).message}`); + } + } + + function initLocalClient(accessToken: string, did?: string, handle?: string): void { + localClient = createLocalClient(); + localClient.setAccessToken(accessToken); + if (did) { + state.localDid = did; + } + if (handle) { + state.localHandle = handle; + } + } + + async function startMigration(currentDid: string): Promise { + if (!localClient || !targetClient) { + throw new Error("Not connected to PDSes"); + } + + setStep("migrating"); + setProgress({ currentOperation: "Getting service auth token..." }); + + try { + const { token } = await localClient.getServiceAuth( + state.targetPdsDid, + "com.atproto.server.createAccount", + ); + state.serviceAuthToken = token; + + setProgress({ currentOperation: "Creating account on new PDS..." }); + + const accountParams = { + did: currentDid, + handle: state.targetHandle, + email: state.targetEmail, + password: state.targetPassword, + inviteCode: state.inviteCode || undefined, + }; + + const session = await targetClient.createAccount(accountParams, token); + state.targetAccessToken = session.accessJwt; + state.targetRefreshToken = session.refreshJwt; + targetClient.setAccessToken(session.accessJwt); + + setProgress({ currentOperation: "Exporting repository..." }); + + const car = await localClient.getRepo(currentDid); + setProgress({ repoExported: true, currentOperation: "Importing repository..." }); + + await targetClient.importRepo(car); + setProgress({ repoImported: true, currentOperation: "Counting blobs..." }); + + const accountStatus = await targetClient.checkAccountStatus(); + setProgress({ + blobsTotal: accountStatus.expectedBlobs, + currentOperation: "Migrating blobs...", + }); + + await migrateBlobs(currentDid); + + setProgress({ currentOperation: "Migrating preferences..." }); + await migratePreferences(); + + setProgress({ currentOperation: "Requesting PLC operation token..." }); + await localClient.requestPlcOperationSignature(); + + setStep("plc-token"); + } catch (e) { + const err = e as Error & { error?: string; status?: number }; + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + setError(message); + setStep("error"); + } + } + + async function migrateBlobs(did: string): Promise { + if (!localClient || !targetClient) return; + + let cursor: string | undefined; + let migrated = 0; + + do { + const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs( + cursor, + 100, + ); + + for (const blob of blobs) { + try { + setProgress({ + currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`, + }); + + const blobData = await localClient.getBlob(did, blob.cid); + await targetClient.uploadBlob(blobData, "application/octet-stream"); + migrated++; + setProgress({ blobsMigrated: migrated }); + } catch (e) { + state.progress.blobsFailed.push(blob.cid); + } + } + + cursor = nextCursor; + } while (cursor); + } + + async function migratePreferences(): Promise { + if (!localClient || !targetClient) return; + + try { + const prefs = await localClient.getPreferences(); + await targetClient.putPreferences(prefs); + setProgress({ prefsMigrated: true }); + } catch { + } + } + + async function submitPlcToken(token: string): Promise { + if (!localClient || !targetClient) { + throw new Error("Not connected to PDSes"); + } + + state.plcToken = token; + setStep("finalizing"); + setProgress({ currentOperation: "Signing PLC operation..." }); + + try { + const credentials = await targetClient.getRecommendedDidCredentials(); + + const { operation } = await localClient.signPlcOperation({ + token, + ...credentials, + }); + + setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." }); + + await targetClient.submitPlcOperation(operation); + + setProgress({ currentOperation: "Activating account on new PDS..." }); + await targetClient.activateAccount(); + setProgress({ activated: true }); + + setProgress({ currentOperation: "Deactivating old account..." }); + try { + await localClient.deactivateAccount(); + setProgress({ deactivated: true }); + } catch { + } + + if (state.localDid.startsWith("did:web:")) { + setProgress({ currentOperation: "Updating DID document forwarding..." }); + try { + await localClient.updateMigrationForwarding(state.targetPdsUrl); + } catch (e) { + console.warn("Failed to update migration forwarding:", e); + } + } + + setStep("success"); + clearMigrationState(); + } catch (e) { + const err = e as Error & { error?: string; status?: number }; + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + setError(message); + setStep("plc-token"); + } + } + + async function resendPlcToken(): Promise { + if (!localClient) { + throw new Error("Not connected to local PDS"); + } + await localClient.requestPlcOperationSignature(); + } + + function reset(): void { + state = { + direction: "outbound", + step: "welcome", + localDid: "", + localHandle: "", + targetPdsUrl: "", + targetPdsDid: "", + targetHandle: "", + targetEmail: "", + targetPassword: "", + inviteCode: "", + targetAccessToken: null, + targetRefreshToken: null, + serviceAuthToken: null, + plcToken: "", + progress: createInitialProgress(), + error: null, + targetServerInfo: null, + }; + localClient = null; + targetClient = null; + clearMigrationState(); + } + + return { + get state() { return state; }, + setStep, + setError, + validateTargetPds, + initLocalClient, + startMigration, + submitPlcToken, + resendPlcToken, + reset, + + updateField( + field: K, + value: OutboundMigrationState[K], + ) { + state[field] = value; + }, + }; +} + +export type InboundMigrationFlow = ReturnType; +export type OutboundMigrationFlow = ReturnType; diff --git a/frontend/src/lib/migration/index.ts b/frontend/src/lib/migration/index.ts new file mode 100644 index 0000000..fe08ff7 --- /dev/null +++ b/frontend/src/lib/migration/index.ts @@ -0,0 +1,9 @@ +export * from "./types"; +export * from "./atproto-client"; +export * from "./storage"; +export { + createInboundMigrationFlow, + createOutboundMigrationFlow, + type InboundMigrationFlow, + type OutboundMigrationFlow, +} from "./flow.svelte"; diff --git a/frontend/src/lib/migration/storage.ts b/frontend/src/lib/migration/storage.ts new file mode 100644 index 0000000..cecab9f --- /dev/null +++ b/frontend/src/lib/migration/storage.ts @@ -0,0 +1,138 @@ +import type { MigrationDirection, MigrationState, StoredMigrationState } from "./types"; + +const STORAGE_KEY = "tranquil_migration_state"; +const MAX_AGE_MS = 24 * 60 * 60 * 1000; + +export function saveMigrationState(state: MigrationState): void { + const storedState: StoredMigrationState = { + version: 1, + direction: state.direction, + step: state.direction === "inbound" ? state.step : state.step, + startedAt: new Date().toISOString(), + sourcePdsUrl: state.direction === "inbound" ? state.sourcePdsUrl : window.location.origin, + targetPdsUrl: state.direction === "inbound" ? window.location.origin : state.targetPdsUrl, + sourceDid: state.direction === "inbound" ? state.sourceDid : "", + sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", + targetHandle: state.targetHandle, + targetEmail: state.targetEmail, + progress: { + repoExported: state.progress.repoExported, + repoImported: state.progress.repoImported, + blobsTotal: state.progress.blobsTotal, + blobsMigrated: state.progress.blobsMigrated, + prefsMigrated: state.progress.prefsMigrated, + plcSigned: state.progress.plcSigned, + }, + lastError: state.error ?? undefined, + lastErrorStep: state.error ? state.step : undefined, + }; + + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState)); + } catch { + } +} + +export function loadMigrationState(): StoredMigrationState | null { + try { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (!stored) return null; + + const state = JSON.parse(stored) as StoredMigrationState; + + if (state.version !== 1) return null; + + const startedAt = new Date(state.startedAt).getTime(); + if (Date.now() - startedAt > MAX_AGE_MS) { + clearMigrationState(); + return null; + } + + return state; + } catch { + return null; + } +} + +export function clearMigrationState(): void { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + } +} + +export function hasPendingMigration(): boolean { + return loadMigrationState() !== null; +} + +export function getResumeInfo(): { + direction: MigrationDirection; + sourceHandle: string; + targetHandle: string; + sourcePdsUrl: string; + targetPdsUrl: string; + progressSummary: string; + step: string; +} | null { + const state = loadMigrationState(); + if (!state) return null; + + const progressParts: string[] = []; + if (state.progress.repoExported) progressParts.push("repo exported"); + if (state.progress.repoImported) progressParts.push("repo imported"); + if (state.progress.blobsMigrated > 0) { + progressParts.push( + `${state.progress.blobsMigrated}/${state.progress.blobsTotal} blobs`, + ); + } + if (state.progress.prefsMigrated) progressParts.push("preferences migrated"); + if (state.progress.plcSigned) progressParts.push("PLC signed"); + + return { + direction: state.direction, + sourceHandle: state.sourceHandle, + targetHandle: state.targetHandle, + sourcePdsUrl: state.sourcePdsUrl, + targetPdsUrl: state.targetPdsUrl, + progressSummary: progressParts.length > 0 + ? progressParts.join(", ") + : "just started", + step: state.step, + }; +} + +export function updateProgress( + updates: Partial, +): void { + const state = loadMigrationState(); + if (!state) return; + + state.progress = { ...state.progress, ...updates }; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + } +} + +export function updateStep(step: string): void { + const state = loadMigrationState(); + if (!state) return; + + state.step = step; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + } +} + +export function setError(error: string, step: string): void { + const state = loadMigrationState(); + if (!state) return; + + state.lastError = error; + state.lastErrorStep = step; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + } +} diff --git a/frontend/src/lib/migration/types.ts b/frontend/src/lib/migration/types.ts new file mode 100644 index 0000000..7bc7728 --- /dev/null +++ b/frontend/src/lib/migration/types.ts @@ -0,0 +1,214 @@ +export type InboundStep = + | "welcome" + | "source-login" + | "choose-handle" + | "review" + | "migrating" + | "email-verify" + | "plc-token" + | "finalizing" + | "success" + | "error"; + +export type OutboundStep = + | "welcome" + | "target-pds" + | "new-account" + | "review" + | "migrating" + | "plc-token" + | "finalizing" + | "success" + | "error"; + +export type MigrationDirection = "inbound" | "outbound"; + +export interface MigrationProgress { + repoExported: boolean; + repoImported: boolean; + blobsTotal: number; + blobsMigrated: number; + blobsFailed: string[]; + prefsMigrated: boolean; + plcSigned: boolean; + activated: boolean; + deactivated: boolean; + currentOperation: string; +} + +export interface InboundMigrationState { + direction: "inbound"; + step: InboundStep; + sourcePdsUrl: string; + sourceDid: string; + sourceHandle: string; + targetHandle: string; + targetEmail: string; + targetPassword: string; + inviteCode: string; + sourceAccessToken: string | null; + sourceRefreshToken: string | null; + serviceAuthToken: string | null; + emailVerifyToken: string; + plcToken: string; + progress: MigrationProgress; + error: string | null; + requires2FA: boolean; + twoFactorCode: string; +} + +export interface OutboundMigrationState { + direction: "outbound"; + step: OutboundStep; + localDid: string; + localHandle: string; + targetPdsUrl: string; + targetPdsDid: string; + targetHandle: string; + targetEmail: string; + targetPassword: string; + inviteCode: string; + targetAccessToken: string | null; + targetRefreshToken: string | null; + serviceAuthToken: string | null; + plcToken: string; + progress: MigrationProgress; + error: string | null; + targetServerInfo: ServerDescription | null; +} + +export type MigrationState = InboundMigrationState | OutboundMigrationState; + +export interface StoredMigrationState { + version: 1; + direction: MigrationDirection; + step: string; + startedAt: string; + sourcePdsUrl: string; + targetPdsUrl: string; + sourceDid: string; + sourceHandle: string; + targetHandle: string; + targetEmail: string; + progress: { + repoExported: boolean; + repoImported: boolean; + blobsTotal: number; + blobsMigrated: number; + prefsMigrated: boolean; + plcSigned: boolean; + }; + lastErrorStep?: string; + lastError?: string; +} + +export interface ServerDescription { + did: string; + availableUserDomains: string[]; + inviteCodeRequired: boolean; + phoneVerificationRequired?: boolean; + links?: { + privacyPolicy?: string; + termsOfService?: string; + }; +} + +export interface Session { + did: string; + handle: string; + email?: string; + accessJwt: string; + refreshJwt: string; + active?: boolean; +} + +export interface DidDocument { + id: string; + alsoKnownAs?: string[]; + verificationMethod?: Array<{ + id: string; + type: string; + controller: string; + publicKeyMultibase?: string; + }>; + service?: Array<{ + id: string; + type: string; + serviceEndpoint: string; + }>; +} + +export interface DidCredentials { + rotationKeys?: string[]; + alsoKnownAs?: string[]; + verificationMethods?: { + atproto?: string; + }; + services?: { + atproto_pds?: { + type: string; + endpoint: string; + }; + }; +} + +export interface PlcOperation { + type: "plc_operation"; + prev: string | null; + sig: string; + rotationKeys: string[]; + verificationMethods: { + atproto: string; + }; + alsoKnownAs: string[]; + services: { + atproto_pds: { + type: string; + endpoint: string; + }; + }; +} + +export interface AccountStatus { + activated: boolean; + validDid: boolean; + repoCommit: string; + repoRev: string; + repoBlocks: number; + indexedRecords: number; + privateStateValues: number; + expectedBlobs: number; + importedBlobs: number; +} + +export interface BlobRef { + $type: "blob"; + ref: { $link: string }; + mimeType: string; + size: number; +} + +export interface CreateAccountParams { + did?: string; + handle: string; + email: string; + password: string; + inviteCode?: string; + recoveryKey?: string; +} + +export interface Preferences { + preferences: unknown[]; +} + +export class MigrationError extends Error { + constructor( + message: string, + public code: string, + public recoverable: boolean = false, + public details?: unknown, + ) { + super(message); + this.name = "MigrationError"; + } +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 8827011..d05f57c 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -71,7 +71,7 @@ "infoNextDesc": "After creating your account, you'll verify your contact method and then you're ready to use any ATProto app with your new identity.", "migrateTitle": "Already have a Bluesky account?", "migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.", - "migrateLink": "Migrate with PDS Moover", + "migrateLink": "Migrate your account", "handle": "Handle", "handlePlaceholder": "yourname", "handleHint": "Your full handle will be: @{handle}", @@ -991,5 +991,206 @@ "codeLabel": "Verification Code", "codeHelp": "Copy the entire code from your message, including dashes.", "verifyButton": "Verify" + }, + "migration": { + "title": "Account Migration", + "subtitle": "Move your AT Protocol identity between servers", + "navTitle": "Migration", + "navDesc": "Move your account to or from another PDS", + "migrateHere": "Migrate Here", + "migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.", + "migrateAway": "Migrate Away", + "migrateAwayDesc": "Move your account from this PDS to another server.", + "loginRequired": "Login required", + "bringDid": "Bring your DID and identity", + "transferData": "Transfer all your data", + "keepFollowers": "Keep your followers", + "exportRepo": "Export your repository", + "transferToPds": "Transfer to new PDS", + "updateIdentity": "Update your identity", + "whatIsMigration": "What is account migration?", + "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.", + "beforeMigrate": "Before you migrate", + "beforeMigrate1": "You will need your current account credentials", + "beforeMigrate2": "Migration requires email verification for security", + "beforeMigrate3": "Large accounts with many images may take several minutes", + "beforeMigrate4": "Your old PDS will be notified to deactivate your account", + "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.", + "learnMore": "Learn more about migration risks", + "resume": { + "title": "Resume Migration?", + "incomplete": "You have an incomplete migration in progress:", + "direction": "Direction", + "migratingHere": "Migrating here", + "migratingAway": "Migrating away", + "from": "From", + "to": "To", + "progress": "Progress", + "reenterCredentials": "You will need to re-enter your credentials to continue.", + "startOver": "Start Over", + "resumeButton": "Resume" + }, + "inbound": { + "welcome": { + "title": "Migrate to This PDS", + "desc": "Move your existing AT Protocol account to this server.", + "understand": "I understand the risks and want to proceed" + }, + "sourceLogin": { + "title": "Sign In to Your Current PDS", + "desc": "Enter your credentials for the account you want to migrate.", + "handle": "Handle", + "handlePlaceholder": "you.bsky.social", + "password": "Password", + "twoFactorCode": "Two-Factor Code", + "twoFactorRequired": "Two-factor authentication required", + "signIn": "Sign In & Continue" + }, + "chooseHandle": { + "title": "Choose Your New Handle", + "desc": "Select a handle for your account on this PDS.", + "handleHint": "Your full handle will be: @{handle}" + }, + "review": { + "title": "Review Migration", + "desc": "Please review and confirm your migration details.", + "currentHandle": "Current Handle", + "newHandle": "New Handle", + "sourcePds": "Source PDS", + "targetPds": "This PDS", + "email": "Email", + "inviteCode": "Invite Code", + "confirm": "I confirm I want to migrate my account", + "startMigration": "Start Migration" + }, + "migrating": { + "title": "Migrating Your Account", + "desc": "Please wait while we transfer your data...", + "gettingServiceAuth": "Getting service authorization...", + "creatingAccount": "Creating account on new PDS...", + "exportingRepo": "Exporting repository...", + "importingRepo": "Importing repository...", + "countingBlobs": "Counting blobs...", + "migratingBlobs": "Migrating blobs ({current}/{total})...", + "migratingPrefs": "Migrating preferences...", + "requestingPlc": "Requesting PLC operation..." + }, + "emailVerify": { + "title": "Verify Your Email", + "desc": "A verification code has been sent to {email}.", + "hint": "Enter the code below, or click the link in the email to continue automatically.", + "tokenLabel": "Verification Code", + "tokenPlaceholder": "Enter code from email", + "resend": "Resend Code", + "verify": "Verify Email", + "verifying": "Verifying..." + }, + "plcToken": { + "title": "Verify Your Identity", + "desc": "A verification code has been sent to your email on your current PDS.", + "tokenLabel": "Verification Token", + "tokenPlaceholder": "Enter the token from your email", + "resend": "Resend Token", + "resending": "Resending..." + }, + "finalizing": { + "title": "Finalizing Migration", + "desc": "Please wait while we complete the migration...", + "signingPlc": "Sign identity update", + "activating": "Activate account on new PDS", + "deactivating": "Deactivate account on old PDS" + }, + "success": { + "title": "Migration Complete!", + "desc": "Your account has been successfully migrated to this PDS.", + "newHandle": "New Handle", + "did": "DID", + "goToDashboard": "Go to Dashboard" + } + }, + "outbound": { + "welcome": { + "title": "Migrate Away from This PDS", + "desc": "Move your account to another Personal Data Server.", + "warning": "After migration, your account here will be deactivated.", + "didWebNotice": "did:web Migration Notice", + "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.", + "understand": "I understand the risks and want to proceed" + }, + "targetPds": { + "title": "Choose Target PDS", + "desc": "Enter the URL of the PDS you want to migrate to.", + "url": "PDS URL", + "urlPlaceholder": "https://pds.example.com", + "validate": "Validate & Continue", + "validating": "Validating...", + "connected": "Connected to {name}", + "inviteRequired": "Invite code required", + "privacyPolicy": "Privacy Policy", + "termsOfService": "Terms of Service" + }, + "newAccount": { + "title": "New Account Details", + "desc": "Set up your account on the new PDS.", + "handle": "Handle", + "availableDomains": "Available domains", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "inviteCode": "Invite Code" + }, + "review": { + "title": "Review Migration", + "desc": "Please review and confirm your migration details.", + "currentHandle": "Current Handle", + "newHandle": "New Handle", + "sourcePds": "This PDS", + "targetPds": "Target PDS", + "confirm": "I confirm I want to migrate my account", + "startMigration": "Start Migration" + }, + "migrating": { + "title": "Migrating Your Account", + "desc": "Please wait while we transfer your data..." + }, + "plcToken": { + "title": "Verify Your Identity", + "desc": "A verification code has been sent to your email." + }, + "finalizing": { + "title": "Finalizing Migration", + "desc": "Please wait while we complete the migration...", + "updatingForwarding": "Updating DID document forwarding..." + }, + "success": { + "title": "Migration Complete!", + "desc": "Your account has been successfully migrated to your new PDS.", + "newHandle": "New Handle", + "newPds": "New PDS", + "nextSteps": "Next Steps", + "nextSteps1": "Sign in to your new PDS", + "nextSteps2": "Update any apps with your new credentials", + "nextSteps3": "Your followers will automatically see your new location", + "loggingOut": "Logging you out in {seconds} seconds..." + } + }, + "progress": { + "repoExported": "Repository exported", + "repoImported": "Repository imported", + "blobsMigrated": "{count} blobs migrated", + "prefsMigrated": "Preferences migrated", + "plcSigned": "Identity updated", + "activated": "Account activated", + "deactivated": "Old account deactivated" + }, + "errors": { + "connectionFailed": "Could not connect to PDS", + "invalidCredentials": "Invalid credentials", + "twoFactorRequired": "Two-factor authentication required", + "accountExists": "Account already exists on target PDS", + "plcFailed": "PLC operation failed", + "blobFailed": "Failed to migrate blob: {cid}", + "networkError": "Network error. Please try again." + } } } diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 6fdebad..7060827 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -1007,5 +1007,206 @@ "permissionsLimitedDesc": "Todelliset oikeutesi rajoitetaan {level}-käyttöoikeustasoosi riippumatta siitä, mitä sovellus pyytää.", "viewerLimitedDesc": "Katselijana sinulla on vain lukuoikeus. Tämä sovellus ei voi luoda, muokata tai poistaa sisältöä tällä tilillä.", "editorLimitedDesc": "Muokkaajana voit luoda ja muokata sisältöä, mutta et voi hallita tilin asetuksia tai tietoturvaa." + }, + "migration": { + "title": "Tilin siirto", + "subtitle": "Siirrä AT Protocol -identiteettisi palvelimien välillä", + "navTitle": "Siirto", + "navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä", + "migrateHere": "Siirrä tänne", + "migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.", + "migrateAway": "Siirrä pois", + "migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.", + "loginRequired": "Kirjautuminen vaaditaan", + "bringDid": "Tuo DID ja identiteettisi", + "transferData": "Siirrä kaikki tietosi", + "keepFollowers": "Säilytä seuraajasi", + "exportRepo": "Vie tietovarastosi", + "transferToPds": "Siirrä uuteen PDS:ään", + "updateIdentity": "Päivitä identiteettisi", + "whatIsMigration": "Mikä on tilin siirto?", + "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.", + "beforeMigrate": "Ennen siirtoa", + "beforeMigrate1": "Tarvitset nykyisen tilisi tunnukset", + "beforeMigrate2": "Siirto vaatii sähköpostivahvistuksen turvallisuussyistä", + "beforeMigrate3": "Suuret tilit, joissa on paljon kuvia, voivat kestää useita minuutteja", + "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", + "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ä.", + "learnMore": "Lue lisää siirron riskeistä", + "resume": { + "title": "Jatka siirtoa?", + "incomplete": "Sinulla on keskeneräinen siirto:", + "direction": "Suunta", + "migratingHere": "Siirretään tänne", + "migratingAway": "Siirretään pois", + "from": "Mistä", + "to": "Minne", + "progress": "Edistyminen", + "reenterCredentials": "Sinun täytyy syöttää tunnuksesi uudelleen jatkaaksesi.", + "startOver": "Aloita alusta", + "resumeButton": "Jatka" + }, + "inbound": { + "welcome": { + "title": "Siirrä tähän PDS:ään", + "desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.", + "understand": "Ymmärrän riskit ja haluan jatkaa" + }, + "sourceLogin": { + "title": "Kirjaudu nykyiseen PDS:ääsi", + "desc": "Syötä siirrettävän tilin tunnukset.", + "handle": "Käyttäjätunnus", + "handlePlaceholder": "sinä.bsky.social", + "password": "Salasana", + "twoFactorCode": "Kaksivaiheinen koodi", + "twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan", + "signIn": "Kirjaudu ja jatka" + }, + "chooseHandle": { + "title": "Valitse uusi käyttäjätunnuksesi", + "desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.", + "handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}" + }, + "review": { + "title": "Tarkista siirto", + "desc": "Tarkista ja vahvista siirtotietosi.", + "currentHandle": "Nykyinen käyttäjätunnus", + "newHandle": "Uusi käyttäjätunnus", + "sourcePds": "Lähde-PDS", + "targetPds": "Tämä PDS", + "email": "Sähköposti", + "inviteCode": "Kutsukoodi", + "confirm": "Vahvistan haluavani siirtää tilini", + "startMigration": "Aloita siirto" + }, + "migrating": { + "title": "Siirretään tiliäsi", + "desc": "Odota, kun siirrämme tietojasi...", + "gettingServiceAuth": "Haetaan palveluvaltuutusta...", + "creatingAccount": "Luodaan tiliä uuteen PDS:ään...", + "exportingRepo": "Viedään tietovarastoa...", + "importingRepo": "Tuodaan tietovarastoa...", + "countingBlobs": "Lasketaan blob-tiedostoja...", + "migratingBlobs": "Siirretään blob-tiedostoja ({current}/{total})...", + "migratingPrefs": "Siirretään asetuksia...", + "requestingPlc": "Pyydetään PLC-toimintoa..." + }, + "emailVerify": { + "title": "Vahvista sähköpostisi", + "desc": "Vahvistuskoodi on lähetetty osoitteeseen {email}.", + "hint": "Syötä koodi alle tai klikkaa sähköpostissa olevaa linkkiä jatkaaksesi automaattisesti.", + "tokenLabel": "Vahvistuskoodi", + "tokenPlaceholder": "Syötä sähköpostista saatu koodi", + "resend": "Lähetä koodi uudelleen", + "verify": "Vahvista sähköposti", + "verifying": "Vahvistetaan..." + }, + "plcToken": { + "title": "Vahvista henkilöllisyytesi", + "desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.", + "tokenLabel": "Vahvistuskoodi", + "tokenPlaceholder": "Syötä sähköpostista saatu koodi", + "resend": "Lähetä uudelleen", + "resending": "Lähetetään..." + }, + "finalizing": { + "title": "Viimeistellään siirtoa", + "desc": "Odota, kun viimeistelemme siirtoa...", + "signingPlc": "Allekirjoita identiteettipäivitys", + "activating": "Aktivoi tili uudessa PDS:ssä", + "deactivating": "Deaktivoi tili vanhassa PDS:ssä" + }, + "success": { + "title": "Siirto valmis!", + "desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.", + "newHandle": "Uusi käyttäjätunnus", + "did": "DID", + "goToDashboard": "Siirry hallintapaneeliin" + } + }, + "outbound": { + "welcome": { + "title": "Siirrä pois tästä PDS:stä", + "desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.", + "warning": "Siirron jälkeen tilisi täällä deaktivoidaan.", + "didWebNotice": "did:web-siirtoilmoitus", + "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ä.", + "understand": "Ymmärrän riskit ja haluan jatkaa" + }, + "targetPds": { + "title": "Valitse kohde-PDS", + "desc": "Syötä sen PDS:n URL, johon haluat siirtyä.", + "url": "PDS URL", + "urlPlaceholder": "https://pds.example.com", + "validate": "Vahvista ja jatka", + "validating": "Vahvistetaan...", + "connected": "Yhdistetty: {name}", + "inviteRequired": "Kutsukoodi vaaditaan", + "privacyPolicy": "Tietosuojakäytäntö", + "termsOfService": "Käyttöehdot" + }, + "newAccount": { + "title": "Uuden tilin tiedot", + "desc": "Määritä tilisi uudessa PDS:ssä.", + "handle": "Käyttäjätunnus", + "availableDomains": "Käytettävissä olevat verkkotunnukset", + "email": "Sähköposti", + "password": "Salasana", + "confirmPassword": "Vahvista salasana", + "inviteCode": "Kutsukoodi" + }, + "review": { + "title": "Tarkista siirto", + "desc": "Tarkista ja vahvista siirtotietosi.", + "currentHandle": "Nykyinen käyttäjätunnus", + "newHandle": "Uusi käyttäjätunnus", + "sourcePds": "Tämä PDS", + "targetPds": "Kohde-PDS", + "confirm": "Vahvistan haluavani siirtää tilini", + "startMigration": "Aloita siirto" + }, + "migrating": { + "title": "Siirretään tiliäsi", + "desc": "Odota, kun siirrämme tietojasi..." + }, + "plcToken": { + "title": "Vahvista henkilöllisyytesi", + "desc": "Vahvistuskoodi on lähetetty sähköpostiisi." + }, + "finalizing": { + "title": "Viimeistellään siirtoa", + "desc": "Odota, kun viimeistelemme siirtoa...", + "updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..." + }, + "success": { + "title": "Siirto valmis!", + "desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.", + "newHandle": "Uusi käyttäjätunnus", + "newPds": "Uusi PDS", + "nextSteps": "Seuraavat vaiheet", + "nextSteps1": "Kirjaudu uuteen PDS:ääsi", + "nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi", + "nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi", + "loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..." + } + }, + "progress": { + "repoExported": "Tietovarasto viety", + "repoImported": "Tietovarasto tuotu", + "blobsMigrated": "{count} blob-tiedostoa siirretty", + "prefsMigrated": "Asetukset siirretty", + "plcSigned": "Identiteetti päivitetty", + "activated": "Tili aktivoitu", + "deactivated": "Vanha tili deaktivoitu" + }, + "errors": { + "connectionFailed": "Yhteys PDS:ään epäonnistui", + "invalidCredentials": "Virheelliset tunnukset", + "twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan", + "accountExists": "Tili on jo olemassa kohde-PDS:ssä", + "plcFailed": "PLC-toiminto epäonnistui", + "blobFailed": "Blob-tiedoston siirto epäonnistui: {cid}", + "networkError": "Verkkovirhe. Yritä uudelleen." + } } } diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index c2e7c6c..edef215 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -1029,5 +1029,206 @@ "permissionsLimitedDesc": "アプリが何を要求しても、実際の権限は{level}アクセスレベルに制限されます。", "viewerLimitedDesc": "閲覧者として、読み取り専用アクセスのみ可能です。このアプリはこのアカウントでコンテンツの作成、更新、削除ができません。", "editorLimitedDesc": "編集者として、コンテンツの作成と編集が可能ですが、アカウント設定やセキュリティの管理はできません。" + }, + "migration": { + "title": "アカウント移行", + "subtitle": "AT Protocolアイデンティティをサーバー間で移動", + "navTitle": "移行", + "navDesc": "別のPDSへ、または別のPDSからアカウントを移動", + "migrateHere": "ここに移行", + "migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。", + "migrateAway": "別の場所に移行", + "migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。", + "loginRequired": "ログインが必要です", + "bringDid": "DIDとアイデンティティを持ち込む", + "transferData": "すべてのデータを転送", + "keepFollowers": "フォロワーを維持", + "exportRepo": "リポジトリをエクスポート", + "transferToPds": "新しいPDSに転送", + "updateIdentity": "アイデンティティを更新", + "whatIsMigration": "アカウント移行とは?", + "whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。", + "beforeMigrate": "移行前の確認事項", + "beforeMigrate1": "現在のアカウント認証情報が必要です", + "beforeMigrate2": "セキュリティのためメール認証が必要です", + "beforeMigrate3": "画像が多い大きなアカウントは数分かかる場合があります", + "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", + "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", + "learnMore": "移行のリスクについて詳しく", + "resume": { + "title": "移行を再開しますか?", + "incomplete": "未完了の移行があります:", + "direction": "方向", + "migratingHere": "ここに移行中", + "migratingAway": "別の場所に移行中", + "from": "移行元", + "to": "移行先", + "progress": "進行状況", + "reenterCredentials": "続行するには認証情報を再入力する必要があります。", + "startOver": "最初からやり直す", + "resumeButton": "再開" + }, + "inbound": { + "welcome": { + "title": "このPDSに移行", + "desc": "既存のAT Protocolアカウントをこのサーバーに移動します。", + "understand": "リスクを理解し、続行します" + }, + "sourceLogin": { + "title": "現在のPDSにサインイン", + "desc": "移行するアカウントの認証情報を入力してください。", + "handle": "ハンドル", + "handlePlaceholder": "you.bsky.social", + "password": "パスワード", + "twoFactorCode": "2要素認証コード", + "twoFactorRequired": "2要素認証が必要です", + "signIn": "サインインして続行" + }, + "chooseHandle": { + "title": "新しいハンドルを選択", + "desc": "このPDSでのアカウントのハンドルを選択してください。", + "handleHint": "完全なハンドル: @{handle}" + }, + "review": { + "title": "移行の確認", + "desc": "移行の詳細を確認してください。", + "currentHandle": "現在のハンドル", + "newHandle": "新しいハンドル", + "sourcePds": "移行元PDS", + "targetPds": "このPDS", + "email": "メール", + "inviteCode": "招待コード", + "confirm": "アカウントを移行することを確認します", + "startMigration": "移行を開始" + }, + "migrating": { + "title": "アカウントを移行中", + "desc": "データを転送しています...", + "gettingServiceAuth": "サービス認証を取得中...", + "creatingAccount": "新しいPDSにアカウントを作成中...", + "exportingRepo": "リポジトリをエクスポート中...", + "importingRepo": "リポジトリをインポート中...", + "countingBlobs": "blobをカウント中...", + "migratingBlobs": "blobを移行中 ({current}/{total})...", + "migratingPrefs": "設定を移行中...", + "requestingPlc": "PLC操作をリクエスト中..." + }, + "emailVerify": { + "title": "メールアドレスを確認", + "desc": "確認コードが {email} に送信されました。", + "hint": "下記にコードを入力するか、メール内のリンクをクリックして自動的に続行できます。", + "tokenLabel": "確認コード", + "tokenPlaceholder": "メールに記載されたコードを入力", + "resend": "コードを再送信", + "verify": "メールを確認", + "verifying": "確認中..." + }, + "plcToken": { + "title": "本人確認", + "desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。", + "tokenLabel": "確認トークン", + "tokenPlaceholder": "メールに記載されたトークンを入力", + "resend": "再送信", + "resending": "送信中..." + }, + "finalizing": { + "title": "移行を完了中", + "desc": "移行を完了しています...", + "signingPlc": "アイデンティティ更新に署名", + "activating": "新しいPDSでアカウントを有効化", + "deactivating": "古いPDSでアカウントを無効化" + }, + "success": { + "title": "移行完了!", + "desc": "アカウントはこのPDSに正常に移行されました。", + "newHandle": "新しいハンドル", + "did": "DID", + "goToDashboard": "ダッシュボードへ" + } + }, + "outbound": { + "welcome": { + "title": "このPDSから移行", + "desc": "アカウントを別のパーソナルデータサーバーに移動します。", + "warning": "移行後、ここでのアカウントは無効化されます。", + "didWebNotice": "did:web移行のお知らせ", + "didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。", + "understand": "リスクを理解し、続行します" + }, + "targetPds": { + "title": "移行先PDSを選択", + "desc": "移行先のPDSのURLを入力してください。", + "url": "PDS URL", + "urlPlaceholder": "https://pds.example.com", + "validate": "検証して続行", + "validating": "検証中...", + "connected": "{name}に接続しました", + "inviteRequired": "招待コードが必要です", + "privacyPolicy": "プライバシーポリシー", + "termsOfService": "利用規約" + }, + "newAccount": { + "title": "新しいアカウントの詳細", + "desc": "新しいPDSでアカウントを設定します。", + "handle": "ハンドル", + "availableDomains": "利用可能なドメイン", + "email": "メール", + "password": "パスワード", + "confirmPassword": "パスワードを確認", + "inviteCode": "招待コード" + }, + "review": { + "title": "移行の確認", + "desc": "移行の詳細を確認してください。", + "currentHandle": "現在のハンドル", + "newHandle": "新しいハンドル", + "sourcePds": "このPDS", + "targetPds": "移行先PDS", + "confirm": "アカウントを移行することを確認します", + "startMigration": "移行を開始" + }, + "migrating": { + "title": "アカウントを移行中", + "desc": "データを転送しています..." + }, + "plcToken": { + "title": "本人確認", + "desc": "確認コードがメールに送信されました。" + }, + "finalizing": { + "title": "移行を完了中", + "desc": "移行を完了しています...", + "updatingForwarding": "DIDドキュメントの転送先を更新中..." + }, + "success": { + "title": "移行完了!", + "desc": "アカウントは新しいPDSに正常に移行されました。", + "newHandle": "新しいハンドル", + "newPds": "新しいPDS", + "nextSteps": "次のステップ", + "nextSteps1": "新しいPDSにサインイン", + "nextSteps2": "アプリの認証情報を更新", + "nextSteps3": "フォロワーは自動的に新しい場所を確認できます", + "loggingOut": "{seconds}秒後にログアウトします..." + } + }, + "progress": { + "repoExported": "リポジトリをエクスポートしました", + "repoImported": "リポジトリをインポートしました", + "blobsMigrated": "{count}個のblobを移行しました", + "prefsMigrated": "設定を移行しました", + "plcSigned": "アイデンティティを更新しました", + "activated": "アカウントを有効化しました", + "deactivated": "古いアカウントを無効化しました" + }, + "errors": { + "connectionFailed": "PDSに接続できませんでした", + "invalidCredentials": "認証情報が無効です", + "twoFactorRequired": "2要素認証が必要です", + "accountExists": "移行先PDSにアカウントが既に存在します", + "plcFailed": "PLC操作に失敗しました", + "blobFailed": "blobの移行に失敗しました: {cid}", + "networkError": "ネットワークエラー。再試行してください。" + } } } diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index dc60d5e..e71bcad 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -1029,5 +1029,206 @@ "permissionsLimitedDesc": "앱이 무엇을 요청하든 실제 권한은 {level} 액세스 수준으로 제한됩니다.", "viewerLimitedDesc": "뷰어로서 읽기 전용 액세스 권한만 있습니다. 이 앱은 이 계정에서 콘텐츠를 생성, 수정 또는 삭제할 수 없습니다.", "editorLimitedDesc": "편집자로서 콘텐츠를 생성하고 편집할 수 있지만 계정 설정이나 보안을 관리할 수 없습니다." + }, + "migration": { + "title": "계정 마이그레이션", + "subtitle": "AT Protocol 아이덴티티를 서버 간에 이동", + "navTitle": "마이그레이션", + "navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동", + "migrateHere": "여기로 마이그레이션", + "migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.", + "migrateAway": "다른 곳으로 마이그레이션", + "migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.", + "loginRequired": "로그인 필요", + "bringDid": "DID와 아이덴티티 가져오기", + "transferData": "모든 데이터 전송", + "keepFollowers": "팔로워 유지", + "exportRepo": "저장소 내보내기", + "transferToPds": "새 PDS로 전송", + "updateIdentity": "아이덴티티 업데이트", + "whatIsMigration": "계정 마이그레이션이란?", + "whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.", + "beforeMigrate": "마이그레이션 전 확인사항", + "beforeMigrate1": "현재 계정 인증 정보가 필요합니다", + "beforeMigrate2": "보안을 위해 이메일 인증이 필요합니다", + "beforeMigrate3": "이미지가 많은 대용량 계정은 몇 분이 걸릴 수 있습니다", + "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", + "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", + "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", + "resume": { + "title": "마이그레이션을 재개하시겠습니까?", + "incomplete": "완료되지 않은 마이그레이션이 있습니다:", + "direction": "방향", + "migratingHere": "여기로 마이그레이션 중", + "migratingAway": "다른 곳으로 마이그레이션 중", + "from": "출발지", + "to": "목적지", + "progress": "진행 상황", + "reenterCredentials": "계속하려면 인증 정보를 다시 입력해야 합니다.", + "startOver": "처음부터 다시 시작", + "resumeButton": "재개" + }, + "inbound": { + "welcome": { + "title": "이 PDS로 마이그레이션", + "desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.", + "understand": "위험을 이해하고 계속 진행합니다" + }, + "sourceLogin": { + "title": "현재 PDS에 로그인", + "desc": "마이그레이션할 계정의 인증 정보를 입력하세요.", + "handle": "핸들", + "handlePlaceholder": "you.bsky.social", + "password": "비밀번호", + "twoFactorCode": "2단계 인증 코드", + "twoFactorRequired": "2단계 인증이 필요합니다", + "signIn": "로그인 및 계속" + }, + "chooseHandle": { + "title": "새 핸들 선택", + "desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.", + "handleHint": "전체 핸들: @{handle}" + }, + "review": { + "title": "마이그레이션 검토", + "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", + "currentHandle": "현재 핸들", + "newHandle": "새 핸들", + "sourcePds": "소스 PDS", + "targetPds": "이 PDS", + "email": "이메일", + "inviteCode": "초대 코드", + "confirm": "계정 마이그레이션을 확인합니다", + "startMigration": "마이그레이션 시작" + }, + "migrating": { + "title": "계정 마이그레이션 중", + "desc": "데이터를 전송하는 중입니다...", + "gettingServiceAuth": "서비스 인증 획득 중...", + "creatingAccount": "새 PDS에 계정 생성 중...", + "exportingRepo": "저장소 내보내기 중...", + "importingRepo": "저장소 가져오기 중...", + "countingBlobs": "blob 개수 세는 중...", + "migratingBlobs": "blob 마이그레이션 중 ({current}/{total})...", + "migratingPrefs": "환경설정 마이그레이션 중...", + "requestingPlc": "PLC 작업 요청 중..." + }, + "emailVerify": { + "title": "이메일 인증", + "desc": "인증 코드가 {email}(으)로 전송되었습니다.", + "hint": "아래에 코드를 입력하거나, 이메일의 링크를 클릭하여 자동으로 계속할 수 있습니다.", + "tokenLabel": "인증 코드", + "tokenPlaceholder": "이메일에서 받은 코드 입력", + "resend": "코드 재전송", + "verify": "이메일 인증", + "verifying": "인증 중..." + }, + "plcToken": { + "title": "신원 확인", + "desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.", + "tokenLabel": "인증 토큰", + "tokenPlaceholder": "이메일에서 받은 토큰 입력", + "resend": "재전송", + "resending": "전송 중..." + }, + "finalizing": { + "title": "마이그레이션 완료 중", + "desc": "마이그레이션을 완료하는 중입니다...", + "signingPlc": "아이덴티티 업데이트 서명", + "activating": "새 PDS에서 계정 활성화", + "deactivating": "이전 PDS에서 계정 비활성화" + }, + "success": { + "title": "마이그레이션 완료!", + "desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.", + "newHandle": "새 핸들", + "did": "DID", + "goToDashboard": "대시보드로 이동" + } + }, + "outbound": { + "welcome": { + "title": "이 PDS에서 마이그레이션", + "desc": "계정을 다른 개인 데이터 서버로 이동합니다.", + "warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.", + "didWebNotice": "did:web 마이그레이션 알림", + "didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.", + "understand": "위험을 이해하고 계속 진행합니다" + }, + "targetPds": { + "title": "대상 PDS 선택", + "desc": "마이그레이션할 PDS의 URL을 입력하세요.", + "url": "PDS URL", + "urlPlaceholder": "https://pds.example.com", + "validate": "확인 및 계속", + "validating": "확인 중...", + "connected": "{name}에 연결됨", + "inviteRequired": "초대 코드 필요", + "privacyPolicy": "개인정보 처리방침", + "termsOfService": "서비스 약관" + }, + "newAccount": { + "title": "새 계정 세부 정보", + "desc": "새 PDS에서 계정을 설정합니다.", + "handle": "핸들", + "availableDomains": "사용 가능한 도메인", + "email": "이메일", + "password": "비밀번호", + "confirmPassword": "비밀번호 확인", + "inviteCode": "초대 코드" + }, + "review": { + "title": "마이그레이션 검토", + "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", + "currentHandle": "현재 핸들", + "newHandle": "새 핸들", + "sourcePds": "이 PDS", + "targetPds": "대상 PDS", + "confirm": "계정 마이그레이션을 확인합니다", + "startMigration": "마이그레이션 시작" + }, + "migrating": { + "title": "계정 마이그레이션 중", + "desc": "데이터를 전송하는 중입니다..." + }, + "plcToken": { + "title": "신원 확인", + "desc": "이메일로 인증 코드가 전송되었습니다." + }, + "finalizing": { + "title": "마이그레이션 완료 중", + "desc": "마이그레이션을 완료하는 중입니다...", + "updatingForwarding": "DID 문서 포워딩 업데이트 중..." + }, + "success": { + "title": "마이그레이션 완료!", + "desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.", + "newHandle": "새 핸들", + "newPds": "새 PDS", + "nextSteps": "다음 단계", + "nextSteps1": "새 PDS에 로그인", + "nextSteps2": "새 인증 정보로 앱 업데이트", + "nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다", + "loggingOut": "{seconds}초 후 로그아웃됩니다..." + } + }, + "progress": { + "repoExported": "저장소 내보내기 완료", + "repoImported": "저장소 가져오기 완료", + "blobsMigrated": "{count}개 blob 마이그레이션됨", + "prefsMigrated": "환경설정 마이그레이션됨", + "plcSigned": "아이덴티티 업데이트됨", + "activated": "계정 활성화됨", + "deactivated": "이전 계정 비활성화됨" + }, + "errors": { + "connectionFailed": "PDS에 연결할 수 없습니다", + "invalidCredentials": "잘못된 인증 정보", + "twoFactorRequired": "2단계 인증이 필요합니다", + "accountExists": "대상 PDS에 계정이 이미 존재합니다", + "plcFailed": "PLC 작업 실패", + "blobFailed": "blob 마이그레이션 실패: {cid}", + "networkError": "네트워크 오류. 다시 시도하세요." + } } } diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index ea2bc07..cc0bb30 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -1029,5 +1029,206 @@ "permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.", "viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.", "editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet." + }, + "migration": { + "title": "Kontoflyttning", + "subtitle": "Flytta din AT Protocol-identitet mellan servrar", + "navTitle": "Flytta", + "navDesc": "Flytta ditt konto till eller från en annan PDS", + "migrateHere": "Flytta hit", + "migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.", + "migrateAway": "Flytta bort", + "migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.", + "loginRequired": "Inloggning krävs", + "bringDid": "Ta med din DID och identitet", + "transferData": "Överför all din data", + "keepFollowers": "Behåll dina följare", + "exportRepo": "Exportera ditt arkiv", + "transferToPds": "Överför till ny PDS", + "updateIdentity": "Uppdatera din identitet", + "whatIsMigration": "Vad är kontoflyttning?", + "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.", + "beforeMigrate": "Innan du flyttar", + "beforeMigrate1": "Du behöver dina nuvarande kontouppgifter", + "beforeMigrate2": "Flytt kräver e-postverifiering för säkerhet", + "beforeMigrate3": "Stora konton med många bilder kan ta flera minuter", + "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", + "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.", + "learnMore": "Läs mer om flyttningsrisker", + "resume": { + "title": "Återuppta flytt?", + "incomplete": "Du har en ofullständig flytt pågående:", + "direction": "Riktning", + "migratingHere": "Flyttar hit", + "migratingAway": "Flyttar bort", + "from": "Från", + "to": "Till", + "progress": "Framsteg", + "reenterCredentials": "Du måste ange dina uppgifter igen för att fortsätta.", + "startOver": "Börja om", + "resumeButton": "Återuppta" + }, + "inbound": { + "welcome": { + "title": "Flytta till denna PDS", + "desc": "Flytta ditt befintliga AT Protocol-konto till denna server.", + "understand": "Jag förstår riskerna och vill fortsätta" + }, + "sourceLogin": { + "title": "Logga in på din nuvarande PDS", + "desc": "Ange uppgifterna för kontot du vill flytta.", + "handle": "Användarnamn", + "handlePlaceholder": "du.bsky.social", + "password": "Lösenord", + "twoFactorCode": "Tvåfaktorkod", + "twoFactorRequired": "Tvåfaktorautentisering krävs", + "signIn": "Logga in och fortsätt" + }, + "chooseHandle": { + "title": "Välj ditt nya användarnamn", + "desc": "Välj ett användarnamn för ditt konto på denna PDS.", + "handleHint": "Ditt fullständiga användarnamn blir: @{handle}" + }, + "review": { + "title": "Granska flytt", + "desc": "Granska och bekräfta dina flyttdetaljer.", + "currentHandle": "Nuvarande användarnamn", + "newHandle": "Nytt användarnamn", + "sourcePds": "Käll-PDS", + "targetPds": "Denna PDS", + "email": "E-post", + "inviteCode": "Inbjudningskod", + "confirm": "Jag bekräftar att jag vill flytta mitt konto", + "startMigration": "Starta flytt" + }, + "migrating": { + "title": "Flyttar ditt konto", + "desc": "Vänta medan vi överför din data...", + "gettingServiceAuth": "Hämtar tjänstauktorisering...", + "creatingAccount": "Skapar konto på ny PDS...", + "exportingRepo": "Exporterar arkiv...", + "importingRepo": "Importerar arkiv...", + "countingBlobs": "Räknar blobbar...", + "migratingBlobs": "Flyttar blobbar ({current}/{total})...", + "migratingPrefs": "Flyttar inställningar...", + "requestingPlc": "Begär PLC-operation..." + }, + "emailVerify": { + "title": "Verifiera din e-post", + "desc": "En verifieringskod har skickats till {email}.", + "hint": "Ange koden nedan eller klicka på länken i e-postmeddelandet för att fortsätta automatiskt.", + "tokenLabel": "Verifieringskod", + "tokenPlaceholder": "Ange kod från e-post", + "resend": "Skicka kod igen", + "verify": "Verifiera e-post", + "verifying": "Verifierar..." + }, + "plcToken": { + "title": "Verifiera din identitet", + "desc": "En verifieringskod har skickats till din e-post på din nuvarande PDS.", + "tokenLabel": "Verifieringstoken", + "tokenPlaceholder": "Ange token från din e-post", + "resend": "Skicka igen", + "resending": "Skickar..." + }, + "finalizing": { + "title": "Slutför flytt", + "desc": "Vänta medan vi slutför flytten...", + "signingPlc": "Signera identitetsuppdatering", + "activating": "Aktivera konto på ny PDS", + "deactivating": "Inaktivera konto på gammal PDS" + }, + "success": { + "title": "Flytt klar!", + "desc": "Ditt konto har framgångsrikt flyttats till denna PDS.", + "newHandle": "Nytt användarnamn", + "did": "DID", + "goToDashboard": "Gå till instrumentpanel" + } + }, + "outbound": { + "welcome": { + "title": "Flytta från denna PDS", + "desc": "Flytta ditt konto till en annan personlig dataserver.", + "warning": "Efter flytten kommer ditt konto här att inaktiveras.", + "didWebNotice": "did:web-flyttmeddelande", + "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.", + "understand": "Jag förstår riskerna och vill fortsätta" + }, + "targetPds": { + "title": "Välj mål-PDS", + "desc": "Ange URL:en för PDS du vill flytta till.", + "url": "PDS URL", + "urlPlaceholder": "https://pds.example.com", + "validate": "Validera och fortsätt", + "validating": "Validerar...", + "connected": "Ansluten till {name}", + "inviteRequired": "Inbjudningskod krävs", + "privacyPolicy": "Integritetspolicy", + "termsOfService": "Användarvillkor" + }, + "newAccount": { + "title": "Nya kontouppgifter", + "desc": "Konfigurera ditt konto på den nya PDS.", + "handle": "Användarnamn", + "availableDomains": "Tillgängliga domäner", + "email": "E-post", + "password": "Lösenord", + "confirmPassword": "Bekräfta lösenord", + "inviteCode": "Inbjudningskod" + }, + "review": { + "title": "Granska flytt", + "desc": "Granska och bekräfta dina flyttdetaljer.", + "currentHandle": "Nuvarande användarnamn", + "newHandle": "Nytt användarnamn", + "sourcePds": "Denna PDS", + "targetPds": "Mål-PDS", + "confirm": "Jag bekräftar att jag vill flytta mitt konto", + "startMigration": "Starta flytt" + }, + "migrating": { + "title": "Flyttar ditt konto", + "desc": "Vänta medan vi överför din data..." + }, + "plcToken": { + "title": "Verifiera din identitet", + "desc": "En verifieringskod har skickats till din e-post." + }, + "finalizing": { + "title": "Slutför flytt", + "desc": "Vänta medan vi slutför flytten...", + "updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..." + }, + "success": { + "title": "Flytt klar!", + "desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.", + "newHandle": "Nytt användarnamn", + "newPds": "Ny PDS", + "nextSteps": "Nästa steg", + "nextSteps1": "Logga in på din nya PDS", + "nextSteps2": "Uppdatera dina appar med nya uppgifter", + "nextSteps3": "Dina följare kommer automatiskt se din nya plats", + "loggingOut": "Loggar ut om {seconds} sekunder..." + } + }, + "progress": { + "repoExported": "Arkiv exporterat", + "repoImported": "Arkiv importerat", + "blobsMigrated": "{count} blobbar flyttade", + "prefsMigrated": "Inställningar flyttade", + "plcSigned": "Identitet uppdaterad", + "activated": "Konto aktiverat", + "deactivated": "Gammalt konto inaktiverat" + }, + "errors": { + "connectionFailed": "Kunde inte ansluta till PDS", + "invalidCredentials": "Ogiltiga uppgifter", + "twoFactorRequired": "Tvåfaktorautentisering krävs", + "accountExists": "Konto finns redan på mål-PDS", + "plcFailed": "PLC-operation misslyckades", + "blobFailed": "Kunde inte flytta blob: {cid}", + "networkError": "Nätverksfel. Försök igen." + } } } diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 4fcca43..c048f2e 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -1013,5 +1013,206 @@ "permissionsLimitedDesc": "无论应用请求什么权限,您的实际权限将限制在{level}访问级别。", "viewerLimitedDesc": "作为查看者,您只有只读权限。此应用无法在此账户上创建、更新或删除内容。", "editorLimitedDesc": "作为编辑者,您可以创建和编辑内容,但无法管理账户设置或安全选项。" + }, + "migration": { + "title": "账户迁移", + "subtitle": "在服务器之间移动您的AT Protocol身份", + "navTitle": "迁移", + "navDesc": "将您的账户移至其他PDS或从其他PDS移入", + "migrateHere": "迁移到此处", + "migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。", + "migrateAway": "迁移离开", + "migrateAwayDesc": "将您的账户从此PDS移至其他服务器。", + "loginRequired": "需要登录", + "bringDid": "携带您的DID和身份", + "transferData": "转移所有数据", + "keepFollowers": "保留您的关注者", + "exportRepo": "导出您的存储库", + "transferToPds": "转移到新PDS", + "updateIdentity": "更新您的身份", + "whatIsMigration": "什么是账户迁移?", + "whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。", + "beforeMigrate": "迁移前须知", + "beforeMigrate1": "您需要当前账户的凭据", + "beforeMigrate2": "为确保安全,迁移需要邮箱验证", + "beforeMigrate3": "包含大量图片的大型账户可能需要几分钟", + "beforeMigrate4": "您的旧PDS将收到账户停用通知", + "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", + "learnMore": "了解更多迁移风险", + "resume": { + "title": "恢复迁移?", + "incomplete": "您有一个未完成的迁移:", + "direction": "方向", + "migratingHere": "正在迁移到此处", + "migratingAway": "正在迁移离开", + "from": "从", + "to": "到", + "progress": "进度", + "reenterCredentials": "您需要重新输入凭据以继续。", + "startOver": "重新开始", + "resumeButton": "恢复" + }, + "inbound": { + "welcome": { + "title": "迁移到此PDS", + "desc": "将您现有的AT Protocol账户移至此服务器。", + "understand": "我了解风险并希望继续" + }, + "sourceLogin": { + "title": "登录到您当前的PDS", + "desc": "输入您要迁移的账户凭据。", + "handle": "用户名", + "handlePlaceholder": "you.bsky.social", + "password": "密码", + "twoFactorCode": "双因素验证码", + "twoFactorRequired": "需要双因素认证", + "signIn": "登录并继续" + }, + "chooseHandle": { + "title": "选择新用户名", + "desc": "为您在此PDS上的账户选择用户名。", + "handleHint": "您的完整用户名将是:@{handle}" + }, + "review": { + "title": "检查迁移", + "desc": "请检查并确认您的迁移详情。", + "currentHandle": "当前用户名", + "newHandle": "新用户名", + "sourcePds": "源PDS", + "targetPds": "此PDS", + "email": "邮箱", + "inviteCode": "邀请码", + "confirm": "我确认要迁移我的账户", + "startMigration": "开始迁移" + }, + "migrating": { + "title": "正在迁移您的账户", + "desc": "请稍候,正在转移您的数据...", + "gettingServiceAuth": "正在获取服务授权...", + "creatingAccount": "正在新PDS上创建账户...", + "exportingRepo": "正在导出存储库...", + "importingRepo": "正在导入存储库...", + "countingBlobs": "正在统计blob...", + "migratingBlobs": "正在迁移blob ({current}/{total})...", + "migratingPrefs": "正在迁移偏好设置...", + "requestingPlc": "正在请求PLC操作..." + }, + "emailVerify": { + "title": "验证您的邮箱", + "desc": "验证码已发送至 {email}。", + "hint": "在下方输入验证码,或点击邮件中的链接自动继续。", + "tokenLabel": "验证码", + "tokenPlaceholder": "输入邮件中的验证码", + "resend": "重新发送", + "verify": "验证邮箱", + "verifying": "验证中..." + }, + "plcToken": { + "title": "验证您的身份", + "desc": "验证码已发送到您在当前PDS注册的邮箱。", + "tokenLabel": "验证令牌", + "tokenPlaceholder": "输入邮件中的令牌", + "resend": "重新发送", + "resending": "发送中..." + }, + "finalizing": { + "title": "正在完成迁移", + "desc": "请稍候,正在完成迁移...", + "signingPlc": "签署身份更新", + "activating": "在新PDS上激活账户", + "deactivating": "在旧PDS上停用账户" + }, + "success": { + "title": "迁移完成!", + "desc": "您的账户已成功迁移到此PDS。", + "newHandle": "新用户名", + "did": "DID", + "goToDashboard": "前往仪表板" + } + }, + "outbound": { + "welcome": { + "title": "从此PDS迁移离开", + "desc": "将您的账户移至另一个个人数据服务器。", + "warning": "迁移后,您在此处的账户将被停用。", + "didWebNotice": "did:web迁移通知", + "didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。", + "understand": "我了解风险并希望继续" + }, + "targetPds": { + "title": "选择目标PDS", + "desc": "输入您要迁移到的PDS的URL。", + "url": "PDS URL", + "urlPlaceholder": "https://pds.example.com", + "validate": "验证并继续", + "validating": "验证中...", + "connected": "已连接到 {name}", + "inviteRequired": "需要邀请码", + "privacyPolicy": "隐私政策", + "termsOfService": "服务条款" + }, + "newAccount": { + "title": "新账户详情", + "desc": "在新PDS上设置您的账户。", + "handle": "用户名", + "availableDomains": "可用域名", + "email": "邮箱", + "password": "密码", + "confirmPassword": "确认密码", + "inviteCode": "邀请码" + }, + "review": { + "title": "检查迁移", + "desc": "请检查并确认您的迁移详情。", + "currentHandle": "当前用户名", + "newHandle": "新用户名", + "sourcePds": "此PDS", + "targetPds": "目标PDS", + "confirm": "我确认要迁移我的账户", + "startMigration": "开始迁移" + }, + "migrating": { + "title": "正在迁移您的账户", + "desc": "请稍候,正在转移您的数据..." + }, + "plcToken": { + "title": "验证您的身份", + "desc": "验证码已发送到您的邮箱。" + }, + "finalizing": { + "title": "正在完成迁移", + "desc": "请稍候,正在完成迁移...", + "updatingForwarding": "正在更新DID文档转发..." + }, + "success": { + "title": "迁移完成!", + "desc": "您的账户已成功迁移到新PDS。", + "newHandle": "新用户名", + "newPds": "新PDS", + "nextSteps": "后续步骤", + "nextSteps1": "登录到您的新PDS", + "nextSteps2": "使用新凭据更新您的应用", + "nextSteps3": "您的关注者将自动看到您的新位置", + "loggingOut": "{seconds}秒后退出登录..." + } + }, + "progress": { + "repoExported": "存储库已导出", + "repoImported": "存储库已导入", + "blobsMigrated": "已迁移{count}个blob", + "prefsMigrated": "偏好设置已迁移", + "plcSigned": "身份已更新", + "activated": "账户已激活", + "deactivated": "旧账户已停用" + }, + "errors": { + "connectionFailed": "无法连接到PDS", + "invalidCredentials": "凭据无效", + "twoFactorRequired": "需要双因素认证", + "accountExists": "目标PDS上已存在账户", + "plcFailed": "PLC操作失败", + "blobFailed": "blob迁移失败:{cid}", + "networkError": "网络错误,请重试。" + } } } diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index e272f54..8f86589 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -190,6 +190,10 @@

{$_('dashboard.navDelegation')}

{$_('dashboard.navDelegationDesc')}

+ +

{$_('migration.navTitle')}

+

{$_('migration.navDesc')}

+
{#if auth.session.isAdmin}

{$_('dashboard.navAdmin')}

diff --git a/frontend/src/routes/Migration.svelte b/frontend/src/routes/Migration.svelte new file mode 100644 index 0000000..18a5aee --- /dev/null +++ b/frontend/src/routes/Migration.svelte @@ -0,0 +1,413 @@ + + +
+ {#if showResumeModal && resumeInfo} + + {/if} + + {#if direction === 'select'} + + +
+ + + +
+ +
+ + {:else if direction === 'inbound' && inboundFlow} + + + {:else if direction === 'outbound' && outboundFlow} + + {/if} +
+ + diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index 2ceea87..76f3654 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -173,7 +173,7 @@
{$_('register.migrateTitle')}

{$_('register.migrateDescription')}

- + {$_('register.migrateLink')} →
diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index d83d23d..754d943 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -449,6 +449,7 @@ pub struct VerificationMethods { #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct Services { + #[serde(rename = "atproto_pds")] pub atproto_pds: AtprotoPds, } diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index c1a50ef..a4674bc 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -295,7 +295,7 @@ pub async fn list_missing_blobs( .into_response(); } }; - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { Ok(user) => user, Err(_) => { return ( diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs index dfb03d1..15066fc 100644 --- a/src/api/repo/import.rs +++ b/src/api/repo/import.rs @@ -318,8 +318,10 @@ pub async fn import_repo( records.len(), did ); - if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { - warn!("Failed to sequence import event: {:?}", e); + if !is_migration { + if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { + warn!("Failed to sequence import event: {:?}", e); + } } (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 8ea3304..91d7641 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -1,6 +1,6 @@ use super::validation::validate_record; use super::write::has_verified_comms_channel; -use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; +use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; use crate::delegation::{self, DelegationActionType}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -295,6 +295,7 @@ pub async fn apply_writes( let mut results: Vec = Vec::new(); let mut ops: Vec = Vec::new(); let mut modified_keys: Vec = Vec::new(); + let mut all_blob_cids: Vec = Vec::new(); for write in &input.writes { match write { WriteOp::Create { @@ -307,6 +308,7 @@ pub async fn apply_writes( { return *err_response; } + all_blob_cids.extend(extract_blob_cids(value)); let rkey = rkey .clone() .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); @@ -359,6 +361,7 @@ pub async fn apply_writes( { return *err_response; } + all_blob_cids.extend(extract_blob_cids(value)); let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); @@ -468,6 +471,7 @@ pub async fn apply_writes( new_mst_root, ops, blocks_cids: &written_cids_str, + blobs: &all_blob_cids, }, ) .await diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs index ab9661c..2808bd7 100644 --- a/src/api/repo/record/delete.rs +++ b/src/api/repo/record/delete.rs @@ -168,6 +168,7 @@ pub async fn delete_record( new_mst_root, ops: vec![op], blocks_cids: &written_cids_str, + blobs: &[], }, ) .await diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index 9d74dba..28a2846 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -6,14 +6,47 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; +use base64::Engine; use cid::Cid; +use ipld_core::ipld::Ipld; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Map, Value}; use std::collections::HashMap; use std::str::FromStr; use tracing::{error, info}; +fn ipld_to_json(ipld: Ipld) -> Value { + match ipld { + Ipld::Null => Value::Null, + Ipld::Bool(b) => Value::Bool(b), + Ipld::Integer(i) => { + if let Ok(n) = i64::try_from(i) { + Value::Number(n.into()) + } else { + Value::String(i.to_string()) + } + } + Ipld::Float(f) => serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null), + Ipld::String(s) => Value::String(s), + Ipld::Bytes(b) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&b); + json!({ "$bytes": encoded }) + } + Ipld::List(arr) => Value::Array(arr.into_iter().map(ipld_to_json).collect()), + Ipld::Map(map) => { + let obj: Map = map + .into_iter() + .map(|(k, v)| (k, ipld_to_json(v))) + .collect(); + Value::Object(obj) + } + Ipld::Link(cid) => json!({ "$link": cid.to_string() }), + } +} + #[derive(Deserialize)] pub struct GetRecordInput { pub repo: String, @@ -163,7 +196,7 @@ pub async fn get_record( .into_response(); } }; - let value: serde_json::Value = match serde_ipld_dagcbor::from_slice(&block) { + let ipld: Ipld = match serde_ipld_dagcbor::from_slice(&block) { Ok(v) => v, Err(e) => { error!("Failed to deserialize record: {:?}", e); @@ -174,6 +207,7 @@ pub async fn get_record( .into_response(); } }; + let value = ipld_to_json(ipld); Json(json!({ "uri": format!("at://{}/{}/{}", input.repo, input.collection, input.rkey), "cid": record_cid_str, @@ -323,8 +357,9 @@ pub async fn list_records( for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) { if let Some(block) = block_opt && let Some((rkey, cid_str)) = cid_to_rkey.get(cid) - && let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) + && let Ok(ipld) = serde_ipld_dagcbor::from_slice::(&block) { + let value = ipld_to_json(ipld); records.push(json!({ "uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey), "cid": cid_str, diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs index 23cb1d4..31c2329 100644 --- a/src/api/repo/record/utils.rs +++ b/src/api/repo/record/utils.rs @@ -5,10 +5,39 @@ use jacquard::types::{integer::LimitedU32, string::Tid}; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use k256::ecdsa::SigningKey; -use serde_json::json; +use serde_json::{json, Value}; use std::str::FromStr; use uuid::Uuid; +pub fn extract_blob_cids(record: &Value) -> Vec { + let mut blobs = Vec::new(); + extract_blob_cids_recursive(record, &mut blobs); + blobs +} + +fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec) { + match value { + Value::Object(map) => { + if map.get("$type").and_then(|v| v.as_str()) == Some("blob") { + if let Some(ref_obj) = map.get("ref") { + if let Some(link) = ref_obj.get("$link").and_then(|v| v.as_str()) { + blobs.push(link.to_string()); + } + } + } + for v in map.values() { + extract_blob_cids_recursive(v, blobs); + } + } + Value::Array(arr) => { + for v in arr { + extract_blob_cids_recursive(v, blobs); + } + } + _ => {} + } +} + pub fn create_signed_commit( did: &str, data: Cid, @@ -63,6 +92,7 @@ pub struct CommitParams<'a> { pub new_mst_root: Cid, pub ops: Vec, pub blocks_cids: &'a [String], + pub blobs: &'a [String], } pub async fn commit_and_log( @@ -77,6 +107,7 @@ pub async fn commit_and_log( new_mst_root, ops, blocks_cids, + blobs, } = params; let key_row = sqlx::query!( "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", @@ -274,7 +305,7 @@ pub async fn commit_and_log( new_root_cid.to_string(), prev_cid_str, json!(ops_json), - &[] as &[String], + blobs, blocks_cids, prev_data_cid_str, ) @@ -368,6 +399,7 @@ pub async fn create_record_internal( } } let written_cids_str: Vec = written_cids.iter().map(|c| c.to_string()).collect(); + let blob_cids = extract_blob_cids(record); let result = commit_and_log( state, CommitParams { @@ -378,6 +410,7 @@ pub async fn create_record_internal( new_mst_root, ops: vec![op], blocks_cids: &written_cids_str, + blobs: &blob_cids, }, ) .await?; diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index 42d9f0b..980a0d4 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -1,5 +1,5 @@ use super::validation::validate_record; -use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; +use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; use crate::delegation::{self, DelegationActionType}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -334,6 +334,7 @@ pub async fn create_record( .iter() .map(|c| c.to_string()) .collect::>(); + let blob_cids = extract_blob_cids(&input.record); if let Err(e) = commit_and_log( &state, CommitParams { @@ -344,6 +345,7 @@ pub async fn create_record( new_mst_root, ops: vec![op], blocks_cids: &written_cids_str, + blobs: &blob_cids, }, ) .await @@ -582,6 +584,7 @@ pub async fn put_record( .map(|c| c.to_string()) .collect::>(); let is_update = existing_cid.is_some(); + let blob_cids = extract_blob_cids(&input.record); if let Err(e) = commit_and_log( &state, CommitParams { @@ -592,6 +595,7 @@ pub async fn put_record( new_mst_root, ops: vec![op], blocks_cids: &written_cids_str, + blobs: &blob_cids, }, ) .await diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index 34124cf..02b8894 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -1,4 +1,5 @@ use crate::api::ApiError; +use crate::plc::PlcClient; use crate::state::AppState; use axum::{ Json, @@ -8,6 +9,7 @@ use axum::{ }; use bcrypt::verify; use chrono::{Duration, Utc}; +use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, info, warn}; @@ -118,6 +120,185 @@ pub async fn check_account_status( .into_response() } +async fn assert_valid_did_document_for_service( + db: &sqlx::PgPool, + did: &str, +) -> Result<(), (StatusCode, Json)> { + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let expected_endpoint = format!("https://{}", hostname); + + if did.starts_with("did:plc:") { + let plc_client = PlcClient::new(None); + + let mut last_error = None; + let mut doc_data = None; + for attempt in 0..5 { + if attempt > 0 { + let delay_ms = 500 * (1 << (attempt - 1)); + info!( + "Waiting {}ms before retry {} for DID document validation ({})", + delay_ms, attempt, did + ); + tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; + } + + match plc_client.get_document_data(did).await { + Ok(data) => { + let pds_endpoint = data + .get("services") + .and_then(|s| s.get("atproto_pds").or_else(|| s.get("atprotoPds"))) + .and_then(|p| p.get("endpoint")) + .and_then(|e| e.as_str()); + + if pds_endpoint == Some(&expected_endpoint) { + doc_data = Some(data); + break; + } else { + info!( + "Attempt {}: DID {} has endpoint {:?}, expected {} - retrying", + attempt + 1, + did, + pds_endpoint, + expected_endpoint + ); + last_error = Some(format!( + "DID document endpoint {:?} does not match expected {}", + pds_endpoint, expected_endpoint + )); + } + } + Err(e) => { + warn!( + "Attempt {}: Failed to fetch PLC document for {}: {:?}", + attempt + 1, + did, + e + ); + last_error = Some(format!("Could not resolve DID document: {}", e)); + } + } + } + + let doc_data = match doc_data { + Some(d) => d, + None => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": last_error.unwrap_or_else(|| "DID document validation failed".to_string()) + })), + )); + } + }; + + let doc_signing_key = doc_data + .get("verificationMethods") + .and_then(|v| v.get("atproto")) + .and_then(|k| k.as_str()); + + let user_row = sqlx::query!( + "SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk JOIN users u ON uk.user_id = u.id WHERE u.did = $1", + did + ) + .fetch_optional(db) + .await + .map_err(|e| { + error!("Failed to fetch user key: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + })?; + + if let Some(row) = user_row { + let key_bytes = + crate::config::decrypt_key(&row.key_bytes, row.encryption_version).map_err(|e| { + error!("Failed to decrypt user key: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + })?; + let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| { + error!("Failed to create signing key: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + })?; + let expected_did_key = crate::plc::signing_key_to_did_key(&signing_key); + + if doc_signing_key != Some(&expected_did_key) { + warn!( + "DID {} has signing key {:?}, expected {}", + did, doc_signing_key, expected_did_key + ); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "DID document verification method does not match expected signing key" + })), + )); + } + } + } else if did.starts_with("did:web:") { + let client = reqwest::Client::new(); + let did_path = &did[8..]; + let url = format!("https://{}/.well-known/did.json", did_path.replace(':', "/")); + let resp = client.get(&url).send().await.map_err(|e| { + warn!("Failed to fetch did:web document for {}: {:?}", did, e); + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": format!("Could not resolve DID document: {}", e) + })), + ) + })?; + let doc: serde_json::Value = resp.json().await.map_err(|e| { + warn!("Failed to parse did:web document for {}: {:?}", did, e); + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": format!("Could not parse DID document: {}", e) + })), + ) + })?; + + let pds_endpoint = doc + .get("service") + .and_then(|s| s.as_array()) + .and_then(|arr| { + arr.iter().find(|svc| { + svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds") + || svc.get("type").and_then(|t| t.as_str()) + == Some("AtprotoPersonalDataServer") + }) + }) + .and_then(|svc| svc.get("serviceEndpoint")) + .and_then(|e| e.as_str()); + + if pds_endpoint != Some(&expected_endpoint) { + warn!( + "DID {} has endpoint {:?}, expected {}", + did, pds_endpoint, expected_endpoint + ); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "DID document atproto_pds service endpoint does not match PDS public url" + })), + )); + } + } + + Ok(()) +} + pub async fn activate_account( State(state): State, headers: axum::http::HeaderMap, @@ -158,6 +339,15 @@ pub async fn activate_account( } let did = auth_user.did; + + if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did).await { + info!( + "activateAccount rejected for {}: DID document validation failed", + did + ); + return (status, json).into_response(); + } + let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await @@ -182,13 +372,20 @@ pub async fn activate_account( { warn!("Failed to sequence identity event for activation: {}", e); } - if let Err(e) = - crate::api::repo::record::sequence_empty_commit_event(&state, &did).await - { - warn!( - "Failed to sequence empty commit event for activation: {}", - e - ); + let repo_root = sqlx::query_scalar!( + "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1", + did + ) + .fetch_optional(&state.db) + .await + .ok() + .flatten(); + if let Some(root_cid) = repo_root { + if let Err(e) = + crate::api::repo::record::sequence_sync_event(&state, &did, &root_cid).await + { + warn!("Failed to sequence sync event for activation: {}", e); + } } (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/src/api/server/migration.rs b/src/api/server/migration.rs new file mode 100644 index 0000000..096fe10 --- /dev/null +++ b/src/api/server/migration.rs @@ -0,0 +1,239 @@ +use crate::api::ApiError; +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetMigrationStatusOutput { + pub did: String, + pub did_type: String, + pub migrated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub migrated_to_pds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub migrated_at: Option>, +} + +pub async fn get_migration_status( + State(state): State, + headers: axum::http::HeaderMap, +) -> Response { + let extracted = match crate::auth::extract_auth_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => return ApiError::AuthenticationRequired.into_response(), + }; + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); + let http_uri = format!( + "https://{}/xrpc/com.tranquil.account.getMigrationStatus", + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + ); + let auth_user = match crate::auth::validate_token_with_dpop( + &state.db, + &extracted.token, + extracted.is_dpop, + dpop_proof, + "GET", + &http_uri, + true, + ) + .await + { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + let user = match sqlx::query!( + "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", + auth_user.did + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + Ok(None) => return ApiError::AccountNotFound.into_response(), + Err(e) => { + tracing::error!("DB error getting migration status: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + let did_type = if user.did.starts_with("did:plc:") { + "plc" + } else if user.did.starts_with("did:web:") { + "web" + } else { + "unknown" + }; + let migrated = user.migrated_to_pds.is_some(); + ( + StatusCode::OK, + Json(GetMigrationStatusOutput { + did: user.did, + did_type: did_type.to_string(), + migrated, + migrated_to_pds: user.migrated_to_pds, + migrated_at: user.migrated_at, + }), + ) + .into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMigrationForwardingInput { + pub pds_url: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMigrationForwardingOutput { + pub success: bool, + pub migrated_to_pds: String, + pub migrated_at: DateTime, +} + +pub async fn update_migration_forwarding( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let extracted = match crate::auth::extract_auth_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => return ApiError::AuthenticationRequired.into_response(), + }; + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); + let http_uri = format!( + "https://{}/xrpc/com.tranquil.account.updateMigrationForwarding", + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + ); + let auth_user = match crate::auth::validate_token_with_dpop( + &state.db, + &extracted.token, + extracted.is_dpop, + dpop_proof, + "POST", + &http_uri, + true, + ) + .await + { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + if !auth_user.did.starts_with("did:web:") { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates." + })), + ) + .into_response(); + } + let pds_url = input.pds_url.trim(); + if pds_url.is_empty() { + return ApiError::InvalidRequest("pds_url is required".into()).into_response(); + } + if !pds_url.starts_with("https://") { + return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response(); + } + let pds_url_clean = pds_url.trim_end_matches('/'); + let now = Utc::now(); + let result = sqlx::query!( + "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", + pds_url_clean, + now, + auth_user.did + ) + .execute(&state.db) + .await; + match result { + Ok(_) => { + tracing::info!( + "Updated migration forwarding for {} to {}", + auth_user.did, + pds_url_clean + ); + ( + StatusCode::OK, + Json(UpdateMigrationForwardingOutput { + success: true, + migrated_to_pds: pds_url_clean.to_string(), + migrated_at: now, + }), + ) + .into_response() + } + Err(e) => { + tracing::error!("DB error updating migration forwarding: {:?}", e); + ApiError::InternalError.into_response() + } + } +} + +pub async fn clear_migration_forwarding( + State(state): State, + headers: axum::http::HeaderMap, +) -> Response { + let extracted = match crate::auth::extract_auth_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => return ApiError::AuthenticationRequired.into_response(), + }; + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); + let http_uri = format!( + "https://{}/xrpc/com.tranquil.account.clearMigrationForwarding", + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) + ); + let auth_user = match crate::auth::validate_token_with_dpop( + &state.db, + &extracted.token, + extracted.is_dpop, + dpop_proof, + "POST", + &http_uri, + true, + ) + .await + { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + if !auth_user.did.starts_with("did:web:") { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Migration forwarding is only available for did:web accounts" + })), + ) + .into_response(); + } + let result = sqlx::query!( + "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", + auth_user.did + ) + .execute(&state.db) + .await; + match result { + Ok(_) => { + tracing::info!("Cleared migration forwarding for {}", auth_user.did); + (StatusCode::OK, Json(json!({ "success": true }))).into_response() + } + Err(e) => { + tracing::error!("DB error clearing migration forwarding: {:?}", e); + ApiError::InternalError.into_response() + } + } +} diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 588113c..7a1e41b 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -4,6 +4,7 @@ pub mod email; pub mod invite; pub mod logo; pub mod meta; +pub mod migration; pub mod passkey_account; pub mod passkeys; pub mod password; @@ -56,5 +57,8 @@ pub use trusted_devices::{ extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device, trust_device, update_trusted_device, }; +pub use migration::{ + clear_migration_forwarding, get_migration_status, update_migration_forwarding, +}; pub use verify_email::{resend_migration_verification, verify_migration_email}; pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal}; diff --git a/src/lib.rs b/src/lib.rs index 78ecc0a..14cee3e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -280,6 +280,18 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.tranquil.account.recoverPasskeyAccount", post(api::server::recover_passkey_account), ) + .route( + "/xrpc/com.tranquil.account.getMigrationStatus", + get(api::server::get_migration_status), + ) + .route( + "/xrpc/com.tranquil.account.updateMigrationForwarding", + post(api::server::update_migration_forwarding), + ) + .route( + "/xrpc/com.tranquil.account.clearMigrationForwarding", + post(api::server::clear_migration_forwarding), + ) .route( "/xrpc/com.atproto.server.requestEmailUpdate", post(api::server::request_email_update), diff --git a/src/sync/import.rs b/src/sync/import.rs index fa13fac..a8a6bd9 100644 --- a/src/sync/import.rs +++ b/src/sync/import.rs @@ -163,68 +163,91 @@ pub fn walk_mst( root_cid: &Cid, ) -> Result, ImportError> { let mut records = Vec::new(); - let mut stack = vec![*root_cid]; - let mut visited = std::collections::HashSet::new(); - while let Some(cid) = stack.pop() { - if visited.contains(&cid) { - continue; + walk_mst_node(blocks, root_cid, &[], &mut records)?; + Ok(records) +} + +fn walk_mst_node( + blocks: &HashMap, + cid: &Cid, + prev_key: &[u8], + records: &mut Vec, +) -> Result<(), ImportError> { + let block = blocks + .get(cid) + .ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?; + let value: Ipld = serde_ipld_dagcbor::from_slice(block) + .map_err(|e| ImportError::InvalidCbor(e.to_string()))?; + + if let Ipld::Map(ref obj) = value { + if let Some(Ipld::Link(left_cid)) = obj.get("l") { + walk_mst_node(blocks, left_cid, prev_key, records)?; } - visited.insert(cid); - let block = blocks - .get(&cid) - .ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?; - let value: Ipld = serde_ipld_dagcbor::from_slice(block) - .map_err(|e| ImportError::InvalidCbor(e.to_string()))?; - if let Ipld::Map(ref obj) = value { - if let Some(Ipld::List(entries)) = obj.get("e") { - for entry in entries { - if let Ipld::Map(entry_obj) = entry { - let key = entry_obj.get("k").and_then(|k| { - if let Ipld::Bytes(b) = k { - String::from_utf8(b.clone()).ok() - } else if let Ipld::String(s) = k { - Some(s.clone()) - } else { - None - } - }); - let record_cid = entry_obj.get("v").and_then(|v| { - if let Ipld::Link(cid) = v { - Some(*cid) - } else { - None - } - }); - if let (Some(key), Some(record_cid)) = (key, record_cid) - && let Some(record_block) = blocks.get(&record_cid) - && let Ok(record_value) = - serde_ipld_dagcbor::from_slice::(record_block) - { - let blob_refs = find_blob_refs_ipld(&record_value, 0); - let parts: Vec<&str> = key.split('/').collect(); - if parts.len() >= 2 { - let collection = parts[..parts.len() - 1].join("/"); - let rkey = parts[parts.len() - 1].to_string(); - records.push(ImportedRecord { - collection, - rkey, - cid: record_cid, - blob_refs, - }); - } + + let mut current_key = prev_key.to_vec(); + + if let Some(Ipld::List(entries)) = obj.get("e") { + for entry in entries { + if let Ipld::Map(entry_obj) = entry { + let prefix_len = entry_obj.get("p").and_then(|p| { + if let Ipld::Integer(n) = p { + Some(*n as usize) + } else { + None } - if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { - stack.push(*tree_cid); + }).unwrap_or(0); + + let key_suffix = entry_obj.get("k").and_then(|k| { + if let Ipld::Bytes(b) = k { + Some(b.clone()) + } else { + None + } + }); + + if let Some(suffix) = key_suffix { + current_key.truncate(prefix_len); + current_key.extend_from_slice(&suffix); + } + + if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { + walk_mst_node(blocks, tree_cid, ¤t_key, records)?; + } + + let record_cid = entry_obj.get("v").and_then(|v| { + if let Ipld::Link(cid) = v { + Some(*cid) + } else { + None + } + }); + + if let Some(record_cid) = record_cid { + if let Ok(full_key) = String::from_utf8(current_key.clone()) { + if let Some(record_block) = blocks.get(&record_cid) + && let Ok(record_value) = + serde_ipld_dagcbor::from_slice::(record_block) + { + let blob_refs = find_blob_refs_ipld(&record_value, 0); + let parts: Vec<&str> = full_key.split('/').collect(); + if parts.len() >= 2 { + let collection = parts[..parts.len() - 1].join("/"); + let rkey = parts[parts.len() - 1].to_string(); + records.push(ImportedRecord { + collection, + rkey, + cid: record_cid, + blob_refs, + }); + } + } } } } } - if let Some(Ipld::Link(left_cid)) = obj.get("l") { - stack.push(*left_cid); - } } } - Ok(records) + Ok(()) } pub struct CommitInfo { diff --git a/src/sync/util.rs b/src/sync/util.rs index 34cb7de..7aff5d1 100644 --- a/src/sync/util.rs +++ b/src/sync/util.rs @@ -86,6 +86,15 @@ fn format_account_event(event: &SequencedEvent) -> Result, anyhow::Error let mut bytes = Vec::new(); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; + let hex_str: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); + tracing::info!( + did = %frame.did, + active = frame.active, + status = ?frame.status, + cbor_len = bytes.len(), + cbor_hex = %hex_str, + "Sending account event to firehose" + ); Ok(bytes) }