From eef5c6149c868d70a8d6a372d894ef1973a33106 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 19 Oct 2026 07:48:52 +0000 Subject: [PATCH] Add a CrawlProof ad unit above the footer on public pages One text_link unit served as a cross-origin /api/ads/frame iframe for slot 9c3d6e3d-ae1f-43ff-b6d9-4f42f11725ce. The board's strict CSP gains only frame-src https://crawlproof.com; no ad.js, no extra script or storage. Skipped on noindex pages and on flows (tracker, post, me, inbox, auth...). Co-Authored-By: Claude Opus 5.5 --- src/server/middleware.ts | 3 ++ src/views/ad-unit.tsx | 66 ++++++++++++++++++++++++++++++++++++++++ src/views/layout.tsx | 2 ++ test/views.test.ts | 33 ++++++++++++++++++++ web/public/app.css | 12 ++++++++ web/public/sw.js | 2 +- 6 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/views/ad-unit.tsx diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 5a5805a..c414539 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -107,6 +107,9 @@ export function securityHeaders( `frame-ancestors 'none'`, `object-src 'none'`, `script-src 'self' https://crawlproof.com`, + // The one ad unit is a cross-origin CrawlProof frame (/api/ads/frame), so + // it costs this entry and nothing else: no script, no style, no storage. + `frame-src https://crawlproof.com`, `style-src 'self' 'unsafe-inline'`, // Employer logos and avatars come from wherever the employer hosts them. // 'self' has to be listed explicitly: omitting it works in production and diff --git a/src/views/ad-unit.tsx b/src/views/ad-unit.tsx new file mode 100644 index 0000000..a61b4d7 --- /dev/null +++ b/src/views/ad-unit.tsx @@ -0,0 +1,66 @@ +/** + * One CrawlProof ad unit, as a script-free cross-origin iframe. + * + * `ad.js` fills a placeholder with a `srcdoc` iframe, and a srcdoc document + * inherits this page's Content-Security-Policy: the creative's inline styles + * and remote images would only render with a far looser policy than this + * board sends. `/api/ads/frame` serves the same creative as a real + * cross-origin document, so the cost here is one `frame-src` entry, no + * third-party JavaScript and nothing written to this origin's storage. + * Impressions are metered server-side exactly as on the script path. + * + * The format is `text_link`, the one fluid creative: 40px tall at any width, + * with its own "Sponsored" mark. Banners are laid out at a fixed pixel width + * and would be cropped in a narrow column, and choosing by viewport needs a + * script. No `&theme=`: the frame answers prefers-color-scheme itself. + */ +import type { FC } from 'hono/jsx'; + +export const AD_ORIGIN = 'https://crawlproof.com'; +export const AD_SLOT = '9c3d6e3d-ae1f-43ff-b6d9-4f42f11725ce'; + +/** First path segments that never carry an ad: flows, accounts, dashboards, admin. */ +const NO_AD_SEGMENTS = new Set([ + 'tracker', + 'fleets', + 'post', + 'post-a-job', + 'me', + 'inbox', + 'notifications', + 'applications', + 'admin', + 'login', + 'auth', + 'device', + 'invoices', + 'coinpay', + 'revisions', +]); + +/** Public, indexable pages only. A noindex page is a dashboard or a one-off flow. */ +export function showsAd(path: string, noindex?: boolean): boolean { + if (noindex) return false; + const first = path.split(/[/?#]/)[1] ?? ''; + return !NO_AD_SEGMENTS.has(first); +} + +export const AdUnit: FC<{ format?: 'text_link' }> = ({ format = 'text_link' }) => { + const src = `${AD_ORIGIN}/api/ads/frame?slot=${encodeURIComponent(AD_SLOT)}&format=${encodeURIComponent(format)}`; + return ( + + ); +}; diff --git a/src/views/layout.tsx b/src/views/layout.tsx index 11f3f1c..305c33f 100644 --- a/src/views/layout.tsx +++ b/src/views/layout.tsx @@ -16,6 +16,7 @@ import type { SkillCount } from '../core/skills.ts'; import { pathForQuery } from '../core/landing.ts'; import { EMPTY_QUERY } from '../schema/query.ts'; import { pfsFooter } from './pfs-footer.ts'; +import { AdUnit, showsAd } from './ad-unit.tsx'; export interface PageProps { title: string; @@ -268,6 +269,7 @@ export const Layout: FC> = (props) => {
{children}
+ {showsAd(path, noindex) && } {skills.length > 0 && (