Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
17 kB
444 lines
1import { docResolver, cleanHandle, handleResolver } from './atprotoUtils.js'
2import { AtpAgent } from '@atproto/api'
3
4function safeStatusUpdate(statusUpdateHandler, status) {
5 if (statusUpdateHandler) {
6 statusUpdateHandler(status)
7 }
8}
9
10/**
11 * Handles normal PDS Migrations between two PDSs that are both up.
12 * On pdsmoover.com this is the logic for the MOOver
13 */
14class Migrator {
15 constructor() {
16 /** @type {AtpAgent} */
17 this.oldAgent = null
18 /** @type {AtpAgent} */
19 this.newAgent = null
20 /** @type {[string]} */
21 this.missingBlobs = []
22 //State for reruns
23 /** @type {boolean} */
24 this.createNewAccount = true
25 /** @type {boolean} */
26 this.migrateRepo = true
27 /** @type {boolean} */
28 this.migrateBlobs = true
29 /** @type {boolean} */
30 this.migrateMissingBlobs = true
31 /** @type {boolean} */
32 this.migratePrefs = true
33 /** @type {boolean} */
34 this.migratePlcRecord = true
35 }
36
37 /**
38 * This migrator is pretty cut and dry and makes a few assumptions
39 * 1. You are using the same password between each account
40 * 2. If this command fails for something like oauth 2fa code it throws an error and expects the same values when ran again.
41 * 3. You can control which "actions" happen by setting the class variables to false.
42 * 4. Each instance of the class is assumed to be for a single migration
43 * @param {string} oldHandle - The handle you use on your old pds, something like alice.bsky.social
44 * @param {string} password - Your password for your current login. Has to be your real password, no app password. When setting up a new account we reuse it as well for that account
45 * @param {string} newPdsUrl - The new URL for your pds. Like https://coolnewpds.com
46 * @param {string} newEmail - The email you want to use on the new pds (can be the same as the previous one as long as it's not already being used on the new pds)
47 * @param {string} newHandle - The new handle you want, like alice.bsky.social, or if you already have a domain name set as a handle can use it myname.com.
48 * @param {string|null} inviteCode - The invite code you got from the PDS you are migrating to. If null does not include one
49 * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status)
50 * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
51 * @param verificationCode - Optional verification captcha code for account creation if the PDS requires it
52 */
53 async migrate(
54 oldHandle,
55 password,
56 newPdsUrl,
57 newEmail,
58 newHandle,
59 inviteCode,
60 statusUpdateHandler = null,
61 twoFactorCode = null,
62 verificationCode = null,
63 ) {
64 //Leaving this logic that either sets the agent to bsky.social, or the PDS since it's what I found worked best for migrations.
65 // handleAndPDSResolver should be able to handle it, but there have been edge cases and this was what worked best
66 oldHandle = cleanHandle(oldHandle)
67 let oldAgent
68 let usersDid
69 //If it's a bsky handle just go with the entryway and let it sort everything
70 if (oldHandle.endsWith('.bsky.social')) {
71 oldAgent = new AtpAgent({ service: 'https://bsky.social' })
72 const publicAgent = new AtpAgent({
73 service: 'https://public.api.bsky.app',
74 })
75 const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({
76 handle: oldHandle,
77 })
78 usersDid = resolveIdentityFromEntryway.data.did
79 } else {
80 //Resolves the did and finds the did document for the old PDS
81 safeStatusUpdate(statusUpdateHandler, 'Resolving old PDS')
82 usersDid = await handleResolver.resolve(oldHandle)
83 const didDoc = await docResolver.resolve(usersDid)
84 safeStatusUpdate(
85 statusUpdateHandler,
86 'Resolving did document and finding your current PDS URL',
87 )
88
89 let oldPds
90 try {
91 oldPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0]
92 .serviceEndpoint
93 } catch (error) {
94 console.error(error)
95 throw new Error('Could not find a PDS in the DID document.')
96 }
97
98 oldAgent = new AtpAgent({
99 service: oldPds,
100 })
101 }
102
103 safeStatusUpdate(statusUpdateHandler, 'Logging you in to the old PDS')
104 //Login to the old PDS
105 if (twoFactorCode === null) {
106 await oldAgent.login({ identifier: oldHandle, password })
107 } else {
108 await oldAgent.login({
109 identifier: oldHandle,
110 password: password,
111 authFactorToken: twoFactorCode,
112 })
113 }
114
115 safeStatusUpdate(
116 statusUpdateHandler,
117 'Checking that the new PDS is an actual PDS (if the url is wrong this takes a while to error out)',
118 )
119 const newAgent = new AtpAgent({ service: newPdsUrl })
120 const newHostDesc = await newAgent.com.atproto.server.describeServer()
121 if (this.createNewAccount) {
122 const newHostWebDid = newHostDesc.data.did
123
124 safeStatusUpdate(statusUpdateHandler, 'Creating a new account on the new PDS')
125
126 const createAuthResp = await oldAgent.com.atproto.server.getServiceAuth({
127 aud: newHostWebDid,
128 lxm: 'com.atproto.server.createAccount',
129 })
130 const serviceJwt = createAuthResp.data.token
131
132 let createAccountRequest = {
133 did: usersDid,
134 handle: newHandle,
135 email: newEmail,
136 password: password,
137 }
138 if (inviteCode) {
139 createAccountRequest.inviteCode = inviteCode
140 }
141 if (verificationCode) {
142 createAccountRequest.verificationCode = verificationCode
143 }
144 const createNewAccount = await newAgent.com.atproto.server.createAccount(
145 createAccountRequest,
146 {
147 headers: { authorization: `Bearer ${serviceJwt}` },
148 encoding: 'application/json',
149 },
150 )
151
152 if (createNewAccount.data.did !== usersDid.toString()) {
153 throw new Error('Did not create the new account with the same did as the old account')
154 }
155 }
156 safeStatusUpdate(statusUpdateHandler, 'Logging in with the new account')
157
158 await newAgent.login({
159 identifier: usersDid,
160 password: password,
161 })
162
163 if (this.migrateRepo) {
164 safeStatusUpdate(statusUpdateHandler, 'Migrating your repo')
165 const repoRes = await oldAgent.com.atproto.sync.getRepo({
166 did: usersDid,
167 })
168 await newAgent.com.atproto.repo.importRepo(repoRes.data, {
169 encoding: 'application/vnd.ipld.car',
170 })
171 }
172
173 let newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus()
174
175 if (this.migrateBlobs) {
176 safeStatusUpdate(statusUpdateHandler, 'Migrating your blobs')
177
178 let blobCursor = undefined
179 let uploadedBlobs = 0
180 do {
181 safeStatusUpdate(
182 statusUpdateHandler,
183 `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`,
184 )
185
186 const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
187 did: usersDid,
188 cursor: blobCursor,
189 limit: 100,
190 })
191
192 for (const cid of listedBlobs.data.cids) {
193 try {
194 const blobRes = await oldAgent.com.atproto.sync.getBlob({
195 did: usersDid,
196 cid,
197 })
198 await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
199 encoding: blobRes.headers['content-type'],
200 })
201 uploadedBlobs++
202 if (uploadedBlobs % 10 === 0) {
203 safeStatusUpdate(
204 statusUpdateHandler,
205 `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`,
206 )
207 }
208 } catch (error) {
209 console.error(error)
210 }
211 }
212 blobCursor = listedBlobs.data.cursor
213 } while (blobCursor)
214 }
215
216 if (this.migrateMissingBlobs) {
217 newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus()
218 if (newAccountStatus.data.expectedBlobs !== newAccountStatus.data.importedBlobs) {
219 let totalMissingBlobs =
220 newAccountStatus.data.expectedBlobs - newAccountStatus.data.importedBlobs
221 safeStatusUpdate(
222 statusUpdateHandler,
223 'Looks like there are some missing blobs. Going to try and upload them now.',
224 )
225 //Probably should be shared between main blob uploader, but eh
226 let missingBlobCursor = undefined
227 let missingUploadedBlobs = 0
228 do {
229 safeStatusUpdate(
230 statusUpdateHandler,
231 `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`,
232 )
233
234 const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs({
235 cursor: missingBlobCursor,
236 limit: 100,
237 })
238
239 for (const recordBlob of missingBlobs.data.blobs) {
240 try {
241 const blobRes = await oldAgent.com.atproto.sync.getBlob({
242 did: usersDid,
243 cid: recordBlob.cid,
244 })
245 await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
246 encoding: blobRes.headers['content-type'],
247 })
248 if (missingUploadedBlobs % 10 === 0) {
249 safeStatusUpdate(
250 statusUpdateHandler,
251 `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`,
252 )
253 }
254 missingUploadedBlobs++
255 } catch (error) {
256 //TODO silently logging prob should list them so user can manually download
257 console.error(error)
258 this.missingBlobs.push(recordBlob.cid)
259 }
260 }
261 missingBlobCursor = missingBlobs.data.cursor
262 } while (missingBlobCursor)
263 }
264 }
265 if (this.migratePrefs) {
266 const prefs = await oldAgent.app.bsky.actor.getPreferences()
267 await newAgent.app.bsky.actor.putPreferences(prefs.data)
268 }
269
270 this.oldAgent = oldAgent
271 this.newAgent = newAgent
272
273 if (this.migratePlcRecord) {
274 await oldAgent.com.atproto.identity.requestPlcOperationSignature()
275 safeStatusUpdate(statusUpdateHandler, 'Please check your email for a PLC token')
276 }
277 }
278
279 /**
280 * Sign and submits the PLC operation to officially migrate the account
281 * @param {string} token - the PLC token sent in the email. If you're just wanting to run this rerun migrate with all the flags set as false except for migratePlcRecord
282 * @param additionalRotationKeysToAdd {string[]} - additional rotation keys to add in addition to the ones provided by the new PDS.
283 * @returns {Promise<void>}
284 */
285 async signPlcOperation(token, additionalRotationKeysToAdd = []) {
286 const getDidCredentials =
287 await this.newAgent.com.atproto.identity.getRecommendedDidCredentials()
288 const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? []
289 // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key
290 const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys]
291 if (!rotationKeys) {
292 throw new Error('No rotation key provided from the new PDS')
293 }
294 const credentials = {
295 ...getDidCredentials.data,
296 rotationKeys: rotationKeys,
297 }
298
299 const plcOp = await this.oldAgent.com.atproto.identity.signPlcOperation({
300 token: token,
301 ...credentials,
302 })
303
304 await this.newAgent.com.atproto.identity.submitPlcOperation({
305 operation: plcOp.data.operation,
306 })
307
308 await this.newAgent.com.atproto.server.activateAccount()
309 await this.oldAgent.com.atproto.server.deactivateAccount({})
310 }
311
312 /**
313 * Using this method assumes the Migrator class was constructed new and this was called.
314 * Find the user's previous PDS from the PLC op logs,
315 * logs in and deactivates their old account if it was found still active.
316 *
317 * @param oldHandle {string}
318 * @param oldPassword {string}
319 * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI.
320 * Like (status) => console.log(status)
321 * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
322 * @returns {Promise<void>}
323 */
324 async deactivateOldAccount(
325 oldHandle,
326 oldPassword,
327 statusUpdateHandler = null,
328 twoFactorCode = null,
329 ) {
330 //Leaving this logic that either sets the agent to bsky.social, or the PDS since it's what I found worked best for migrations.
331 // handleAndPDSResolver should be able to handle it, but there have been edge cases and this was what worked best oldHandle = cleanHandle(oldHandle);
332 let usersDid
333 //If it's a bsky handle just go with the entryway and let it sort everything
334 if (oldHandle.endsWith('.bsky.social')) {
335 const publicAgent = new AtpAgent({
336 service: 'https://public.api.bsky.app',
337 })
338 const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({
339 handle: oldHandle,
340 })
341 usersDid = resolveIdentityFromEntryway.data.did
342 } else {
343 //Resolves the did and finds the did document for the old PDS
344 safeStatusUpdate(statusUpdateHandler, 'Resolving did from handle')
345 usersDid = await handleResolver.resolve(oldHandle)
346 }
347
348 const didDoc = await docResolver.resolve(usersDid)
349 let currentPds
350 try {
351 currentPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0]
352 .serviceEndpoint
353 } catch (error) {
354 console.error(error)
355 throw new Error('Could not find a PDS in the DID document.')
356 }
357
358 const plcLogRequest = await fetch(`https://plc.directory/${usersDid}/log`)
359 const plcLog = await plcLogRequest.json()
360 let pdsBeforeCurrent = ''
361 for (const log of plcLog) {
362 try {
363 const pds = log.services.atproto_pds.endpoint
364 if (pds.toLowerCase() === currentPds.toLowerCase()) {
365 console.log('Found the PDS before the current one')
366 break
367 }
368 pdsBeforeCurrent = pds
369 } catch (e) {
370 console.log(e)
371 }
372 }
373 if (pdsBeforeCurrent === '') {
374 throw new Error('Could not find the PDS before the current one')
375 }
376
377 let oldAgent = new AtpAgent({ service: pdsBeforeCurrent })
378 safeStatusUpdate(statusUpdateHandler, `Logging you in to the old PDS: ${pdsBeforeCurrent}`)
379 //Login to the old PDS
380 if (twoFactorCode === null) {
381 await oldAgent.login({ identifier: oldHandle, password: oldPassword })
382 } else {
383 await oldAgent.login({
384 identifier: oldHandle,
385 password: oldPassword,
386 authFactorToken: twoFactorCode,
387 })
388 }
389 safeStatusUpdate(statusUpdateHandler, "Checking this isn't your current PDS")
390 if (pdsBeforeCurrent === currentPds) {
391 throw new Error('This is your current PDS. Login to your old account username and password')
392 }
393
394 let currentAccountStatus = await oldAgent.com.atproto.server.checkAccountStatus()
395 if (!currentAccountStatus.data.activated) {
396 safeStatusUpdate(statusUpdateHandler, 'All good. Your old account is not activated.')
397 }
398 safeStatusUpdate(statusUpdateHandler, 'Deactivating your OLD account')
399 await oldAgent.com.atproto.server.deactivateAccount({})
400 safeStatusUpdate(statusUpdateHandler, 'Successfully deactivated your OLD account')
401 }
402
403 /**
404 * Signs the logged-in user in this.newAgent for backups with PDS MOOver. This is usually called after migrate and signPlcOperation are successful
405 *
406 * @param {string} didWeb
407 * @returns {Promise<void>}
408 */
409 async signUpForBackupsFromMigration(didWeb = 'did:web:pdsmoover.com') {
410 //Manually grabbing the jwt and making a call with fetch cause for the life of me I could not figure out
411 //how you used @atproto/api to make a call for proxying
412 const url = `${this.newAgent.serviceUrl.origin}/xrpc/com.pdsmoover.backup.signUp`
413
414 const accessJwt = this.newAgent?.session?.accessJwt
415 if (!accessJwt) {
416 throw new Error('Missing access token for authorization')
417 }
418
419 const res = await fetch(url, {
420 method: 'POST',
421 headers: {
422 'Authorization': `Bearer ${accessJwt}`,
423 'Content-Type': 'application/json',
424 'Accept': 'application/json',
425 'atproto-proxy': `${didWeb}#repo_backup`,
426 },
427 body: JSON.stringify({}),
428 })
429
430 if (!res.ok) {
431 let bodyText = ''
432 try {
433 bodyText = await res.text()
434 } catch {}
435 throw new Error(
436 `Backup signup failed: ${res.status} ${res.statusText}${bodyText ? ` - ${bodyText}` : ''}`,
437 )
438 }
439
440 //No return the success is all that is needed, if there's an error it will throw
441 }
442}
443
444export { Migrator }