import type { LoaderFunctionArgs } from "react-router"; type CacheEntry = { data: ArrayBuffer; contentType: string; expiresAt: number }; const cache = new Map(); const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const MAX_CACHE_ENTRIES = 200; const PRIVATE_IP_RE = /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1$|fd[0-9a-f]{2}:)/i; function isPrivateHost(hostname: string): boolean { return PRIVATE_IP_RE.test(hostname) || hostname === "localhost"; } function resolveContentType(declared: string, data: ArrayBuffer): string | null { if (declared.startsWith("image/")) return declared; if (declared.startsWith("application/octet-stream") || declared === "") { const b = new Uint8Array(data, 0, 12); if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"; if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return "image/gif"; if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp"; } return null; } export async function loader({ request }: LoaderFunctionArgs) { const raw = new URL(request.url).searchParams.get("url"); if (!raw) return new Response("Missing url", { status: 400 }); let parsed: URL; try { parsed = new URL(raw); } catch { return new Response("Invalid url", { status: 400 }); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return new Response("Invalid protocol", { status: 400 }); } if (isPrivateHost(parsed.hostname)) { return new Response("Forbidden", { status: 403 }); } const now = Date.now(); const cached = cache.get(raw); if (cached && cached.expiresAt > now) { return new Response(cached.data.slice(0), { headers: { "Content-Type": cached.contentType, "Cache-Control": "public, max-age=86400", }, }); } let res: Response; try { res = await fetch(raw, { headers: { "User-Agent": "Tokyo-Livehouse-Events/1.0" }, signal: AbortSignal.timeout(10_000), }); } catch { return new Response("Upstream unreachable", { status: 502 }); } if (!res.ok) return new Response("Upstream error", { status: 502 }); const contentType = res.headers.get("content-type") ?? ""; const data = await res.arrayBuffer(); const resolvedType = resolveContentType(contentType, data); if (!resolvedType) { return new Response("Not an image", { status: 400 }); } if (cache.size >= MAX_CACHE_ENTRIES) { cache.delete(cache.keys().next().value!); } cache.set(raw, { data, contentType: resolvedType, expiresAt: now + CACHE_TTL_MS }); return new Response(data.slice(0), { headers: { "Content-Type": resolvedType, "Cache-Control": "public, max-age=86400", "X-Content-Type-Options": "nosniff", }, }); }