From e28fc6c8dd153e9a4cc8b2638dc397ee5fa2a5d9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 30 Jul 2026 09:27:01 +0000 Subject: [PATCH] feat(ads): terminal (ASCII) ad format served as plain text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminals can't run /ad.js and can't render HTML, so the ad network had nothing to sell to shells, SSH banners, BBS screens, or CLIs. This adds a terminal_ascii format that is fetched, not embedded: curl -s "https://crawlproof.com/api/ads/motd?slot=" - lib/ads/terminal.ts renders a fixed-width, pure-ASCII box. Advertiser copy is untrusted text going straight into a TTY, so escapes/control chars are stripped and non-ASCII is folded (accents) or dropped (CJK, emoji) — both to keep terminals safe and to keep column maths honest. - /api/ads/motd returns text/plain, with ?cols=44..120, ?color=1 for ANSI, and ?src= to tell surfaces apart. Impressions meter server-side via serveAd exactly like the HTML paths; an unknown slot falls back to the house ad so a login banner is never blank. - Terminal clients identify as curl/wget/etc., which the tracker buckets as "bot". On this endpoint that's the actual audience, so a dedicated classifier lets shell clients through while still excluding crawlers. The click path deliberately keeps the strict rule: scripted hits on /a/ stay unbilled. - Clicks use a short /a/ URL (the long query-string form is unusable as printed text) and carry utm_source/medium/content through to the advertiser, since a shell sends no referrer. - lib/ads/template.ts parses {{ads}} / {{ads:64}} / {{ads:terminal:64}} so a publisher's server can place the fill in its own template. - Migration widens the format CHECK, adds terminal_ascii to slot inventory, and backfills a terminal creative for every existing campaign from the copy it already has (no LLM call, no advertiser action). Co-Authored-By: Claude Opus 5 (1M context) --- app/a/[id]/route.ts | 85 ++++++ app/api/ads/motd/route.ts | 110 ++++++++ components/ads/ad-preview.tsx | 32 ++- components/ads/slot-manager.tsx | 46 +++- lib/ads/creative.ts | 8 + lib/ads/formats.ts | 21 ++ lib/ads/house.ts | 19 +- lib/ads/serve.ts | 14 +- lib/ads/template.ts | 112 ++++++++ lib/ads/terminal.ts | 254 ++++++++++++++++++ .../20260730120000_ad_terminal_ascii.sql | 74 +++++ tests/contract/ads-template.test.ts | 93 +++++++ tests/contract/ads-terminal.test.ts | 201 ++++++++++++++ 13 files changed, 1059 insertions(+), 10 deletions(-) create mode 100644 app/a/[id]/route.ts create mode 100644 app/api/ads/motd/route.ts create mode 100644 lib/ads/template.ts create mode 100644 lib/ads/terminal.ts create mode 100644 supabase/migrations/20260730120000_ad_terminal_ascii.sql create mode 100644 tests/contract/ads-template.test.ts create mode 100644 tests/contract/ads-terminal.test.ts diff --git a/app/a/[id]/route.ts b/app/a/[id]/route.ts new file mode 100644 index 00000000..0d6195e6 --- /dev/null +++ b/app/a/[id]/route.ts @@ -0,0 +1,85 @@ +// Short ad click redirector: https://crawlproof.com/a/ +// +// Terminal ads print their click URL as literal text into someone's shell, so +// the long /api/ads/click?i=…&s=…&c=…&cr=… form doesn't work — it wraps, it +// looks like spam, and it's unusable when hand-typed. This path carries only +// the impression id and re-reads slot/campaign/creative from the impression +// row, then hands off to the same resolveClick() metering as the web path. +// +// Unknown/expired ids (including unmetered house-ad fills, which never get an +// impression row) redirect to the site rather than dead-ending. + +import { NextRequest, NextResponse } from "next/server"; +import { resolveClick } from "@/lib/ads/serve"; +import { serviceClient } from "@/lib/supabase/service"; +import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo"; +import { parseDevice } from "@/lib/tracker/device"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const fallback = env.siteUrl || "https://crawlproof.com"; + try { + const { id } = await ctx.params; + if (!UUID.test(id)) return NextResponse.redirect(fallback, { status: 302 }); + + const sb = serviceClient(); + const { data: imp } = await sb + .from("ad_impressions") + .select("id, slot_id, campaign_id, creative_id, visitor_id") + .eq("id", id) + .maybeSingle(); + if (!imp) return NextResponse.redirect(fallback, { status: 302 }); + + const ip = clientIpFromHeaders(request.headers); + const geo = await lookupGeo(ip).catch(() => null); + // Deliberately the STRICT classification here, unlike /api/ads/motd: a + // terminal ad is served to curl, but it's clicked from a browser when the + // reader follows the link. Anyone can curl this URL in a loop, so scripted + // hits stay unbilled (recorded with valid=false) rather than paying out. + const device = parseDevice(request.headers.get("user-agent")).deviceType; + + const dest = await resolveClick({ + impressionId: imp.id, + slotId: imp.slot_id, + campaignId: imp.campaign_id, + creativeId: imp.creative_id, + ctx: { + visitorId: imp.visitor_id, + ip, + country: geo?.countryCode ?? null, + device, + }, + }); + + if (!dest) return NextResponse.redirect(fallback, { status: 302 }); + + // Terminal traffic is invisible in an advertiser's analytics without a tag + // — there's no referrer from a shell. resolveClick already appended + // ?ref=; add utm on top, plus the publisher's own ?src= + // surface tag when the ad carried one. Never overwrite utm params the + // advertiser put on their own destination URL. + const q = new URL(request.url).searchParams; + const src = q.get("s") ?? q.get("src"); + return NextResponse.redirect(withTerminalUtm(dest, src), { status: 302 }); + } catch { + return NextResponse.redirect(fallback, { status: 302 }); + } +} + +function withTerminalUtm(dest: string, src: string | null): string { + try { + const u = new URL(dest); + if (!u.searchParams.has("utm_source")) u.searchParams.set("utm_source", "crawlproof"); + if (!u.searchParams.has("utm_medium")) u.searchParams.set("utm_medium", "terminal"); + const tag = (src ?? "").trim().replace(/[^\w.-]/g, "").slice(0, 32); + if (tag && !u.searchParams.has("utm_content")) u.searchParams.set("utm_content", tag); + return u.toString(); + } catch { + return dest; + } +} diff --git a/app/api/ads/motd/route.ts b/app/api/ads/motd/route.ts new file mode 100644 index 00000000..a55db6b8 --- /dev/null +++ b/app/api/ads/motd/route.ts @@ -0,0 +1,110 @@ +// Plain-text ad fill for terminals — the MOTD endpoint. +// +// curl -s "https://crawlproof.com/api/ads/motd?slot=" +// curl -s "https://crawlproof.com/api/ads/motd?slot=&cols=64&color=1" +// +// Returns an ASCII box (text/plain), sized to `cols`, with optional ANSI +// colour. Meant for shell MOTDs, SSH login banners, BBS screens, and CLI tools +// — anywhere an