mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-09 05:40:09 +00:00
Three-quarter-done in-house migration flow
This commit is contained in:
26
.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json
generated
Normal file
26
.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
28
.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json
generated
Normal file
28
.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
generated
Normal file
14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
generated
Normal file
34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
16
.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json
generated
Normal file
16
.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
1024
frontend/src/components/migration/InboundWizard.svelte
Normal file
1024
frontend/src/components/migration/InboundWizard.svelte
Normal file
File diff suppressed because it is too large
Load Diff
992
frontend/src/components/migration/OutboundWizard.svelte
Normal file
992
frontend/src/components/migration/OutboundWizard.svelte
Normal file
@@ -0,0 +1,992 @@
|
||||
<script lang="ts">
|
||||
import type { OutboundMigrationFlow } from '../../lib/migration'
|
||||
import type { ServerDescription } from '../../lib/migration/types'
|
||||
import { getAuthState, logout } from '../../lib/auth.svelte'
|
||||
|
||||
interface Props {
|
||||
flow: OutboundMigrationFlow
|
||||
onBack: () => void
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
let { flow, onBack, onComplete }: Props = $props()
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
let loading = $state(false)
|
||||
let understood = $state(false)
|
||||
let pdsUrlInput = $state('')
|
||||
let handleInput = $state('')
|
||||
let selectedDomain = $state('')
|
||||
let confirmFinal = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.step === 'success') {
|
||||
setTimeout(async () => {
|
||||
await logout()
|
||||
onComplete()
|
||||
}, 3000)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.targetServerInfo?.availableUserDomains?.length) {
|
||||
selectedDomain = flow.state.targetServerInfo.availableUserDomains[0]
|
||||
}
|
||||
})
|
||||
|
||||
async function validatePds(e: Event) {
|
||||
e.preventDefault()
|
||||
loading = true
|
||||
flow.updateField('error', null)
|
||||
|
||||
try {
|
||||
let url = pdsUrlInput.trim()
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `https://${url}`
|
||||
}
|
||||
await flow.validateTargetPds(url)
|
||||
flow.setStep('new-account')
|
||||
} catch (err) {
|
||||
flow.setError((err as Error).message)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceedToReview() {
|
||||
const fullHandle = handleInput.includes('.')
|
||||
? handleInput
|
||||
: `${handleInput}.${selectedDomain}`
|
||||
|
||||
flow.updateField('targetHandle', fullHandle)
|
||||
flow.setStep('review')
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
try {
|
||||
await flow.startMigration(auth.session.did)
|
||||
} catch (err) {
|
||||
flow.setError((err as Error).message)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPlcToken(e: Event) {
|
||||
e.preventDefault()
|
||||
loading = true
|
||||
try {
|
||||
await flow.submitPlcToken(flow.state.plcToken)
|
||||
} catch (err) {
|
||||
flow.setError((err as Error).message)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resendToken() {
|
||||
loading = true
|
||||
try {
|
||||
await flow.resendPlcToken()
|
||||
flow.setError(null)
|
||||
} catch (err) {
|
||||
flow.setError((err as Error).message)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function isDidWeb(): boolean {
|
||||
return auth.session?.did?.startsWith('did:web:') ?? false
|
||||
}
|
||||
|
||||
const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete']
|
||||
function getCurrentStepIndex(): number {
|
||||
switch (flow.state.step) {
|
||||
case 'welcome': return -1
|
||||
case 'target-pds': return 0
|
||||
case 'new-account': return 1
|
||||
case 'review': return 2
|
||||
case 'migrating': return 3
|
||||
case 'plc-token':
|
||||
case 'finalizing': return 4
|
||||
case 'success': return 5
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="outbound-wizard">
|
||||
{#if flow.state.step !== 'welcome'}
|
||||
<div class="step-indicator">
|
||||
{#each steps as stepName, i}
|
||||
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
|
||||
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
|
||||
<span class="step-label">{stepName}</span>
|
||||
</div>
|
||||
{#if i < steps.length - 1}
|
||||
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.state.step === 'welcome'}
|
||||
<div class="step-content">
|
||||
<h2>Migrate Your Account Away</h2>
|
||||
<p>This wizard will help you move your AT Protocol account from this PDS to another one.</p>
|
||||
|
||||
<div class="current-account">
|
||||
<span class="label">Current account:</span>
|
||||
<span class="value">@{auth.session?.handle}</span>
|
||||
</div>
|
||||
|
||||
{#if isDidWeb()}
|
||||
<div class="warning-box">
|
||||
<strong>did:web Migration Notice</strong>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
You can return here anytime to update the forwarding if you migrate again in the future.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info-box">
|
||||
<h3>What will happen:</h3>
|
||||
<ol>
|
||||
<li>Choose your new PDS</li>
|
||||
<li>Set up your account on the new server</li>
|
||||
<li>Your repository and blobs will be transferred</li>
|
||||
<li>Verify the migration via email</li>
|
||||
<li>Your identity will be updated to point to the new PDS</li>
|
||||
<li>Your account here will be deactivated</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>Before you proceed:</strong>
|
||||
<ul>
|
||||
<li>You need access to the email registered with this account</li>
|
||||
<li>You will lose access to this account on this PDS</li>
|
||||
<li>Make sure you trust the destination PDS</li>
|
||||
<li>Large accounts may take several minutes to transfer</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={understood} />
|
||||
<span>I understand that my account will be moved and deactivated here</span>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onBack}>Cancel</button>
|
||||
<button disabled={!understood} onclick={() => flow.setStep('target-pds')}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'target-pds'}
|
||||
<div class="step-content">
|
||||
<h2>Choose Your New PDS</h2>
|
||||
<p>Enter the URL of the PDS you want to migrate to.</p>
|
||||
|
||||
<form onsubmit={validatePds}>
|
||||
<div class="field">
|
||||
<label for="pds-url">PDS URL</label>
|
||||
<input
|
||||
id="pds-url"
|
||||
type="text"
|
||||
placeholder="pds.example.com"
|
||||
bind:value={pdsUrlInput}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<p class="hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button>
|
||||
<button type="submit" disabled={loading || !pdsUrlInput.trim()}>
|
||||
{loading ? 'Checking...' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if flow.state.targetServerInfo}
|
||||
<div class="server-info">
|
||||
<h3>Connected to PDS</h3>
|
||||
<div class="info-row">
|
||||
<span class="label">Server:</span>
|
||||
<span class="value">{flow.state.targetPdsUrl}</span>
|
||||
</div>
|
||||
{#if flow.state.targetServerInfo.availableUserDomains.length > 0}
|
||||
<div class="info-row">
|
||||
<span class="label">Available domains:</span>
|
||||
<span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info-row">
|
||||
<span class="label">Invite required:</span>
|
||||
<span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
{#if flow.state.targetServerInfo.links?.termsOfService}
|
||||
<a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener">
|
||||
Terms of Service
|
||||
</a>
|
||||
{/if}
|
||||
{#if flow.state.targetServerInfo.links?.privacyPolicy}
|
||||
<a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener">
|
||||
Privacy Policy
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'new-account'}
|
||||
<div class="step-content">
|
||||
<h2>Set Up Your New Account</h2>
|
||||
<p>Configure your account details on the new PDS.</p>
|
||||
|
||||
<div class="current-info">
|
||||
<span class="label">Migrating to:</span>
|
||||
<span class="value">{flow.state.targetPdsUrl}</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-handle">New Handle</label>
|
||||
<div class="handle-input-group">
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
bind:value={handleInput}
|
||||
/>
|
||||
{#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
|
||||
<select bind:value={selectedDomain}>
|
||||
{#each flow.state.targetServerInfo.availableUserDomains as domain}
|
||||
<option value={domain}>.{domain}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
bind:value={flow.state.targetEmail}
|
||||
oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-password">Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
placeholder="Password for your new account"
|
||||
bind:value={flow.state.targetPassword}
|
||||
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
<p class="hint">At least 8 characters. This will be your password on the new PDS.</p>
|
||||
</div>
|
||||
|
||||
{#if flow.state.targetServerInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite">Invite Code</label>
|
||||
<input
|
||||
id="invite"
|
||||
type="text"
|
||||
placeholder="Enter invite code"
|
||||
bind:value={flow.state.inviteCode}
|
||||
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
/>
|
||||
<p class="hint">Required by this PDS to create an account</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button>
|
||||
<button
|
||||
disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword}
|
||||
onclick={proceedToReview}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'review'}
|
||||
<div class="step-content">
|
||||
<h2>Review Migration</h2>
|
||||
<p>Please confirm the details of your migration.</p>
|
||||
|
||||
<div class="review-card">
|
||||
<div class="review-row">
|
||||
<span class="label">Current Handle:</span>
|
||||
<span class="value">@{auth.session?.handle}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">New Handle:</span>
|
||||
<span class="value">@{flow.state.targetHandle}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">DID:</span>
|
||||
<span class="value mono">{auth.session?.did}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">From PDS:</span>
|
||||
<span class="value">{window.location.origin}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">To PDS:</span>
|
||||
<span class="value">{flow.state.targetPdsUrl}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">New Email:</span>
|
||||
<span class="value">{flow.state.targetEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning-box final-warning">
|
||||
<strong>This action cannot be easily undone!</strong>
|
||||
<p>
|
||||
After migration completes, your account on this PDS will be deactivated.
|
||||
To return, you would need to migrate back from the new PDS.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={confirmFinal} />
|
||||
<span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button>
|
||||
<button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}>
|
||||
{loading ? 'Starting...' : 'Start Migration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'migrating'}
|
||||
<div class="step-content">
|
||||
<h2>Migration in Progress</h2>
|
||||
<p>Please wait while your account is being transferred...</p>
|
||||
|
||||
<div class="progress-section">
|
||||
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
|
||||
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
|
||||
<span>Export repository</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
|
||||
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
|
||||
<span>Import repository to new PDS</span>
|
||||
</div>
|
||||
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
|
||||
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
|
||||
<span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
|
||||
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
|
||||
<span>Migrate preferences</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if flow.state.progress.blobsTotal > 0}
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="status-text">{flow.state.progress.currentOperation}</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'plc-token'}
|
||||
<div class="step-content">
|
||||
<h2>Verify Migration</h2>
|
||||
<p>A verification code has been sent to your email ({auth.session?.email}).</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
This code confirms you have access to the account and authorizes updating your identity
|
||||
to point to the new PDS.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={submitPlcToken}>
|
||||
<div class="field">
|
||||
<label for="plc-token">Verification Code</label>
|
||||
<input
|
||||
id="plc-token"
|
||||
type="text"
|
||||
placeholder="Enter code from email"
|
||||
bind:value={flow.state.plcToken}
|
||||
oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost" onclick={resendToken} disabled={loading}>
|
||||
Resend Code
|
||||
</button>
|
||||
<button type="submit" disabled={loading || !flow.state.plcToken}>
|
||||
{loading ? 'Verifying...' : 'Complete Migration'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'finalizing'}
|
||||
<div class="step-content">
|
||||
<h2>Finalizing Migration</h2>
|
||||
<p>Please wait while we complete the migration...</p>
|
||||
|
||||
<div class="progress-section">
|
||||
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
|
||||
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
|
||||
<span>Sign identity update</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.activated}>
|
||||
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
|
||||
<span>Activate account on new PDS</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
|
||||
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
|
||||
<span>Deactivate account here</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-text">{flow.state.progress.currentOperation}</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'success'}
|
||||
<div class="step-content success-content">
|
||||
<div class="success-icon">✓</div>
|
||||
<h2>Migration Complete!</h2>
|
||||
<p>Your account has been successfully migrated to your new PDS.</p>
|
||||
|
||||
<div class="success-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">Your new handle:</span>
|
||||
<span class="value">@{flow.state.targetHandle}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">New PDS:</span>
|
||||
<span class="value">{flow.state.targetPdsUrl}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">DID:</span>
|
||||
<span class="value mono">{auth.session?.did}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if flow.state.progress.blobsFailed.length > 0}
|
||||
<div class="warning-box">
|
||||
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
|
||||
These may be images or other media that are no longer available.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="next-steps">
|
||||
<h3>Next Steps</h3>
|
||||
<ol>
|
||||
<li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li>
|
||||
<li>Log in with your new credentials</li>
|
||||
<li>Your followers and following will continue to work</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p class="redirect-text">Logging out in a moment...</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'error'}
|
||||
<div class="step-content">
|
||||
<h2>Migration Error</h2>
|
||||
<p>An error occurred during migration.</p>
|
||||
|
||||
<div class="error-box">
|
||||
{flow.state.error}
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onBack}>Start Over</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.outbound-wizard {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-8);
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.step.active .step-dot {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.step.completed .step-dot {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-text);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.step.active .step-label {
|
||||
color: var(--accent);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.step-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
margin: 0 var(--space-2);
|
||||
margin-bottom: var(--space-6);
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.step-line.completed {
|
||||
background: var(--success-text);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.step-content h2 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
.step-content > p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-5) 0;
|
||||
}
|
||||
|
||||
.current-account {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.current-account .label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.current-account .value {
|
||||
font-weight: var(--font-medium);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.info-box h3 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.info-box ol, .info-box ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: var(--space-3) 0 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.warning-box ul {
|
||||
margin: var(--space-3) 0 0 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.final-warning {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
.final-warning strong {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-5);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-2);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.field input:focus, .field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: var(--space-2) 0 0 0;
|
||||
}
|
||||
|
||||
.handle-input-group {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.handle-input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.handle-input-group select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.current-info {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.current-info .label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.current-info .value {
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.server-info {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.server-info h3 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.server-info .info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.server-info .label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.server-info a {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-2);
|
||||
margin-right: var(--space-3);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.review-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.review-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.review-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.review-row .label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.review-row .value {
|
||||
font-weight: var(--font-medium);
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.review-row .value.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progress-item.completed {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.progress-item.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.progress-item .icon {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-2xl);
|
||||
margin: 0 auto var(--space-5) auto;
|
||||
}
|
||||
|
||||
.success-details {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin: var(--space-5) 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.success-details .detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.success-details .label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.success-details .value {
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.success-details .value.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
background: var(--accent-muted);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
margin: var(--space-5) 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.next-steps h3 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
.next-steps ol {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.next-steps li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.next-steps a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.redirect-text {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
</style>
|
||||
448
frontend/src/lib/migration/atproto-client.ts
Normal file
448
frontend/src/lib/migration/atproto-client.ts
Normal file
@@ -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<T>(
|
||||
method: string,
|
||||
options?: {
|
||||
httpMethod?: "GET" | "POST";
|
||||
params?: Record<string, string>;
|
||||
body?: unknown;
|
||||
authToken?: string;
|
||||
rawBody?: Uint8Array | Blob;
|
||||
contentType?: string;
|
||||
},
|
||||
): Promise<T> {
|
||||
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<string, string> = {};
|
||||
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<Session> {
|
||||
const body: Record<string, string> = { identifier, password };
|
||||
if (authFactorToken) {
|
||||
body.authFactorToken = authFactorToken;
|
||||
}
|
||||
|
||||
const session = await this.xrpc<Session>("com.atproto.server.createSession", {
|
||||
httpMethod: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
this.accessToken = session.accessJwt;
|
||||
return session;
|
||||
}
|
||||
|
||||
async refreshSession(refreshJwt: string): Promise<Session> {
|
||||
const session = await this.xrpc<Session>(
|
||||
"com.atproto.server.refreshSession",
|
||||
{
|
||||
httpMethod: "POST",
|
||||
authToken: refreshJwt,
|
||||
},
|
||||
);
|
||||
this.accessToken = session.accessJwt;
|
||||
return session;
|
||||
}
|
||||
|
||||
async describeServer(): Promise<ServerDescription> {
|
||||
return this.xrpc<ServerDescription>("com.atproto.server.describeServer");
|
||||
}
|
||||
|
||||
async getServiceAuth(
|
||||
aud: string,
|
||||
lxm?: string,
|
||||
): Promise<{ token: string }> {
|
||||
const params: Record<string, string> = { aud };
|
||||
if (lxm) {
|
||||
params.lxm = lxm;
|
||||
}
|
||||
return this.xrpc("com.atproto.server.getServiceAuth", { params });
|
||||
}
|
||||
|
||||
async getRepo(did: string): Promise<Uint8Array> {
|
||||
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<string, string> = { did, limit: String(limit) };
|
||||
if (cursor) {
|
||||
params.cursor = cursor;
|
||||
}
|
||||
return this.xrpc("com.atproto.sync.listBlobs", { params });
|
||||
}
|
||||
|
||||
async getBlob(did: string, cid: string): Promise<Uint8Array> {
|
||||
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<Preferences> {
|
||||
return this.xrpc("app.bsky.actor.getPreferences");
|
||||
}
|
||||
|
||||
async putPreferences(preferences: Preferences): Promise<void> {
|
||||
await this.xrpc("app.bsky.actor.putPreferences", {
|
||||
httpMethod: "POST",
|
||||
body: preferences,
|
||||
});
|
||||
}
|
||||
|
||||
async createAccount(
|
||||
params: CreateAccountParams,
|
||||
serviceToken?: string,
|
||||
): Promise<Session> {
|
||||
const headers: Record<string, string> = {
|
||||
"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<void> {
|
||||
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<string, string> = { limit: String(limit) };
|
||||
if (cursor) {
|
||||
params.cursor = cursor;
|
||||
}
|
||||
return this.xrpc("com.atproto.repo.listMissingBlobs", { params });
|
||||
}
|
||||
|
||||
async requestPlcOperationSignature(): Promise<void> {
|
||||
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<void> {
|
||||
await this.xrpc("com.atproto.identity.submitPlcOperation", {
|
||||
httpMethod: "POST",
|
||||
body: { operation },
|
||||
});
|
||||
}
|
||||
|
||||
async getRecommendedDidCredentials(): Promise<DidCredentials> {
|
||||
return this.xrpc("com.atproto.identity.getRecommendedDidCredentials");
|
||||
}
|
||||
|
||||
async activateAccount(): Promise<void> {
|
||||
await this.xrpc("com.atproto.server.activateAccount", {
|
||||
httpMethod: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async deactivateAccount(): Promise<void> {
|
||||
await this.xrpc("com.atproto.server.deactivateAccount", {
|
||||
httpMethod: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async checkAccountStatus(): Promise<AccountStatus> {
|
||||
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<Session> {
|
||||
const session = await this.xrpc<Session>("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<void> {
|
||||
await this.xrpc("com.atproto.server.resendMigrationVerification", {
|
||||
httpMethod: "POST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveDidDocument(did: string): Promise<DidDocument> {
|
||||
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);
|
||||
}
|
||||
734
frontend/src/lib/migration/flow.svelte.ts
Normal file
734
frontend/src/lib/migration/flow.svelte.ts
Normal file
@@ -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<InboundMigrationState>({
|
||||
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<MigrationProgress>) {
|
||||
state.progress = { ...state.progress, ...updates };
|
||||
updateProgress(updates);
|
||||
}
|
||||
|
||||
async function loadLocalServerInfo(): Promise<ServerDescription> {
|
||||
if (!localClient) {
|
||||
localClient = createLocalClient();
|
||||
}
|
||||
if (!localServerInfo) {
|
||||
localServerInfo = await localClient.describeServer();
|
||||
}
|
||||
return localServerInfo;
|
||||
}
|
||||
|
||||
async function resolveSourcePds(handle: string): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
if (!localClient) {
|
||||
localClient = createLocalClient();
|
||||
}
|
||||
try {
|
||||
await localClient.resolveHandle(handle);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateToLocal(email: string, password: string): Promise<void> {
|
||||
if (!localClient) {
|
||||
localClient = createLocalClient();
|
||||
}
|
||||
await localClient.loginDeactivated(email, password);
|
||||
}
|
||||
|
||||
async function startMigration(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!localClient) {
|
||||
localClient = createLocalClient();
|
||||
}
|
||||
await localClient.resendMigrationVerification();
|
||||
}
|
||||
|
||||
let checkingEmailVerification = false;
|
||||
|
||||
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!sourceClient) {
|
||||
throw new Error("Not connected to source PDS");
|
||||
}
|
||||
setProgress({ currentOperation: "Requesting PLC operation token..." });
|
||||
await sourceClient.requestPlcOperationSignature();
|
||||
}
|
||||
|
||||
async function resendPlcToken(): Promise<void> {
|
||||
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<void> {
|
||||
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<K extends keyof InboundMigrationState>(
|
||||
field: K,
|
||||
value: InboundMigrationState[K],
|
||||
) {
|
||||
state[field] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createOutboundMigrationFlow() {
|
||||
let state = $state<OutboundMigrationState>({
|
||||
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<MigrationProgress>) {
|
||||
state.progress = { ...state.progress, ...updates };
|
||||
updateProgress(updates);
|
||||
}
|
||||
|
||||
async function validateTargetPds(url: string): Promise<ServerDescription> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!localClient || !targetClient) return;
|
||||
|
||||
try {
|
||||
const prefs = await localClient.getPreferences();
|
||||
await targetClient.putPreferences(prefs);
|
||||
setProgress({ prefsMigrated: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPlcToken(token: string): Promise<void> {
|
||||
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<void> {
|
||||
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<K extends keyof OutboundMigrationState>(
|
||||
field: K,
|
||||
value: OutboundMigrationState[K],
|
||||
) {
|
||||
state[field] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type InboundMigrationFlow = ReturnType<typeof createInboundMigrationFlow>;
|
||||
export type OutboundMigrationFlow = ReturnType<typeof createOutboundMigrationFlow>;
|
||||
9
frontend/src/lib/migration/index.ts
Normal file
9
frontend/src/lib/migration/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./types";
|
||||
export * from "./atproto-client";
|
||||
export * from "./storage";
|
||||
export {
|
||||
createInboundMigrationFlow,
|
||||
createOutboundMigrationFlow,
|
||||
type InboundMigrationFlow,
|
||||
type OutboundMigrationFlow,
|
||||
} from "./flow.svelte";
|
||||
138
frontend/src/lib/migration/storage.ts
Normal file
138
frontend/src/lib/migration/storage.ts
Normal file
@@ -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<StoredMigrationState["progress"]>,
|
||||
): 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 {
|
||||
}
|
||||
}
|
||||
214
frontend/src/lib/migration/types.ts
Normal file
214
frontend/src/lib/migration/types.ts
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ネットワークエラー。再試行してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "네트워크 오류. 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "网络错误,请重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
<h3>{$_('dashboard.navDelegation')}</h3>
|
||||
<p>{$_('dashboard.navDelegationDesc')}</p>
|
||||
</a>
|
||||
<a href="#/migrate" class="nav-card">
|
||||
<h3>{$_('migration.navTitle')}</h3>
|
||||
<p>{$_('migration.navDesc')}</p>
|
||||
</a>
|
||||
{#if auth.session.isAdmin}
|
||||
<a href="#/admin" class="nav-card admin-card">
|
||||
<h3>{$_('dashboard.navAdmin')}</h3>
|
||||
|
||||
413
frontend/src/routes/Migration.svelte
Normal file
413
frontend/src/routes/Migration.svelte
Normal file
@@ -0,0 +1,413 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState, logout, setSession } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import {
|
||||
createInboundMigrationFlow,
|
||||
createOutboundMigrationFlow,
|
||||
hasPendingMigration,
|
||||
getResumeInfo,
|
||||
clearMigrationState,
|
||||
loadMigrationState,
|
||||
} from '../lib/migration'
|
||||
import InboundWizard from '../components/migration/InboundWizard.svelte'
|
||||
import OutboundWizard from '../components/migration/OutboundWizard.svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
type Direction = 'select' | 'inbound' | 'outbound'
|
||||
let direction = $state<Direction>('select')
|
||||
let showResumeModal = $state(false)
|
||||
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
|
||||
|
||||
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
|
||||
let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
|
||||
|
||||
if (hasPendingMigration()) {
|
||||
resumeInfo = getResumeInfo()
|
||||
if (resumeInfo) {
|
||||
showResumeModal = true
|
||||
}
|
||||
}
|
||||
|
||||
function selectInbound() {
|
||||
direction = 'inbound'
|
||||
inboundFlow = createInboundMigrationFlow()
|
||||
}
|
||||
|
||||
function selectOutbound() {
|
||||
if (!auth.session) {
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
direction = 'outbound'
|
||||
outboundFlow = createOutboundMigrationFlow()
|
||||
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
const stored = loadMigrationState()
|
||||
if (!stored) return
|
||||
|
||||
showResumeModal = false
|
||||
|
||||
if (stored.direction === 'inbound') {
|
||||
direction = 'inbound'
|
||||
inboundFlow = createInboundMigrationFlow()
|
||||
inboundFlow.resumeFromState(stored)
|
||||
} else {
|
||||
if (!auth.session) {
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
direction = 'outbound'
|
||||
outboundFlow = createOutboundMigrationFlow()
|
||||
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
|
||||
}
|
||||
}
|
||||
|
||||
function handleStartOver() {
|
||||
showResumeModal = false
|
||||
clearMigrationState()
|
||||
resumeInfo = null
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (inboundFlow) {
|
||||
inboundFlow.reset()
|
||||
inboundFlow = null
|
||||
}
|
||||
if (outboundFlow) {
|
||||
outboundFlow.reset()
|
||||
outboundFlow = null
|
||||
}
|
||||
direction = 'select'
|
||||
}
|
||||
|
||||
function handleInboundComplete() {
|
||||
const session = inboundFlow?.getLocalSession()
|
||||
if (session) {
|
||||
setSession({
|
||||
did: session.did,
|
||||
handle: session.handle,
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: '',
|
||||
})
|
||||
}
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
async function handleOutboundComplete() {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="migration-page">
|
||||
{#if showResumeModal && resumeInfo}
|
||||
<div class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h2>Resume Migration?</h2>
|
||||
<p>You have an incomplete migration in progress:</p>
|
||||
<div class="resume-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">Direction:</span>
|
||||
<span class="value">{resumeInfo.direction === 'inbound' ? 'Migrating here' : 'Migrating away'}</span>
|
||||
</div>
|
||||
{#if resumeInfo.sourceHandle}
|
||||
<div class="detail-row">
|
||||
<span class="label">From:</span>
|
||||
<span class="value">{resumeInfo.sourceHandle}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if resumeInfo.targetHandle}
|
||||
<div class="detail-row">
|
||||
<span class="label">To:</span>
|
||||
<span class="value">{resumeInfo.targetHandle}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="detail-row">
|
||||
<span class="label">Progress:</span>
|
||||
<span class="value">{resumeInfo.progressSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">You will need to re-enter your credentials to continue.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" onclick={handleStartOver}>Start Over</button>
|
||||
<button onclick={handleResume}>Resume</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if direction === 'select'}
|
||||
<header class="page-header">
|
||||
<h1>Account Migration</h1>
|
||||
<p class="subtitle">Move your AT Protocol identity between servers</p>
|
||||
</header>
|
||||
|
||||
<div class="direction-cards">
|
||||
<button class="direction-card ghost" onclick={selectInbound}>
|
||||
<div class="card-icon">↓</div>
|
||||
<h2>Migrate Here</h2>
|
||||
<p>Move your existing AT Protocol account to this PDS from another server.</p>
|
||||
<ul class="features">
|
||||
<li>Bring your DID and identity</li>
|
||||
<li>Transfer all your data</li>
|
||||
<li>Keep your followers</li>
|
||||
</ul>
|
||||
</button>
|
||||
|
||||
<button class="direction-card ghost" onclick={selectOutbound} disabled={!auth.session}>
|
||||
<div class="card-icon">↑</div>
|
||||
<h2>Migrate Away</h2>
|
||||
<p>Move your account from this PDS to another server.</p>
|
||||
<ul class="features">
|
||||
<li>Export your repository</li>
|
||||
<li>Transfer to new PDS</li>
|
||||
<li>Update your identity</li>
|
||||
</ul>
|
||||
{#if !auth.session}
|
||||
<p class="login-required">Login required</p>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h3>What is account migration?</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Before you migrate</h3>
|
||||
<ul>
|
||||
<li>You will need your current account credentials</li>
|
||||
<li>Migration requires email verification for security</li>
|
||||
<li>Large accounts with many images may take several minutes</li>
|
||||
<li>Your old PDS will be notified to deactivate your account</li>
|
||||
</ul>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>Important:</strong> 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.
|
||||
<a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener">
|
||||
Learn more about migration risks
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if direction === 'inbound' && inboundFlow}
|
||||
<InboundWizard
|
||||
flow={inboundFlow}
|
||||
onBack={handleBack}
|
||||
onComplete={handleInboundComplete}
|
||||
/>
|
||||
|
||||
{:else if direction === 'outbound' && outboundFlow}
|
||||
<OutboundWizard
|
||||
flow={outboundFlow}
|
||||
onBack={handleBack}
|
||||
onComplete={handleOutboundComplete}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.migration-page {
|
||||
max-width: var(--width-lg);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.direction-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.direction-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.direction-card:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.direction-card:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: var(--text-3xl);
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.direction-card h2 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.direction-card p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.features {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.features li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.login-required {
|
||||
color: var(--warning-text);
|
||||
font-weight: var(--font-medium);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.info-section h3:not(:first-child) {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.info-section p {
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
color: var(--text-secondary);
|
||||
padding-left: var(--space-5);
|
||||
margin: var(--space-3) 0 0 0;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-5);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box a {
|
||||
display: block;
|
||||
margin-top: var(--space-3);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
}
|
||||
|
||||
.resume-details {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.detail-row:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-row .label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-row .value {
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -173,7 +173,7 @@
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
|
||||
<a href="#/migrate" class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<WriteResult> = Vec::new();
|
||||
let mut ops: Vec<RecordOp> = Vec::new();
|
||||
let mut modified_keys: Vec<String> = Vec::new();
|
||||
let mut all_blob_cids: Vec<String> = 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
|
||||
|
||||
@@ -168,6 +168,7 @@ pub async fn delete_record(
|
||||
new_mst_root,
|
||||
ops: vec![op],
|
||||
blocks_cids: &written_cids_str,
|
||||
blobs: &[],
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -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<String, Value> = 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::<serde_json::Value>(&block)
|
||||
&& let Ok(ipld) = serde_ipld_dagcbor::from_slice::<Ipld>(&block)
|
||||
{
|
||||
let value = ipld_to_json(ipld);
|
||||
records.push(json!({
|
||||
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
|
||||
"cid": cid_str,
|
||||
|
||||
@@ -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<String> {
|
||||
let mut blobs = Vec::new();
|
||||
extract_blob_cids_recursive(record, &mut blobs);
|
||||
blobs
|
||||
}
|
||||
|
||||
fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec<String>) {
|
||||
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<RecordOp>,
|
||||
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<String> = 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?;
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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
|
||||
|
||||
@@ -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<serde_json::Value>)> {
|
||||
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<AppState>,
|
||||
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()
|
||||
}
|
||||
|
||||
239
src/api/server/migration.rs
Normal file
239
src/api/server/migration.rs
Normal file
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub migrated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub async fn get_migration_status(
|
||||
State(state): State<AppState>,
|
||||
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<Utc>,
|
||||
}
|
||||
|
||||
pub async fn update_migration_forwarding(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<UpdateMigrationForwardingInput>,
|
||||
) -> 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<AppState>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
12
src/lib.rs
12
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),
|
||||
|
||||
@@ -163,68 +163,91 @@ pub fn walk_mst(
|
||||
root_cid: &Cid,
|
||||
) -> Result<Vec<ImportedRecord>, 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, Bytes>,
|
||||
cid: &Cid,
|
||||
prev_key: &[u8],
|
||||
records: &mut Vec<ImportedRecord>,
|
||||
) -> 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::<Ipld>(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::<Ipld>(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 {
|
||||
|
||||
@@ -86,6 +86,15 @@ fn format_account_event(event: &SequencedEvent) -> Result<Vec<u8>, 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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user