From 0fae6363b0ecfa9798e574c6731cf85c7ebb35f3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 7 Jul 2026 11:33:58 +0000 Subject: [PATCH] Add no-JS ad frame endpoint (/api/ads/frame) Serves an ad as a full HTML document so publishers can embed a plain cross-origin +// Unlike /ad.js (which fetches JSON and injects a srcdoc iframe), this returns a +// full HTML document so the ad renders and is clickable with zero JavaScript on +// the host page. That makes it embeddable on JS-restricted contexts such as Tor +// hidden services. The click link (target="_blank") lives inside CrawlProof's +// own document, so the host page can never intercept it. Impressions are metered +// server-side in serveAd, exactly like the JSON path. + +import { NextRequest, NextResponse } from "next/server"; +import { serveAd, isAdFormat } from "@/lib/ads/serve"; +import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; +import { parseDevice } from "@/lib/tracker/device"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Minimal empty document so an unfilled slot renders as blank rather than a +// broken frame. Never blocks the host page. +const EMPTY_HTML = + ''; + +// Framed cross-origin by design (host sites, incl. .onion). We deliberately do +// NOT send X-Frame-Options / a restrictive frame-ancestors here. +function htmlResponse(html: string): NextResponse { + return new NextResponse(html, { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "content-security-policy": "frame-ancestors *", + }, + }); +} + +export async function GET(request: NextRequest) { + try { + const url = new URL(request.url); + const slotId = url.searchParams.get("slot"); + const format = url.searchParams.get("format"); + const visitorId = url.searchParams.get("v"); + if (!slotId || !isAdFormat(format)) return htmlResponse(EMPTY_HTML); + + const ip = clientIpFromHeaders(request.headers); + const geo = await lookupGeo(ip).catch(() => null); + const device = parseDevice(request.headers.get("user-agent")).deviceType; + + const fill = await serveAd(slotId, format, { + visitorId, + ip, + country: geo?.countryCode ?? null, + device, + }); + + if (!fill) return htmlResponse(EMPTY_HTML); + return htmlResponse(fill.html); + } catch { + return htmlResponse(EMPTY_HTML); + } +}