Skip to content

Commit ba45a5f

Browse files
alanshawAlan Shaw
and
Alan Shaw
authored
feat: add old web3 storage migrator (#129)
<img width="1356" alt="Screenshot 2024-09-25 at 18 34 57" src="https://github.com/user-attachments/assets/eaefb7b7-9231-4823-a2f2-b31d87d94457"> --------- Co-authored-by: Alan Shaw <[email protected]>
1 parent fe5b60e commit ba45a5f

14 files changed

+1732
-1793
lines changed

pnpm-lock.yaml

+1,328-1,722
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/web3storage-psa-logo.png

60.8 KB
Loading

src/app/migration/[id]/page.tsx

+8-4
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import { useMigrations } from '@/components/MigrationsProvider'
88
import { DidIcon } from '@/components/DidIcon'
99
import CopyIcon from '@/components/CopyIcon'
1010
import { CheckCircleIcon, ClockIcon, FlagIcon } from '@heroicons/react/20/solid'
11-
import { Migration, MigrationProgress } from '@/lib/migrations/api'
11+
import { Migration, Progress } from '@/lib/migrations/api'
1212
import { useRouter } from 'next/navigation'
1313
import { UnknownLink } from '@w3ui/react'
14+
import { dataSources } from '@/app/migration/data-sources'
1415

1516
interface PageProps {
1617
params: {
@@ -49,14 +50,17 @@ export default function MigrationPage ({ params }: PageProps): JSX.Element {
4950
const migration = migrations.find(m => m.id === params.id)
5051
if (!migration) return <H1>Migration not found</H1>
5152

53+
const ds = dataSources.find(({ source }) => source.id === migration.source)
54+
if (!ds) return <H1>Unknown data source</H1>
55+
5256
const handleRemove = () => {
5357
removeMigration(migration.id)
5458
router.replace('/')
5559
}
5660

5761
return (
5862
<div className='max-w-6xl'>
59-
<H1>Migrating from {migration.source}</H1>
63+
<H1>Migrating from {ds.name}</H1>
6064
<div className='bg-white my-4 p-4 rounded-2xl border border-hot-red'>
6165
<div className='flex mb-4'>
6266
<div className='flex-auto'>
@@ -122,7 +126,7 @@ const LogLines = ({ lines }: { lines: string[] }) => {
122126
)
123127
}
124128

125-
const ProgressBar = ({ progress }: { progress?: MigrationProgress }) => {
129+
const ProgressBar = ({ progress }: { progress?: Progress }) => {
126130
const attempted = progress ? progress.succeeded + progress.failed.length : 0
127131
const failed = progress?.failed.length
128132
const total = progress ? attempted + progress.pending : 0
@@ -142,7 +146,7 @@ const ProgressBar = ({ progress }: { progress?: MigrationProgress }) => {
142146
)
143147
}
144148

145-
const RemoveButton = ({ onRemove, progress }: { onRemove: () => void, progress?: MigrationProgress }) => {
149+
const RemoveButton = ({ onRemove, progress }: { onRemove: () => void, progress?: Progress }) => {
146150
const [isRemoveConfirmModalOpen, setRemoveConfirmModalOpen] = useState(false)
147151

148152
if (progress && progress.pending <= 0) {

src/app/migration/create/page.tsx

+29-26
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22

33
import { MouseEventHandler, useState } from 'react'
44
import { useRouter } from 'next/navigation'
5-
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'
5+
import { ArrowPathIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'
66
import { H1, H2 } from '@/components/Text'
77
import { useMigrations } from '@/components/MigrationsProvider'
88
import { DIDKey, useW3 } from '@w3ui/react'
9-
import * as NFTStorageMigrator from '@/lib/migrations/nft-storage'
10-
import * as Web3StorageMigrator from '@/lib/migrations/web3-storage'
119
import { DidIcon } from '@/components/DidIcon'
12-
import { MigrationConfiguration, MigrationSource } from '@/lib/migrations/api'
10+
import { MigrationConfiguration, DataSourceID } from '@/lib/migrations/api'
11+
import { dataSources } from '@/app/migration/data-sources'
1312

1413
interface WizardProps {
1514
config: Partial<MigrationConfiguration>
@@ -46,7 +45,7 @@ export default function CreateMigrationPage (): JSX.Element {
4645
}
4746

4847
function ChooseSource ({ config, onNext }: WizardProps) {
49-
const [source, setSource] = useState<MigrationSource|undefined>(config.source)
48+
const [source, setSource] = useState<DataSourceID|undefined>(config.source)
5049
const handleNextClick: MouseEventHandler = e => {
5150
e.preventDefault()
5251
if (!source) return
@@ -58,16 +57,14 @@ function ChooseSource ({ config, onNext }: WizardProps) {
5857
<H1>Create a new migration</H1>
5958
<div className='bg-white my-4 p-5 rounded-2xl border border-hot-red font-epilogue'>
6059
<p className='mb-8'>This tool allows data to be migrated from a previous provider to one of your spaces.</p>
61-
6260
<H2>Where from?</H2>
6361
<p className='mb-4'>Pick a storage service you want to migrate data from.</p>
64-
<div className='mb-4'>
65-
<button className={`bg-white/60 rounded-lg shadow-md p-8 hover:outline mr-4 ${source === 'classic.nft.storage' ? 'outline' : ''}`} type='button' onClick={() => setSource('classic.nft.storage')} title='Migrate from NFT.Storage (Classic)'>
66-
<img src='/nftstorage-logo.png' width='350' />
67-
</button>
68-
<button className={`bg-white/60 opacity-60 rounded-lg shadow-md p-8 ${source === 'old.web3.storage' ? 'outline' : ''}`} type='button' onClick={() => setSource('old.web3.storage')} title='COMING SOON! Migrate from Web3.Storage (Old)' disabled={true}>
69-
<img src='/web3storage-logo.png' width='350' />
70-
</button>
62+
<div className='mb-4 text-center'>
63+
{dataSources.map(({ name, logo, source: { id } }) => (
64+
<button key={id} className={`bg-white/60 rounded-lg shadow-md p-8 border border-black hover:outline ml-4 first:ml-0 mb-4 ${source === id ? 'outline' : ''}`} type='button' onClick={() => setSource(id)} title={`Migrate from ${name}`}>
65+
{logo}
66+
</button>
67+
))}
7168
</div>
7269
<button onClick={handleNextClick} className={`inline-block bg-hot-red border border-hot-red font-epilogue text-white uppercase text-sm px-6 py-2 rounded-full whitespace-nowrap ${source ? 'hover:bg-white hover:text-hot-red' : 'opacity-10'}`} disabled={!source}>
7370
Next <ChevronRightIcon className='h-5 w-5 inline-block ml-1 align-middle' style={{marginTop: -4}} />
@@ -79,32 +76,33 @@ function ChooseSource ({ config, onNext }: WizardProps) {
7976

8077
function AddSourceToken ({ config, onNext, onPrev }: WizardProps) {
8178
const [token, setToken] = useState<string|undefined>(config.token)
79+
const [checking, setChecking] = useState(false)
8280
const [error, setError] = useState('')
8381

82+
const ds = dataSources.find(({ source }) => source.id === config.source)
83+
if (!ds) return
84+
8485
const handleNextClick: MouseEventHandler = async e => {
8586
e.preventDefault()
86-
if (!token) return
87+
if (!token || checking) return
8788
setError('')
89+
setChecking(true)
8890

8991
try {
90-
if (config.source === 'classic.nft.storage') {
91-
await NFTStorageMigrator.checkToken(token)
92-
} else if (config.source === 'old.web3.storage') {
93-
await Web3StorageMigrator.checkToken(token)
94-
} else {
95-
throw new Error(`unknown data source: ${config.source}`)
96-
}
92+
await ds.source.checkToken(token)
9793
} catch (err: any) {
9894
console.error(err)
9995
return setError(`Error using token: ${err.message}`)
96+
} finally {
97+
setChecking(false)
10098
}
10199
onNext({ ...config, token })
102100
}
103101
return (
104102
<div className='max-w-4xl'>
105103
<H1>Add data source token</H1>
106104
<div className='bg-white my-4 p-5 rounded-2xl border border-hot-red font-epilogue'>
107-
<p className='mb-8'>Add your <strong>{config.source}</strong> API token. Note: the key never leaves this device, it is for local use only by the migration tool.</p>
105+
<p className='mb-8'>Add your API token for <strong>{ds.name}</strong>. Note: the key never leaves this device, it is for local use only by the migration tool.</p>
108106
<H2>API Token</H2>
109107
<div className='max-w-xl mb-4'>
110108
<input
@@ -120,8 +118,10 @@ function AddSourceToken ({ config, onNext, onPrev }: WizardProps) {
120118
<button onClick={e => { e.preventDefault(); onPrev() }} className={`inline-block bg-hot-red border border-hot-red font-epilogue text-white uppercase text-sm mr-2 px-6 py-2 rounded-full whitespace-nowrap hover:bg-white hover:text-hot-red`}>
121119
<ChevronLeftIcon className='h-5 w-5 inline-block mr-1 align-middle' style={{marginTop: -4}}/> Previous
122120
</button>
123-
<button onClick={handleNextClick} className={`inline-block bg-hot-red border border-hot-red font-epilogue text-white uppercase text-sm px-6 py-2 rounded-full whitespace-nowrap ${token ? 'hover:bg-white hover:text-hot-red' : 'opacity-10'}`} disabled={!token}>
124-
Next <ChevronRightIcon className='h-5 w-5 inline-block ml-1 align-middle' style={{marginTop: -4}}/>
121+
<button onClick={handleNextClick} className={`inline-block bg-hot-red border border-hot-red font-epilogue text-white uppercase text-sm px-6 py-2 rounded-full whitespace-nowrap ${token ? 'hover:bg-white hover:text-hot-red' : 'opacity-10'}`} disabled={!token || checking}>
122+
{checking
123+
? <><ArrowPathIcon className={`h-5 w-5 animate-spin inline-block mr-1 align-middle`}/>Checking...</>
124+
: <>Next <ChevronRightIcon className='h-5 w-5 inline-block ml-1 align-middle' style={{marginTop: -4}}/></>}
125125
</button>
126126
</div>
127127
</div>
@@ -176,6 +176,9 @@ function Confirmation ({ config, onNext, onPrev }: WizardProps) {
176176
const space = spaces.find(s => s.did() === config.space)
177177
if (!space) return
178178

179+
const ds = dataSources.find(({ source }) => source.id === config.source)
180+
if (!ds) return
181+
179182
const handleNextClick: MouseEventHandler = async e => {
180183
e.preventDefault()
181184
onNext(config)
@@ -186,8 +189,8 @@ function Confirmation ({ config, onNext, onPrev }: WizardProps) {
186189
<div className='bg-white my-4 p-5 rounded-2xl border border-hot-red font-epilogue'>
187190
<p className='mb-8'>Make sure these details are correct before starting the migration.</p>
188191
<H2>Source</H2>
189-
<div className={`bg-white/60 rounded-lg shadow-md p-8 mb-4 inline-block`} title='Web3.Storage (Old)'>
190-
<img src={config.source === 'old.web3.storage' ? '/web3storage-logo.png' : '/nftstorage-logo.png'} width='360' />
192+
<div className={`bg-white/60 rounded-lg shadow-md p-8 mb-4 inline-block border border-black`} title={ds.name}>
193+
{ds.logo}
191194
</div>
192195
<H2>Target</H2>
193196
<div className='max-w-lg border rounded-2xl border-hot-red bg-white mb-4'>

src/app/migration/data-sources.tsx

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as NFTStorage from '@/lib/migrations/nft-storage'
2+
import * as Web3Storage from '@/lib/migrations/web3-storage'
3+
import * as Web3StoragePSA from '@/lib/migrations/web3-storage-psa'
4+
5+
export const dataSources = [
6+
{
7+
name: 'NFT.Storage (Classic)',
8+
logo: <img src='/nftstorage-logo.png' width='350' />,
9+
source: NFTStorage
10+
},
11+
{
12+
name: 'Web3.Storage (Old)',
13+
logo: <img src='/web3storage-logo.png' width='350' />,
14+
source: Web3Storage
15+
},
16+
{
17+
name: 'Web3.Storage Pinning Service API (Old)',
18+
logo: <img src='/web3storage-psa-logo.png' width='274' style={{ margin: 'auto 38px' }} />,
19+
source: Web3StoragePSA
20+
}
21+
]

src/components/MigrationsProvider.tsx

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import React, { createContext, useContext, ReactNode, useState, useEffect } from 'react'
4-
import { Migration, MigrationConfiguration, MigrationID, MigrationProgress, MigrationSource, UploadsSource } from '@/lib/migrations/api'
4+
import { Migration, MigrationConfiguration, MigrationID, Progress } from '@/lib/migrations/api'
55
import { useW3 } from '@w3ui/react'
66
import * as Migrations from '@/lib/migrations'
77
import { MigrationsStorage } from '@/lib/migrations/store'
@@ -80,15 +80,15 @@ export function Provider ({ children }: ProviderProps): ReactNode {
8080
const controller = new AbortController()
8181
runningMigrations[id] = controller
8282

83-
const uploads = Migrations.create(migration.source, {
83+
const uploads = Migrations.createReader(migration.source, {
8484
token: migration.token,
8585
cursor: migration.progress?.cursor
8686
})
8787
const initProgress = async () => ({
8888
pending: await uploads.count(),
8989
succeeded: 0,
9090
failed: []
91-
}) as MigrationProgress
91+
}) as Progress
9292

9393
Migrations.migrate({
9494
signal: controller.signal,
@@ -127,7 +127,7 @@ export function Provider ({ children }: ProviderProps): ReactNode {
127127
},
128128
onError: async (err, upload, shard) => {
129129
console.error(err)
130-
log(id, `migration failed ${upload.root}${shard ? ` (shard: ${shard.link})` : ''}: ${err.stack}`)
130+
log(id, `failed migration ${upload.root}${shard ? ` (shard: ${shard.link})` : ''}: ${err.stack}`)
131131
const migration = migrationsStore.read(id)
132132
migration.progress = migration.progress ?? await initProgress()
133133
migration.progress.failed.push(upload.root)
@@ -138,6 +138,16 @@ export function Provider ({ children }: ProviderProps): ReactNode {
138138
}
139139
migrationsStore.update(migration)
140140
setMigrations(() => migrationsStore.load())
141+
},
142+
onComplete: async () => {
143+
log(id, 'finished migration')
144+
const migration = migrationsStore.read(id)
145+
// there will be no progress if there are 0 items to migrate
146+
if (!migration.progress) {
147+
migration.progress = migration.progress ?? await initProgress()
148+
migrationsStore.update(migration)
149+
setMigrations(() => migrationsStore.load())
150+
}
141151
}
142152
})
143153
}

src/components/SpaceCreator.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function SpaceCreatorForm({
8787
return (
8888
<div className={className}>
8989
<div className='max-w-3xl border border-hot-red rounded-2xl'>
90-
<SpacePreview did={space.did()} name={space.name} />
90+
<SpacePreview did={space.did()} name={space.name} capabilities={['*']} />
9191
</div>
9292
</div>
9393
)

src/lib/migrations/api.ts

+28-14
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,48 @@
1-
import { CARLink, DIDKey, Link, UnknownLink } from '@w3ui/react'
1+
import { CARLink, DIDKey, UnknownLink } from '@w3ui/react'
22

3-
export type MigrationID = string
3+
/** A data source for a migration. */
4+
export interface DataSource {
5+
/** Identifier for the migration. */
6+
id: DataSourceID
7+
/** Checks a token can be used, throws if invalid. */
8+
checkToken: (token: string) => Promise<void>
9+
/** Creates a new reader instance for this migration source */
10+
createReader: (config: DataSourceConfiguration) => Reader
11+
}
412

5-
export type MigrationSource = 'classic.nft.storage' | 'old.web3.storage'
13+
export type DataSourceID = 'classic.nft.storage' | 'old.web3.storage' | 'psa.old.web3.storage'
614

7-
export interface MigrationSourceConfiguration {
15+
export interface DataSourceConfiguration {
816
/** API token for data source */
917
token: string
1018
/** Cursor for resuming migration. */
1119
cursor?: string
1220
}
1321

22+
export interface Reader extends AsyncIterable<Upload> {
23+
/** The total number of uploads to be migrated. */
24+
count: () => Promise<number>
25+
}
26+
1427
export interface MigrationConfiguration {
1528
/** Data source */
16-
source: MigrationSource
29+
source: DataSourceID
1730
/** API token for data source */
1831
token: string
1932
/** Target space to migrate data to */
2033
space: DIDKey
2134
}
2235

23-
export interface MigrationProgress {
36+
export interface Migration extends MigrationConfiguration {
37+
id: MigrationID
38+
/** The progress of the migration. */
39+
progress?: Progress
40+
}
41+
42+
/** A opaque string used to identify the migration instance. */
43+
export type MigrationID = string
44+
45+
export interface Progress {
2446
/** The current item being migrated. */
2547
current?: UnknownLink
2648
/** Cursor for resuming migration. */
@@ -33,12 +55,6 @@ export interface MigrationProgress {
3355
failed: UnknownLink[]
3456
}
3557

36-
export interface Migration extends MigrationConfiguration {
37-
id: MigrationID
38-
/** The progress of the migration. */
39-
progress?: MigrationProgress
40-
}
41-
4258
export interface Shard {
4359
link: CARLink
4460
size: () => Promise<number>
@@ -49,5 +65,3 @@ export interface Upload {
4965
root: UnknownLink
5066
shards: Shard[]
5167
}
52-
53-
export interface UploadsSource extends AsyncIterable<Upload> {}

src/lib/migrations/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const carCode = 0x0202

src/lib/migrations/gateway.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { UnknownLink } from 'multiformats'
2+
import * as Link from 'multiformats/link'
3+
import { sha256 } from 'multiformats/hashes/sha2'
4+
import { CarBlockIterator } from '@ipld/car'
5+
import { LinkIndexer } from 'linkdex'
6+
import { carCode } from './constants'
7+
import { Shard } from './api'
8+
9+
export const fetchCAR = async (root: UnknownLink, options?: { timeout?: number }): Promise<Shard> => {
10+
const controller = new AbortController()
11+
const timeoutID = setTimeout(() => controller.abort(), options?.timeout ?? 30_000)
12+
try {
13+
const res = await fetch(`https://w3s.link/ipfs/${root}?format=car`, { signal: controller.signal })
14+
if (!res.ok) throw new Error('failed to get DAG as CAR', { cause: { status: res.status } })
15+
clearTimeout(timeoutID)
16+
17+
const bytes = new Uint8Array(await res.arrayBuffer())
18+
// Verify CAR is complete
19+
const iterator = await CarBlockIterator.fromBytes(bytes)
20+
const index = new LinkIndexer()
21+
for await (const block of iterator) {
22+
index.decodeAndIndex(block)
23+
}
24+
if (!index.isCompleteDag()) {
25+
throw new Error('CAR does not contain a complete DAG')
26+
}
27+
const link = Link.create(carCode, await sha256.digest(bytes))
28+
return { link, size: async () => bytes.length, bytes: async () => bytes }
29+
} finally {
30+
clearTimeout(timeoutID)
31+
}
32+
}

0 commit comments

Comments
 (0)