blob: eda871b8667fa5ebb14f646f840164f3d0bdf34f [file] [log] [blame]
// 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_icon/cr_icon.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 {I18nMixinLit} from 'chrome://resources/cr_elements/i18n_mixin_lit.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {getHtml} from './runtime_hosts_dialog.html.js';
import {getCss as getSharedStyleCss} from './shared_style.css.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 =
I18nMixinLit(SiteSettingsMixin(CrLitElement));
export class ExtensionsRuntimeHostsDialogElement extends
ExtensionsRuntimeHostsDialogElementBase {
static get is() {
return 'extensions-runtime-hosts-dialog';
}
static override get styles() {
return getSharedStyleCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
itemId: {type: String},
/**
* The site that this entry is currently managing. Only non-empty if this
* is for editing an existing entry.
*/
currentSite: {type: String},
/**
* Whether the dialog should update the host access to be "on specific
* sites" before adding a new host permission.
*/
updateHostAccess: {type: Boolean},
/** The site to add an exception for. */
site_: {type: String},
/** Whether the currently-entered input is valid. */
inputInvalid_: {type: Boolean},
/**
* the list of user specified restricted sites that match with `site_` if
* `site_` is valid.
*/
matchingRestrictedSites_: {type: Array},
};
}
accessor itemId: string = '';
accessor currentSite: string|null = null;
accessor updateHostAccess: boolean = false;
protected accessor site_: string = '';
protected accessor inputInvalid_: boolean = false;
protected accessor matchingRestrictedSites_: string[] = [];
override connectedCallback() {
super.connectedCallback();
if (this.currentSite !== null && this.currentSite !== undefined) {
this.site_ = this.currentSite;
this.validate_();
}
this.$.dialog.showModal();
}
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedProperties.has('restrictedSites') ||
changedPrivateProperties.has('site_')) {
this.matchingRestrictedSites_ = this.computeMatchingRestrictedSites_();
}
}
isOpen(): boolean {
return this.$.dialog.open;
}
/**
* Validates that the pattern entered is valid.
*/
protected 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_);
}
protected onSiteChanged_(e: CustomEvent<{value: string}>) {
this.site_ = e.detail.value;
}
protected computeDialogTitle_(): string {
const stringId = this.currentSite === null ? 'runtimeHostsDialogTitle' :
'hostPermissionsEdit';
return loadTimeData.getString(stringId);
}
protected computeSubmitButtonDisabled_(): boolean {
return this.inputInvalid_ || this.site_ === undefined ||
this.site_.trim().length === 0;
}
protected computeSubmitButtonLabel_(): string {
const stringId = this.currentSite === null ? 'add' : 'save';
return loadTimeData.getString(stringId);
}
private computeMatchingRestrictedSites_(): string[] {
return getMatchingUserSpecifiedSites(this.restrictedSites, this.site_);
}
protected onCancelClick_() {
this.$.dialog.cancel();
}
/**
* The tap handler for the submit button (adds the pattern and closes
* the dialog).
*/
protected 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.
*/
protected 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);