From e26f22e4f3aaa54bdf2e8efa56fd1b0b65682f25 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 15 Oct 2026 18:24:47 +0200 Subject: [PATCH] feat(19.3-04): shared site cache purge route POST /api/__site__/cache/purge - server/lib/siteCachePurge.ts: authorizeSitePurge, purgeSiteRouteCache, parseSitePurgeEvent (kinds: lower-case letters and underscores) - the route reads runtimeConfig.siteCache.purgeToken (NUXT_SITE_CACHE_PURGE_TOKEN), 404 when off or proxied, 401 unauthorized - route rule '/api/__site__/**': { cache: false } - sitePurge specs with sha256 pins of the two shared files - version 0.2.0 --- README.md | 24 +++++- nuxt.config.ts | 15 ++++ package.json | 2 +- server/api/__site__/cache/purge.post.ts | 39 ++++++++++ server/lib/siteCachePurge.ts | 91 +++++++++++++++++++++++ tests/sitePurge.test.ts | 97 +++++++++++++++++++++++++ 6 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 server/api/__site__/cache/purge.post.ts create mode 100644 server/lib/siteCachePurge.ts create mode 100644 tests/sitePurge.test.ts diff --git a/README.md b/README.md index 5731ff4..cc7573c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Nuxt 4 layer that renders a FAQ of the SummerCMS FAQ plugin as native `
` items. ```bash -pnpm add git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.1.0 +pnpm add git+https://git.golem15.com/golem15/vue-faq-layer.git#v0.2.0 ``` ## Overview @@ -18,6 +18,7 @@ The layer is neutral: it ships no site colours, no site copy and no Nuxt modules - **`FaqList`** and **`FaqItem`**: one native `
` per question in API order, the question in `` with a CSS chevron (no animation under `prefers-reduced-motion`), the answer below. All items start closed (`openFirst` opens the first). With `exclusive`, the items share a `name`, so the browser keeps one open at a time. - **HTML**: the description and answers are HTML the FAQ API sanitizes on output; the layer renders them with `v-html` and adds no client sanitizer. Questions are plain text. - **Empty states**: a missing FAQ (404), a failed request or a FAQ without items renders nothing, or the `#fallback` slot. There is never an empty heading. +- **Site cache purge**: the shared route `POST /api/__site__/cache/purge` drops the site's route cache when the backend saves a FAQ (or a menu or post, through the other core layers). See [Site cache purge](#site-cache-purge). - **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server. ## Usage @@ -58,8 +59,27 @@ export default defineNuxtConfig({ | `runtimeConfig.faq.apiBase` | `http://127.0.0.1:8096/_faq/api/v1` | `NUXT_FAQ_API_BASE` (server only, used for SSR) | | `runtimeConfig.public.faq.apiBase` | `/_faq/api/v1` | `NUXT_PUBLIC_FAQ_API_BASE` (browser, same origin) | +| `runtimeConfig.siteCache.purgeToken` | `''` (route off) | `NUXT_SITE_CACHE_PURGE_TOKEN` (server only; at least 32 characters) | + The base URLs always come from runtime config, never from request input. In production the web server routes `/_faq/` to the SummerCMS binary. +## Site cache purge + +A site that route-caches its pages (for example `routeRules: { '/**': { swr: 300 } }`) shows an admin edit only when the cache window ends. The layer ships the shared purge route that the SummerCMS backend calls after an admin save commits: + +``` +POST /api/__site__/cache/purge +Authorization: Bearer +{"kind":"faq","slug":"shipping"} +→ {"purged": } +``` + +- It removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and logs `[site-cache] purge: …` with the kind and slug. +- Set `NUXT_SITE_CACHE_PURGE_TOKEN` to the backend's `site_cache.purge_token` (at least 32 characters) and point the backend's `site_cache.purge_url` at `http://127.0.0.1:/api/__site__/cache/purge`. Without a token of that length the route answers 404. +- It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), so only the backend on the same machine reaches it, and 401 to a missing or wrong bearer token (compared in constant time). +- The layer adds the route rule `'/api/__site__/**': { cache: false }`; without it, a site-wide `swr` rule would answer the purge from the cache. +- vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same `server/lib/siteCachePurge.ts` and `server/api/__site__/cache/purge.post.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps. + ## API The layer expects the FAQ plugin's public API: @@ -108,6 +128,6 @@ Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their d ## Testing ```bash -pnpm test # node --test: the client +pnpm test # node --test: the client and the site cache purge pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs ``` diff --git a/nuxt.config.ts b/nuxt.config.ts index 55a8a7d..98cb612 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -23,6 +23,13 @@ export default defineNuxtConfig({ // from request input. apiBase: 'http://127.0.0.1:8096/_faq/api/v1', }, + siteCache: { + // Server-only bearer token of the shared site cache purge route + // POST /api/__site__/cache/purge (NUXT_SITE_CACHE_PURGE_TOKEN), the + // backend's site_cache.purge_token. Shorter than 32 characters, the + // route answers 404. + purgeToken: '', + }, public: { faq: { // Same-origin base for browser fetches (NUXT_PUBLIC_FAQ_API_BASE). @@ -30,4 +37,12 @@ export default defineNuxtConfig({ }, }, }, + + // The shared site routes are never route-cached. Nitro wraps every + // handler matching an app rule such as '/**': { swr } in its cached + // handler, which keys by path, does not skip POST and hides request + // headers, so the purge route would answer from the cache without running. + routeRules: { + '/api/__site__/**': { cache: false }, + }, }) diff --git a/package.json b/package.json index 934e5b3..36dd49b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vue-faq-layer", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "main": "./nuxt.config.ts", diff --git a/server/api/__site__/cache/purge.post.ts b/server/api/__site__/cache/purge.post.ts new file mode 100644 index 0000000..2409806 --- /dev/null +++ b/server/api/__site__/cache/purge.post.ts @@ -0,0 +1,39 @@ +// POST /api/__site__/cache/purge: removes every Nitro route-cache entry +// (pages, _payload.json and feeds), so an admin save shows on the next page +// view. The backend calls it on loopback after the save commits, with +// `Authorization: Bearer ` and the body +// {"kind":"","slug":""}; the answer is {"purged":}. The kind +// and slug only reach the log line. +// +// 404 without a token of at least 32 characters, and for any request that +// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded); +// 401 for a missing or wrong bearer token. The '/api/__site__/**' route rule +// keeps this route out of the route cache. +// +// Shared: several layers ship this file byte for byte, and Nitro keeps one +// handler per route and method. + +import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache } from '../../../lib/siteCachePurge.ts' + +const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded'] + +export default defineEventHandler(async (event) => { + setResponseHeader(event, 'cache-control', 'no-store') + const config = useRuntimeConfig(event) + const verdict = authorizeSitePurge({ + token: (config.siteCache as { purgeToken?: unknown } | undefined)?.purgeToken, + authorization: getRequestHeader(event, 'authorization'), + proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined), + }) + if (verdict === 'disabled' || verdict === 'proxied') { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + if (verdict === 'unauthorized') { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + + const { kind, slug } = parseSitePurgeEvent(await readBody(event).catch(() => null)) + const purged = await purgeSiteRouteCache(useStorage('cache')) + console.info(`[site-cache] purge: removed ${purged} route cache entries (${kind} ${slug})`) + return { purged } +}) diff --git a/server/lib/siteCachePurge.ts b/server/lib/siteCachePurge.ts new file mode 100644 index 0000000..73ae62b --- /dev/null +++ b/server/lib/siteCachePurge.ts @@ -0,0 +1,91 @@ +// Shared site cache purge: the authorization rules of the purge route +// POST /api/__site__/cache/purge, the purge of Nitro's route cache and the +// parsing of the purge request body. Pure TypeScript outside Nitro's and +// Nuxt's auto-import directories, so several layers can ship this file +// byte for byte, and `node --test` and the route import it directly. + +import { createHash, timingSafeEqual } from 'node:crypto' + +/** The shortest purge token the route accepts; with a shorter one it is off. */ +export const MIN_SITE_PURGE_TOKEN_LENGTH = 32 + +/** The cache storage base of Nitro's route cache (routeRules cache and swr). */ +export const SITE_ROUTE_CACHE_BASE = 'nitro:routes' + +/** Longest slug the purge log line carries. */ +const MAX_SLUG_LENGTH = 200 + +/** A purge kind: lower-case letters and underscores, up to 40 characters. */ +const KIND_PATTERN = /^[a-z_]{1,40}$/ + +export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized' + +export interface SitePurgeAuthInput { + /** The configured token (runtimeConfig.siteCache.purgeToken). */ + token: unknown + /** The request's Authorization header, if any. */ + authorization?: string | null + /** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */ + proxied: boolean +} + +function digest(value: string): Buffer { + return createHash('sha256').update(value, 'utf8').digest() +} + +/** + * Decides a purge request: `disabled` when no token of at least + * MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the + * request came through a reverse proxy (checked before the token), `ok` when + * the Bearer value equals the token (compared in constant time over sha256 + * digests) and `unauthorized` otherwise. + */ +export function authorizeSitePurge({ token, authorization, proxied }: SitePurgeAuthInput): SitePurgeVerdict { + if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled' + if (proxied) return 'proxied' + const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '') + if (!m) return 'unauthorized' + return timingSafeEqual(digest(m[1]), digest(token)) ? 'ok' : 'unauthorized' +} + +/** The part of a Nitro/unstorage storage the purge uses. */ +export interface SitePurgeStorage { + getKeys(base?: string): Promise | string[] + removeItem(key: string): Promise | unknown +} + +/** + * Removes every entry of Nitro's route cache (pages, their _payload.json and + * feeds) from storage, which is `useStorage('cache')`, and returns how many + * it removed. Other cache groups (cached functions, handlers) stay. + */ +export async function purgeSiteRouteCache(storage: SitePurgeStorage): Promise { + const keys = await storage.getKeys(SITE_ROUTE_CACHE_BASE) + let removed = 0 + for (const key of keys) { + if (!key.startsWith(SITE_ROUTE_CACHE_BASE + ':')) continue + await storage.removeItem(key) + removed++ + } + return removed +} + +export interface SitePurgeEvent { + kind: string + slug: string +} + +/** + * Reads the kind and slug of a purge request body for the log line: kind is + * kept when it is lower-case letters and underscores up to 40 characters + * (faq, menu, menu_entry, post, category, ...), else it is unknown; the slug + * loses control characters and is cut to 200 characters. + */ +export function parseSitePurgeEvent(body: unknown): SitePurgeEvent { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return { kind: 'unknown', slug: '' } + const raw = body as Record + const kind = typeof raw.kind === 'string' && KIND_PATTERN.test(raw.kind) ? raw.kind : 'unknown' + // eslint-disable-next-line no-control-regex + const slug = typeof raw.slug === 'string' ? raw.slug.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, MAX_SLUG_LENGTH) : '' + return { kind, slug } +} diff --git a/tests/sitePurge.test.ts b/tests/sitePurge.test.ts new file mode 100644 index 0000000..e66c68f --- /dev/null +++ b/tests/sitePurge.test.ts @@ -0,0 +1,97 @@ +// Shared site cache purge: route authorization, the route-cache purge, the +// purge body parsing and the byte-identity of the files every layer ships. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { + MIN_SITE_PURGE_TOKEN_LENGTH, + SITE_ROUTE_CACHE_BASE, + authorizeSitePurge, + parseSitePurgeEvent, + purgeSiteRouteCache, +} from '../server/lib/siteCachePurge.ts' + +// The sha256 of the two shared files. vue-journal-layer, vue-menu-layer and +// vue-faq-layer ship them byte for byte; a change lands in all three with +// new values here. +const SHARED_LIB_SHA256 = '7c34121ae06cf011b30a1dc5d787a94657b71b8768bed3a200be0f41566001b8' +const SHARED_ROUTE_SHA256 = 'c459012280cdf59551e57f0440fcaeb24566a5ba6e3dccf31133e67a683f1c06' + +const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) + +function sha256(relative: string): string { + return createHash('sha256').update(readFileSync(new URL(relative, import.meta.url))).digest('hex') +} + +test('sitePurge: the shared files match the pinned hashes', () => { + assert.equal(sha256('../server/lib/siteCachePurge.ts'), SHARED_LIB_SHA256, 'server/lib/siteCachePurge.ts') + assert.equal(sha256('../server/api/__site__/cache/purge.post.ts'), SHARED_ROUTE_SHA256, 'server/api/__site__/cache/purge.post.ts') +}) + +test('sitePurge: a missing or short token disables the route', () => { + for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) { + assert.equal(authorizeSitePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`) + } + assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled') + assert.equal(MIN_SITE_PURGE_TOKEN_LENGTH, 32) +}) + +test('sitePurge: a proxied request is refused before the token is checked', () => { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied') +}) + +test('sitePurge: authorize ok with the token and unauthorized otherwise', () => { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok') + for (const authorization of [undefined, null, '', 'Bearer', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`) + } +}) + +/** A fake unstorage prefix storage over a plain key list. */ +function fakeStorage(keys: string[]) { + const live = new Set(keys) + const removed: string[] = [] + return { + removed, + live, + async getKeys(base?: string) { + return [...live].filter((k) => !base || k === base || k.startsWith(base + ':')) + }, + async removeItem(key: string) { + live.delete(key) + removed.push(key) + }, + } +} + +test('sitePurge: purge removes only nitro:routes: keys', async () => { + const routes = [ + `${SITE_ROUTE_CACHE_BASE}:_:faq.abc123.json`, + `${SITE_ROUTE_CACHE_BASE}:_:faq_payloadjson.def456.json`, + `${SITE_ROUTE_CACHE_BASE}:_:index.0a1b2c.json`, + ] + const others = ['nitro:functions:_:faq.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json'] + const storage = fakeStorage([...routes, ...others]) + assert.equal(SITE_ROUTE_CACHE_BASE, 'nitro:routes') + assert.equal(await purgeSiteRouteCache(storage), routes.length) + assert.deepEqual([...storage.removed].sort(), [...routes].sort()) + assert.deepEqual([...storage.live].sort(), [...others].sort()) + assert.equal(await purgeSiteRouteCache(storage), 0) +}) + +test('sitePurge: purge event kinds and slugs', () => { + for (const kind of ['faq', 'menu', 'menu_entry', 'post', 'category']) { + assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind, slug: 'x' }) + } + for (const kind of ['FAQ', 'menu-entry', 'a'.repeat(41), '', 7, null]) { + assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind: 'unknown', slug: 'x' }, JSON.stringify(kind)) + } + assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 'a\nb\r\tc\u0000d\u007fe\u0085f' }), { kind: 'faq', slug: 'abcdef' }) + assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'x'.repeat(500) }).slug.length, 200) + for (const body of [null, undefined, 'faq', 42, ['faq', 'x']]) { + assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body)) + } +})