From 9c9c6a4450be67c78ffea808879319e0f7b61cac Mon Sep 17 00:00:00 2001 From: Slava Leleka Date: Wed, 12 Oct 2022 17:09:07 +0300 Subject: [PATCH 01/15] fix pardot-1.0 docs Squashed commit of the following: commit 077dc3b47a8c503b4f75803f67b8280ec645cb35 Author: Slava Leleka Date: Tue Sep 27 20:51:14 2022 +0300 fix pardot-1.0 docs --- src/redirects/pardot-1.0.js | 3 ++- wiki/about-redirects.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/redirects/pardot-1.0.js b/src/redirects/pardot-1.0.js index 92fa72d8e..eb2d312b1 100644 --- a/src/redirects/pardot-1.0.js +++ b/src/redirects/pardot-1.0.js @@ -10,9 +10,10 @@ import { * @redirect pardot-1.0 * * @description - * Mocks the pd.js file of Salesforce + * Mocks the pd.js file of Salesforce. * https://pi.pardot.com/pd.js * https://developer.salesforce.com/docs/marketing/pardot/overview + * * **Example** * ``` * ||pi.pardot.com/pd.js$script,redirect=pardot diff --git a/wiki/about-redirects.md b/wiki/about-redirects.md index af2edfc01..e446ff986 100644 --- a/wiki/about-redirects.md +++ b/wiki/about-redirects.md @@ -413,9 +413,10 @@ https://github.com/gorhill/uBlock/wiki/Resources-Library#noeval-silentjs- ### ⚡️ pardot-1.0 -Mocks the pd.js file of Salesforce +Mocks the pd.js file of Salesforce. https://pi.pardot.com/pd.js https://developer.salesforce.com/docs/marketing/pardot/overview + **Example** ``` ||pi.pardot.com/pd.js$script,redirect=pardot From 33a0369bfe6e9401b1619584f3d8e196fd4dc6aa Mon Sep 17 00:00:00 2001 From: Atlassian Bamboo Date: Wed, 12 Oct 2022 17:09:27 +0300 Subject: [PATCH 02/15] skipci: Automatic increment build number --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 67e34c231..c1b85a552 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adguard/scriptlets", - "version": "1.6.55", + "version": "1.6.56", "description": "AdGuard's JavaScript library of Scriptlets and Redirect resources", "scripts": { "build": "babel-node bundler.js", From 66b417a0cfa364a0a4cb7430c416768d49dcfb62 Mon Sep 17 00:00:00 2001 From: Stanislav Atroschenko Date: Mon, 17 Oct 2022 20:42:20 +0300 Subject: [PATCH 03/15] add trusted-click-element scriptlet #23 AG-169 Merge in ADGUARD-FILTERS/scriptlets from feature/AG-169 to release/v1.7 Squashed commit of the following: commit d1a7f38a5e4bc87b7cb375b2a1b15aee14dd8910 Merge: d7f0ae2 cfa9570 Author: Stanislav A Date: Mon Oct 17 19:44:23 2022 +0300 Merge branch 'release/v1.7' into feature/AG-169 commit d7f0ae2408977affab51ffe8644fd94a9e1a7940 Author: Stanislav A Date: Fri Oct 14 13:51:52 2022 +0300 add comment to delimiter regex commit 34778587eeb30689e59d19093d1c36b7467d3526 Author: Stanislav A Date: Thu Oct 13 14:57:17 2022 +0300 add regexp to extraMatch parsing with a testcase commit 99fc8fc8739bbfdf557011362a2d636cc1c9a516 Author: Stanislav A Date: Thu Oct 13 13:43:08 2022 +0300 improve parseCookieString commit ea51d04b3c0e28b40db110514f7cb1d56898d96c Author: Stanislav A Date: Wed Oct 12 18:38:59 2022 +0300 minor fixes commit f8097f02598ec0056909d9a7e2320d40e165d35c Author: Stanislav A Date: Wed Oct 12 18:36:11 2022 +0300 fix examples in description commit 991d55dc9f288c026ed47cd8c0d6d7ade8893f00 Author: Stanislav A Date: Tue Oct 11 20:17:37 2022 +0300 redo cookie parsing and matching, update tests commit ae82d2d63490d532743c73d8d8f168d15b73394f Author: Stanislav A Date: Mon Oct 10 15:59:49 2022 +0300 fix throttle and delay arg in tests commit 69137173f3680e8d2a0df1d5756df67de6ffa9d5 Author: Stanislav A Date: Mon Oct 10 14:53:08 2022 +0300 improve localStorage matching commit f26ca3ecb69ad95d4bac08b22429cb0be05827c0 Author: Stanislav A Date: Mon Oct 10 14:28:39 2022 +0300 fix broken comment commit 339b00227e953362d8b54cd2d4be881e3b52f1f5 Author: Stanislav A Date: Mon Oct 10 14:27:54 2022 +0300 fix temiout var name commit 3c61def324121819785bcfca95f6d557c2b00617 Author: Stanislav A Date: Mon Oct 10 14:25:07 2022 +0300 add throttle commit a721c37e20b452e38e9c895a65eb0744b4e79871 Author: Stanislav A Date: Fri Oct 7 20:08:25 2022 +0300 add more comments on logic & fix observer pre-timeout disconnect condition commit 04e1d2ef87c341c03754901ab815b4226a10e88e Author: Stanislav A Date: Fri Oct 7 19:31:31 2022 +0300 improve elementStruct usage commit ade2862d2a6b3a51ce6169a348f16971dadaa9a2 Author: Stanislav A Date: Fri Oct 7 19:21:20 2022 +0300 improve delay arg commit bb06036e54d0a7d2fa94216ac3ba0bee1207a801 Author: Stanislav A Date: Fri Oct 7 18:38:30 2022 +0300 improve comments commit 19b4903c5a1f60c1b6416d97f8e5e09f92c52f03 Author: Stanislav A Date: Fri Oct 7 18:22:37 2022 +0300 improve description & add common delimiter commit a98f115b452a6f552ac37ae55c85e6414c6ef737 Author: Stanislav A Date: Thu Oct 6 18:04:20 2022 +0300 guard selectors arg commit 26dab5c3b850b2f4164a4aa7e3bf5ebfa0447b76 Author: Stanislav A Date: Thu Oct 6 15:50:38 2022 +0300 log on invalid delay commit 37442d25e76339fe0387653c63e9b5e964db8321 Author: Stanislav A Date: Thu Oct 6 15:43:54 2022 +0300 add localStorage tests ... and 17 more commits --- .../{prepare-cookie.js => cookie-utils.js} | 30 ++ src/helpers/index.js | 2 +- src/scriptlets/scriptlets-list.js | 1 + src/scriptlets/trusted-click-element.js | 301 ++++++++++++++++ tests/scriptlets/index.test.js | 1 + .../scriptlets/trusted-click-element.test.js | 335 ++++++++++++++++++ 6 files changed, 669 insertions(+), 1 deletion(-) rename src/helpers/{prepare-cookie.js => cookie-utils.js} (58%) create mode 100644 src/scriptlets/trusted-click-element.js create mode 100644 tests/scriptlets/trusted-click-element.test.js diff --git a/src/helpers/prepare-cookie.js b/src/helpers/cookie-utils.js similarity index 58% rename from src/helpers/prepare-cookie.js rename to src/helpers/cookie-utils.js index 29e1abb28..447d6f077 100644 --- a/src/helpers/prepare-cookie.js +++ b/src/helpers/cookie-utils.js @@ -49,3 +49,33 @@ export const prepareCookie = (name, value) => { return cookieData; }; + +/** + * Parses cookie string into object + * @param {string} cookieString string that conforms to document.cookie format + * @returns {Object} key:value object that corresponds with incoming cookies keys and values + */ +export const parseCookieString = (cookieString) => { + const COOKIE_DELIMITER = '='; + const COOKIE_PAIRS_DELIMITER = ';'; + + // Get raw cookies + const cookieChunks = cookieString.split(COOKIE_PAIRS_DELIMITER); + const cookieData = {}; + + cookieChunks.forEach((singleCookie) => { + let cookieKey; + let cookieValue; + const delimiterIndex = singleCookie.indexOf(COOKIE_DELIMITER); + if (delimiterIndex === -1) { + cookieKey = singleCookie.trim(); + } else { + cookieKey = singleCookie.slice(0, delimiterIndex).trim(); + cookieValue = singleCookie.slice(delimiterIndex + 1); + } + // Save cookie key=value data with null instead of empty ('') values + cookieData[cookieKey] = cookieValue || null; + }); + + return cookieData; +}; diff --git a/src/helpers/index.js b/src/helpers/index.js index 6a3765f4f..110df45bf 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -14,7 +14,7 @@ export * from './observer'; export * from './match-stack'; export * from './open-shadow-dom-utils'; export * from './array-utils'; -export * from './prepare-cookie'; +export * from './cookie-utils'; export * from './number-utils'; export * from './adjust-set-utils'; export * from './fetch-utils'; diff --git a/src/scriptlets/scriptlets-list.js b/src/scriptlets/scriptlets-list.js index cba47c6b2..5d9af2821 100644 --- a/src/scriptlets/scriptlets-list.js +++ b/src/scriptlets/scriptlets-list.js @@ -1,6 +1,7 @@ /** * This file must export all scriptlets which should be accessible */ +export * from './trusted-click-element'; export * from './abort-on-property-read'; export * from './abort-on-property-write'; export * from './prevent-setTimeout'; diff --git a/src/scriptlets/trusted-click-element.js b/src/scriptlets/trusted-click-element.js new file mode 100644 index 000000000..510b7b69a --- /dev/null +++ b/src/scriptlets/trusted-click-element.js @@ -0,0 +1,301 @@ +import { + hit, + toRegExp, + parseCookieString, +} from '../helpers/index'; + +/* eslint-disable max-len */ +/** + * @scriptlet trusted-click-element + * + * @description + * Clicks selected elements in a strict sequence, ordered by selectors passed, and waiting for them to render in the DOM first. + * Deactivates after all elements have been clicked or by 10s timeout. + * + * **Syntax** + * ``` + * example.com#%#//scriptlet('trusted-click-element', selectors[, extraMatch[, delay]]) + * ``` + * + * - `selectors` — required, string with query selectors delimited by comma + * - `extraMatch` — optional, extra condition to check on a page; allows to match `cookie` and `localStorage`; can be set as `name:key[=value]` where `value` is optional. + * Multiple conditions are allowed inside one `extraMatch` but they should be delimited by comma and each of them should match the syntax. Possible `name`s: + * - `cookie` - test string or regex against cookies on a page + * - `localStorage` - check if localStorage item is present + * - 'delay' - optional, time in ms to delay scriptlet execution, defaults to instant execution. + * **Examples** + * 1. Click single element by selector + * ``` + * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]') + * ``` + * + * 2. Delay click execution by 500ms + * ``` + * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', '', '500') + * ``` + * + * 3. Click multiple elements by selector with a delay + * ``` + * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"], button[name='check"], input[type="submit"][value="akkoord"]', '', '500') + * ``` + * + * 4. Match cookies by keys using regex and string + * ``` + * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'cookie:userConsentCommunity, cookie:/cmpconsent|cmp/') + * ``` + * + * 5. Match by cookie key=value pairs using regex and string + * ``` + * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'cookie:userConsentCommunity=true, cookie:/cmpconsent|cmp/=/[a-z]{1,5}/') + * ``` + * + * 6. Match by localStorage item 'promo' key + * ``` + * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'localStorage:promo') + * ``` + * + * 7. Click multiple elements with delay and matching by both cookie string and localStorage item + * ``` + * example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"], input[type="submit"][value="akkoord"]', 'cookie:cmpconsent, localStorage:promo', '250') + * ``` + */ +/* eslint-enable max-len */ +export function trustedClickElement(source, selectors, extraMatch = '', delay = NaN) { + if (!selectors) { + return; + } + // eslint-disable-next-line no-console + const log = console.log.bind(console); + + const OBSERVER_TIMEOUT_MS = 10000; + const THROTTLE_DELAY_MS = 20; + const COOKIE_MATCH_MARKER = 'cookie:'; + const LOCAL_STORAGE_MATCH_MARKER = 'localStorage:'; + const SELECTORS_DELIMITER = ','; + const COOKIE_STRING_DELIMITER = ';'; + // Regex to split match pairs by commas, avoiding the ones included in regexes + const EXTRA_MATCH_DELIMITER = /(,\s*){1}(?=cookie:|localStorage:)/; + + let parsedDelay; + if (delay) { + parsedDelay = parseInt(delay, 10); + const isValidDelay = !Number.isNaN(parsedDelay) || parsedDelay < OBSERVER_TIMEOUT_MS; + if (!isValidDelay) { + log(`Passed delay '${delay}' is invalid or bigger than ${OBSERVER_TIMEOUT_MS} ms`); + return; + } + } + + let canClick = !parsedDelay; + + const cookieMatches = []; + const localStorageMatches = []; + + if (extraMatch) { + // Get all match marker:value pairs from argument + const parsedExtraMatch = extraMatch + .split(EXTRA_MATCH_DELIMITER) + .map((matchStr) => matchStr.trim()); + + // Filter match pairs by marker + parsedExtraMatch.forEach((matchStr) => { + if (matchStr.indexOf(COOKIE_MATCH_MARKER) > -1) { + const cookieMatch = matchStr.replace(COOKIE_MATCH_MARKER, ''); + cookieMatches.push(cookieMatch); + } + if (matchStr.indexOf(LOCAL_STORAGE_MATCH_MARKER) > -1) { + const localStorageMatch = matchStr.replace(LOCAL_STORAGE_MATCH_MARKER, ''); + localStorageMatches.push(localStorageMatch); + } + }); + } + + if (cookieMatches.length > 0) { + const parsedCookieMatches = parseCookieString(cookieMatches.join(COOKIE_STRING_DELIMITER)); + const parsedCookies = parseCookieString(document.cookie); + const cookieKeys = Object.keys(parsedCookies); + if (cookieKeys.length === 0) { + return; + } + + const cookiesMatched = Object.keys(parsedCookieMatches).every((key) => { + // Avoid getting /.?/ result from toRegExp on undefined + // as cookie may be set without value, + // on which cookie parsing will return cookieKey:undefined pair + const valueMatch = parsedCookieMatches[key] ? toRegExp(parsedCookieMatches[key]) : null; + const keyMatch = toRegExp(key); + + return cookieKeys.some((key) => { + const keysMatched = keyMatch.test(key); + if (!keysMatched) { + return false; + } + + // Key matching is enough if cookie value match is not specified + if (!valueMatch) { + return true; + } + + return valueMatch.test(parsedCookies[key]); + }); + }); + + if (!cookiesMatched) { + return; + } + } + + if (localStorageMatches.length > 0) { + const localStorageMatched = localStorageMatches + .every((str) => { + const itemValue = window.localStorage.getItem(str); + return itemValue || itemValue === ''; + }); + if (!localStorageMatched) { + return; + } + } + + /** + * Create selectors array and swap selectors to null on finding it's element + * + * Selectors / nulls should not be (re)moved from array to: + * - keep track of selectors order + * - always know on what index corresponding element should be put + * - prevent selectors from being queried multiple times + */ + let selectorsSequence = selectors + .split(SELECTORS_DELIMITER) + .map((selector) => selector.trim()); + + const createElementObj = (element) => { + return { + element: element || null, + clicked: false, + }; + }; + const elementsSequence = Array(selectorsSequence.length).fill(createElementObj()); + + /** + * Go through elementsSequence from left to right, clicking on found elements + * + * Element should not be clicked if it is already clicked, + * or a previous element is not found or clicked yet + */ + const clickElementsBySequence = () => { + for (let i = 0; i < elementsSequence.length; i += 1) { + const elementObj = elementsSequence[i]; + // Stop clicking if that pos element is not found yet + if (!elementObj.element) { + break; + } + // Skip already clicked elements + if (!elementObj.clicked) { + elementObj.element.click(); + elementObj.clicked = true; + } + } + + const allElementsClicked = elementsSequence + .every((elementObj) => elementObj.clicked === true); + if (allElementsClicked) { + // At this stage observer is already disconnected + hit(source); + } + }; + + const handleElement = (element, i) => { + const elementObj = createElementObj(element); + elementsSequence[i] = elementObj; + + if (canClick) { + clickElementsBySequence(); + } + }; + + /** + * Query all selectors from queue on each mutation + * Each selector is swapped to null in selectorsSequence on founding corresponding element + * + * We start looking for elements before possible delay is over, to avoid cases + * when delay is getting off after the last mutation took place. + * + */ + const findElements = (mutations, observer) => { + const fulfilledSelectors = []; + selectorsSequence.forEach((selector, i) => { + if (!selector) { + return; + } + const element = document.querySelector(selector); + if (!element) { + return; + } + + handleElement(element, i); + fulfilledSelectors.push(selector); + }); + + // selectorsSequence should be modified after the loop to not break loop indexation + selectorsSequence = selectorsSequence.map((selector) => { + return fulfilledSelectors.indexOf(selector) === -1 ? selector : null; + }); + + // Disconnect observer after finding all elements + const allSelectorsFulfilled = selectorsSequence.every((selector) => selector === null); + if (allSelectorsFulfilled) { + observer.disconnect(); + } + }; + + const throttle = (cb, ms) => { + let wait = false; + let savedArgs; + const wrapper = (...args) => { + if (wait) { + savedArgs = args; + return; + } + + cb(...args); + wait = true; + + setTimeout(() => { + wait = false; + if (savedArgs) { + wrapper(savedArgs); + savedArgs = null; + } + }, ms); + }; + return wrapper; + }; + + // eslint-disable-next-line compat/compat + const observer = new MutationObserver(throttle(findElements, THROTTLE_DELAY_MS)); + observer.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + + if (parsedDelay) { + setTimeout(() => { + // Click previously collected elements + clickElementsBySequence(); + canClick = true; + }, parsedDelay); + } + + setTimeout(() => observer.disconnect(), OBSERVER_TIMEOUT_MS); +} + +trustedClickElement.names = [ + 'trusted-click-element', +]; + +trustedClickElement.injections = [ + hit, + toRegExp, + parseCookieString, +]; diff --git a/tests/scriptlets/index.test.js b/tests/scriptlets/index.test.js index 0be3c1cb6..0d9ff391e 100644 --- a/tests/scriptlets/index.test.js +++ b/tests/scriptlets/index.test.js @@ -44,3 +44,4 @@ import './close-window.test'; import './prevent-refresh.test'; import './prevent-element-src-loading.test'; import './no-topics.test'; +import './trusted-click-element.test'; diff --git a/tests/scriptlets/trusted-click-element.test.js b/tests/scriptlets/trusted-click-element.test.js new file mode 100644 index 000000000..bde73da64 --- /dev/null +++ b/tests/scriptlets/trusted-click-element.test.js @@ -0,0 +1,335 @@ +/* eslint-disable no-underscore-dangle, no-console */ +import { runScriptlet, clearGlobalProps } from '../helpers'; +import { prepareCookie } from '../../src/helpers'; + +const { test, module } = QUnit; +const name = 'trusted-click-element'; + +const PANEL_ID = 'panel'; +const CLICKABLE_NAME = 'clickable'; +const SELECTORS_DELIMITER = ','; + +// Generate selectors for each clickable element +const createSelectorsString = (clickOrder) => { + const selectors = clickOrder.map((elemNum) => `#${PANEL_ID} > #${CLICKABLE_NAME}${elemNum}`); + return selectors.join(SELECTORS_DELIMITER); +}; + +// Create clickable element with it's count as id and assertion as onclick +const createClickable = (elementNum) => { + const clickableId = `${CLICKABLE_NAME}${elementNum}`; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = clickableId; + checkbox.onclick = (e) => { + e.currentTarget.setAttribute('clicked', true); + window.clickOrder.push(elementNum); + }; + return checkbox; +}; + +const createPanel = () => { + const panel = document.createElement('div'); + panel.id = PANEL_ID; + document.body.appendChild(panel); + return panel; +}; + +const removePanel = () => document.getElementById('panel').remove(); + +const clearCookie = (cName) => { + document.cookie = `${cName}=; max-age=0`; +}; + +const beforeEach = () => { + window.__debug = () => { + window.hit = 'FIRED'; + }; + window.clickOrder = []; +}; + +const afterEach = () => { + removePanel(); + clearGlobalProps('hit', '__debug', 'clickOrder'); +}; + +module(name, { beforeEach, afterEach }); + +test('Single element clicked', (assert) => { + const ELEM_COUNT = 1; + // Check elements for being clicked and hit func execution + const ASSERTIONS = ELEM_COUNT + 1; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + + runScriptlet(name, [selectorsString]); + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); + + setTimeout(() => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 150); +}); + +test('Single element clicked, delay is set', (assert) => { + const ELEM_COUNT = 1; + const DELAY = 300; + // Check elements for being clicked and hit func execution + const ASSERTIONS = (ELEM_COUNT + 1) * 2; + assert.expect(ASSERTIONS); + const done = assert.async(); + const done2 = assert.async(); + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + + runScriptlet(name, [selectorsString, '', DELAY]); + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); + + setTimeout(() => { + assert.notOk(clickable.getAttribute('clicked'), 'Element should not be clicked before delay'); + assert.strictEqual(window.hit, undefined, 'hit should not fire before delay'); + done(); + }, 200); + + setTimeout(() => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked after delay'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed after delay'); + done2(); + }, 400); +}); + +test('Multiple elements clicked', (assert) => { + const CLICK_ORDER = [1, 2, 3]; + // Assert elements for being clicked, hit func execution & click order + const ASSERTIONS = CLICK_ORDER.length + 2; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = createSelectorsString(CLICK_ORDER); + + runScriptlet(name, [selectorsString]); + const panel = createPanel(); + const clickables = []; + CLICK_ORDER.forEach((number) => { + const clickable = createClickable(number); + panel.appendChild(clickable); + clickables.push(clickable); + }); + + setTimeout(() => { + clickables.forEach((clickable) => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + }); + assert.strictEqual(CLICK_ORDER.join(), window.clickOrder.join(), 'Elements were clicked in a given order'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 150); +}); + +test('Multiple elements clicked, non-ordered render', (assert) => { + const CLICK_ORDER = [2, 1, 3]; + // Assert elements for being clicked, hit func execution & click order + const ASSERTIONS = CLICK_ORDER.length + 2; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = createSelectorsString(CLICK_ORDER); + + runScriptlet(name, [selectorsString]); + const panel = createPanel(); + const clickables = []; + CLICK_ORDER.forEach((number) => { + const clickable = createClickable(number); + panel.appendChild(clickable); + clickables.push(clickable); + }); + + setTimeout(() => { + clickables.forEach((clickable) => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + }); + assert.strictEqual(CLICK_ORDER.join(), window.clickOrder.join(), 'Elements were clicked in a given order'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 150); +}); + +test('extraMatch - single cookie match, matched', (assert) => { + const cookieKey1 = 'first'; + const cookieData = prepareCookie(cookieKey1, 'true'); + document.cookie = cookieData; + const EXTRA_MATCH_STR = `cookie:${cookieKey1}`; + + const ELEM_COUNT = 1; + // Check elements for being clicked and hit func execution + const ASSERTIONS = ELEM_COUNT + 1; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + + runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]); + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); + + setTimeout(() => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 150); + clearCookie(cookieKey1); +}); + +test('extraMatch - single cookie match, not matched', (assert) => { + const cookieKey1 = 'first'; + const cookieKey2 = 'second'; + const cookieData = prepareCookie(cookieKey1, 'true'); + document.cookie = cookieData; + const EXTRA_MATCH_STR = `cookie:${cookieKey2}`; + + const ELEM_COUNT = 1; + // Check elements for being clicked and hit func execution + const ASSERTIONS = ELEM_COUNT + 1; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + + runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]); + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); + + setTimeout(() => { + assert.notOk(clickable.getAttribute('clicked'), 'Element should not be clicked'); + assert.strictEqual(window.hit, undefined, 'hit should not fire'); + done(); + }, 150); + clearCookie(cookieKey1); +}); + +test('extraMatch - string+regex cookie input, matched', (assert) => { + const cookieKey1 = 'first'; + const cookieVal1 = 'true'; + const cookieData1 = prepareCookie(cookieKey1, cookieVal1); + document.cookie = cookieData1; + const EXTRA_MATCH_STR = 'cookie:/firs/=true'; + + const ELEM_COUNT = 1; + // Check elements for being clicked and hit func execution + const ASSERTIONS = ELEM_COUNT + 1; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + + runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]); + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); + + setTimeout(() => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 150); + clearCookie(cookieKey1); +}); + +test('extraMatch - single localStorage match, matched', (assert) => { + const itemName = 'item'; + window.localStorage.setItem(itemName, 'value'); + const EXTRA_MATCH_STR = `localStorage:${itemName}`; + + const ELEM_COUNT = 1; + // Check elements for being clicked and hit func execution + const ASSERTIONS = ELEM_COUNT + 1; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + + runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]); + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); + + setTimeout(() => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 150); + window.localStorage.clear(); +}); + +test('extraMatch - single localStorage match, not matched', (assert) => { + const itemName = 'item'; + const itemName2 = 'key'; + window.localStorage.setItem(itemName, 'value'); + const EXTRA_MATCH_STR = `localStorage:${itemName2}`; + + const ELEM_COUNT = 1; + // Check elements for being clicked and hit func execution + const ASSERTIONS = ELEM_COUNT + 1; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + + runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]); + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); + + setTimeout(() => { + assert.notOk(clickable.getAttribute('clicked'), 'Element should not be clicked'); + assert.strictEqual(window.hit, undefined, 'hit should not fire'); + done(); + }, 150); + window.localStorage.clear(); +}); + +test('extraMatch - complex string+regex cookie input & whitespaces & comma in regex, matched', (assert) => { + const cookieKey1 = 'first'; + const cookieVal1 = 'true'; + const cookieData1 = prepareCookie(cookieKey1, cookieVal1); + const cookieKey2 = 'sec'; + const cookieVal2 = '1-1'; + const cookieData2 = prepareCookie(cookieKey2, cookieVal2); + const cookieKey3 = 'third'; + const cookieVal3 = 'true'; + const cookieData3 = prepareCookie(cookieKey3, cookieVal3); + + document.cookie = cookieData1; + document.cookie = cookieData2; + document.cookie = cookieData3; + + const EXTRA_MATCH_STR = 'cookie:/firs/=true,cookie:sec=/(1-1){1,2}/, cookie:third=true'; + + const ELEM_COUNT = 1; + // Check elements for being clicked and hit func execution + const ASSERTIONS = ELEM_COUNT + 1; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + + runScriptlet(name, [selectorsString, EXTRA_MATCH_STR]); + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); + + setTimeout(() => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 150); + clearCookie(cookieKey1); +}); From e1489eedaba448a8b26ea4728261660079937459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3blewski?= Date: Tue, 18 Oct 2022 09:41:00 +0300 Subject: [PATCH 04/15] AG-16687 add xml-prune scriptlet Squashed commit of the following: commit 18a5a61203b5b705a1772f674782f807121ae373 Merge: 93efce6 0976bb9 Author: Slava Leleka Date: Tue Oct 18 00:57:30 2022 +0300 Merge branch 'feature/AG-16687_01' of ssh://bit.adguard.com:7999/adguard-filters/scriptlets into feature/AG-16687_01 commit 93efce638e76739279f0fb7b52072a3cbdaabf80 Merge: 10ee63d 66b417a Author: Slava Leleka Date: Tue Oct 18 00:57:12 2022 +0300 merge release/v1.7 into feature/AG-16687_01, resolve conflicts commit 0976bb9aae5ec0480adbd6efa6a6b0275e329167 Author: Adam Date: Fri Oct 14 18:47:47 2022 +0200 Remove unnecessary variable commit 10ee63d6e74c3767beabf47c89b997b276ce5fff Author: Adam Date: Fri Oct 14 14:51:54 2022 +0200 Return original value if response is not pruned Fix tests commit 0053d0cf53877ffecf9abb0e8941a623c0f2087b Author: Adam Date: Fri Oct 14 11:40:53 2022 +0200 Remove unnecessary conditional statement commit 930dd6a5dffdf81922e7afe90f9c6e31a44226ab Merge: fe26660 cfa9570 Author: Adam Date: Thu Oct 13 12:41:31 2022 +0200 Merge branch 'release/v1.7' into feature/AG-16687_01 commit fe26660585a92f93e747693c55e729fdf8e1d4fd Author: Adam Date: Thu Oct 13 12:40:41 2022 +0200 Add common constant GET_METHOD for tests Remove unnecessary MPD_PATH commit 63c4beffd727fa8c6c87e27ce53c9be93d806509 Author: Adam Date: Wed Oct 12 14:03:21 2022 +0200 Rename url to urlMatchRegexp commit e43bd18b3cd0fb25aaa7c4ad5b026b825e93eafd Author: Adam Date: Wed Oct 12 13:46:21 2022 +0200 Avoid regexp Improve log Add a note about usage without propsToMatch Change realFetch to nativeFetch Do not reassign input variable Add validity of xmlDoc Get rid of try...catch Remove unnecessary spaces in test file Fix endsWith function commit 8185ae31bbc965d8c1957fbb4c47a11d26148d0d Author: Adam Date: Mon Oct 10 14:33:28 2022 +0200 Remove unnecessary hit function Rename shouldLog to shouldPruneResponse commit cf1f38031e2b9bb3c2f46f4d0a74ae121542f4f1 Author: Adam Date: Fri Oct 7 17:47:07 2022 +0200 Update description commit f358b8a7f54ef1f2dbb77dd902e1312392f9cd95 Author: Adam Date: Fri Oct 7 14:36:48 2022 +0200 Rename checkIfXML functin to isXML commit c197e0aaefd8523eb7481666fc1742c4bcd0f89a Author: Adam Date: Fri Oct 7 14:28:45 2022 +0200 Add ability to log response in a browser console Add tests for logging Use assert.notOk Improve description Remove unnecessary hit function Simplify test file Add removeEventListener() method Improve function name (pruneXML) Add checkIfXML function and comment to it Check if Reflect is supported Add eslint-enable max-len Use indexOf() instead of includes() in tests Add more aliases commit 66b76c04c0bb8444fef11835ec01d4cfeff30f82 Author: Adam Date: Tue Oct 4 10:19:31 2022 +0200 Add new scriptlet - `xml-prune` --- src/helpers/string-utils.js | 2 +- src/scriptlets/scriptlets-list.js | 1 + src/scriptlets/xml-prune.js | 217 +++++++++++++ tests/scriptlets/index.test.js | 1 + tests/scriptlets/test-files/manifestMPD.mpd | 32 ++ tests/scriptlets/xml-prune.test.js | 322 ++++++++++++++++++++ wiki/about-scriptlets.md | 48 +++ 7 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 src/scriptlets/xml-prune.js create mode 100644 tests/scriptlets/test-files/manifestMPD.mpd create mode 100644 tests/scriptlets/xml-prune.test.js diff --git a/src/helpers/string-utils.js b/src/helpers/string-utils.js index a4b79b384..5f5252707 100644 --- a/src/helpers/string-utils.js +++ b/src/helpers/string-utils.js @@ -96,7 +96,7 @@ export const startsWith = (str, prefix) => { export const endsWith = (str, ending) => { // if str === '', (str && false) will return '' // that's why it has to be !!str - return !!str && str.indexOf(ending) === str.length - ending.length; + return !!str && str.lastIndexOf(ending) === str.length - ending.length; }; export const substringAfter = (str, separator) => { diff --git a/src/scriptlets/scriptlets-list.js b/src/scriptlets/scriptlets-list.js index 5d9af2821..bc86709c6 100644 --- a/src/scriptlets/scriptlets-list.js +++ b/src/scriptlets/scriptlets-list.js @@ -48,3 +48,4 @@ export * from './close-window'; export * from './prevent-refresh'; export * from './prevent-element-src-loading'; export * from './no-topics'; +export * from './xml-prune'; diff --git a/src/scriptlets/xml-prune.js b/src/scriptlets/xml-prune.js new file mode 100644 index 000000000..c1de27b30 --- /dev/null +++ b/src/scriptlets/xml-prune.js @@ -0,0 +1,217 @@ +import { + hit, + toRegExp, + startsWith, + endsWith, +} from '../helpers/index'; + +/* eslint-disable max-len */ +/** + * @scriptlet xml-prune + * + * @description + * Removes an element from the specified XML. + * + * + * **Syntax** + * ``` + * example.org#%#//scriptlet('xml-prune'[, propsToMatch[, optionalProp[, urlToMatch]]]) + * ``` + * + * - `propsToMatch` - optional, selector of elements which will be removed from XML + * - `optionalProp` - optional, selector of elements that must occur in XML document + * - `urlToMatch` - optional, string or regular expression for matching the request's URL + * > Usage with no arguments will log response payload and URL to browser console; + * which is useful for debugging but prohibited for production filter lists. + * + * **Examples** + * 1. Remove `Period` tag whose `id` contains `-ad-` from all requests + * ``` + * example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]') + * ``` + * + * 2. Remove `Period` tag whose `id` contains `-ad-`, only if XML contains `SegmentTemplate` + * ``` + * example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]', 'SegmentTemplate') + * ``` + * + * 3. Remove `Period` tag whose `id` contains `-ad-`, only if request's URL contains `.mpd` + * ``` + * example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]', '', '.mpd') + * ``` + * + * 4. Call with no arguments will log response payload and URL at the console + * ``` + * example.org#%#//scriptlet('xml-prune') + * ``` + * + * 5. Call with only `urlToMatch` argument will log response payload and URL only for the matched URL + * ``` + * example.org#%#//scriptlet('xml-prune', '', '', '.mpd') + * ``` + */ +/* eslint-enable max-len */ + +export function xmlPrune(source, propsToRemove, optionalProp = '', urlToMatch) { + // do nothing if browser does not support Reflect, fetch or Proxy (e.g. Internet Explorer) + // https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect + if (typeof Reflect === 'undefined' + || typeof fetch === 'undefined' + || typeof Proxy === 'undefined' + || typeof Response === 'undefined') { + return; + } + + let shouldPruneResponse = true; + // eslint-disable-next-line no-console + const log = console.log.bind(console); + if (!propsToRemove) { + // If "propsToRemove" is not defined, then response shouldn't be pruned + // but it should be logged in browser console + shouldPruneResponse = false; + } + + const urlMatchRegexp = toRegExp(urlToMatch); + + const isXML = (text) => { + // Check if "text" starts with "<" and check if it ends with ">" + // If so, then it might be an XML file and should be pruned or logged + const trimedText = text.trim(); + if (startsWith(trimedText, '<') && endsWith(trimedText, '>')) { + return true; + } + return false; + }; + + const pruneXML = (text) => { + if (!isXML(text)) { + shouldPruneResponse = false; + return text; + } + const xmlParser = new DOMParser(); + const xmlDoc = xmlParser.parseFromString(text, 'text/xml'); + const errorNode = xmlDoc.querySelector('parsererror'); + if (errorNode) { + return text; + } + if (optionalProp !== '' && xmlDoc.querySelector(optionalProp) === null) { + shouldPruneResponse = false; + return text; + } + const elems = xmlDoc.querySelectorAll(propsToRemove); + if (!elems.length) { + shouldPruneResponse = false; + return text; + } + elems.forEach((elem) => { + elem.remove(); + }); + const serializer = new XMLSerializer(); + text = serializer.serializeToString(xmlDoc); + return text; + }; + + const xhrWrapper = (target, thisArg, args) => { + const xhrURL = args[1]; + if (typeof xhrURL !== 'string' || xhrURL.length === 0) { + return Reflect.apply(target, thisArg, args); + } + if (urlMatchRegexp.test(xhrURL)) { + thisArg.addEventListener('readystatechange', function pruneResponse() { + if (thisArg.readyState === 4) { + const { response } = thisArg; + thisArg.removeEventListener('readystatechange', pruneResponse); + if (!shouldPruneResponse) { + if (isXML(response)) { + log(`XMLHttpRequest.open() URL: ${xhrURL}\nresponse: ${response}`); + } + } else { + const prunedResponseContent = pruneXML(response); + if (shouldPruneResponse) { + Object.defineProperty(thisArg, 'response', { + value: prunedResponseContent, + }); + Object.defineProperty(thisArg, 'responseText', { + value: prunedResponseContent, + }); + hit(source); + } + // In case if response shouldn't be pruned + // pruneXML sets shouldPruneResponse to false + // so it's necessary to set it to true again + // otherwise response will be only logged + shouldPruneResponse = true; + } + } + }); + } + return Reflect.apply(target, thisArg, args); + }; + + const xhrHandler = { + apply: xhrWrapper, + }; + // eslint-disable-next-line max-len + window.XMLHttpRequest.prototype.open = new Proxy(window.XMLHttpRequest.prototype.open, xhrHandler); + + // eslint-disable-next-line compat/compat + const nativeFetch = window.fetch; + + const fetchWrapper = (target, thisArg, args) => { + const fetchURL = args[0]; + if (typeof fetchURL !== 'string' || fetchURL.length === 0) { + return Reflect.apply(target, thisArg, args); + } + if (urlMatchRegexp.test(fetchURL)) { + return nativeFetch.apply(this, args).then((response) => { + return response.text().then((text) => { + if (!shouldPruneResponse) { + if (isXML(text)) { + log(`fetch URL: ${fetchURL}\nresponse text: ${text}`); + } + return Reflect.apply(target, thisArg, args); + } + const prunedText = pruneXML(text); + if (shouldPruneResponse) { + hit(source); + return new Response(prunedText, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + // In case if response shouldn't be pruned + // pruneXML sets shouldPruneResponse to false + // so it's necessary to set it to true again + // otherwise response will be only logged + shouldPruneResponse = true; + return Reflect.apply(target, thisArg, args); + }); + }); + } + return Reflect.apply(target, thisArg, args); + }; + + const fetchHandler = { + apply: fetchWrapper, + }; + // eslint-disable-next-line compat/compat + window.fetch = new Proxy(window.fetch, fetchHandler); +} + +xmlPrune.names = [ + 'xml-prune', + // aliases are needed for matching the related scriptlet converted into our syntax + 'xml-prune.js', + 'ubo-xml-prune.js', + 'ubo-xml-prune', +]; + +xmlPrune.injections = [ + hit, + toRegExp, + startsWith, + endsWith, +]; diff --git a/tests/scriptlets/index.test.js b/tests/scriptlets/index.test.js index 0d9ff391e..91d0a9e61 100644 --- a/tests/scriptlets/index.test.js +++ b/tests/scriptlets/index.test.js @@ -44,4 +44,5 @@ import './close-window.test'; import './prevent-refresh.test'; import './prevent-element-src-loading.test'; import './no-topics.test'; +import './xml-prune.test'; import './trusted-click-element.test'; diff --git a/tests/scriptlets/test-files/manifestMPD.mpd b/tests/scriptlets/test-files/manifestMPD.mpd new file mode 100644 index 000000000..68aa64c84 --- /dev/null +++ b/tests/scriptlets/test-files/manifestMPD.mpd @@ -0,0 +1,32 @@ + + + https://vod-gcs-cedexis.cbsaavideo.com/intl_vms/2017/02/17/879659075884/609941_cenc_precon_dash/ + + https://dai.google.com/segments/redirect/c/ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/scriptlets/xml-prune.test.js b/tests/scriptlets/xml-prune.test.js new file mode 100644 index 000000000..e851b6f0f --- /dev/null +++ b/tests/scriptlets/xml-prune.test.js @@ -0,0 +1,322 @@ +/* eslint-disable no-underscore-dangle, no-console */ +import { runScriptlet, clearGlobalProps } from '../helpers'; +import { startsWith } from '../../src/helpers/string-utils'; + +const { test, module } = QUnit; +const name = 'xml-prune'; + +const MPD_OBJECTS_PATH = './test-files/manifestMPD.mpd'; +const GET_METHOD = 'GET'; +const nativeFetch = fetch; +const nativeXhrOpen = XMLHttpRequest.prototype.open; +const nativeConsole = console.log; + +const beforeEach = () => { + window.__debug = () => { + window.hit = 'FIRED'; + }; +}; + +const afterEach = () => { + clearGlobalProps('hit', '__debug'); + fetch = nativeFetch; // eslint-disable-line no-global-assign + console.log = nativeConsole; + XMLHttpRequest.prototype.open = nativeXhrOpen; +}; + +module(name, { beforeEach, afterEach }); + +const isSupported = typeof fetch !== 'undefined' && typeof Proxy !== 'undefined' && typeof Response !== 'undefined'; + +if (!isSupported) { + test('unsupported', (assert) => { + assert.ok(true, 'Browser does not support it'); + }); +} else { + test('Checking if alias name works', (assert) => { + const adgParams = { + name, + engine: 'test', + verbose: true, + }; + const uboParams = { + name: 'xml-prune.js', + engine: 'test', + verbose: true, + }; + + const codeByAdgParams = window.scriptlets.invoke(adgParams); + const codeByUboParams = window.scriptlets.invoke(uboParams); + + assert.strictEqual(codeByAdgParams, codeByUboParams, 'ubo name - ok'); + }); + + test('fetch - no prune (log)', async (assert) => { + const done = assert.async(); + + // mock console.log function for log checking + console.log = function log(input) { + if (input.indexOf('trace') > -1) { + return; + } + const EXPECTED_LOG_STR_START = `fetch URL: ${MPD_OBJECTS_PATH}`; + assert.ok(startsWith(input, EXPECTED_LOG_STR_START), 'console.hit input'); + assert.ok(input.indexOf('pre-roll-1-ad-1') > -1); + }; + + runScriptlet(name); + + const response = await fetch(MPD_OBJECTS_PATH); + const responseMPD = await response.text(); + + assert.ok(responseMPD.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + }); + + test('fetch URL does not match - no prune', async (assert) => { + const MATCH_DATA = "Period[id*='-ad-']"; + const OPTIONAL_MATCH = ''; + const MATCH_URL = 'noPrune'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const response = await fetch(MPD_OBJECTS_PATH); + const responseMPD = await response.text(); + + assert.ok(responseMPD.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + }); + + test('fetch match URL, element to remove does not match - no prune', async (assert) => { + const MATCH_DATA = "Period[id*='do-no-match']"; + const OPTIONAL_MATCH = ''; + const MATCH_URL = '.mpd'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const response = await fetch(MPD_OBJECTS_PATH); + const responseMPD = await response.text(); + + assert.ok(responseMPD.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + }); + + test('fetch match URL, optional argument does not match - no prune', async (assert) => { + const MATCH_DATA = "Period[id*='-ad-']"; + const OPTIONAL_MATCH = 'DO_NOT_MATCH'; + const MATCH_URL = '.mpd'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const response = await fetch(MPD_OBJECTS_PATH); + const responseMPD = await response.text(); + + assert.ok(responseMPD.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + }); + + test('fetch - remove ads', async (assert) => { + const MATCH_DATA = ["Period[id*='-ad-']"]; + + runScriptlet(name, MATCH_DATA); + + const done = assert.async(); + + const response = await fetch(MPD_OBJECTS_PATH); + const responseMPD = await response.text(); + + assert.notOk(responseMPD.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }); + + test('fetch match URL - remove ads', async (assert) => { + const MATCH_DATA = "Period[id*='-ad-']"; + const OPTIONAL_MATCH = ''; + const MATCH_URL = '.mpd'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const response = await fetch(MPD_OBJECTS_PATH); + const responseMPD = await response.text(); + + assert.notOk(responseMPD.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }); + + test('fetch match URL, match optional argument - remove ads', async (assert) => { + const MATCH_DATA = "Period[id*='-ad-']"; + const OPTIONAL_MATCH = 'AdaptationSet'; + const MATCH_URL = '.mpd'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const response = await fetch(MPD_OBJECTS_PATH); + const responseMPD = await response.text(); + + assert.notOk(responseMPD.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }); + + test('xhr - no prune (log)', async (assert) => { + const done = assert.async(); + + console.log = function log(input) { + if (input.indexOf('trace') > -1) { + return; + } + const EXPECTED_LOG_STR_START = `XMLHttpRequest.open() URL: ${MPD_OBJECTS_PATH}`; + assert.ok(startsWith(input, EXPECTED_LOG_STR_START), 'console.hit input'); + assert.ok(input.indexOf('pre-roll-1-ad-1') > -1, 'console.hit input'); + }; + + runScriptlet(name); + + const xhr = new XMLHttpRequest(); + xhr.open(GET_METHOD, MPD_OBJECTS_PATH); + xhr.onload = () => { + assert.ok(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + }; + xhr.send(); + }); + + test('xhr URL does not match - no prune', async (assert) => { + const MATCH_DATA = "Period[id*='-ad-']"; + const OPTIONAL_MATCH = ''; + const MATCH_URL = 'noPrune'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(GET_METHOD, MPD_OBJECTS_PATH); + xhr.onload = () => { + assert.ok(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + }; + xhr.send(); + }); + + test('xhr match URL, element to remove does not match - no prune', async (assert) => { + const MATCH_DATA = "Period[id*='do-no-match']"; + const OPTIONAL_MATCH = ''; + const MATCH_URL = '.mpd'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(GET_METHOD, MPD_OBJECTS_PATH); + xhr.onload = () => { + assert.ok(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + }; + xhr.send(); + }); + + test('xhr match URL, optional argument does not match - no prune', async (assert) => { + const MATCH_DATA = "Period[id*='-ad-']"; + const OPTIONAL_MATCH = 'DO_NOT_MATCH'; + const MATCH_URL = '.mpd'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(GET_METHOD, MPD_OBJECTS_PATH); + xhr.onload = () => { + assert.ok(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, undefined, 'should not hit'); + done(); + }; + xhr.send(); + }); + + test('xhr - remove ads', async (assert) => { + const MATCH_DATA = ["Period[id*='-ad-']"]; + + runScriptlet(name, MATCH_DATA); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(GET_METHOD, MPD_OBJECTS_PATH); + xhr.onload = () => { + assert.notOk(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); + }); + + test('xhr match URL - remove ads', async (assert) => { + const MATCH_DATA = "Period[id*='-ad-']"; + const OPTIONAL_MATCH = ''; + const MATCH_URL = '.mpd'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(GET_METHOD, MPD_OBJECTS_PATH); + xhr.onload = () => { + assert.notOk(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); + }); + + test('xhr match URL, match optional argument - remove ads', async (assert) => { + const MATCH_DATA = "Period[id*='-ad-']"; + const OPTIONAL_MATCH = 'AdaptationSet'; + const MATCH_URL = '.mpd'; + const scriptletArgs = [MATCH_DATA, OPTIONAL_MATCH, MATCH_URL]; + + runScriptlet(name, scriptletArgs); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(GET_METHOD, MPD_OBJECTS_PATH); + xhr.onload = () => { + assert.notOk(xhr.responseText.indexOf('pre-roll-1-ad-1') > -1); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); + }); +} diff --git a/wiki/about-scriptlets.md b/wiki/about-scriptlets.md index 847dad792..ac43183ba 100644 --- a/wiki/about-scriptlets.md +++ b/wiki/about-scriptlets.md @@ -45,6 +45,7 @@ * [set-local-storage-item](#set-local-storage-item) * [set-popads-dummy](#set-popads-dummy) * [set-session-storage-item](#set-session-storage-item) +* [xml-prune](#xml-prune) * * * ### ⚡️ abort-current-inline-script @@ -1493,6 +1494,8 @@ Creates a constant property and assigns it one of the values from the predefined > Actually, it's not a constant. Please note, that it can be rewritten with a value of a different type. +> If empty object is present in chain it will be trapped until chain leftovers appear. + Related UBO scriptlet: https://github.com/gorhill/uBlock/wiki/Resources-Library#set-constantjs- @@ -1692,3 +1695,48 @@ example.org#%#//scriptlet('set-session-storage-item', 'exit-intent-marketing', ' [Scriptlet source](../src/scriptlets/set-session-storage-item.js) * * * +### ⚡️ xml-prune + +Removes an element from the specified XML. + + +**Syntax** +``` +example.org#%#//scriptlet('xml-prune'[, propsToMatch[, optionalProp[, urlToMatch]]]) +``` + +- `propsToMatch` - optional, selector of elements which will be removed from XML +- `optionalProp` - optional, selector of elements that must occur in XML document +- `urlToMatch` - optional, string or regular expression for matching the request's URL +> Usage with no arguments will log response payload and URL to browser console; +which is useful for debugging but prohibited for production filter lists. + +**Examples** +1. Remove `Period` tag whose `id` contains `-ad-` from all requests + ``` + example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]') + ``` + +2. Remove `Period` tag whose `id` contains `-ad-`, only if XML contains `SegmentTemplate` + ``` + example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]', 'SegmentTemplate') + ``` + +3. Remove `Period` tag whose `id` contains `-ad-`, only if request's URL contains `.mpd` + ``` + example.org#%#//scriptlet('xml-prune', 'Period[id*="-ad-"]', '', '.mpd') + ``` + +4. Call with no arguments will log response payload and URL at the console + ``` + example.org#%#//scriptlet('xml-prune') + ``` + +5. Call with only `urlToMatch` argument will log response payload and URL only for the matched URL + ``` + example.org#%#//scriptlet('xml-prune', '', '', '.mpd') + ``` + +[Scriptlet source](../src/scriptlets/xml-prune.js) +* * * + From 932ff8209835bd0b73a8ebfacdbe37ebbdda870b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3blewski?= Date: Tue, 18 Oct 2022 19:44:50 +0300 Subject: [PATCH 05/15] AG-17027 fix issue with isAbortingSuspended in abort-current-inline-script Squashed commit of the following: commit bde3e4b8e3b17ff3cc12baa0f452a2540709d1ec Author: Adam Date: Tue Oct 18 15:04:48 2022 +0200 Update comment commit 0117103a291d024c9d34d85e8ea52dacb90b79e8 Author: Adam Date: Tue Oct 18 14:57:09 2022 +0200 Add comments to test commit 71d257bfc2ff39741d71b09dd34fdd196679ffda Author: Adam Date: Tue Oct 18 14:45:41 2022 +0200 Revert changes in wiki commit 8e1ea58284d306095253b4e44b3d97d47dea6433 Author: Adam Date: Tue Oct 18 14:38:20 2022 +0200 Revert changes in wiki commit 0a48e7a32a97d7d6f9cbc11d932292fe92292eb0 Author: Adam Date: Tue Oct 18 14:12:53 2022 +0200 Fix typo commit f0cf3ee2d94ea891c3a69dca10c484c1f032f689 Author: Adam Date: Tue Oct 18 14:07:38 2022 +0200 Fix issue with isAbortingSuspended in abort-current-inline-script --- src/helpers/get-descriptor-addon.js | 20 ++++++++++++--- .../abort-current-inline-script.test.js | 25 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/helpers/get-descriptor-addon.js b/src/helpers/get-descriptor-addon.js index 2740826f7..f8fb9bb60 100644 --- a/src/helpers/get-descriptor-addon.js +++ b/src/helpers/get-descriptor-addon.js @@ -1,7 +1,9 @@ +import { randomId } from './random-id'; /** * Prevent infinite loops when trapping props that could be used by scriptlet's own helpers * Example: window.RegExp, that is used by matchStackTrace > toRegExp * + * https://github.com/AdguardTeam/Scriptlets/issues/251 * https://github.com/AdguardTeam/Scriptlets/issues/226 * https://github.com/AdguardTeam/Scriptlets/issues/232 * @@ -12,9 +14,21 @@ export function getDescriptorAddon() { isAbortingSuspended: false, isolateCallback(cb, ...args) { this.isAbortingSuspended = true; - const result = cb(...args); - this.isAbortingSuspended = false; - return result; + // try...catch is required in case if there are more than one inline scripts + // which should be aborted. + // so after the first successful abortion, `cb(...args);` will throw error, + // and we should not stop on that and continue to abort other scripts + try { + const result = cb(...args); + this.isAbortingSuspended = false; + return result; + } catch { + this.isAbortingSuspended = false; + const rid = randomId(); + // It's necessary to throw error + // otherwise script will be not aborted + throw new ReferenceError(rid); + } }, }; } diff --git a/tests/scriptlets/abort-current-inline-script.test.js b/tests/scriptlets/abort-current-inline-script.test.js index 05d160534..71199fe75 100644 --- a/tests/scriptlets/abort-current-inline-script.test.js +++ b/tests/scriptlets/abort-current-inline-script.test.js @@ -20,9 +20,9 @@ module(name, { beforeEach, afterEach }); const onError = (assert) => (message) => { const browserErrorMessage = 'Script error.'; - const nodePuppeteerErrorMessageRgx = /Reference error/g; + const nodePuppeteerErrorMessageRgx = /Reference error|ReferenceError/g; const checkResult = message === browserErrorMessage - || message.test(nodePuppeteerErrorMessageRgx); + || nodePuppeteerErrorMessageRgx.test(message); assert.ok(checkResult); }; @@ -246,3 +246,24 @@ test('Protected from infinite loop when prop is used in a helper', (assert) => { assert.strictEqual(window.hit, undefined, 'hit should NOT fire'); }); + +test('searches script by regexp - abort few inline scripts', (assert) => { + window.onerror = onError(assert); + window.shouldBeAborted = true; + window.shouldNotBeAborted = false; + const property = 'console.log'; + const shouldBeAborted = 'shouldBeAborted'; + const shouldNotBeAborted = 'shouldNotBeAborted'; + const search = '/test|abcd|1234|qwerty/'; + const scriptletArgs = [property, search]; + runScriptlet(name, scriptletArgs); + addAndRemoveInlineScript(`window.${property}('test'); window.${shouldBeAborted} = false;`); + addAndRemoveInlineScript(`window.${property}('abcd'); window.${shouldBeAborted} = false;`); + addAndRemoveInlineScript(`window.${property}('1234'); window.${shouldBeAborted} = false;`); + addAndRemoveInlineScript(`window.${property}('should not be aborted'); window.${shouldNotBeAborted} = true;`); + addAndRemoveInlineScript(`window.${property}('qwerty'); window.${shouldBeAborted} = false;`); + + assert.strictEqual(window.shouldBeAborted, true, 'initial value of shouldBeAborted has not changed'); + assert.strictEqual(window.shouldNotBeAborted, true, 'value of shouldBeAborted has been changed from false to true'); + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); From af22d63fc5c6d4069a717e06c8482266ecb00910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3blewski?= Date: Mon, 24 Oct 2022 18:51:27 +0300 Subject: [PATCH 06/15] =?UTF-8?q?AG-17071=20improve=20googlesyndication-ad?= =?UTF-8?q?sbygoogle=20=E2=80=94=20adsbygoogle.push.=20#252?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge in ADGUARD-FILTERS/scriptlets from fix/AG-17071 to release/v1.7 Squashed commit of the following: commit 45daa0bcce51c81e8f6f15c8270cbae35d46e67a Author: Adam Date: Fri Oct 21 13:14:07 2022 +0200 Improve test commit 85a76773abdc5a6153881b42ad3f57f20407f5be Author: Adam Date: Fri Oct 21 12:29:53 2022 +0200 Improve googlesyndication-adsbygoogle — adsbygoogle.push --- src/redirects/googlesyndication-adsbygoogle.js | 4 +++- .../redirects/googlesyndication-adsbygoogle.test.js | 13 +++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/redirects/googlesyndication-adsbygoogle.js b/src/redirects/googlesyndication-adsbygoogle.js index 326c01c25..d65b72438 100644 --- a/src/redirects/googlesyndication-adsbygoogle.js +++ b/src/redirects/googlesyndication-adsbygoogle.js @@ -32,7 +32,9 @@ export function GoogleSyndicationAdsByGoogle(source) { for (const key of Object.keys(arg)) { if (typeof arg[key] === 'function') { try { - arg[key].call(); + // https://github.com/AdguardTeam/Scriptlets/issues/252 + // argument "{}" is needed to fix issue with undefined argument + arg[key].call(this, {}); } catch { /* empty */ } diff --git a/tests/redirects/googlesyndication-adsbygoogle.test.js b/tests/redirects/googlesyndication-adsbygoogle.test.js index 0a10fcdf0..72f6dc2f9 100644 --- a/tests/redirects/googlesyndication-adsbygoogle.test.js +++ b/tests/redirects/googlesyndication-adsbygoogle.test.js @@ -69,8 +69,17 @@ test('Redirect testing', (assert) => { assert.strictEqual(window.adsbygoogle.length, undefined, 'adsbygoogle.length check'); assert.strictEqual(window.adsbygoogle.push.length, 1, 'push.length check'); - const pushCallback = () => { - assert.ok(true, 'callback was called'); + const pushCallback = (arg) => { + try { + // Test for https://github.com/AdguardTeam/Scriptlets/issues/252 + // If arg is not defined then error will be thrown + if (arg.whatever) { + arg.whatever = 1; + } + assert.ok(typeof arg !== 'undefined', 'arg is defined'); + } catch (error) { + assert.ok(false, 'something went wrong'); + } }; const pushArg = { test: 'test', From c01dd655c0b0ead360ae7f3dde6ffe06edc623af Mon Sep 17 00:00:00 2001 From: Stanislav Atroschenko Date: Tue, 25 Oct 2022 12:45:01 +0300 Subject: [PATCH 07/15] add trusted-replace-xhr-response scriptlet #202 AG-13337 Merge in ADGUARD-FILTERS/scriptlets from feature/AG-13337 to release/v1.7 Squashed commit of the following: commit 009b6a77067f72a1c8b06e67e501df484eedddf4 Merge: 4ffc6ac af22d63 Author: Stanislav A Date: Tue Oct 25 11:34:32 2022 +0300 Merge branch 'release/v1.7' into feature/AG-13337 commit 4ffc6ac4700855b375b6f50d736b1550e35daebb Author: Stanislav A Date: Tue Oct 25 11:33:42 2022 +0300 fix description commit 68e5994a442386e8ac846358777922bc85eda836 Author: Stanislav A Date: Mon Oct 24 19:57:31 2022 +0300 fix description commit 43b3d8d36fc76dabe04078e12f12cca091971b11 Author: Stanislav A Date: Mon Oct 24 15:13:00 2022 +0300 fix pattern arg usage commit d02e1fe0c699c7658f7928f5f69787eb6a7ed607 Author: Stanislav A Date: Mon Oct 24 13:07:13 2022 +0300 secretXhr to replacingRequest commit fd494327d0d01f32e45d8304265f762d9b81bc0e Author: Stanislav A Date: Mon Oct 24 13:06:08 2022 +0300 improve match_all_characters_regex constant commit ed2a34874012fae952dc413d9547f1d2a862d665 Author: Stanislav A Date: Fri Oct 21 14:09:48 2022 +0300 add more comments commit 42b271b8ebd8bcaf8e105db697e44fff4dfe6d7e Author: Stanislav A Date: Fri Oct 21 13:56:26 2022 +0300 improve content extraction commit 1a094c97297a8d93fe209fec95e4cafd20fcd2ea Author: Stanislav A Date: Fri Oct 21 12:50:22 2022 +0300 improve description commit 94a419f12da7d9a5e45242089baa7eb633a420c2 Author: Stanislav A Date: Fri Oct 21 12:49:03 2022 +0300 origOpen to nativeOpen, same for Send commit dee24bd567b8447ac69d7fbc13fee34be11e68ce Author: Stanislav A Date: Fri Oct 21 12:44:49 2022 +0300 fix imports and injections order commit 3a1c7a0215b2a497eebd4f1482f27308d33019f4 Author: Stanislav A Date: Thu Oct 20 19:26:51 2022 +0300 clear requestHeaders array after setting headers commit 11befc9b0141751ed86776a76cf5eb5a1d40553e Author: Stanislav A Date: Thu Oct 20 19:21:34 2022 +0300 miscellaneous fixes commit 12a5d9dbfa6ec06504d3080583ae83aa3709158c Author: Stanislav A Date: Thu Oct 20 18:54:47 2022 +0300 improve pattern and replacement args description commit 15f743dabb117b263d77b361227c4a7b6a384710 Author: Stanislav A Date: Thu Oct 20 18:53:10 2022 +0300 redo replacement logic, move matching to matchRequestProps helper commit ae6d501c308d053617de282a6aae106c78239699 Author: Stanislav A Date: Tue Oct 18 14:23:25 2022 +0300 fix example in description commit 72ba62bc623df697deb841aa4088e30d21630b1d Author: Stanislav A Date: Tue Oct 18 14:06:46 2022 +0300 update prevention condition commit e21452bffb99b2ea77e4c39bfd0af2b7c1c21934 Author: Stanislav A Date: Tue Oct 18 13:50:30 2022 +0300 fix duplicate values commit 4a3bd7dec8387cc97be68bf6e4aeecaa1ffe3e0d Author: Stanislav A Date: Tue Oct 18 13:49:34 2022 +0300 add alias comment commit 23769efafe8fdc2dbd3406df34647e3d76876d86 Merge: 0ee0f61 e1489ee Author: Stanislav A Date: Tue Oct 18 13:48:48 2022 +0300 resolve conflict & merge with 1.7 ... and 12 more commits --- src/helpers/index.js | 3 +- src/helpers/match-request-props.js | 35 +++ .../{fetch-utils.js => request-utils.js} | 19 ++ src/scriptlets/prevent-xhr.js | 2 +- src/scriptlets/scriptlets-list.js | 1 + src/scriptlets/trusted-click-element.js | 1 + .../trusted-replace-xhr-response.js | 240 ++++++++++++++++++ tests/scriptlets/index.test.js | 1 + .../trusted-replace-xhr-response.test.js | 168 ++++++++++++ 9 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 src/helpers/match-request-props.js rename src/helpers/{fetch-utils.js => request-utils.js} (88%) create mode 100644 src/scriptlets/trusted-replace-xhr-response.js create mode 100644 tests/scriptlets/trusted-replace-xhr-response.test.js diff --git a/src/helpers/index.js b/src/helpers/index.js index 110df45bf..56700c1cc 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -17,7 +17,7 @@ export * from './array-utils'; export * from './cookie-utils'; export * from './number-utils'; export * from './adjust-set-utils'; -export * from './fetch-utils'; +export * from './request-utils'; export * from './object-utils'; export * from './prevent-window-open-utils'; export * from './add-event-listener-utils'; @@ -26,3 +26,4 @@ export * from './regexp-utils'; export * from './random-response'; export * from './get-descriptor-addon'; export * from './parse-flags'; +export * from './match-request-props'; diff --git a/src/helpers/match-request-props.js b/src/helpers/match-request-props.js new file mode 100644 index 000000000..d8de05df0 --- /dev/null +++ b/src/helpers/match-request-props.js @@ -0,0 +1,35 @@ +import { + getMatchPropsData, + validateParsedData, + parseMatchProps, +} from './request-utils'; + +/** + * Checks if given propsToMatch string matches with given request data + * This is used by prevent-xhr, prevent-fetch, trusted-replace-xhr-response + * and trusted-replace-fetch-response scriptlets + * @param {string} propsToMatch + * @param {Object} requestData object with standard properties of fetch/xhr like url, method etc + * @returns {boolean} + */ +export const matchRequestProps = (propsToMatch, requestData) => { + let isMatched; + + const parsedData = parseMatchProps(propsToMatch); + if (!validateParsedData(parsedData)) { + // eslint-disable-next-line no-console + console.log(`Invalid parameter: ${propsToMatch}`); + isMatched = false; + } else { + const matchData = getMatchPropsData(parsedData); + // prevent only if all props match + isMatched = Object.keys(matchData) + .every((matchKey) => { + const matchValue = matchData[matchKey]; + return Object.prototype.hasOwnProperty.call(requestData, matchKey) + && matchValue.test(requestData[matchKey]); + }); + } + + return isMatched; +}; diff --git a/src/helpers/fetch-utils.js b/src/helpers/request-utils.js similarity index 88% rename from src/helpers/fetch-utils.js rename to src/helpers/request-utils.js index 0fd3dcbb7..b80370e3a 100644 --- a/src/helpers/fetch-utils.js +++ b/src/helpers/request-utils.js @@ -58,6 +58,25 @@ export const getFetchData = (args) => { return fetchPropsObj; }; +/** + * Collect xhr.open arguments to object + * @param {string} method + * @param {string} url + * @param {string} async + * @param {string} user + * @param {string} password + * @returns {Object} + */ +export const getXhrData = (method, url, async, user, password) => { + return { + method, + url, + async, + user, + password, + }; +}; + /** * Parse propsToMatch input string into object; * used for prevent-fetch and prevent-xhr diff --git a/src/scriptlets/prevent-xhr.js b/src/scriptlets/prevent-xhr.js index bec3bde8a..f6ec99d49 100644 --- a/src/scriptlets/prevent-xhr.js +++ b/src/scriptlets/prevent-xhr.js @@ -47,7 +47,7 @@ import { * - value — range on numbers, for example `100-300`, limited to 500000 characters * * > Usage with no arguments will log XMLHttpRequest objects to browser console; - * which is useful for debugging but permitted for production filter lists. + * which is useful for debugging but not allowed for production filter lists. * * **Examples** * 1. Log all XMLHttpRequests diff --git a/src/scriptlets/scriptlets-list.js b/src/scriptlets/scriptlets-list.js index bc86709c6..c3b4f3ba3 100644 --- a/src/scriptlets/scriptlets-list.js +++ b/src/scriptlets/scriptlets-list.js @@ -48,4 +48,5 @@ export * from './close-window'; export * from './prevent-refresh'; export * from './prevent-element-src-loading'; export * from './no-topics'; +export * from './trusted-replace-xhr-response'; export * from './xml-prune'; diff --git a/src/scriptlets/trusted-click-element.js b/src/scriptlets/trusted-click-element.js index 510b7b69a..c364c0dcb 100644 --- a/src/scriptlets/trusted-click-element.js +++ b/src/scriptlets/trusted-click-element.js @@ -292,6 +292,7 @@ export function trustedClickElement(source, selectors, extraMatch = '', delay = trustedClickElement.names = [ 'trusted-click-element', + // trusted scriptlets support no aliases ]; trustedClickElement.injections = [ diff --git a/src/scriptlets/trusted-replace-xhr-response.js b/src/scriptlets/trusted-replace-xhr-response.js new file mode 100644 index 000000000..8f2d83062 --- /dev/null +++ b/src/scriptlets/trusted-replace-xhr-response.js @@ -0,0 +1,240 @@ +import { + hit, + toRegExp, + objectToString, + getWildcardSymbol, + matchRequestProps, + getXhrData, + // following helpers should be imported and injected + // because they are used by helpers above + getMatchPropsData, + validateParsedData, + parseMatchProps, + isValidStrPattern, + escapeRegExp, + isEmptyObject, + getObjectEntries, +} from '../helpers/index'; + +/* eslint-disable max-len */ +/** + * @scriptlet trusted-replace-xhr-response + * + * @description + * Replaces response content of `xhr` requests if **all** given parameters match. + * + * **Syntax** + * ``` + * example.org#%#//scriptlet('trusted-replace-xhr-response'[, pattern, replacement[, propsToMatch]]) + * ``` + * + * - pattern - optional, argument for matching contents of responseText that should be replaced. If set, `replacement` is required; + * possible values: + * - '*' to match all text content + * - string + * - regular expression + * - replacement — optional, should be set if `pattern` is set. String to replace matched content with. Empty string to remove content. + * - propsToMatch — optional, string of space-separated properties to match for extra condition; possible props: + * - string or regular expression for matching the URL passed to `.open()` call; + * - colon-separated pairs name:value where + * - name - name is string or regular expression for matching XMLHttpRequest property name + * - value is string or regular expression for matching the value of the option passed to `.open()` call + * + * > Usage with no arguments will log XMLHttpRequest objects to browser console; + * which is useful for debugging but not permitted for production filter lists. + * + * **Examples** + * 1. Log all XMLHttpRequests + * ``` + * example.org#%#//scriptlet('trusted-replace-xhr-response') + * ``` + * + * 2. Replace text content of XMLHttpRequests with specific url + * ``` + * example.org#%#//scriptlet('trusted-replace-xhr-response', 'adb_detect:true', 'adb_detect:false', 'example.org') + * example.org#%#//scriptlet('trusted-replace-xhr-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', 'example.org') + * ``` + * + * 3. Remove all text content of XMLHttpRequests with specific request method + * ``` + * example.org#%#//scriptlet('trusted-replace-xhr-response', '*', '', 'method:GET') + * ``` + * + * 4. Replace text content of XMLHttpRequests matching by URL regex and request methods + * ``` + * example.org#%#//scriptlet('trusted-replace-xhr-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', '/\.m3u8/ method:/GET|HEAD/') + * ``` + * 5. Remove all text content of all XMLHttpRequests for example.com + * ``` + * example.org#%#//scriptlet('trusted-replace-xhr-response', '*', '', 'example.com') + * ``` + */ +/* eslint-enable max-len */ +export function trustedReplaceXhrResponse(source, pattern = '', replacement = '', propsToMatch = '') { + // do nothing if browser does not support Proxy (e.g. Internet Explorer) + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy + if (typeof Proxy === 'undefined') { + return; + } + + if (typeof pattern === 'undefined' || typeof replacement === 'undefined') { + return; + } + + // eslint-disable-next-line no-console + const log = console.log.bind(console); + const nativeOpen = window.XMLHttpRequest.prototype.open; + const nativeSend = window.XMLHttpRequest.prototype.send; + + let shouldReplace = false; + let xhrData; + let requestHeaders = []; + + const openWrapper = (target, thisArg, args) => { + xhrData = getXhrData(...args); + + if (pattern === '' && replacement === '') { + // Log if no propsToMatch given + const logMessage = `log: xhr( ${objectToString(xhrData)} )`; + log(source, logMessage); + } else { + shouldReplace = matchRequestProps(propsToMatch, xhrData); + } + + // Trap setRequestHeader of target xhr object to mimic request headers later + if (shouldReplace) { + const setRequestHeaderWrapper = (target, thisArg, args) => { + // Collect headers + requestHeaders.push(args); + return Reflect.apply(target, thisArg, args); + }; + + const setRequestHeaderHandler = { + apply: setRequestHeaderWrapper, + }; + + // setRequestHeader can only be called on open xhr object, + // so we can safely proxy it here + thisArg.setRequestHeader = new Proxy(thisArg.setRequestHeader, setRequestHeaderHandler); + } + + return Reflect.apply(target, thisArg, args); + }; + + const sendWrapper = async (target, thisArg, args) => { + if (!shouldReplace) { + return Reflect.apply(target, thisArg, args); + } + + /** + * Create separate XHR request with original request's input + * to be able to collect response data without triggering + * listeners on original XHR object + */ + const replacingRequest = new XMLHttpRequest(); + replacingRequest.addEventListener('readystatechange', () => { + if (replacingRequest.readyState !== 4) { + return; + } + + const { + readyState, + response, + responseText, + responseURL, + responseXML, + status, + statusText, + } = replacingRequest; + + // Extract content from response + const content = responseText || response; + if (typeof content !== 'string') { + return; + } + + const patternRegexp = pattern === getWildcardSymbol() + ? toRegExp + : toRegExp(pattern); + + const modifiedContent = content.replace(patternRegexp, replacement); + + // Manually put required values into target XHR object + // as thisArg can't be redefined and XHR objects can't be (re)assigned or copied + Object.defineProperties(thisArg, { + readyState: { value: readyState }, + response: { value: modifiedContent }, + responseText: { value: modifiedContent }, + responseURL: { value: responseURL }, + responseXML: { value: responseXML }, + status: { value: status }, + statusText: { value: statusText }, + }); + + // Mock events + setTimeout(() => { + const stateEvent = new Event('readystatechange'); + thisArg.dispatchEvent(stateEvent); + + const loadEvent = new Event('load'); + thisArg.dispatchEvent(loadEvent); + + const loadEndEvent = new Event('loadend'); + thisArg.dispatchEvent(loadEndEvent); + }, 1); + + hit(source); + }); + + nativeOpen.apply(replacingRequest, [xhrData.method, xhrData.url]); + + // Mimic request headers before sending + // setRequestHeader can only be called on open request objects + requestHeaders.forEach((header) => { + const name = header[0]; + const value = header[1]; + + replacingRequest.setRequestHeader(name, value); + }); + requestHeaders = []; + + try { + nativeSend.call(replacingRequest, args); + } catch { + return Reflect.apply(target, thisArg, args); + } + return undefined; + }; + + const openHandler = { + apply: openWrapper, + }; + + const sendHandler = { + apply: sendWrapper, + }; + + XMLHttpRequest.prototype.open = new Proxy(XMLHttpRequest.prototype.open, openHandler); + XMLHttpRequest.prototype.send = new Proxy(XMLHttpRequest.prototype.send, sendHandler); +} + +trustedReplaceXhrResponse.names = [ + 'trusted-replace-xhr-response', + // trusted scriptlets support no aliases +]; + +trustedReplaceXhrResponse.injections = [ + hit, + toRegExp, + objectToString, + getWildcardSymbol, + matchRequestProps, + getXhrData, + getMatchPropsData, + validateParsedData, + parseMatchProps, + isValidStrPattern, + escapeRegExp, + isEmptyObject, + getObjectEntries, +]; diff --git a/tests/scriptlets/index.test.js b/tests/scriptlets/index.test.js index 91d0a9e61..5b75d3ebe 100644 --- a/tests/scriptlets/index.test.js +++ b/tests/scriptlets/index.test.js @@ -44,5 +44,6 @@ import './close-window.test'; import './prevent-refresh.test'; import './prevent-element-src-loading.test'; import './no-topics.test'; +import './trusted-replace-xhr-response.test'; import './xml-prune.test'; import './trusted-click-element.test'; diff --git a/tests/scriptlets/trusted-replace-xhr-response.test.js b/tests/scriptlets/trusted-replace-xhr-response.test.js new file mode 100644 index 000000000..347496c58 --- /dev/null +++ b/tests/scriptlets/trusted-replace-xhr-response.test.js @@ -0,0 +1,168 @@ +/* eslint-disable no-underscore-dangle, no-console */ +import { runScriptlet, clearGlobalProps } from '../helpers'; +import { startsWith } from '../../src/helpers/string-utils'; + +const { test, module } = QUnit; +const name = 'trusted-replace-xhr-response'; + +const FETCH_OBJECTS_PATH = './test-files'; +const nativeXhrOpen = XMLHttpRequest.prototype.open; +const nativeXhrSend = XMLHttpRequest.prototype.send; +const nativeConsole = console.log; + +const beforeEach = () => { + window.__debug = () => { + window.hit = 'FIRED'; + }; +}; + +const afterEach = () => { + clearGlobalProps('hit', '__debug'); + XMLHttpRequest.prototype.open = nativeXhrOpen; + XMLHttpRequest.prototype.send = nativeXhrSend; + console.log = nativeConsole; +}; + +module(name, { beforeEach, afterEach }); + +const isSupported = typeof Proxy !== 'undefined'; + +if (isSupported) { + test('No args, logging', async (assert) => { + const METHOD = 'GET'; + const URL = `${FETCH_OBJECTS_PATH}/test01.json`; + + const done = assert.async(); + + // mock console.log function for log checking + console.log = function log(input) { + if (input.indexOf('trace') > -1) { + return; + } + const EXPECTED_LOG_STR = `xhr( method:"${METHOD}" url:"${URL}" )`; + assert.ok(startsWith(input, EXPECTED_LOG_STR), 'console.hit input'); + }; + const PATTERN = ''; + const REPLACEMENT = ''; + + runScriptlet(name, [PATTERN, REPLACEMENT]); + + const xhr = new XMLHttpRequest(); + xhr.open(METHOD, URL); + xhr.onload = () => { + assert.strictEqual(xhr.readyState, 4, 'Response done'); + assert.ok(xhr.response, 'Response data exists'); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); + }); + + test('Matched, string pattern', async (assert) => { + const METHOD = 'GET'; + const URL = `${FETCH_OBJECTS_PATH}/test01.json`; + const PATTERN = /[-0-9]+/; + const REPLACEMENT = 'a'; + const MATCH_DATA = [PATTERN, REPLACEMENT, `${URL} method:${METHOD}`]; + + runScriptlet(name, MATCH_DATA); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(METHOD, URL); + xhr.onload = () => { + assert.strictEqual(xhr.readyState, 4, 'Response done'); + assert.ok(!PATTERN.test(xhr.response), 'Response has been modified'); + assert.ok(!PATTERN.test(xhr.responseText), 'Response text has been modified'); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); + }); + + test('Matched, regex pattern', async (assert) => { + const METHOD = 'GET'; + const URL = `${FETCH_OBJECTS_PATH}/test01.json`; + const PATTERN = '/a1/'; + const REPLACEMENT = 'x'; + const MATCH_DATA = [PATTERN, REPLACEMENT, `${URL} method:${METHOD}`]; + + runScriptlet(name, MATCH_DATA); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(METHOD, URL); + xhr.onload = () => { + assert.strictEqual(xhr.readyState, 4, 'Response done'); + assert.ok(xhr.response.includes(REPLACEMENT) && !xhr.response.includes(PATTERN), 'Response has been modified'); + assert.ok(xhr.responseText.includes(REPLACEMENT) && !xhr.responseText.includes(PATTERN), 'Response text has been modified'); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); + }); + + test('Not matched, response and responseText are intact', async (assert) => { + const METHOD = 'GET'; + const URL = `${FETCH_OBJECTS_PATH}/test01.json`; + const PATTERN = 'a1'; + const REPLACEMENT = 'x'; + const MATCH_DATA = [ + PATTERN, + REPLACEMENT, + `${FETCH_OBJECTS_PATH}/not_test01.json method:${METHOD}`, + ]; + + runScriptlet(name, MATCH_DATA); + + const done = assert.async(); + + const xhr = new XMLHttpRequest(); + xhr.open(METHOD, URL); + xhr.onload = () => { + assert.strictEqual(xhr.readyState, 4, 'Response done'); + assert.notOk(xhr.response.includes(REPLACEMENT), 'Response is intact'); + assert.notOk(xhr.responseText.includes(REPLACEMENT), 'Response text is intact'); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }; + xhr.send(); + }); + + test('Matched, listeners after .send work', async (assert) => { + const METHOD = 'GET'; + const URL = `${FETCH_OBJECTS_PATH}/test01.json`; + const PATTERN = 'a1'; + const REPLACEMENT = 'x'; + const MATCH_DATA = [PATTERN, REPLACEMENT, `${URL} method:${METHOD}`]; + + runScriptlet(name, MATCH_DATA); + + const done1 = assert.async(); + const done2 = assert.async(); + const done3 = assert.async(); + assert.expect(0); + + const xhr = new XMLHttpRequest(); + xhr.open(METHOD, URL); + xhr.send(); + xhr.addEventListener('load', () => { + done1(); + }); + xhr.onload = () => { + done2(); + }; + xhr.addEventListener('loadend', () => { + done3(); + }); + }); +} else { + test('unsupported', (assert) => { + assert.ok(true, 'Browser does not support it'); + }); +} From d435aacd4ea23e7d96fd4c376a67ee7cb46d18a7 Mon Sep 17 00:00:00 2001 From: Stanislav Atroschenko Date: Tue, 25 Oct 2022 16:18:32 +0300 Subject: [PATCH 08/15] =?UTF-8?q?improve=20prevent-fetch=20=E2=80=94=20ret?= =?UTF-8?q?urn=20original=20url=20in=20response=20#216=20AG-14514?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge in ADGUARD-FILTERS/scriptlets from fix/AG-14514 to release/v1.7 Squashed commit of the following: commit 53cfe6a97b6ec99897f874c5842a4d958a338b5a Merge: 1a0d9c5 c01dd65 Author: Stanislav A Date: Tue Oct 25 16:06:50 2022 +0300 Merge branch 'release/v1.7' into fix/AG-14514 commit 1a0d9c58c641dbfe9b51933533a3a2b672c8978e Author: Stanislav A Date: Fri Oct 21 14:19:56 2022 +0300 fix tests commit 2959639f0d993d01e2c81aa4c7c59e8fe557cafe Author: Stanislav A Date: Fri Oct 21 14:14:57 2022 +0300 remove redundant whitespaces in tests assertions commit b39f05cb7005eba368d7fb1c9827e52864ddb8e0 Author: Stanislav A Date: Fri Oct 21 14:13:04 2022 +0300 update build-tests: move helpers to MULTIPLE_TEST_FILES_DIR commit c35d374fbce826e1f8074bc3f24d6b23a3de1f08 Author: Stanislav A Date: Thu Oct 20 13:14:15 2022 +0300 redo helpers tests file structure commit 5a659c23f6cc9e45a4aa9deb54baeb0e164029d2 Author: Stanislav A Date: Thu Oct 20 12:56:33 2022 +0300 fix imports commit 3e3b940d555216dce13fa666ccf5f7c9b7415244 Author: Stanislav A Date: Thu Oct 20 12:46:47 2022 +0300 improve tests commit b9f9c1e5412231fa2c8db483b99fc462ce3d837c Author: Stanislav A Date: Wed Oct 19 20:09:20 2022 +0300 revert index.test typo commit bd76d2c276414edadf51d3691b4f1657c9c118e3 Author: Stanislav A Date: Wed Oct 19 20:07:36 2022 +0300 split helpers tests to separate files and fix typo in noopPromiseResolve commit 1bc6e81143845a6eff03c98a1aa1c3cac3d96c0c Author: Stanislav A Date: Wed Oct 19 19:40:22 2022 +0300 add arg parser test case commit 6509f76ecd0c3d06a5428eb8b0537ede2f22918f Author: Stanislav A Date: Wed Oct 19 19:23:36 2022 +0300 fix response type restrictions and add tests commit 44ed1adfb848b644fb0abb266b822c4e94a87d0f Author: Stanislav A Date: Wed Oct 19 17:01:43 2022 +0300 only allow opaque for responseType arg commit 562ad116f8061ed28b166691bc423d06021cd608 Author: Stanislav A Date: Wed Oct 19 14:24:11 2022 +0300 fix typo and helper param description commit a9f9cdf8e91529a5c2742704843a5c696c637874 Author: Stanislav A Date: Wed Oct 19 14:05:28 2022 +0300 fix match prop parser and add tests commit d9c0c53a1e2d6af47a29964d71f3fcb0462fe3ce Author: Stanislav A Date: Wed Oct 19 13:04:25 2022 +0300 add response type mock for noopPromiseResolve commit 2f886f0373c39c64425d9b72a53ef3ce9f0eedd1 Author: Stanislav A Date: Fri Oct 14 15:09:04 2022 +0300 fix url parsing commit fac3e173741dfb8f68e59f0f479c28ab102abd55 Author: Stanislav A Date: Thu Oct 13 15:09:42 2022 +0300 remove defineProperty in favor of prop commit b9be4e5ffc5fd90742f58ddae0daacd1d60c691c Author: Stanislav A Date: Tue Oct 11 20:49:11 2022 +0300 improve helper comments commit 9fa0ab10bd190c755972cbc4f1081bef99012a48 Author: Stanislav A Date: Mon Oct 10 17:34:00 2022 +0300 improve prevent-fetch --- scripts/build-tests.js | 2 +- src/helpers/noop.js | 15 +++- src/helpers/request-utils.js | 30 ++++++- src/scriptlets/prevent-fetch.js | 19 +++- tests/helpers/fetch-utils.test.js | 87 +++++++++++++++++++ tests/helpers/index.test.js | 115 ++----------------------- tests/helpers/match-stack.test.js | 12 +++ tests/helpers/noop.test.js | 24 ++++++ tests/helpers/number-utils.test.js | 86 ++++++++++++++++++ tests/helpers/string-utils.test.js | 48 +++++++++++ tests/scriptlets/prevent-fetch.test.js | 44 ++++++++++ 11 files changed, 360 insertions(+), 122 deletions(-) create mode 100644 tests/helpers/fetch-utils.test.js create mode 100644 tests/helpers/match-stack.test.js create mode 100644 tests/helpers/noop.test.js create mode 100644 tests/helpers/number-utils.test.js create mode 100644 tests/helpers/string-utils.test.js diff --git a/scripts/build-tests.js b/scripts/build-tests.js index 760bcb998..d75871f96 100644 --- a/scripts/build-tests.js +++ b/scripts/build-tests.js @@ -81,10 +81,10 @@ const getTestConfigs = () => { const MULTIPLE_TEST_FILES_DIRS = [ 'scriptlets', 'redirects', + 'helpers', ]; const ONE_TEST_FILE_DIRS = [ 'lib-tests', - 'helpers', ]; const multipleFilesConfigs = MULTIPLE_TEST_FILES_DIRS diff --git a/src/helpers/noop.js b/src/helpers/noop.js index cdd9e5e6b..b4cca11ec 100644 --- a/src/helpers/noop.js +++ b/src/helpers/noop.js @@ -53,10 +53,11 @@ export const noopObject = () => ({}); export const noopPromiseReject = () => Promise.reject(); // eslint-disable-line compat/compat /** - * Returns Promise object that is resolved with a response - * @param {string} [responseBody='{}'] value of response body + * Returns Promise object that is resolved value of response body + * @param {string} [url=''] value of response url to set on response object + * @param {string} [response='default'] value of response type to set on response object */ -export const noopPromiseResolve = (responseBody = '{}') => { +export const noopPromiseResolve = (responseBody = '{}', responseUrl = '', responseType = 'default') => { if (typeof Response === 'undefined') { return; } @@ -65,6 +66,14 @@ export const noopPromiseResolve = (responseBody = '{}') => { status: 200, statusText: 'OK', }); + + // Mock response' url & type to avoid adb checks + // https://github.com/AdguardTeam/Scriptlets/issues/216 + Object.defineProperties(response, { + url: { value: responseUrl }, + type: { value: responseType }, + }); + // eslint-disable-next-line compat/compat, consistent-return return Promise.resolve(response); }; diff --git a/src/helpers/request-utils.js b/src/helpers/request-utils.js index b80370e3a..bc93ee3c7 100644 --- a/src/helpers/request-utils.js +++ b/src/helpers/request-utils.js @@ -86,18 +86,40 @@ export const getXhrData = (method, url, async, user, password) => { export const parseMatchProps = (propsToMatchStr) => { const PROPS_DIVIDER = ' '; const PAIRS_MARKER = ':'; + const LEGAL_MATCH_PROPS = [ + 'method', + 'url', + 'headers', + 'body', + 'mode', + 'credentials', + 'cache', + 'redirect', + 'referrer', + 'referrerPolicy', + 'integrity', + 'keepalive', + 'signal', + 'async', + ]; const propsObj = {}; const props = propsToMatchStr.split(PROPS_DIVIDER); props.forEach((prop) => { const dividerInd = prop.indexOf(PAIRS_MARKER); - if (dividerInd === -1) { - propsObj.url = prop; - } else { - const key = prop.slice(0, dividerInd); + + const key = prop.slice(0, dividerInd); + const hasLegalMatchProp = LEGAL_MATCH_PROPS.indexOf(key) !== -1; + + if (hasLegalMatchProp) { const value = prop.slice(dividerInd + 1); propsObj[key] = value; + } else { + // Escape multiple colons in prop + // i.e regex value and/or url with protocol specified, with or without 'url:' match prop + // https://github.com/AdguardTeam/Scriptlets/issues/216#issuecomment-1178591463 + propsObj.url = prop; } }); diff --git a/src/scriptlets/prevent-fetch.js b/src/scriptlets/prevent-fetch.js index 061322f02..ae8d9d7ab 100644 --- a/src/scriptlets/prevent-fetch.js +++ b/src/scriptlets/prevent-fetch.js @@ -30,7 +30,7 @@ import { * * **Syntax** * ``` - * example.org#%#//scriptlet('prevent-fetch'[, propsToMatch[, responseBody]]) + * example.org#%#//scriptlet('prevent-fetch'[, propsToMatch[, responseBody[, responseType]]]) * ``` * * - `propsToMatch` - optional, string of space-separated properties to match; possible props: @@ -41,8 +41,12 @@ import { * - responseBody - optional, string for defining response body value, defaults to `emptyObj`. Possible values: * - `emptyObj` - empty object * - `emptyArr` - empty array + * - responseType - optional, string for defining response type, defaults to `default`. Possible values: + * - default + * - opaque + * * > Usage with no arguments will log fetch calls to browser console; - * which is useful for debugging but permitted for production filter lists. + * which is useful for debugging but not permitted for production filter lists. * * **Examples** * 1. Log all fetch calls @@ -82,7 +86,7 @@ import { * ``` */ /* eslint-enable max-len */ -export function preventFetch(source, propsToMatch, responseBody = 'emptyObj') { +export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', responseType = 'default') { // do nothing if browser does not support fetch or Proxy (e.g. Internet Explorer) // https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy @@ -101,6 +105,13 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj') { return; } + // Skip disallowed response types + if (!(responseType === 'default' || responseType === 'opaque')) { + // eslint-disable-next-line no-console + console.log(`Invalid parameter: ${responseType}`); + return; + } + const handlerWrapper = (target, thisArg, args) => { let shouldPrevent = false; const fetchData = getFetchData(args); @@ -131,7 +142,7 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj') { if (shouldPrevent) { hit(source); - return noopPromiseResolve(strResponseBody); + return noopPromiseResolve(strResponseBody, fetchData.url, responseType); } return Reflect.apply(target, thisArg, args); diff --git a/tests/helpers/fetch-utils.test.js b/tests/helpers/fetch-utils.test.js new file mode 100644 index 000000000..03440fd39 --- /dev/null +++ b/tests/helpers/fetch-utils.test.js @@ -0,0 +1,87 @@ +import { parseMatchProps } from '../../src/helpers'; + +const { test, module } = QUnit; +const name = 'scriptlets-redirects helpers'; + +module(name); + +const GET_METHOD = 'GET'; +const METHOD_PROP = 'method'; +const URL_PROP = 'url'; + +const URL1 = 'example.com'; +const URL2 = 'http://example.com'; +const URL3 = '/^https?://example.org/'; +const URL4 = '/^https?://example.org/section#user:45/comments/'; + +test('Test parseMatchProps with different url props, simple input', (assert) => { + assert.strictEqual(parseMatchProps(URL1).url, URL1, 'No url match prop, no protocol, not regexp'); + assert.strictEqual(parseMatchProps(`url:${URL1}`).url, URL1, 'url match prop, no protocol, not regexp'); + + assert.strictEqual(parseMatchProps(URL2).url, URL2, 'No url match prop, has protocol, not regexp'); + assert.strictEqual(parseMatchProps(`url:${URL2}`).url, URL2, 'url match prop, has protocol, not regexp'); + + assert.strictEqual(parseMatchProps(URL3).url, URL3, 'No url match prop, has protocol, regexp'); + assert.strictEqual(parseMatchProps(`url:${URL3}`).url, URL3, 'url match prop, has protocol, regexp'); + + assert.strictEqual(parseMatchProps(URL4).url, URL4, 'No url match prop, has protocol, regexp, extra colon in url'); + assert.strictEqual(parseMatchProps(`url:${URL4}`).url, URL4, 'url match prop, has protocol, extra colon in url'); +}); + +test('Test parseMatchProps with different url props, mixed input', (assert) => { + const INPUT1 = `${URL1} ${METHOD_PROP}:${GET_METHOD}`; + const expected1 = { + url: URL1, + [METHOD_PROP]: GET_METHOD, + }; + assert.deepEqual(parseMatchProps(INPUT1), expected1, 'No url match prop, no protocol, not regexp'); + + const INPUT1_PREFIXED = `${URL_PROP}:${URL1} ${METHOD_PROP}:${GET_METHOD}`; + const expectedPrefixed1 = { + url: URL1, + [METHOD_PROP]: GET_METHOD, + }; + assert.deepEqual(parseMatchProps(INPUT1_PREFIXED), expectedPrefixed1, 'Has url match prop, no protocol, not regexp'); + + const INPUT2 = `${URL2} ${METHOD_PROP}:${GET_METHOD}`; + const expected2 = { + url: URL2, + [METHOD_PROP]: GET_METHOD, + }; + assert.deepEqual(parseMatchProps(INPUT2), expected2, 'No url match prop, has protocol, not regexp'); + + const INPUT2_PREFIXED = `${URL_PROP}:${URL2} ${METHOD_PROP}:${GET_METHOD}`; + const expectedPrefixed2 = { + url: URL2, + [METHOD_PROP]: GET_METHOD, + }; + assert.deepEqual(parseMatchProps(INPUT2_PREFIXED), expectedPrefixed2, 'Has url match prop, has protocol, not regexp'); + + const INPUT3 = `${URL3} ${METHOD_PROP}:${GET_METHOD}`; + const expected3 = { + url: URL3, + [METHOD_PROP]: GET_METHOD, + }; + assert.deepEqual(parseMatchProps(INPUT3), expected3, 'No url match prop, has protocol, regexp'); + + const INPUT3_PREFIXED = `${URL_PROP}:${URL3} ${METHOD_PROP}:${GET_METHOD}`; + const expectedPrefixed3 = { + url: URL3, + [METHOD_PROP]: GET_METHOD, + }; + assert.deepEqual(parseMatchProps(INPUT3_PREFIXED), expectedPrefixed3, 'Has url match prop, has protocol, regexp'); + + const INPUT4 = `${URL4} ${METHOD_PROP}:${GET_METHOD}`; + const expected4 = { + url: URL4, + [METHOD_PROP]: GET_METHOD, + }; + assert.deepEqual(parseMatchProps(INPUT4), expected4, 'No url match prop, has protocol, regexp, extra colon in url'); + + const INPUT4_PREFIXED = `${URL_PROP}:${URL4} ${METHOD_PROP}:${GET_METHOD}`; + const expectedPrefixed4 = { + url: URL4, + [METHOD_PROP]: GET_METHOD, + }; + assert.deepEqual(parseMatchProps(INPUT4_PREFIXED), expectedPrefixed4, 'Has url match prop, has protocol, regexp, extra colon in url'); +}); diff --git a/tests/helpers/index.test.js b/tests/helpers/index.test.js index bd992d1d5..b606a3cdf 100644 --- a/tests/helpers/index.test.js +++ b/tests/helpers/index.test.js @@ -1,110 +1,5 @@ -import { - toRegExp, - getNumberFromString, - noopPromiseResolve, - matchStackTrace, -} from '../../src/helpers'; - -const { test, module } = QUnit; -const name = 'scriptlets-redirects helpers'; - -module(name); - -test('Test toRegExp for valid inputs', (assert) => { - const DEFAULT_VALUE = '.?'; - const defaultRegexp = new RegExp(DEFAULT_VALUE); - let inputStr; - let expRegex; - - inputStr = '/abc/'; - expRegex = /abc/; - assert.deepEqual(toRegExp(inputStr), expRegex); - - inputStr = '/[a-z]{1,9}/'; - expRegex = /[a-z]{1,9}/; - assert.deepEqual(toRegExp(inputStr), expRegex); - - inputStr = ''; - assert.deepEqual(toRegExp(inputStr), defaultRegexp); -}); - -test('Test toRegExp for invalid inputs', (assert) => { - let inputStr; - - assert.throws(() => { - inputStr = '/\\/'; - toRegExp(inputStr); - }); - - assert.throws(() => { - inputStr = '/[/'; - toRegExp(inputStr); - }); - - assert.throws(() => { - inputStr = '/*/'; - toRegExp(inputStr); - }); - - assert.throws(() => { - inputStr = '/[0-9]++/'; - toRegExp(inputStr); - }); -}); - -test('Test getNumberFromString for all data types inputs', (assert) => { - let inputValue; - - // Boolean - inputValue = true; - assert.strictEqual(getNumberFromString(inputValue), null); - - // null - inputValue = null; - assert.strictEqual(getNumberFromString(inputValue), null); - - // undefined - inputValue = undefined; - assert.strictEqual(getNumberFromString(inputValue), null); - - // undefined - inputValue = undefined; - assert.strictEqual(getNumberFromString(inputValue), null); - - // number - inputValue = 123; - assert.strictEqual(getNumberFromString(inputValue), 123); - - // valid string - inputValue = '123parsable'; - assert.strictEqual(getNumberFromString(inputValue), 123); - - // invalid string - inputValue = 'not parsable 123'; - assert.strictEqual(getNumberFromString(inputValue), null); - - // object - inputValue = { test: 'test' }; - assert.strictEqual(getNumberFromString(inputValue), null); - - // array - inputValue = ['test']; - assert.strictEqual(getNumberFromString(inputValue), null); -}); - -test('Test noopPromiseResolve for valid response.body values', async (assert) => { - const objResponse = await noopPromiseResolve('{}'); - const objBody = await objResponse.json(); - - const arrResponse = await noopPromiseResolve('[]'); - const arrBody = await arrResponse.json(); - - assert.ok(typeof objBody === 'object' && !objBody.length); - assert.ok(Array.isArray(arrBody) && !arrBody.length); -}); - -test('Test matchStackTrace for working with getNativeRegexpTest helper', async (assert) => { - const match = matchStackTrace('stack', new Error().stack); - - assert.ok(!match); -}); +import './get-number-from-string.test'; +import './match-stack-trace.test'; +import './noop-promise-resolve.test'; +import './parse-match-props.test'; +import './to-regexp.test'; diff --git a/tests/helpers/match-stack.test.js b/tests/helpers/match-stack.test.js new file mode 100644 index 000000000..ebd90405d --- /dev/null +++ b/tests/helpers/match-stack.test.js @@ -0,0 +1,12 @@ +import { matchStackTrace } from '../../src/helpers'; + +const { test, module } = QUnit; +const name = 'scriptlets-redirects helpers'; + +module(name); + +test('Test matchStackTrace for working with getNativeRegexpTest helper', async (assert) => { + const match = matchStackTrace('stack', new Error().stack); + + assert.ok(!match); +}); diff --git a/tests/helpers/noop.test.js b/tests/helpers/noop.test.js new file mode 100644 index 000000000..510386fad --- /dev/null +++ b/tests/helpers/noop.test.js @@ -0,0 +1,24 @@ +import { noopPromiseResolve } from '../../src/helpers'; + +const { test, module } = QUnit; +const name = 'scriptlets-redirects helpers'; + +module(name); + +test('Test noopPromiseResolve for valid response props', async (assert) => { + const TEST_URL = 'url'; + const TEST_TYPE = 'opaque'; + const objResponse = await noopPromiseResolve('{}'); + const objBody = await objResponse.json(); + + const arrResponse = await noopPromiseResolve('[]'); + const arrBody = await arrResponse.json(); + + const responseWithUrl = await noopPromiseResolve('{}', TEST_URL); + const responseWithType = await noopPromiseResolve('{}', '', TEST_TYPE); + + assert.ok(responseWithUrl.url === TEST_URL); + assert.ok(typeof objBody === 'object' && !objBody.length); + assert.ok(Array.isArray(arrBody) && !arrBody.length); + assert.strictEqual(responseWithType.type, TEST_TYPE); +}); diff --git a/tests/helpers/number-utils.test.js b/tests/helpers/number-utils.test.js new file mode 100644 index 000000000..eb6e6f242 --- /dev/null +++ b/tests/helpers/number-utils.test.js @@ -0,0 +1,86 @@ +import { getNumberFromString } from '../../src/helpers'; + +const { test, module } = QUnit; +const name = 'scriptlets-redirects helpers'; + +module(name); + +test('Test getNumberFromString for all data types inputs', (assert) => { + let inputValue; + + // Boolean + inputValue = true; + assert.strictEqual(getNumberFromString(inputValue), null); + + // null + inputValue = null; + assert.strictEqual(getNumberFromString(inputValue), null); + + // undefined + inputValue = undefined; + assert.strictEqual(getNumberFromString(inputValue), null); + + // undefined + inputValue = undefined; + assert.strictEqual(getNumberFromString(inputValue), null); + + // number + inputValue = 123; + assert.strictEqual(getNumberFromString(inputValue), 123); + + // valid string + inputValue = '123parsable'; + assert.strictEqual(getNumberFromString(inputValue), 123); + + // invalid string + inputValue = 'not parsable 123'; + assert.strictEqual(getNumberFromString(inputValue), null); + + // object + inputValue = { test: 'test' }; + assert.strictEqual(getNumberFromString(inputValue), null); + + // array + inputValue = ['test']; + assert.strictEqual(getNumberFromString(inputValue), null); +}); + +test('Test getNumberFromString for all data types inputs', (assert) => { + let inputValue; + + // Boolean + inputValue = true; + assert.strictEqual(getNumberFromString(inputValue), null); + + // null + inputValue = null; + assert.strictEqual(getNumberFromString(inputValue), null); + + // undefined + inputValue = undefined; + assert.strictEqual(getNumberFromString(inputValue), null); + + // undefined + inputValue = undefined; + assert.strictEqual(getNumberFromString(inputValue), null); + + // number + inputValue = 123; + assert.strictEqual(getNumberFromString(inputValue), 123); + + // valid string + inputValue = '123parsable'; + assert.strictEqual(getNumberFromString(inputValue), 123); + + // invalid string + inputValue = 'not parsable 123'; + assert.strictEqual(getNumberFromString(inputValue), null); + + // object + inputValue = { test: 'test' }; + assert.strictEqual(getNumberFromString(inputValue), null); + + // array + inputValue = ['test']; + assert.strictEqual(getNumberFromString(inputValue), null); +}); diff --git a/tests/helpers/string-utils.test.js b/tests/helpers/string-utils.test.js new file mode 100644 index 000000000..50114b75e --- /dev/null +++ b/tests/helpers/string-utils.test.js @@ -0,0 +1,48 @@ +import { toRegExp } from '../../src/helpers'; + +const { test, module } = QUnit; +const name = 'scriptlets-redirects helpers'; + +module(name); + +test('Test toRegExp for valid inputs', (assert) => { + const DEFAULT_VALUE = '.?'; + const defaultRegexp = new RegExp(DEFAULT_VALUE); + let inputStr; + let expRegex; + + inputStr = '/abc/'; + expRegex = /abc/; + assert.deepEqual(toRegExp(inputStr), expRegex); + + inputStr = '/[a-z]{1,9}/'; + expRegex = /[a-z]{1,9}/; + assert.deepEqual(toRegExp(inputStr), expRegex); + + inputStr = ''; + assert.deepEqual(toRegExp(inputStr), defaultRegexp); +}); + +test('Test toRegExp for invalid inputs', (assert) => { + let inputStr; + + assert.throws(() => { + inputStr = '/\\/'; + toRegExp(inputStr); + }); + + assert.throws(() => { + inputStr = '/[/'; + toRegExp(inputStr); + }); + + assert.throws(() => { + inputStr = '/*/'; + toRegExp(inputStr); + }); + + assert.throws(() => { + inputStr = '/[0-9]++/'; + toRegExp(inputStr); + }); +}); diff --git a/tests/scriptlets/prevent-fetch.test.js b/tests/scriptlets/prevent-fetch.test.js index cc4935230..4233d71df 100644 --- a/tests/scriptlets/prevent-fetch.test.js +++ b/tests/scriptlets/prevent-fetch.test.js @@ -287,4 +287,48 @@ if (!isSupported) { assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); done(); }); + + test('simple fetch - valid response type', async (assert) => { + const OPAQUE_RESPONSE_TYPE = 'opaque'; + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const init = { + method: 'GET', + }; + + runScriptlet(name, ['*', '', OPAQUE_RESPONSE_TYPE]); + const done = assert.async(); + + const response = await fetch(INPUT_JSON_PATH, init); + + assert.strictEqual(response.type, OPAQUE_RESPONSE_TYPE, 'Response type is set'); + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + done(); + }); + + test('simple fetch - invalid response type', async (assert) => { + const INVALID_RESPONSE_TYPE = 'invalid_type'; + const BASIC_RESPONSE_TYPE = 'basic'; + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const init = { + method: 'GET', + }; + + const expectedJson = { + a1: 1, + b2: 'test', + c3: 3, + }; + + runScriptlet(name, ['*', '', INVALID_RESPONSE_TYPE]); + const done = assert.async(); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + assert.deepEqual(actualJson, expectedJson, 'Request is not modified'); + + assert.strictEqual(response.type, BASIC_RESPONSE_TYPE, 'Response type is not modified'); + assert.strictEqual(window.hit, undefined, 'hit function fired'); + done(); + }); } From 0bc5d92f7a40be31dd4f1315bd35023fd8d0375f Mon Sep 17 00:00:00 2001 From: Slava Leleka Date: Thu, 27 Oct 2022 15:01:17 +0300 Subject: [PATCH 09/15] AG-17113 add path parameter to set cookie scriptlets Squashed commit of the following: commit 055f551b9f168b1e507a2e8f8bfe3a32c9c01f65 Author: Slava Leleka Date: Thu Oct 27 14:26:42 2022 +0300 disallow to set unsupported cookie path commit 76ffe8f67e27ae8cefe237e94d27f4171d884710 Author: Slava Leleka Date: Thu Oct 27 14:24:31 2022 +0300 fix tests/helpers/index imports commit cdab0fe3cd88a39897231e3903808752672ffcbc Author: Slava Leleka Date: Tue Oct 25 19:23:57 2022 +0300 fix description for prepareCookie path arg commit 06f1d03a91d3f2539fab6c09e2c2683eb89e9d69 Author: Slava Leleka Date: Tue Oct 25 19:12:40 2022 +0300 fix description for prepareCookie path arg commit ac00fb121d657ea33cf2979d7b23a6795a33e27d Merge: d435aac e05fefc Author: Slava Leleka Date: Tue Oct 25 19:11:48 2022 +0300 Merge branch 'fix/AG-17113' into fix/AG-17113_01 commit e05fefc8317ba3e5fc3c2f85478e400ee8f97f7c Author: Slava Leleka Date: Tue Oct 25 15:26:21 2022 +0300 add path parameter to set cookie scriptlets --- src/helpers/cookie-utils.js | 19 +++++++++++++++++-- src/scriptlets/set-cookie-reload.js | 14 ++++++++++---- src/scriptlets/set-cookie.js | 15 ++++++++++----- tests/helpers/index.test.js | 10 +++++----- wiki/about-scriptlets.md | 21 ++++++++++++++++----- 5 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/helpers/cookie-utils.js b/src/helpers/cookie-utils.js index 447d6f077..13766524e 100644 --- a/src/helpers/cookie-utils.js +++ b/src/helpers/cookie-utils.js @@ -1,15 +1,19 @@ import { nativeIsNaN } from './number-utils'; + /** * Prepares cookie string if given parameters are ok * @param {string} name cookie name to set * @param {string} value cookie value to set + * @param {string} path cookie path to set, 'none' for no path * @returns {string|null} cookie string if ok OR null if not */ -export const prepareCookie = (name, value) => { +export const prepareCookie = (name, value, path) => { if (!name || !value) { return null; } + const log = console.log.bind(console); // eslint-disable-line no-console + let valueToSet; if (value === 'true') { valueToSet = 'true'; @@ -34,16 +38,27 @@ export const prepareCookie = (name, value) => { } else if (/^\d+$/.test(value)) { valueToSet = parseFloat(value); if (nativeIsNaN(valueToSet)) { + log(`Invalid cookie value: '${value}'`); return null; } if (Math.abs(valueToSet) < 0 || Math.abs(valueToSet) > 15) { + log(`Invalid cookie value: '${value}'`); return null; } } else { return null; } - const pathToSet = 'path=/;'; + let pathToSet; + if (path === '/') { + pathToSet = 'path=/'; + } else if (path === 'none') { + pathToSet = ''; + } else { + log(`Invalid cookie path: '${path}'`); + return null; + } + // eslint-disable-next-line max-len const cookieData = `${encodeURIComponent(name)}=${encodeURIComponent(valueToSet)}; ${pathToSet}`; diff --git a/src/scriptlets/set-cookie-reload.js b/src/scriptlets/set-cookie-reload.js index 97a14c755..037c589d7 100644 --- a/src/scriptlets/set-cookie-reload.js +++ b/src/scriptlets/set-cookie-reload.js @@ -8,12 +8,13 @@ import { * @scriptlet set-cookie-reload * * @description - * Sets a cookie with the specified name and value, and then reloads the current page. + * Sets a cookie with the specified name and value, and path, + * and reloads the current page after the cookie setting. * If reloading option is not needed, use [set-cookie](#set-cookie) scriptlet. * * **Syntax** * ``` - * example.org#%#//scriptlet('set-cookie-reload', name, value) + * example.org#%#//scriptlet('set-cookie-reload', name, value[, path]) * ``` * * - `name` - required, cookie name to be set @@ -25,15 +26,20 @@ import { * - `yes` / `Yes` / `Y` * - `no` * - `ok` / `OK` + * - `path` - optional, cookie path, defaults to `/`; possible values: + * - `/` — root path + * - `none` — to set no path at all * * **Examples** * ``` * example.org#%#//scriptlet('set-cookie-reload', 'checking', 'ok') * * example.org#%#//scriptlet('set-cookie-reload', 'gdpr-settings-cookie', '1') + * + * example.org#%#//scriptlet('set-cookie-reload', 'cookie-set', 'true', 'none') * ``` */ -export function setCookieReload(source, name, value) { +export function setCookieReload(source, name, value, path = '/') { const isCookieSetWithValue = (name, value) => { return document.cookie.split(';') .some((cookieStr) => { @@ -52,7 +58,7 @@ export function setCookieReload(source, name, value) { return; } - const cookieData = prepareCookie(name, value); + const cookieData = prepareCookie(name, value, path); if (cookieData) { document.cookie = cookieData; diff --git a/src/scriptlets/set-cookie.js b/src/scriptlets/set-cookie.js index d6f23ad75..441b368eb 100644 --- a/src/scriptlets/set-cookie.js +++ b/src/scriptlets/set-cookie.js @@ -5,11 +5,11 @@ import { hit, nativeIsNaN, prepareCookie } from '../helpers/index'; * @scriptlet set-cookie * * @description - * Sets a cookie with the specified name and value. Cookie path defaults to root. + * Sets a cookie with the specified name, value, and path. * * **Syntax** * ``` - * example.org#%#//scriptlet('set-cookie', name, value) + * example.org#%#//scriptlet('set-cookie', name, value[, path]) * ``` * * - `name` - required, cookie name to be set @@ -21,17 +21,22 @@ import { hit, nativeIsNaN, prepareCookie } from '../helpers/index'; * - `yes` / `Yes` / `Y` * - `no` * - `ok` / `OK` + * - `path` - optional, cookie path, defaults to `/`; possible values: + * - `/` — root path + * - `none` — to set no path at all * * **Examples** * ``` - * example.org#%#//scriptlet('set-cookie', 'ReadlyCookieConsent', '1') + * example.org#%#//scriptlet('set-cookie', 'CookieConsent', '1') * * example.org#%#//scriptlet('set-cookie', 'gdpr-settings-cookie', 'true') + * + * example.org#%#//scriptlet('set-cookie', 'cookie_consent', 'ok', 'none') * ``` */ /* eslint-enable max-len */ -export function setCookie(source, name, value) { - const cookieData = prepareCookie(name, value); +export function setCookie(source, name, value, path = '/') { + const cookieData = prepareCookie(name, value, path); if (cookieData) { hit(source); diff --git a/tests/helpers/index.test.js b/tests/helpers/index.test.js index b606a3cdf..d5adb43a6 100644 --- a/tests/helpers/index.test.js +++ b/tests/helpers/index.test.js @@ -1,5 +1,5 @@ -import './get-number-from-string.test'; -import './match-stack-trace.test'; -import './noop-promise-resolve.test'; -import './parse-match-props.test'; -import './to-regexp.test'; +import './fetch-utils.test'; +import './match-stack.test'; +import './noop.test'; +import './number-utils.test'; +import './string-utils.test'; diff --git a/wiki/about-scriptlets.md b/wiki/about-scriptlets.md index ac43183ba..d7f769e3a 100644 --- a/wiki/about-scriptlets.md +++ b/wiki/about-scriptlets.md @@ -1557,12 +1557,13 @@ example.org#%#//scriptlet('set-constant', 'document.third', 'trueFunc', 'checkin ### ⚡️ set-cookie-reload -Sets a cookie with the specified name and value, and then reloads the current page. +Sets a cookie with the specified name and value, and path, +and reloads the current page after the cookie setting. If reloading option is not needed, use [set-cookie](#set-cookie) scriptlet. **Syntax** ``` -example.org#%#//scriptlet('set-cookie-reload', name, value) +example.org#%#//scriptlet('set-cookie-reload', name, value[, path]) ``` - `name` - required, cookie name to be set @@ -1574,12 +1575,17 @@ example.org#%#//scriptlet('set-cookie-reload', name, value) - `yes` / `Yes` / `Y` - `no` - `ok` / `OK` +- `path` - optional, cookie path, defaults to `/`; possible values: + - `/` — root path + - `none` — to set no path at all **Examples** ``` example.org#%#//scriptlet('set-cookie-reload', 'checking', 'ok') example.org#%#//scriptlet('set-cookie-reload', 'gdpr-settings-cookie', '1') + +example.org#%#//scriptlet('set-cookie-reload', 'cookie-set', 'true', 'none') ``` [Scriptlet source](../src/scriptlets/set-cookie-reload.js) @@ -1587,11 +1593,11 @@ example.org#%#//scriptlet('set-cookie-reload', 'gdpr-settings-cookie', '1') ### ⚡️ set-cookie -Sets a cookie with the specified name and value. Cookie path defaults to root. +Sets a cookie with the specified name, value, and path. **Syntax** ``` -example.org#%#//scriptlet('set-cookie', name, value) +example.org#%#//scriptlet('set-cookie', name, value[, path]) ``` - `name` - required, cookie name to be set @@ -1603,12 +1609,17 @@ example.org#%#//scriptlet('set-cookie', name, value) - `yes` / `Yes` / `Y` - `no` - `ok` / `OK` +- `path` - optional, cookie path, defaults to `/`; possible values: + - `/` — root path + - `none` — to set no path at all **Examples** ``` -example.org#%#//scriptlet('set-cookie', 'ReadlyCookieConsent', '1') +example.org#%#//scriptlet('set-cookie', 'CookieConsent', '1') example.org#%#//scriptlet('set-cookie', 'gdpr-settings-cookie', 'true') + +example.org#%#//scriptlet('set-cookie', 'cookie_consent', 'ok', 'none') ``` [Scriptlet source](../src/scriptlets/set-cookie.js) From 788f73afffd2c52d032864df723925e32988ec80 Mon Sep 17 00:00:00 2001 From: Stanislav Atroschenko Date: Thu, 3 Nov 2022 17:39:58 +0300 Subject: [PATCH 10/15] add trusted-replace-fetch-response scriptlet AG-17043 Merge in ADGUARD-FILTERS/scriptlets from feature/AG-17043 to release/v1.7 Squashed commit of the following: commit d9ab3c1498675fd52def1dbf9dc327c610db20a2 Author: Stanislav A Date: Tue Nov 1 20:17:18 2022 +0300 fix logging in all request scriptlets commit 8d7d5a3d6a662cd70b9e175817b1ee7ff8e08418 Author: Stanislav A Date: Tue Nov 1 19:56:34 2022 +0300 improve log/matching forks for prevent-fetch, replace-fetch, replace-xhr commit d2f0562db5471f46a788eeaf03f67c627741be94 Author: Stanislav A Date: Tue Nov 1 17:01:05 2022 +0300 fix log condition commit 545d6b3a25402327f8542814a00eb505544dc58b Author: Stanislav A Date: Tue Nov 1 15:10:31 2022 +0300 move 'match all' logic to matchRequestProps logic commit 88e0d032e07c54cc73fbe20a68f9f93ec89de270 Author: Stanislav A Date: Tue Nov 1 13:42:16 2022 +0300 improve forgeResponse and rename replacingRequest > forgedRequest for xhr scriptlet commit 7de569dc9df54c682b41563cff19b3979adfe3d9 Author: Stanislav A Date: Tue Nov 1 13:13:39 2022 +0300 remove unused helper commit 65ee8258e9ddcbbe22ea68584fa89e9b044b63a2 Author: Stanislav A Date: Tue Nov 1 13:12:20 2022 +0300 fix description commit 1227b94955448ad4ad077b8bf02bf8788ef34eb7 Author: Stanislav A Date: Tue Nov 1 13:06:36 2022 +0300 fix toRegExp => toRegExp() commit e50b102566488c61d8de6751e5d3c2e7ae34a163 Author: Stanislav A Date: Mon Oct 31 18:44:08 2022 +0300 use matchRequestProps helper in prevent-xhr & prevent-fetch commit 53b1904bd15bbcaed4238063f9230d3977299787 Author: Stanislav A Date: Mon Oct 31 18:39:52 2022 +0300 move helpers dependencies lower on the list commit 9e76de8552c0bce78c7d367922d97f845e54e0a2 Author: Stanislav A Date: Mon Oct 31 18:27:15 2022 +0300 add testcases commit dc8ab9df0cea4d74be1ec5e6895654c4ba09aa54 Author: Stanislav A Date: Mon Oct 31 17:53:50 2022 +0300 add testcases commit 522ade9f544429c7ccdb1f6036366344152de73e Author: Stanislav A Date: Mon Oct 31 17:20:00 2022 +0300 add replacement logic commit edafb05a549757307148b79f91573977e1f57034 Merge: e1ac8b5 0bc5d92 Author: Stanislav A Date: Mon Oct 31 14:09:47 2022 +0300 Merge branch 'release/v1.7' into feature/AG-17043 commit e1ac8b57073309f297dca6caa2736f68bcec5ea5 Author: Stanislav A Date: Tue Oct 25 14:10:02 2022 +0300 merge parent branch & use matchRequestProps commit b2cf83c8dc011e700c61042d43f73a733671d140 Merge: 039c5b0 c01dd65 Author: Stanislav A Date: Tue Oct 25 13:34:46 2022 +0300 Merge branch 'release/v1.7' into feature/AG-17043 commit 039c5b08e1846fca1b8794bc42e404b0babab05c Author: Stanislav A Date: Tue Oct 25 13:34:01 2022 +0300 add tests stub & fix logging logic commit acef717d0bdb82175c4d746ad28d2ba8b86fd114 Author: Stanislav A Date: Mon Oct 24 19:48:53 2022 +0300 add replacement logic & improve description commit f96716dfa453df480ad172a0a835590fcf302c38 Author: Stanislav A Date: Mon Oct 24 13:03:53 2022 +0300 add trsuted-replace-fetch-response scriptlet --- src/helpers/match-request-props.js | 4 + src/scriptlets/prevent-fetch.js | 45 ++-- src/scriptlets/prevent-xhr.js | 43 ++-- src/scriptlets/scriptlets-list.js | 1 + .../trusted-replace-fetch-response.js | 211 +++++++++++++++++ .../trusted-replace-xhr-response.js | 40 ++-- tests/scriptlets/index.test.js | 1 + .../trusted-replace-fetch-response.test.js | 219 ++++++++++++++++++ 8 files changed, 492 insertions(+), 72 deletions(-) create mode 100644 src/scriptlets/trusted-replace-fetch-response.js create mode 100644 tests/scriptlets/trusted-replace-fetch-response.test.js diff --git a/src/helpers/match-request-props.js b/src/helpers/match-request-props.js index d8de05df0..1d2925506 100644 --- a/src/helpers/match-request-props.js +++ b/src/helpers/match-request-props.js @@ -13,6 +13,10 @@ import { * @returns {boolean} */ export const matchRequestProps = (propsToMatch, requestData) => { + if (propsToMatch === '' || propsToMatch === '*') { + return true; + } + let isMatched; const parsedData = parseMatchProps(propsToMatch); diff --git a/src/scriptlets/prevent-fetch.js b/src/scriptlets/prevent-fetch.js index ae8d9d7ab..6d2982a42 100644 --- a/src/scriptlets/prevent-fetch.js +++ b/src/scriptlets/prevent-fetch.js @@ -2,11 +2,9 @@ import { hit, getFetchData, objectToString, - parseMatchProps, - validateParsedData, - getMatchPropsData, noopPromiseResolve, getWildcardSymbol, + matchRequestProps, // following helpers should be imported and injected // because they are used by helpers above toRegExp, @@ -16,6 +14,9 @@ import { getRequestData, getObjectEntries, getObjectFromEntries, + parseMatchProps, + validateParsedData, + getMatchPropsData, } from '../helpers/index'; /* eslint-disable max-len */ @@ -96,6 +97,9 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re return; } + // eslint-disable-next-line no-console + const log = console.log.bind(console); + let strResponseBody; if (responseBody === 'emptyObj') { strResponseBody = '{}'; @@ -108,7 +112,7 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re // Skip disallowed response types if (!(responseType === 'default' || responseType === 'opaque')) { // eslint-disable-next-line no-console - console.log(`Invalid parameter: ${responseType}`); + log(`Invalid parameter: ${responseType}`); return; } @@ -117,29 +121,13 @@ export function preventFetch(source, propsToMatch, responseBody = 'emptyObj', re const fetchData = getFetchData(args); if (typeof propsToMatch === 'undefined') { // log if no propsToMatch given - const logMessage = `log: fetch( ${objectToString(fetchData)} )`; - hit(source, logMessage); - } else if (propsToMatch === '' || propsToMatch === getWildcardSymbol()) { - // prevent all fetch calls - shouldPrevent = true; - } else { - const parsedData = parseMatchProps(propsToMatch); - if (!validateParsedData(parsedData)) { - // eslint-disable-next-line no-console - console.log(`Invalid parameter: ${propsToMatch}`); - shouldPrevent = false; - } else { - const matchData = getMatchPropsData(parsedData); - // prevent only if all props match - shouldPrevent = Object.keys(matchData) - .every((matchKey) => { - const matchValue = matchData[matchKey]; - return Object.prototype.hasOwnProperty.call(fetchData, matchKey) - && matchValue.test(fetchData[matchKey]); - }); - } + log(`fetch( ${objectToString(fetchData)} )`); + hit(source); + return Reflect.apply(target, thisArg, args); } + shouldPrevent = matchRequestProps(propsToMatch); + if (shouldPrevent) { hit(source); return noopPromiseResolve(strResponseBody, fetchData.url, responseType); @@ -167,11 +155,9 @@ preventFetch.injections = [ hit, getFetchData, objectToString, - parseMatchProps, - validateParsedData, - getMatchPropsData, noopPromiseResolve, getWildcardSymbol, + matchRequestProps, toRegExp, isValidStrPattern, escapeRegExp, @@ -179,4 +165,7 @@ preventFetch.injections = [ getRequestData, getObjectEntries, getObjectFromEntries, + parseMatchProps, + validateParsedData, + getMatchPropsData, ]; diff --git a/src/scriptlets/prevent-xhr.js b/src/scriptlets/prevent-xhr.js index f6ec99d49..71c9fb302 100644 --- a/src/scriptlets/prevent-xhr.js +++ b/src/scriptlets/prevent-xhr.js @@ -2,12 +2,10 @@ import { hit, objectToString, getWildcardSymbol, - parseMatchProps, - validateParsedData, - getMatchPropsData, getRandomIntInclusive, getRandomStrByLength, generateRandomResponse, + matchRequestProps, // following helpers should be imported and injected // because they are used by helpers above toRegExp, @@ -18,6 +16,9 @@ import { getNumberFromString, nativeIsFinite, nativeIsNaN, + parseMatchProps, + validateParsedData, + getMatchPropsData, } from '../helpers/index'; /* eslint-disable max-len */ @@ -94,6 +95,9 @@ export function preventXHR(source, propsToMatch, customResponseText) { return; } + // eslint-disable-next-line no-console + const log = console.log.bind(console); + let shouldPrevent = false; let response = ''; let responseText = ''; @@ -107,27 +111,10 @@ export function preventXHR(source, propsToMatch, customResponseText) { responseUrl = xhrData.url; if (typeof propsToMatch === 'undefined') { // Log if no propsToMatch given - const logMessage = `log: xhr( ${objectToString(xhrData)} )`; - hit(source, logMessage); - } else if (propsToMatch === '' || propsToMatch === getWildcardSymbol()) { - // Prevent all fetch calls - shouldPrevent = true; + log(`log: xhr( ${objectToString(xhrData)} )`); + hit(source); } else { - const parsedData = parseMatchProps(propsToMatch); - if (!validateParsedData(parsedData)) { - // eslint-disable-next-line no-console - console.log(`Invalid parameter: ${propsToMatch}`); - shouldPrevent = false; - } else { - const matchData = getMatchPropsData(parsedData); - // prevent only if all props match - shouldPrevent = Object.keys(matchData) - .every((matchKey) => { - const matchValue = matchData[matchKey]; - return Object.prototype.hasOwnProperty.call(xhrData, matchKey) - && matchValue.test(xhrData[matchKey]); - }); - } + shouldPrevent = matchRequestProps(propsToMatch); } return Reflect.apply(target, thisArg, args); @@ -151,8 +138,7 @@ export function preventXHR(source, propsToMatch, customResponseText) { if (randomText) { responseText = randomText; } else { - // eslint-disable-next-line no-console - console.log(`Invalid range: ${customResponseText}`); + log(`Invalid range: ${customResponseText}`); } } // Mock response object @@ -205,9 +191,7 @@ preventXHR.injections = [ hit, objectToString, getWildcardSymbol, - parseMatchProps, - validateParsedData, - getMatchPropsData, + matchRequestProps, getRandomIntInclusive, getRandomStrByLength, generateRandomResponse, @@ -219,4 +203,7 @@ preventXHR.injections = [ getNumberFromString, nativeIsFinite, nativeIsNaN, + parseMatchProps, + validateParsedData, + getMatchPropsData, ]; diff --git a/src/scriptlets/scriptlets-list.js b/src/scriptlets/scriptlets-list.js index c3b4f3ba3..f00ab89f7 100644 --- a/src/scriptlets/scriptlets-list.js +++ b/src/scriptlets/scriptlets-list.js @@ -50,3 +50,4 @@ export * from './prevent-element-src-loading'; export * from './no-topics'; export * from './trusted-replace-xhr-response'; export * from './xml-prune'; +export * from './trusted-replace-fetch-response'; diff --git a/src/scriptlets/trusted-replace-fetch-response.js b/src/scriptlets/trusted-replace-fetch-response.js new file mode 100644 index 000000000..a3472c501 --- /dev/null +++ b/src/scriptlets/trusted-replace-fetch-response.js @@ -0,0 +1,211 @@ +import { + hit, + getFetchData, + objectToString, + getWildcardSymbol, + matchRequestProps, + // following helpers should be imported and injected + // because they are used by helpers above + toRegExp, + isValidStrPattern, + escapeRegExp, + isEmptyObject, + getRequestData, + getObjectEntries, + getObjectFromEntries, + parseMatchProps, + validateParsedData, + getMatchPropsData, +} from '../helpers/index'; + +/* eslint-disable max-len */ +/** + * @scriptlet trusted-replace-fetch-response + * + * @description + * Replaces response text content of `fetch` requests if **all** given parameters match. + * + * **Syntax** + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response'[, pattern, replacement[, propsToMatch]]) + * ``` + * + * - pattern - optional, argument for matching contents of responseText that should be replaced. If set, `replacement` is required; + * possible values: + * - '*' to match all text content + * - non-empty string + * - regular expression + * - replacement — optional, should be set if `pattern` is set. String to replace the response text content matched by `pattern`. + * Empty string to remove content. Defaults to empty string. + * - propsToMatch - optional, string of space-separated properties to match; possible props: + * - string or regular expression for matching the URL passed to fetch call; empty string, wildcard `*` or invalid regular expression will match all fetch calls + * - colon-separated pairs `name:value` where + * - `name` is [`init` option name](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters) + * - `value` is string or regular expression for matching the value of the option passed to fetch call; invalid regular expression will cause any value matching + * + * > Usage with no arguments will log fetch calls to browser console; + * which is useful for debugging but only allowed for production filter lists. + * + * > Scriptlet does nothing if response body can't be converted to text. + * + * **Examples** + * 1. Log all fetch calls + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response') + * ``` + * + * 2. Replace response text content of fetch requests with specific url + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response', 'adb_detect:true', 'adb_detect:false', 'example.org') + * example.org#%#//scriptlet('trusted-replace-fetch-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', 'example.org') + * ``` + * + * 3. Remove all text content of fetch responses with specific request method + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response', '*', '', 'method:GET') + * ``` + * + * 4. Replace response text content of fetch requests matching by URL regex and request methods + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response', '/#EXT-X-VMAP-AD-BREAK[\s\S]*?/', '#EXT-X-ENDLIST', '/\.m3u8/ method:/GET|HEAD/') + * ``` + * 5. Remove text content of all fetch responses for example.com + * ``` + * example.org#%#//scriptlet('trusted-replace-fetch-response', '*', '', 'example.com') + * ``` + */ +/* eslint-enable max-len */ +export function trustedReplaceFetchResponse(source, pattern = '', replacement = '', propsToMatch = '') { + // do nothing if browser does not support fetch or Proxy (e.g. Internet Explorer) + // https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy + if (typeof fetch === 'undefined' + || typeof Proxy === 'undefined' + || typeof Response === 'undefined') { + return; + } + + // eslint-disable-next-line no-console + const log = console.log.bind(console); + + // Only allow pattern as empty string for logging purposes + if (pattern === '' && replacement !== '') { + const logMessage = 'log: Pattern argument should not be empty string.'; + log(source, logMessage); + return; + } + const shouldLog = pattern === '' && replacement === ''; + + const nativeFetch = fetch; + + let shouldReplace = false; + let fetchData; + + const handlerWrapper = async (target, thisArg, args) => { + fetchData = getFetchData(args); + + if (shouldLog) { + // log if no propsToMatch given + log(`fetch( ${objectToString(fetchData)} )`); + hit(source); + return Reflect.apply(target, thisArg, args); + } + + shouldReplace = matchRequestProps(propsToMatch, fetchData); + + if (!shouldReplace) { + return Reflect.apply(target, thisArg, args); + } + + /** + * Create new Response object using original response' properties + * and given text as body content + * @param {Response} response original response to copy properties from + * @param {string} textContent text to set as body content + * @returns {Response} + */ + const forgeResponse = (response, textContent) => { + const { + bodyUsed, + headers, + ok, + redirected, + status, + statusText, + type, + url, + } = response; + + // eslint-disable-next-line compat/compat + const forgedResponse = new Response(textContent, { + status, + statusText, + headers, + }); + + // Manually set properties which can't be set by Response constructor + Object.defineProperties(forgedResponse, { + url: { value: url }, + type: { value: type }, + ok: { value: ok }, + bodyUsed: { value: bodyUsed }, + redirected: { value: redirected }, + }); + + return forgedResponse; + }; + + return nativeFetch(...args) + .then((response) => { + return response.text() + .then((bodyText) => { + const patternRegexp = pattern === getWildcardSymbol() + ? toRegExp() + : toRegExp(pattern); + + const modifiedTextContent = bodyText.replace(patternRegexp, replacement); + const forgedResponse = forgeResponse(response, modifiedTextContent); + + hit(source); + return forgedResponse; + }) + .catch(() => { + // log if response body can't be converted to a string + const fetchDataStr = objectToString(fetchData); + const logMessage = `log: Response body can't be converted to text: ${fetchDataStr}`; + log(source, logMessage); + return Reflect.apply(target, thisArg, args); + }); + }) + .catch(() => Reflect.apply(target, thisArg, args)); + }; + + const fetchHandler = { + apply: handlerWrapper, + }; + + fetch = new Proxy(fetch, fetchHandler); // eslint-disable-line no-global-assign +} + +trustedReplaceFetchResponse.names = [ + 'trusted-replace-fetch-response', + +]; + +trustedReplaceFetchResponse.injections = [ + hit, + getFetchData, + objectToString, + getWildcardSymbol, + matchRequestProps, + toRegExp, + isValidStrPattern, + escapeRegExp, + isEmptyObject, + getRequestData, + getObjectEntries, + getObjectFromEntries, + parseMatchProps, + validateParsedData, + getMatchPropsData, +]; diff --git a/src/scriptlets/trusted-replace-xhr-response.js b/src/scriptlets/trusted-replace-xhr-response.js index 8f2d83062..99c3a6953 100644 --- a/src/scriptlets/trusted-replace-xhr-response.js +++ b/src/scriptlets/trusted-replace-xhr-response.js @@ -31,7 +31,7 @@ import { * - pattern - optional, argument for matching contents of responseText that should be replaced. If set, `replacement` is required; * possible values: * - '*' to match all text content - * - string + * - non-empty string * - regular expression * - replacement — optional, should be set if `pattern` is set. String to replace matched content with. Empty string to remove content. * - propsToMatch — optional, string of space-separated properties to match for extra condition; possible props: @@ -77,12 +77,18 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' return; } - if (typeof pattern === 'undefined' || typeof replacement === 'undefined') { + // eslint-disable-next-line no-console + const log = console.log.bind(console); + + // Only allow pattern as empty string for logging purposes + if (pattern === '' && replacement !== '') { + const logMessage = 'log: Pattern argument should not be empty string.'; + log(source, logMessage); return; } - // eslint-disable-next-line no-console - const log = console.log.bind(console); + const shouldLog = pattern === '' && replacement === ''; + const nativeOpen = window.XMLHttpRequest.prototype.open; const nativeSend = window.XMLHttpRequest.prototype.send; @@ -93,14 +99,16 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' const openWrapper = (target, thisArg, args) => { xhrData = getXhrData(...args); - if (pattern === '' && replacement === '') { + if (shouldLog) { // Log if no propsToMatch given const logMessage = `log: xhr( ${objectToString(xhrData)} )`; - log(source, logMessage); - } else { - shouldReplace = matchRequestProps(propsToMatch, xhrData); + log(logMessage); + hit(source); + return Reflect.apply(target, thisArg, args); } + shouldReplace = matchRequestProps(propsToMatch, xhrData); + // Trap setRequestHeader of target xhr object to mimic request headers later if (shouldReplace) { const setRequestHeaderWrapper = (target, thisArg, args) => { @@ -131,9 +139,9 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' * to be able to collect response data without triggering * listeners on original XHR object */ - const replacingRequest = new XMLHttpRequest(); - replacingRequest.addEventListener('readystatechange', () => { - if (replacingRequest.readyState !== 4) { + const forgedRequest = new XMLHttpRequest(); + forgedRequest.addEventListener('readystatechange', () => { + if (forgedRequest.readyState !== 4) { return; } @@ -145,7 +153,7 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' responseXML, status, statusText, - } = replacingRequest; + } = forgedRequest; // Extract content from response const content = responseText || response; @@ -154,7 +162,7 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' } const patternRegexp = pattern === getWildcardSymbol() - ? toRegExp + ? toRegExp() : toRegExp(pattern); const modifiedContent = content.replace(patternRegexp, replacement); @@ -186,7 +194,7 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' hit(source); }); - nativeOpen.apply(replacingRequest, [xhrData.method, xhrData.url]); + nativeOpen.apply(forgedRequest, [xhrData.method, xhrData.url]); // Mimic request headers before sending // setRequestHeader can only be called on open request objects @@ -194,12 +202,12 @@ export function trustedReplaceXhrResponse(source, pattern = '', replacement = '' const name = header[0]; const value = header[1]; - replacingRequest.setRequestHeader(name, value); + forgedRequest.setRequestHeader(name, value); }); requestHeaders = []; try { - nativeSend.call(replacingRequest, args); + nativeSend.call(forgedRequest, args); } catch { return Reflect.apply(target, thisArg, args); } diff --git a/tests/scriptlets/index.test.js b/tests/scriptlets/index.test.js index 5b75d3ebe..865db978d 100644 --- a/tests/scriptlets/index.test.js +++ b/tests/scriptlets/index.test.js @@ -47,3 +47,4 @@ import './no-topics.test'; import './trusted-replace-xhr-response.test'; import './xml-prune.test'; import './trusted-click-element.test'; +import './trusted-replace-fetch-response.test'; diff --git a/tests/scriptlets/trusted-replace-fetch-response.test.js b/tests/scriptlets/trusted-replace-fetch-response.test.js new file mode 100644 index 000000000..6133c8f6d --- /dev/null +++ b/tests/scriptlets/trusted-replace-fetch-response.test.js @@ -0,0 +1,219 @@ +/* eslint-disable no-underscore-dangle, no-console */ +import { runScriptlet, clearGlobalProps } from '../helpers'; +import { startsWith } from '../../src/helpers/string-utils'; + +const { test, module } = QUnit; +const name = 'trusted-replace-fetch-response'; + +const FETCH_OBJECTS_PATH = './test-files'; +const nativeFetch = fetch; +const nativeConsole = console.log; +const nativeResponseJson = Response.prototype.json; + +const beforeEach = () => { + window.__debug = () => { + window.hit = 'FIRED'; + }; +}; + +const afterEach = () => { + clearGlobalProps('hit', '__debug'); + fetch = nativeFetch; // eslint-disable-line no-global-assign + console.log = nativeConsole; + Response.prototype.json = nativeResponseJson; +}; + +module(name, { beforeEach, afterEach }); + +const isSupported = typeof fetch !== 'undefined' && typeof Proxy !== 'undefined' && typeof Response !== 'undefined'; + +if (!isSupported) { + test('unsupported', (assert) => { + assert.ok(true, 'Browser does not support it'); + }); +} else { + test('No arguments, no replacement, logging', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + const expectedJson = { + a1: 1, + b2: 'test', + c3: 3, + }; + const done = assert.async(); + + // mock console.log function for log checking + console.log = function log(input) { + if (input.indexOf('trace') > -1) { + return; + } + // eslint-disable-next-line max-len + const EXPECTED_LOG_STR_START = `fetch( url:"${INPUT_JSON_PATH}" method:"${TEST_METHOD}"`; + assert.ok(startsWith(input, EXPECTED_LOG_STR_START), 'console.hit input'); + }; + + // no args -> just logging, no replacements + runScriptlet(name); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.deepEqual(actualJson, expectedJson); + done(); + }); + + test('Match all requests, replace by substring', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const done = assert.async(); + + const PATTERN = 'test'; + const REPLACEMENT = 'new content'; + runScriptlet(name, [PATTERN, REPLACEMENT]); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + const textContent = JSON.stringify(actualJson); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.notOk(textContent.includes(PATTERN), 'Pattern is removed'); + assert.ok(textContent.includes(REPLACEMENT), 'New content is set'); + done(); + }); + + test('Match all requests, replace by regex', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const done = assert.async(); + + const PATTERN = /test/; + const REPLACEMENT = 'new content'; + runScriptlet(name, [PATTERN, REPLACEMENT]); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + const textContent = JSON.stringify(actualJson); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.notOk(PATTERN.test(textContent), 'Pattern is removed'); + assert.ok(textContent.includes(REPLACEMENT), 'New content is set'); + done(); + }); + + test('Match request by url and method, remove all text content', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const done = assert.async(); + + const PATTERN = ''; + const REPLACEMENT = ''; + const PROPS_TO_MATCH = 'test01 method:GET'; + runScriptlet(name, [PATTERN, REPLACEMENT, PROPS_TO_MATCH]); + + const response = await fetch(INPUT_JSON_PATH, init); + const actualTextContent = await response.text(); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.strictEqual(actualTextContent, '', 'Content is removed'); + done(); + }); + + test('Unmatched request\'s content is not modified', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const expectedJson = { + a1: 1, + b2: 'test', + c3: 3, + }; + + const PATTERN = ''; + const REPLACEMENT = ''; + const PROPS_TO_MATCH = 'test99 method:POST'; + runScriptlet(name, [PATTERN, REPLACEMENT, PROPS_TO_MATCH]); + + const done = assert.async(); + const response = await fetch(INPUT_JSON_PATH, init); + const actualJson = await response.json(); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + assert.deepEqual(actualJson, expectedJson, 'Content is intact'); + done(); + }); + + test('Forged response props are copied properly', async (assert) => { + const INPUT_JSON_PATH = `${FETCH_OBJECTS_PATH}/test01.json`; + const TEST_METHOD = 'GET'; + const init = { + method: TEST_METHOD, + }; + + const done = assert.async(); + + const PATTERN = ''; + const REPLACEMENT = ''; + const PROPS_TO_MATCH = 'test01 method:GET'; + + const expectedResponse = await fetch(INPUT_JSON_PATH, init); + + runScriptlet(name, [PATTERN, REPLACEMENT, PROPS_TO_MATCH]); + + const actualResponse = await fetch(INPUT_JSON_PATH, init); + + assert.strictEqual(window.hit, 'FIRED', 'hit function fired'); + + const { + bodyUsedExpected, + headersExpected, + okExpected, + redirectedExpected, + statusExpected, + statusTextExpected, + typeExpected, + urlExpected, + } = expectedResponse; + + const { + bodyUsed, + headers, + ok, + redirected, + status, + statusText, + type, + url, + } = actualResponse; + + assert.strictEqual(bodyUsed, bodyUsedExpected, 'response prop is copied'); + assert.strictEqual(headers, headersExpected, 'response prop is copied'); + assert.strictEqual(ok, okExpected, 'response prop is copied'); + assert.strictEqual(redirected, redirectedExpected, 'response prop is copied'); + assert.strictEqual(status, statusExpected, 'response prop is copied'); + assert.strictEqual(statusText, statusTextExpected, 'response prop is copied'); + assert.strictEqual(type, typeExpected, 'response prop is copied'); + assert.strictEqual(url, urlExpected, 'response prop is copied'); + done(); + }); +} From ee8393008ad4212dc68c365288790ab54e17ac9f Mon Sep 17 00:00:00 2001 From: Stanislav Atroschenko Date: Tue, 8 Nov 2022 11:37:18 +0300 Subject: [PATCH 11/15] AG-14398 Fix wiki build scripts Squashed commit of the following: commit 84393affe182c0c9cc4c3d9b7834097fddde1375 Author: Slava Leleka Date: Mon Nov 7 13:35:02 2022 +0200 delete obsolete comment commit 16bf36778f6a8234aab33252e9376ac72086ddb8 Author: Slava Leleka Date: Mon Nov 7 13:34:08 2022 +0200 fix getFilesList description commit d0d6c71490f78658a2d0313eb39bfa602d411951 Author: Slava Leleka Date: Mon Nov 7 13:33:16 2022 +0200 rename writeFile helper back commit 7790df71365857a5a62898b72fb860d240d84905 Author: Slava Leleka Date: Thu Nov 3 21:13:07 2022 +0200 fix specs -- update wiki about pages while build commit 74523b44bd592f19d58a070f0a93431d8656706e Author: Slava Leleka Date: Thu Nov 3 21:11:56 2022 +0200 update readme commit c604e1653a3e48dc2d19bbefebe367f3623b87fe Author: Slava Leleka Date: Thu Nov 3 21:11:41 2022 +0200 fix package.json scripts for wiki commit 11d2e3ca3e9f04da8dc96d2cda5be7904295bc64 Author: Slava Leleka Date: Thu Nov 3 21:10:23 2022 +0200 do not throw error on some changes while checking compatibility table updated commit 162089f1f7716984a64148d36305949250113e96 Merge: 4a4d308 788f73a Author: Slava Leleka Date: Thu Nov 3 16:49:54 2022 +0200 Merge branch 'release/v1.7' into fix/AG-14398 commit 4a4d30894e63326ce8cd9dc56c17eab2f0279fd6 Author: Slava Leleka Date: Thu Nov 3 00:03:26 2022 +0200 update package.json scripts for wiki commit a23f2b0339c5ebc9012da72048f1e1fe6a5bb8fd Author: Slava Leleka Date: Thu Nov 3 00:02:31 2022 +0200 do not build compatibility table while build-docs commit a43af1bf42df65c7318625a17ac5e7b89beb8944 Author: Slava Leleka Date: Thu Nov 3 00:01:54 2022 +0200 fix constants commit 24e51b692d498beaeadab861042624d7eba98ab2 Author: Slava Leleka Date: Thu Nov 3 00:01:09 2022 +0200 fix getCurrentUBORedirects() commit 4c18c35493804fe4ccc94043da42db18d526fb27 Author: Slava Leleka Date: Wed Nov 2 23:25:32 2022 +0200 fix typo commit 456f7c08fcd8e3bead582ae7ed73202ee53a5e65 Author: Slava Leleka Date: Wed Nov 2 20:58:35 2022 +0200 change fixme to todo commit ec4e5e34d99919266da5b6f9c557afd96af445fc Author: Slava Leleka Date: Wed Nov 2 20:57:37 2022 +0200 add comment for writeFileAsync commit 7d1ca123a44896f864b0deebe8c294f5e2b76720 Author: Slava Leleka Date: Wed Nov 2 20:55:10 2022 +0200 refactor scripts methods, fix types commit f0e919f9503918f2e905743929d458b9be523090 Author: Slava Leleka Date: Wed Nov 2 18:56:32 2022 +0200 refactor build scripts helpers and constants commit d2b9c93c2781d2a9f9cb936c711e90531a86d0a4 Author: Stanislav A Date: Wed Nov 2 13:43:04 2022 +0300 Build wiki/compatibility-table.md while build --- README.md | 7 +- bamboo-specs/build.yaml | 2 + package.json | 3 +- scripts/build-compatibility-table.js | 109 ++++++++---- scripts/build-docs.js | 256 ++++++++++++--------------- scripts/build-redirects.js | 26 +-- scripts/check-sources-updates.js | 10 +- scripts/constants.js | 48 +++-- scripts/helpers.js | 122 ++++++++++++- 9 files changed, 365 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index fa80345a7..efd5b1f4b 100644 --- a/README.md +++ b/README.md @@ -484,10 +484,9 @@ and open needed HTML file from `tests/dist` in your browser with devtools ## How to update wiki -``` -yarn wiki:update -``` -`about-scriptlets.md` and `about-redirects.md` are being built from JSDoc notation of corresponding scriptlets and redirects source files with `@scriptlet/@redirect` and `@description` tags. +There are two scripts to update wiki: +1. `yarn wiki:build-table` — checks compatibility data updates and updates the compatibility table. Should be run manually while the release preparation. +2. `yarn wiki:build-docs` — updates wiki pages `about-scriptlets.md` and `about-redirects.md`. They are being generated from JSDoc-type comments of corresponding scriptlets and redirects source files due to `@scriptlet`/`@redirect` and `@description` tags. Runs automatically while the release build. ## Browser Compatibility | Chrome | Edge | Firefox | IE | Opera | Safari | diff --git a/bamboo-specs/build.yaml b/bamboo-specs/build.yaml index 9af2e2f4b..4bd6c8f32 100644 --- a/bamboo-specs/build.yaml +++ b/bamboo-specs/build.yaml @@ -35,6 +35,8 @@ Build: yarn install ${bamboo.varsYarn} yarn build + yarn wiki:build-docs + rm -rf node_modules - inject-variables: file: dist/build.txt diff --git a/package.json b/package.json index 81f2c59a0..39e12ca33 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,8 @@ "gui-test": "babel-node scripts/build-tests.js && open http://localhost:8585 && node ./tests/server.js", "lint": "eslint .", "lint-staged": "lint-staged", + "wiki:build-table": "node ./scripts/check-sources-updates.js && node ./scripts/build-compatibility-table.js", "wiki:build-docs": "node scripts/build-docs.js", - "wiki:check-updates": "node ./scripts/check-sources-updates.js", - "wiki:update": "yarn wiki:check-updates && node ./scripts/build-compatibility-table.js", "prepublishOnly": "yarn build", "increment": "yarn version --patch --no-git-tag-version" }, diff --git a/scripts/build-compatibility-table.js b/scripts/build-compatibility-table.js index 4165352a3..f1ab4179c 100644 --- a/scripts/build-compatibility-table.js +++ b/scripts/build-compatibility-table.js @@ -1,80 +1,115 @@ +const path = require('path'); const fs = require('fs'); -const os = require('os'); +const { EOL } = require('os'); + const { REMOVED_MARKER, + WIKI_DIR_PATH, COMPATIBILITY_TABLE_DATA_PATH, - WIKI_COMPATIBILITY_TABLE_PATH, } = require('./constants'); +const COMPATIBILITY_TABLE_OUTPUT_FILENAME = 'compatibility-table.md'; + +/** + * Path to **output** wiki compatibility table file + */ +const WIKI_COMPATIBILITY_TABLE_PATH = path.resolve( + __dirname, + WIKI_DIR_PATH, + COMPATIBILITY_TABLE_OUTPUT_FILENAME, +); + +/** + * @typedef {Object} CompatibilityItem + * @property {string} adg + * @property {string} abp + * @property {string} ubo + */ + /** - * Returns data for compatibility tables + * @typedef {Object} CompatibilityData + * @property {CompatibilityItem[]} scriptlets + * @property {CompatibilityItem[]} redirects */ -function getTableData() { + +/** + * Returns data for compatibility table + * + * @returns {CompatibilityData} input compatibility data from json + */ +const getTableData = () => { const rawData = fs.readFileSync(COMPATIBILITY_TABLE_DATA_PATH); - const parsed = JSON.parse(rawData); - return parsed; -} + return JSON.parse(rawData); +}; /** * Returns markdown row of compatibility table - * @param {{ - * adg: string, - * ubo: string, - * abp: string - * }} item { an } + * + * @param {'scriptlets'|'redirects'} id + * @param {CompatibilityItem} item params object + * @param {string} item.adg AdGuard name + * @param {string} item.abp Adblock Plus name + * @param {string} item.ubo uBlock name + * + * @returns {string} markdown table row */ -const getRow = (id, item) => { +const getRow = (id, { adg, abp, ubo }) => { let adgCell = ''; - if (item.adg) { - adgCell = item.adg.includes(REMOVED_MARKER) - ? item.adg - : `[${item.adg}](../wiki/about-${id}.md#${item.adg})`; + if (adg) { + adgCell = adg.includes(REMOVED_MARKER) + ? adg + : `[${adg}](${WIKI_DIR_PATH}/about-${id}.md#${adg})`; } - return `| ${adgCell} | ${item.ubo || ''} | ${item.abp || ''} |${os.EOL}`; + return `| ${adgCell} | ${ubo || ''} | ${abp || ''} |${EOL}`; }; /** * Generates table header + * + * @returns {string} */ const getTableHeader = () => { - let res = `| AdGuard | uBO | Adblock Plus |${os.EOL}`; - res += `|---|---|---|${os.EOL}`; + let res = `| AdGuard | uBO | Adblock Plus |${EOL}`; + res += `|---|---|---|${EOL}`; return res; }; /** - * Builds markdown string with scriptlets compatibility table - * @param {Array} data array with scriptlets names + * Builds markdown string of scriptlets/redirect compatibility table + * @param {string} title title for scriptlets or redirects + * @param {CompatibilityItem[]} data array of scriptlets or redirects compatibility data items + * @param {'scriptlets'|'redirects'} id + * + * @returns {string} scriptlets or redirects compatibility table */ -function buildTable(title, data = [], id = '') { +const buildTable = (title, data = [], id = '') => { // title - let res = `# ${title}${os.EOL}${os.EOL}`; + let res = `# ${title}${EOL}${EOL}`; // header res += getTableHeader(); // rows res += data - .map((item) => { - const row = getRow(id, item); - return row; - }) + .map((item) => getRow(id, item)) .join(''); return res; -} +}; /** - * Save tables to compatibility table + * Saves tables to compatibility table + * + * @param {string[]} args */ -function saveTables(...args) { - const res = args.join(`${os.EOL}${os.EOL}`); +const saveTables = (...args) => { + const res = args.join(`${EOL}${EOL}`); fs.writeFileSync(WIKI_COMPATIBILITY_TABLE_PATH, res); -} +}; /** - * Entry function + * Builds full compatibility table */ -function init() { +const buildCompatibilityTable = () => { const { scriptlets, redirects } = getTableData(); const scriptletsTable = buildTable( @@ -89,6 +124,6 @@ function init() { ); saveTables(scriptletsTable, redirectsTable); -} +}; -init(); +buildCompatibilityTable(); diff --git a/scripts/build-docs.js b/scripts/build-docs.js index 922037326..ab67e62a5 100644 --- a/scripts/build-docs.js +++ b/scripts/build-docs.js @@ -1,138 +1,92 @@ -const dox = require('dox'); const fs = require('fs'); const path = require('path'); - const yaml = require('js-yaml'); +const { EOL } = require('os'); -const SCRIPTLETS_FILES_DIRECTORY = '../src/scriptlets'; -const REDIRECTS_FILES_DIRECTORY = '../src/redirects'; -const STATIC_REDIRECTS = '../src/redirects/static-redirects.yml'; -const BLOCKING_REDIRECTS = '../src/redirects/blocking-redirects.yml'; - -const ABOUT_SCRIPTLETS_PATH = path.resolve(__dirname, '../wiki/about-scriptlets.md'); -const ABOUT_REDIRECTS_PATH = path.resolve(__dirname, '../wiki/about-redirects.md'); - -// files which are not scriptlets or redirects in their directories -const NON_SCRIPTLETS_FILES = [ - 'index.js', - 'scriptlets.js', - 'scriptlets-list.js', - 'scriptlets-wrapper.js', - 'scriptlets-umd-wrapper.js', -]; -const NON_REDIRECTS_FILES = [ - 'index.js', - 'redirects.js', - 'redirects-list.js', -]; +const { getDataFromFiles } = require('./helpers'); -/** - * Gets list of files - * @param {string} dirPath path to directory - */ -const getFilesList = (dirPath) => { - const filesList = fs.readdirSync(path.resolve(__dirname, dirPath), { encoding: 'utf8' }) - .filter((el) => el.includes('.js')); - return filesList; -}; +const { + WIKI_DIR_PATH, + scriptletsFilenames, + redirectsFilenames, + SCRIPTLETS_SRC_RELATIVE_DIR_PATH, + REDIRECTS_SRC_RELATIVE_DIR_PATH, +} = require('./constants'); -const scriptletsFilesList = getFilesList(SCRIPTLETS_FILES_DIRECTORY) - .filter((el) => !NON_SCRIPTLETS_FILES.includes(el)); +const STATIC_REDIRECTS_FILENAME = 'static-redirects.yml'; +const BLOCKING_REDIRECTS_FILENAME = 'blocking-redirects.yml'; -const redirectsFilesList = getFilesList(REDIRECTS_FILES_DIRECTORY) - .filter((el) => !NON_REDIRECTS_FILES.includes(el)); +// eslint-disable-next-line max-len +const STATIC_REDIRECTS_RELATIVE_SOURCE = `${REDIRECTS_SRC_RELATIVE_DIR_PATH}/${STATIC_REDIRECTS_FILENAME}`; -/** - * Gets required comments from file. - * In one file might be comments describing scriptlet and redirect as well. - * @param {string} srcPath path to file - */ -const getComments = (srcPath) => { - const srcCode = fs.readFileSync(srcPath, { encoding: 'utf8' }); - const parsedCommentsFromFile = dox.parseComments(srcCode); - const describingComment = Object.values(parsedCommentsFromFile) - .filter((comment) => { - const [base] = comment.tags; - const isNeededComment = (base - && (base.type === 'scriptlet' || base.type === 'redirect')); - return isNeededComment; - }); - - if (describingComment.length === 0) { - throw new Error(`No description in ${srcPath}. -Please add one OR edit the list of NON_SCRIPTLETS_FILES / NON_REDIRECTS_FILES.`); - } +const staticRedirectsPath = path.resolve(__dirname, STATIC_REDIRECTS_RELATIVE_SOURCE); - return describingComment; -}; +const blockingRedirectsPath = path.resolve( + __dirname, + REDIRECTS_SRC_RELATIVE_DIR_PATH, + BLOCKING_REDIRECTS_FILENAME, +); -/** - * Convert parsed comments to objects - * @param {object} requiredComments parsed comments for one file - * @param {string} sourcePath path to file - */ -const prepareData = (requiredComments, sourcePath) => { - return requiredComments.map((el) => { - const [base, sup] = el.tags; - return { - type: base.type, - name: base.string, - description: sup.string, - source: sourcePath, - }; - }); -}; +const ABOUT_SCRIPTLETS_FILENAME = 'about-scriptlets.md'; +const ABOUT_REDIRECTS_FILENAME = 'about-redirects.md'; -/** - * Gets data objects which describe every required comment in one directory - * @param {array} filesList list of files in directory - * @param {string} directoryPath path to directory - */ -const getDataFromFiles = (filesList, directoryPath) => { - const pathToDir = path.resolve(__dirname, directoryPath); - return filesList.map((file) => { - const pathToFile = path.resolve(pathToDir, file); - const requiredComments = getComments(pathToFile); - - return prepareData(requiredComments, `${directoryPath}/${file}`); - }); -}; +const aboutScriptletsPath = path.resolve(__dirname, WIKI_DIR_PATH, ABOUT_SCRIPTLETS_FILENAME); +const aboutRedirectsPath = path.resolve(__dirname, WIKI_DIR_PATH, ABOUT_REDIRECTS_FILENAME); /** * Collects required comments from files and * returns describing object for scriptlets and redirects */ const manageDataFromFiles = () => { - // eslint-disable-next-line max-len - const dataFromScriptletsFiles = getDataFromFiles(scriptletsFilesList, SCRIPTLETS_FILES_DIRECTORY); - const dataFromRedirectsFiles = getDataFromFiles(redirectsFilesList, REDIRECTS_FILES_DIRECTORY); + const dataFromScriptletsFiles = getDataFromFiles( + scriptletsFilenames, + SCRIPTLETS_SRC_RELATIVE_DIR_PATH, + ); + const dataFromRedirectsFiles = getDataFromFiles( + redirectsFilenames, + REDIRECTS_SRC_RELATIVE_DIR_PATH, + ); const fullData = dataFromScriptletsFiles.concat(dataFromRedirectsFiles).flat(Infinity); - const scriptletsData = fullData.filter((el) => { - return el.type === 'scriptlet'; - }); - const redirectsData = fullData.filter((el) => { - return el.type === 'redirect'; - }); + const scriptletsData = fullData.filter(({ type }) => type === 'scriptlet'); + const redirectsData = fullData.filter(({ type }) => type === 'redirect'); return { scriptletsData, redirectsData }; }; /** - * Generates markdown list and describing text - * @param {object} data array of filtered objects - scriptlets or redirects + * @typedef { import('./helpers').DescribingCommentData } DescribingCommentData */ -const generateMD = (data) => { - const output = data.reduce((acc, el) => { - acc.list.push(`* [${el.name}](#${el.name})\n`); - const typeOfSrc = el.type === 'scriptlet' ? 'Scriptlet' : 'Redirect'; +/** + * @typedef {Object} MarkdownData + * @property {string} list table of content + * @property {string} body main content which + */ - const body = `### ⚡️ ${el.name} -${el.description}\n -[${typeOfSrc} source](${el.source}) -* * *\n\n`; +/** + * Generates markdown list and describing text. + * + * @param {DescribingCommentData[]} dataItems array of comment data objects + * + * @returns {MarkdownData} + */ +const getMarkdownData = (dataItems) => { + const output = dataItems.reduce((acc, { + name, + type, + description, + source, + }) => { + acc.list.push(`* [${name}](#${name})${EOL}`); + + const typeOfSrc = type === 'scriptlet' ? 'Scriptlet' : 'Redirect'; + + const body = `### ⚡️ ${name} +${description}${EOL} +[${typeOfSrc} source](${source}) +* * *${EOL}${EOL}`; acc.body.push(body); return acc; @@ -146,22 +100,24 @@ ${el.description}\n /** * Generates markdown list and describing text for static redirect resources + * + * @returns {MarkdownData} */ -const mdForStaticRedirects = () => { - const staticRedirects = fs.readFileSync(path.resolve(__dirname, STATIC_REDIRECTS), { encoding: 'utf8' }); +const getMarkdownDataForStaticRedirects = () => { + const staticRedirects = fs.readFileSync(path.resolve(__dirname, staticRedirectsPath), { encoding: 'utf8' }); const parsedStaticRedirects = yaml.safeLoad(staticRedirects); - const output = parsedStaticRedirects.reduce((acc, el) => { - if (el.description) { - acc.list.push(`* [${el.title}](#${el.title})\n`); + const output = parsedStaticRedirects.reduce((acc, { title, description }) => { + if (description) { + acc.list.push(`* [${title}](#${title})${EOL}`); - const body = `### ⚡️ ${el.title} -${el.description} -[Redirect source](${STATIC_REDIRECTS}) -* * *\n\n`; + const body = `### ⚡️ ${title} +${description} +[Redirect source](${STATIC_REDIRECTS_RELATIVE_SOURCE}) +* * *${EOL}${EOL}`; acc.body.push(body); } else { - throw new Error(`No description for ${el.title}`); + throw new Error(`No description for ${title}`); } return acc; @@ -175,24 +131,28 @@ ${el.description} /** * Generates markdown list and describing text for blocking redirect resources, i.e click2load.html + * + * @returns {MarkdownData} */ -const mdForBlockingRedirects = () => { - const BLOCKING_REDIRECTS_SOURCE_DIR = '../src/redirects/blocking-redirects'; +const getMarkdownDataForBlockingRedirects = () => { + const BLOCKING_REDIRECTS_SOURCE_SUB_DIR = 'blocking-redirects'; + // eslint-disable-next-line max-len + const BLOCKING_REDIRECTS_RELATIVE_SOURCE = `${REDIRECTS_SRC_RELATIVE_DIR_PATH}/${BLOCKING_REDIRECTS_SOURCE_SUB_DIR}`; - const blockingRedirects = fs.readFileSync(path.resolve(__dirname, BLOCKING_REDIRECTS), { encoding: 'utf8' }); + const blockingRedirects = fs.readFileSync(blockingRedirectsPath, { encoding: 'utf8' }); const parsedBlockingRedirects = yaml.safeLoad(blockingRedirects); - const output = parsedBlockingRedirects.reduce((acc, el) => { - if (el.description) { - acc.list.push(`* [${el.title}](#${el.title})\n`); + const output = parsedBlockingRedirects.reduce((acc, { title, description }) => { + if (description) { + acc.list.push(`* [${title}](#${title})${EOL}`); - const body = `### ⚡️ ${el.title} -${el.description} -[Redirect source](${BLOCKING_REDIRECTS_SOURCE_DIR}/${el.title}) -* * *\n\n`; + const body = `### ⚡️ ${title} +${description} +[Redirect source](${BLOCKING_REDIRECTS_RELATIVE_SOURCE}/${title}) +* * *${EOL}${EOL}`; acc.body.push(body); } else { - throw new Error(`No description for ${el.title}`); + throw new Error(`No description for ${title}`); } return acc; @@ -205,33 +165,37 @@ ${el.description} }; /** - * Entry function + * Builds about wiki pages for scriptlets and redirects */ -function init() { +const buildWikiAboutPages = () => { try { - const scriptletsMarkdownData = generateMD(manageDataFromFiles().scriptletsData); - const redirectsMarkdownData = generateMD(manageDataFromFiles().redirectsData); - const staticRedirectsMarkdownData = mdForStaticRedirects(); - const blockingRedirectsMarkdownData = mdForBlockingRedirects(); + const filesData = manageDataFromFiles(); + const scriptletsMarkdownData = getMarkdownData(filesData.scriptletsData); + const redirectsMarkdownData = getMarkdownData(filesData.redirectsData); + const staticRedirectsMarkdownData = getMarkdownDataForStaticRedirects(); + const blockingRedirectsMarkdownData = getMarkdownDataForBlockingRedirects(); + + const scriptletsPageContent = `## Available Scriptlets +${scriptletsMarkdownData.list}* * * +${scriptletsMarkdownData.body}`; + fs.writeFileSync( + path.resolve(__dirname, aboutScriptletsPath), + scriptletsPageContent, + ); /* eslint-disable max-len */ - const scriptletsAbout = `## Available Scriptlets\n${scriptletsMarkdownData.list}* * *\n${scriptletsMarkdownData.body}`; - fs.writeFileSync(path.resolve(__dirname, ABOUT_SCRIPTLETS_PATH), scriptletsAbout); - - const redirectsAbout = `## Available Redirect resources + const redirectsPageContent = `## Available Redirect resources ${staticRedirectsMarkdownData.list}${redirectsMarkdownData.list}${blockingRedirectsMarkdownData.list}* * * ${staticRedirectsMarkdownData.body}${redirectsMarkdownData.body}${blockingRedirectsMarkdownData.body}`; /* eslint-enable max-len */ - fs.writeFileSync(path.resolve(__dirname, ABOUT_REDIRECTS_PATH), redirectsAbout); + fs.writeFileSync( + path.resolve(__dirname, aboutRedirectsPath), + redirectsPageContent, + ); } catch (e) { // eslint-disable-next-line no-console console.log(e.message); } -} - -init(); - -module.exports = { - redirectsFilesList, - getDataFromFiles, }; + +buildWikiAboutPages(); diff --git a/scripts/build-redirects.js b/scripts/build-redirects.js index eccc95e47..3f2293da7 100644 --- a/scripts/build-redirects.js +++ b/scripts/build-redirects.js @@ -13,19 +13,20 @@ import generateHtml from 'rollup-plugin-generate-html'; import { minify } from 'terser'; import * as redirectsList from '../src/redirects/redirects-list'; import { version } from '../package.json'; -import { redirectsFilesList, getDataFromFiles } from './build-docs'; -import { writeFile } from './helpers'; import { rollupStandard } from './rollup-runners'; +import { writeFileAsync, getDataFromFiles } from './helpers'; +import { redirectsFilenames, REDIRECTS_SRC_RELATIVE_DIR_PATH } from './constants'; const FILE_NAME = 'redirects.yml'; const CORELIBS_FILE_NAME = 'redirects.json'; const PATH_TO_DIST = './dist'; + const RESULT_PATH = path.resolve(PATH_TO_DIST, FILE_NAME); const REDIRECT_FILES_PATH = path.resolve(PATH_TO_DIST, 'redirect-files'); const CORELIBS_RESULT_PATH = path.resolve(PATH_TO_DIST, CORELIBS_FILE_NAME); +// TODO: check if constants may be used const DIST_REDIRECT_FILES = 'dist/redirect-files'; -const REDIRECTS_DIRECTORY = '../src/redirects'; const STATIC_REDIRECTS_PATH = './src/redirects/static-redirects.yml'; const BLOCKING_REDIRECTS_PATH = './src/redirects/blocking-redirects.yml'; const banner = `# @@ -134,9 +135,12 @@ const getJsRedirects = async (options = {}) => { }; })); - const redirectsDescriptions = getDataFromFiles(redirectsFilesList, REDIRECTS_DIRECTORY) - .flat(1); + const redirectsDescriptions = getDataFromFiles( + redirectsFilenames, + REDIRECTS_SRC_RELATIVE_DIR_PATH, + ).flat(1); + // TODO: seems like duplicate of already existed code /** * Returns first line of describing comment from redirect resource file * @param {string} rrName redirect resource name @@ -166,7 +170,7 @@ const getJsRedirects = async (options = {}) => { throw new Error(`Couldn't find source for non-static redirect: ${fileName}`); }; - const jsRedirects = redirectsFilesList.map((filename) => complementJsRedirects(filename)); + const jsRedirects = redirectsFilenames.map((filename) => complementJsRedirects(filename)); return jsRedirects; }; @@ -198,7 +202,7 @@ export const getPreparedRedirects = async (options) => { const buildJsRedirectFiles = async (redirectsData) => { const saveRedirectData = async (redirect) => { const redirectPath = `${REDIRECT_FILES_PATH}/${redirect.file}`; - await writeFile(redirectPath, redirect.content); + await writeFileAsync(redirectPath, redirect.content); }; await Promise.all(Object.values(redirectsData) @@ -226,9 +230,9 @@ const buildStaticRedirectFiles = async (redirectsData) => { // replace them all because base64 isn't supposed to have them contentToWrite = content.replace(/(\r\n|\n|\r|\s)/gm, ''); const buff = Buffer.from(contentToWrite, 'base64'); - await writeFile(redirectPath, buff); + await writeFileAsync(redirectPath, buff); } else { - await writeFile(redirectPath, contentToWrite); + await writeFileAsync(redirectPath, contentToWrite); } }; @@ -247,7 +251,7 @@ const buildRedirectsYamlFile = async (mergedRedirects) => { // add version and title to the top yamlRedirects = `${banner}${yamlRedirects}`; - await writeFile(RESULT_PATH, yamlRedirects); + await writeFileAsync(RESULT_PATH, yamlRedirects); }; export const prebuildRedirects = async () => { @@ -368,7 +372,7 @@ export const buildRedirectsForCorelibs = async () => { try { const jsonString = JSON.stringify(base64Redirects, null, 4); - await writeFile(CORELIBS_RESULT_PATH, jsonString); + await writeFileAsync(CORELIBS_RESULT_PATH, jsonString); } catch (e) { // eslint-disable-next-line no-console console.log(`Couldn't save to ${CORELIBS_RESULT_PATH}, because of: ${e.message}`); diff --git a/scripts/check-sources-updates.js b/scripts/check-sources-updates.js index fc4ba4a66..ba6700d20 100644 --- a/scripts/check-sources-updates.js +++ b/scripts/check-sources-updates.js @@ -191,7 +191,7 @@ async function checkForUBOScriptletsUpdates() { /** * UBO redirects github page */ -const UBO_REDIRECTS_DIRECTORY_FILE = 'https://raw.githubusercontent.com/gorhill/uBlock/master/src/js/redirect-engine.js'; +const UBO_REDIRECTS_DIRECTORY_FILE = 'https://raw.githubusercontent.com/gorhill/uBlock/master/src/js/redirect-resources.js'; /** * Make request to UBO repo(master), parses and returns the list of UBO redirects @@ -201,7 +201,7 @@ async function getCurrentUBORedirects() { let { data } = await axios.get(UBO_REDIRECTS_DIRECTORY_FILE); console.log('Done.'); - const startTrigger = 'const redirectableResources = new Map(['; + const startTrigger = 'export default new Map(['; const endTrigger = ']);'; const startIndex = data.indexOf(startTrigger); @@ -270,7 +270,7 @@ async function getCurrentABPSnippets() { // eslint-disable-line no-unused-vars /** * Checks for ABP Snippets updates */ -async function checkForABPScriptletssUpdates() { +async function checkForABPScriptletsUpdates() { const oldList = getScriptletsFromTable('abp'); // ABP_SNIPPETS_FILE is unavailable // TODO: fix later, AG-11891 @@ -333,7 +333,7 @@ async function checkForABPRedirectsUpdates() { const UBORedirectsDiff = await checkForUBORedirectsUpdates(); const UBOScriptletsDiff = await checkForUBOScriptletsUpdates(); const ABPRedirectsDiff = await checkForABPRedirectsUpdates(); - const ABPScriptletsDiff = await checkForABPScriptletssUpdates(); + const ABPScriptletsDiff = await checkForABPScriptletsUpdates(); if (UBORedirectsDiff) { markTableWithDiff(UBORedirectsDiff, 'redirects', 'ubo'); @@ -368,6 +368,6 @@ async function checkForABPRedirectsUpdates() { ${added.length ? `Added: ${added}.` : ''} `; - throw new Error(message); + console.log(message); } }()); diff --git a/scripts/constants.js b/scripts/constants.js index 26a21d482..8f9c90c00 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -1,25 +1,53 @@ const path = require('path'); +const { getFilesList } = require('./helpers'); + /** * Rules which were removed from the list should be marked with it */ const REMOVED_MARKER = '(removed)'; -const COMPATIBILITY_TABLE_INPUT_FILE = './compatibility-table.json'; -const COMPATIBILITY_TABLE_OUTPUT_FILE = '../wiki/compatibility-table.md'; - +const COMPATIBILITY_TABLE_INPUT_FILENAME = 'compatibility-table.json'; /** - * Path to compatibility data source json + * Path to **input** compatibility data source json */ -const COMPATIBILITY_TABLE_DATA_PATH = path.resolve(__dirname, COMPATIBILITY_TABLE_INPUT_FILE); +const COMPATIBILITY_TABLE_DATA_PATH = path.resolve(__dirname, COMPATIBILITY_TABLE_INPUT_FILENAME); -/** - * Path to file with compatibility tables - */ -const WIKI_COMPATIBILITY_TABLE_PATH = path.resolve(__dirname, COMPATIBILITY_TABLE_OUTPUT_FILE); +const WIKI_DIR_PATH = '../wiki'; + +const SRC_RELATIVE_DIR = '../src'; +const SRC_SCRIPTLETS_SUB_DIR = 'scriptlets'; +const SRC_REDIRECTS_SUB_DIR = 'redirects'; + +const SCRIPTLETS_SRC_RELATIVE_DIR_PATH = `${SRC_RELATIVE_DIR}/${SRC_SCRIPTLETS_SUB_DIR}`; +const REDIRECTS_SRC_RELATIVE_DIR_PATH = `${SRC_RELATIVE_DIR}/${SRC_REDIRECTS_SUB_DIR}`; + +// files which are not scriptlets in the source directory +const NON_SCRIPTLETS_FILES = [ + 'index.js', + 'scriptlets.js', + 'scriptlets-list.js', + 'scriptlets-wrapper.js', + 'scriptlets-umd-wrapper.js', +]; +const scriptletsFilenames = getFilesList(SCRIPTLETS_SRC_RELATIVE_DIR_PATH) + .filter((el) => !NON_SCRIPTLETS_FILES.includes(el)); + +// files which are not redirects in the source directory +const NON_REDIRECTS_FILES = [ + 'index.js', + 'redirects.js', + 'redirects-list.js', +]; +const redirectsFilenames = getFilesList(REDIRECTS_SRC_RELATIVE_DIR_PATH) + .filter((el) => !NON_REDIRECTS_FILES.includes(el)); module.exports = { REMOVED_MARKER, COMPATIBILITY_TABLE_DATA_PATH, - WIKI_COMPATIBILITY_TABLE_PATH, + WIKI_DIR_PATH, + SCRIPTLETS_SRC_RELATIVE_DIR_PATH, + REDIRECTS_SRC_RELATIVE_DIR_PATH, + scriptletsFilenames, + redirectsFilenames, }; diff --git a/scripts/helpers.js b/scripts/helpers.js index 4863bbc8e..b3d126b64 100644 --- a/scripts/helpers.js +++ b/scripts/helpers.js @@ -1,9 +1,125 @@ -import path from 'path'; -import fs from 'fs-extra'; +const path = require('path'); +const fs = require('fs-extra'); +const dox = require('dox'); -export const writeFile = async (filePath, content) => { +/** + * Asynchronously writes data to a file, replacing the file if it already exists. + * + * @param {string} filePath absolute path to file + * @param {string} content content to write to the file + */ +const writeFile = async (filePath, content) => { const dirname = path.dirname(filePath); await fs.ensureDir(dirname); await fs.writeFile(filePath, content); }; + +/** + * Gets list of `.js` files in directory + * @param {string} relativeDirPath relative path to directory + * @returns {string[]} array of file names + */ +const getFilesList = (relativeDirPath) => { + return fs.readdirSync(path.resolve(__dirname, relativeDirPath), { encoding: 'utf8' }) + .filter((el) => el.includes('.js')); +}; + +/** + * @typedef {Object} CommentTag + * @property {string} type tag name + * @property {string} string text following the tag + */ + +/** + * Returns parsed tags data which we use to describe the sources: + * - `@scriptlet`/`@redirect` to describe the type and name of source; + * - `@description` actual description for scriptlet or redirect. + * required comments from file. + * In one file might be comments describing scriptlet and redirect as well. + * + * @param {string} filePath absolute path to file + * + * @returns {CommentTag[]} + */ +const getDescribingCommentTags = (filePath) => { + const fileContent = fs.readFileSync(filePath, { encoding: 'utf8' }); + const parsedFileComments = dox.parseComments(fileContent); + const describingComment = parsedFileComments + // get rid of not needed comments data + .filter(({ tags }) => { + // '@scriptlet', '@redirect', and 'description' + // are parser by dox.parseComments() as `tags` + if (tags.length === 0) { + return false; + } + const [base] = tags; + return base?.type === 'scriptlet' + || base?.type === 'redirect'; + }); + + if (describingComment.length === 0) { + throw new Error(`No description in ${filePath}. +Please add one OR edit the list of NON_SCRIPTLETS_FILES / NON_REDIRECTS_FILES.`); + } + + if (describingComment.length > 1) { + throw new Error(`File should have one description comment: ${filePath}.`); + } + + // eventually only one comment data item should left + return describingComment[0].tags; +}; + +/** + * @typedef {Object} DescribingCommentData + * + * Collected data from jsdoc-type comment for every scriptlet or redirect. + * + * @property {string} type parsed instance tag: + * 'scriptlet' for '@scriptlet', 'redirect' for '@redirect' + * @property {string} name name of instance which goes after the instance tag + * @property {string} description description, goes after `@description` tag + * @property {string} source relative path to source of scriptlet or redirect from wiki/about page + */ + +/** + * Converts parsed comment to data object. + * + * @param {CommentTag[]} commentTags parsed tags from describing comment + * @param {string} source relative path to file + * + * @returns {DescribingCommentData} + */ +const prepareCommentsData = (commentTags, source) => { + const [base, sup] = commentTags; + return { + type: base.type, + name: base.string, + description: sup.string, + source, + }; +}; + +/** + * Gets data objects which describe every required comment in one directory + * @param {string[]} filesList list of files in directory + * @param {string} relativeDirPath relative path to directory + * + * @returns {DescribingCommentData} + */ +const getDataFromFiles = (filesList, relativeDirPath) => { + const pathToDir = path.resolve(__dirname, relativeDirPath); + return filesList.map((file) => { + const pathToFile = path.resolve(pathToDir, file); + const requiredCommentTags = getDescribingCommentTags(pathToFile); + + return prepareCommentsData(requiredCommentTags, `${relativeDirPath}/${file}`); + }); +}; + +module.exports = { + writeFile, + getFilesList, + getDataFromFiles, +}; From 3327e89a7dcd1ecbf0da8307a85e09f3769d810a Mon Sep 17 00:00:00 2001 From: Stanislav Atroschenko Date: Tue, 8 Nov 2022 14:11:38 +0300 Subject: [PATCH 12/15] add trusted-set-cookie scriptlet AG-16969 Merge in ADGUARD-FILTERS/scriptlets from feature/AG-16969 to release/v1.7 Squashed commit of the following: commit 9264fe5a7a6efcb072fe4e10848453653afe2a6b Merge: 036c10a ee83930 Author: Stanislav A Date: Tue Nov 8 14:02:56 2022 +0300 Merge branch 'release/v1.7' into feature/AG-16969 commit 036c10ae9d51ec487659f77b5bb459aa1def8735 Author: Stanislav A Date: Tue Nov 8 12:10:56 2022 +0300 fix eslint comment commit caf2a241cfbe10af04d248beeacdf368ee095a44 Author: Stanislav A Date: Mon Nov 7 20:29:34 2022 +0300 fix eslint comment commit b3b8d29a58e80fde6042a8c235b5346edb6191aa Author: Stanislav A Date: Mon Nov 7 19:28:04 2022 +0300 fix null guard commit 639ab15858ff7caa922c79bc629cf8c2e59a2637 Author: Stanislav A Date: Mon Nov 7 16:57:18 2022 +0300 fix scriptlet name in the description commit e1e5fc7a63b2f89e89157fa8dc86c37ac7b15524 Merge: e7c6566 788f73a Author: Stanislav A Date: Mon Nov 7 15:16:25 2022 +0300 fix trusted-replace-fetch logging commit e7c6566197b20efe7e85a56b6894082d59bc8632 Author: Stanislav A Date: Mon Nov 7 15:10:12 2022 +0300 add null value guard to concatCookieNameValuePath commit 69367ab993f356ab9c3c147b4c8f8491ea300b68 Author: Stanislav A Date: Thu Nov 3 17:30:24 2022 +0300 move clearCookie to test/helpers commit 9f1f0d4705ceeb025886c66eb4d8b334c29ab2b7 Author: Stanislav A Date: Thu Nov 3 17:16:28 2022 +0300 redo helpers commit deb0cbbb326f62f3966d56bd4742352f1bf7a2c3 Author: Stanislav A Date: Thu Nov 3 13:16:45 2022 +0300 fix comments & arg guard commit cd821d6c11059c225490f772841ba8b003de79e3 Author: Stanislav A Date: Wed Nov 2 18:03:19 2022 +0300 improve cookie helpers commit 08810f9de461545893cfbde5c9f4134d6a9415f0 Author: Stanislav A Date: Wed Nov 2 17:14:16 2022 +0300 improve description commit bbf6812affc7ea2c066be02972e8eb7e0ecf2fb3 Author: Stanislav A Date: Tue Nov 1 14:59:00 2022 +0300 add logging on incorrect name-value input, improve description commit 080b89f4195dab53e41c17985c3ec4d88cdea3da Author: Stanislav A Date: Tue Nov 1 14:46:29 2022 +0300 swap 'now' keyword to '$' commit 3fb182717d8f439c6bab2e90b9e2e2c0abcf8600 Author: Stanislav A Date: Mon Oct 31 19:21:34 2022 +0300 move path parsing to a helper commit 9d9584ed312af98b0835d26cbaa0506a6800f65b Author: Stanislav A Date: Mon Oct 31 18:53:04 2022 +0300 fix the description commit ed23e256b1e3c9c965502ef3cbc8ae73d23dcc34 Author: Stanislav A Date: Mon Oct 31 14:02:28 2022 +0300 fix path arg & tests commit 661214493ff56de552ede6e76bd44f14a5ce3064 Merge: 7379f0b 0bc5d92 Author: Stanislav A Date: Mon Oct 31 13:04:20 2022 +0300 merge release/v1.7 commit 7379f0b023b8e4975df9735ed63f61c9c6ddadbe Author: Stanislav A Date: Thu Oct 27 13:17:26 2022 +0300 add path argument commit da7919e68e059a434a8a282865fc61e48067ed88 Author: Stanislav A Date: Tue Oct 25 19:17:53 2022 +0300 remove unnecessary helper & improve guard comment ... and 13 more commits --- src/helpers/cookie-utils.js | 124 +++++++++---- src/scriptlets/scriptlets-list.js | 1 + src/scriptlets/set-cookie-reload.js | 43 +++-- src/scriptlets/set-cookie.js | 33 +++- .../trusted-replace-fetch-response.js | 3 +- src/scriptlets/trusted-set-cookie.js | 165 ++++++++++++++++++ tests/helpers.js | 8 + tests/scriptlets/index.test.js | 1 + tests/scriptlets/set-cookie-reload.test.js | 10 +- tests/scriptlets/set-cookie.test.js | 10 +- .../scriptlets/trusted-click-element.test.js | 14 +- tests/scriptlets/trusted-set-cookie.test.js | 116 ++++++++++++ 12 files changed, 453 insertions(+), 75 deletions(-) create mode 100644 src/scriptlets/trusted-set-cookie.js create mode 100644 tests/scriptlets/trusted-set-cookie.test.js diff --git a/src/helpers/cookie-utils.js b/src/helpers/cookie-utils.js index 13766524e..a7532cabb 100644 --- a/src/helpers/cookie-utils.js +++ b/src/helpers/cookie-utils.js @@ -1,47 +1,91 @@ import { nativeIsNaN } from './number-utils'; /** - * Prepares cookie string if given parameters are ok - * @param {string} name cookie name to set - * @param {string} value cookie value to set - * @param {string} path cookie path to set, 'none' for no path - * @returns {string|null} cookie string if ok OR null if not + * Checks whether the input path is supported + * + * @param {string} rawPath input path + * + * @returns {boolean} */ -export const prepareCookie = (name, value, path) => { - if (!name || !value) { - return null; +export const isValidCookieRawPath = (rawPath) => rawPath === '/' || rawPath === 'none'; + +/** + * Returns 'path=/' if rawPath is '/' + * or empty string '' for other cases, `rawPath === 'none'` included + * + * @param {string} rawPath + * + * @returns {string} + */ +export const getCookiePath = (rawPath) => { + if (rawPath === '/') { + return 'path=/'; } + // otherwise do not set path as invalid + // the same for pathArg === 'none' + // + return ''; +}; +/** + * Combines input cookie name, value, and path into string. + * + * @param {string} rawName + * @param {string} rawValue + * @param {string} rawPath + * + * @returns {string} string OR `null` if path is not supported + */ +export const concatCookieNameValuePath = (rawName, rawValue, rawPath) => { const log = console.log.bind(console); // eslint-disable-line no-console + if (!isValidCookieRawPath(rawPath)) { + log(`Invalid cookie path: '${rawPath}'`); + return null; + } + // eslint-disable-next-line max-len + return `${encodeURIComponent(rawName)}=${encodeURIComponent(rawValue)}; ${getCookiePath(rawPath)}`; +}; - let valueToSet; +/** + * Gets supported cookie value + * + * @param {string} value input cookie value + * + * @returns {string|null} valid cookie string if ok OR null if not + */ +export const getLimitedCookieValue = (value) => { + if (!value) { + return null; + } + const log = console.log.bind(console); // eslint-disable-line no-console + let validValue; if (value === 'true') { - valueToSet = 'true'; + validValue = 'true'; } else if (value === 'True') { - valueToSet = 'True'; + validValue = 'True'; } else if (value === 'false') { - valueToSet = 'false'; + validValue = 'false'; } else if (value === 'False') { - valueToSet = 'False'; + validValue = 'False'; } else if (value === 'yes') { - valueToSet = 'yes'; + validValue = 'yes'; } else if (value === 'Yes') { - valueToSet = 'Yes'; + validValue = 'Yes'; } else if (value === 'Y') { - valueToSet = 'Y'; + validValue = 'Y'; } else if (value === 'no') { - valueToSet = 'no'; + validValue = 'no'; } else if (value === 'ok') { - valueToSet = 'ok'; + validValue = 'ok'; } else if (value === 'OK') { - valueToSet = 'OK'; + validValue = 'OK'; } else if (/^\d+$/.test(value)) { - valueToSet = parseFloat(value); - if (nativeIsNaN(valueToSet)) { + validValue = parseFloat(value); + if (nativeIsNaN(validValue)) { log(`Invalid cookie value: '${value}'`); return null; } - if (Math.abs(valueToSet) < 0 || Math.abs(valueToSet) > 15) { + if (Math.abs(validValue) < 0 || Math.abs(validValue) > 15) { log(`Invalid cookie value: '${value}'`); return null; } @@ -49,20 +93,7 @@ export const prepareCookie = (name, value, path) => { return null; } - let pathToSet; - if (path === '/') { - pathToSet = 'path=/'; - } else if (path === 'none') { - pathToSet = ''; - } else { - log(`Invalid cookie path: '${path}'`); - return null; - } - - // eslint-disable-next-line max-len - const cookieData = `${encodeURIComponent(name)}=${encodeURIComponent(valueToSet)}; ${pathToSet}`; - - return cookieData; + return validValue; }; /** @@ -94,3 +125,24 @@ export const parseCookieString = (cookieString) => { return cookieData; }; + +/** + * Check if cookie with specified name and value is present in a cookie string + * @param {string} cookieString + * @param {string} name + * @param {string} value + * @returns {boolean} + */ +export const isCookieSetWithValue = (cookieString, name, value) => { + return cookieString.split(';') + .some((cookieStr) => { + const pos = cookieStr.indexOf('='); + if (pos === -1) { + return false; + } + const cookieName = cookieStr.slice(0, pos).trim(); + const cookieValue = cookieStr.slice(pos + 1).trim(); + + return name === cookieName && value === cookieValue; + }); +}; diff --git a/src/scriptlets/scriptlets-list.js b/src/scriptlets/scriptlets-list.js index f00ab89f7..86f6031b1 100644 --- a/src/scriptlets/scriptlets-list.js +++ b/src/scriptlets/scriptlets-list.js @@ -50,4 +50,5 @@ export * from './prevent-element-src-loading'; export * from './no-topics'; export * from './trusted-replace-xhr-response'; export * from './xml-prune'; +export * from './trusted-set-cookie'; export * from './trusted-replace-fetch-response'; diff --git a/src/scriptlets/set-cookie-reload.js b/src/scriptlets/set-cookie-reload.js index 037c589d7..128126fb3 100644 --- a/src/scriptlets/set-cookie-reload.js +++ b/src/scriptlets/set-cookie-reload.js @@ -1,7 +1,13 @@ import { hit, nativeIsNaN, - prepareCookie, + isCookieSetWithValue, + getLimitedCookieValue, + concatCookieNameValuePath, + // following helpers should be imported and injected + // because they are used by helpers above + isValidCookieRawPath, + getCookiePath, } from '../helpers/index'; /** @@ -40,25 +46,20 @@ import { * ``` */ export function setCookieReload(source, name, value, path = '/') { - const isCookieSetWithValue = (name, value) => { - return document.cookie.split(';') - .some((cookieStr) => { - const pos = cookieStr.indexOf('='); - if (pos === -1) { - return false; - } - const cookieName = cookieStr.slice(0, pos).trim(); - const cookieValue = cookieStr.slice(pos + 1).trim(); + if (isCookieSetWithValue(name, value)) { + return; + } - return name === cookieName && value === cookieValue; - }); - }; + // eslint-disable-next-line no-console + const log = console.log.bind(console); - if (isCookieSetWithValue(name, value)) { + const validValue = getLimitedCookieValue(value); + if (validValue === null) { + log(`Invalid cookie value: '${validValue}'`); return; } - const cookieData = prepareCookie(name, value, path); + const cookieData = concatCookieNameValuePath(name, validValue, path); if (cookieData) { document.cookie = cookieData; @@ -66,7 +67,7 @@ export function setCookieReload(source, name, value, path = '/') { // Only reload the page if cookie was set // https://github.com/AdguardTeam/Scriptlets/issues/212 - if (isCookieSetWithValue(name, value)) { + if (isCookieSetWithValue(document.cookie, name, value)) { window.location.reload(); } } @@ -76,4 +77,12 @@ setCookieReload.names = [ 'set-cookie-reload', ]; -setCookieReload.injections = [hit, nativeIsNaN, prepareCookie]; +setCookieReload.injections = [ + hit, + nativeIsNaN, + isCookieSetWithValue, + getLimitedCookieValue, + concatCookieNameValuePath, + isValidCookieRawPath, + getCookiePath, +]; diff --git a/src/scriptlets/set-cookie.js b/src/scriptlets/set-cookie.js index 441b368eb..c686ef498 100644 --- a/src/scriptlets/set-cookie.js +++ b/src/scriptlets/set-cookie.js @@ -1,4 +1,14 @@ -import { hit, nativeIsNaN, prepareCookie } from '../helpers/index'; +import { + hit, + nativeIsNaN, + isCookieSetWithValue, + getLimitedCookieValue, + concatCookieNameValuePath, + // following helpers should be imported and injected + // because they are used by helpers above + isValidCookieRawPath, + getCookiePath, +} from '../helpers/index'; /* eslint-disable max-len */ /** @@ -36,7 +46,16 @@ import { hit, nativeIsNaN, prepareCookie } from '../helpers/index'; */ /* eslint-enable max-len */ export function setCookie(source, name, value, path = '/') { - const cookieData = prepareCookie(name, value, path); + // eslint-disable-next-line no-console + const log = console.log.bind(console); + + const validValue = getLimitedCookieValue(value); + if (validValue === null) { + log(`Invalid cookie value: '${validValue}'`); + return; + } + + const cookieData = concatCookieNameValuePath(name, validValue, path); if (cookieData) { hit(source); @@ -48,4 +67,12 @@ setCookie.names = [ 'set-cookie', ]; -setCookie.injections = [hit, nativeIsNaN, prepareCookie]; +setCookie.injections = [ + hit, + nativeIsNaN, + isCookieSetWithValue, + getLimitedCookieValue, + concatCookieNameValuePath, + isValidCookieRawPath, + getCookiePath, +]; diff --git a/src/scriptlets/trusted-replace-fetch-response.js b/src/scriptlets/trusted-replace-fetch-response.js index a3472c501..7dc999643 100644 --- a/src/scriptlets/trusted-replace-fetch-response.js +++ b/src/scriptlets/trusted-replace-fetch-response.js @@ -172,8 +172,7 @@ export function trustedReplaceFetchResponse(source, pattern = '', replacement = .catch(() => { // log if response body can't be converted to a string const fetchDataStr = objectToString(fetchData); - const logMessage = `log: Response body can't be converted to text: ${fetchDataStr}`; - log(source, logMessage); + log(`Response body can't be converted to text: ${fetchDataStr}`); return Reflect.apply(target, thisArg, args); }); }) diff --git a/src/scriptlets/trusted-set-cookie.js b/src/scriptlets/trusted-set-cookie.js new file mode 100644 index 000000000..9e2f05a80 --- /dev/null +++ b/src/scriptlets/trusted-set-cookie.js @@ -0,0 +1,165 @@ +import { + hit, + nativeIsNaN, + isCookieSetWithValue, + concatCookieNameValuePath, + // following helpers should be imported and injected + // because they are used by helpers above + isValidCookieRawPath, + getCookiePath, +} from '../helpers/index'; + +/* eslint-disable max-len */ +/** + * @scriptlet trusted-set-cookie + * + * @description + * Sets a cookie with arbitrary name and value, with optional path + * and the ability to reload the page after cookie was set. + * + * **Syntax** + * ``` + * example.org#%#//scriptlet('trusted-set-cookie', name, value[, offsetExpiresSec[, reload[, path]]]) + * ``` + * + * - `name` - required, cookie name to be set + * - `value` - required, cookie value. Possible values: + * - arbitrary value + * - empty string for no value + * - `$now$` keyword for setting current time + * - 'offsetExpiresSec' - optional, offset from current time in seconds, after which cookie should expire; defaults to no offset + * Possible values: + * - positive integer in seconds + * - `1year` keyword for setting expiration date to one year + * - `1day` keyword for setting expiration date to one day + * - 'reload' - optional, boolean. Argument for reloading page after cookie is set. Defaults to `false` + * - `path` - optional, argument for setting cookie path, defaults to `/`; possible values: + * - `/` — root path + * - `none` — to set no path at all + * + * **Examples** + * 1. Set cookie + * ``` + * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'accept') + * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', '1-accept_1') + * ``` + * + * 2. Set cookie with `new Date().getTime()` value + * ``` + * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', '$now') + * ``` + * + * 3. Set cookie which will expire in 3 days + * ``` + * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'accept', '259200') + * ``` + * + * 4. Set cookie which will expire in one year + * ``` + * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'accept', '1year') + * ``` + * 5. Reload the page if cookie was successfully set + * ``` + * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'decline', '', 'true') + * ``` + * + * 6. Set cookie with no path + * ``` + * example.org#%#//scriptlet('trusted-set-cookie', 'cmpconsent', 'decline', '', '', 'none') + * ``` + */ +/* eslint-enable max-len */ + +export function trustedSetCookie(source, name, value, offsetExpiresSec = '', reload = 'false', path = '/') { + // eslint-disable-next-line no-console + const log = console.log.bind(console); + + if (typeof name === 'undefined') { + log('Cookie name should be specified.'); + return; + } + + if (typeof value === 'undefined') { + log('Cookie value should be specified.'); + return; + } + + // Prevent infinite reloads if cookie was already set or blocked by the browser + // https://github.com/AdguardTeam/Scriptlets/issues/212 + if (reload === 'true' && isCookieSetWithValue(document.cookie, name, value)) { + return; + } + + const NOW_VALUE_KEYWORD = '$now$'; + const ONE_YEAR_EXPIRATION_KEYWORD = '1year'; + const ONE_DAY_EXPIRATION_KEYWORD = '1day'; + + let parsedValue; + + if (value === NOW_VALUE_KEYWORD) { + // Set cookie value to current time if corresponding keyword was passed + const date = new Date(); + const currentTime = date.getTime(); + + parsedValue = currentTime.toString(); + } else { + parsedValue = value; + } + + let cookieToSet = concatCookieNameValuePath(name, parsedValue, path); + if (!cookieToSet) { + return; + } + + // Set expiration date if offsetExpiresSec was passed + if (offsetExpiresSec) { + const MS_IN_SEC = 1000; + const SECONDS_IN_YEAR = 365 * 24 * 60 * 60; + const SECONDS_IN_DAY = 24 * 60 * 60; + + let parsedOffsetExpiresSec; + + // Set predefined expire value if corresponding keyword was passed + if (offsetExpiresSec === ONE_YEAR_EXPIRATION_KEYWORD) { + parsedOffsetExpiresSec = SECONDS_IN_YEAR; + } else if (offsetExpiresSec === ONE_DAY_EXPIRATION_KEYWORD) { + parsedOffsetExpiresSec = SECONDS_IN_DAY; + } else { + parsedOffsetExpiresSec = Number.parseInt(offsetExpiresSec, 10); + + // If offsetExpiresSec has been parsed to NaN - do not set cookie at all + if (Number.isNaN(parsedOffsetExpiresSec)) { + log(`log: Invalid offsetExpiresSec value: ${offsetExpiresSec}`); + return; + } + } + + const expires = Date.now() + parsedOffsetExpiresSec * MS_IN_SEC; + cookieToSet += ` expires=${new Date(expires).toUTCString()};`; + } + + if (cookieToSet) { + document.cookie = cookieToSet; + hit(source); + + // Only reload the page if cookie was set + // https://github.com/AdguardTeam/Scriptlets/issues/212 + if (reload === 'true' && isCookieSetWithValue(document.cookie, name, value)) { + window.location.reload(); + } + } +} + +trustedSetCookie.names = [ + 'trusted-set-cookie', + // trusted scriptlets support no aliases +]; + +trustedSetCookie.injections = [ + hit, + nativeIsNaN, + isCookieSetWithValue, + concatCookieNameValuePath, + isValidCookieRawPath, + getCookiePath, +]; diff --git a/tests/helpers.js b/tests/helpers.js index 8ed45f9f0..bfda76242 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -59,4 +59,12 @@ export const runRedirect = (name, verbose = true) => { evalWrapper(resultString); }; +/** + * Clear cookie by name + * @param {string} cName + */ +export const clearCookie = (cName) => { + document.cookie = `${cName}=; max-age=0`; +}; + export const isSafariBrowser = () => navigator.vendor === 'Apple Computer, Inc.'; diff --git a/tests/scriptlets/index.test.js b/tests/scriptlets/index.test.js index 865db978d..d45c9f8d3 100644 --- a/tests/scriptlets/index.test.js +++ b/tests/scriptlets/index.test.js @@ -47,4 +47,5 @@ import './no-topics.test'; import './trusted-replace-xhr-response.test'; import './xml-prune.test'; import './trusted-click-element.test'; +import './trusted-set-cookie.test'; import './trusted-replace-fetch-response.test'; diff --git a/tests/scriptlets/set-cookie-reload.test.js b/tests/scriptlets/set-cookie-reload.test.js index 0f6dd04a8..6075424d3 100644 --- a/tests/scriptlets/set-cookie-reload.test.js +++ b/tests/scriptlets/set-cookie-reload.test.js @@ -1,5 +1,9 @@ /* eslint-disable no-underscore-dangle */ -import { runScriptlet, clearGlobalProps } from '../helpers'; +import { + runScriptlet, + clearGlobalProps, + clearCookie, +} from '../helpers'; const { test, module } = QUnit; const name = 'set-cookie-reload'; @@ -14,10 +18,6 @@ const afterEach = () => { clearGlobalProps('hit', '__debug'); }; -const clearCookie = (cName) => { - document.cookie = `${cName}=; max-age=0`; -}; - module(name, { beforeEach, afterEach }); test('Set cookie with valid value', (assert) => { diff --git a/tests/scriptlets/set-cookie.test.js b/tests/scriptlets/set-cookie.test.js index 869e70cf9..31c4338bd 100644 --- a/tests/scriptlets/set-cookie.test.js +++ b/tests/scriptlets/set-cookie.test.js @@ -1,5 +1,9 @@ /* eslint-disable no-underscore-dangle */ -import { runScriptlet, clearGlobalProps } from '../helpers'; +import { + runScriptlet, + clearGlobalProps, + clearCookie, +} from '../helpers'; const { test, module } = QUnit; const name = 'set-cookie'; @@ -16,10 +20,6 @@ const afterEach = () => { module(name, { beforeEach, afterEach }); -const clearCookie = (cName) => { - document.cookie = `${cName}=; max-age=0`; -}; - test('Set cookie with valid value', (assert) => { let cName = '__test-cookie_OK'; let cValue = 'OK'; diff --git a/tests/scriptlets/trusted-click-element.test.js b/tests/scriptlets/trusted-click-element.test.js index bde73da64..663f60625 100644 --- a/tests/scriptlets/trusted-click-element.test.js +++ b/tests/scriptlets/trusted-click-element.test.js @@ -1,6 +1,6 @@ /* eslint-disable no-underscore-dangle, no-console */ import { runScriptlet, clearGlobalProps } from '../helpers'; -import { prepareCookie } from '../../src/helpers'; +import { concatCookieNameValuePath } from '../../src/helpers'; const { test, module } = QUnit; const name = 'trusted-click-element'; @@ -162,7 +162,7 @@ test('Multiple elements clicked, non-ordered render', (assert) => { test('extraMatch - single cookie match, matched', (assert) => { const cookieKey1 = 'first'; - const cookieData = prepareCookie(cookieKey1, 'true'); + const cookieData = concatCookieNameValuePath(cookieKey1, 'true', '/'); document.cookie = cookieData; const EXTRA_MATCH_STR = `cookie:${cookieKey1}`; @@ -190,7 +190,7 @@ test('extraMatch - single cookie match, matched', (assert) => { test('extraMatch - single cookie match, not matched', (assert) => { const cookieKey1 = 'first'; const cookieKey2 = 'second'; - const cookieData = prepareCookie(cookieKey1, 'true'); + const cookieData = concatCookieNameValuePath(cookieKey1, 'true', '/'); document.cookie = cookieData; const EXTRA_MATCH_STR = `cookie:${cookieKey2}`; @@ -218,7 +218,7 @@ test('extraMatch - single cookie match, not matched', (assert) => { test('extraMatch - string+regex cookie input, matched', (assert) => { const cookieKey1 = 'first'; const cookieVal1 = 'true'; - const cookieData1 = prepareCookie(cookieKey1, cookieVal1); + const cookieData1 = concatCookieNameValuePath(cookieKey1, cookieVal1, '/'); document.cookie = cookieData1; const EXTRA_MATCH_STR = 'cookie:/firs/=true'; @@ -299,13 +299,13 @@ test('extraMatch - single localStorage match, not matched', (assert) => { test('extraMatch - complex string+regex cookie input & whitespaces & comma in regex, matched', (assert) => { const cookieKey1 = 'first'; const cookieVal1 = 'true'; - const cookieData1 = prepareCookie(cookieKey1, cookieVal1); + const cookieData1 = concatCookieNameValuePath(cookieKey1, cookieVal1, '/'); const cookieKey2 = 'sec'; const cookieVal2 = '1-1'; - const cookieData2 = prepareCookie(cookieKey2, cookieVal2); + const cookieData2 = concatCookieNameValuePath(cookieKey2, cookieVal2, '/'); const cookieKey3 = 'third'; const cookieVal3 = 'true'; - const cookieData3 = prepareCookie(cookieKey3, cookieVal3); + const cookieData3 = concatCookieNameValuePath(cookieKey3, cookieVal3, '/'); document.cookie = cookieData1; document.cookie = cookieData2; diff --git a/tests/scriptlets/trusted-set-cookie.test.js b/tests/scriptlets/trusted-set-cookie.test.js new file mode 100644 index 000000000..3fecbf393 --- /dev/null +++ b/tests/scriptlets/trusted-set-cookie.test.js @@ -0,0 +1,116 @@ +/* eslint-disable no-underscore-dangle */ +import { + runScriptlet, + clearGlobalProps, + clearCookie, +} from '../helpers'; + +const { test, module } = QUnit; +const name = 'trusted-set-cookie'; + +const beforeEach = () => { + window.__debug = () => { + window.hit = 'FIRED'; + }; +}; + +const afterEach = () => { + clearGlobalProps('hit', '__debug'); +}; + +module(name, { beforeEach, afterEach }); + +test('Set cookie string', (assert) => { + let cName = '__test-cookie_OK'; + let cValue = 'OK'; + runScriptlet(name, [cName, cValue]); + assert.strictEqual(window.hit, 'FIRED', 'Hit was fired'); + assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set'); + assert.strictEqual(document.cookie.includes(cValue), true, 'Cookie value has been set'); + clearCookie(cName); + + cName = '__test-cookie_0'; + cValue = 0; + runScriptlet(name, [cName, cValue, '', '']); + assert.strictEqual(window.hit, 'FIRED', 'Hit was fired'); + assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set'); + assert.strictEqual(document.cookie.includes(cValue), true, 'Cookie value has been set'); + clearCookie(cName); + + cName = 'trackingSettings'; + cValue = '{%22ads%22:false%2C%22performance%22:false}'; + runScriptlet(name, [cName, cValue]); + + assert.strictEqual(window.hit, 'FIRED', 'Hit was fired'); + assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set'); + assert.strictEqual(document.cookie.includes(encodeURIComponent(cValue)), true, 'Cookie value has been set'); + clearCookie(cName); +}); + +test('Set cookie with current time value', (assert) => { + const cName = '__test-cookie_OK'; + const cValue = '$now$'; + + runScriptlet(name, [cName, cValue]); + + assert.strictEqual(window.hit, 'FIRED', 'Hit was fired'); + assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set'); + + // Some time will pass between calling scriptlet + // and qunit running assertion + const tolerance = 20; + const cookieValue = document.cookie.split('=')[1]; + const currentTime = new Date().getTime(); + const timeDiff = currentTime - cookieValue; + + assert.ok(timeDiff < tolerance, 'Cookie value has been set to current time'); + + clearCookie(cName); +}); + +test('Set cookie with expires', (assert) => { + const cName = '__test-cookie_expires'; + const cValue = 'expires'; + const expiresSec = 2; + + runScriptlet(name, [cName, cValue, `${expiresSec}`]); + + assert.strictEqual(window.hit, 'FIRED', 'Hit was fired'); + assert.strictEqual(document.cookie.includes(cName), true, 'Cookie name has been set'); + assert.strictEqual(document.cookie.includes(cValue), true, 'Cookie value has been set'); + + const done = assert.async(); + + setTimeout(() => { + assert.strictEqual(document.cookie.includes(cName), false, 'Cookie name has been deleted'); + assert.strictEqual(document.cookie.includes(cValue), false, 'Cookie value has been deleted'); + clearCookie(cName); + done(); + }, expiresSec * 1000); +}); + +test('Set cookie with negative expires', (assert) => { + const cName = '__test-cookie_expires_negative'; + const cValue = 'expires'; + const expiresSec = -2; + + runScriptlet(name, [cName, cValue, `${expiresSec}`]); + + assert.strictEqual(window.hit, 'FIRED', 'Hit was fired'); + assert.strictEqual(document.cookie.includes(cName), false, 'Cookie name has not been set'); + assert.strictEqual(document.cookie.includes(cValue), false, 'Cookie value has not been set'); + clearCookie(cName); +}); + +test('Set cookie with invalid expires', (assert) => { + const cName = '__test-cookie_expires_invalid'; + const cValue = 'expires'; + const expiresSec = 'invalid_value'; + + runScriptlet(name, [cName, cValue, `${expiresSec}`]); + + assert.strictEqual(window.hit, undefined, 'Hit was not fired'); + assert.strictEqual(document.cookie.includes(cName), false, 'Cookie name has not been set'); + assert.strictEqual(document.cookie.includes(cValue), false, 'Cookie value has not been set'); + clearCookie(cName); +}); From f55b7ad1da589f592be2e3ea11c117f75e05073b Mon Sep 17 00:00:00 2001 From: Stanislav Atroschenko Date: Wed, 9 Nov 2022 14:46:38 +0300 Subject: [PATCH 13/15] fix redirects build AG-14398 Merge in ADGUARD-FILTERS/scriptlets from fix/AG-14398_01 to release/v1.7 Squashed commit of the following: commit ecf384b69c1bca677d8a1c7bbf8d2e9f4bc9455d Author: Stanislav A Date: Wed Nov 9 14:38:37 2022 +0300 fix redirects build --- scripts/build-redirects.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/build-redirects.js b/scripts/build-redirects.js index 3f2293da7..d12437e47 100644 --- a/scripts/build-redirects.js +++ b/scripts/build-redirects.js @@ -14,7 +14,7 @@ import { minify } from 'terser'; import * as redirectsList from '../src/redirects/redirects-list'; import { version } from '../package.json'; import { rollupStandard } from './rollup-runners'; -import { writeFileAsync, getDataFromFiles } from './helpers'; +import { writeFile, getDataFromFiles } from './helpers'; import { redirectsFilenames, REDIRECTS_SRC_RELATIVE_DIR_PATH } from './constants'; const FILE_NAME = 'redirects.yml'; @@ -202,7 +202,7 @@ export const getPreparedRedirects = async (options) => { const buildJsRedirectFiles = async (redirectsData) => { const saveRedirectData = async (redirect) => { const redirectPath = `${REDIRECT_FILES_PATH}/${redirect.file}`; - await writeFileAsync(redirectPath, redirect.content); + await writeFile(redirectPath, redirect.content); }; await Promise.all(Object.values(redirectsData) @@ -230,9 +230,9 @@ const buildStaticRedirectFiles = async (redirectsData) => { // replace them all because base64 isn't supposed to have them contentToWrite = content.replace(/(\r\n|\n|\r|\s)/gm, ''); const buff = Buffer.from(contentToWrite, 'base64'); - await writeFileAsync(redirectPath, buff); + await writeFile(redirectPath, buff); } else { - await writeFileAsync(redirectPath, contentToWrite); + await writeFile(redirectPath, contentToWrite); } }; @@ -251,7 +251,7 @@ const buildRedirectsYamlFile = async (mergedRedirects) => { // add version and title to the top yamlRedirects = `${banner}${yamlRedirects}`; - await writeFileAsync(RESULT_PATH, yamlRedirects); + await writeFile(RESULT_PATH, yamlRedirects); }; export const prebuildRedirects = async () => { @@ -372,7 +372,7 @@ export const buildRedirectsForCorelibs = async () => { try { const jsonString = JSON.stringify(base64Redirects, null, 4); - await writeFileAsync(CORELIBS_RESULT_PATH, jsonString); + await writeFile(CORELIBS_RESULT_PATH, jsonString); } catch (e) { // eslint-disable-next-line no-console console.log(`Couldn't save to ${CORELIBS_RESULT_PATH}, because of: ${e.message}`); From 1a375cf3243e843ec57b94fbe36f03c01860b2de Mon Sep 17 00:00:00 2001 From: Stanislav Atroschenko Date: Wed, 9 Nov 2022 18:51:03 +0300 Subject: [PATCH 14/15] fix metrika-yandex-tag #254 AG-17129 Merge in ADGUARD-FILTERS/scriptlets from fix/AG-17129 to release/v1.7 Squashed commit of the following: commit 90a0f04ac4de311198432eb2300bd5fb71d7924b Author: Stanislav A Date: Wed Nov 9 15:05:10 2022 +0300 fix metrika-yandex-tag --- src/redirects/metrika-yandex-tag.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/redirects/metrika-yandex-tag.js b/src/redirects/metrika-yandex-tag.js index 75a495ef0..068551c3b 100644 --- a/src/redirects/metrika-yandex-tag.js +++ b/src/redirects/metrika-yandex-tag.js @@ -104,7 +104,6 @@ export function metrikaYandexTag(source) { function ym(id, funcName, ...args) { return api[funcName] && api[funcName](id, ...args); } - ym.a = []; function init(id) { // yaCounter object should provide api @@ -114,12 +113,13 @@ export function metrikaYandexTag(source) { if (typeof window.ym === 'undefined') { window.ym = ym; + ym.a = []; } else if (window.ym && window.ym.a) { - // Get id for yaCounter object - const counters = window.ym.a; - + // Keep initial counters array intact + ym.a = window.ym.a; window.ym = ym; - counters.forEach((params) => { + + window.ym.a.forEach((params) => { const id = params[0]; init(id); }); From 0fb636b6ee89dd43456834777c237c6563856906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Wr=C3=B3blewski?= Date: Wed, 9 Nov 2022 20:14:44 +0300 Subject: [PATCH 15/15] AG-17390 fix google-ima3. #255 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed commit of the following: commit a8cf7adfc9cfa1868aeed0b309866d2c6c651f4a Merge: b642bbd 1a375cf Author: Adam Wróblewski Date: Wed Nov 9 18:02:29 2022 +0100 Merge branch 'release/v1.7' into fix/AG-17390 commit b642bbd4b015c68622051ddb8ab704e02353ffb8 Author: Adam Wróblewski Date: Wed Nov 9 16:52:55 2022 +0100 Fix eslint commit 6996210a840120900a4555fcdbe33058843840af Author: Adam Wróblewski Date: Wed Nov 9 16:04:37 2022 +0100 Improve google-ima3 - remove managerLoaded --- src/redirects/google-ima3.js | 38 ++++++++++++++--------------- tests/redirects/google-ima3.test.js | 26 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/redirects/google-ima3.js b/src/redirects/google-ima3.js index ea260a08e..8c237908a 100644 --- a/src/redirects/google-ima3.js +++ b/src/redirects/google-ima3.js @@ -105,8 +105,6 @@ export function GoogleIma3(source) { }, }; - let managerLoaded = false; - const EventHandler = function () { this.listeners = new Map(); this._dispatch = function (e) { @@ -205,27 +203,27 @@ export function GoogleIma3(source) { }; AdsLoader.prototype.getVersion = () => VERSION; AdsLoader.prototype.requestAds = function (adsRequest, userRequestContext) { - if (!managerLoaded) { - managerLoaded = true; - - requestAnimationFrame(() => { - const { ADS_MANAGER_LOADED } = AdsManagerLoadedEvent.Type; - // eslint-disable-next-line max-len - this._dispatch(new ima.AdsManagerLoadedEvent(ADS_MANAGER_LOADED, adsRequest, userRequestContext)); - }); - - const e = new ima.AdError( - 'adPlayError', - 1205, - 1205, - 'The browser prevented playback initiated without user interaction.', + requestAnimationFrame(() => { + const { ADS_MANAGER_LOADED } = AdsManagerLoadedEvent.Type; + const event = new ima.AdsManagerLoadedEvent( + ADS_MANAGER_LOADED, adsRequest, userRequestContext, ); - requestAnimationFrame(() => { - this._dispatch(new ima.AdErrorEvent(e)); - }); - } + this._dispatch(event); + }); + + const e = new ima.AdError( + 'adPlayError', + 1205, + 1205, + 'The browser prevented playback initiated without user interaction.', + adsRequest, + userRequestContext, + ); + requestAnimationFrame(() => { + this._dispatch(new ima.AdErrorEvent(e)); + }); }; const AdsRenderingSettings = noopFunc; diff --git a/tests/redirects/google-ima3.test.js b/tests/redirects/google-ima3.test.js index 9b62a0833..12971cb2e 100644 --- a/tests/redirects/google-ima3.test.js +++ b/tests/redirects/google-ima3.test.js @@ -43,3 +43,29 @@ test('Ima mocked', (assert) => { assert.strictEqual(adError.adsRequest, 'adsRequest', 'AdError adsRequest saved'); assert.strictEqual(adError.userRequestContext, 'userRequestContext', 'AdError request context saved'); }); + +test('Ima - run requestAds function twice', (assert) => { + // Test for https://github.com/AdguardTeam/Scriptlets/issues/255 + const done = assert.async(); + + runRedirect(name); + + let number = 0; + const test = () => { + number += 1; + }; + const { ima } = window.google; + const AdsLoader = new ima.AdsLoader(); + AdsLoader.addEventListener(ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, test); + + AdsLoader.requestAds(); + requestAnimationFrame(() => { + assert.strictEqual(number, 1, 'number is equal to 1'); + }); + + AdsLoader.requestAds(); + requestAnimationFrame(() => { + assert.strictEqual(number, 2, 'number is equal to 2'); + done(); + }); +});