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