diff --git a/workers/website-lambda/README.md b/workers/website-lambda/README.md index cb89f2cb..f6d23c8b 100644 --- a/workers/website-lambda/README.md +++ b/workers/website-lambda/README.md @@ -70,9 +70,32 @@ dynamically — the `nodejs22.x` runtime provides AWS SDK v3, so it is not bundl ever lacks it, the dynamic import degrades to fail-closed instead of crashing init; the fallback is to `npm i` it and include `node_modules` in the deploy zip. +## Page variants (`.plain.html` and `.md`) + +AEM serves every page in two head-less variants as well: `.plain.html` +(body markup only) and `.md` (Markdown). Neither includes the page's +``, so the `` check can't run on +the variant itself. For anonymous visitors the Lambda handles them as follows: + +1. [`lib/gate.js`](./lib/gate.js) `getCanonicalPagePath` maps the variant to its + page (`/a/b.plain.html` → `/a/b`, `/index.md` → `/`, `/a/index.md` → `/a/`). +2. Before proxying the variant, [`index.js`](./index.js) `isPublicCanonicalPage` + fetches that page from AEM and runs `isPrivateHtml` on it. The lookup fails + closed: a private page, a non-`200` or redirect, a non-HTML response, or a + fetch error returns `404`, and the variant is never fetched. +3. A public variant is then served with its audience blocks stripped: + `filterAudienceBlocks` handles `.plain.html` markup (no `
` wrapper), and + `filterAudienceMarkdown` removes `Name (audience private)` block tables from + `.md`. + +Authenticated requests skip the canonical lookup. For anonymous requests it +adds one origin fetch per variant request that reaches the Lambda; the result is +cached like any other anonymous page (see "Content caching"). Both variants fall +under the default CloudFront behavior, so they always reach the Lambda. + ## CloudFront routing -Only HTML and JSON vary by viewer, so only they go through the Lambda. Everything +Only HTML, Markdown (`.md`), and JSON vary by viewer, so only they go through the Lambda. Everything else goes straight from CloudFront to AEM. | Behavior (in order) | Origin | Cache policy (prod) | Cache policy (stage) | diff --git a/workers/website-lambda/index.js b/workers/website-lambda/index.js index a07ce493..0d152a7a 100644 --- a/workers/website-lambda/index.js +++ b/workers/website-lambda/index.js @@ -35,8 +35,10 @@ import { fetchFromAem } from './handlers/aem.js'; import { createSession, deleteSession } from './handlers/auth.js'; import { readSession, DEFAULT_SESSION_COOKIE_NAME } from './lib/session.js'; -import { classifyPublicPath, isPageLike, isPrivateHtml, PUBLIC_FILTER_PATHS } from './lib/gate.js'; -import { filterAudienceBlocks } from './lib/audience.js'; +import { + classifyPublicPath, getCanonicalPagePath, isPageLike, isPrivateHtml, PUBLIC_FILTER_PATHS, +} from './lib/gate.js'; +import { filterAudienceBlocks, filterAudienceMarkdown } from './lib/audience.js'; import { filterPrivateEntries, compactEntries, collectPrivatePaths } from './lib/query-index.js'; import { filterSitemap, rewriteSitemapHosts } from './lib/sitemap.js'; import { toGatedEtag, toUpstreamIfNoneMatch } from './lib/etag.js'; @@ -213,21 +215,23 @@ const isAuthenticated = async (request) => { return (await readSession(cookie, env.SESSION_SECRET, Date.now())) !== null; }; -// Post-fetch processing of a proxied HTML page, in two passes that both need -// the body (which lives in the HTML and so can only be inspected after -// proxying): +// Post-fetch processing of a proxied page, in two passes that both need the +// body (which lives in the HTML and so can only be inspected after proxying): // 1. Meta gate (anonymous only): a page that opts into privacy with // becomes a 404, indistinguishable -// from a path that does not exist. Authenticated viewers see it. +// from a path that does not exist. Authenticated viewers see it. Skipped +// for head-less variants (`variant`): a .plain.html body has no to +// scan, so route() has already gated it on the canonical page instead. // 2. Audience blocks: content blocks the viewer must not see are stripped // (audience-private for anonymous, audience-public for authenticated) so -// private markup never leaves the edge. -// Non-HTML/non-200 responses (assets, redirects, the AEM 404 for a missing -// page) pass through untouched without reading the body. content-length and -// content-encoding are stripped in toLambdaResponse, so re-wrapping the -// already-read body here stays consistent. `anonPage` marks an anonymous -// page-like request, whose filtered response gets a gated ETag. -const processHtmlResponse = async (resp, authed, anonPage) => { +// private markup never leaves the edge - from HTML (full page or +// .plain.html) and from the .md variant's Markdown alike. +// Non-HTML/Markdown or non-200 responses (assets, redirects, the AEM 404 for a +// missing page) pass through untouched without reading the body. +// content-length and content-encoding are stripped in toLambdaResponse, so +// re-wrapping the already-read body here stays consistent. `anonPage` marks an +// anonymous page-like request, whose filtered response gets a gated ETag. +const processHtmlResponse = async (resp, authed, anonPage, variant = false) => { const contentType = resp.headers.get('content-type') || ''; // Revalidation of a cached anonymous page. route() only forwards a gated // If-None-Match (a tag this gate version issued), so this 304 confirms a copy @@ -241,10 +245,14 @@ const processHtmlResponse = async (resp, authed, anonPage) => { // Only a full 200 body can be gated. Any other success (e.g. a 206 slice) // would skip the private-page check, so fail closed for anonymous pages. if (anonPage && resp.status > 200 && resp.status < 300) { return notFound(); } - if (resp.status !== 200 || !contentType.includes('text/html')) { return resp; } + const isHtml = contentType.includes('text/html'); + const isMarkdown = contentType.includes('text/markdown'); + if (resp.status !== 200 || !(isHtml || isMarkdown)) { return resp; } const body = await resp.text(); - if (!authed && isPrivateHtml(body)) { return notFound(); } - const filtered = filterAudienceBlocks(body, authed); + if (!authed && !variant && isPrivateHtml(body)) { return notFound(); } + const filtered = isMarkdown + ? filterAudienceMarkdown(body, authed) + : filterAudienceBlocks(body, authed); const out = new Response(filtered, resp); // Filtered per viewer: anonymous gets the public view (short shared TTL), // authenticated stays no-store. The cookie-keyed cache keeps them separate. @@ -253,6 +261,35 @@ const processHtmlResponse = async (resp, authed, anonPage) => { return out; }; +// Privacy check for a head-less page variant (.plain.html / .md): neither body +// carries the page's , so the audience meta is read from the canonical +// page instead. GETs the canonical path from AEM through the same upstream +// setup as the variant (origin, credential, no cookies), dropping conditional +// headers so a revalidation can't turn it into a bodiless 304, and not +// following redirects. Fails closed: only a 200 HTML canonical page without +// the private meta clears the variant; private, missing, redirected, non-HTML, +// or a fetch error all answer false, and the caller 404s. +const isPublicCanonicalPage = async (req, url, canonicalPath) => { + try { + const canonicalUrl = new URL(url.href); + canonicalUrl.pathname = canonicalPath; + canonicalUrl.search = ''; + const headers = new Headers(req.headers); + for (const name of ['if-none-match', 'if-modified-since', 'if-match', 'if-unmodified-since', 'if-range', 'range']) { + headers.delete(name); + } + const base = new Request(canonicalUrl, { method: 'GET', headers }); + const upstream = await formatRequest(base, canonicalUrl); + const resp = await fetch(upstream, { cache: 'no-store', redirect: 'manual' }); + if (resp.status !== 200) { return false; } + if (!(resp.headers.get('content-type') || '').includes('text/html')) { return false; } + return !isPrivateHtml(await resp.text()); + } catch (err) { + console.error('website-lambda: canonical privacy check failed; denying variant:', err); + return false; + } +}; + // Post-fetch transform for the query-index JSON: for anonymous visitors strip // the private rows and the whole `audience` column (so private paths/titles/ // excerpts never reach an anonymous client, and it can't even tell which rows @@ -432,6 +469,18 @@ const route = async (req) => { const verdict = authed ? 'allow' : classifyPublicPath(url.pathname); if (verdict === 'deny') { return notFound(); } + // Head-less page variants (.plain.html / .md) carry no , so the meta + // gate can't run on their body. For an anonymous visitor, settle privacy on + // the canonical page *before* proxying the variant; a private (or + // unverifiable) page is a 404 and the variant is never fetched. Runs for + // every method, so a HEAD can't probe a private page either. + const canonicalPath = getCanonicalPagePath(url.pathname); + const variant = canonicalPath !== null; + if (!authed && verdict === 'gate' && variant + && !(await isPublicCanonicalPage(req, url, canonicalPath))) { + return notFound(); + } + // Read the compact opt-in before formatSearchParams strips it (it keeps only // limit/offset/sheet for JSON) - the query-index transform below reads it. const compact = url.searchParams.get('compact') === 'true'; @@ -525,10 +574,10 @@ const route = async (req) => { // Every other proxied HTML page is processed - not just 'gate' pages - so // allow-listed content (e.g. the homepage, which carries audience blocks) is - // filtered too. processHtmlResponse no-ops on non-HTML responses. `anonPage` - // tells it a bodiless 304 is a gated page revalidation that keeps the short - // anon TTL and gated tag, vs an asset 304 (leave AEM's headers). - return processHtmlResponse(resp, authed, anonPage); + // filtered too. processHtmlResponse no-ops on anything but HTML/Markdown. + // `anonPage` tells it a bodiless 304 is a gated page revalidation that keeps + // the short anon TTL and gated tag, vs an asset 304 (leave AEM's headers). + return processHtmlResponse(resp, authed, anonPage, variant); }; /* diff --git a/workers/website-lambda/index.variants.test.js b/workers/website-lambda/index.variants.test.js new file mode 100644 index 00000000..94df19a5 --- /dev/null +++ b/workers/website-lambda/index.variants.test.js @@ -0,0 +1,217 @@ +import { + describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach, +} from 'vitest'; +import { signToken } from './lib/session.js'; +import { GATE_ETAG_SUFFIX } from './lib/etag.js'; + +// Routing tests for the head-less page variant gate (.plain.html / .md). The +// global fetch is stubbed with a fake AEM origin keyed by path, so both the +// proxied variant and the canonical-page privacy lookup stay local - no +// network, no real session tokens. + +const TEST_ENV = { + AEM_ORG: 'adobe', + AEM_SITE: 'spectrum-hub', + SESSION_SECRET: 'test-secret', +}; +let savedEnv; +let handler; + +beforeAll(async () => { + savedEnv = Object.fromEntries(Object.keys(TEST_ENV).map((key) => [key, process.env[key]])); + Object.assign(process.env, TEST_ENV); + ({ handler } = await import('./index.js')); +}); + +afterAll(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } + } +}); + +const html = (head, main) => `${head}
${main}
`; +const PRIVATE_META = ''; +const PRIVATE_BLOCK = ''; +const PUBLIC_BLOCK = ''; + +const mdTable = (header, body) => { + const width = Math.max(header.length, body.length) + 2; + const border = `+${'-'.repeat(width)}+`; + const row = (text) => `| ${text.padEnd(width - 2)} |`; + return [border, row(header), border, row(body), border].join('\n'); +}; + +const HTML_TYPE = { 'content-type': 'text/html; charset=utf-8' }; +const MD_TYPE = { 'content-type': 'text/markdown; charset=utf-8' }; + +// The fake AEM origin: path -> () => Response. +const PAGES = { + '/private': () => new Response(html(PRIVATE_META, '

secret

'), { headers: HTML_TYPE }), + '/private.plain.html': () => new Response('

secret

', { headers: HTML_TYPE }), + '/private.md': () => new Response(`# Secret\n\n${mdTable('Metadata', 'audience | private')}\n`, { headers: MD_TYPE }), + '/': () => new Response(html('', `
${PUBLIC_BLOCK}${PRIVATE_BLOCK}
`), { headers: HTML_TYPE }), + '/index.plain.html': () => new Response(`
${PUBLIC_BLOCK}${PRIVATE_BLOCK}
`, { headers: HTML_TYPE }), + '/index.md': () => new Response( + `${mdTable('Banner (audience public)', 'public banner')}\n\n${mdTable('Banner (audience private)', 'private banner')}\n\n# Home\n`, + { headers: MD_TYPE }, + ), + '/public': () => new Response(html('', '

hello

'), { headers: HTML_TYPE }), + '/public.plain.html': () => new Response('

hello

', { headers: HTML_TYPE }), + '/moved': () => new Response(null, { status: 301, headers: { location: '/elsewhere' } }), + '/moved.plain.html': () => new Response('

moved

', { headers: HTML_TYPE }), + '/broken': () => new Response('upstream error', { status: 500 }), + '/broken.plain.html': () => new Response('

broken

', { headers: HTML_TYPE }), + '/orphan.plain.html': () => new Response('

orphan

', { headers: HTML_TYPE }), +}; + +let fetchMock; + +const upstreamPaths = () => fetchMock.mock.calls.map(([req]) => new URL(req.url).pathname); + +const sessionCookie = async () => { + const claims = JSON.stringify({ + email: 'user@example.com', + created_at: String(Date.now()), + expires_in: '86400000', + }); + return `spectrum_session=${await signToken(claims, TEST_ENV.SESSION_SECRET)}`; +}; + +const invoke = async (path, { method = 'GET', cookies, headers = {} } = {}) => { + const resp = await handler({ + rawPath: path, + rawQueryString: '', + headers: { host: 'example.com', ...headers }, + cookies, + requestContext: { http: { method } }, + }); + const body = resp.isBase64Encoded ? Buffer.from(resp.body, 'base64').toString('utf8') : resp.body; + return { ...resp, text: body }; +}; + +beforeEach(() => { + fetchMock = vi.fn(async (req) => { + const page = PAGES[new URL(req.url).pathname]; + return page ? page() : new Response('Not found', { status: 404, headers: HTML_TYPE }); + }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('private page variants (anonymous)', () => { + it('still 404s the extensionless private page', async () => { + const resp = await invoke('/private'); + expect(resp.statusCode).toBe(404); + }); + + it('404s the .plain.html of a private page without fetching the variant', async () => { + const resp = await invoke('/private.plain.html'); + expect(resp.statusCode).toBe(404); + expect(resp.text).not.toContain('secret'); + expect(resp.headers['cache-control']).toBe('no-store'); + expect(upstreamPaths()).toEqual(['/private']); + }); + + it('404s the .md of a private page', async () => { + const resp = await invoke('/private.md'); + expect(resp.statusCode).toBe(404); + expect(resp.text).not.toContain('Secret'); + expect(upstreamPaths()).toEqual(['/private']); + }); + + it('404s a HEAD for a private variant too', async () => { + const resp = await invoke('/private.plain.html', { method: 'HEAD' }); + expect(resp.statusCode).toBe(404); + }); + + it('checks the canonical page with a plain GET, no conditional headers or cookies', async () => { + await invoke('/private.plain.html', { + method: 'HEAD', + headers: { 'if-none-match': '"abc"', 'if-modified-since': 'Wed, 01 Jan 2025 00:00:00 GMT' }, + cookies: ['other=1'], + }); + const [[canonicalReq, init]] = fetchMock.mock.calls; + expect(new URL(canonicalReq.url).pathname).toBe('/private'); + expect(canonicalReq.method).toBe('GET'); + expect(canonicalReq.headers.get('if-none-match')).toBeNull(); + expect(canonicalReq.headers.get('if-modified-since')).toBeNull(); + expect(canonicalReq.headers.get('cookie')).toBeNull(); + expect(new URL(canonicalReq.url).hostname).toBe('main--spectrum-hub--adobe.aem.live'); + expect(init.redirect).toBe('manual'); + }); + + it('re-checks the canonical page even when revalidating a gated copy', async () => { + const resp = await invoke('/private.plain.html', { + headers: { 'if-none-match': `W/"abc${GATE_ETAG_SUFFIX}"` }, + }); + expect(resp.statusCode).toBe(404); + expect(upstreamPaths()).toEqual(['/private']); + }); + + it.each([ + ['the canonical page is missing', '/orphan.plain.html'], + ['the canonical page redirects', '/moved.plain.html'], + ['the canonical lookup errors upstream', '/broken.plain.html'], + ])('fails closed when %s', async (_label, path) => { + const resp = await invoke(path); + expect(resp.statusCode).toBe(404); + expect(upstreamPaths()).toHaveLength(1); + }); + + it('fails closed when the canonical fetch throws', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + fetchMock.mockImplementationOnce(async () => { throw new Error('network down'); }); + const resp = await invoke('/public.plain.html'); + expect(resp.statusCode).toBe(404); + errorSpy.mockRestore(); + }); +}); + +describe('public page variants (anonymous)', () => { + it('serves the .plain.html of a public page after checking the canonical page', async () => { + const resp = await invoke('/public.plain.html'); + expect(resp.statusCode).toBe(200); + expect(resp.text).toContain('hello'); + expect(upstreamPaths()).toEqual(['/public', '/public.plain.html']); + }); + + it('maps /index.plain.html to / and strips audience-private blocks', async () => { + const resp = await invoke('/index.plain.html'); + expect(resp.statusCode).toBe(200); + expect(upstreamPaths()[0]).toBe('/'); + expect(resp.text).toContain('public banner'); + expect(resp.text).not.toContain('private banner'); + }); + + it('maps /index.md to / and strips audience-private block tables', async () => { + const resp = await invoke('/index.md'); + expect(resp.statusCode).toBe(200); + expect(upstreamPaths()[0]).toBe('/'); + expect(resp.text).toContain('public banner'); + expect(resp.text).not.toContain('private banner'); + expect(resp.text).toContain('# Home'); + }); +}); + +describe('page variants (authenticated)', () => { + it('serves a private variant without a canonical lookup', async () => { + const resp = await invoke('/private.plain.html', { cookies: [await sessionCookie()] }); + expect(resp.statusCode).toBe(200); + expect(resp.text).toContain('secret'); + expect(resp.headers['cache-control']).toBe('private, no-store'); + expect(upstreamPaths()).toEqual(['/private.plain.html']); + }); + + it('strips audience-public blocks from .plain.html and .md', async () => { + const cookies = [await sessionCookie()]; + const plain = await invoke('/index.plain.html', { cookies }); + expect(plain.text).toContain('private banner'); + expect(plain.text).not.toContain('public banner'); + const md = await invoke('/index.md', { cookies }); + expect(md.text).toContain('private banner'); + expect(md.text).not.toContain('public banner'); + }); +}); diff --git a/workers/website-lambda/lib/audience.js b/workers/website-lambda/lib/audience.js index 844ed520..c1f6260e 100644 --- a/workers/website-lambda/lib/audience.js +++ b/workers/website-lambda/lib/audience.js @@ -6,8 +6,11 @@ * * EDS blocks are `body > main > div (section) > div[class]` in the served HTML * (the `.block-content` wrapper the frontend adds is injected client-side, so - * server-side a block is a direct grandchild of
). A block opts a viewer - * out with a class: + * server-side a block is a direct grandchild of
). The `.plain.html` + * variant is the same markup without the document/
wrapper, so there a + * block is a top-level `div (section) > div[class]`. The `.md` variant renders + * each block as a grid table headed `Name (variants)` (see + * filterAudienceMarkdown). A block opts a viewer out with a class: * audience-public - shown only to anonymous visitors; removed for authed * audience-private - shown only to authorized visitors; removed for anonymous * The block is deleted from the served HTML entirely (nested children included) @@ -40,7 +43,11 @@ export const filterAudienceBlocks = (html, authed) => { // Child combinators restrict the match to block-level divs (section > block); // an audience class on a section or nested deeper inside a block is ignored. - const blocks = root.querySelectorAll(`main > div > div.${removeClass}`); + // The :scope form covers the head-less .plain.html variant, whose sections + // are top-level (no
); it can't match a full page, whose root is . + const blocks = root.querySelectorAll( + `main > div > div.${removeClass}, :scope > div > div.${removeClass}`, + ); if (!blocks.length) { return html; } // Splice from the tail so each removal leaves earlier offsets valid. @@ -57,3 +64,85 @@ export const filterAudienceBlocks = (html, authed) => { } return out; }; + +// A grid-table border line: `+---+`, `+===+`, `+:--+--:+` etc. +const isTableBorder = (line) => /^\+[-=:+]+\+\s*$/.test(line); +const isTableLine = (line) => line.startsWith('+') || line.startsWith('|'); + +// Class names a markdown block table header declares. AEM renders a block as a +// grid table whose first row is `Name (variant, variant)`; the variants become +// the block's classes the way AEM's toClassName derives them (lowercased, runs +// of non-alphanumerics as one hyphen: "audience private" -> audience-private). +// The header keeps the author's inline formatting (`**Cards (audience +// private)**`), so emphasis/code markers and escapes are dropped first, and +// every parenthesised group counts - erring towards stripping. The header row +// may wrap over several `|` lines. +const toClassName = (value) => value.toLowerCase().replace(/[^0-9a-z]+/g, '-').replace(/^-+|-+$/g, ''); +const getTableClasses = (headerLines) => { + const text = headerLines + .map((line) => line.replace(/^\|/, '').replace(/\|\s*$/, '')) + .join(' ') + .replace(/[*_`\\]/g, '') + .replace(/\s+/g, ' '); + return [...text.matchAll(/\(([^)]*)\)/g)] + .flatMap((match) => match[1].split(',')) + .map(toClassName); +}; + +// Reference labels (`![][image0]`, `[text][ref]`) used on the given lines. +const getReferenceLabels = (lines) => new Set( + lines.flatMap((line) => [...line.matchAll(/\]\[([^\]]+)\]/g)].map((m) => m[1].toLowerCase())), +); +const REFERENCE_DEFINITION = /^\s{0,3}\[([^\]]+)\]:/; + +// Markdown counterpart of filterAudienceBlocks for the `.md` page variant: +// removes each block table this viewer must not see (audience-private for +// anonymous, audience-public for authenticated), plus one trailing blank line. +// A table is a maximal run of lines starting with `+` or `|` that opens on a +// border line, so a nested table inside a cell goes with its parent block. +// AEM writes a cell's images as `![][imageN]` with the URL/title defined at the +// end of the document, so a reference definition used only by removed tables +// goes too. Prose that merely mentions "audience private" is untouched. +export const filterAudienceMarkdown = (md, authed) => { + if (typeof md !== 'string' || !/audience/i.test(md)) { return md; } + + const removeClass = authed ? AUDIENCE_PUBLIC_CLASS : AUDIENCE_PRIVATE_CLASS; + const lines = md.split('\n'); + const out = []; + const removed = []; + let i = 0; + while (i < lines.length) { + if (!isTableBorder(lines[i])) { + out.push(lines[i]); + i += 1; + continue; + } + let end = i + 1; + while (end < lines.length && isTableLine(lines[end])) { end += 1; } + const table = lines.slice(i, end); + const headerEnd = table.findIndex((line, idx) => idx > 0 && isTableBorder(line)); + const header = table.slice(1, headerEnd === -1 ? table.length : headerEnd); + if (getTableClasses(header).includes(removeClass)) { + removed.push(...table); + if (end < lines.length && lines[end].trim() === '') { end += 1; } + } else { + out.push(...table); + } + i = end; + } + if (!removed.length) { return md; } + + const kept = getReferenceLabels(out.filter((line) => !REFERENCE_DEFINITION.test(line))); + const orphaned = [...getReferenceLabels(removed)].filter((label) => !kept.has(label)); + if (!orphaned.length) { return out.join('\n'); } + const result = []; + for (let j = 0; j < out.length; j += 1) { + const def = out[j].match(REFERENCE_DEFINITION); + if (def && orphaned.includes(def[1].toLowerCase())) { + if (out[j + 1]?.trim() === '' && (result.length === 0 || result.at(-1).trim() === '')) { j += 1; } + continue; + } + result.push(out[j]); + } + return result.join('\n'); +}; diff --git a/workers/website-lambda/lib/audience.test.js b/workers/website-lambda/lib/audience.test.js index 6cd7db2b..6abb1ba3 100644 --- a/workers/website-lambda/lib/audience.test.js +++ b/workers/website-lambda/lib/audience.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { filterAudienceBlocks, hasAudienceBlocks } from './audience.js'; +import { filterAudienceBlocks, filterAudienceMarkdown, hasAudienceBlocks } from './audience.js'; // A block is `main > div (section) > div[class]` in the served HTML. Build a // section from block markup strings so tests read close to real pages. @@ -86,3 +86,132 @@ describe('filterAudienceBlocks', () => { expect(out).toContain('t'); }); }); + +describe('filterAudienceBlocks on .plain.html markup', () => { + // The .plain.html variant is the sections without the document or
. + const plain = (...blocks) => `\n
\n${blocks.join('\n')}\n
\n`; + + it('removes audience-private blocks for anonymous viewers', () => { + const out = filterAudienceBlocks(plain(PUBLIC, PRIVATE, PLAIN), false); + expect(out).toContain('public banner'); + expect(out).toContain('shared'); + expect(out).not.toContain('private banner'); + }); + + it('removes audience-public blocks for authenticated viewers', () => { + const out = filterAudienceBlocks(plain(PUBLIC, PRIVATE), true); + expect(out).toContain('private banner'); + expect(out).not.toContain('public banner'); + }); + + it('ignores audience classes nested deeper than block level', () => { + const nested = '
inner
'; + const html = plain(nested); + expect(filterAudienceBlocks(html, false)).toBe(html); + }); +}); + +describe('filterAudienceMarkdown', () => { + // AEM renders each block in the .md variant as a grid table headed + // `Name (variants)`. + const table = (header, ...rows) => { + const width = Math.max(header.length, ...rows.map((r) => r.length)) + 2; + const border = `+${'-'.repeat(width)}+`; + const row = (text) => `| ${text.padEnd(width - 2)} |`; + return [border, row(header), border, ...rows.map(row), border].join('\n'); + }; + const doc = (...parts) => `${parts.join('\n\n')}\n`; + + const MD_PUBLIC = table('Banner (audience public)', 'public banner'); + const MD_PRIVATE = table('Banner (audience private)', 'private banner'); + const MD_PLAIN = table('Columns (large)', 'shared'); + + it('removes audience-private tables for anonymous viewers', () => { + const out = filterAudienceMarkdown(doc('# Title', MD_PUBLIC, MD_PRIVATE, MD_PLAIN), false); + expect(out).toContain('public banner'); + expect(out).toContain('shared'); + expect(out).toContain('# Title'); + expect(out).not.toContain('private banner'); + expect(out).toBe(doc('# Title', MD_PUBLIC, MD_PLAIN)); + }); + + it('removes audience-public tables for authenticated viewers', () => { + const out = filterAudienceMarkdown(doc(MD_PUBLIC, MD_PRIVATE), true); + expect(out).toContain('private banner'); + expect(out).not.toContain('public banner'); + }); + + it('matches the audience variant among others, hyphenated or spaced, any case', () => { + for (const header of ['Columns (centered, audience private)', 'Columns (Audience-Private, large)']) { + const out = filterAudienceMarkdown(doc(table(header, 'hidden'), '# Kept'), false); + expect(out).not.toContain('hidden'); + expect(out).toContain('# Kept'); + } + }); + + it('removes every row of a multi-line block, including nested tables', () => { + const nested = [ + '+--------------------------------+', + '| Columns (audience private) |', + '+---------------+----------------+', + '| cell one | +------------+ |', + '| | | inner | |', + '| | +------------+ |', + '+---------------+----------------+', + ].join('\n'); + const out = filterAudienceMarkdown(doc(nested, 'After'), false); + expect(out).toBe(doc('After')); + }); + + it('matches headers that keep the author\'s inline formatting', () => { + for (const header of [ + '**Cards (audience private)**', + 'Cards **(audience private)**', + '*Cards (audience-private)*', + 'Cards (audience\\-private)', + 'Cards (`audience private`)', + ]) { + const out = filterAudienceMarkdown(doc(table(header, 'hidden'), '# Kept'), false); + expect(out, header).not.toContain('hidden'); + expect(out, header).toContain('# Kept'); + } + }); + + it('drops reference definitions used only by removed blocks', () => { + const md = doc( + table('Columns (audience private)', '![][image0] ![][image1]'), + table('Columns', '![][image1]'), + '[image0]: https://example.com/media_aaa.png#width=1&height=1 "private title"', + '[image1]: https://example.com/media_bbb.png#width=1&height=1', + ); + const out = filterAudienceMarkdown(md, false); + expect(out).not.toContain('media_aaa'); + expect(out).not.toContain('private title'); + expect(out).toContain('[image1]: https://example.com/media_bbb.png'); + expect(out).toBe(doc( + table('Columns', '![][image1]'), + '[image1]: https://example.com/media_bbb.png#width=1&height=1', + )); + }); + + it('keeps reference definitions when nothing is removed', () => { + const md = doc(MD_PUBLIC, '![][image0]', '[image0]: https://example.com/media_aaa.png'); + expect(filterAudienceMarkdown(md, false)).toBe(md); + }); + + it('leaves prose that mentions the audience class untouched', () => { + const md = doc('Add `audience private` to a block to hide it.', MD_PLAIN); + expect(filterAudienceMarkdown(md, false)).toBe(md); + }); + + it('does not treat a metadata row as a block variant', () => { + const md = doc('# Title', table('Metadata', 'audience | public')); + expect(filterAudienceMarkdown(md, true)).toBe(md); + }); + + it('returns non-strings and audience-free markdown unchanged', () => { + expect(filterAudienceMarkdown(undefined, false)).toBeUndefined(); + const md = doc('# Title', MD_PLAIN); + expect(filterAudienceMarkdown(md, false)).toBe(md); + }); +}); diff --git a/workers/website-lambda/lib/etag.js b/workers/website-lambda/lib/etag.js index 51853caf..1a586de6 100644 --- a/workers/website-lambda/lib/etag.js +++ b/workers/website-lambda/lib/etag.js @@ -18,7 +18,7 @@ * no CloudFront invalidation needed. */ -export const GATE_ETAG_VERSION = 1; +export const GATE_ETAG_VERSION = 2; export const GATE_ETAG_SUFFIX = `--gate-v${GATE_ETAG_VERSION}`; const ENTITY_TAG = /^(W\/)?"([^"]*)"$/; diff --git a/workers/website-lambda/lib/gate.js b/workers/website-lambda/lib/gate.js index acc80c2c..bd9b403e 100644 --- a/workers/website-lambda/lib/gate.js +++ b/workers/website-lambda/lib/gate.js @@ -8,8 +8,13 @@ * is served unless the page itself opts into privacy. A page opts in with * in its ; because that lives in * the fetched HTML, index.js applies it *after* proxying (see isPrivateHtml, - * used by index.js's gateByMeta). Whole paths/prefixes can also be marked - * private up front here, with no fetch, via PRIVATE_DENY_EXACT/PREFIX. + * used by index.js's processHtmlResponse). Whole paths/prefixes can also be + * marked private up front here, with no fetch, via PRIVATE_DENY_EXACT/PREFIX. + * + * AEM also serves every page as head-less variants - `.plain.html` (body + * markup only) and `.md` (Markdown) - which carry no to scan. For + * those, getCanonicalPagePath maps the variant back to its page, and index.js + * decides privacy from the canonical page's HTML before serving the variant. */ // Paths that are private up front, before any fetch - an anonymous visitor @@ -75,18 +80,39 @@ const getExtension = (pathname) => { return (basename === '' || pos < 1) ? '' : basename.slice(pos + 1).toLowerCase(); }; -// A "page" is an extensionless path or an .html path - the responses that can -// carry the gate. Everything else (json, xml, ...) is -// data, served by the default-allow fallthrough unless explicitly private. +// Head-less page variants AEM serves alongside every page. Neither carries the +// page's metadata, so privacy must be read from the canonical page. +// Matched case-sensitively: AEM only serves the lowercase forms (anything else +// is an AEM 404), and the canonical lookup must target the same page AEM would. +const PAGE_VARIANT_SUFFIXES = ['.plain.html', '.md']; + +// Canonical page path for a head-less page variant, or null when the path is +// not one. `/a/b.plain.html` -> `/a/b`, `/a/b.md` -> `/a/b`, and a folder index +// (`/index.md`, `/a/index.plain.html`) -> the folder (`/`, `/a/`), which is how +// AEM addresses index pages. +export const getCanonicalPagePath = (pathname) => { + const suffix = PAGE_VARIANT_SUFFIXES.find((s) => pathname.endsWith(s)); + if (!suffix) { return null; } + const base = pathname.slice(0, -suffix.length); + if (base === '' || base.endsWith('/')) { return base || '/'; } + if (base === '/index' || base.endsWith('/index')) { return base.slice(0, -'index'.length); } + return base; +}; + +// A "page" is an extensionless path, an .html path, or a page variant (.md) - +// the responses that render a page and so must honour its audience gate. +// Everything else (json, xml, ...) is data, served by the default-allow +// fallthrough unless explicitly private. export const isPageLike = (pathname) => { const ext = getExtension(pathname); - return ext === '' || ext === 'html'; + return ext === '' || ext === 'html' || getCanonicalPagePath(pathname) !== null; }; // Verdict for an anonymous visitor: // 'allow' - serve as-is (no meta parse) // 'filter' - JSON proxied, then private rows stripped before serving // 'gate' - page-like: proxy, then 404 only if the HTML opts into privacy +// (for a .plain.html/.md variant, the canonical page's HTML) // 'deny' - 404 up front, indistinguishable from a path that does not exist // Order matters: an explicitly-private path is denied before any allow/gate. export const classifyPublicPath = (pathname) => { @@ -111,8 +137,10 @@ const getMetaAttr = (tag, name) => { }; // True when the page opts into privacy via in its . Pure - index.js's gateByMeta reads the -// proxied HTML and calls this. The scan is scoped to the so a stray +// content="private"> in its . Pure - index.js reads the proxied HTML (or, +// for a head-less variant, the canonical page's HTML) and calls this. Never +// call it on a .plain.html body: with no it would scan the whole body +// and miss the page's metadata. The scan is scoped to the so a stray // example of the tag in body content can't gate the page; attribute order, // quoting, and case are all tolerated. export const isPrivateHtml = (html) => { diff --git a/workers/website-lambda/lib/gate.test.js b/workers/website-lambda/lib/gate.test.js index 63e04245..09ff2922 100644 --- a/workers/website-lambda/lib/gate.test.js +++ b/workers/website-lambda/lib/gate.test.js @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { classifyPublicPath, + getCanonicalPagePath, isPrivateHtml, PUBLIC_ALLOW_PREFIX, PRIVATE_DENY_EXACT, @@ -51,6 +52,11 @@ describe('classifyPublicPath', () => { expect(classifyPublicPath('/some/section/')).toBe('gate'); }); + it('gates the .md page variant instead of default-allowing it', () => { + expect(classifyPublicPath('/foo.md')).toBe('gate'); + expect(classifyPublicPath('/index.md')).toBe('gate'); + }); + it('defaults non-page, non-listed resources to allow (json/xml/other)', () => { expect(classifyPublicPath('/data/other.json')).toBe('allow'); expect(classifyPublicPath('/other/feed.xml')).toBe('allow'); @@ -128,3 +134,35 @@ describe('isPrivateHtml', () => { expect(isPrivateHtml(undefined)).toBe(false); }); }); + +describe('getCanonicalPagePath', () => { + it('maps .plain.html and .md variants to their page', () => { + expect(getCanonicalPagePath('/support/developer-overview.plain.html')).toBe('/support/developer-overview'); + expect(getCanonicalPagePath('/support/developer-overview.md')).toBe('/support/developer-overview'); + }); + + it('maps folder index variants to the folder', () => { + expect(getCanonicalPagePath('/index.plain.html')).toBe('/'); + expect(getCanonicalPagePath('/index.md')).toBe('/'); + expect(getCanonicalPagePath('/support/index.plain.html')).toBe('/support/'); + expect(getCanonicalPagePath('/support/index.md')).toBe('/support/'); + }); + + it('maps bare-suffix paths to the enclosing folder', () => { + expect(getCanonicalPagePath('/.md')).toBe('/'); + expect(getCanonicalPagePath('/support/.plain.html')).toBe('/support/'); + }); + + it('does not treat a name merely ending in "index" as a folder index', () => { + expect(getCanonicalPagePath('/reindex.md')).toBe('/reindex'); + expect(getCanonicalPagePath('/a/reindex.plain.html')).toBe('/a/reindex'); + }); + + it('returns null for anything that is not a page variant', () => { + expect(getCanonicalPagePath('/support/developer-overview')).toBeNull(); + expect(getCanonicalPagePath('/support/developer-overview.html')).toBeNull(); + expect(getCanonicalPagePath('/query-index.json')).toBeNull(); + expect(getCanonicalPagePath('/foo.plain.html/')).toBeNull(); + expect(getCanonicalPagePath('/foo.mdx')).toBeNull(); + }); +});