proxies.ts raw
1 /**
2 * Privacy-redirect destinations. When enabled, Warden rewrites the
3 * URL just before launch so a tracked-host (Twitter, X, YouTube, …)
4 * is opened through a privacy-respecting front-end (Nitter, Invidious,
5 * Piped, …). Path / query / hash are preserved — only the host is
6 * swapped.
7 *
8 * The matching host list, per destination, includes every variant
9 * Warden will hand off (e.g. `twitter.com`, `x.com`, plus subdomains
10 * via the suffix check). Add a new destination by appending here +
11 * giving it a stable `id` (used as a settings-key fragment).
12 */
13
14 export type ProxyInstance = {
15 /** Bare hostname of the proxy instance — no scheme, no trailing slash. */
16 host: string;
17 /** Optional human label. Falls back to the hostname when omitted. */
18 label?: string;
19 };
20
21 export type ProxyDestination = {
22 /** Stable id used in settings prefs — never renamed. */
23 id: string;
24 /** Display label for the picker. */
25 label: string;
26 /** Hostname suffixes that route to this proxy. Subdomain-aware. */
27 matches: string[];
28 /** Default instance list. Order = fallback priority when randomize is off. */
29 instances: ProxyInstance[];
30 /** Optional in-place URL rewrite applied BEFORE the host swap.
31 * Use when paths differ between the original site and the proxy
32 * (e.g. youtu.be uses short paths, Invidious wants /watch?v=…). */
33 rewritePath?: (u: URL) => void;
34 };
35
36 export const PROXY_DESTINATIONS: ProxyDestination[] = [
37 {
38 id: 'twitter',
39 label: 'Twitter / X',
40 matches: ['twitter.com', 'x.com'],
41 instances: [
42 { host: 'nitter.net' },
43 { host: 'nitter.catsarch.com' },
44 { host: 'nitter.tiekoetter.com' },
45 { host: 'nitter.privacyredirect.com' },
46 { host: 'xcancel.com' },
47 { host: 'nitter.kareem.one', label: 'kareem.one' },
48 { host: 'nuku.trabun.org', label: 'nuku' },
49 { host: 'twiiit.com', label: 'twiiit' },
50 { host: 'twitterviewer.net', label: 'twitterviewer' },
51 ],
52 },
53 {
54 id: 'youtube',
55 label: 'YouTube',
56 matches: ['youtube.com', 'youtu.be'],
57 instances: [
58 { host: 'inv.nadeko.net' },
59 { host: 'invidious.tiekoetter.com' },
60 { host: 'invidious.nerdvpn.de', label: 'nerdvpn' },
61 ],
62 rewritePath: (u) => {
63 const host = u.hostname.toLowerCase();
64 if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
65 const id = u.pathname.replace(/^\/+/, '').split('/')[0];
66 if (id && !u.searchParams.has('v')) {
67 u.pathname = '/watch';
68 u.searchParams.set('v', id);
69 }
70 }
71 },
72 },
73 {
74 id: 'reddit',
75 label: 'Reddit',
76 matches: ['reddit.com'],
77 instances: [
78 { host: 'redlib.catsarch.com' },
79 { host: 'redlib.tiekoetter.com' },
80 { host: 'redlib.nadeko.net', label: 'nadeko' },
81 { host: 'redlib.privacyredirect.com', label: 'privacyredirect' },
82 { host: 'redlib.privadency.com', label: 'privadency' },
83 { host: 'safereddit.com', label: 'safereddit (SFW)' },
84 { host: 'red.artemislena.eu', label: 'artemislena' },
85 { host: 'redlib.r4fo.com', label: 'r4fo' },
86 { host: 'redlib.cow.rip', label: 'cow.rip' },
87 ],
88 },
89 {
90 id: 'bluesky',
91 label: 'Bluesky',
92 matches: ['bsky.app'],
93 instances: [
94 { host: 'fxbsky.app', label: 'fxbsky (embed)' },
95 { host: 'skylib.coffee', label: 'skylib' },
96 { host: 'skylib.catsarch.com', label: 'skylib (catsarch)' },
97 ],
98 },
99 {
100 id: 'instagram',
101 label: 'Instagram',
102 matches: ['instagram.com'],
103 instances: [
104 { host: 'toinstagram.com', label: 'toinstagram' },
105 { host: 'adamlikes.men', label: 'adamlikes' },
106 { host: 'instagram7.com', label: 'instagram7' },
107 { host: 'kittygr.am', label: 'kittygram' },
108 { host: 'kg.meowing.de', label: 'kittygram (meowing)' },
109 { host: 'kittygram.kareem.one', label: 'kittygram (kareem)' },
110 ],
111 },
112 {
113 id: 'tiktok',
114 label: 'TikTok',
115 matches: ['tiktok.com'],
116 instances: [
117 { host: 'tnktok.com', label: 'tnktok' },
118 { host: 'tfxktok.com', label: 'tfxktok' },
119 { host: 'tiktokez.com', label: 'tiktokez' },
120 { host: 'kktiktok.com', label: 'kktiktok' },
121 { host: 'vxtiktok.com', label: 'vxtiktok (legacy)' },
122 { host: 'tiktxk.com', label: 'tiktxk (legacy)' },
123 ],
124 },
125 {
126 id: 'pinterest',
127 label: 'Pinterest',
128 matches: ['pinterest.com'],
129 instances: [
130 { host: 'pinterest.bunk.im', label: 'bunk.im' },
131 ],
132 },
133 {
134 id: 'threads',
135 label: 'Threads',
136 matches: ['threads.net', 'threads.com'],
137 instances: [
138 { host: 'shoelace.mint.lgbt', label: 'shoelace' },
139 ],
140 },
141 ];
142
143 // ────────────────────────────────────────────────────────────────────────
144 // Config shape — persisted as a JSON blob via the native string-pref shim.
145 // ────────────────────────────────────────────────────────────────────────
146
147 export type ProxyConfig = {
148 /** Master toggle. When false, no rewriting happens regardless of dest state. */
149 enabled: boolean;
150 /** Per-destination state, keyed by destination id. */
151 dests: Record<string, ProxyDestConfig>;
152 /** User-defined destinations alongside the built-in ones. Each is
153 * self-contained — its own label, match hosts, and instance list. */
154 customDests: CustomDest[];
155 };
156
157 /**
158 * Fully user-defined proxy destination. Lives in ProxyConfig.customDests
159 * (NOT in `dests`) because its label, matches, and instance list are
160 * all user-supplied — there's no built-in defaults table to reference.
161 */
162 export type CustomDest = {
163 id: string; // 'custom_<random>'
164 label: string;
165 matches: string[]; // hostnames to intercept (subdomain-aware)
166 instances: string[]; // proxy hosts (always user-defined)
167 randomize: boolean;
168 disabled: string[]; // instance hosts the user has toggled off
169 };
170
171 export type ProxyDestConfig = {
172 /** When true, every launch picks a fresh random enabled instance. */
173 randomize: boolean;
174 /** Instance hosts the user has disabled (works for built-in or custom). */
175 disabled: string[];
176 /** User-added custom instance hosts. Merged into the candidate list
177 * alongside the built-in defaults. */
178 custom: string[];
179 };
180
181 export const PROXY_PREF_KEY = 'proxy_config';
182
183 export const DEFAULT_PROXY_CONFIG: ProxyConfig = {
184 // On by default — Warden's whole premise is "your default browser
185 // isn't the right place for every link"; routing social/video URLs
186 // through privacy front-ends matches that intent. Per-destination
187 // rotation defaults remain off (user opts in per host).
188 enabled: true,
189 dests: Object.fromEntries(
190 PROXY_DESTINATIONS.map((d) => [d.id, { randomize: false, disabled: [], custom: [] }]),
191 ),
192 customDests: [],
193 };
194
195 /**
196 * Parse a stored JSON blob into a ProxyConfig, defaulting any missing
197 * keys. Used at app start to hydrate the in-memory config from
198 * SharedPreferences without crashing on schema drift.
199 */
200 export function loadProxyConfig(raw: string): ProxyConfig {
201 try {
202 if (!raw) return DEFAULT_PROXY_CONFIG;
203 const parsed = JSON.parse(raw);
204 const merged: ProxyConfig = {
205 enabled: parsed?.enabled === true,
206 dests: { ...DEFAULT_PROXY_CONFIG.dests },
207 customDests: [],
208 };
209 for (const dest of PROXY_DESTINATIONS) {
210 const entry = parsed?.dests?.[dest.id];
211 merged.dests[dest.id] = {
212 randomize: entry?.randomize === true,
213 disabled: Array.isArray(entry?.disabled) ? entry.disabled.filter((x: unknown) => typeof x === 'string') : [],
214 custom: Array.isArray(entry?.custom) ? entry.custom.filter((x: unknown) => typeof x === 'string') : [],
215 };
216 }
217 if (Array.isArray(parsed?.customDests)) {
218 merged.customDests = parsed.customDests
219 .filter((c: any) => c && typeof c.id === 'string' && typeof c.label === 'string')
220 .map((c: any) => ({
221 id: c.id,
222 label: c.label,
223 matches: Array.isArray(c.matches) ? c.matches.filter((x: unknown) => typeof x === 'string') : [],
224 instances: Array.isArray(c.instances) ? c.instances.filter((x: unknown) => typeof x === 'string') : [],
225 randomize: c.randomize === true,
226 disabled: Array.isArray(c.disabled) ? c.disabled.filter((x: unknown) => typeof x === 'string') : [],
227 }));
228 }
229 return merged;
230 } catch {
231 return DEFAULT_PROXY_CONFIG;
232 }
233 }
234
235 // ────────────────────────────────────────────────────────────────────────
236 // URL rewriting
237 // ────────────────────────────────────────────────────────────────────────
238
239 /**
240 * Adapt a CustomDest into the ProxyDestination shape so the rest of
241 * the code can iterate one homogenous list. The mapping is a thin
242 * view — both sides share `id` so subsequent config lookups still
243 * point at the original CustomDest entry.
244 */
245 function customAsDestination(c: CustomDest): ProxyDestination {
246 return {
247 id: c.id,
248 label: c.label,
249 matches: c.matches,
250 instances: c.instances.map((host) => ({ host })),
251 };
252 }
253
254 /** Built-in destinations + the user's custom destinations, in display order. */
255 export function effectiveDestinations(cfg: ProxyConfig): ProxyDestination[] {
256 return [...PROXY_DESTINATIONS, ...cfg.customDests.map(customAsDestination)];
257 }
258
259 /**
260 * The effective ProxyDestConfig for a destination id, whether it's a
261 * built-in (state lives in `cfg.dests`) or a custom one (state lives
262 * inline on the CustomDest itself). Returned shape matches
263 * ProxyDestConfig so callers don't need to branch.
264 */
265 function configFor(destId: string, cfg: ProxyConfig): ProxyDestConfig {
266 const c = cfg.customDests.find((x) => x.id === destId);
267 if (c) {
268 return { randomize: c.randomize, disabled: c.disabled, custom: [] };
269 }
270 return cfg.dests[destId] ?? { randomize: false, disabled: [], custom: [] };
271 }
272
273 function destForHost(host: string, cfg: ProxyConfig): ProxyDestination | null {
274 const lower = host.toLowerCase();
275 for (const d of effectiveDestinations(cfg)) {
276 if (d.matches.some((m) => lower === m || lower.endsWith('.' + m))) return d;
277 }
278 return null;
279 }
280
281 /**
282 * Resolve the proxy destination (if any) for a given URL. Public
283 * wrapper so callers can decide whether to surface UI like a "cycle
284 * instance" button without re-parsing the URL themselves.
285 */
286 export function findDestinationForUrl(input: string, cfg: ProxyConfig): ProxyDestination | null {
287 try {
288 return destForHost(new URL(input).hostname, cfg);
289 } catch {
290 return null;
291 }
292 }
293
294 /** Number of enabled instances for a destination under a given config. */
295 export function enabledInstanceCount(
296 dest: ProxyDestination,
297 cfg: ProxyConfig,
298 ): number {
299 const destCfg = configFor(dest.id, cfg);
300 const disabled = new Set(destCfg.disabled);
301 return allInstancesFor(dest, cfg).filter((i) => !disabled.has(i.host)).length;
302 }
303
304 /**
305 * Pick the proxy instance Warden should rewrite to for a given
306 * destination, honouring per-destination randomize + per-instance
307 * enable state. Returns null when the user has disabled every
308 * instance for this destination.
309 */
310 /**
311 * Combine built-in + user-added custom instances into one candidate
312 * list, in the order they'd appear in the UI (built-ins first, then
313 * customs in insertion order). Order matters because the non-random
314 * pick always returns the first enabled candidate.
315 */
316 export function allInstancesFor(
317 dest: ProxyDestination,
318 cfg: ProxyConfig,
319 ): ProxyInstance[] {
320 // Custom destinations carry their instances inline (already on
321 // dest.instances), so the per-built-in `custom` extension is a no-op
322 // for them. For built-ins we splice in the user-added customs from
323 // the config.
324 const destCfg = cfg.dests[dest.id];
325 const customs: ProxyInstance[] = (destCfg?.custom ?? []).map((host) => ({ host }));
326 return [...dest.instances, ...customs];
327 }
328
329 /**
330 * Pick a proxy instance from the enabled candidate list using an
331 * explicit cycle index. Index wraps modulo enabled-count, so the
332 * caller can simply increment a counter to walk forward through the
333 * rotation without worrying about bounds.
334 *
335 * The "randomize" preference now only seeds the initial cycle index
336 * (handled by the caller) — the per-pick logic itself is deterministic
337 * for a given (cfg, index) pair so the status-row preview and the
338 * actual launch can never disagree.
339 */
340 export function pickInstance(
341 dest: ProxyDestination,
342 cfg: ProxyConfig,
343 cycleIndex: number = 0,
344 ): ProxyInstance | null {
345 const destCfg = configFor(dest.id, cfg);
346 const disabled = new Set(destCfg.disabled);
347 const enabled = allInstancesFor(dest, cfg).filter((i) => !disabled.has(i.host));
348 if (enabled.length === 0) return null;
349 const n = enabled.length;
350 // Positive modulo so negative indices (shouldn't happen, but defensive) still wrap.
351 const idx = ((cycleIndex % n) + n) % n;
352 return enabled[idx];
353 }
354
355 /**
356 * Normalise + validate a user-entered proxy hostname. Strips any
357 * scheme / path / query / hash the user may have pasted and verifies
358 * the remainder is a plausible host (lowercase alnum + hyphens with
359 * at least one dot). Returns null on invalid input.
360 */
361 /**
362 * Generate a stable id for a freshly-added custom destination.
363 * `custom_<timestamp-base36>` is short, unique enough, and obviously
364 * a custom id when grepping a config blob later.
365 */
366 export function newCustomDestId(): string {
367 return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
368 }
369
370 export function normalizeProxyHost(input: string): string | null {
371 let s = input.trim().toLowerCase();
372 if (!s) return null;
373 s = s.replace(/^https?:\/\//, '');
374 s = s.split(/[/?#]/)[0];
375 if (!/^[a-z0-9][a-z0-9.-]*\.[a-z0-9-]+$/.test(s)) return null;
376 return s;
377 }
378
379 /**
380 * Rewrite the URL's host if it matches one of our proxy destinations
381 * and the master toggle is on. Returns the rewritten URL (or the
382 * original if no rewrite applies). `cycleIndex` advances forward
383 * through the enabled candidate list — used by the status-row cycle
384 * button so the user can step through proxies deterministically.
385 */
386 export function rewriteThroughProxy(
387 input: string,
388 cfg: ProxyConfig,
389 cycleIndex: number = 0,
390 ): { url: string; via: string | null } {
391 if (!cfg.enabled) return { url: input, via: null };
392 try {
393 const u = new URL(input);
394 const dest = destForHost(u.hostname, cfg);
395 if (!dest) return { url: input, via: null };
396 const inst = pickInstance(dest, cfg, cycleIndex);
397 if (!inst) return { url: input, via: null };
398 // Apply per-destination path normalisation BEFORE the host swap
399 // (e.g. youtu.be/{id} → /watch?v={id} for Invidious).
400 dest.rewritePath?.(u);
401 u.hostname = inst.host;
402 // Most proxies (Nitter, Invidious, Piped) speak HTTPS; force it.
403 u.protocol = 'https:';
404 return { url: u.toString(), via: inst.host };
405 } catch {
406 return { url: input, via: null };
407 }
408 }
409