chromium/chrome/browser/resources/extensions/site_permissions/site_permissions_edit_permissions_dialog.ts

// Copyright 2022 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_radio_button/cr_radio_button.js';
import 'chrome://resources/cr_elements/cr_radio_group/cr_radio_group.js';
import 'chrome://resources/cr_elements/cr_shared_style.css.js';
import 'chrome://resources/cr_elements/md_select.css.js';
import '../strings.m.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 type {DomRepeatEvent} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getItemSource, SourceType} from '../item_util.js';
import {getTemplate} from './site_permissions_edit_permissions_dialog.html.js';
import type {SiteSettingsDelegate} from './site_settings_mixin.js';
import {matchesSubdomains, SUBDOMAIN_SPECIFIER} from '../url_util.js';

interface ExtensionSiteAccessInfo {
  id: string;
  name: string;
  iconUrl: string;
  siteAccess: string;
  addedByPolicy: boolean;
  canRequestAllSites: boolean;
}

export interface SitePermissionsEditPermissionsDialogElement {
  $: {
    dialog: CrDialogElement,
    includesSubdomains: HTMLElement,
    site: HTMLElement,
    submit: CrButtonElement,
  };
}

const EXTENSION_SPECIFIED = chrome.developerPrivate.SiteSet.EXTENSION_SPECIFIED;

// A list of possible schemes that can be specified by extension host
// permissions. This is derived from URLPattern::SchemeMasks.
const VALID_SCHEMES = [
  '*',
  'http',
  'https',
  'file',
  'ftp',
  'chrome',
  'chrome-extension',
  'filesystem',
  'ftp',
  'ws',
  'wss',
  'data',
  'uuid-in-package',
];

const SitePermissionsEditPermissionsDialogElementBase =
    I18nMixin(PolymerElement);

export class SitePermissionsEditPermissionsDialogElement extends
    SitePermissionsEditPermissionsDialogElementBase {
  static get is() {
    return 'site-permissions-edit-permissions-dialog';
  }

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

  static get properties() {
    return {
      delegate: Object,
      extensions: {
        type: Array,
        value: () => [],
        observer: 'onExtensionsUpdated_',
      },

      /**
       * The current siteSet for `site`, as stored in the backend. Specifies
       * whether `site` is a user specified permitted or restricted site, or is
       * a pattern specified by an extension's host permissions..
       */
      originalSiteSet: String,

      /**
       * The url of the site whose permissions are currently being edited.
       */
      site: String,

      /**
       * The temporary siteSet for `site` as displayed in the dialog. Will be
       * saved to the backend when the dialog is submitted.
       */
      siteSet_: {
        type: String,
        observer: 'onSiteSetUpdated_',
      },

      siteSetEnum_: {
        type: Object,
        value: chrome.developerPrivate.SiteSet,
      },

      extensionSiteAccessData_: {
        type: Array,
        value: () => [],
      },

      showPermittedOption_: {
        type: Boolean,
        value: () => loadTimeData.getBoolean('enableUserPermittedSites'),
      },

      /**
       * Proxying the enum to be used easily by the html template.
       */
      hostAccessEnum_: {
        type: Object,
        value: chrome.developerPrivate.HostAccess,
      },
    };
  }

  delegate: SiteSettingsDelegate;
  extensions: chrome.developerPrivate.ExtensionInfo[];
  originalSiteSet: chrome.developerPrivate.SiteSet;
  site: string;
  private siteSet_: chrome.developerPrivate.SiteSet;
  private extensionsIdToInfo_:
      Map<string, chrome.developerPrivate.ExtensionInfo>;
  private extensionSiteAccessData_: ExtensionSiteAccessInfo[];
  private showPermittedOption_: boolean;

  // Tracks any unsaved changes to HostAccess for each extension made by
  // changing the value in the ".extension-host-access" <select> element. Any
  // values in here should be different than the HostAccess for the extension
  // inside `extensionSiteAccessData_`.
  private unsavedExtensionsIdToHostAccess_:
      Map<string, chrome.developerPrivate.HostAccess>;

  constructor() {
    super();
    this.unsavedExtensionsIdToHostAccess_ = new Map();
  }

  override ready() {
    super.ready();

    // Setting this to an initial value will trigger a call to
    // `updateExtensionSiteAccessData_`.
    this.siteSet_ = this.originalSiteSet;

    // If `this.site` matches subdomains, then it should not be a user specified
    // site.
    assert(
        !this.matchesSubdomains_() ||
        this.originalSiteSet === EXTENSION_SPECIFIED);
  }

  private onExtensionsUpdated_(extensions:
                                   chrome.developerPrivate.ExtensionInfo[]) {
    this.extensionsIdToInfo_ = new Map();
    for (const extension of extensions) {
      this.extensionsIdToInfo_.set(extension.id, extension);
    }
    this.updateExtensionSiteAccessData_(this.siteSet_);
  }

  private onSiteSetUpdated_(siteSet: chrome.developerPrivate.SiteSet) {
    this.updateExtensionSiteAccessData_(siteSet);
  }

  // Returns true if this.site is a just a host by checking whether or not it
  // starts with a valid scheme. If not, assume the site is a full URL.
  // Different components that use this dialog may supply either a URL or just a
  // host.
  private isSiteHostOnly_(): boolean {
    return !VALID_SCHEMES.some(scheme => this.site.startsWith(`${scheme}://`));
  }

  // Fetches all extensions that have requested access to `this.site` along with
  // their access status. This information is joined with some fields in
  // `this.extensions` to update `this.extensionSiteAccessData_`.
  private async updateExtensionSiteAccessData_(
      siteSet: chrome.developerPrivate.SiteSet) {
    // Avoid fetching the list of matching extensions if they will not be
    // displayed.
    if (siteSet !== EXTENSION_SPECIFIED) {
      return;
    }

    const siteToCheck =
        this.isSiteHostOnly_() ? `*://${this.site}/` : `${this.site}/`;

    const matchingExtensionsInfo =
        await this.delegate.getMatchingExtensionsForSite(siteToCheck);

    const extensionSiteAccessData: ExtensionSiteAccessInfo[] = [];
    matchingExtensionsInfo.forEach(({id, siteAccess, canRequestAllSites}) => {
      assert(this.extensionsIdToInfo_.has(id));
      const {name, iconUrl} = this.extensionsIdToInfo_.get(id)!;
      const addedByPolicy = getItemSource(this.extensionsIdToInfo_.get(id)!) ===
          SourceType.POLICY;
      extensionSiteAccessData.push(
          {id, name, iconUrl, siteAccess, addedByPolicy, canRequestAllSites});

      // Remove the unsaved HostAccess from `unsavedExtensionsIdToHostAccess_`
      // if it is now the same as `siteAccess`.
      if (this.unsavedExtensionsIdToHostAccess_.get(id) === siteAccess) {
        this.unsavedExtensionsIdToHostAccess_.delete(id);
      }
    });

    // Remove any HostAccess from `unsavedExtensionsIdToHostAccess_` for
    // extensions that are no longer in `extensionSiteAccessData`.
    for (const extensionId of this.unsavedExtensionsIdToHostAccess_.keys()) {
      if (!this.extensionsIdToInfo_.has(extensionId)) {
        this.unsavedExtensionsIdToHostAccess_.delete(extensionId);
      }
    }

    this.extensionSiteAccessData_ = extensionSiteAccessData;
  }

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

  private async onSubmitClick_() {
    if (this.siteSet_ !== this.originalSiteSet) {
      // If `this.site` has a scheme (and can be considered a full url), use it
      // as is. Otherwise if `this.site` is just a host, append the http and
      // https schemes to it.
      const sitesToChange = this.isSiteHostOnly_() ?
          [`http://${this.site}`, `https://${this.site}`] :
          [this.site];
      if (this.siteSet_ === EXTENSION_SPECIFIED) {
        await this.delegate.removeUserSpecifiedSites(
            this.originalSiteSet, sitesToChange);
      } else {
        await this.delegate.addUserSpecifiedSites(this.siteSet_, sitesToChange);
      }
    }

    if (this.siteSet_ === EXTENSION_SPECIFIED &&
        this.unsavedExtensionsIdToHostAccess_.size) {
      const updates: chrome.developerPrivate.ExtensionSiteAccessUpdate[] = [];
      this.unsavedExtensionsIdToHostAccess_.forEach((val, key) => {
        updates.push({id: key, siteAccess: val});
      });

      // For changing extensions' site access, first. the wildcard path "/*" is
      // added to the end. Then, if the site does not specify a scheme, use the
      // wildcard scheme.
      const siteToUpdate =
          this.isSiteHostOnly_() ? `*://${this.site}/` : `${this.site}/`;
      await this.delegate.updateSiteAccess(siteToUpdate, updates);
    }

    this.$.dialog.close();
  }

  private getSiteWithoutSubdomainSpecifier_(): string {
    return this.site.replace(SUBDOMAIN_SPECIFIER, '');
  }

  private getPermittedSiteLabel_(): string {
    return this.i18n('editSitePermissionsAllowAllExtensions', this.site);
  }

  private getRestrictedSiteLabel_(): string {
    return this.i18n('editSitePermissionsRestrictExtensions', this.site);
  }

  private matchesSubdomains_(): boolean {
    return matchesSubdomains(this.site);
  }

  private showExtensionSiteAccessData_(): boolean {
    return this.siteSet_ === EXTENSION_SPECIFIED;
  }

  private getDialogBodyContainerClass_(): string {
    return this.matchesSubdomains_() ? 'site-access-list' :
                                       'indented-site-access-list';
  }

  // Returns the value to be displayed for the <select> element for the
  // extension's host access. This shows the unsaved HostAccess value that was
  // changed by the user. Otherwise, show the preexisting HostAccess value.
  private getExtensionHostAccess_(
      extensionId: string,
      originalSiteAccess: chrome.developerPrivate.HostAccess):
      chrome.developerPrivate.HostAccess {
    return this.unsavedExtensionsIdToHostAccess_.get(extensionId) ||
        originalSiteAccess;
  }

  private onHostAccessChange_(e: DomRepeatEvent<ExtensionSiteAccessInfo>) {
    const selectMenu = this.shadowRoot!.querySelectorAll<HTMLSelectElement>(
        '.extension-host-access')[e.model.index];
    assert(selectMenu);

    const originalSiteAccess = e.model.item.siteAccess;
    const newSiteAccess =
        selectMenu.value as chrome.developerPrivate.HostAccess;

    // Sanity check that extensions that don't request all sites access cannot
    // request all sites access from the dialog.
    assert(
        e.model.item.canRequestAllSites ||
        newSiteAccess !== chrome.developerPrivate.HostAccess.ON_ALL_SITES);

    if (originalSiteAccess === newSiteAccess) {
      this.unsavedExtensionsIdToHostAccess_.delete(e.model.item.id);
    } else {
      this.unsavedExtensionsIdToHostAccess_.set(e.model.item.id, newSiteAccess);
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'site-permissions-edit-permissions-dialog':
        SitePermissionsEditPermissionsDialogElement;
  }
}

customElements.define(
    SitePermissionsEditPermissionsDialogElement.is,
    SitePermissionsEditPermissionsDialogElement);