diff --git a/apps/web/public/static/css/style.css b/apps/web/public/static/css/style.css index d3485eb..8b00818 100644 --- a/apps/web/public/static/css/style.css +++ b/apps/web/public/static/css/style.css @@ -776,6 +776,47 @@ a:hover { margin-top: 2.2rem; } +/* ------------------------------------------------------------------- ads */ + +/* One sponsored line, sold through CrawlProof. Sized here rather than by an ad + script, so it occupies the same space before and after it loads and moves + nothing underneath it. + + An unsold slot answers with a blank document, and nothing on this side can + see that across an origin — so the unit is styled as a strip of whitespace + with no border, no ground and no label of its own. Empty, it reads as the gap + between two sections, which is where it was put. */ +.ad-unit { + display: block; + margin: 2.6rem 0; +} +.ad-unit iframe { + display: block; + border: 0; + max-width: 100%; +} +/* The fluid format: the creative lays itself out at whatever width it is given + and drops its second line on a narrow one. */ +.ad-unit[data-ad-format="text_link"] iframe { + width: 100%; + height: 40px; +} +/* The banner creatives are laid out at a fixed pixel width inside the frame, so + a narrowed frame would crop one rather than reflow it. They keep their own + width and centre instead. */ +.ad-unit:not([data-ad-format="text_link"]) iframe { + margin-inline: auto; +} +.ad-label { + display: block; + margin-bottom: 0.3rem; + font-family: var(--mono); + font-size: 0.6rem; + letter-spacing: 0.17em; + text-transform: uppercase; + color: var(--text-faint); +} + /* --------------------------------------------------------------- test page */ /* Test pages run as a rack: the instrument list stays put, the bench changes. */ diff --git a/apps/web/src/app.js b/apps/web/src/app.js index f0ebfba..503f006 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -35,7 +35,8 @@ app.use('*', async (c, next) => { /** * The analytics script, when one is configured, is the only third-party origin - * the policy below has to make room for. + * the policy below has to make room for in `script-src`. The ad frame is the + * other third party, and it needs no script source at all. */ const analyticsOrigin = (() => { if (!config.analytics.src) return null; @@ -71,6 +72,20 @@ const CSP = [ `connect-src 'self'${analyticsOrigin ? ` ${analyticsOrigin}` : ''}`, "worker-src 'self'", "manifest-src 'self'", + /** + * Advertising costs exactly one directive, and deliberately so. The ad is a + * plain cross-origin document in an iframe, which carries its own policy, so + * nothing else here has to move. The vendor's own snippet would have needed + * `script-src` for its tag, `connect-src` for the fetch behind it, and then — + * because it injects the creative as `srcdoc`, and a srcdoc document inherits + * the embedder's policy — `'unsafe-inline'` in `style-src` plus a wide-open + * `img-src` for every page on the site. See AdUnit.jsx. + * + * Omitted rather than set to 'none' when there is no slot: `default-src` + * already keeps frames to this origin, and a bare 'none' would be a rule + * about something nothing on the site does. + */ + ...(config.ads.slot ? [`frame-src ${config.ads.origin}`] : []), ].join('; '); /** diff --git a/apps/web/src/components/AdUnit.jsx b/apps/web/src/components/AdUnit.jsx new file mode 100644 index 0000000..2573614 --- /dev/null +++ b/apps/web/src/components/AdUnit.jsx @@ -0,0 +1,62 @@ +import { config } from '@d3vices/config'; + +/** + * One CrawlProof ad unit, embedded as a script-free cross-origin iframe. + * + * The generated install snippet loads `crawlproof.com/ad.js`, which fills each + * placeholder with a `srcdoc` iframe. A srcdoc document inherits the embedding + * page's Content-Security-Policy, so the creative's own `