Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338/* * Reroute — background script. * * This file is the only place where rules take effect. It reads the rule list * from extension storage and compiles it into (a) declarativeNetRequest (DNR) * dynamic rules and (b) a single dynamically-registered content script for * "param-redirect" rules. The browser then performs the redirects natively: * no request data passes through extension code for DNR rules, nothing is * logged, and nothing is sent anywhere. */
"use strict";
// Shared constants, default state, migration and pure helpers. Firefox 154// removed importScripts() from MV3 background contexts (backgrounds must be// ES modules now), so this file is imported as a module and its API is read// back from the globalThis bridge that common.js installs. common.js itself// stays a classic script for the options/popup/go pages and the content// script, which cannot be modules.import "./common.js";const { api, migrateState, hostPool, isValidMatchPattern, acceptHosts } = globalThis.__rerouteCommon || {};
// Explicit resource-type lists (defaults differ between browsers, so we never// rely on them). "Pages only" is the safe default: it rewrites the URL you// navigate to, without breaking embedded widgets/scripts on other sites.const PAGE_TYPES = ["main_frame"];const ALL_TYPES = [ "main_frame", "sub_frame", "stylesheet", "script", "image", "font", "object", "xmlhttprequest", "ping", "media", "websocket", "other",];
// The single dynamically-registered content script that implements every// enabled "param-redirect" rule. Its `matches` is the union of all active// rules' patterns, so it only ever runs on URLs the user configured it for.const PARAM_SCRIPT_ID = "param-redirect";const PARAM_SCRIPT_FILES = ["common.js", "param-redirect.js"];
async function getState() { const stored = await api.storage.local.get("state"); return stored.state || null;}
function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");}
/** Compile a host-swap rule ("host" type) into a DNR rule. */function compileHostRule(rule, dnrId) { const pool = hostPool(rule); if (pool.length === 0) throw new Error("Missing destination host"); for (const h of pool) { if (!/^[a-z0-9.-]+(:\d+)?$/i.test(h)) { throw new Error('"' + h + '" is not a bare hostname like example.com'); } } const from = (rule.fromDomains || []) .map((d) => String(d).trim().toLowerCase()) .filter(Boolean); if (from.length === 0) throw new Error("No source domains"); for (const h of pool) { const bare = h.toLowerCase().replace(/:\d+$/, ""); if (from.some((d) => bare === d || bare.endsWith("." + d))) { throw new Error('Destination "' + h + '" is covered by a source domain (would loop)'); } }
if (pool.length === 1) { // Single destination: a direct, zero-hop host transform. return { id: dnrId, priority: 1, action: { type: "redirect", // `transform` swaps only the host (and forces https); path, query and // fragment are preserved by the browser. redirect: { transform: { host: pool[0], scheme: "https" } }, }, condition: { // Matches these domains and all their subdomains. requestDomains: from, resourceTypes: rule.allRequests ? ALL_TYPES : PAGE_TYPES, }, }; }
// Pool of destinations: DNR rules are static, so per-visit round-robin is // done by bouncing through the local go.html page, which picks the next // host from storage and immediately location.replace()s to it. The full // original URL rides along in the fragment (request URLs never contain a // fragment, so \0 is unambiguous). Pools are navigation-only: subresources // like images can't be served by an extension page. const domainAlt = from.map(escapeRegex).join("|"); const pattern = "^https?://(?:[^./]+\\.)*(?:" + domainAlt + ")(?::\\d+)?(?:/.*)?$"; return { id: dnrId, priority: 1, action: { type: "redirect", redirect: { regexSubstitution: api.runtime.getURL("go.html") + "#\\0" }, }, condition: { regexFilter: pattern, isUrlFilterCaseSensitive: false, resourceTypes: PAGE_TYPES, }, };}
/** Compile a regex rewrite rule ("regex" type) into a DNR rule. */async function compileRegexRule(rule, dnrId) { const pattern = String(rule.pattern || ""); const substitution = String(rule.substitution || ""); if (!pattern) throw new Error("Missing pattern"); if (!substitution) throw new Error("Missing substitution");
// DNR regexes must be RE2-compatible; ask the browser instead of guessing. const check = await api.declarativeNetRequest.isRegexSupported({ regex: pattern, isCaseSensitive: false, requireCapturing: /\\\d/.test(substitution), }); if (!check.isSupported) { throw new Error("Pattern rejected by browser (" + (check.reason || "not RE2-compatible") + ")"); }
return { id: dnrId, priority: 1, action: { type: "redirect", // Substitution uses \1 … \9 for capture groups, \0 for the whole match. redirect: { regexSubstitution: substitution }, }, condition: { regexFilter: pattern, isUrlFilterCaseSensitive: false, resourceTypes: rule.allRequests ? ALL_TYPES : PAGE_TYPES, }, };}
/** * Compile an accept-header rule ("accept" type) into a DNR modifyHeaders rule. * By default it applies to main_frame navigations only, so it can't mangle * the Accept header of subresources (images, scripts) on other sites. */function compileAcceptRule(rule, dnrId) { const hosts = acceptHosts(rule); if (hosts.length === 0) throw new Error("No source hosts"); const header = String(rule.header || "Accept").trim(); const value = String(rule.value == null ? "" : rule.value).trim(); if (!header) throw new Error("Missing header name"); if (!value) throw new Error("Missing header value"); return { id: dnrId, priority: 1, action: { type: "modifyHeaders", requestHeaders: [ { header: header.toLowerCase(), operation: "set", value }, ], }, condition: { requestDomains: hosts, resourceTypes: rule.allRequests ? ALL_TYPES : PAGE_TYPES, }, };}
/** * Keep the single registered content script in sync with the enabled * param-redirect rules. `active` is the (ordered) list of * {pattern, param, allowedHosts} for rules that should currently run. */async function syncParamContentScript(active) { const patterns = active.map((r) => r.pattern).filter(Boolean); const id = PARAM_SCRIPT_ID;
let registered = false; try { const existing = await api.scripting.getRegisteredContentScripts(); registered = existing.some((s) => s.id === id); } catch (_) { registered = false; }
if (patterns.length === 0) { if (registered) { await api.scripting.unregisterContentScripts({ ids: [id] }); } return; }
const desc = { id, js: PARAM_SCRIPT_FILES, matches: patterns, runAt: "document_start", };
if (registered) { try { await api.scripting.updateContentScripts([desc]); return; } catch (_) { // update only works if the script is actually present; fall through to // a clean re-register if the browser disagrees with our bookkeeping. try { await api.scripting.unregisterContentScripts({ ids: [id] }); } catch (_) { /* ignore */ } } } await api.scripting.registerContentScripts([desc]);}
/** * Rebuild the entire dynamic rule set from storage. Rules that fail to * compile are skipped and their error is written to storage.ruleErrors so the * options page can surface it — one bad rule never disables the rest. */async function rebuild() { const state = (await ensureState()).state; const errors = {}; const addRules = []; const activeParam = [];
if (state.enabled) { let nextId = 1; for (const rule of state.rules || []) { if (!rule.enabled) continue;
if (rule.type === "param-redirect") { // Content-script rule: not a DNR rule. Only included if its match // pattern is a valid content-script match pattern (registering an // invalid one would reject the whole batch). if (!isValidMatchPattern(rule.pattern)) { errors[rule.id] = "Not a valid match pattern like *://host/path*"; continue; } activeParam.push({ pattern: rule.pattern, param: rule.param || "url", allowedHosts: (rule.allowedHosts || []).map(String), }); continue; }
try { const dnr = rule.type === "regex" ? await compileRegexRule(rule, nextId) : rule.type === "accept" ? compileAcceptRule(rule, nextId) : compileHostRule(rule, nextId); dnr._sourceId = rule.id; // stripped before the API call addRules.push(dnr); nextId++; } catch (e) { errors[rule.id] = e && e.message ? e.message : String(e); } } }
const sourceIds = addRules.map((r) => r._sourceId || null); for (const r of addRules) delete r._sourceId;
const existing = await api.declarativeNetRequest.getDynamicRules(); const removeRuleIds = existing.map((r) => r.id);
try { await api.declarativeNetRequest.updateDynamicRules({ removeRuleIds, addRules }); } catch (e) { // A whole-batch failure (shouldn't normally happen after validation): // clear everything, then re-add one by one so a single bad rule can't // take the others down with it. await api.declarativeNetRequest.updateDynamicRules({ removeRuleIds }); for (let i = 0; i < addRules.length; i++) { try { await api.declarativeNetRequest.updateDynamicRules({ addRules: [addRules[i]] }); } catch (err) { if (sourceIds[i]) errors[sourceIds[i]] = err && err.message ? err.message : String(err); } } }
// Make the current param-redirect rules available to the content script. // Writing "paramRules" (not "state") also means this cannot trigger a loop. await api.storage.local.set({ paramRules: activeParam }); try { await syncParamContentScript(activeParam); } catch (e) { // A failed registration shouldn't take the DNR rules down with it. const msg = e && e.message ? e.message : String(e); for (const r of activeParam) { if (!errors[r.id]) errors[r.id] = "Content script: " + msg; } }
await api.storage.local.set({ ruleErrors: errors }); await updateBadge(state);}
/** * Load state, running the v1 → v2 migration if needed, and persist the result * only when something changed (so a once-migrated state stays put). */async function ensureState() { const stored = await getState(); const { state, changed } = migrateState(stored); if (changed) await api.storage.local.set({ state }); return { state };}
async function updateBadge(state) { try { await api.action.setBadgeText({ text: state.enabled ? "" : "off" }); if (api.action.setBadgeBackgroundColor) { await api.action.setBadgeBackgroundColor({ color: "#8a8f98" }); } } catch (_) { /* badge is cosmetic; ignore */ }}
// IMPORTANT: every listener returns the rebuild() promise. Firefox MV3 event// pages suspend once a listener settles without pending work; a fire-and-forget// rebuild() (listener returns undefined) can be killed mid-flight — the seed// write or the DNR update never lands, so a fresh install shows no builtins and// no redirect rules fire. Returning the promise keeps the page alive until the// work completes.api.runtime.onInstalled.addListener(() => rebuild());api.runtime.onStartup.addListener(() => rebuild());api.storage.onChanged.addListener((changes, area) => { // Note: rebuild() writes "ruleErrors" and "paramRules", not "state", so this // cannot loop on our own writes. if (area === "local" && changes.state) return rebuild();});