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
32 changes: 32 additions & 0 deletions app/api/ads/slots/[id]/install-embed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
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({
Expand All @@ -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 });
}
}
138 changes: 118 additions & 20 deletions lib/github/install-ad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -117,59 +165,109 @@ export async function installAdEmbed(input: InstallAdInput): Promise<InstallAdRe
return { status: "noop", path: targetPath, detail: `File not found: ${targetPath}` };
}

if (hasAdReference(file.content, input.slotId)) {
return { status: "noop", path: file.path, detail: `Ad embed already present in ${file.path}.` };
}

// Layout may already carry the embed (e.g. a re-run, or the publisher pasted
// it by hand). We still open a PR when there's a CSP file to patch.
const alreadyInstalled = hasAdReference(file.content, input.slotId);
const embed = embedForPath(input.slotId, format, file.path);
const updated = injectBeforeBodyClose(file.content, embed, file.path);
if (!updated) {
const updated = alreadyInstalled
? null
: injectBeforeBodyClose(file.content, embed, file.path);
if (!alreadyInstalled && !updated) {
return { status: "noop", path: file.path, detail: `No </body> 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 \`</body>\`.`,
updated
? `- Injected into \`${file.path}\` before \`</body>\`.`
: `- 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}`,
};
}
15 changes: 10 additions & 5 deletions lib/github/install-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
);
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
}
Expand Down Expand Up @@ -731,6 +735,7 @@ export async function installTracker(input: InstallInput): Promise<InstallResult
repo: input.repo,
ref: base,
root,
patch: patchCspForTracker,
});

if (!target && cspPatches.length === 0) {
Expand Down
7 changes: 7 additions & 0 deletions supabase/migrations/20260708120000_pr_runs_install_ad.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Ad-unit PR installs reuse project_pr_runs (like install_tracker / apply_fix /
-- audience_hub). Widen the kind check to allow 'install_ad'.
alter table public.project_pr_runs
drop constraint if exists project_pr_runs_kind_check;
alter table public.project_pr_runs
add constraint project_pr_runs_kind_check
check (kind in ('install_tracker', 'apply_fix', 'audience_hub', 'install_ad'));
Loading
Loading