Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
52 changes: 38 additions & 14 deletions injected/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,31 +126,54 @@ 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 {
framingOrigin = globalThis.document.referrer;
framingURLString = globalThis.document.referrer;
}

// 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);
if (!framingURLString) {
// This is suboptimal, but we need to get the top level origin in an about:blank frame
const topLevelOriginFromFrameAncestors = getTopLevelOriginFromFrameAncestors();
if (topLevelOriginFromFrameAncestors) {
framingURLString = topLevelOriginFromFrameAncestors;
}
}

let framingURL;
try {
// @ts-expect-error - framingOrigin is possibly 'null' here
framingOrigin = new URL(framingOrigin).hostname;
framingURL = new URL(framingURLString);
} catch {
framingOrigin = null;
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
return globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1);
}
return framingOrigin;
return null;
}

/**
* 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 +555,7 @@ export function computeLimitedSiteObject() {
const topLevelHostname = getTabHostname();
return {
domain: topLevelHostname,
url: getTabUrl()?.href || null,
};
}

Expand Down
Loading
Loading