chromium/chrome/browser/resources/extensions/runtime_hosts_dialog.ts

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/icons.html.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import './strings.m.js';
import './shared_vars.css.js';

import type {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './runtime_hosts_dialog.html.js';
import {sitePermissionsPatternRegExp} from './site_permissions/site_permissions_edit_url_dialog.js';
import {SiteSettingsMixin} from './site_permissions/site_settings_mixin.js';

// A RegExp to roughly match acceptable patterns entered by the user.
// exec'ing() this RegExp will match the following groups:
// 0: Full matched string.
// 1: Scheme + scheme separator (e.g., 'https://').
// 2: Scheme only (e.g., 'https').
// 3: Match subdomains ('*.').
// 4: Hostname (e.g., 'example.com').
// 5: Port, including ':' separator (e.g., ':80').
// 6: Path, include '/' separator (e.g., '/*').
const runtimeHostsPatternRegExp = new RegExp(
    '^' +
    // Scheme; optional.
    '((http|https|\\*)://)?' +
    // Include subdomains specifier; optional.
    '(\\*\\.)?' +
    // Hostname or localhost, required.
    '([a-z0-9\\.-]+\\.[a-z0-9]+|localhost)' +
    // Port, optional.
    '(:[0-9]+)?' +
    // Path, optional but if present must be '/' or '/*'.
    '(\\/\\*|\\/)?' +
    '$');

export function getPatternFromSite(site: string): string {
  const res = runtimeHostsPatternRegExp.exec(site)!;
  assert(res);
  const scheme = res[1] || '*://';
  const host = (res[3] || '') + res[4];
  const port = res[5] || '';
  const path = '/*';
  return scheme + host + port + path;
}

// Returns the sublist of `userSites` which match the pattern specified by
// `host`.
export function getMatchingUserSpecifiedSites(
    userSites: string[], host: string): string[] {
  if (!runtimeHostsPatternRegExp.test(host)) {
    return [];
  }

  const newHostRes = runtimeHostsPatternRegExp.exec(host);
  assert(newHostRes);

  const matchAllSchemes = !newHostRes[1] || newHostRes[1] === '*://';
  const matchAllSubdomains = newHostRes[3] === '*.';

  // For each restricted site, break it down into
  // `sitePermissionsPatternRegExp` components and check against components
  // from `newHostRes`.
  return userSites.filter((userSite: string) => {
    const siteRes = sitePermissionsPatternRegExp.exec(userSite);
    assert(siteRes);

    // Check if schemes match, unless `newHostRes` has a wildcard scheme.
    if (!matchAllSchemes && newHostRes[1] !== siteRes[1]) {
      return false;
    }

    // Check if host names match. If `matchAllSubdomains` is specified, check
    // that `newHostRes[4]` is a suffix of `siteRes[3]`
    if (matchAllSubdomains && !siteRes[3].endsWith(newHostRes[4])) {
      return false;
    }
    if (!matchAllSubdomains && siteRes[3] !== newHostRes[4]) {
      return false;
    }

    // Ports match if:
    //  - both are unspecified
    //  - both are specified and are an exact match
    //  - specified for `restrictedSite` but not `this,site_`
    return !newHostRes[5] || newHostRes[5] === siteRes[4];
  });
}

export interface ExtensionsRuntimeHostsDialogElement {
  $: {
    dialog: CrDialogElement,
    submit: CrButtonElement,
  };
}

const ExtensionsRuntimeHostsDialogElementBase =
    I18nMixin(SiteSettingsMixin(PolymerElement));

export class ExtensionsRuntimeHostsDialogElement extends
    ExtensionsRuntimeHostsDialogElementBase {
  static get is() {
    return 'extensions-runtime-hosts-dialog';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      itemId: String,

      /**
       * The site that this entry is currently managing. Only non-empty if this
       * is for editing an existing entry.
       */
      currentSite: {
        type: String,
        value: null,
      },

      /**
       * Whether the dialog should update the host access to be "on specific
       * sites" before adding a new host permission.
       */
      updateHostAccess: {
        type: Boolean,
        value: false,
      },

      /** The site to add an exception for. */
      site_: String,

      /** Whether the currently-entered input is valid. */
      inputInvalid_: {
        type: Boolean,
        value: false,
      },

      /**
       * the list of user specified restricted sites that match with `site_` if
       * `site_` is valid.
       */
      matchingRestrictedSites_: {
        type: Array,
        computed: 'computeMatchingRestrictedSites_(site_, restrictedSites)',
      },
    };
  }

  itemId: string;
  currentSite: string|null;
  updateHostAccess: boolean;
  private site_: string;
  private inputInvalid_: boolean;
  private matchingRestrictedSites_: string[];

  override connectedCallback() {
    super.connectedCallback();

    if (this.currentSite !== null && this.currentSite !== undefined) {
      this.site_ = this.currentSite;
      this.validate_();
    }
    this.$.dialog.showModal();
  }

  isOpen(): boolean {
    return this.$.dialog.open;
  }

  /**
   * Validates that the pattern entered is valid.
   */
  private validate_() {
    // If input is empty, disable the action button, but don't show the red
    // invalid message.
    if (this.site_.trim().length === 0) {
      this.inputInvalid_ = false;
      return;
    }

    this.inputInvalid_ = !runtimeHostsPatternRegExp.test(this.site_);
  }

  private computeDialogTitle_(): string {
    const stringId = this.currentSite === null ? 'runtimeHostsDialogTitle' :
                                                 'hostPermissionsEdit';
    return loadTimeData.getString(stringId);
  }

  private computeSubmitButtonDisabled_(): boolean {
    return this.inputInvalid_ || this.site_ === undefined ||
        this.site_.trim().length === 0;
  }

  private computeSubmitButtonLabel_(): string {
    const stringId = this.currentSite === null ? 'add' : 'save';
    return loadTimeData.getString(stringId);
  }

  private computeMatchingRestrictedSites_(): string[] {
    return getMatchingUserSpecifiedSites(this.restrictedSites, this.site_);
  }

  private onCancelClick_() {
    this.$.dialog.cancel();
  }

  /**
   * The tap handler for the submit button (adds the pattern and closes
   * the dialog).
   */
  private onSubmitClick_() {
    chrome.metricsPrivate.recordUserAction(
        'Extensions.Settings.Hosts.AddHostDialogSubmitted');
    if (this.currentSite !== null) {
      this.handleEdit_();
    } else {
      this.handleAdd_();
    }
  }

  /**
   * Handles adding a new site entry.
   */
  private handleAdd_() {
    assert(!this.currentSite);

    if (this.updateHostAccess) {
      this.delegate.setItemHostAccess(
          this.itemId, chrome.developerPrivate.HostAccess.ON_SPECIFIC_SITES);
    }

    this.addPermission_();
  }

  /**
   * Handles editing an existing site entry.
   */
  private handleEdit_() {
    assert(this.currentSite);
    assert(
        !this.updateHostAccess,
        'Editing host permissions should only be possible if the host ' +
            'access is already set to specific sites.');

    if (this.currentSite === this.site_) {
      // No change in values, so no need to update anything.
      this.$.dialog.close();
      return;
    }

    // Editing an existing entry is done by removing the current site entry,
    // and then adding the new one.
    this.delegate.removeRuntimeHostPermission(this.itemId, this.currentSite)
        .then(() => {
          this.addPermission_();
        });
  }

  /**
   * Adds the runtime host permission through the delegate. If successful,
   * closes the dialog; otherwise displays the invalid input message.
   */
  private addPermission_() {
    const pattern = getPatternFromSite(this.site_);
    const restrictedSites = this.matchingRestrictedSites_;
    this.delegate.addRuntimeHostPermission(this.itemId, pattern)
        .then(
            () => {
              if (restrictedSites.length) {
                this.delegate.removeUserSpecifiedSites(
                    chrome.developerPrivate.SiteSet.USER_RESTRICTED,
                    restrictedSites);
              }
              this.$.dialog.close();
            },
            () => {
              this.inputInvalid_ = true;
            });
  }

  /**
   * Returns a warning message containing the first restricted site that
   * overlaps with `this.site_`, or an empty string if there are no matching
   * restricted sites.
   */
  private computeMatchingRestrictedSitesWarning_(): string {
    return this.matchingRestrictedSites_.length ?
        this.i18n(
            'matchingRestrictedSitesWarning',
            this.matchingRestrictedSites_[0]) :
        '';
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'extensions-runtime-hosts-dialog': ExtensionsRuntimeHostsDialogElement;
  }
}

customElements.define(
    ExtensionsRuntimeHostsDialogElement.is,
    ExtensionsRuntimeHostsDialogElement);