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
This commit is contained in:
2026-10-10 18:19:58 +02:00
parent 782aa4da7d
commit a03e076c62
6 changed files with 322 additions and 9 deletions

View File

@@ -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',

85
app/utils/siteCache.ts Normal file
View File

@@ -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[]> | string[]
removeItem(key: string): Promise<unknown> | 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<number> {
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<string, unknown>
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 }
}

View File

@@ -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 },

View File

@@ -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 <runtimeConfig.journal.cachePurgeToken>`
// and the body {"kind":"post"|"category","slug":"<slug>"}; the answer is
// {"purged":<n>}.
//
// 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 }
})

View File

@@ -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<void> }} FixtureApi
* @typedef {{ url: string, requests: FixtureRequest[], reload: () => void, close: () => Promise<void> }} 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 = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></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 = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></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())),
})
})

167
testing/purge-check.mjs Normal file
View File

@@ -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 <dir> --kind post|category --slug <slug> --path <page path>
//
// The fixtures are copied to a temporary directory, so the source fixtures
// never change. The last line is "purge-check: ok (<n> 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<string, string>} */
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<p>${marker}</p>\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<void> } | 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<string, string>} [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<string, string>} 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)
})