tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 );