redirect-unwrap.ts raw
1 type RedirectExtractor = {
2 host: string;
3 exact?: boolean;
4 path: string;
5 param: string;
6 decode?: 'none' | 'uri' | 'form' | 'b64url';
7 };
8
9 const REDIRECT_EXTRACTORS: RedirectExtractor[] = [
10 { host: 'l.facebook.com', exact: true, path: '/l.php', param: 'u', decode: 'uri' },
11 { host: 'lm.facebook.com', exact: true, path: '/l.php', param: 'u', decode: 'uri' },
12 { host: 'linkedin.com', exact: false, path: '/safety/go', param: 'url', decode: 'uri' },
13 { host: 'youtube.com', exact: false, path: '/redirect', param: 'q', decode: 'uri' },
14 { host: 'go.bsky.app', exact: true, path: '/redirect', param: 'u', decode: 'uri' },
15 { host: 'googleadservices.com', exact: false, path: '/pagead/aclk', param: 'adurl', decode: 'uri' },
16 { host: 'target.georiot.com', exact: true, path: '/Proxy.ashx', param: 'GR_URL', decode: 'uri' },
17 { host: 'click.linksynergy.com', exact: true, path: '/link', param: 'murl', decode: 'uri' },
18 ];
19
20 function decodeParam(value: string, mode: 'none' | 'uri' | 'form' | 'b64url'): string | null {
21 try {
22 switch (mode) {
23 case 'none': return value;
24 case 'uri': return decodeURIComponent(value);
25 case 'form': return decodeURIComponent(value.replace(/\+/g, ' '));
26 case 'b64url':
27 return new TextDecoder().decode(
28 Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)),
29 );
30 }
31 } catch { return null; }
32 }
33
34 function isHttp(s: string): boolean { return /^https?:\/\//i.test(s); }
35
36 export function unwrapRedirect(url: string): string | null {
37 try {
38 const u = new URL(url);
39 if (u.hostname === 'out.reddit.com') {
40 const target = u.searchParams.get('url');
41 if (target && isHttp(target)) {
42 const d = decodeURIComponent(target);
43 if (isHttp(d)) return d;
44 }
45 return null;
46 }
47 for (const ex of REDIRECT_EXTRACTORS) {
48 if (ex.exact ? u.hostname !== ex.host : (u.hostname !== ex.host && !u.hostname.endsWith('.' + ex.host))) continue;
49 if (!u.pathname.startsWith(ex.path)) continue;
50 const raw = u.searchParams.get(ex.param);
51 if (!raw) continue;
52 const d = decodeParam(raw, ex.decode ?? 'uri');
53 if (d && isHttp(d)) return d;
54 }
55 return null;
56 } catch { return null; }
57 }
58
59 export function fullyUnwrapRedirect(url: string, maxHops: number = 5): { url: string; hops: string[] } {
60 const hops: string[] = [];
61 let cur = url;
62 for (let i = 0; i < maxHops; i++) {
63 const next = unwrapRedirect(cur);
64 if (!next || next === cur) break;
65 try { hops.push(new URL(cur).hostname); } catch {}
66 cur = next;
67 }
68 return { url: cur, hops };
69 }
70