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