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