// 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 /** * runtimeConfig.siteCache.allowProxied: when true a proxied request is not * refused, and the bearer token is the only gate. For a backend that reaches * the site through a TLS-terminating proxy, which adds forwarding headers. */ allowProxied?: 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) unless * allowProxied is true, `ok` when the Bearer value equals the token (compared * in constant time over sha256 digests) and `unauthorized` otherwise. With * allowProxied the token is the only gate, so it must stay long and secret. */ export function authorizeSitePurge({ token, authorization, proxied, allowProxied }: SitePurgeAuthInput): SitePurgeVerdict { if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled' if (proxied && allowProxied !== true) 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 } }