From 80e7a454273e66e3c0ef052fbf547a79014335fd Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 8 Jul 2026 10:47:18 +0000 Subject: [PATCH] ads: make the ad-unit PR installer smart like the stats tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Submit PR to install" flow for ad slots only injected the embed before — it never touched the site's CSP, so on any publisher with a Content-Security-Policy the ad unit was silently blocked (the /ad.js script, the /api/ads/serve fetch, the creative iframe, and its images all get refused). The stats-tracker installer already patches CSP; this brings the ad installer to parity. - Generalize the tracker's CSP machinery: export addSourceToDirective / hasDirective / looksLikeCsp and make findCspPatchTargets take a `patch` fn so both installers share it. - Add patchCspForAds: appends the CrawlProof origin to script-src / script-src-elem / connect-src / img-src, and 'self' to frame-src / child-src (the ad is a same-origin srcdoc iframe). Append-only and conservative, same as the tracker. - installAdEmbed now opens a single PR that both injects the embed and patches every CSP config file, returns cspPaths, and handles the embed-already-present case as a CSP-only PR. - Record the install as a project_pr_runs row (kind=install_ad) so it shows up with the project's other automated PRs; widen the kind check constraint via migration. - Add contract tests for patchCspForAds and installAdEmbed. Co-Authored-By: Claude Opus 4.8 --- app/api/ads/slots/[id]/install-embed/route.ts | 32 ++++ lib/github/install-ad.ts | 138 +++++++++++++--- lib/github/install-tracker.ts | 15 +- .../20260708120000_pr_runs_install_ad.sql | 7 + tests/contract/install-ad.test.ts | 149 ++++++++++++++++++ 5 files changed, 316 insertions(+), 25 deletions(-) create mode 100644 supabase/migrations/20260708120000_pr_runs_install_ad.sql create mode 100644 tests/contract/install-ad.test.ts diff --git a/app/api/ads/slots/[id]/install-embed/route.ts b/app/api/ads/slots/[id]/install-embed/route.ts index 49537ab2..65d77da8 100644 --- a/app/api/ads/slots/[id]/install-embed/route.ts +++ b/app/api/ads/slots/[id]/install-embed/route.ts @@ -109,6 +109,31 @@ export async function POST(request: NextRequest, ctx: { params: Promise<{ id: st ); } + // Track the install as a PR run (mirrors the stats-tracker installer) so it + // shows up alongside other automated PRs for the project. + const { data: run } = await (svc as any) + .from("project_pr_runs") + .insert({ + project_id: slot.project_id, + owner_id: user.id, + kind: "install_ad", + installation_id: installationId!, + repo_owner: owner!, + repo_name: repo!, + status: "running", + }) + .select("id") + .single(); + const runId = run?.id as string | undefined; + + async function finalize(patch: Record) { + if (!runId) return; + await (svc as any) + .from("project_pr_runs") + .update({ ...patch, updated_at: new Date().toISOString() }) + .eq("id", runId); + } + try { const token = await getOrMintInstallationToken(installationId!); const result = await installAdEmbed({ @@ -118,9 +143,16 @@ export async function POST(request: NextRequest, ctx: { params: Promise<{ id: st slotId, targetPath: body.target_path, }); + await finalize({ + status: result.status, + pr_url: result.prUrl ?? null, + pr_number: result.prNumber ?? null, + branch_name: result.branch ?? null, + }); return NextResponse.json({ data: result }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); + await finalize({ status: "failed", error: msg }); return NextResponse.json({ error: msg }, { status: 500 }); } } diff --git a/lib/github/install-ad.ts b/lib/github/install-ad.ts index 1bb5a00a..426165e1 100644 --- a/lib/github/install-ad.ts +++ b/lib/github/install-ad.ts @@ -11,11 +11,57 @@ import { openPullRequest, putFile, } from "./repos"; -import { findInstallCandidates } from "./install-tracker"; +import { + addSourceToDirective, + findCspPatchTargets, + findInstallCandidates, + hasDirective, + looksLikeCsp, +} from "./install-tracker"; const AD_ORIGIN = env.siteUrl.replace(/\/$/, ""); const BRANCH_PREFIX = "crawlproof/install-ad-embed"; +// Append CrawlProof's origin to the CSP directives the ad unit needs, so the +// embed isn't silently blocked on sites that ship a Content-Security-Policy. +// The unit (1) loads /ad.js (script-src), (2) fetches /api/ads/serve +// (connect-src), and (3) renders the creative — including its images — inside +// a same-origin srcdoc iframe that inherits the host page's CSP (img-src for +// the artwork; frame-src/child-src must allow 'self' for the srcdoc frame). +// Append-only and conservative: only rewrites files that already look like a +// CSP, and never adds a directive that wasn't there. +export function patchCspForAds(content: string): string | null { + if (!looksLikeCsp(content)) return null; + let updated = content; + + const addOrigin = (directive: string, fallback = "default-src") => { + if (hasDirective(updated, directive)) { + updated = addSourceToDirective(updated, directive, AD_ORIGIN); + } else if (fallback && hasDirective(updated, fallback)) { + updated = addSourceToDirective(updated, fallback, AD_ORIGIN); + } + }; + + addOrigin("script-src"); // load /ad.js + if (hasDirective(updated, "script-src-elem")) { + updated = addSourceToDirective(updated, "script-src-elem", AD_ORIGIN); + } + addOrigin("connect-src"); // fetch /api/ads/serve + addOrigin("img-src"); // creative + house artwork inside the srcdoc iframe + + // The ad is a same-origin srcdoc iframe: a restrictive frame-src/child-src + // must allow 'self'. There's no origin to add — srcdoc frames take the host's + // origin — so we only widen these directives when they already exist. + if (hasDirective(updated, "frame-src")) { + updated = addSourceToDirective(updated, "frame-src", "'self'"); + } + if (hasDirective(updated, "child-src")) { + updated = addSourceToDirective(updated, "child-src", "'self'"); + } + + return updated === content ? null : updated; +} + export interface InstallAdInput { token: string; owner: string; @@ -33,6 +79,8 @@ export interface InstallAdResult { prNumber?: number; branch?: string; path?: string; + /** CSP config files patched so the ad unit isn't blocked. */ + cspPaths?: string[]; detail: string; } @@ -117,59 +165,109 @@ export async function installAdEmbed(input: InstallAdInput): Promise tag in ${file.path}.` }; } - const branch = `${BRANCH_PREFIX}-${input.slotId.slice(0, 8)}-${Date.now().toString(36)}`; - await createBranch({ + // Patch the site's CSP so the browser can load /ad.js, reach /api/ads/serve, + // and render the creative iframe — mirrors the stats-tracker installer. + const root = (input.rootPath ?? "").replace(/^\/+/, "").replace(/\/+$/, ""); + const cspPatches = await findCspPatchTargets({ token: input.token, owner: input.owner, repo: input.repo, - newBranch: branch, - fromBranch: base, + ref: base, + root, + patch: patchCspForAds, }); - await putFile({ + + // Nothing to do: embed present and no CSP needs widening. + if (alreadyInstalled && cspPatches.length === 0) { + return { + status: "noop", + path: file.path, + detail: `Ad embed already present in ${file.path}; no CSP changes needed.`, + }; + } + + const branch = `${BRANCH_PREFIX}-${input.slotId.slice(0, 8)}-${Date.now().toString(36)}`; + await createBranch({ token: input.token, owner: input.owner, repo: input.repo, - path: file.path, - branch, - message: "Add CrawlProof ad unit", - contentUtf8: updated, - sha: file.sha, + newBranch: branch, + fromBranch: base, }); + if (updated) { + await putFile({ + token: input.token, + owner: input.owner, + repo: input.repo, + path: file.path, + branch, + message: "Add CrawlProof ad unit", + contentUtf8: updated, + sha: file.sha, + }); + } + for (const patch of cspPatches) { + await putFile({ + token: input.token, + owner: input.owner, + repo: input.repo, + path: patch.path, + branch, + message: "Allow CrawlProof ads in CSP", + contentUtf8: patch.updated, + sha: patch.sha, + }); + } + + const cspPaths = cspPatches.map((p) => p.path); + const cspBody = cspPaths.length + ? `\n- Allowed \`${AD_ORIGIN}\` in your CSP (${cspPaths + .map((p) => `\`${p}\``) + .join(", ")}) so the ad unit isn't blocked.` + : ""; const pr = await openPullRequest({ token: input.token, owner: input.owner, repo: input.repo, head: branch, base, - title: "Add CrawlProof ad unit", + title: updated ? "Add CrawlProof ad unit" : "Allow CrawlProof ads in CSP", body: [ "This PR adds the CrawlProof ad unit so this site can show network ads and earn crypto for clicks.", "", `- Slot: \`${input.slotId}\``, `- Format: \`${format}\``, - `- Injected into \`${file.path}\` before \`\`.`, + updated + ? `- Injected into \`${file.path}\` before \`\`.` + : `- Ad embed already present in \`${file.path}\`.`, + cspBody, "", "The unit renders inside a sandboxed iframe and never blocks page load. Manage the slot at " + `${AD_ORIGIN}/ads/slots`, ].join("\n"), }); + const cspDetail = cspPaths.length ? ` Patched CSP in ${cspPaths.join(", ")}.` : ""; return { status: "opened", prUrl: pr.html_url, prNumber: pr.number, branch, path: file.path, - detail: `Opened PR #${pr.number} injecting the ad unit into ${file.path}.`, + cspPaths, + detail: updated + ? `Opened PR #${pr.number} injecting the ad unit into ${file.path}.${cspDetail}` + : `Opened PR #${pr.number} to allow CrawlProof ads in your CSP.${cspDetail}`, }; } diff --git a/lib/github/install-tracker.ts b/lib/github/install-tracker.ts index b9401c56..d3a90692 100644 --- a/lib/github/install-tracker.ts +++ b/lib/github/install-tracker.ts @@ -447,7 +447,7 @@ export async function previewInstallAtPath(input: { }; } -function addSourceToDirective(content: string, directive: string, source: string) { +export function addSourceToDirective(content: string, directive: string, source: string) { const re = new RegExp( `(${directive}\\b[^;"\`\\n\\r]*)(?=[;"\`\\n\\r]|$)`, "gi", @@ -463,11 +463,11 @@ function addSourceToDirective(content: string, directive: string, source: string return changed ? next : content; } -function hasDirective(content: string, directive: string) { +export function hasDirective(content: string, directive: string) { return new RegExp(`${directive}\\b`, "i").test(content); } -function looksLikeCsp(content: string) { +export function looksLikeCsp(content: string) { return /Content-Security-Policy|script-src|script-src-elem|default-src|connect-src/i.test( content, ); @@ -507,12 +507,16 @@ export function patchCspForTracker(content: string): string | null { return updated === content ? null : updated; } -async function findCspPatchTargets(input: { +// Scans the repo for CSP config files and returns the ones that `patch` +// rewrites (append-only). Reused by both the tracker and ad installers — the +// only difference is which origins/directives `patch` touches. +export async function findCspPatchTargets(input: { token: string; owner: string; repo: string; ref: string; root: string; + patch: (content: string) => string | null; }) { const found = new Map< string, @@ -530,7 +534,7 @@ async function findCspPatchTargets(input: { ref: input.ref, }); if (!file) return; - const updated = patchCspForTracker(file.content); + const updated = input.patch(file.content); if (updated) { found.set(file.path, { ...file, updated }); } @@ -731,6 +735,7 @@ export async function installTracker(input: InstallInput): Promise { + const files = new Map(); + return { + files, + getRepo: vi.fn(async () => ({ + default_branch: "main", + full_name: "owner/repo", + private: false, + id: 1, + })), + getFileContent: vi.fn(async ({ path }: { path: string }) => { + const content = files.get(path); + if (!content) return null; + return { path, sha: `sha-${path}`, content }; + }), + searchRepoCode: vi.fn(async () => []), + createBranch: vi.fn(async () => ({ created: true })), + putFile: vi.fn(async ({ path }: { path: string; contentUtf8: string }) => ({ + content: { sha: `new-sha-${path}`, path }, + commit: { sha: "commit-sha" }, + })), + openPullRequest: vi.fn(async () => ({ + html_url: "https://github.test/owner/repo/pull/7", + number: 7, + state: "open", + })), + }; +}); + +vi.mock("@/lib/github/repos", () => ({ + getRepo: github.getRepo, + getFileContent: github.getFileContent, + searchRepoCode: github.searchRepoCode, + createBranch: github.createBranch, + putFile: github.putFile, + openPullRequest: github.openPullRequest, +})); + +import { installAdEmbed, patchCspForAds } from "@/lib/github/install-ad"; + +const ORIGIN = "http://localhost:3000"; + +describe("patchCspForAds", () => { + it("allows the CrawlProof origin on the directives the ad unit needs", () => { + const before = `const csp = [ + "default-src 'self'", + "script-src 'self'", + "connect-src 'self'", + "img-src 'self' data:", + "frame-src https://www.youtube.com", +].join('; ');`; + + const after = patchCspForAds(before); + expect(after).not.toBeNull(); + // ad.js, /api/ads/serve, and creative artwork. + expect(after).toContain(`script-src 'self' ${ORIGIN}`); + expect(after).toContain(`connect-src 'self' ${ORIGIN}`); + expect(after).toContain(`img-src 'self' data: ${ORIGIN}`); + // The srcdoc ad iframe is same-origin — a restrictive frame-src needs 'self'. + expect(after).toContain("frame-src https://www.youtube.com 'self'"); + // Idempotent. + expect(patchCspForAds(after!)).toBeNull(); + }); + + it("returns null for content that isn't a CSP", () => { + expect(patchCspForAds("export const x = 1;")).toBeNull(); + }); +}); + +describe("installAdEmbed", () => { + beforeEach(() => { + github.files.clear(); + github.getRepo.mockClear(); + github.getFileContent.mockClear(); + github.searchRepoCode.mockClear(); + github.createBranch.mockClear(); + github.putFile.mockClear(); + github.openPullRequest.mockClear(); + }); + + it("injects the embed and patches CSP in one PR", async () => { + github.files.set( + "app/layout.tsx", + "export default function RootLayout({ children }) {\n return {children};\n}\n", + ); + github.files.set( + "next.config.ts", + `const csp = ["default-src 'self'", "script-src 'self'"].join('; ');\nexport default {};\n`, + ); + + const result = await installAdEmbed({ + token: "token", + owner: "owner", + repo: "repo", + slotId: "slot-abc", + }); + + expect(result.status).toBe("opened"); + expect(result.path).toBe("app/layout.tsx"); + expect(result.cspPaths).toContain("next.config.ts"); + + const written = github.putFile.mock.calls.map((c) => c[0].path); + expect(written).toContain("app/layout.tsx"); + expect(written).toContain("next.config.ts"); + }); + + it("opens a CSP-only PR when the embed is already present", async () => { + github.files.set( + "app/layout.tsx", + `export default function RootLayout({ children }) {\n return {children}
;\n}\n`, + ); + github.files.set( + "next.config.ts", + `const csp = ["default-src 'self'", "script-src 'self'"].join('; ');\nexport default {};\n`, + ); + + const result = await installAdEmbed({ + token: "token", + owner: "owner", + repo: "repo", + slotId: "slot-abc", + }); + + expect(result.status).toBe("opened"); + expect(result.cspPaths).toContain("next.config.ts"); + const written = github.putFile.mock.calls.map((c) => c[0].path); + expect(written).not.toContain("app/layout.tsx"); + expect(written).toContain("next.config.ts"); + }); + + it("no-ops when the embed exists and no CSP needs changes", async () => { + github.files.set( + "app/layout.tsx", + `export default function RootLayout({ children }) {\n return {children}
;\n}\n`, + ); + + const result = await installAdEmbed({ + token: "token", + owner: "owner", + repo: "repo", + slotId: "slot-abc", + }); + + expect(result.status).toBe("noop"); + expect(github.openPullRequest).not.toHaveBeenCalled(); + }); +});