import { NextResponse } from "next/server"; // Image proxy. News CDN images hotlink-block on Referer and some are http-only // (mixed content against our https site), so the paper loads them through here. // No auth needed: images are public anyway, and it keeps the tags dumb. const MAX_BYTES = 4 * 1024 * 1024; export async function GET(req) { const raw = new URL(req.url).searchParams.get("u") || ""; let target; try { target = new URL(raw); } catch { return new NextResponse("bad url", { status: 400 }); } if (!/^https?:$/.test(target.protocol)) return new NextResponse("bad scheme", { status: 400 }); const cacheKey = raw; const cached = globalThis.__ft_img?.get(cacheKey); if (cached && Date.now() - cached.t < 1000 * 60 * 60 * 12) { return new NextResponse(cached.body, { headers: imgHeaders(cached.type, "public, max-age=43200"), }); } try { const res = await fetch(target.href, { headers: { // A browser-looking Referer is what unblocks most PH/tech CDNs. Referer: `${target.protocol}//${target.host}/`, "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", }, redirect: "follow", signal: AbortSignal.timeout(12000), }); if (!res.ok) return new NextResponse("upstream " + res.status, { status: 502 }); const len = Number(res.headers.get("content-length") || 0); if (len > MAX_BYTES) return new NextResponse("too big", { status: 413 }); const type = res.headers.get("content-type") || "image/jpeg"; if (!/^image\//i.test(type)) return new NextResponse("not an image", { status: 415 }); const buf = await res.arrayBuffer(); if (buf.byteLength > MAX_BYTES) return new NextResponse("too big", { status: 413 }); if (!globalThis.__ft_img) globalThis.__ft_img = new Map(); const map = globalThis.__ft_img; if (map.size > 400) for (const k of map.keys()) { map.delete(k); if (map.size < 300) break; } map.set(cacheKey, { t: Date.now(), type, body: buf }); return new NextResponse(buf, { headers: imgHeaders(type, "public, max-age=43200") }); } catch (e) { return new NextResponse("error", { status: 502 }); } } function imgHeaders(type, cache) { return { "Content-Type": type, "Cache-Control": cache, "X-Content-Type-Options": "nosniff", }; }