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
3 changes: 3 additions & 0 deletions src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions src/views/ad-unit.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside class="ad-unit" data-ad-format={format} aria-label="Advertisement">
<div class="container">
{/* No allow-scripts, no allow-same-origin: the creative is static HTML.
The popup permissions let its click-through open a new tab. */}
<iframe
src={src}
title="Advertisement"
height="40"
loading="lazy"
scrolling="no"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
></iframe>
</div>
</aside>
);
};
2 changes: 2 additions & 0 deletions src/views/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -268,6 +269,7 @@ export const Layout: FC<PropsWithChildren<PageProps>> = (props) => {
<main id="main">
<div class="container">{children}</div>
</main>
{showsAd(path, noindex) && <AdUnit />}
{skills.length > 0 && (
<footer class="site-footer">
<div class="container footer-skills">
Expand Down
33 changes: 33 additions & 0 deletions test/views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,36 @@ test('a signed-out visitor is offered somewhere to hire from', async () => {
// Signed in, the nav already has a button to the form; two would be noise.
assert.match(nav({ id: 'u' }), /href="\/post"/, 'signed in, the form is still one click away');
});

test('the ad unit is a crawlproof frame on public pages only, and the policy frames it', async () => {
const { showsAd, AD_SLOT } = await import('../dist/views/ad-unit.js');
const { Layout } = await import('../dist/views/layout.js');
const { securityHeaders } = await import('../dist/server/middleware.js');
assert.equal(showsAd('/', false), true);
assert.equal(showsAd('/jobs/abc', false), true);
assert.equal(showsAd('/me/settings', false), false);
assert.equal(showsAd('/post-a-job', false), false);
assert.equal(showsAd('/tracker', false), false);
assert.equal(showsAd('/jobs/abc', true), false);

const page = (path: string) =>
String(
Layout({
title: 'A board',
viewer: null,
boardName: 'A board',
publicUrl: 'https://example.test',
path,
children: 'x',
}),
);
const home = page('/');
assert.equal(home.match(/crawlproof\.com\/api\/ads\/frame/g)?.length, 1, 'exactly one unit');
assert.ok(home.includes(`slot=${AD_SLOT}&amp;format=text_link`), home);
assert.ok(!home.includes('theme='), 'the frame answers prefers-color-scheme itself');
assert.ok(!page('/inbox').includes('/api/ads/frame'), 'no ad inside the inbox');

const headers = new Headers();
await securityHeaders()({ res: { headers } } as never, async () => undefined);
assert.match(headers.get('content-security-policy') ?? '', /frame-src https:\/\/crawlproof\.com/);
});
12 changes: 12 additions & 0 deletions web/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1099,3 +1099,15 @@ a.badge-plain:hover .badge {
.tracker-metrics .card-content { padding: 1rem; }
.tracker-missing { color: var(--warning); font-size: 0.9rem; margin: 0.25rem 0; }
.tracker-improve ul { margin: 0; padding-left: 1.25rem; display: grid; gap: 0.5rem; }

/* CrawlProof ad unit (src/views/ad-unit.tsx): one fluid text_link frame above the footer. */
.ad-unit {
margin: 1.5rem 0 0;
}
.ad-unit iframe {
display: block;
width: 100%;
height: 40px;
border: 0;
background: transparent;
}
2 changes: 1 addition & 1 deletion web/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* drifted, which is the only version of this rule that survives contact.
*/

const VERSION = 'ea250e72';
const VERSION = '4a4fd178';
const SHELL = `shell-${VERSION}`;
const ASSETS = ['/assets/app.css', '/assets/tokens.css', '/assets/icon.svg'];

Expand Down
Loading