Skip to content
Merged
Show file tree
Hide file tree
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
85 changes: 85 additions & 0 deletions app/a/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Short ad click redirector: https://crawlproof.com/a/<impression_id>
//
// 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=<campaign slug>; 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;
}
}
110 changes: 110 additions & 0 deletions app/api/ads/motd/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Plain-text ad fill for terminals — the MOTD endpoint.
//
// curl -s "https://crawlproof.com/api/ads/motd?slot=<slot_id>"
// curl -s "https://crawlproof.com/api/ads/motd?slot=<slot_id>&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 <iframe> can't go. Impressions are metered server-side by
// serveAd, exactly like the HTML paths; an unknown or inactive slot falls back
// to the (unmetered) CrawlProof house ad so a login banner is never blank.

import { NextRequest, NextResponse } from "next/server";
import { serveAd } from "@/lib/ads/serve";
import { houseFill } from "@/lib/ads/house";
import { TERMINAL_FORMAT_ID } from "@/lib/ads/formats";
import { clampCols, renderCreativeText, terminalDeviceType } from "@/lib/ads/terminal";
import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo";
import { parseDevice } from "@/lib/tracker/device";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

function headers(request: Request): Record<string, string> {
const origin = request.headers.get("origin");
return {
"content-type": "text/plain; charset=utf-8",
// Every request is a fresh fill + impression, so never cache at the edge.
"cache-control": "no-store",
"access-control-allow-origin": origin ?? "*",
"access-control-allow-methods": "GET, OPTIONS",
vary: "Origin",
"x-robots-tag": "noindex",
};
}

export function OPTIONS(request: NextRequest) {
return new NextResponse(null, { status: 204, headers: headers(request) });
}

// color=1 / true / yes / ansi all opt into ANSI escapes. Default is plain text:
// a consumer piping us into a file or a web page must not get escape codes.
function wantsColor(v: string | null): boolean {
return v !== null && /^(1|true|yes|ansi|on)$/i.test(v);
}

// Optional publisher-supplied surface tag (?src=bbs, ?src=ssh-banner, …). It
// rides along on the click URL so one slot can tell its surfaces apart in the
// advertiser's own analytics. Sanitised hard: it ends up in a printed URL.
function cleanSrc(v: string | null): string {
return (v ?? "").trim().replace(/[^\w.-]/g, "").slice(0, 32);
}

function withParam(rawUrl: string, key: string, value: string): string {
try {
const u = new URL(rawUrl);
u.searchParams.set(key, value);
return u.toString();
} catch {
return rawUrl;
}
}

export async function GET(request: NextRequest) {
const h = headers(request);
try {
const url = new URL(request.url);
const slotId = url.searchParams.get("slot");
const visitorId = url.searchParams.get("v");
const cols = clampCols(url.searchParams.get("cols") ?? url.searchParams.get("width"));
const color = wantsColor(url.searchParams.get("color"));
const src = cleanSrc(url.searchParams.get("src"));

let fill = null;
if (slotId) {
const ip = clientIpFromHeaders(request.headers);
const geo = await lookupGeo(ip).catch(() => null);
// Terminal clients identify as curl/wget/etc., which the generic tracker
// buckets as "bot" — correct for a web page, wrong here, where that's the
// actual audience. terminalDeviceType keeps real crawlers out and lets
// shell clients through; anything else falls back to normal parsing.
const ua = request.headers.get("user-agent");
const device = terminalDeviceType(ua) ?? parseDevice(ua).deviceType;
fill = await serveAd(slotId, TERMINAL_FORMAT_ID, {
visitorId,
ip,
country: geo?.countryCode ?? null,
device,
});
}
// No slot given, or the slot is inactive / has no terminal inventory.
if (!fill) fill = houseFill(TERMINAL_FORMAT_ID);

// Re-render at the caller's width/colour from the same creative + click URL
// the fill was metered with. House fills keep their own border label so an
// unsold slot doesn't read as a paid placement.
// Short key: the click URL is printed as literal text, so every character
// spent here is a character of box width.
const clickUrl = src ? withParam(fill.clickUrl, "s", src) : fill.clickUrl;
const body = renderCreativeText(fill.creative, clickUrl, {
cols,
color,
label: fill.campaignId === "house" ? "CRAWLPROOF ADS" : undefined,
});
return new NextResponse(`${body}\n`, { status: 200, headers: h });
} catch {
// Never fail a login banner: fall back to the house ad at defaults.
const fallback = houseFill(TERMINAL_FORMAT_ID);
return new NextResponse(`${fallback.text}\n`, { status: 200, headers: h });
}
}
32 changes: 31 additions & 1 deletion components/ads/ad-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,43 @@

import type { CSSProperties } from "react";
import type { AdCreative } from "@/lib/ads/formats";
import { brandInitial, formatSpec, hexToRgba } from "@/lib/ads/formats";
import { brandInitial, formatSpec, hexToRgba, TERMINAL_FORMAT_ID } from "@/lib/ads/formats";
import { renderCreativeText } from "@/lib/ads/terminal";

// Stand-in for the real /a/<impression_id> click URL, so the preview box is the
// width the served ad will actually be.
const PREVIEW_CLICK_URL = "https://crawlproof.com/a/00000000-0000-0000-0000-000000000000";

// Live React mirror of renderCreativeHtml (lib/ads/creative.ts). Kept visually
// in sync with the served HTML so the editor preview matches production.
export function AdPreview({ creative, scale = 1 }: { creative: AdCreative; scale?: number }) {
const { w, h } = formatSpec(creative.format);

// Terminal ad — render the exact ASCII the MOTD endpoint serves, monospaced.
if (creative.format === TERMINAL_FORMAT_ID) {
return (
<div style={{ width: w * scale, maxWidth: "100%", flex: "0 0 auto" }}>
<pre
style={{
margin: 0,
background: creative.bgColor,
color: creative.fgColor,
border: "1px solid rgba(255,255,255,.08)",
borderRadius: 8,
padding: "10px 12px",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: 11,
lineHeight: 1.35,
whiteSpace: "pre",
overflowX: "auto",
}}
>
{renderCreativeText(creative, PREVIEW_CLICK_URL)}
</pre>
</div>
);
}

// Native text link — a borderless, full-width single line.
if (creative.format === "text_link") {
return (
Expand Down
46 changes: 41 additions & 5 deletions components/ads/slot-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,51 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createSlot, setSlotStatus, saveSlotPayout, requestPayout } from "@/app/actions/ads";
import { PUBLISHER_FORMAT_IDS, formatSpec, type AdFormatId } from "@/lib/ads/formats";
import {
PUBLISHER_FORMAT_IDS,
PUBLISHER_TEXT_FORMAT_IDS,
TERMINAL_COLS_LABEL,
TERMINAL_FORMAT_ID,
formatSpec,
type AdFormatId,
} from "@/lib/ads/formats";

// Every unit a publisher can install: HTML embeds first, then the fetch-based
// text formats (terminal/MOTD).
const INSTALLABLE_FORMAT_IDS: AdFormatId[] = [
...PUBLISHER_FORMAT_IDS,
...PUBLISHER_TEXT_FORMAT_IDS,
];

// The paste-once embed for a given size. data-format tells /ad.js which creative
// to request; the medium rectangle stays the default the auto-installer uses.
// The terminal format isn't embedded at all — it's curled, so it gets a shell
// snippet instead of markup.
function embedFor(slotId: string, format: AdFormatId, origin: string): string {
if (format === TERMINAL_FORMAT_ID) {
return [
"# Terminal ad — plain ASCII over HTTP. No JavaScript, no HTML, no iframe.",
"# Drop in ~/.zshrc, /etc/profile.d/, /etc/update-motd.d, or any CLI banner.",
`curl -fsS --max-time 3 "${origin}/api/ads/motd?slot=${slotId}&cols=72"`,
"",
"# Options: &color=1 for ANSI colour, &cols=44..120 for width,",
"# &src=<tag> to tell surfaces apart (rides through to the click URL).",
"",
"# Rendering a template server-side? Leave a token where the ad goes —",
"# {{ads}} {{ads:64}} {{ads:terminal:64}}",
"# — and swap it for the fetched text before you send the response.",
].join("\n");
}
return `<div data-cp-ad data-slot="${slotId}" data-format="${format}"></div>\n<script src="${origin}/ad.js" async></script>`;
}

// Button caption: pixel sizes for banners, columns for the terminal box.
function formatButtonLabel(id: AdFormatId): string {
const spec = formatSpec(id);
if (id === TERMINAL_FORMAT_ID) return `${spec.label} · ${TERMINAL_COLS_LABEL}`;
return `${spec.label} · ${spec.w}×${spec.h}`;
}

type Project = { id: string; name: string; url: string };
type Slot = {
id: string;
Expand Down Expand Up @@ -237,13 +274,12 @@ export function SlotManager({
)}
<div>
<div className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
Embed — pick a size, paste on your page
Embed — pick a unit, paste on your page (or in your shell)
</div>
{/* One button per available size. Clicking reveals that size's code;
clicking the open size again collapses it. */}
<div className="mt-2 flex flex-wrap gap-2">
{PUBLISHER_FORMAT_IDS.map((id) => {
const spec = formatSpec(id);
{INSTALLABLE_FORMAT_IDS.map((id) => {
const open = id === fmt && showCode;
return (
<button
Expand All @@ -259,7 +295,7 @@ export function SlotManager({
}
}}
>
{spec.label} · {spec.w}×{spec.h}
{formatButtonLabel(id)}
</button>
);
})}
Expand Down
8 changes: 8 additions & 0 deletions lib/ads/creative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { env } from "@/lib/env";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { extractSiteBrand, type SiteBrand } from "./brand";
import { resolveAdHeroImage } from "./heroImage";
import { renderCreativeText, renderTerminalHtml } from "./terminal";
import {
AD_FORMATS,
AD_FORMAT_IDS,
Expand All @@ -20,6 +21,7 @@ import {
// Re-export the client-safe format primitives so existing server importers of
// this module keep working; client components should import from ./formats.
export { AD_FORMATS, AD_FORMAT_IDS, formatSpec };
export { renderCreativeText, renderTerminalHtml };
export type { AdCreative, AdFormatId };

// Copy is generated once per URL by a frontier-ish model; sizes are rendered
Expand Down Expand Up @@ -186,6 +188,12 @@ function markHtml(creative: AdCreative, size: number): string {
export function renderCreativeHtml(creative: AdCreative, clickUrl: string): string {
const { w, h } = formatSpec(creative.format);

// Terminal ad — the ASCII artwork in a <pre>, so the same creative can also
// fill a web slot. The canonical delivery is /api/ads/motd (text/plain).
if (creative.format === "terminal_ascii") {
return renderTerminalHtml(creative, clickUrl);
}

// Native text link — a borderless, full-width single line. No image/box.
if (creative.format === "text_link") {
const body = creative.body
Expand Down
21 changes: 21 additions & 0 deletions lib/ads/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export const AD_FORMATS = [
// Native, borderless single-line text ad. Renders full-width (the w/h below
// is the nominal iframe box; the unit itself fills its container).
{ id: "text_link", label: "Text Link", w: 600, h: 40 },
// ASCII box for terminals — SSH banners, shell MOTDs, BBS screens, CLI tools.
// There is no pixel box: w/h below is only the nominal size used by the web
// preview when the same creative is rendered in a <pre>. The real dimension
// is TERMINAL_COLS (see ./terminal).
{ id: "terminal_ascii", label: "Terminal (ASCII)", w: 600, h: 148 },
] as const;

export type AdFormatId = (typeof AD_FORMATS)[number]["id"];
Expand All @@ -19,13 +24,29 @@ export const AD_FORMAT_IDS = AD_FORMATS.map((f) => f.id) as AdFormatId[];
// embed for and install. A subset of AD_FORMATS that grows as each size is
// surfaced (one PR per size). Keep the medium rectangle first: it's the safe
// default the auto-installer falls back to.
//
// WEB ONLY: these are the formats rendered as an <iframe> by /ad.js and dropped
// into HTML by the GitHub auto-installer. Text/terminal formats must not be in
// this list — they're fetched, not embedded.
export const PUBLISHER_FORMAT_IDS: AdFormatId[] = [
"banner_300x250",
"banner_728x90",
"banner_320x50",
"text_link",
];

// The terminal format id, kept as a named constant since several call sites
// (serving, the MOTD endpoint, the publisher snippet) branch on it.
export const TERMINAL_FORMAT_ID = "terminal_ascii" as const;

// Caption for the terminal unit's real dimension — it has columns, not pixels.
export const TERMINAL_COLS_LABEL = "76 cols";

// Formats a publisher consumes over plain HTTP as text rather than embedding —
// `curl https://crawlproof.com/api/ads/motd?slot=<id>` in a login banner, MOTD,
// or CLI. Shown as their own group on the Monetize page.
export const PUBLISHER_TEXT_FORMAT_IDS: AdFormatId[] = [TERMINAL_FORMAT_ID];

export function formatSpec(id: AdFormatId) {
return AD_FORMATS.find((f) => f.id === id) ?? AD_FORMATS[0];
}
Expand Down
Loading
Loading