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

PDS auto fill link

authored by baileytownsend.dev and committed by Tangled bb24992b 999cdbef

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

This is a binary file and will not be displayed.

+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 ··· 72 75 version: 4.52.5 73 76 74 77 packages: 78 + 79 + '@atcute/atproto@3.1.9': 80 + resolution: {integrity: sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==} 75 81 76 82 '@atcute/cbor@2.2.7': 77 83 resolution: {integrity: sha512-/mwAF0gnokOphceZqFq3uzMGdd8sbw5y6bxF8CRutRkCCUcpjjpJc5fkLwhxyGgOveF3mZuHE6p7t/+IAqb7Aw==} ··· 1341 1347 resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 1342 1348 1343 1349 snapshots: 1350 + 1351 + '@atcute/atproto@3.1.9': 1352 + dependencies: 1353 + '@atcute/lexicons': 1.2.2 1344 1354 1345 1355 '@atcute/cbor@2.2.7': 1346 1356 dependencies:
+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>
+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 } ··· 49 86 errorMessage = 'Please confirm that you understand the risks of doing an account migration'; 50 87 disableSubmit = false; 51 88 return; 89 + } 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 + } 52 106 } 53 107 54 108 try { ··· 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> 216 + 159 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}> 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} 226 + 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> 165 236 </div> 166 237 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}> 172 - </div> 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> 247 + 174 248 175 249 <div class="form-group"> 176 250 <button type="button" onclick={() => showAdvance = !showAdvance} id="advance" name="advance">Advance ··· 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
+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 + };