From a03e076c62920bef7889ca99f9073b7cc4401f52 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 18:19:58 +0200 Subject: [PATCH] feat: token-protected site cache purge route - POST /api/__journal__/cache/purge removes every Nitro route-cache entry - 404 without a 32-character token or behind a proxy, 401 on a wrong bearer token (constant-time over sha256 digests) - routeRules keep /api/__journal__/** out of the route cache - runtimeConfig.journal.cachePurgeToken (NUXT_JOURNAL_CACHE_PURGE_TOKEN) - fixture API reload(); testing/purge-check.mjs proves a cached page shows a change after a purge; the playground caches with swr 300 --- .playground/nuxt.config.ts | 3 + app/utils/siteCache.ts | 85 +++++++++++ nuxt.config.ts | 12 ++ server/api/__journal__/cache/purge.post.ts | 36 +++++ testing/fixture-api.mjs | 28 ++-- testing/purge-check.mjs | 167 +++++++++++++++++++++ 6 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 app/utils/siteCache.ts create mode 100644 server/api/__journal__/cache/purge.post.ts create mode 100644 testing/purge-check.mjs diff --git a/.playground/nuxt.config.ts b/.playground/nuxt.config.ts index 547f604..cae7822 100644 --- a/.playground/nuxt.config.ts +++ b/.playground/nuxt.config.ts @@ -4,6 +4,9 @@ export default defineNuxtConfig({ extends: ['..'], compatibilityDate: '2025-07-15', site: { url: 'https://acme.test', name: 'Acme Blog' }, + // Route-cached like a real host, so testing/purge-check.mjs can prove a + // purge clears cached pages. + routeRules: { '/**': { swr: 300 } }, i18n: { strategy: 'prefix_except_default', defaultLocale: 'en', diff --git a/app/utils/siteCache.ts b/app/utils/siteCache.ts new file mode 100644 index 0000000..84a1c97 --- /dev/null +++ b/app/utils/siteCache.ts @@ -0,0 +1,85 @@ +// Site cache purge: the authorization rules of the purge route, the purge of +// Nitro's route cache and the parsing of the purge request body. Pure +// TypeScript, so `node --test` and the server route both 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_PURGE_TOKEN_LENGTH = 32 + +/** The cache storage base of Nitro's route cache (routeRules cache and swr). */ +export const ROUTE_CACHE_BASE = 'nitro:routes' + +/** Longest slug the purge log line carries. */ +const MAX_SLUG_LENGTH = 200 + +export type PurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized' + +export interface PurgeAuthInput { + /** The configured token (runtimeConfig.journal.cachePurgeToken). */ + 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_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 authorizePurge({ token, authorization, proxied }: PurgeAuthInput): PurgeVerdict { + if (typeof token !== 'string' || token.length < MIN_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 PurgeStorage { + 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 purgeRouteCache(storage: PurgeStorage): Promise { + const keys = await storage.getKeys(ROUTE_CACHE_BASE) + let removed = 0 + for (const key of keys) { + if (!key.startsWith(ROUTE_CACHE_BASE + ':')) continue + await storage.removeItem(key) + removed++ + } + return removed +} + +export interface PurgeEvent { + kind: 'post' | 'category' | 'unknown' + slug: string +} + +/** + * Reads the kind and slug of a purge request body for the log line: kind is + * post, category or unknown; the slug loses control characters and is cut + * to 200 characters. + */ +export function parsePurgeEvent(body: unknown): PurgeEvent { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return { kind: 'unknown', slug: '' } + const raw = body as Record + const kind = raw.kind === 'post' || raw.kind === 'category' ? 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/nuxt.config.ts b/nuxt.config.ts index 6c0e980..a9e7f50 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -29,6 +29,10 @@ export default defineNuxtConfig({ // Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never // comes from request input. apiBase: 'http://127.0.0.1:8096/_journal/api/v1', + // Server-only bearer token of the site cache purge route + // (NUXT_JOURNAL_CACHE_PURGE_TOKEN). Shorter than 32 characters, the + // route answers 404. + cachePurgeToken: '', }, public: { journal: { @@ -38,6 +42,14 @@ export default defineNuxtConfig({ }, }, + // The Journal's internal 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/__journal__/**': { cache: false }, + }, + // No OG image rendering at runtime. ogImage: { enabled: false }, diff --git a/server/api/__journal__/cache/purge.post.ts b/server/api/__journal__/cache/purge.post.ts new file mode 100644 index 0000000..c6cf9bb --- /dev/null +++ b/server/api/__journal__/cache/purge.post.ts @@ -0,0 +1,36 @@ +// POST /api/__journal__/cache/purge: removes every Nitro route-cache entry +// (pages, _payload.json and feeds), so a Journal save in the admin shows on +// the next page view. The backend calls it on loopback after the save +// commits, with `Authorization: Bearer ` +// and the body {"kind":"post"|"category","slug":""}; the answer is +// {"purged":}. +// +// 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 layer's routeRules keep this +// route out of the route cache. + +import { authorizePurge, parsePurgeEvent, purgeRouteCache } from '../../../../app/utils/siteCache.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 = authorizePurge({ + token: (config.journal as { cachePurgeToken?: unknown }).cachePurgeToken, + 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 } = parsePurgeEvent(await readBody(event).catch(() => null)) + const purged = await purgeRouteCache(useStorage('cache')) + console.info(`[journal] cache purge: removed ${purged} route cache entries (${kind} ${slug})`) + return { purged } +}) diff --git a/testing/fixture-api.mjs b/testing/fixture-api.mjs index 1e219dd..003b493 100644 --- a/testing/fixture-api.mjs +++ b/testing/fixture-api.mjs @@ -18,7 +18,7 @@ const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title /** * @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest - * @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise }} FixtureApi + * @typedef {{ url: string, requests: FixtureRequest[], reload: () => void, close: () => Promise }} FixtureApi */ /** @param {string} dir @param {string} name @param {any} fallback */ @@ -118,19 +118,28 @@ function sibling(post) { */ export function startFixtureApi({ dir, port = 0 }) { /** @type {any[]} */ - const posts = readJson(dir, 'posts.json', []) + let posts = [] /** @type {any[]} */ - const categories = readJson(dir, 'categories.json', []) + let categories = [] /** @type {any[]} */ - const tags = readJson(dir, 'tags.json', []) + let tags = [] /** @type {any[]} */ - const authors = readJson(dir, 'authors.json', []) + let authors = [] let rss = '' - try { - rss = readFileSync(join(dir, 'rss.xml'), 'utf8') - } catch { - rss = '' + // Reads (or re-reads) the fixtures, so a check can change a fixture file + // while the API runs. + const load = () => { + posts = readJson(dir, 'posts.json', []) + categories = readJson(dir, 'categories.json', []) + tags = readJson(dir, 'tags.json', []) + authors = readJson(dir, 'authors.json', []) + try { + rss = readFileSync(join(dir, 'rss.xml'), 'utf8') + } catch { + rss = '' + } } + load() /** @type {FixtureRequest[]} */ const requests = [] @@ -240,6 +249,7 @@ export function startFixtureApi({ dir, port = 0 }) { resolve({ url: `http://127.0.0.1:${addr.port}`, requests, + reload: load, close: () => new Promise((done) => server.close(() => done())), }) }) diff --git a/testing/purge-check.mjs b/testing/purge-check.mjs new file mode 100644 index 0000000..9368e09 --- /dev/null +++ b/testing/purge-check.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// Site cache purge check: proves on a built Nuxt server with swr route rules +// that a page cached before a content change shows the change on the first +// request after a purge, and that the purge route refuses unauthenticated, +// wrong-token and proxied requests. +// +// node purge-check.mjs --output <.output dir> --fixtures --kind post|category --slug --path +// +// The fixtures are copied to a temporary directory, so the source fixtures +// never change. The last line is "purge-check: ok ( checks)" on success; +// every failure prints "purge-check: FAIL ..." and exits 1. + +import { randomBytes } from 'node:crypto' +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { startFixtureApi } from './fixture-api.mjs' +import { startNuxtServer } from './nuxt-server.mjs' + +const PURGE_PATH = '/api/__journal__/cache/purge' + +/** @param {string[]} argv */ +function parseArgs(argv) { + /** @type {Record} */ + const out = {} + const names = new Set(['--output', '--fixtures', '--kind', '--slug', '--path']) + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (!names.has(arg)) throw new Error(`unknown argument ${arg}`) + const v = argv[++i] + if (v === undefined) throw new Error(`${arg} needs a value`) + out[arg.slice(2)] = v + } + for (const name of names) { + if (!out[name.slice(2)]) throw new Error('--output, --fixtures, --kind, --slug and --path are required') + } + if (out.kind !== 'post' && out.kind !== 'category') throw new Error(`--kind must be post or category, got ${out.kind}`) + if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`) + return /** @type {{ output: string, fixtures: string, kind: 'post' | 'category', slug: string, path: string }} */ (out) +} + +/** @param {any[]} nodes @returns {any[]} */ +function flatten(nodes) { + return (nodes ?? []).flatMap((n) => [n, ...flatten(n.children)]) +} + +/** + * Appends a marker paragraph to the post's content and content_html, or to + * the category's content, in the fixture directory. + * @param {string} dir @param {'post' | 'category'} kind @param {string} slug @param {string} marker + */ +function appendMarker(dir, kind, slug, marker) { + const file = join(dir, kind === 'post' ? 'posts.json' : 'categories.json') + const rows = JSON.parse(readFileSync(file, 'utf8')) + const row = (kind === 'post' ? rows : flatten(rows)).find((/** @type {any} */ r) => r.slug === slug) + if (!row) throw new Error(`no ${kind} with slug ${slug} in ${file}`) + const para = `\n

${marker}

\n` + if (kind === 'post') { + row.content = (row.content ?? '') + `\n\n${marker}\n` + row.content_html = (row.content_html ?? '') + para + } else { + row.content = (row.content ?? '') + para + } + writeFileSync(file, JSON.stringify(rows, null, 2)) +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const dir = mkdtempSync(join(tmpdir(), 'purge-check-')) + cpSync(resolve(args.fixtures), dir, { recursive: true }) + const token = randomBytes(24).toString('hex') + const marker = `purge-check-marker-${randomBytes(6).toString('hex')}` + + /** @type {string[]} */ + const failures = [] + let checks = 0 + /** @param {boolean} ok @param {string} what */ + const check = (ok, what) => { + checks++ + if (!ok) failures.push(what) + return ok + } + + const api = await startFixtureApi({ dir }) + /** @type {{ url: string, close: () => Promise } | null} */ + let nuxt = null + try { + nuxt = await startNuxtServer({ + outputDir: args.output, + apiBase: `${api.url}/_journal/api/v1`, + env: { NUXT_JOURNAL_CACHE_PURGE_TOKEN: token }, + }) + const base = nuxt.url + /** @param {string} path @param {Record} [headers] */ + const get = async (path, headers = {}) => { + const res = await fetch(base + path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) }) + return { status: res.status, headers: res.headers, body: await res.text() } + } + /** @param {Record} headers */ + const purge = async (headers) => { + const res = await fetch(base + PURGE_PATH, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify({ kind: args.kind, slug: args.slug }), + signal: AbortSignal.timeout(20000), + }) + const text = await res.text() + /** @type {any} */ + let json = null + try { + json = JSON.parse(text) + } catch { + json = null + } + return { status: res.status, json, text } + } + const auth = { authorization: `Bearer ${token}` } + + // The steps stop at the first failure that makes the later ones + // meaningless; the failures are reported after the servers stop. + const steps = async () => { + // 1. The page renders and is cached. + const first = await get(args.path) + if (!check(first.status === 200, `GET ${args.path} before the change: status ${first.status}, want 200`)) return + + // 2. Change the content behind the API; the cached page must not show it. + appendMarker(dir, args.kind, args.slug, marker) + api.reload() + const cached = await get(args.path) + check(cached.status === 200, `GET ${args.path} after the change: status ${cached.status}, want 200`) + if (!check(!cached.body.includes(marker), `GET ${args.path} shows the change before any purge: the page is not route-cached (missing swr route rules?)`)) return + + // 3. An authorized purge removes the cached page. + const purged = await purge(auth) + check(purged.status === 200, `authorized purge: status ${purged.status}, want 200 (${purged.text.slice(0, 200)})`) + check(typeof purged.json?.purged === 'number' && purged.json.purged >= 1, `authorized purge: answer ${purged.text.slice(0, 200)}, want purged >= 1`) + + // 4. The next request shows the change. + const fresh = await get(args.path) + check(fresh.status === 200 && fresh.body.includes(marker), `GET ${args.path} after the purge: status ${fresh.status}, change shown: ${fresh.body.includes(marker)}`) + + // 5. Refused purges. + const none = await purge({}) + check(none.status === 401, `purge without a token: status ${none.status}, want 401`) + const wrong = await purge({ authorization: `Bearer ${randomBytes(24).toString('hex')}` }) + check(wrong.status === 401, `purge with a wrong token: status ${wrong.status}, want 401`) + const proxied = await purge({ ...auth, 'x-forwarded-for': '203.0.113.7' }) + check(proxied.status === 404, `proxied purge with the right token: status ${proxied.status}, want 404`) + } + await steps() + } finally { + if (nuxt) await nuxt.close() + await api.close() + rmSync(dir, { recursive: true, force: true }) + } + + if (failures.length > 0) { + for (const f of failures) console.error(`purge-check: FAIL ${f}`) + process.exit(1) + } + console.log(`purge-check: ok (${checks} checks)`) +} + +main().catch((err) => { + console.error(`purge-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) +})