Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow

Compare changes

Choose any two refs to compare.

Changed files
+214 -33
.tangled
images
web-ui
.tangled/images/network.webp

This is a binary file and will not be displayed.

+2 -2
README.md
··· 9 9 10 10 - Looking for the old pds moover for simple code to fork 11 11 check [here](https://tangled.org/@baileytownsend.dev/pds-moover/tree/803d8a70b7100c9e14df3402277441050e0f6194), if 12 - you'd like to see the newer front end check [here](./web/ui-code/src) 12 + you'd like to see the newer front end check [here](./web-ui) 13 13 - Want to run your own instance of PDS MOOver? [check this docker compose](./compose.selfhost.yml). It should have all 14 14 the 15 15 services in one easy `docker compose up`, just don't forget to create a `.env` from [.env.template](.env.template) ··· 43 43 ## Do you have a pretty picture to show how the network looks? 44 44 45 45 yes. Thanks to [Orual](https://bsky.app/profile/nonbinary.computer) 46 - ![](./web/public/PDSMOOver.excalidraw.png) 46 + ![](.tangled/images/network.webp)
+1 -1
justfile
··· 25 25 docker buildx build \ 26 26 --platform linux/arm64,linux/amd64 \ 27 27 --tag fatfingers23/moover_ui:latest \ 28 - --tag fatfingers23/moover_ui:0.0.2 \ 28 + --tag fatfingers23/moover_ui:0.0.3 \ 29 29 --file Dockerfiles/web-ui.Dockerfile \ 30 30 --push .
test.compose.yml
+1
web-ui/package.json
··· 13 13 "lint": "eslint ." 14 14 }, 15 15 "dependencies": { 16 + "@atcute/atproto": "^3.1.9", 16 17 "@atcute/client": "^4.0.5", 17 18 "@atcute/lexicons": "^1.2.2", 18 19 "@pds-moover/lexicons": "^1.0.1",
+10
web-ui/pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atcute/atproto': 12 + specifier: ^3.1.9 13 + version: 3.1.9 11 14 '@atcute/client': 12 15 specifier: ^4.0.5 13 16 version: 4.0.5 ··· 73 76 74 77 packages: 75 78 79 + '@atcute/atproto@3.1.9': 80 + resolution: {integrity: sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==} 81 + 76 82 '@atcute/cbor@2.2.7': 77 83 resolution: {integrity: sha512-/mwAF0gnokOphceZqFq3uzMGdd8sbw5y6bxF8CRutRkCCUcpjjpJc5fkLwhxyGgOveF3mZuHE6p7t/+IAqb7Aw==} 78 84 ··· 1342 1348 1343 1349 snapshots: 1344 1350 1351 + '@atcute/atproto@3.1.9': 1352 + dependencies: 1353 + '@atcute/lexicons': 1.2.2 1354 + 1345 1355 '@atcute/cbor@2.2.7': 1346 1356 dependencies: 1347 1357 '@atcute/cid': 2.2.6
+8 -8
web-ui/src/app.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 - <head> 4 - <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 - %sveltekit.head% 7 - </head> 8 - <body data-sveltekit-preload-data="hover"> 9 - <div style="display: contents">%sveltekit.body%</div> 10 - </body> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/> 6 + %sveltekit.head% 7 + </head> 8 + <body data-sveltekit-preload-data="hover"> 9 + <div style="display: contents">%sveltekit.body%</div> 10 + </body> 11 11 </html>
+33
web-ui/src/lib/assets/style.css
··· 89 89 box-sizing: border-box; 90 90 } 91 91 92 + /* Input group for handle with domain dropdown */ 93 + .input-group { 94 + display: flex; 95 + width: 100%; 96 + } 97 + 98 + .input-group input { 99 + flex: 1; 100 + border-top-right-radius: 0; 101 + border-bottom-right-radius: 0; 102 + border-right: none; 103 + } 104 + 105 + .input-group .domain-select { 106 + padding: 8px; 107 + border: 1px solid rgba(128, 128, 128, 0.5); 108 + border-top-left-radius: 0; 109 + border-bottom-left-radius: 0; 110 + border-top-right-radius: 4px; 111 + border-bottom-right-radius: 4px; 112 + background-color: #1a1a1a; 113 + color: rgba(255, 255, 255, 0.87); 114 + cursor: pointer; 115 + min-width: 120px; 116 + } 117 + 118 + @media (prefers-color-scheme: light) { 119 + .input-group .domain-select { 120 + background-color: #f9f9f9; 121 + color: #213547; 122 + } 123 + } 124 + 92 125 .cow-image { 93 126 height: 150px; 94 127 margin: 20px 0 8px 0;
+3 -2
web-ui/src/lib/components/NavBar.svelte
··· 15 15 ]; 16 16 </script> 17 17 18 - <header class="navbar" role="banner"> 18 + <header class="navbar"> 19 19 20 20 <div class="navbar-inner"> 21 21 <a class="brand" href={resolve('/')}>PDS MOOver</a> ··· 45 45 <nav id="primary-navigation" class="nav-links {open ? 'open' : ''}" aria-label="Primary" 46 46 > 47 47 {#each links as link (link.path)} 48 - <a href={resolve(link.path)} class={page.url.pathname === link.path ? 'active' : '' } onclick={() => open = false}>{link.text}</a> 48 + <a href={resolve(link.path)} class={page.url.pathname.startsWith(link.path) ? 'active' : '' } 49 + onclick={() => open = false}>{link.text}</a> 49 50 {/each} 50 51 </nav> 51 52 </div>
+34
web-ui/src/routes/moover/[[pds]]/+page.server.ts
··· 1 + import type {PageServerLoad} from './$types'; 2 + import {Client, simpleFetchHandler} from '@atcute/client'; 3 + import type {} from '@atcute/atproto'; 4 + import {env} from '$env/dynamic/private'; 5 + 6 + export const load: PageServerLoad = async ({params}) => { 7 + 8 + if (!params.pds) { 9 + return {pdsOptions: null, intinalDomain: null}; 10 + } 11 + 12 + const allowedPds = env.PDS_AUTOFILL.split(','); 13 + if (!allowedPds.includes(params.pds.toLowerCase())) { 14 + console.error('PDS not allowed', params.pds); 15 + return {pdsOptions: null, intinalDomain: null}; 16 + } 17 + 18 + try { 19 + const handler = simpleFetchHandler({service: `https://${params.pds}`}); 20 + const rpc = new Client({handler}); 21 + const {ok, data} = await rpc.get('com.atproto.server.describeServer', {}) 22 + if (!ok) { 23 + console.error('Failed to describe the PDS server', data); 24 + return {pds: null}; 25 + } 26 + return { 27 + pdsOptions: data, 28 + intinalDomain: data?.availableUserDomains[0] ?? '' 29 + }; 30 + } catch (e) { 31 + console.error('Failed to describe the PDS server', e); 32 + return {pdsOptions: null, intinalDomain: null}; 33 + } 34 + };
+122 -20
web-ui/src/routes/moover/+page.svelte web-ui/src/routes/moover/[[pds]]/+page.svelte
··· 5 5 import {Migrator} from '@pds-moover/moover'; 6 6 import SignThePapers from './SignThePapers.svelte'; 7 7 8 + let {data} = $props(); 9 + 10 + let selectedPds = $derived(data.pdsOptions); 11 + let cleanSelectedPds = $derived(selectedPds?.did.replace('did:web:', '')); 12 + //Kept as a "global" state to handle logic of passing the full handle that is used to SignThePapers 13 + let newHandle = $state(''); 14 + 15 + let selectedDomain = $state(data.intinalDomain); 16 + 17 + let handlePlaceHolder = $derived( 18 + selectedPds ? `username${selectedDomain === 'custom' ? '' : `${selectedPds?.availableUserDomains[0]}`} or mydomain.com` : 'username.newpds.com or mycooldomain.com') 19 + 20 + 21 + $effect(() => { 22 + if (!selectedPds) return; 23 + 24 + if (selectedDomain == 'custom') return; 25 + 26 + 27 + if (formData.newHandle.includes('.')) { 28 + // When a period is typed, force custom domain selection 29 + selectedDomain = 'custom'; 30 + } else { 31 + // If user clears the dot and we have provider domains, fall back to first option 32 + if ((selectedPds?.availableUserDomains?.length ?? 0) > 0 && selectedDomain === 'custom') { 33 + selectedDomain = selectedPds!.availableUserDomains[0]! 34 + } 35 + } 36 + }); 37 + 8 38 let formData = $state({ 9 39 handle: '', 10 40 password: '', ··· 14 44 inviteCode: null, 15 45 twoFactorCode: null, 16 46 confirmation: false, 47 + // Acceptance of provider policies (when required by selected PDS) 48 + acceptPolicies: false, 17 49 // Advanced options 18 50 createNewAccount: true, 19 51 migrateRepo: true, ··· 35 67 let errorMessage: null | string = $state(null); 36 68 let statusMessage: null | string = $state(null); 37 69 70 + // Links that may require acceptance prior to migration from the selected PDS 71 + const privacyUrl = $derived(selectedPds?.links?.privacyPolicy); 72 + const tosUrl = $derived(selectedPds?.links?.termsOfService); 73 + const requiresAccept = $derived(!!(privacyUrl || tosUrl)); 74 + 38 75 const updateStatusHandler = (status: string) => { 39 76 statusMessage = status; 40 77 } ··· 51 88 return; 52 89 } 53 90 91 + // If the selected PDS provides policy or privacy links, require explicit acceptance 92 + if (requiresAccept && !formData.acceptPolicies) { 93 + errorMessage = 'Please review and accept the providers policies'; 94 + disableSubmit = false; 95 + return; 96 + } 97 + newHandle = formData.newHandle; 98 + if (selectedPds) { 99 + //Not happy about this unwrap, but it should always have a value on a legit PDS that I know of 100 + 101 + formData.newPds = `https://${cleanSelectedPds!}`; 102 + // Combine username and selected domain for the new handle 103 + if (selectedDomain !== 'custom') { 104 + newHandle = formData.newHandle + selectedDomain; 105 + } 106 + } 107 + 54 108 try { 55 109 56 110 if (showTwoFactorCodeInput) { ··· 69 123 migrator.migratePrefs = formData.migratePrefs; 70 124 migrator.migratePlcRecord = formData.migratePlcRecord; 71 125 72 - console.log(migrator); 126 + console.log(formData.newPds, newHandle); 73 127 74 128 updateStatusHandler('Starting migration...'); 75 129 showStatusMessage = true; ··· 78 132 formData.password, 79 133 formData.newPds, 80 134 formData.newEmail, 81 - formData.newHandle, 135 + newHandle, 82 136 formData.inviteCode, 83 137 updateStatusHandler, 84 138 formData.twoFactorCode, ··· 144 198 145 199 <!-- Second section: New account details --> 146 200 <div class="section"> 147 - <h2>Setup for the new PDS</h2> 148 - <div class="form-group"> 149 - <label for="new-pds">New PDS (URL):</label> 150 - <input type="url" id="new-pds" name="newPds" placeholder="https://coolnewpds.com" 151 - required bind:value={formData.newPds}> 152 - </div> 201 + <h2>{selectedPds ? `Setup for ${cleanSelectedPds}` : 'Setup for the new PDS'}</h2> 202 + {#if !selectedPds} 203 + <div class="form-group"> 204 + <label for="new-pds">New PDS (URL):</label> 205 + <input type="url" id="new-pds" name="newPds" placeholder="https://coolnewpds.com" 206 + required bind:value={formData.newPds}> 207 + </div> 208 + {/if} 153 209 154 210 <div class="form-group"> 155 211 <label for="new-email">New Email:</label> 156 - <input type="email" id="new-email" name="newEmail" placeholder="CanBeSameEmailAsTheOldPds@email.com" 212 + <input type="email" id="new-email" name="newEmail" 213 + placeholder="CanBeSameEmailAsTheOldPds@email.com" 157 214 required bind:value={formData.newEmail}> 158 215 </div> 159 216 217 + 160 218 <div class="form-group"> 161 219 <label for="new-handle">New Handle:</label> 162 - <input type="text" id="new-handle" name="newHandle" 163 - placeholder="username.newpds.com or mycooldomain.com" required 164 - bind:value={formData.newHandle}> 165 - </div> 220 + <div class={selectedPds ? 'input-group' : ''}> 221 + <input type="text" id="new-handle" name="newHandle" 222 + placeholder="{handlePlaceHolder}" 223 + required 224 + bind:value={formData.newHandle}> 225 + {#if selectedPds} 166 226 167 - <div class="form-group"> 168 - <label for="invite-code">Invite Code:</label> 169 - <input type="text" id="invite-code" name="inviteCode" 170 - placeholder="Invite code from your new PDS (Leave blank if you don't have one)" 171 - bind:value={formData.inviteCode}> 227 + <select bind:value={selectedDomain} class="domain-select"> 228 + {#each selectedPds?.availableUserDomains as domain (domain)} 229 + <option value={domain}>{domain}</option> 230 + {/each} 231 + <option value="custom">I have my own domain setup</option> 232 + 233 + </select> 234 + {/if} 235 + </div> 172 236 </div> 237 + 238 + {#if !selectedPds || selectedPds.inviteCodeRequired !== false} 239 + <div class="form-group"> 240 + <label for="invite-code">Invite Code:</label> 241 + <input type="text" id="invite-code" name="inviteCode" 242 + placeholder="Invite code from your new PDS (Leave blank if you don't have one)" 243 + bind:value={formData.inviteCode}> 244 + </div> 245 + {/if} 173 246 </div> 174 247 248 + 175 249 <div class="form-group"> 176 250 <button type="button" onclick={() => showAdvance = !showAdvance} id="advance" name="advance">Advance 177 251 Options ··· 227 301 </div> 228 302 {/if} 229 303 304 + {#if requiresAccept} 305 + <div class="section" style="text-align: left"> 306 + <h3>Provider policies</h3> 307 + <p> 308 + To migrate to {cleanSelectedPds}, you must review and accept: 309 + </p> 310 + <ul> 311 + {#if privacyUrl} 312 + <li><a href={privacyUrl} target="_blank" rel="noopener noreferrer">Privacy 313 + Policy</a></li> 314 + {/if} 315 + {#if tosUrl} 316 + <li><a href={tosUrl} target="_blank" rel="noopener noreferrer">Terms of Service</a></li> 317 + {/if} 318 + </ul> 319 + <div class="form-group"> 320 + <label for="accept-policies" class="moove-checkbox-label"> 321 + <input bind:checked={formData.acceptPolicies} type="checkbox" id="accept-policies" 322 + name="acceptPolicies" required> 323 + <span> 324 + I have read and accept 325 + 326 + </span> 327 + </label> 328 + </div> 329 + </div> 330 + {/if} 230 331 <p style="text-align: left">There are some risks that come with doing an account migration. 231 332 (Can view them 232 333 <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md#%EF%B8%8F-warning-%EF%B8%8F-%EF%B8%8F">here</a>) ··· 257 358 {/if} 258 359 259 360 <div> 260 - <button disabled={disableSubmit} type="submit">MOOve</button> 361 + <button disabled={disableSubmit} 362 + type="submit">{selectedPds ? `MOOve to ${cleanSelectedPds}` : 'MOOve'}</button> 261 363 </div> 262 364 </form> 263 365 264 366 {:else} 265 - <SignThePapers migrator={migrator} newHandle={formData.newHandle}/> 367 + <SignThePapers migrator={migrator} newHandle={newHandle}/> 266 368 {/if} 267 369 </div>
web-ui/src/routes/moover/SignThePapers.svelte web-ui/src/routes/moover/[[pds]]/SignThePapers.svelte