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
61 changes: 54 additions & 7 deletions app/a/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,70 @@ 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 { isShortCode } from "@/lib/ads/shortcode";
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;

type ImpressionRow = {
id: string;
slot_id: string;
campaign_id: string;
creative_id: string;
visitor_id: string | null;
src?: string | null;
};

const BASE_COLS = "id, slot_id, campaign_id, creative_id, visitor_id";

/**
* Look up the impression by short code or UUID.
*
* `src` is a newer column and migrations here are applied by hand, so the app
* can briefly run ahead of the schema. Ask for it, and if the projection fails
* because the column isn't there yet, retry without it rather than dropping the
* click — an unresolved click is a payout the publisher never sees. Only the
* UUID path is worth retrying: a lookup *by* short code cannot succeed before
* the migration anyway.
*/
async function findImpression(
sb: ReturnType<typeof serviceClient>,
id: string,
byCode: boolean,
): Promise<ImpressionRow | null> {
const column = byCode ? "short_code" : "id";
const { data } = await sb
.from("ad_impressions")
.select(`${BASE_COLS}, src`)
.eq(column, id)
.maybeSingle();
if (data) return data as ImpressionRow;
if (byCode) return null;

const { data: legacy } = await sb
.from("ad_impressions")
.select(BASE_COLS)
.eq(column, id)
.maybeSingle();
return (legacy as ImpressionRow) ?? null;
}

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 });
// Two address forms. New fills use a 12-character short code, which is what
// lets the URL fit inside a 44-col ASCII box. UUIDs are still accepted and
// must stay that way: click URLs from before the change are sitting in
// people's MOTDs, SSH banners and BBS screens, and those are not reissued.
const byCode = isShortCode(id);
if (!byCode && !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();
const imp = await findImpression(sb, id, byCode);
if (!imp) return NextResponse.redirect(fallback, { status: 302 });

const ip = clientIpFromHeaders(request.headers);
Expand Down Expand Up @@ -63,8 +108,10 @@ export async function GET(request: NextRequest, ctx: { params: Promise<{ id: str
// ?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.
// Prefer the tag recorded on the impression; fall back to the query string
// for the older URLs that still carry "&s=<tag>" inline.
const q = new URL(request.url).searchParams;
const src = q.get("s") ?? q.get("src");
const src = imp.src ?? q.get("s") ?? q.get("src");
return NextResponse.redirect(withTerminalUtm(dest, src), { status: 302 });
} catch {
return NextResponse.redirect(fallback, { status: 302 });
Expand Down
16 changes: 12 additions & 4 deletions app/api/ads/motd/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export async function GET(request: NextRequest) {
ip,
country: geo?.countryCode ?? null,
device,
// Recorded on the impression, so the printed click URL doesn't have to
// carry it. /a/<code> reads it back when it builds utm_content.
src: src || null,
});
}
// No slot given, or the slot is inactive / has no terminal inventory.
Expand All @@ -103,13 +106,18 @@ export async function GET(request: NextRequest) {
// 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;
//
// The click URL is printed as literal text, so every character here is a
// character of box width. A paid fill already carries the surface tag on
// its impression row, so nothing is appended — that's what keeps /a/<code>
// inside a 44-col box. House fills have no impression row, so theirs still
// rides the URL, where /h has the room for it.
const isHouse = fill.campaignId === "house";
const clickUrl = src && isHouse ? withParam(fill.clickUrl, "s", src) : fill.clickUrl;
const body = renderCreativeText(fill.creative, clickUrl, {
cols,
color,
label: fill.campaignId === "house" ? "CRAWLPROOF ADS" : undefined,
label: isHouse ? "CRAWLPROOF ADS" : undefined,
});
return new NextResponse(`${body}\n`, { status: 200, headers: h });
} catch {
Expand Down
57 changes: 42 additions & 15 deletions lib/ads/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { houseFill, HOUSE_AD_ROTATION_RATE } from "./house";
import { CREDIT_CENTS, DEFAULT_BID_CREDITS, PLATFORM_RATE } from "./pricing";
import { assessClickValidity, isBotDevice } from "./fraud";
import { runAuction } from "./auction";
import { generateShortCode } from "./shortcode";

// Server-side ad selection + metering. Runs under the service-role client so
// the public serving endpoints can read cross-tenant campaigns/creatives and
Expand Down Expand Up @@ -89,6 +90,13 @@ export type ServeContext = {
ip?: string | null;
country?: string | null;
device?: string | null;
/**
* Publisher's surface tag (?src=bbs, ?src=ssh-banner, …). Recorded on the
* impression rather than appended to the printed click URL, where it cost up
* to 35 columns of a box that has 40. The click handler reads it back off the
* row to build utm_content.
*/
src?: string | null;
};

// Returns a rendered fill for the slot, or null if the slot is inactive /
Expand Down Expand Up @@ -218,32 +226,51 @@ export async function serveAd(
if (!campaign) return null;

// Record the impression first so we have an id to bind the click to.
const { data: imp } = await sb
const base = {
slot_id: slotId,
campaign_id: campaign.id,
creative_id: pick.id,
visitor_id: ctx.visitorId ?? null,
ip_hash: hashIp(ctx.ip ?? null),
geo_country: ctx.country ?? null,
device: ctx.device ?? null,
billable: false,
tier,
};

// The short code is what lets a terminal click URL fit inside the box, and
// ctx.src records the publisher's surface tag on the row instead of in the
// printed URL. Both live behind `add column if not exists`, and migrations
// here are applied by hand — so if this deploy lands first, the insert would
// fail on the unknown columns and take *all* paid serving down with it.
// Retry once without them and fall back to the UUID click URL: a wide URL is
// a cosmetic problem, a dropped impression is a lost sale.
const shortCode = generateShortCode();
let { data: imp } = await sb
.from("ad_impressions")
.insert({
slot_id: slotId,
campaign_id: campaign.id,
creative_id: pick.id,
visitor_id: ctx.visitorId ?? null,
ip_hash: hashIp(ctx.ip ?? null),
geo_country: ctx.country ?? null,
device: ctx.device ?? null,
billable: false,
tier,
})
.select("id")
.insert({ ...base, short_code: shortCode, src: ctx.src ?? null })
.select("id, short_code")
.single();

if (!imp) {
({ data: imp } = await sb.from("ad_impressions").insert(base).select("id").single());
}

const impressionId = imp?.id ?? crypto.randomUUID();
// Only address the click by code once we know the code was actually stored —
// otherwise /a/<code> would resolve to nothing and the click would go
// unmetered and unpaid.
const clickRef =
imp && "short_code" in imp && imp.short_code ? (imp.short_code as string) : impressionId;
const creative = rowToCreative(pick);

// Click goes through our redirector so we can meter it, then lands on the
// destination with ?ref= applied. Terminals print the URL as literal text, so
// the terminal format gets the short /a/<impression> form — it resolves the
// the terminal format gets the short /a/<code> form — it resolves the
// slot/campaign/creative from the impression row instead of the query string.
const clickUrl =
format === TERMINAL_FORMAT_ID
? `${env.siteUrl}/a/${impressionId}`
? `${env.siteUrl}/a/${clickRef}`
: `${env.siteUrl}/api/ads/click?i=${impressionId}&s=${slotId}&c=${campaign.id}&cr=${pick.id}`;

return {
Expand Down
67 changes: 67 additions & 0 deletions lib/ads/shortcode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import crypto from "node:crypto";

// Short, URL-safe impression codes, so a paid terminal ad's click URL fits
// inside the ASCII box instead of dangling below it.
//
// The length is not arbitrary — it is what the narrowest supported box can
// afford:
//
// cols = 44 the minimum width /api/ads/motd accepts
// inner = cols - 4 = 40 usable columns between "| " and " |"
// "https://crawlproof.com/a/" 25 characters of fixed prefix
// ------------------------------------------------------------------
// 40 - 25 = 15 columns left for the code
//
// 12 leaves three columns of headroom for a longer origin (a staging host, a
// trailing slash in siteUrl) while still being 71 bits of entropy:
//
// 62^12 ~= 3.2e21 ~= 2^71
//
// For comparison the old form printed the raw impression UUID, 36 characters,
// which needed 61 columns and so never fit a 44-col box.
//
// Entropy matters because the code is the only thing standing between a
// stranger and a click charge on someone else's campaign: /a/<code> meters a
// click against the campaign named by the impression row. Guessing is the
// attack, so the space has to be far too large to sweep. It is deliberately
// well above the ~42 bits a 7-character code would have given.
export const SHORT_CODE_LENGTH = 12;

// Base62. No look-alike stripping: these are copy-pasted or clicked, not read
// aloud, and dropping characters would cost entropy we are already budgeting
// tightly.
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

export const SHORT_CODE_RE = new RegExp(`^[0-9A-Za-z]{${SHORT_CODE_LENGTH}}$`);

/** True when `v` looks like an impression short code (not a UUID). */
export function isShortCode(v: string | null | undefined): boolean {
return typeof v === "string" && SHORT_CODE_RE.test(v);
}

// 256 is not a multiple of 62, so a plain `byte % 62` would make the first four
// symbols marginally more likely. Rejection sampling keeps the distribution
// flat, which is cheap here and means the 71-bit figure above is honest.
const LIMIT = 256 - (256 % ALPHABET.length); // 248

/**
* A cryptographically random base62 code.
*
* Uniqueness is enforced by a unique index on the column, not by this function
* — at 71 bits a collision is not a practical concern, but the index makes it
* an error rather than a silently mis-attributed click.
*/
export function generateShortCode(length = SHORT_CODE_LENGTH): string {
let out = "";
while (out.length < length) {
// Over-fetch: on average ~3% of bytes are rejected, so one round is
// almost always enough.
const bytes = crypto.randomBytes(length - out.length + 8);
for (const b of bytes) {
if (b >= LIMIT) continue;
out += ALPHABET[b % ALPHABET.length];
if (out.length === length) break;
}
}
return out;
}
30 changes: 30 additions & 0 deletions supabase/migrations/20260731130000_ad_impression_short_codes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Short impression codes, so a paid terminal ad's click URL fits inside the
-- ASCII box.
--
-- A terminal ad prints its click URL as literal text inside a box the caller
-- sized. The old form, https://crawlproof.com/a/<uuid>, is 61 characters and
-- the narrowest supported box (44 cols) has 40 usable columns, so the URL was
-- always pushed outside the frame. A 12-character base62 code brings that to
-- 37 characters. See lib/ads/shortcode.ts for the width arithmetic.
--
-- Both columns are additive and nullable, and the application tolerates their
-- absence, so this migration is safe to apply before or after the deploy that
-- starts using them. Existing rows keep resolving through their UUID, which
-- /a/[id] still accepts — click URLs already printed into people's MOTDs and
-- SSH banners must not break.

alter table ad_impressions add column if not exists short_code text;

-- The publisher's surface tag (?src=bbs, ?src=ssh-banner, …). It used to ride
-- the printed click URL as "&s=<tag>", which cost up to 35 more columns in a
-- box that had none to give. Recording it on the impression instead means the
-- printed URL is just /a/<code>, and the click handler reads the tag back from
-- here. It also makes the tag queryable for per-surface reporting, which the
-- query-string form never was.
alter table ad_impressions add column if not exists src text;

-- Partial: only real codes are constrained, so the pre-existing rows (all
-- NULL) cost nothing and the index stays small.
create unique index if not exists ad_impressions_short_code_key
on ad_impressions (short_code)
where short_code is not null;
Loading
Loading