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
This commit is contained in:
Jakub Zych
2026-10-15 18:24:47 +02:00
parent 321bc90ddf
commit e26f22e4f3
6 changed files with 265 additions and 3 deletions

39
server/api/__site__/cache/purge.post.ts vendored Normal file
View File

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

View File

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