Skip to content

Commit 4b4a73f

Browse files
authored
Fix ad reporting and throttle crawlers through x402 gateway (#259)
* Fix account ad reporting and throttle paid crawler access * Run ad SQL and crawler rate contracts in CI * Wait for PostgreSQL initialization and keep investigation local
1 parent 487da66 commit 4b4a73f

40 files changed

Lines changed: 1547 additions & 253 deletions

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,8 @@ jobs:
3131

3232
- name: Tests
3333
run: npm test
34+
35+
- name: Ad reporting and crawler limits (PostgreSQL + Redis)
36+
run: |
37+
node scripts/test-ad-token-earnings.mjs
38+
node --import tsx scripts/test-crawl-limits.mjs

‎app/(app)/dashboard/ads/earnings/page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const EMPTY: EarningsModel = {
2121
// Signed out: nothing was attempted, so nothing failed.
2222
statsUnavailable: false,
2323
totals: {
24+
advBilledClicks: 0, advFreeClicks: 0, pubBilledClicks: 0, pubFreeClicks: 0,
25+
advPaidImpressions: 0, advFreeImpressions: 0, pubPaidImpressions: 0, pubFreeImpressions: 0,
2426
spentCents: 0,
2527
earnedCents: 0,
2628
netCents: 0,

‎app/a/[id]/route.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { adClickIp } from "@/lib/ads/client-ip";
1717
import { parseDevice } from "@/lib/tracker/device";
1818
import { isShortCode } from "@/lib/ads/shortcode";
1919
import { env } from "@/lib/env";
20+
import { gate } from "@/lib/crawl-gateway";
21+
import { crawlerFamily } from "@/lib/crawl-policy";
2022

2123
export const runtime = "nodejs";
2224
export const dynamic = "force-dynamic";
@@ -67,6 +69,8 @@ async function findImpression(
6769
}
6870

6971
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
72+
const denied = await gate(request);
73+
if (denied) return denied;
7074
const fallback = env.siteUrl || "https://crawlproof.com";
7175
try {
7276
const { id } = await ctx.params;
@@ -82,12 +86,13 @@ export async function GET(request: NextRequest, ctx: { params: Promise<{ id: str
8286
if (!imp) return NextResponse.redirect(fallback, { status: 302 });
8387

8488
const ip = adClickIp(request.headers);
85-
const geo = await lookupGeo(ip).catch(() => null);
89+
const crawler = crawlerFamily(request.headers.get("user-agent"));
90+
const geo = crawler ? null : await lookupGeo(ip).catch(() => null);
8691
// Deliberately the STRICT classification here, unlike /api/ads/motd: a
8792
// terminal ad is served to curl, but it's clicked from a browser when the
8893
// reader follows the link. Anyone can curl this URL in a loop, so scripted
8994
// hits stay unbilled (recorded with valid=false) rather than paying out.
90-
const device = parseDevice(request.headers.get("user-agent")).deviceType;
95+
const device = crawler ? "bot" : parseDevice(request.headers.get("user-agent")).deviceType;
9196

9297
const dest = await resolveClick({
9398
impressionId: imp.id,
@@ -103,6 +108,7 @@ export async function GET(request: NextRequest, ctx: { params: Promise<{ id: str
103108
});
104109

105110
if (!dest) return NextResponse.redirect(fallback, { status: 302 });
111+
if (crawler) return NextResponse.redirect(dest, { status: 302, headers: { "cache-control": "no-store", "x-robots-tag": "noindex, nofollow" } });
106112

107113
// Terminal traffic is invisible in an advertiser's analytics without a tag
108114
// — there's no referrer from a shell. resolveClick already appended
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { authenticateBearer } from "@/lib/sp/apiAuth";
3+
import { createClient } from "@/lib/supabase/server";
4+
import { serviceClient } from "@/lib/supabase/service";
5+
import { readCrawlerActivity } from "@/lib/crawl-limits";
6+
7+
export const runtime = "nodejs";
8+
export const dynamic = "force-dynamic";
9+
10+
export async function GET(request: NextRequest) {
11+
let userId: string | undefined;
12+
if (request.headers.has("authorization")) {
13+
const auth = await authenticateBearer(request);
14+
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
15+
userId = auth.userId;
16+
} else {
17+
const sb = await createClient();
18+
userId = (await sb.auth.getUser()).data.user?.id;
19+
}
20+
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
21+
const { data: profile, error } = await serviceClient().from("profiles").select("is_admin").eq("id", userId).maybeSingle();
22+
if (error || !profile?.is_admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
23+
const requested = Number(request.nextUrl.searchParams.get("days") ?? 7);
24+
const days = Number.isInteger(requested) ? Math.min(31, Math.max(1, requested)) : 7;
25+
try {
26+
return NextResponse.json({ scope: "network", rangeDays: days, daily: await readCrawlerActivity(days) }, { headers: { "cache-control": "private, no-store" } });
27+
} catch {
28+
return NextResponse.json({ error: "Crawler activity is temporarily unavailable" }, { status: 503 });
29+
}
30+
}

‎app/api/ads/click/route.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ import { lookupGeo } from "@/lib/tracker/geo";
88
import { adClickIp } from "@/lib/ads/client-ip";
99
import { parseDevice } from "@/lib/tracker/device";
1010
import { env } from "@/lib/env";
11+
import { gate } from "@/lib/crawl-gateway";
12+
import { crawlerFamily } from "@/lib/crawl-policy";
1113

1214
export const runtime = "nodejs";
1315
export const dynamic = "force-dynamic";
1416

1517
export async function GET(request: NextRequest) {
18+
const denied = await gate(request);
19+
if (denied) return denied;
1620
const fallback = env.siteUrl || "https://crawlproof.com";
1721
try {
1822
const url = new URL(request.url);
@@ -23,8 +27,9 @@ export async function GET(request: NextRequest) {
2327
const visitorId = url.searchParams.get("v");
2428

2529
const ip = adClickIp(request.headers);
26-
const geo = await lookupGeo(ip).catch(() => null);
27-
const device = parseDevice(request.headers.get("user-agent")).deviceType;
30+
const crawler = crawlerFamily(request.headers.get("user-agent"));
31+
const geo = crawler ? null : await lookupGeo(ip).catch(() => null);
32+
const device = crawler ? "bot" : parseDevice(request.headers.get("user-agent")).deviceType;
2833

2934
const dest = await resolveClick({
3035
impressionId,

‎app/api/ads/v1/earnings/route.ts‎

Lines changed: 14 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,32 @@
1-
// /api/ads/v1/earnings — the account's ad money and delivery, for a token caller.
2-
//
3-
// GET ?days=7|30|90|365
4-
//
5-
// The same model /dashboard/ads/earnings renders, for something holding an API
6-
// token. It exists because the alternative for a client that wants fleet totals
7-
// is one /campaigns/[id] request per campaign, and the account is past 170 of
8-
// them — see `crawlproof dashboard`, which polls this on a timer.
9-
//
10-
// Same auth as the rest of /api/ads/v1/*: `Authorization: Bearer crp_…`.
11-
121
import { NextResponse, type NextRequest } from "next/server";
13-
142
import { serviceClient } from "@/lib/supabase/service";
153
import { authenticateBearer } from "@/lib/sp/apiAuth";
164
import { loadEarnings } from "@/lib/ads/earnings-data";
5+
import { loadTokenDelivery } from "@/lib/ads/token-earnings";
176

187
export const runtime = "nodejs";
198
export const dynamic = "force-dynamic";
209

21-
const ALLOWED_DAYS = [7, 30, 90, 365];
22-
23-
/** An unknown window falls back to 30 rather than reaching the query planner. */
2410
export function parseDays(raw: string | null): number {
2511
const n = Number(raw);
26-
return ALLOWED_DAYS.includes(n) ? n : 30;
12+
return [7, 30, 90, 365].includes(n) ? n : 30;
2713
}
2814

2915
export async function GET(req: NextRequest) {
3016
const auth = await authenticateBearer(req);
3117
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
32-
3318
const days = parseDays(req.nextUrl.searchParams.get("days"));
34-
const sb = serviceClient();
35-
// The service client has no RLS. loadEarnings filters every table by
36-
// owner_id itself, which is what makes passing it here safe.
37-
const model = await loadEarnings(sb, auth.userId, days);
38-
39-
// The windowed delivery figures come from RPCs that are `security definer`
40-
// and filter on `auth.uid()`. A service client has no auth.uid(), so they
41-
// return nothing and every impression count arrives as a confident zero.
42-
// The stats views are `security_invoker` and granted to service_role, so
43-
// they can be read directly. They are lifetime rather than windowed, which
44-
// is why this only replaces figures that came back empty, and why the answer
45-
// says which it gave you.
46-
const delivery = await lifetimeDelivery(sb, model);
47-
return NextResponse.json({ ...model, ...delivery });
48-
}
49-
50-
type StatsRow = {
51-
impressions: number | null;
52-
free_impressions: number | null;
53-
clicks: number | null;
54-
free_clicks: number | null;
55-
};
56-
57-
/**
58-
* What the stats views actually mean, which is not what the column names
59-
* suggest and is worth writing down once:
60-
*
61-
* impressions paid tier, non-duplicate
62-
* free_impressions free tier, non-duplicate -> delivery is the SUM of both
63-
* clicks **valid** clicks, either tier -> delivery is this alone
64-
* free_clicks free tier and **not valid** -> fraud/duplicate, NOT delivery
65-
*
66-
* So impressions add up and clicks do not. Adding `free_clicks` into clicks
67-
* would fold invalid clicks into the CTR, which is the one number a click
68-
* fraud problem would show up in.
69-
*/
70-
const sum = (rows: StatsRow[], key: keyof StatsRow) =>
71-
rows.reduce((total, row) => total + (Number(row[key]) || 0), 0);
72-
73-
/**
74-
* Read a stats view for a list of ids, in chunks.
75-
*
76-
* PostgREST puts `in.(…)` in the query string, and this account is past 180
77-
* campaigns, so one call is a 7KB URL that comes back empty rather than
78-
* erroring. That empty answer is exactly what a network with no delivery looks
79-
* like, which is how it went unnoticed.
80-
*/
81-
async function readStats(
82-
sb: ReturnType<typeof serviceClient>,
83-
view: "ad_campaign_stats" | "ad_slot_stats",
84-
key: "campaign_id" | "slot_id",
85-
ids: string[],
86-
): Promise<{ rows: StatsRow[]; failed: boolean }> {
87-
const rows: StatsRow[] = [];
88-
let failed = false;
89-
const CHUNK = 50;
90-
for (let i = 0; i < ids.length; i += CHUNK) {
91-
const { data, error } = await sb
92-
.from(view)
93-
.select("impressions, free_impressions, clicks, free_clicks")
94-
.in(key, ids.slice(i, i + CHUNK));
95-
if (error) failed = true;
96-
else rows.push(...((data ?? []) as StatsRow[]));
19+
try {
20+
const sb = serviceClient();
21+
const model = await loadEarnings(sb, auth.userId, days, loadTokenDelivery(sb, auth.userId, days));
22+
if (model.statsUnavailable) throw new Error("incomplete_reporting");
23+
return NextResponse.json({ ...model, deliveryWindow: "range", clickSemantics: "accepted_billed_plus_free" }, {
24+
headers: { "cache-control": "private, no-store" },
25+
});
26+
} catch {
27+
console.error("[ads] Earnings response unavailable");
28+
return NextResponse.json({ error: "Ad reporting is temporarily unavailable. Please retry.", statsUnavailable: true }, {
29+
status: 503, headers: { "retry-after": "5", "cache-control": "no-store" },
30+
});
9731
}
98-
return { rows, failed };
99-
}
100-
101-
async function lifetimeDelivery(
102-
sb: ReturnType<typeof serviceClient>,
103-
model: Awaited<ReturnType<typeof loadEarnings>>,
104-
) {
105-
const t = model.totals;
106-
const empty = !t.advImpressions && !t.advClicks && !t.pubImpressions && !t.pubClicks;
107-
if (!empty) return { deliveryWindow: "range" as const };
108-
109-
const campaignIds = model.campaigns.map((c) => c.id);
110-
const slotIds = model.slots.map((s) => s.id);
111-
if (!campaignIds.length && !slotIds.length) return { deliveryWindow: "range" as const };
112-
113-
const [c, s] = await Promise.all([
114-
readStats(sb, "ad_campaign_stats", "campaign_id", campaignIds),
115-
readStats(sb, "ad_slot_stats", "slot_id", slotIds),
116-
]);
117-
118-
return {
119-
deliveryWindow: "lifetime" as const,
120-
statsUnavailable: model.statsUnavailable || c.failed || s.failed,
121-
totals: {
122-
...t,
123-
advImpressions: sum(c.rows, "impressions") + sum(c.rows, "free_impressions"),
124-
advClicks: sum(c.rows, "clicks"),
125-
advFreeImpressions: sum(c.rows, "free_impressions"),
126-
advPaidImpressions: sum(c.rows, "impressions"),
127-
pubImpressions: sum(s.rows, "impressions") + sum(s.rows, "free_impressions"),
128-
pubClicks: sum(s.rows, "clicks"),
129-
pubFreeImpressions: sum(s.rows, "free_impressions"),
130-
pubPaidImpressions: sum(s.rows, "impressions"),
131-
invalidClicks: sum(c.rows, "free_clicks"),
132-
},
133-
};
13432
}

‎app/robots.txt/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ import { gateway } from "@/lib/crawl-gateway";
55
// crawlers are refused everywhere but /crawl (where they can buy a pass),
66
// retrieval crawlers are named as welcome, everyone else gets the rules below.
77
export const GET = robotsRoute(gateway, {
8-
disallow: ["/api/"],
8+
disallow: ["/api/", "/a/"],
99
});

0 commit comments

Comments
 (0)