provideBridgeDialog.js (15244B)
1 "use strict"; 2 3 const { TorSettings, TorBridgeSource, validateBridgeLines } = 4 ChromeUtils.importESModule("resource://gre/modules/TorSettings.sys.mjs"); 5 6 const { TorConnect, TorConnectStage, TorConnectTopics } = 7 ChromeUtils.importESModule("resource://gre/modules/TorConnect.sys.mjs"); 8 9 const { TorParsers } = ChromeUtils.importESModule( 10 "resource://gre/modules/TorParsers.sys.mjs" 11 ); 12 13 const { Lox, LoxError } = ChromeUtils.importESModule( 14 "resource://gre/modules/Lox.sys.mjs" 15 ); 16 17 /* 18 * Fake Lox module: 19 20 const LoxError = { 21 BadInvite: "BadInvite", 22 LoxServerUnreachable: "LoxServerUnreachable", 23 Other: "Other", 24 }; 25 26 const Lox = { 27 failError: null, 28 // failError: LoxError.BadInvite, 29 // failError: LoxError.LoxServerUnreachable, 30 // failError: LoxError.Other, 31 redeemInvite(invite) { 32 return new Promise((res, rej) => { 33 setTimeout(() => { 34 if (this.failError) { 35 rej({ type: this.failError }); 36 } 37 res("lox-id-000000"); 38 }, 4000); 39 }); 40 }, 41 validateInvitation(invite) { 42 return invite.startsWith("lox-invite"); 43 }, 44 getBridges(id) { 45 return [ 46 "0:0 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 47 "0:1 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 48 ]; 49 }, 50 }; 51 */ 52 53 const gProvideBridgeDialog = { 54 init() { 55 this._result = window.arguments[0]; 56 const mode = window.arguments[1].mode; 57 58 let titleId; 59 switch (mode) { 60 case "edit": 61 titleId = "user-provide-bridge-dialog-edit-title"; 62 break; 63 case "add": 64 titleId = "user-provide-bridge-dialog-add-title"; 65 break; 66 case "replace": 67 default: 68 titleId = "user-provide-bridge-dialog-replace-title"; 69 break; 70 } 71 72 document.l10n.setAttributes(document.documentElement, titleId); 73 74 this._allowLoxInvite = mode !== "edit" && Lox.enabled; 75 76 document.l10n.setAttributes( 77 document.getElementById("user-provide-bridge-textarea-label"), 78 this._allowLoxInvite 79 ? "user-provide-bridge-dialog-textarea-addresses-or-invite-label" 80 : "user-provide-bridge-dialog-textarea-addresses-label" 81 ); 82 83 this._dialog = document.getElementById("user-provide-bridge-dialog"); 84 this._acceptButton = this._dialog.getButton("accept"); 85 86 // Inject our stylesheet into the shadow root so that the accept button can 87 // take the spoof-button-disabled styling and tor-button styling. 88 const styleLink = document.createElement("link"); 89 styleLink.rel = "stylesheet"; 90 styleLink.href = 91 "chrome://browser/content/torpreferences/torPreferences.css"; 92 this._dialog.shadowRoot.append(styleLink); 93 94 this._textarea = document.getElementById("user-provide-bridge-textarea"); 95 this._errorEl = document.getElementById( 96 "user-provide-bridge-error-message" 97 ); 98 this._resultDescription = document.getElementById( 99 "user-provide-result-description" 100 ); 101 this._bridgeGrid = document.getElementById( 102 "user-provide-bridge-grid-display" 103 ); 104 this._rowTemplate = document.getElementById( 105 "user-provide-bridge-row-template" 106 ); 107 108 if (mode === "edit") { 109 // Only expected if the bridge source is UseProvided, but verify to be 110 // sure. 111 if (TorSettings.bridges.source == TorBridgeSource.UserProvided) { 112 this._textarea.value = TorSettings.bridges.bridge_strings.join("\n"); 113 } 114 } else { 115 // Set placeholder if not editing. 116 document.l10n.setAttributes( 117 this._textarea, 118 this._allowLoxInvite 119 ? "user-provide-bridge-dialog-textarea-addresses-or-invite" 120 : "user-provide-bridge-dialog-textarea-addresses" 121 ); 122 } 123 124 this._textarea.addEventListener("input", () => this.onValueChange()); 125 126 this._dialog.addEventListener("dialogaccept", event => 127 this.onDialogAccept(event) 128 ); 129 130 Services.obs.addObserver(this, TorConnectTopics.StageChange); 131 132 this.setPage("entry"); 133 this.checkValue(); 134 }, 135 136 uninit() { 137 Services.obs.removeObserver(this, TorConnectTopics.StageChange); 138 }, 139 140 /** 141 * Set the page to display. 142 * 143 * @param {string} page - The page to show. 144 */ 145 setPage(page) { 146 this._page = page; 147 this._dialog.classList.toggle("show-entry-page", page === "entry"); 148 this._dialog.classList.toggle("show-result-page", page === "result"); 149 this.takeFocus(); 150 this.updateResult(); 151 this.updateAcceptDisabled(); 152 this.onAcceptStateChange(); 153 }, 154 155 /** 156 * Reset focus position in the dialog. 157 */ 158 takeFocus() { 159 switch (this._page) { 160 case "entry": 161 this._textarea.focus(); 162 break; 163 case "result": 164 // Move focus to the table. 165 // In particular, we do not want to keep the focus on the (same) accept 166 // button (with now different text). 167 this._bridgeGrid.focus(); 168 break; 169 } 170 }, 171 172 /** 173 * Callback for whenever the input value changes. 174 */ 175 onValueChange() { 176 this.updateAcceptDisabled(); 177 // Reset errors whenever the value changes. 178 this.updateError(null); 179 }, 180 181 /** 182 * Callback for whenever the accept button may need to change. 183 */ 184 onAcceptStateChange() { 185 let connect = false; 186 if (this._page === "entry") { 187 this._acceptButton.setAttribute( 188 "data-l10n-id", 189 "user-provide-bridge-dialog-next-button" 190 ); 191 } else { 192 connect = TorConnect.stageName !== TorConnectStage.Bootstrapped; 193 this._acceptButton.setAttribute( 194 "data-l10n-id", 195 connect 196 ? "bridge-dialog-button-connect2" 197 : "bridge-dialog-button-accept2" 198 ); 199 } 200 this._result.connect = connect; 201 this._acceptButton.classList.toggle("tor-button", connect); 202 }, 203 204 /** 205 * Whether the dialog accept button is disabled. 206 * 207 * @type {boolean} 208 */ 209 _acceptDisabled: false, 210 /** 211 * Callback for whenever the accept button's might need to be disabled. 212 */ 213 updateAcceptDisabled() { 214 const disabled = 215 this._page === "entry" && (this.isEmpty() || this._loxLoading); 216 this._acceptDisabled = disabled; 217 // Spoof the button to look and act as if it is disabled, but still allow 218 // keyboard focus so the user can sit on this button whilst we are loading. 219 // TODO: Replace with moz-button when it handles this for us. See 220 // tor-browser#43275. 221 this._acceptButton.classList.toggle("spoof-button-disabled", disabled); 222 this._acceptButton.tabIndex = disabled ? -1 : 0; 223 if (disabled) { 224 this._acceptButton.setAttribute("aria-disabled", "true"); 225 } else { 226 this._acceptButton.removeAttribute("aria-disabled"); 227 } 228 }, 229 230 /** 231 * The lox loading state. 232 * 233 * @type {boolean} 234 */ 235 _loxLoading: false, 236 237 /** 238 * Set the lox loading state. I.e. whether we are connecting to the lox 239 * server. 240 * 241 * @param {boolean} isLoading - Whether we are loading or not. 242 */ 243 setLoxLoading(isLoading) { 244 this._loxLoading = isLoading; 245 this._textarea.readOnly = isLoading; 246 this._dialog.classList.toggle("show-connecting", isLoading); 247 this.updateAcceptDisabled(); 248 }, 249 250 /** 251 * Callback for when the accept button is pressed. 252 * 253 * @param {Event} event - The dialogaccept event. 254 */ 255 onDialogAccept(event) { 256 if (this._acceptDisabled) { 257 // Prevent closing. 258 event.preventDefault(); 259 return; 260 } 261 262 if (this._page === "result") { 263 this._result.accepted = true; 264 // Continue to close the dialog. 265 return; 266 } 267 // Prevent closing the dialog. 268 event.preventDefault(); 269 270 if (this._loxLoading) { 271 // User can still click Next whilst loading. 272 console.error("Already have a pending lox invite"); 273 return; 274 } 275 276 // Clear the result from any previous attempt. 277 delete this._result.loxId; 278 delete this._result.addresses; 279 // Clear any previous error. 280 this.updateError(null); 281 282 const value = this.checkValue(); 283 if (!value) { 284 // Not valid. 285 return; 286 } 287 if (value.loxInvite) { 288 this.setLoxLoading(true); 289 Lox.redeemInvite(value.loxInvite) 290 .finally(() => { 291 // Set set the loading to false before setting the errors. 292 this.setLoxLoading(false); 293 }) 294 .then( 295 loxId => { 296 this._result.loxId = loxId; 297 this.setPage("result"); 298 }, 299 loxError => { 300 console.error("Redeeming failed", loxError); 301 switch (loxError instanceof LoxError ? loxError.code : null) { 302 case LoxError.BadInvite: 303 // TODO: distinguish between a bad invite, an invite that has 304 // expired, and an invite that has already been redeemed. 305 this.updateError({ type: "bad-invite" }); 306 break; 307 case LoxError.LoxServerUnreachable: 308 this.updateError({ type: "no-server" }); 309 break; 310 default: 311 this.updateError({ type: "invite-error" }); 312 break; 313 } 314 } 315 ); 316 return; 317 } 318 319 if (!value.addresses?.length) { 320 // Not valid 321 return; 322 } 323 this._result.addresses = value.addresses; 324 this.setPage("result"); 325 }, 326 327 /** 328 * Update the displayed error. 329 * 330 * @param {object?} error - The error to show, or null if no error should be 331 * shown. Should include the "type" property. 332 */ 333 updateError(error) { 334 // First clear the existing error. 335 this._errorEl.removeAttribute("data-l10n-id"); 336 this._errorEl.textContent = ""; 337 if (error) { 338 this._textarea.setAttribute("aria-invalid", "true"); 339 } else { 340 this._textarea.removeAttribute("aria-invalid"); 341 } 342 this._textarea.classList.toggle("invalid-input", !!error); 343 this._dialog.classList.toggle("show-error", !!error); 344 345 if (!error) { 346 return; 347 } 348 349 let errorId; 350 let errorArgs; 351 switch (error.type) { 352 case "invalid-address": 353 errorId = "user-provide-bridge-dialog-address-error"; 354 errorArgs = { line: error.line }; 355 break; 356 case "multiple-invites": 357 errorId = "user-provide-bridge-dialog-multiple-invites-error"; 358 break; 359 case "mixed": 360 errorId = "user-provide-bridge-dialog-mixed-error"; 361 break; 362 case "not-allowed-invite": 363 errorId = "user-provide-bridge-dialog-invite-not-allowed-error"; 364 break; 365 case "bad-invite": 366 errorId = "user-provide-bridge-dialog-bad-invite-error"; 367 break; 368 case "no-server": 369 errorId = "user-provide-bridge-dialog-no-server-error"; 370 break; 371 case "invite-error": 372 // Generic invite error. 373 errorId = "user-provide-bridge-dialog-generic-invite-error"; 374 break; 375 } 376 377 document.l10n.setAttributes(this._errorEl, errorId, errorArgs); 378 }, 379 380 /** 381 * The condition for the value to be empty. 382 * 383 * @type {RegExp} 384 */ 385 _emptyRegex: /^\s*$/, 386 /** 387 * Whether the input is considered empty. 388 * 389 * @returns {boolean} true if it is considered empty. 390 */ 391 isEmpty() { 392 return this._emptyRegex.test(this._textarea.value); 393 }, 394 395 /** 396 * Check the current value in the textarea. 397 * 398 * @returns {object?} - The bridge addresses, or lox invite, or null if no 399 * valid value. 400 */ 401 checkValue() { 402 if (this.isEmpty()) { 403 // If empty, we just disable the button, rather than show an error. 404 this.updateError(null); 405 return null; 406 } 407 408 // Only check if this looks like a Lox invite when the Lox module is 409 // enabled. 410 if (Lox.enabled) { 411 let loxInvite = null; 412 for (let line of this._textarea.value.split(/\r?\n/)) { 413 line = line.trim(); 414 if (!line) { 415 continue; 416 } 417 // TODO: Once we have a Lox invite encoding, distinguish between a valid 418 // invite and something that looks like it should be an invite. 419 const isLoxInvite = Lox.validateInvitation(line); 420 if (isLoxInvite) { 421 if (!this._allowLoxInvite) { 422 // Lox is enabled, but not allowed invites when editing bridge 423 // addresses. 424 this.updateError({ type: "not-allowed-invite" }); 425 return null; 426 } 427 if (loxInvite) { 428 this.updateError({ type: "multiple-invites" }); 429 return null; 430 } 431 loxInvite = line; 432 } else if (loxInvite) { 433 this.updateError({ type: "mixed" }); 434 return null; 435 } 436 } 437 438 if (loxInvite) { 439 return { loxInvite }; 440 } 441 } 442 443 const validation = validateBridgeLines(this._textarea.value); 444 if (validation.errorLines.length) { 445 // Report first error. 446 this.updateError({ 447 type: "invalid-address", 448 line: validation.errorLines[0], 449 }); 450 return null; 451 } 452 453 return { addresses: validation.validBridges }; 454 }, 455 456 /** 457 * Update the shown result on the last page. 458 */ 459 updateResult() { 460 if (this._page !== "result") { 461 return; 462 } 463 464 const loxId = this._result.loxId; 465 466 document.l10n.setAttributes( 467 this._resultDescription, 468 loxId 469 ? "user-provide-bridge-dialog-result-invite" 470 : "user-provide-bridge-dialog-result-addresses" 471 ); 472 473 this._bridgeGrid.replaceChildren(); 474 475 const bridgeResult = loxId ? Lox.getBridges(loxId) : this._result.addresses; 476 477 for (const bridgeLine of bridgeResult) { 478 let details; 479 try { 480 details = TorParsers.parseBridgeLine(bridgeLine); 481 } catch (e) { 482 console.error(`Detected invalid bridge line: ${bridgeLine}`, e); 483 } 484 485 const rowEl = this._rowTemplate.content.children[0].cloneNode(true); 486 487 const emojiBlock = rowEl.querySelector(".tor-bridges-emojis-block"); 488 const BridgeEmoji = customElements.get("tor-bridge-emoji"); 489 for (const cell of BridgeEmoji.createForAddress(bridgeLine)) { 490 // Each emoji is its own cell, we rely on the fact that createForAddress 491 // always returns four elements. 492 cell.setAttribute("role", "cell"); 493 cell.classList.add("tor-bridges-grid-cell", "tor-bridges-emoji-cell"); 494 emojiBlock.append(cell); 495 } 496 497 const transport = details?.transport ?? "vanilla"; 498 const typeCell = rowEl.querySelector(".tor-bridges-type-cell"); 499 if (transport === "vanilla") { 500 document.l10n.setAttributes( 501 typeCell, 502 "tor-bridges-type-prefix-generic" 503 ); 504 } else { 505 document.l10n.setAttributes(typeCell, "tor-bridges-type-prefix", { 506 type: transport, 507 }); 508 } 509 510 rowEl.querySelector(".tor-bridges-address-cell-text").textContent = 511 bridgeLine; 512 513 this._bridgeGrid.append(rowEl); 514 } 515 }, 516 517 observe(subject, topic) { 518 switch (topic) { 519 case TorConnectTopics.StageChange: 520 this.onAcceptStateChange(); 521 break; 522 } 523 }, 524 }; 525 526 document.subDialogSetDefaultFocus = () => { 527 // Set the focus to the text area on load. 528 gProvideBridgeDialog.takeFocus(); 529 }; 530 531 window.addEventListener( 532 "DOMContentLoaded", 533 () => { 534 gProvideBridgeDialog.init(); 535 window.addEventListener( 536 "unload", 537 () => { 538 gProvideBridgeDialog.uninit(); 539 }, 540 { once: true } 541 ); 542 }, 543 { once: true } 544 );