-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbackground.js
More file actions
308 lines (260 loc) · 12.6 KB
/
Copy pathbackground.js
File metadata and controls
308 lines (260 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
/**
* Redirect Resolver — Background Script
*
* Reads domain patterns from redirects.json (bundled config) and from
* browser.storage.local (user overrides). Registers a blocking webRequest
* listener for all enabled patterns. When the user edits the domain list
* via the popup the listener is torn down and rebuilt automatically.
*
* Flow per intercepted request:
* 1. Main-frame navigation to a matched URL is intercepted & cancelled.
* 2. Tab is redirected to resolving.html?token=…&url=… immediately.
* 3. Background follows the redirect chain via fetch(redirect:"manual").
* 4. resolving.html polls via runtime.sendMessage {type:"CHECK_RESOLUTION"}.
* 5. Once done, resolving.html shows the result and three action buttons.
* The user chooses where to go — no auto-navigation.
*/
// ── Job / cache / guard stores ────────────────────────────────────────────
const jobs = new Map(); // token → { done, url }
const resolvedCache = new Map(); // original url → final url (session)
const inFlight = new Set(); // "tabId:url" keys currently being handled
// ── Active listener handle (so we can remove & re-add it) ─────────────────
let currentListener = null;
let currentPatterns = []; // the URL patterns the listener is bound to
// ── Token generator ───────────────────────────────────────────────────────
function makeToken() {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// ─────────────────────────────────────────────────────────────────────────
// CONFIG LOADING
// Merges bundled redirects.json with any user overrides stored locally.
// User overrides win: they can toggle/add/remove entries.
// ─────────────────────────────────────────────────────────────────────────
/**
* Load bundled redirects.json.
* Returns the parsed { domains: [...] } object.
*/
async function loadBundledConfig() {
try {
const url = browser.runtime.getURL("redirects.json");
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
console.error("[resolver] failed to load redirects.json:", err);
return { domains: [] };
}
}
/**
* Merge bundled config with user overrides from storage.
*
* Storage key "userDomains" holds an array of domain objects that completely
* replaces the bundled list when present. This lets the popup editor write
* the full list without knowing about the bundled defaults.
*
* Returns an array of { label, pattern, enabled } objects.
*/
async function loadDomains() {
const stored = await browser.storage.local.get({ userDomains: null });
if (stored.userDomains !== null) {
// User has customised the list — use it entirely
return stored.userDomains;
}
// No customisation yet — use the bundled defaults
const cfg = await loadBundledConfig();
return cfg.domains || [];
}
// ─────────────────────────────────────────────────────────────────────────
// LISTENER MANAGEMENT
// ─────────────────────────────────────────────────────────────────────────
function buildListener(patterns) {
return (details) => {
const { url, tabId } = details;
// Loop guard
const guardKey = `${tabId}:${url}`;
if (inFlight.has(guardKey)) return {};
inFlight.add(guardKey);
const token = makeToken();
jobs.set(token, { done: false, url });
const resolvingPage =
browser.runtime.getURL("resolving.html") +
"?token=" + encodeURIComponent(token) +
"&url=" + encodeURIComponent(url);
browser.tabs.update(tabId, { url: resolvingPage }).catch(() => {});
resolveUrl(url)
.then(finalUrl => {
console.log(`[resolver] ${url} → ${finalUrl}`);
jobs.set(token, { done: true, url: finalUrl });
recordResolution(url, finalUrl);
setTimeout(() => jobs.delete(token), 120_000);
})
.catch(err => {
console.error("[resolver] resolution error:", err);
jobs.set(token, { done: true, url }); // fail open: send to original
})
.finally(() => {
inFlight.delete(guardKey);
});
return { cancel: true };
};
}
/**
* (Re)register the webRequest listener for the given URL patterns.
* Safely removes the previous listener first.
*/
function applyListener(patterns) {
// Remove old listener if any
if (currentListener) {
try {
browser.webRequest.onBeforeRequest.removeListener(currentListener);
} catch { /* already gone */ }
currentListener = null;
currentPatterns = [];
}
if (!patterns.length) {
console.log("[resolver] no enabled patterns — listener not registered.");
return;
}
currentListener = buildListener(patterns);
currentPatterns = patterns;
browser.webRequest.onBeforeRequest.addListener(
currentListener,
{ urls: patterns, types: ["main_frame"] },
["blocking"]
);
console.log("[resolver] listening on:", patterns);
}
/**
* Load config and (re)apply the listener. Called at startup and whenever
* the domain list changes.
*/
async function reloadConfig() {
const domains = await loadDomains();
const patterns = domains
.filter(d => d.enabled)
.map(d => d.pattern);
applyListener(patterns);
}
// ─────────────────────────────────────────────────────────────────────────
// REDIRECT RESOLUTION
// ─────────────────────────────────────────────────────────────────────────
// Helper to safely resolve relative URLs
const safeResolve = (dest, base) => {
try { return new URL(dest, base).href; }
catch { return dest; }
};
async function resolveUrl(startUrl, maxHops = 10) {
if (resolvedCache.has(startUrl)) return resolvedCache.get(startUrl);
let current = startUrl;
let hops = 0;
while (hops < maxHops) {
hops++;
let response;
try {
response = await fetch(current, {
method: "GET",
redirect: "follow", // Let the browser handle HTTP 3xx hops natively and securely
credentials: "omit",
cache: "no-store",
// No custom headers — let the browser send its natural defaults.
// Shorteners like t.co fingerprint the User-Agent and return an HTML
// soft-redirect instead of a clean 3xx when they detect a spoofed UA.
});
} catch (err) {
console.warn(`[resolver] fetch error hop ${hops} for ${current}:`, err);
break;
}
// response.url automatically contains the final URL after all HTTP 3xx redirects
if (response.url && response.url !== current) {
current = response.url;
continue;
}
const { status, headers } = response;
// Handle client-side redirects (Meta refresh / JS) inside 2xx HTML responses
if (status >= 200 && status < 300) {
const ct = headers.get("content-type") || "";
if (!ct.includes("text/html")) break; // Found final non-HTML destination
try {
// Use AbortController to cleanly handle timeouts without memory leaks
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const text = await response.text({ signal: controller.signal });
clearTimeout(timeoutId);
// Compress whitespace and truncate to prevent regex hanging on massive DOMs
const compact = text.slice(0, 50000).replace(/\s+/g, " ");
// 1. Meta refresh check
const metaMatch = compact.match(/<meta[^>]+http-equiv=["']?refresh["']?[^>]+content=["']?\d+;\s*url=([^"'>\s]+)/i) ||
compact.match(/<meta[^>]+content=["']?\d+;\s*url=([^"'>\s]+)[^>]+http-equiv=["']?refresh["']?/i);
if (metaMatch) {
current = safeResolve(metaMatch[1].replace(/["']/g, "").trim(), current);
continue;
}
// 2. JS location check
const jsMatch = compact.match(/window\.location(?:\.href)?\s*=\s*["']([^"'`]+)["']|location\.replace\s*\(\s*["']([^"'`]+)["']\s*\)/i);
if (jsMatch) {
current = safeResolve((jsMatch[1] || jsMatch[2]).trim(), current);
continue;
}
} catch (parseErr) {
console.warn("[resolver] body parse error:", parseErr);
}
break; // It's HTML, but no client-side redirects were found.
}
break; // Break on 4xx/5xx
}
resolvedCache.set(startUrl, current);
return current;
}
// ─────────────────────────────────────────────────────────────────────────
// STATS
// ─────────────────────────────────────────────────────────────────────────
async function recordResolution(original, resolved) {
try {
const data = await browser.storage.local.get({ stats: { total: 0, recent: [] } });
const stats = data.stats;
stats.total += 1;
stats.recent.unshift({ original, resolved, ts: Date.now() });
if (stats.recent.length > 50) stats.recent.length = 50;
await browser.storage.local.set({ stats });
} catch { /* non-fatal */ }
}
// ─────────────────────────────────────────────────────────────────────────
// MESSAGE BUS
// ─────────────────────────────────────────────────────────────────────────
browser.runtime.onMessage.addListener((msg, _sender) => {
// resolving.html polls for job completion
if (msg?.type === "CHECK_RESOLUTION") {
const job = jobs.get(msg.token);
return Promise.resolve(job ?? { done: false });
}
// popup requests the current domain list
if (msg?.type === "GET_DOMAINS") {
return loadDomains().then(domains => ({ domains }));
}
// popup pushes an updated domain list
if (msg?.type === "SET_DOMAINS") {
return browser.storage.local
.set({ userDomains: msg.domains })
.then(() => reloadConfig())
.then(() => ({ ok: true }));
}
// popup requests the bundled defaults (for a "reset" action)
if (msg?.type === "GET_DEFAULTS") {
return loadBundledConfig().then(cfg => ({ domains: cfg.domains || [] }));
}
});
// ─────────────────────────────────────────────────────────────────────────
// STORAGE CHANGE WATCHER
// Re-applies the listener if userDomains changes (e.g. from another tab).
// ─────────────────────────────────────────────────────────────────────────
browser.storage.onChanged.addListener((changes, area) => {
if (area === "local" && "userDomains" in changes) {
reloadConfig();
}
});
// ─────────────────────────────────────────────────────────────────────────
// BOOT
// ─────────────────────────────────────────────────────────────────────────
reloadConfig().then(() => {
console.log("[resolver] background script ready.");
});