Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions injected/entry-points/integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function generateConfig() {
},
site: {
domain: topLevelUrl.hostname,
url: topLevelUrl.href,
isBroken: false,
allowlisted: false,
enabledFeatures: [
Expand Down
237 changes: 194 additions & 43 deletions injected/src/config-feature.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { immutableJSONPatch } from 'immutable-json-patch';
import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
import { URLPattern } from 'urlpattern-polyfill';

export default class ConfigFeature {
/** @type {import('./utils.js').RemoteConfig | undefined} */
Expand Down Expand Up @@ -41,26 +42,131 @@ export default class ConfigFeature {
}

/**
* Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page
* Consider using patchSettings instead as per `getFeatureSetting`.
* Given a config key, interpret the value as a list of conditionals objects, and return the elements that match the current page
* Consider in your feature using patchSettings instead as per `getFeatureSetting`.
* @param {string} featureKeyName
* @return {any[]}
* @protected
*/
matchDomainFeatureSetting(featureKeyName) {
const domain = this.args?.site.domain;
if (!domain) return [];
const domains = this._getFeatureSettings()?.[featureKeyName] || [];
return domains.filter((rule) => {
if (Array.isArray(rule.domain)) {
return rule.domain.some((domainRule) => {
return matchHostname(domain, domainRule);
});
matchConditionalFeatureSetting(featureKeyName) {
const conditionalChanges = this._getFeatureSettings()?.[featureKeyName] || [];
return conditionalChanges.filter((rule) => {
let condition = rule.condition;
// Support shorthand for domain matching for backwards compatibility
if (condition === undefined && 'domain' in rule) {
condition = this._domainToConditonBlocks(rule.domain);
}
return matchHostname(domain, rule.domain);
return this._matchConditionalBlockOrArray(condition);
});
}

/**
* Takes a list of domains and returns a list of condition blocks
* @param {string|string[]} domain
* @returns {ConditionBlock[]}
*/
_domainToConditonBlocks(domain) {
if (Array.isArray(domain)) {
return domain.map((domain) => ({ domain }));
} else {
return [{ domain }];
}
}

/**
* Used to match conditional changes for a settings feature.
* @typedef {object} ConditionBlock
* @property {string[] | string} [domain]
* @property {object} [urlPattern]
*/

/**
* Takes multiple conditional blocks and returns true if any apply.
* @param {ConditionBlock|ConditionBlock[]} conditionBlock
* @returns {boolean}
*/
_matchConditionalBlockOrArray(conditionBlock) {
if (Array.isArray(conditionBlock)) {
return conditionBlock.some((block) => this._matchConditionalBlock(block));
}
return this._matchConditionalBlock(conditionBlock);
}

/**
* Takes a conditional block and returns true if it applies.
* All conditions must be met to return true.
* @param {ConditionBlock} conditionBlock
* @returns {boolean}
*/
_matchConditionalBlock(conditionBlock) {
// List of conditions that we support currently, these return truthy if the condition is met
/** @type {Record<string, (conditionBlock: ConditionBlock) => boolean>} */
const conditionChecks = {
domain: this._matchDomainConditional,
urlPattern: this._matchUrlPatternConditional,
};

for (const key in conditionBlock) {
/*
Unsupported condition so fail for backwards compatibility
If you wish to support older clients you should create an old condition block
without the unsupported key also.
Such as:
[
{
condition: {
domain: 'example.com'
}
},
{
condition: {
domain: 'example.com',
newKey: 'value'
}
}
]
*/
if (!conditionChecks[key]) {
return false;
} else if (!conditionChecks[key].call(this, conditionBlock)) {
return false;
}
}
return true;
}

/**
* Takes a condtion block and returns true if the current url matches the urlPattern.
* @param {ConditionBlock} conditionBlock
* @returns {boolean}
*/
_matchUrlPatternConditional(conditionBlock) {
const url = this.args?.site.url;
if (!url) return false;
if (typeof conditionBlock.urlPattern === 'string') {
// Use the current URL as the base for matching
return new URLPattern(conditionBlock.urlPattern, url).test(url);
}
const pattern = new URLPattern(conditionBlock.urlPattern);
return pattern.test(url);
}

/**
* Takes a condition block and returns true if the current domain matches the domain.
* @param {ConditionBlock} conditionBlock
* @returns {boolean}
*/
_matchDomainConditional(conditionBlock) {
if (!conditionBlock.domain) return false;
const domain = this.args?.site.domain;
if (!domain) return false;
if (Array.isArray(conditionBlock.domain)) {
// Explicitly check for an empty array as matchHostname will return true a single item array that matches
return false;
}
return matchHostname(domain, conditionBlock.domain);
}

/**
* Return the settings object for a feature
* @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature
Expand Down Expand Up @@ -104,40 +210,85 @@ export default class ConfigFeature {
}

/**
* Return a specific setting from the feature settings
* If the "settings" key within the config has a "domains" key, it will be used to override the settings.
* This uses JSONPatch to apply the patches to settings before getting the setting value.
* For example.com getFeatureSettings('val') will return 1:
* ```json
* {
* "settings": {
* "domains": [
* {
* "domain": "example.com",
* "patchSettings": [
* { "op": "replace", "path": "/val", "value": 1 }
* ]
* }
* ]
* }
* }
* ```
* "domain" can either be a string or an array of strings.
* For boolean states you should consider using getFeatureSettingEnabled.
* @param {string} featureKeyName
* @param {string} [featureName]
* @returns {any}
*/
* Return a specific setting from the feature settings
* If the "settings" key within the config has a "conditionalChanges" key, it will be used to override the settings.
* This uses JSONPatch to apply the patches to settings before getting the setting value.
* For example.com getFeatureSettings('val') will return 1:
* ```json
* {
* "settings": {
* "conditionalChanges": [
* {
* "domain": "example.com",
* "patchSettings": [
* { "op": "replace", "path": "/val", "value": 1 }
* ]
* }
* ]
* }
* }
* ```
* "domain" can either be a string or an array of strings.
* Additionally we support urlPattern for more complex matching.
* For example.com getFeatureSettings('val') will return 1:
* ```json
* {
* "settings": {
* "conditionalChanges": [
* {
* "condition": {
* "urlPattern": "https://example.com/*",
* },
* "patchSettings": [
* { "op": "replace", "path": "/val", "value": 1 }
* ]
* }
* ]
* }
* }
* ```
* We also support multiple conditions:
* ```json
* {
* "settings": {
* "conditionalChanges": [
* {
* "condition": [
* {
* "urlPattern": "https://example.com/*",
* },
* {
* "urlPattern": "https://other.com/path/something",
* },
* ],
* "patchSettings": [
* { "op": "replace", "path": "/val", "value": 1 }
* ]
* }
* ]
* }
* }
* ```
*
* For boolean states you should consider using getFeatureSettingEnabled.
* @param {string} featureKeyName
* @param {string} [featureName]
* @returns {any}
*/
getFeatureSetting(featureKeyName, featureName) {
let result = this._getFeatureSettings(featureName);
if (featureKeyName === 'domains') {
throw new Error('domains is a reserved feature setting key name');
if (featureKeyName in ['domains', 'conditionalChanges']) {
throw new Error(`${featureKeyName} is a reserved feature setting key name`);
}
const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => {
return a.domain.length - b.domain.length;
});
for (const match of domainMatch) {
// We only support one of these keys at a time, where conditionalChanges takes precedence
let conditionalMatches = [];
// Presence check using result to avoid the [] default response
if (result?.conditionalChanges) {
conditionalMatches = this.matchConditionalFeatureSetting('conditionalChanges');
} else {
conditionalMatches = this.matchConditionalFeatureSetting('domains');
}
for (const match of conditionalMatches) {
if (match.patchSettings === undefined) {
continue;
}
Expand Down
1 change: 1 addition & 0 deletions injected/src/content-feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import ConfigFeature from './config-feature.js';
/**
* @typedef {object} Site
* @property {string | null} domain
* @property {string | null} url
* @property {boolean} [isBroken]
* @property {boolean} [allowlisted]
* @property {string[]} [enabledFeatures]
Expand Down
4 changes: 2 additions & 2 deletions injected/src/features/element-hiding.js
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,11 @@ export default class ElementHiding extends ContentFeature {

// determine whether strict hide rules should be injected as a style tag
if (shouldInjectStyleTag) {
shouldInjectStyleTag = this.matchDomainFeatureSetting('styleTagExceptions').length === 0;
shouldInjectStyleTag = this.matchConditionalFeatureSetting('styleTagExceptions').length === 0;
}

// collect all matching rules for domain
const activeDomainRules = this.matchDomainFeatureSetting('domains').flatMap((item) => item.rules);
const activeDomainRules = this.matchConditionalFeatureSetting('domains').flatMap((item) => item.rules);

const overrideRules = activeDomainRules.filter((rule) => {
return rule.type === 'override';
Expand Down
2 changes: 1 addition & 1 deletion injected/src/features/navigator-interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createPageWorldBridge } from './message-bridge/create-page-world-bridge

export default class NavigatorInterface extends ContentFeature {
load(args) {
if (this.matchDomainFeatureSetting('privilegedDomains').length) {
if (this.matchConditionalFeatureSetting('privilegedDomains').length) {
this.injectNavigatorInterface(args);
}
}
Expand Down
46 changes: 32 additions & 14 deletions injected/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,31 +126,48 @@ export function hasThirdPartyOrigin(scriptOrigins) {
}

/**
* Best guess effort of the tabs hostname; where possible always prefer the args.site.domain
* @returns {string|null} inferred tab hostname
* @returns {URL | null}
*/
export function getTabHostname() {
let framingOrigin = null;
export function getTabUrl() {
let framingURLString = null;
try {
// @ts-expect-error - globalThis.top is possibly 'null' here
framingOrigin = globalThis.top.location.href;
framingURLString = globalThis.top.location.href;
} catch {
// If there's no URL then let's fall back to using the frame ancestors origin which won't have path
// Fall back to the referrer if we can't get the top level origin
framingURLString = getTopLevelOriginFromFrameAncestors() ?? globalThis.document.referrer;
}

let framingURL;
try {
framingURL = new URL(framingURLString);
} catch {
framingOrigin = globalThis.document.referrer;
framingURL = null;
}
return framingURL;
}

/**
* @returns {string | null}
*/
function getTopLevelOriginFromFrameAncestors() {
// For about:blank, we can't get the top location
// Not supported in Firefox
if ('ancestorOrigins' in globalThis.location && globalThis.location.ancestorOrigins.length) {
// ancestorOrigins is reverse order, with the last item being the top frame
framingOrigin = globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1);
return globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1);
}
return null;
}

try {
// @ts-expect-error - framingOrigin is possibly 'null' here
framingOrigin = new URL(framingOrigin).hostname;
} catch {
framingOrigin = null;
}
return framingOrigin;
/**
* Best guess effort of the tabs hostname; where possible always prefer the args.site.domain
* @returns {string|null} inferred tab hostname
*/
export function getTabHostname() {
const topURLString = getTabUrl()?.hostname;
return topURLString || null;
}

/**
Expand Down Expand Up @@ -532,6 +549,7 @@ export function computeLimitedSiteObject() {
const topLevelHostname = getTabHostname();
return {
domain: topLevelHostname,
url: getTabUrl()?.href || null,
};
}

Expand Down
Loading
Loading