Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions app/api/ads/frame/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// No-JS ad frame. Publishers embed a plain, script-free tag:
// <iframe src="https://crawlproof.com/api/ads/frame?slot=<id>&format=banner_300x250"
// width="300" height="250" frameborder="0" scrolling="no"
// style="border:0;max-width:100%" loading="lazy"></iframe>
// 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 =
'<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0"></body></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);
}
}
Loading