tor-browser

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

addEngine.js (14391B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /* globals AdjustableTitle */
      6 
      7 // This is the dialog that is displayed when adding or editing a search engine
      8 // in about:preferences, or when adding a search engine via the context menu of
      9 // an HTML form. Depending on the scenario where it is used, different arguments
     10 // must be supplied in an object in `window.arguments[0]`:
     11 // - `mode`  [required] - The type of dialog: NEW, EDIT or FORM.
     12 // - `title` [optional] - Whether to display a title in the window element.
     13 // - all arguments required by the constructor of the dialog class
     14 
     15 /**
     16 * @import {UserSearchEngine} from "../../../../toolkit/components/search/UserSearchEngine.sys.mjs"
     17 */
     18 
     19 const lazy = {};
     20 
     21 ChromeUtils.defineESModuleGetters(lazy, {
     22  SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
     23 });
     24 
     25 // Set the appropriate l10n id before the dialog's connectedCallback.
     26 if (window.arguments[0].mode == "EDIT") {
     27  document.l10n.setAttributes(
     28    document.querySelector("dialog"),
     29    "edit-engine-dialog"
     30  );
     31  document.l10n.setAttributes(
     32    document.querySelector("window"),
     33    "edit-engine-window"
     34  );
     35 } else {
     36  document.l10n.setAttributes(
     37    document.querySelector("dialog"),
     38    "add-engine-dialog2"
     39  );
     40  document.l10n.setAttributes(
     41    document.querySelector("window"),
     42    "add-engine-window"
     43  );
     44 }
     45 
     46 let loadedResolvers = Promise.withResolvers();
     47 document.mozSubdialogReady = loadedResolvers.promise;
     48 
     49 /** @type {?EngineDialog} */
     50 let gAddEngineDialog = null;
     51 /** @type {?Map<string, string>} */
     52 let l10nCache = null;
     53 
     54 /**
     55 * The abstract base class for all types of user search engine dialogs.
     56 * All subclasses must implement the abstract method `onAddEngine`.
     57 */
     58 class EngineDialog {
     59  constructor() {
     60    this._dialog = document.querySelector("dialog");
     61 
     62    this._form = document.getElementById("addEngineForm");
     63    this._name = document.getElementById("engineName");
     64    this._alias = document.getElementById("engineAlias");
     65    this._url = document.getElementById("engineUrl");
     66    this._postData = document.getElementById("enginePostData");
     67    this._suggestUrl = document.getElementById("suggestUrl");
     68 
     69    this._form.addEventListener("input", e => this.validateInput(e.target));
     70    document.addEventListener("dialogaccept", this.onAccept.bind(this));
     71    document.addEventListener("dialogextra1", () => this.showAdvanced());
     72  }
     73 
     74  /**
     75   * Shows the advanced section and hides the advanced button.
     76   *
     77   * @param {boolean} [resize]
     78   *   Whether the resizeDialog should be called. Before `mozSubdialogReady`
     79   *   is resolved, this should be false to avoid flickering.
     80   */
     81  showAdvanced(resize = true) {
     82    this._dialog.getButton("extra1").hidden = true;
     83    document.getElementById("advanced-section").hidden = false;
     84    if (resize) {
     85      window.resizeDialog();
     86    }
     87  }
     88 
     89  onAccept() {
     90    throw new Error("abstract");
     91  }
     92 
     93  validateName() {
     94    let name = this._name.value.trim();
     95    if (!name) {
     96      this.setValidity(this._name, "add-engine-no-name");
     97      return;
     98    }
     99 
    100    let existingEngine = Services.search.getEngineByName(name);
    101    if (existingEngine && !this.allowedNames.includes(name)) {
    102      this.setValidity(this._name, "add-engine-name-exists");
    103      return;
    104    }
    105 
    106    this.setValidity(this._name, null);
    107  }
    108 
    109  async validateAlias() {
    110    let alias = this._alias.value.trim();
    111    if (!alias) {
    112      this.setValidity(this._alias, null);
    113      return;
    114    }
    115 
    116    let existingEngine = await Services.search.getEngineByAlias(alias);
    117    if (existingEngine && !this.allowedAliases.includes(alias)) {
    118      this.setValidity(this._alias, "add-engine-keyword-exists");
    119      return;
    120    }
    121 
    122    this.setValidity(this._alias, null);
    123  }
    124 
    125  validateUrlInput() {
    126    let urlString = this._url.value.trim();
    127    if (!urlString) {
    128      this.setValidity(this._url, "add-engine-no-url");
    129      return;
    130    }
    131 
    132    let url = URL.parse(urlString);
    133    if (!url) {
    134      this.setValidity(this._url, "add-engine-invalid-url");
    135      return;
    136    }
    137 
    138    if (url.protocol != "http:" && url.protocol != "https:") {
    139      this.setValidity(this._url, "add-engine-invalid-protocol");
    140      return;
    141    }
    142 
    143    let postData = this._postData?.value.trim();
    144    if (!urlString.includes("%s") && !postData) {
    145      this.setValidity(this._url, "add-engine-missing-terms-url");
    146      return;
    147    }
    148    this.setValidity(this._url, null);
    149  }
    150 
    151  validatePostDataInput() {
    152    let postData = this._postData.value.trim();
    153    if (postData && !postData.includes("%s")) {
    154      this.setValidity(this._postData, "add-engine-missing-terms-post-data");
    155      return;
    156    }
    157    this.setValidity(this._postData, null);
    158  }
    159 
    160  validateSuggestUrlInput() {
    161    let urlString = this._suggestUrl.value.trim();
    162    if (!urlString) {
    163      this.setValidity(this._suggestUrl, null);
    164      return;
    165    }
    166 
    167    let url = URL.parse(urlString);
    168    if (!url) {
    169      this.setValidity(this._suggestUrl, "add-engine-invalid-url");
    170      return;
    171    }
    172 
    173    if (url.protocol != "http:" && url.protocol != "https:") {
    174      this.setValidity(this._suggestUrl, "add-engine-invalid-protocol");
    175      return;
    176    }
    177 
    178    if (!urlString.includes("%s")) {
    179      this.setValidity(this._suggestUrl, "add-engine-missing-terms-url");
    180      return;
    181    }
    182 
    183    this.setValidity(this._suggestUrl, null);
    184  }
    185 
    186  /**
    187   * Validates the passed input element and updates error messages.
    188   *
    189   * @param {HTMLInputElement} input
    190   *   The input element to validate.
    191   */
    192  async validateInput(input) {
    193    switch (input.id) {
    194      case this._name.id:
    195        this.validateName();
    196        break;
    197      case this._alias.id:
    198        await this.validateAlias();
    199        break;
    200      case this._postData.id:
    201      case this._url.id:
    202        // Since either the url or the post data input could
    203        // contain %s, we need to update both inputs here.
    204        this.validateUrlInput();
    205        this.validatePostDataInput();
    206        break;
    207      case this._suggestUrl.id:
    208        this.validateSuggestUrlInput();
    209        break;
    210    }
    211  }
    212 
    213  async validateAll() {
    214    for (let input of this._form.elements) {
    215      await this.validateInput(input);
    216    }
    217  }
    218 
    219  /**
    220   * Sets the validity of the passed input element to the string belonging
    221   * to the passed l10n id. Also updates the input's error label and
    222   * the accept button.
    223   *
    224   * @param {HTMLInputElement} inputElement
    225   * @param {string} l10nId
    226   *   The l10n id of the string to use as validity.
    227   *   Must be a key of `l10nCache`.
    228   */
    229  setValidity(inputElement, l10nId) {
    230    if (l10nId) {
    231      inputElement.setCustomValidity(l10nCache.get(l10nId));
    232    } else {
    233      inputElement.setCustomValidity("");
    234    }
    235 
    236    let errorLabel = inputElement.parentElement.querySelector(".error-label");
    237    let validationMessage = inputElement.validationMessage;
    238 
    239    // If valid, set the error label to "valid" to ensure the layout doesn't shift.
    240    // The CSS already hides the error label based on the validity of `inputElement`.
    241    errorLabel.textContent = validationMessage || "valid";
    242 
    243    this._dialog.getButton("accept").disabled = !this._form.checkValidity();
    244  }
    245 
    246  /**
    247   * Engine names that always are allowed, even if they are already in use.
    248   * This is needed for the edit engine dialog.
    249   *
    250   * @type {string[]}
    251   */
    252  get allowedNames() {
    253    return [];
    254  }
    255 
    256  /**
    257   * Engine aliases that always are allowed, even if they are already in use.
    258   * This is needed for the edit engine dialog.
    259   *
    260   * @type {string[]}
    261   */
    262  get allowedAliases() {
    263    return [];
    264  }
    265 }
    266 
    267 /**
    268 * This dialog is opened when adding a new search engine in preferences.
    269 */
    270 class NewEngineDialog extends EngineDialog {
    271  constructor() {
    272    super();
    273    document.l10n.setAttributes(this._name, "add-engine-name-placeholder");
    274    document.l10n.setAttributes(this._url, "add-engine-url-placeholder");
    275    document.l10n.setAttributes(this._alias, "add-engine-keyword-placeholder");
    276 
    277    this.validateAll();
    278  }
    279 
    280  onAccept() {
    281    let params = new URLSearchParams(
    282      this._postData.value.trim().replace(/%s/, "{searchTerms}")
    283    );
    284    let url = this._url.value.trim().replace(/%s/, "{searchTerms}");
    285 
    286    Services.search.addUserEngine({
    287      name: this._name.value.trim(),
    288      url,
    289      method: params.size ? "POST" : "GET",
    290      params,
    291      suggestUrl: this._suggestUrl.value.trim().replace(/%s/, "{searchTerms}"),
    292      alias: this._alias.value.trim(),
    293    });
    294  }
    295 }
    296 
    297 /**
    298 * This dialog is opened when editing a user search engine in preferences.
    299 */
    300 class EditEngineDialog extends EngineDialog {
    301  #engine;
    302  /**
    303   * Initializes the dialog with information from a user search engine.
    304   *
    305   * @param {object} args
    306   *   The arguments.
    307   * @param {UserSearchEngine} args.engine
    308   *   The search engine to edit. Must be a UserSearchEngine.
    309   */
    310  constructor({ engine }) {
    311    super();
    312    this.#engine = engine;
    313    this._name.value = engine.name;
    314    this._alias.value = engine.alias ?? "";
    315 
    316    let [url, postData] = this.getSubmissionTemplate(
    317      lazy.SearchUtils.URL_TYPE.SEARCH
    318    );
    319    this._url.value = url;
    320    this._postData.value = postData;
    321 
    322    let [suggestUrl] = this.getSubmissionTemplate(
    323      lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
    324    );
    325    if (suggestUrl) {
    326      this._suggestUrl.value = suggestUrl;
    327    }
    328 
    329    if (postData || suggestUrl) {
    330      this.showAdvanced(false);
    331    }
    332 
    333    this.validateAll();
    334  }
    335 
    336  onAccept() {
    337    this.#engine.rename(this._name.value.trim());
    338    this.#engine.alias = this._alias.value.trim();
    339 
    340    let newURL = this._url.value.trim();
    341    let newPostData = this._postData.value.trim() || null;
    342 
    343    // UserSearchEngine.changeUrl() does not check whether the URL has actually changed.
    344    let [prevURL, prevPostData] = this.getSubmissionTemplate(
    345      lazy.SearchUtils.URL_TYPE.SEARCH
    346    );
    347    if (newURL != prevURL || prevPostData != newPostData) {
    348      this.#engine.changeUrl(
    349        lazy.SearchUtils.URL_TYPE.SEARCH,
    350        newURL.replace(/%s/, "{searchTerms}"),
    351        newPostData?.replace(/%s/, "{searchTerms}")
    352      );
    353    }
    354 
    355    let newSuggestURL = this._suggestUrl.value.trim() || null;
    356    let [prevSuggestUrl] = this.getSubmissionTemplate(
    357      lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
    358    );
    359    if (newSuggestURL != prevSuggestUrl) {
    360      this.#engine.changeUrl(
    361        lazy.SearchUtils.URL_TYPE.SUGGEST_JSON,
    362        newSuggestURL?.replace(/%s/, "{searchTerms}"),
    363        null
    364      );
    365    }
    366 
    367    this.#engine.updateFavicon();
    368  }
    369 
    370  get allowedAliases() {
    371    return [this.#engine.alias];
    372  }
    373 
    374  get allowedNames() {
    375    return [this.#engine.name];
    376  }
    377 
    378  /**
    379   * Returns url and post data templates of the requested type.
    380   * Both contain %s in place of the search terms.
    381   *
    382   * If no url of the requested type exists, both are null.
    383   * If the url is a GET url, the post data is null.
    384   *
    385   * @param {string} urlType
    386   *   The `SearchUtils.URL_TYPE`.
    387   * @returns {[?string, ?string]}
    388   *   Array of the url and post data.
    389   */
    390  getSubmissionTemplate(urlType) {
    391    let submission = this.#engine.getSubmission("searchTerms", urlType);
    392    if (!submission) {
    393      return [null, null];
    394    }
    395    let postData = null;
    396    if (submission.postData) {
    397      let binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
    398        Ci.nsIBinaryInputStream
    399      );
    400      binaryStream.setInputStream(submission.postData.data);
    401 
    402      postData = binaryStream
    403        .readBytes(binaryStream.available())
    404        .replace("searchTerms", "%s");
    405    }
    406    let url = submission.uri.spec.replace("searchTerms", "%s");
    407    return [url, postData];
    408  }
    409 }
    410 
    411 /**
    412 * This dialog is opened via the context menu of an input and lets the
    413 * user choose a name and an alias for an engine. Unlike the other two
    414 * dialogs, it does not add or change an engine in the search service,
    415 * and instead returns the user input to the caller.
    416 *
    417 * The chosen name and alias are returned via `window.arguments[0].engineInfo`.
    418 * If the user chooses to not save the engine, it's undefined.
    419 */
    420 class NewEngineFromFormDialog extends EngineDialog {
    421  /**
    422   * Initializes the dialog.
    423   *
    424   * @param {object} args
    425   *   The arguments.
    426   * @param {string} args.nameTemplate
    427   *   The initial value of the name input.
    428   */
    429  constructor({ nameTemplate }) {
    430    super();
    431    document.getElementById("engineUrlRow").remove();
    432    this._url = null;
    433    document.getElementById("suggestUrlRow").remove();
    434    this._suggestUrl = null;
    435    document.getElementById("enginePostDataRow").remove();
    436    this._postData = null;
    437    this._dialog.getButton("extra1").hidden = true;
    438 
    439    this._name.value = nameTemplate;
    440    this.validateAll();
    441  }
    442 
    443  onAccept() {
    444    // Return the input to the caller.
    445    window.arguments[0].engineInfo = {
    446      name: this._name.value.trim(),
    447      // Empty string means no alias.
    448      alias: this._alias.value.trim(),
    449    };
    450  }
    451 }
    452 
    453 async function initL10nCache() {
    454  const errorIds = [
    455    "add-engine-name-exists",
    456    "add-engine-keyword-exists",
    457    "add-engine-no-name",
    458    "add-engine-no-url",
    459    "add-engine-invalid-protocol",
    460    "add-engine-invalid-url",
    461    "add-engine-missing-terms-url",
    462    "add-engine-missing-terms-post-data",
    463  ];
    464 
    465  let msgs = await document.l10n.formatValues(errorIds.map(id => ({ id })));
    466  l10nCache = new Map();
    467 
    468  for (let i = 0; i < errorIds.length; i++) {
    469    l10nCache.set(errorIds[i], msgs[i]);
    470  }
    471 }
    472 
    473 window.addEventListener("DOMContentLoaded", async () => {
    474  try {
    475    if (window.arguments[0].title) {
    476      document.documentElement.setAttribute(
    477        "headertitle",
    478        JSON.stringify({ raw: document.title })
    479      );
    480    } else {
    481      AdjustableTitle.hide();
    482    }
    483 
    484    await initL10nCache();
    485 
    486    switch (window.arguments[0].mode) {
    487      case "NEW":
    488        gAddEngineDialog = new NewEngineDialog();
    489        break;
    490      case "EDIT":
    491        gAddEngineDialog = new EditEngineDialog(window.arguments[0]);
    492        break;
    493      case "FORM":
    494        gAddEngineDialog = new NewEngineFromFormDialog(window.arguments[0]);
    495        break;
    496      default:
    497        throw new Error("Mode not supported for addEngine dialog.");
    498    }
    499  } finally {
    500    loadedResolvers.resolve();
    501  }
    502 });