import { NextRequest, NextResponse } from "next/server"; import { verify, mintStreamToken, mintDashBase, StreamClaim, } from "@/lib/security/tokens"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; // Rewrite every URI in an HLS manifest so child playlists, keys and segments // also flow back through this proxy (the real hosts are never exposed). function rewriteManifest( text: string, baseUrl: string, origin: string, ref?: string ): string { const base = new URL(baseUrl); const wrap = (u: string) => { let abs: string; try { abs = new URL(u, base).toString(); } catch { return u; } return `${origin}/api/stream?t=${encodeURIComponent( mintStreamToken(abs, ref) )}`; }; return text .split("\n") .map((line) => { const t = line.trim(); if (!t) return line; if (t.startsWith("#")) { // rewrite URI="..." attributes (EXT-X-KEY / MAP / MEDIA / I-FRAME) return line.replace(/URI="([^"]+)"/g, (_m, u) => `URI="${wrap(u)}"`); } return wrap(t); // bare segment / sub-playlist line }) .join("\n"); } // Rewrite a DASH .mpd so its points at the segment proxy. The real // host is BaseURL[0]; "x-bc.interkh.com" placeholder bases are dropped. function rewriteMpd( xml: string, mpdUrl: string, origin: string, ref?: string ): string { const bases = [...xml.matchAll(/([^<]+)<\/BaseURL>/g)].map((m) => m[1].trim() ); let real = bases.find((u) => !u.includes("x-bc")) || bases[0]; if (!real) { // no explicit BaseURL — derive from the manifest directory real = mpdUrl.split("?")[0].replace(/[^/]*$/, ""); } else { real = new URL(real, mpdUrl).toString(); } if (!real.endsWith("/")) real += "/"; const proxied = `${origin}/api/dseg/${mintDashBase(real, ref)}/`; let first = true; let out = xml.replace(/[^<]*<\/BaseURL>/g, () => first ? ((first = false), `${proxied}`) : "" ); // if the manifest had no BaseURL at all, inject one after if (first) { out = out.replace(/(]*>)/, `$1${proxied}`); } return out; } export async function GET(req: NextRequest) { const { searchParams, origin } = new URL(req.url); const claim = verify(searchParams.get("t")); if (!claim || claim.k !== "stream") { return new NextResponse("forbidden", { status: 403 }); } const target = claim.url; const upstreamHeaders: Record = { "User-Agent": UA }; if (claim.ref) { upstreamHeaders["Referer"] = claim.ref; upstreamHeaders["Origin"] = new URL(claim.ref).origin; } const range = req.headers.get("range"); if (range) upstreamHeaders["Range"] = range; let upstream: Response; try { upstream = await fetch(target, { headers: upstreamHeaders, redirect: "follow", cache: "no-store", // never serve a stale manifest (segment tokens rotate) }); } catch { return new NextResponse("bad gateway", { status: 502 }); } const ct = (upstream.headers.get("content-type") || "").toLowerCase(); const path0 = target.split("?")[0].toLowerCase(); const isManifest = path0.endsWith(".m3u8") || ct.includes("mpegurl"); const isMpd = path0.endsWith(".mpd") || ct.includes("dash+xml"); if (isMpd) { const xml = await upstream.text(); const finalUrl = (upstream as any).url || target; const body = rewriteMpd(xml, finalUrl, origin, claim.ref); return new NextResponse(body, { status: upstream.status, headers: { "Content-Type": "application/dash+xml", "Cache-Control": "no-store", }, }); } if (isManifest) { const text = await upstream.text(); // upstream.url reflects any redirects, giving the correct base for relatives const finalUrl = (upstream as any).url || target; const body = rewriteManifest(text, finalUrl, origin, claim.ref); return new NextResponse(body, { status: upstream.status, headers: { "Content-Type": "application/vnd.apple.mpegurl", "Cache-Control": "no-store", }, }); } // Binary passthrough for segments / mp4 (preserve range/seek headers) const headers = new Headers(); for (const h of [ "content-type", "content-length", "accept-ranges", "content-range", "cache-control", ]) { const v = upstream.headers.get(h); if (v) headers.set(h, v); } if (!headers.has("cache-control")) headers.set("Cache-Control", "no-store"); return new NextResponse(upstream.body, { status: upstream.status, headers, }); }