diff --git a/platform/mv3/extension/_locales/en/messages.json b/platform/mv3/extension/_locales/en/messages.json
index 17a5b24cd..ad9d26911 100644
--- a/platform/mv3/extension/_locales/en/messages.json
+++ b/platform/mv3/extension/_locales/en/messages.json
@@ -250,6 +250,14 @@
"strictblockSentence1": {
"message": "uBO Lite has prevented the following page from loading:",
"description": "Sentence used in the strict-blocked page"
+ },
+ "strictblockReasonSentence1": {
+ "message": "The page was blocked because of a matching filter in {{listname}}.",
+ "description": "Text informing about what is causing the page to be blocked"
+ },
+ "strictblockRedirectSentence1": {
+ "message": "The blocked page wants to redirect to another site. If you choose to proceed, you will navigate directly to: {{url}}",
+ "description": "Text warning about an incoming redirect"
},
"strictblockNoParamsPrompt": {
"message": "without parameters",
diff --git a/platform/mv3/extension/css/settings.css b/platform/mv3/extension/css/settings.css
index a8b07d2a1..02eb60037 100644
--- a/platform/mv3/extension/css/settings.css
+++ b/platform/mv3/extension/css/settings.css
@@ -15,7 +15,7 @@ h3 {
label + legend {
color: color-mix(in srgb, currentColor 69%, transparent);
- font-size: small;
+ font-size: var(--font-size-smaller);
margin-inline-start: var(--default-gap-large);
}
diff --git a/platform/mv3/extension/css/strictblock.css b/platform/mv3/extension/css/strictblock.css
index f7534078c..7f1f0e9c0 100644
--- a/platform/mv3/extension/css/strictblock.css
+++ b/platform/mv3/extension/css/strictblock.css
@@ -26,6 +26,9 @@ body {
:root.mobile body {
padding: var(--default-gap-small);
}
+body.loading {
+ visibility: hidden;
+ }
#rootContainer {
width: min(100%, 640px);
@@ -43,6 +46,9 @@ p {
a {
text-decoration: none;
}
+q {
+ font-weight: bold;
+ }
.code {
font-size: 13px;
word-break: break-all;
@@ -128,6 +134,12 @@ body[dir="rtl"] #toggleParse {
font-weight: bold;
}
+#urlskip a {
+ display: block;
+ overflow-y: auto;
+ word-break: break-all;
+ }
+
#actionContainer {
display: flex;
justify-content: space-between;
diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js
index a58dd7063..66d17a788 100644
--- a/platform/mv3/extension/js/background.js
+++ b/platform/mv3/extension/js/background.js
@@ -377,6 +377,8 @@ function onMessage(request, sender, callback) {
async function start() {
await loadRulesetConfig();
+ // Potentially enable new default rulesets
+
const rulesetsUpdated = process.wakeupRun === false &&
await enableRulesets(rulesetConfig.enabledRulesets);
diff --git a/platform/mv3/extension/js/ruleset-manager.js b/platform/mv3/extension/js/ruleset-manager.js
index c9490e683..c18a575d9 100644
--- a/platform/mv3/extension/js/ruleset-manager.js
+++ b/platform/mv3/extension/js/ruleset-manager.js
@@ -561,7 +561,10 @@ async function filteringModesToDNR(modes) {
/******************************************************************************/
async function defaultRulesetsFromLanguage() {
- const out = await dnr.getEnabledRulesets();
+ const [ out, rulesetDetails ] = await Promise.all([
+ dnr.getEnabledRulesets(),
+ getRulesetDetails(),
+ ]);
const dropCountry = lang => {
const pos = lang.indexOf('-');
@@ -580,7 +583,6 @@ async function defaultRulesetsFromLanguage() {
`\\b(${Array.from(langSet).join('|')})\\b`
);
- const rulesetDetails = await getRulesetDetails();
for ( const [ id, details ] of rulesetDetails ) {
if ( typeof details.lang !== 'string' ) { continue; }
if ( reTargetLang.test(details.lang) === false ) { continue; }
diff --git a/platform/mv3/extension/js/strictblock.js b/platform/mv3/extension/js/strictblock.js
index 616ef6ddb..7600bcfbb 100644
--- a/platform/mv3/extension/js/strictblock.js
+++ b/platform/mv3/extension/js/strictblock.js
@@ -20,21 +20,15 @@
*/
import { dom, qs$ } from './dom.js';
+import { fetchJSON } from './fetch.js';
+import { getEnabledRulesetsDetails } from './ruleset-manager.js';
import { i18n$ } from './i18n.js';
import { sendMessage } from './ext.js';
+import { urlSkip } from './urlskip.js';
/******************************************************************************/
-const toURL = new URL('about:blank');
-
-function setURL(url) {
- try {
- toURL.href = url;
- } catch(_) {
- }
-}
-
-setURL(self.location.hash.slice(1));
+const rulesetDetailsPromise = getEnabledRulesetsDetails();
/******************************************************************************/
@@ -66,6 +60,13 @@ async function proceed() {
/******************************************************************************/
+const toURL = new URL('about:blank');
+
+try {
+ toURL.href = self.location.hash.slice(1);
+} catch(_) {
+}
+
dom.clear('#theURL > p > span:first-of-type');
qs$('#theURL > p > span:first-of-type').append(urlToFragment(toURL.href));
@@ -152,16 +153,92 @@ qs$('#theURL > p > span:first-of-type').append(urlToFragment(toURL.href));
/******************************************************************************/
+// Find which list caused the blocking.
+(async ( ) => {
+ const rulesetDetails = await rulesetDetailsPromise;
+ let iList = -1;
+ const searchInList = async i => {
+ if ( iList !== -1 ) { return; }
+ const hostnames = new Set(
+ await fetchJSON(`/rulesets/strictblock/${rulesetDetails[i].id}`)
+ );
+ if ( iList !== -1 ) { return; }
+ let hn = toURL.hostname;
+ for (;;) {
+ if ( hostnames.has(hn) ) { iList = i; break; }
+ const pos = hn.indexOf('.');
+ if ( pos === -1 ) { break; }
+ hn = hn.slice(pos+1);
+ }
+ };
+ const toFetch = [];
+ for ( let i = 0; i < rulesetDetails.length; i++ ) {
+ if ( rulesetDetails[i].rules.strictblock === 0 ) { continue; }
+ toFetch.push(searchInList(i));
+ }
+ if ( toFetch.length === 0 ) { return; }
+ await Promise.all(toFetch);
+ if ( iList === -1 ) { return; }
+
+ const fragment = new DocumentFragment();
+ const text = i18n$('strictblockReasonSentence1');
+ const placeholder = '{{listname}}';
+ const pos = text.indexOf(placeholder);
+ if ( pos === -1 ) { return; }
+ const q = document.createElement('q');
+ q.append(rulesetDetails[iList].name);
+ fragment.append(text.slice(0, pos), q, text.slice(pos + placeholder.length));
+ qs$('#reason').append(fragment);
+ dom.attr('#reason', 'hidden', null);
+})();
+
+/******************************************************************************/
+
+// Offer to skip redirection whenever possible
+(async ( ) => {
+ const rulesetDetails = await rulesetDetailsPromise;
+ const toFetch = [];
+ for ( const details of rulesetDetails ) {
+ if ( details.rules.urlskip === 0 ) { continue; }
+ toFetch.push(fetchJSON(`/rulesets/urlskip/${details.id}`));
+ }
+ if ( toFetch.length === 0 ) { return; }
+ const urlskipLists = await Promise.all(toFetch);
+ for ( const urlskips of urlskipLists ) {
+ for ( const urlskip of urlskips ) {
+ const re = new RegExp(urlskip.re, urlskip.c ? undefined : 'i');
+ if ( re.test(toURL.href) === false ) { continue; }
+ const finalURL = urlSkip(toURL.href, false, urlskip.steps);
+ if ( finalURL === undefined ) { continue; }
+ const fragment = new DocumentFragment();
+ const text = i18n$('strictblockRedirectSentence1');
+ const linkPlaceholder = '{{url}}';
+ const pos = text.indexOf(linkPlaceholder);
+ if ( pos === -1 ) { return; }
+ const link = document.createElement('a');
+ link.href = finalURL;
+ dom.cl.add(link, 'code');
+ link.append(urlToFragment(finalURL));
+ fragment.append(
+ text.slice(0, pos),
+ link,
+ text.slice(pos + linkPlaceholder.length)
+ );
+ qs$('#urlskip').append(fragment);
+ dom.attr('#urlskip', 'hidden', null);
+ return;
+ }
+ }
+})();
+
+/******************************************************************************/
+
// https://www.reddit.com/r/uBlockOrigin/comments/breeux/
if ( window.history.length > 1 ) {
- dom.on('#back', 'click', ( ) => {
- window.history.back();
- });
+ dom.on('#back', 'click', ( ) => { window.history.back(); });
qs$('#bye').style.display = 'none';
} else {
- dom.on('#bye', 'click', ( ) => {
- window.close();
- });
+ dom.on('#bye', 'click', ( ) => { window.close(); });
qs$('#back').style.display = 'none';
}
@@ -171,8 +248,8 @@ dom.on('#disableWarning', 'change', ev => {
dom.cl.toggle('[data-i18n="strictblockClose"]', 'disabled', checked);
});
-dom.on('#proceed', 'click', ( ) => {
- proceed();
-});
+dom.on('#proceed', 'click', ( ) => { proceed(); });
+
+dom.cl.remove(dom.body, 'loading');
/******************************************************************************/
diff --git a/platform/mv3/make-rulesets.js b/platform/mv3/make-rulesets.js
index b57006705..4ea7ae6db 100644
--- a/platform/mv3/make-rulesets.js
+++ b/platform/mv3/make-rulesets.js
@@ -225,7 +225,7 @@ const isRegex = rule =>
rule.condition.regexFilter !== undefined;
const isRedirect = rule => {
- if ( rule.action === undefined ) { return false; }
+ if ( isUnsupported(rule) ) { return false; }
if ( rule.action.type !== 'redirect' ) { return false; }
if ( rule.action.redirect?.extensionPath !== undefined ) { return true; }
if ( rule.action.redirect?.transform?.path !== undefined ) { return true; }
@@ -233,19 +233,26 @@ const isRedirect = rule => {
};
const isModifyHeaders = rule =>
- rule.action !== undefined &&
+ isUnsupported(rule) === false &&
rule.action.type === 'modifyHeaders';
const isRemoveparam = rule =>
- rule.action !== undefined &&
+ isUnsupported(rule) === false &&
rule.action.type === 'redirect' &&
rule.action.redirect.transform !== undefined;
-const isGood = rule =>
+const isSafe = rule =>
isUnsupported(rule) === false &&
- isRedirect(rule) === false &&
- isModifyHeaders(rule) === false &&
- isRemoveparam(rule) === false;
+ rule.action !== undefined && (
+ rule.action.type === 'block' ||
+ rule.action.type === 'allow' ||
+ rule.action.type === 'allowAllRequests'
+ );
+
+const isURLSkip = rule =>
+ isUnsupported(rule) === false &&
+ rule.action !== undefined &&
+ rule.action.type === 'urlskip';
/******************************************************************************/
@@ -357,7 +364,7 @@ async function processNetworkFilters(assetDetails, network) {
}
}
- const plainGood = rules.filter(rule => isGood(rule) && isRegex(rule) === false);
+ const plainGood = rules.filter(rule => isSafe(rule) && isRegex(rule) === false);
log(`\tPlain good: ${plainGood.length}`);
log(plainGood
.filter(rule => Array.isArray(rule._warning))
@@ -365,7 +372,7 @@ async function processNetworkFilters(assetDetails, network) {
.join('\n'), true
);
- const regexes = rules.filter(rule => isGood(rule) && isRegex(rule));
+ const regexes = rules.filter(rule => isSafe(rule) && isRegex(rule));
log(`\tMaybe good (regexes): ${regexes.length}`);
const redirects = rules.filter(rule =>
@@ -394,6 +401,22 @@ async function processNetworkFilters(assetDetails, network) {
);
log(`\tmodifyHeaders=: ${modifyHeaders.length}`);
+ const urlskips = rules.filter(rule => isURLSkip(rule)).filter(rule =>
+ rule.__modifierAction === 0 &&
+ rule.condition &&
+ rule.condition.regexFilter &&
+ rule.condition.resourceTypes &&
+ rule.condition.resourceTypes.includes('main_frame')
+ ).map(rule => {
+ const steps = rule.__modifierValue;
+ return {
+ re: rule.condition.regexFilter,
+ c: rule.condition.isUrlFilterCaseSensitive,
+ steps: steps.includes(' ') && steps.split(/ +/) || [ steps ],
+ };
+ });
+ log(`\turlskip=: ${urlskips.length}`);
+
const bad = rules.filter(rule =>
isUnsupported(rule)
);
@@ -460,6 +483,13 @@ async function processNetworkFilters(assetDetails, network) {
);
}
+ if ( urlskips.length !== 0 ) {
+ writeFile(
+ `${rulesetDir}/urlskip/${assetDetails.id}.json`,
+ JSON.stringify(urlskips, null, 1)
+ );
+ }
+
return {
total: rules.length,
plain: plainGood.length,
@@ -470,6 +500,7 @@ async function processNetworkFilters(assetDetails, network) {
redirect: redirects.length,
modifyHeaders: modifyHeaders.length,
strictblock: strictBlocked.size,
+ urlskip: urlskips.length,
};
}
@@ -1114,6 +1145,7 @@ async function rulesetFromURLs(assetDetails) {
redirect: netStats.redirect,
modifyHeaders: netStats.modifyHeaders,
strictblock: netStats.strictblock,
+ urlskip: netStats.urlskip,
discarded: netStats.discarded,
rejected: netStats.rejected,
},
@@ -1264,6 +1296,14 @@ async function main() {
});
// Handpicked rulesets from abroad
+ await rulesetFromURLs({
+ id: 'nrd.30day.phishing',
+ name: '30-day Phishing Domain List',
+ enabled: true,
+ urls: [ 'https://raw.githubusercontent.com/xRuffKez/NRD/refs/heads/main/lists/30-day_phishing/domains-only/nrd-phishing-30day.txt' ],
+ homeURL: 'https://github.com/xRuffKez/NRD?tab=readme-ov-file',
+ });
+
await rulesetFromURLs({
id: 'stevenblack-hosts',
name: 'Steven Black’s Unified Hosts (adware + malware)',
diff --git a/platform/mv3/strictblock.html b/platform/mv3/strictblock.html
index 9572eab09..8b6ef2d9b 100644
--- a/platform/mv3/strictblock.html
+++ b/platform/mv3/strictblock.html
@@ -10,20 +10,26 @@
-
+
+
+
+
+
+
+
diff --git a/src/js/messaging.js b/src/js/messaging.js
index d35a1347e..7cb5380f2 100644
--- a/src/js/messaging.js
+++ b/src/js/messaging.js
@@ -1884,7 +1884,11 @@ const onMessage = function(request, sender, callback) {
listPromises.push(
io.get(assetKey, { dontCache: true }).then(details => {
listNames.push(assetKey);
- return { name: assetKey, text: details.content };
+ return {
+ name: assetKey,
+ text: details.content,
+ trustedSource: assetKey.startsWith('ublock-'),
+ };
})
);
}
diff --git a/src/js/static-dnr-filtering.js b/src/js/static-dnr-filtering.js
index d3e7732a6..636390065 100644
--- a/src/js/static-dnr-filtering.js
+++ b/src/js/static-dnr-filtering.js
@@ -284,6 +284,7 @@ function addToDNR(context, list) {
toDNR: true,
nativeCssHas: env.includes('native_css_has'),
badTypes: [ sfp.NODE_TYPE_NET_OPTION_NAME_REDIRECTRULE ],
+ trustedSource: list.trustedSource || undefined,
});
const compiler = staticNetFilteringEngine.createCompiler();
diff --git a/src/js/static-net-filtering.js b/src/js/static-net-filtering.js
index f4769c2c2..f084180ff 100644
--- a/src/js/static-net-filtering.js
+++ b/src/js/static-net-filtering.js
@@ -28,6 +28,7 @@ import BidiTrieContainer from './biditrie.js';
import { CompiledListReader } from './static-filtering-io.js';
import { FilteringContext } from './filtering-context.js';
import HNTrieContainer from './hntrie.js';
+import { urlSkip } from './urlskip.js';
/******************************************************************************/
@@ -4679,6 +4680,25 @@ StaticNetFilteringEngine.prototype.dnrFromCompiled = function(op, context, ...ar
dnrAddRuleError(rule, `Incompatible with DNR: uritransform=${rule.__modifierValue}`);
break;
}
+ case 'urlskip': {
+ let urlFilter = rule.condition?.urlFilter;
+ if ( urlFilter === undefined ) { break; }
+ let anchor = 0b000;
+ if ( urlFilter.startsWith('||') ) {
+ anchor |= 0b100;
+ urlFilter = urlFilter.slice(2);
+ } else if ( urlFilter.startsWith('|') ) {
+ anchor |= 0b10;
+ urlFilter = urlFilter.slice(1);
+ }
+ if ( urlFilter.endsWith('|') ) {
+ anchor |= 0b001;
+ urlFilter = urlFilter.slice(0, -1);
+ }
+ rule.condition.urlFilter = undefined;
+ rule.condition.regexFilter = restrFromGenericPattern(urlFilter, anchor);
+ break;
+ }
default:
dnrAddRuleError(rule, `Unsupported modifier ${rule.__modifierType}`);
break;
@@ -5391,57 +5411,6 @@ StaticNetFilteringEngine.prototype.transformRequest = function(fctxt, out = [])
return out;
};
-/**
- * @trustedOption urlskip
- *
- * @description
- * Extract a URL from another URL according to one or more transformation steps,
- * thereby skipping over intermediate network request(s) to remote servers.
- * Requires a trusted source.
- *
- * @param steps
- * A serie of space-separated directives representing the transformation steps
- * to perform to extract the final URL to which a network request should be
- * redirected.
- *
- * Supported directives:
- *
- * `?name`: extract the value of parameter `name` as the current string.
- *
- * `&i`: extract the name of the parameter at position `i` as the current
- * string. The position is 1-based.
- *
- * `/.../`: extract the first capture group of a regex as the current string.
- *
- * `+https`: prepend the current string with `https://`.
- *
- * `-base64`: decode the current string as a base64-encoded string.
- *
- * `-safebase64`: decode the current string as a safe base64-encoded string.
- *
- * `-uricomponent`: decode the current string as a URI encoded string.
- *
- * `-blocked`: allow the redirection of blocked requests. By default, blocked
- * requests can't by urlskip'ed.
- *
- * At any given step, the currently extracted string may not necessarily be
- * a valid URL, and more transformation steps may be needed to obtain a valid
- * URL once all the steps are applied.
- *
- * An unsupported step or a failed step will abort the transformation and no
- * redirection will be performed.
- *
- * The final step is expected to yield a valid URL. If the result is not a
- * valid URL, no redirection will be performed.
- *
- * @example
- * ||example.com/path/to/tracker$urlskip=?url
- * ||example.com/path/to/tracker$urlskip=?url ?to
- * ||pixiv.net/jump.php?$urlskip=&1
- * ||podtrac.com/pts/redirect.mp3/$urlskip=/\/redirect\.mp3\/(.*?\.mp3\b)/ +https
- *
- * */
-
StaticNetFilteringEngine.prototype.urlSkip = function(
fctxt,
blocked,
@@ -5458,7 +5427,7 @@ StaticNetFilteringEngine.prototype.urlSkip = function(
const urlin = fctxt.url;
const value = directive.value;
const steps = value.includes(' ') && value.split(/ +/) || [ value ];
- const urlout = urlSkip(directive, urlin, blocked, steps);
+ const urlout = urlSkip(urlin, blocked, steps, directive);
if ( urlout === undefined ) { continue; }
if ( urlout === urlin ) { continue; }
fctxt.redirectURL = urlout;
@@ -5469,91 +5438,6 @@ StaticNetFilteringEngine.prototype.urlSkip = function(
return out;
};
-function urlSkip(directive, url, blocked, steps) {
- try {
- let redirectBlocked = false;
- let urlout = url;
- for ( const step of steps ) {
- const urlin = urlout;
- const c0 = step.charCodeAt(0);
- // Extract from URL parameter name at position i
- if ( c0 === 0x26 ) { // &
- const i = (parseInt(step.slice(1)) || 0) - 1;
- if ( i < 0 ) { return; }
- const url = new URL(urlin);
- if ( i >= url.searchParams.size ) { return; }
- const params = Array.from(url.searchParams.keys());
- urlout = decodeURIComponent(params[i]);
- continue;
- }
- // Enforce https
- if ( c0 === 0x2B && step === '+https' ) {
- const s = urlin.replace(/^https?:\/\//, '');
- if ( /^[\w-]:\/\//.test(s) ) { return; }
- urlout = `https://${s}`;
- continue;
- }
- // Decode
- if ( c0 === 0x2D ) {
- // Base64
- if ( step === '-base64' ) {
- urlout = self.atob(urlin);
- continue;
- }
- // Safe Base64
- if ( step === '-safebase64' ) {
- urlout = urlin.replace(/[-_]/g, safeBase64Replacer);
- urlout = self.atob(urlout);
- continue;
- }
- // URI component
- if ( step === '-uricomponent' ) {
- urlout = self.decodeURIComponent(urlin);
- continue;
- }
- // Enable skip of blocked requests
- if ( step === '-blocked' ) {
- redirectBlocked = true;
- continue;
- }
- }
- // Regex extraction from first capture group
- if ( c0 === 0x2F ) { // /
- if ( directive.cache === null ) {
- directive.cache = new RegExp(step.slice(1, -1));
- }
- const match = directive.cache.exec(urlin);
- if ( match === null ) { return; }
- if ( match.length <= 1 ) { return; }
- urlout = match[1];
- continue;
- }
- // Extract from URL parameter
- if ( c0 === 0x3F ) { // ?
- urlout = (new URL(urlin)).searchParams.get(step.slice(1));
- if ( urlout === null ) { return; }
- if ( urlout.includes(' ') ) {
- urlout = urlout.replace(/ /g, '%20');
- }
- continue;
- }
- // Unknown directive
- return;
- }
- const urlfinal = new URL(urlout);
- if ( urlfinal.protocol !== 'https:' ) {
- if ( urlfinal.protocol !== 'http:' ) { return; }
- urlout = urlout.replace('http', 'https');
- }
- if ( blocked && redirectBlocked !== true ) { return; }
- return urlout;
- } catch(x) {
- }
-}
-
-const safeBase64Map = { '-': '+', '_': '/' };
-const safeBase64Replacer = s => safeBase64Map[s];
-
/******************************************************************************/
// https://github.com/uBlockOrigin/uBlock-issues/issues/1626
diff --git a/src/js/urlskip.js b/src/js/urlskip.js
new file mode 100644
index 000000000..e4182bbc4
--- /dev/null
+++ b/src/js/urlskip.js
@@ -0,0 +1,157 @@
+/*******************************************************************************
+
+ uBlock Origin - a comprehensive, efficient content blocker
+ Copyright (C) 2022-present Raymond Hill
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see {http://www.gnu.org/licenses/}.
+
+ Home: https://github.com/gorhill/uBlock
+*/
+
+const safeBase64Map = { '-': '+', '_': '/' };
+const safeBase64Replacer = s => safeBase64Map[s];
+
+/**
+ * @trustedOption urlskip
+ *
+ * @description
+ * Extract a URL from another URL according to one or more transformation steps,
+ * thereby skipping over intermediate network request(s) to remote servers.
+ * Requires a trusted source.
+ *
+ * @param steps
+ * A serie of space-separated directives representing the transformation steps
+ * to perform to extract the final URL to which a network request should be
+ * redirected.
+ *
+ * Supported directives:
+ *
+ * `?name`: extract the value of parameter `name` as the current string.
+ *
+ * `&i`: extract the name of the parameter at position `i` as the current
+ * string. The position is 1-based.
+ *
+ * `/.../`: extract the first capture group of a regex as the current string.
+ *
+ * `+https`: prepend the current string with `https://`.
+ *
+ * `-base64`: decode the current string as a base64-encoded string.
+ *
+ * `-safebase64`: decode the current string as a safe base64-encoded string.
+ *
+ * `-uricomponent`: decode the current string as a URI encoded string.
+ *
+ * `-blocked`: allow the redirection of blocked requests. By default, blocked
+ * requests can't by urlskip'ed.
+ *
+ * At any given step, the currently extracted string may not necessarily be
+ * a valid URL, and more transformation steps may be needed to obtain a valid
+ * URL once all the steps are applied.
+ *
+ * An unsupported step or a failed step will abort the transformation and no
+ * redirection will be performed.
+ *
+ * The final step is expected to yield a valid URL. If the result is not a
+ * valid URL, no redirection will be performed.
+ *
+ * @example
+ * ||example.com/path/to/tracker$urlskip=?url
+ * ||example.com/path/to/tracker$urlskip=?url ?to
+ * ||pixiv.net/jump.php?$urlskip=&1
+ * ||podtrac.com/pts/redirect.mp3/$urlskip=/\/redirect\.mp3\/(.*?\.mp3\b)/ +https
+ *
+ * */
+
+export function urlSkip(url, blocked, steps, directive = {}) {
+ try {
+ let redirectBlocked = false;
+ let urlout = url;
+ for ( const step of steps ) {
+ const urlin = urlout;
+ const c0 = step.charCodeAt(0);
+ // Extract from URL parameter name at position i
+ if ( c0 === 0x26 ) { // &
+ const i = (parseInt(step.slice(1)) || 0) - 1;
+ if ( i < 0 ) { return; }
+ const url = new URL(urlin);
+ if ( i >= url.searchParams.size ) { return; }
+ const params = Array.from(url.searchParams.keys());
+ urlout = decodeURIComponent(params[i]);
+ continue;
+ }
+ // Enforce https
+ if ( c0 === 0x2B && step === '+https' ) {
+ const s = urlin.replace(/^https?:\/\//, '');
+ if ( /^[\w-]:\/\//.test(s) ) { return; }
+ urlout = `https://${s}`;
+ continue;
+ }
+ // Decode
+ if ( c0 === 0x2D ) {
+ // Base64
+ if ( step === '-base64' ) {
+ urlout = self.atob(urlin);
+ continue;
+ }
+ // Safe Base64
+ if ( step === '-safebase64' ) {
+ urlout = urlin.replace(/[-_]/g, safeBase64Replacer);
+ urlout = self.atob(urlout);
+ continue;
+ }
+ // URI component
+ if ( step === '-uricomponent' ) {
+ urlout = self.decodeURIComponent(urlin);
+ continue;
+ }
+ // Enable skip of blocked requests
+ if ( step === '-blocked' ) {
+ redirectBlocked = true;
+ continue;
+ }
+ }
+ // Regex extraction from first capture group
+ if ( c0 === 0x2F ) { // /
+ const re = directive.cache ?? new RegExp(step.slice(1, -1));
+ if ( directive.cache === null ) {
+ directive.cache = re;
+ }
+ const match = re.exec(urlin);
+ if ( match === null ) { return; }
+ if ( match.length <= 1 ) { return; }
+ urlout = match[1];
+ continue;
+ }
+ // Extract from URL parameter
+ if ( c0 === 0x3F ) { // ?
+ urlout = (new URL(urlin)).searchParams.get(step.slice(1));
+ if ( urlout === null ) { return; }
+ if ( urlout.includes(' ') ) {
+ urlout = urlout.replace(/ /g, '%20');
+ }
+ continue;
+ }
+ // Unknown directive
+ return;
+ }
+ const urlfinal = new URL(urlout);
+ if ( urlfinal.protocol !== 'https:' ) {
+ if ( urlfinal.protocol !== 'http:' ) { return; }
+ urlout = urlout.replace('http', 'https');
+ }
+ if ( blocked && redirectBlocked !== true ) { return; }
+ return urlout;
+ } catch(x) {
+ }
+}
diff --git a/tools/make-mv3.sh b/tools/make-mv3.sh
index 287a7b08b..5c812ec21 100755
--- a/tools/make-mv3.sh
+++ b/tools/make-mv3.sh
@@ -76,6 +76,7 @@ cp "$UBO_DIR"/src/css/fa-icons.css "$DES"/css/
cp "$UBO_DIR"/src/js/dom.js "$DES"/js/
cp "$UBO_DIR"/src/js/fa-icons.js "$DES"/js/
cp "$UBO_DIR"/src/js/i18n.js "$DES"/js/
+cp "$UBO_DIR"/src/js/urlskip.js "$DES"/js/
cp "$UBO_DIR"/src/lib/punycode.js "$DES"/js/
cp -R "$UBO_DIR/src/img/flags-of-the-world" "$DES"/img
diff --git a/tools/make-nodejs.sh b/tools/make-nodejs.sh
index 87e96ddad..ed8db6cbb 100755
--- a/tools/make-nodejs.sh
+++ b/tools/make-nodejs.sh
@@ -22,6 +22,7 @@ cp src/js/static-net-filtering.js $DES/js
cp src/js/static-filtering-io.js $DES/js
cp src/js/tasks.js $DES/js
cp src/js/text-utils.js $DES/js
+cp src/js/urlskip.js $DES/js
cp src/js/uri-utils.js $DES/js
cp src/js/url-net-filtering.js $DES/js