// 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 } } /** * Makes browsers revalidate a shared-cache response: for a cache-control * value that carries s-maxage it sets max-age=0, replacing any max-age= * or prepending it, and keeps every other directive (shared caches keep * s-maxage). Other values, such as a static asset's * "public, max-age=31536000, immutable", are returned unchanged. */ export function privateRevalidate(cacheControl: string): string { if (typeof cacheControl !== 'string' || !/(^|[\s,])s-maxage\s*=/i.test(cacheControl)) return cacheControl if (/(^|[\s,])max-age\s*=\s*\d*/i.test(cacheControl)) { return cacheControl.replace(/(^|[\s,])max-age\s*=\s*\d*/i, '$1max-age=0') } return `max-age=0, ${cacheControl}` }