Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow
at main 400 lines 21 kB view raw
1{%- import "partials/cow-header.askama.html" as cow -%} 2{% extends "layout.askama.html" %} 3 4{% block meta %} 5 <meta property="og:description" content="PDS MOOver backups"/> 6 <meta property="og:image" content="/halloween_moover.webp"> 7{% endblock %} 8 9{% block content %} 10 <script> 11 document.addEventListener('alpine:init', () => { 12 13 window.Alpine.data('backups', () => ({ 14 handle: '', 15 password: '', 16 twoFactorCode: '', 17 showTwoFactorCodeInput: false, 18 error: null, 19 showStatusMessage: false, 20 //The landing page to pick to login with password or oauth 21 showLandingButtons: true, 22 //Password login 23 showLoginScreen: false, 24 showRepoNotFoundScreen: false, 25 addRecoveryKey: true, 26 // Rotation key flow state 27 newlyCreatedRotationKey: null, // { publicKey, privateKey } 28 showRotationKeyScreen: false, 29 // PLC token manual entry for rotation key 30 plcTokenInput: null, 31 repoStatus: null, 32 updateStatusHandler(status) { 33 console.log("Status update:", status); 34 const el1 = document.getElementById("status-message"); 35 if (el1) el1.innerText = status; 36 const el2 = document.getElementById("status-message-2"); 37 if (el2) el2.innerText = status; 38 }, 39 async requestPlcToken() { 40 this.error = null; 41 try { 42 this.showStatusMessage = true; 43 this.updateStatusHandler('Requesting PLC token…'); 44 await window.BackupService.requestAPlcToken(); 45 this.updateStatusHandler('PLC token emailed. Check your inbox and paste the token below.'); 46 } catch (e) { 47 console.error(e); 48 this.error = e?.message || 'Failed to request PLC token'; 49 } finally { 50 this.showStatusMessage = false; 51 } 52 }, 53 handleShowLogin() { 54 this.showLandingButtons = false; 55 this.showLoginScreen = true; 56 }, 57 async handleOAuthSignup() { 58 // TODO: Replace this URL with your actual OAuth signup endpoint for backups 59 // If you have an environment-specific route, consider injecting it or making it configurable 60 window.location.href = '/oauth/backups'; 61 }, 62 async handleLoginSubmit() { 63 this.error = null; 64 this.showStatusMessage = false; 65 this.showLandingButtons = false; 66 this.error = null; 67 this.showStatusMessage = false; 68 69 try { 70 if (this.showTwoFactorCodeInput && !this.twoFactorCode) { 71 this.error = 'Please enter the 2FA that was sent to your email.'; 72 return; 73 } 74 75 this.showStatusMessage = true; 76 const result = await window.BackupService.loginAndStatus( 77 this.handle, 78 this.password, 79 this.updateStatusHandler, 80 this.twoFactorCode 81 ); 82 if (result === null) { 83 // Repo not found: offer signup flow 84 this.showRepoNotFoundScreen = true; 85 this.showLoginScreen = false; 86 this.showStatusMessage = false; 87 } else { 88 this.repoStatus = result; 89 this.showLoginScreen = false; 90 this.showStatusMessage = false; 91 } 92 } catch (error) { 93 this.showStatusMessage = false; 94 console.error(error.error, error.message); 95 if (error.error === 'AuthFactorTokenRequired') { 96 this.showTwoFactorCodeInput = true; 97 this.error = 'Two-factor code required. Check your email and enter the code.'; 98 } else { 99 this.error = error.message || 'An unexpected error occurred.'; 100 } 101 } 102 }, 103 async handleSignUpSubmit() { 104 this.error = null; 105 this.showStatusMessage = true; 106 try { 107 108 if (this.addRecoveryKey) { 109 window.handle = this.handle; 110 if (!this.plcTokenInput) { 111 this.error = 'Please paste your PLC token. Use the "Email me a PLC token" button to request one.'; 112 this.showStatusMessage = false; 113 return; 114 } 115 // Create a new rotation key and add it using BackupService 116 this.updateStatusHandler('Creating new rotation key…'); 117 const created = await window.PlcOps.createANewSecp256k1(); 118 this.newlyCreatedRotationKey = created; 119 this.updateStatusHandler('Adding new rotation key to your DID…'); 120 await window.BackupService.addANewRotationKey(this.plcTokenInput, created.publicKey); 121 this.updateStatusHandler('Rotation key added. Please save the private key now.'); 122 this.showRotationKeyScreen = true; 123 this.showRepoNotFoundScreen = false; 124 this.showLoginScreen = false; 125 this.showStatusMessage = false; 126 } 127 // If they cannot add a key it should error before signing them up 128 await window.BackupService.signUp(this.updateStatusHandler); 129 this.updateStatusHandler('Signed up for backups successfully.'); 130 131 this.repoStatus = await window.BackupService.getUsersRepoStatus(this.updateStatusHandler); 132 this.showLoginScreen = false; 133 this.showStatusMessage = false; 134 this.showRepoNotFoundScreen = false 135 } catch (error) { 136 console.error(error.error, error.message); 137 this.error = error.message || 'Failed to sign up for backups.'; 138 this.showStatusMessage = false; 139 } 140 }, 141 async proceedToRepoStatus() { 142 try { 143 this.showStatusMessage = true; 144 this.updateStatusHandler('Fetching repository status…'); 145 this.repoStatus = await window.BackupService.getUsersRepoStatus(this.updateStatusHandler); 146 this.showRotationKeyScreen = false; 147 this.showRepoNotFoundScreen = false; 148 this.showLoginScreen = false; 149 } catch (e) { 150 console.error(e); 151 this.error = e?.message || 'Failed to load repository status'; 152 } finally { 153 this.showStatusMessage = false; 154 } 155 }, 156 async handleRunBackupNow() { 157 this.error = null; 158 this.showStatusMessage = true; 159 try { 160 await window.BackupService.runBackupNow(this.updateStatusHandler); 161 this.repoStatus = await window.BackupService.getUsersRepoStatus(this.updateStatusHandler); 162 this.updateStatusHandler('Backup request sent. Status refreshed.'); 163 this.showStatusMessage = false; 164 } catch (error) { 165 console.error(error.error, error.message); 166 this.error = error.message || 'Failed to request backup.'; 167 } 168 }, 169 async handleRemoveRepo() { 170 if (confirm('Deleting your backups removes all your data and unregisters you for automatic backups. Your rotation key created here will stay valid, but you will not be able to restore your data from PDS MOOver.')) { 171 try { 172 await window.BackupService.removeRepo(this.updateStatusHandler); 173 this.repoStatus = await window.BackupService.getUsersRepoStatus(this.updateStatusHandler); 174 } catch (e) { 175 this.error = e.message || 'Failed to delete backup repository'; } } 176 }, 177 async refreshStatus(){ 178 try{ 179 this.repoStatus = await window.BackupService.getUsersRepoStatus(this.updateStatusHandler) 180 console.log(this.repoStatus.createdAt) 181 }catch(e){ 182 this.error = e.message || 'Failed to refresh status' 183 } 184 finally{ 185 this.showStatusMessage = false 186 } 187 }, 188 //These should be in their own file since they're not the only ones. just eh. weird vite setup and i dont got it in me rn 189 formatDate(value) { 190 if (!value) return '—'; 191 try { 192 const d = new Date(value); 193 return d.toLocaleString(); 194 } catch (err) { 195 console.error(err); 196 return String(value); 197 } 198 }, 199 formatBytes(bytes) { 200 if (bytes == null) return '—'; 201 const units = ['B', 'KB', 'MB', 'GB', 'TB']; 202 let i = 0; 203 let v = Number(bytes); 204 while (v >= 1024 && i < units.length - 1) { 205 v /= 1024; 206 i++; 207 } 208 return v.toFixed(1) + ' ' + units[i]; 209 } 210 211 })) 212 }) 213 </script> 214 215<div class="container" x-data="backups"> 216 217 218 {% call cow::cow_header("Backups") %} 219 220 221 <!-- Landing choice: two buttons --> 222 <div x-show="showLoginScreen"> 223 <!-- Informational section before sign-in --> 224 <div class="section" style="text-align: left;"> 225 <p> 226 PDS MOOver can provide worry-free backups of your AT Protocol account. 227 This is a free service for individual accounts 228 and stores the backups on PDS MOOver's servers. 229 Just like your <a target="_blank" rel="noopener noreferrer" href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">AT Proto data</a>, 230 this is also public. 231 <span x-show="showLoginScreen">On login, 232 you will be asked if you'd like to add a rotation key to your account. 233 A rotation key is a recovery key 234 that allows you to restore your account if your PDS ever goes down. 235 If you're already signed up for backups, then you can log in here to manage them.</span> 236 237 </p> 238 </div> 239 240 <div x-show="showLandingButtons" class="actions" style="display: flex; gap: 1rem; flex-wrap: wrap;"> 241 <button type="button" @click="handleShowLogin()">Sign in to add a recovery key</button> 242 <button type="button" @click="await handleOAuthSignup()">Sign in for backups with OAuth</button> 243 </div> 244 245 <!-- Sign in with password section --> 246 <form id="backup-signup-form" @submit.prevent="await handleLoginSubmit()"> 247 248 <div class="section"> 249 <h2>Sign in to your account</h2> 250 <div class="form-group"> 251 <label for="handle">Handle</label> 252 <input type="text" id="handle" name="handle" placeholder="alice.bsky.social" x-model="handle" required> 253 </div> 254 255 <div class="form-group"> 256 <label for="password">Real Password</label> 257 <input type="password" id="password" name="password" x-model="password" required> 258 <p> If you are signing up and adding a rotation key you have to use your account's real password. If you are just managing your backups or have your own rotation key you can use an app password</p> 259 260 </div> 261 262 <div x-show="showTwoFactorCodeInput" class="form-group"> 263 <label for="two-factor-code">Two-factor code (email)</label> 264 <input type="text" id="two-factor-code" name="two-factor-code" x-model="twoFactorCode"> 265 <div class="error-message">Enter the 2FA code from your email.</div> 266 </div> 267 </div> 268 269 <div x-show="error" x-text="error" class="error-message"></div> 270 <div x-show="showStatusMessage" id="status-message" class="status-message"></div> 271 <div> 272 <button type="submit">Login for backups</button> 273 </div> 274 </form> 275 </div> 276 277 <!-- Repo not found prompt --> 278 <div class="section" x-show="showRepoNotFoundScreen"> 279 <h2>No backup repository found</h2> 280 <p style="text-align: left;"> 281 Sign up now to backup your AT Protocol account. PDS MOOver automatically backups your posts, likes, media, and all account data every 24 hours. This is stored on our servers in something called an <a target="_blank" rel="noopener noreferrer" href="https://en.wikipedia.org/wiki/Object_storage">object store.</a> Just like your <a target="_blank" rel="noopener noreferrer" href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">AT Proto data</a>, this data is public.</p> 282 <div class="info" > 283 <h3>Critical: Save your rotation key</h3> 284 <p style="text-align: left;"> 285 A rotation key is your account recovery key. If you've migrated to a selfhosted or thirdparty PDS, this key is the <span class="bold">ONLY</span> way to recover your account if your PDS goes down. Without it, a failed or rogue PDS means permanent account loss. Generate one and store it safely. 286 </p> 287 </div> 288 <div class="form-group"> 289 <label style="display: inline-flex; align-items: center; gap: 0.5rem; white-space: nowrap;"> 290 <input type="checkbox" x-model="addRecoveryKey" style="margin: 0;"> 291 <span>Create a rotation key (recommended)</span> 292 </label> 293 </div> 294 <div x-show="addRecoveryKey" class="form-group"> 295 <p> 296 To add a new rotation key, you must authorize a PLC operation. Click the button to email yourself a PLC token, then paste it below. 297 <span class="bold">NOTE: Adding a new key will remove all current rotation keys except for the one created and the one from your PDS</span> 298 </p> 299 300 <div class="actions" style="margin-bottom: 0.5rem;"> 301 <button type="button" @click="await requestPlcToken()">Email me a PLC token</button> 302 </div> 303 <div class="form-group"> 304 <label for="plc-token">PLC token</label> 305 <input type="text" id="plc-token" name="plc-token" x-model="plcTokenInput" placeholder="Paste PLC token from email" autocomplete="one-time-code"> 306 </div> 307 </div> 308 <div x-show="!addRecoveryKey" class="form-group"> 309 <div class="status-message" style="text-align: left;"> 310 <p> 311 <span class="bold">Note:</span> a recovery key is <span class="bold">required</span> to restore your account. If you're not sure what this means, then it is important to check the above box and save the rotation key file given. 312 </p> 313 </div> 314 </div> 315 <div x-show="error" x-text="error" class="error-message"></div> 316 <div x-show="showStatusMessage" id="status-message-2" class="status-message"></div> 317 <div> 318 <button type="button" @click="await handleSignUpSubmit()">Sign up for backups</button> 319 </div> 320 </div> 321 322 <!-- Rotation key display screen (after signup when addRecoveryKey is selected) --> 323 <template x-if="showRotationKeyScreen"> 324 <div class="section"> 325 <div x-data="{newlyCreatedRotationKey: newlyCreatedRotationKey}"> 326 {% include "partials/rotation_key_display.askama.html" %} 327 </div> 328 <div class="form-group"> 329 <button type="button" @click="await proceedToRepoStatus()">Next</button> 330 </div> 331 </div> 332 </template> 333 334 <!-- Repo status view for signed-up users --> 335 <div class="section" x-show="!showLandingButtons && !showLoginScreen && !showRepoNotFoundScreen && !showRotationKeyScreen"> 336 <div class="section-header"> 337 <h2 style="margin: 0;">Backup repository status</h2> 338 <button type="button" class="icon-button" title="Refresh status" aria-label="Refresh status" @click="refreshStatus"> 339 <svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"> 340 <path d="M17.65 6.35A7.95 7.95 0 0012 4a8 8 0 108 8h-2a6 6 0 11-6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"></path> 341 </svg> 342 </button> 343 </div> 344 <div class="stats-grid"> 345 <div class="stat-card"> 346 <div class="stat-label">DID</div> 347 <div class="stat-value stat-value--small" x-text="repoStatus?.did || '—'"></div> 348 </div> 349 <div class="stat-card"> 350 <div class="stat-label">Created</div> 351 <div class="stat-value stat-value--small" x-text="formatDate(repoStatus.createdAt)"></div> 352 </div> 353 <template x-if="repoStatus?.lastBackup != null"> 354 <div class="stat-card"> 355 <div class="stat-label">Last backup</div> 356 <div class="stat-value stat-value--small" x-text="formatDate(repoStatus?.lastBackup)"></div> 357 </div> 358 </template> 359 <template x-if="repoStatus?.rev != null"> 360 <div class="stat-card"> 361 <div class="stat-label">Current rev</div> 362 <div class="stat-value stat-value--small" x-text="repoStatus?.rev"></div> 363 </div> 364 </template> 365 <template x-if="repoStatus?.blobCount != null"> 366 <div class="stat-card"> 367 <div class="stat-label">Total blobs</div> 368 <div class="stat-value" x-text="repoStatus?.blobCount.toLocaleString()"></div> 369 </div> 370 </template> 371 <template x-if="repoStatus?.estimatedBackupSize != null"> 372 <div class="stat-card"> 373 <div class="stat-label">Estimated size</div> 374 <div class="stat-value" x-text="formatBytes(repoStatus?.estimatedBackupSize)"></div> 375 </div> 376 </template> 377 <template x-if="repoStatus?.missingBlobCount != null"> 378 <div class="stat-card"> 379 <div class="stat-label">Missing blobs</div> 380 <div class="stat-value" x-text="repoStatus?.missingBlobCount.toLocaleString()"></div> 381 </div> 382 </template> 383 <template x-if="repoStatus?.source != null"> 384 <div class="stat-card"> 385 <div class="stat-label">Source</div> 386 <div class="stat-value stat-value--small" x-text="repoStatus?.source"></div> 387 </div> 388 </template> 389 </div> 390 <div class="actions" style="padding-top: 5%"> 391 <button type="button" @click="await handleRunBackupNow()">Manually run backup now</button> 392 <button type="button" style="background-color: #c62828; color: #fff;" @click="await handleRemoveRepo()">Delete Backups</button> 393 </div> 394 <div x-show="error" x-text="error" class="error-message"></div> 395 <div x-show="showStatusMessage" id="status-message" class="status-message"></div> 396 </div> 397</div> 398 399 400{% endblock %}