Skip to content

Commit be19fb5

Browse files
Merge e38f978 into f96c655
2 parents f96c655 + e38f978 commit be19fb5

File tree

11 files changed

+550
-79
lines changed

11 files changed

+550
-79
lines changed

injected/entry-points/integration.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function generateConfig() {
2424
},
2525
site: {
2626
domain: topLevelUrl.hostname,
27+
url: topLevelUrl.href,
2728
isBroken: false,
2829
allowlisted: false,
2930
enabledFeatures: [

injected/src/config-feature.js

Lines changed: 194 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { immutableJSONPatch } from 'immutable-json-patch';
22
import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
3+
import { URLPattern } from 'urlpattern-polyfill';
34

45
export default class ConfigFeature {
56
/** @type {import('./utils.js').RemoteConfig | undefined} */
@@ -41,26 +42,131 @@ export default class ConfigFeature {
4142
}
4243

4344
/**
44-
* Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page
45-
* Consider using patchSettings instead as per `getFeatureSetting`.
45+
* Given a config key, interpret the value as a list of conditionals objects, and return the elements that match the current page
46+
* Consider in your feature using patchSettings instead as per `getFeatureSetting`.
4647
* @param {string} featureKeyName
4748
* @return {any[]}
4849
* @protected
4950
*/
50-
matchDomainFeatureSetting(featureKeyName) {
51-
const domain = this.args?.site.domain;
52-
if (!domain) return [];
53-
const domains = this._getFeatureSettings()?.[featureKeyName] || [];
54-
return domains.filter((rule) => {
55-
if (Array.isArray(rule.domain)) {
56-
return rule.domain.some((domainRule) => {
57-
return matchHostname(domain, domainRule);
58-
});
51+
matchConditionalFeatureSetting(featureKeyName) {
52+
const conditionalChanges = this._getFeatureSettings()?.[featureKeyName] || [];
53+
return conditionalChanges.filter((rule) => {
54+
let condition = rule.condition;
55+
// Support shorthand for domain matching for backwards compatibility
56+
if (condition === undefined && 'domain' in rule) {
57+
condition = this._domainToConditonBlocks(rule.domain);
5958
}
60-
return matchHostname(domain, rule.domain);
59+
return this._matchConditionalBlockOrArray(condition);
6160
});
6261
}
6362

63+
/**
64+
* Takes a list of domains and returns a list of condition blocks
65+
* @param {string|string[]} domain
66+
* @returns {ConditionBlock[]}
67+
*/
68+
_domainToConditonBlocks(domain) {
69+
if (Array.isArray(domain)) {
70+
return domain.map((domain) => ({ domain }));
71+
} else {
72+
return [{ domain }];
73+
}
74+
}
75+
76+
/**
77+
* Used to match conditional changes for a settings feature.
78+
* @typedef {object} ConditionBlock
79+
* @property {string[] | string} [domain]
80+
* @property {object} [urlPattern]
81+
*/
82+
83+
/**
84+
* Takes multiple conditional blocks and returns true if any apply.
85+
* @param {ConditionBlock|ConditionBlock[]} conditionBlock
86+
* @returns {boolean}
87+
*/
88+
_matchConditionalBlockOrArray(conditionBlock) {
89+
if (Array.isArray(conditionBlock)) {
90+
return conditionBlock.some((block) => this._matchConditionalBlock(block));
91+
}
92+
return this._matchConditionalBlock(conditionBlock);
93+
}
94+
95+
/**
96+
* Takes a conditional block and returns true if it applies.
97+
* All conditions must be met to return true.
98+
* @param {ConditionBlock} conditionBlock
99+
* @returns {boolean}
100+
*/
101+
_matchConditionalBlock(conditionBlock) {
102+
// List of conditions that we support currently, these return truthy if the condition is met
103+
/** @type {Record<string, (conditionBlock: ConditionBlock) => boolean>} */
104+
const conditionChecks = {
105+
domain: this._matchDomainConditional,
106+
urlPattern: this._matchUrlPatternConditional,
107+
};
108+
109+
for (const key in conditionBlock) {
110+
/*
111+
Unsupported condition so fail for backwards compatibility
112+
If you wish to support older clients you should create an old condition block
113+
without the unsupported key also.
114+
Such as:
115+
[
116+
{
117+
condition: {
118+
domain: 'example.com'
119+
}
120+
},
121+
{
122+
condition: {
123+
domain: 'example.com',
124+
newKey: 'value'
125+
}
126+
}
127+
]
128+
*/
129+
if (!conditionChecks[key]) {
130+
return false;
131+
} else if (!conditionChecks[key].call(this, conditionBlock)) {
132+
return false;
133+
}
134+
}
135+
return true;
136+
}
137+
138+
/**
139+
* Takes a condtion block and returns true if the current url matches the urlPattern.
140+
* @param {ConditionBlock} conditionBlock
141+
* @returns {boolean}
142+
*/
143+
_matchUrlPatternConditional(conditionBlock) {
144+
const url = this.args?.site.url;
145+
if (!url) return false;
146+
if (typeof conditionBlock.urlPattern === 'string') {
147+
// Use the current URL as the base for matching
148+
return new URLPattern(conditionBlock.urlPattern, url).test(url);
149+
}
150+
const pattern = new URLPattern(conditionBlock.urlPattern);
151+
return pattern.test(url);
152+
}
153+
154+
/**
155+
* Takes a condition block and returns true if the current domain matches the domain.
156+
* @param {ConditionBlock} conditionBlock
157+
* @returns {boolean}
158+
*/
159+
_matchDomainConditional(conditionBlock) {
160+
if (!conditionBlock.domain) return false;
161+
const domain = this.args?.site.domain;
162+
if (!domain) return false;
163+
if (Array.isArray(conditionBlock.domain)) {
164+
// Explicitly check for an empty array as matchHostname will return true a single item array that matches
165+
return false;
166+
}
167+
return matchHostname(domain, conditionBlock.domain);
168+
}
169+
64170
/**
65171
* Return the settings object for a feature
66172
* @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature
@@ -104,40 +210,85 @@ export default class ConfigFeature {
104210
}
105211

106212
/**
107-
* Return a specific setting from the feature settings
108-
* If the "settings" key within the config has a "domains" key, it will be used to override the settings.
109-
* This uses JSONPatch to apply the patches to settings before getting the setting value.
110-
* For example.com getFeatureSettings('val') will return 1:
111-
* ```json
112-
* {
113-
* "settings": {
114-
* "domains": [
115-
* {
116-
* "domain": "example.com",
117-
* "patchSettings": [
118-
* { "op": "replace", "path": "/val", "value": 1 }
119-
* ]
120-
* }
121-
* ]
122-
* }
123-
* }
124-
* ```
125-
* "domain" can either be a string or an array of strings.
126-
127-
* For boolean states you should consider using getFeatureSettingEnabled.
128-
* @param {string} featureKeyName
129-
* @param {string} [featureName]
130-
* @returns {any}
131-
*/
213+
* Return a specific setting from the feature settings
214+
* If the "settings" key within the config has a "conditionalChanges" key, it will be used to override the settings.
215+
* This uses JSONPatch to apply the patches to settings before getting the setting value.
216+
* For example.com getFeatureSettings('val') will return 1:
217+
* ```json
218+
* {
219+
* "settings": {
220+
* "conditionalChanges": [
221+
* {
222+
* "domain": "example.com",
223+
* "patchSettings": [
224+
* { "op": "replace", "path": "/val", "value": 1 }
225+
* ]
226+
* }
227+
* ]
228+
* }
229+
* }
230+
* ```
231+
* "domain" can either be a string or an array of strings.
232+
* Additionally we support urlPattern for more complex matching.
233+
* For example.com getFeatureSettings('val') will return 1:
234+
* ```json
235+
* {
236+
* "settings": {
237+
* "conditionalChanges": [
238+
* {
239+
* "condition": {
240+
* "urlPattern": "https://example.com/*",
241+
* },
242+
* "patchSettings": [
243+
* { "op": "replace", "path": "/val", "value": 1 }
244+
* ]
245+
* }
246+
* ]
247+
* }
248+
* }
249+
* ```
250+
* We also support multiple conditions:
251+
* ```json
252+
* {
253+
* "settings": {
254+
* "conditionalChanges": [
255+
* {
256+
* "condition": [
257+
* {
258+
* "urlPattern": "https://example.com/*",
259+
* },
260+
* {
261+
* "urlPattern": "https://other.com/path/something",
262+
* },
263+
* ],
264+
* "patchSettings": [
265+
* { "op": "replace", "path": "/val", "value": 1 }
266+
* ]
267+
* }
268+
* ]
269+
* }
270+
* }
271+
* ```
272+
*
273+
* For boolean states you should consider using getFeatureSettingEnabled.
274+
* @param {string} featureKeyName
275+
* @param {string} [featureName]
276+
* @returns {any}
277+
*/
132278
getFeatureSetting(featureKeyName, featureName) {
133279
let result = this._getFeatureSettings(featureName);
134-
if (featureKeyName === 'domains') {
135-
throw new Error('domains is a reserved feature setting key name');
280+
if (featureKeyName in ['domains', 'conditionalChanges']) {
281+
throw new Error(`${featureKeyName} is a reserved feature setting key name`);
136282
}
137-
const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => {
138-
return a.domain.length - b.domain.length;
139-
});
140-
for (const match of domainMatch) {
283+
// We only support one of these keys at a time, where conditionalChanges takes precedence
284+
let conditionalMatches = [];
285+
// Presence check using result to avoid the [] default response
286+
if (result?.conditionalChanges) {
287+
conditionalMatches = this.matchConditionalFeatureSetting('conditionalChanges');
288+
} else {
289+
conditionalMatches = this.matchConditionalFeatureSetting('domains');
290+
}
291+
for (const match of conditionalMatches) {
141292
if (match.patchSettings === undefined) {
142293
continue;
143294
}

injected/src/content-feature.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import ConfigFeature from './config-feature.js';
1717
/**
1818
* @typedef {object} Site
1919
* @property {string | null} domain
20+
* @property {string | null} url
2021
* @property {boolean} [isBroken]
2122
* @property {boolean} [allowlisted]
2223
* @property {string[]} [enabledFeatures]

injected/src/features/element-hiding.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,11 @@ export default class ElementHiding extends ContentFeature {
320320

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

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

329329
const overrideRules = activeDomainRules.filter((rule) => {
330330
return rule.type === 'override';

injected/src/features/navigator-interface.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createPageWorldBridge } from './message-bridge/create-page-world-bridge
44

55
export default class NavigatorInterface extends ContentFeature {
66
load(args) {
7-
if (this.matchDomainFeatureSetting('privilegedDomains').length) {
7+
if (this.matchConditionalFeatureSetting('privilegedDomains').length) {
88
this.injectNavigatorInterface(args);
99
}
1010
}

injected/src/utils.js

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,31 +126,48 @@ export function hasThirdPartyOrigin(scriptOrigins) {
126126
}
127127

128128
/**
129-
* Best guess effort of the tabs hostname; where possible always prefer the args.site.domain
130-
* @returns {string|null} inferred tab hostname
129+
* @returns {URL | null}
131130
*/
132-
export function getTabHostname() {
133-
let framingOrigin = null;
131+
export function getTabUrl() {
132+
let framingURLString = null;
134133
try {
135134
// @ts-expect-error - globalThis.top is possibly 'null' here
136-
framingOrigin = globalThis.top.location.href;
135+
framingURLString = globalThis.top.location.href;
136+
} catch {
137+
// If there's no URL then let's fall back to using the frame ancestors origin which won't have path
138+
// Fall back to the referrer if we can't get the top level origin
139+
framingURLString = getTopLevelOriginFromFrameAncestors() ?? globalThis.document.referrer;
140+
}
141+
142+
let framingURL;
143+
try {
144+
framingURL = new URL(framingURLString);
137145
} catch {
138-
framingOrigin = globalThis.document.referrer;
146+
framingURL = null;
139147
}
148+
return framingURL;
149+
}
140150

151+
/**
152+
* @returns {string | null}
153+
*/
154+
function getTopLevelOriginFromFrameAncestors() {
155+
// For about:blank, we can't get the top location
141156
// Not supported in Firefox
142157
if ('ancestorOrigins' in globalThis.location && globalThis.location.ancestorOrigins.length) {
143158
// ancestorOrigins is reverse order, with the last item being the top frame
144-
framingOrigin = globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1);
159+
return globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1);
145160
}
161+
return null;
162+
}
146163

147-
try {
148-
// @ts-expect-error - framingOrigin is possibly 'null' here
149-
framingOrigin = new URL(framingOrigin).hostname;
150-
} catch {
151-
framingOrigin = null;
152-
}
153-
return framingOrigin;
164+
/**
165+
* Best guess effort of the tabs hostname; where possible always prefer the args.site.domain
166+
* @returns {string|null} inferred tab hostname
167+
*/
168+
export function getTabHostname() {
169+
const topURLString = getTabUrl()?.hostname;
170+
return topURLString || null;
154171
}
155172

156173
/**
@@ -532,6 +549,7 @@ export function computeLimitedSiteObject() {
532549
const topLevelHostname = getTabHostname();
533550
return {
534551
domain: topLevelHostname,
552+
url: getTabUrl()?.href || null,
535553
};
536554
}
537555

0 commit comments

Comments
 (0)