// Shared site cache purge: the authorization rules of the purge route // POST /api/__site__/cache/purge, the purge of Nitro's route cache, the // parsing of the purge request body, the purge settings read from runtime // config (new and deprecated names) and the removal of the purge keys from // nuxt-site-config's site config. Pure TypeScript outside Nitro's and Nuxt's // auto-import directories: vue-journal-layer, vue-menu-layer and // vue-faq-layer ship this file byte for byte, and `node --test`, the route // and the Nitro plugin 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 (see resolveSitePurgeConfig). */ 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 /** * The allowProxied setting (see resolveSitePurgeConfig): 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 } } /** * The site config keys nuxt-site-config derives from the deprecated * variables NUXT_SITE_CACHE_PURGE_TOKEN and NUXT_SITE_CACHE_ALLOW_PROXIED: it * copies every NUXT_SITE_* variable into the site config, camel-cased, and * Nuxt serializes the site config into every page's payload. */ export const SITE_CONFIG_PURGE_KEYS: readonly string[] = Object.freeze(['cachePurgeToken', 'cacheAllowProxied']) /** * The globalThis flag that keeps the deprecation warning to one per process, * however many layers ship the Nitro plugin. */ export const SITE_PURGE_WARNED = Symbol.for('summercms.siteCachePurge.deprecationWarned') export type SitePurgeDeprecated = 'token' | 'allowProxied' export interface SitePurgeConfig { /** The token the route checks; authorizeSitePurge decides whether it is usable. */ token: unknown /** True when a proxied request may reach the token check. */ allowProxied: boolean /** The settings that came from a deprecated name. */ deprecated: SitePurgeDeprecated[] } function section(config: unknown, name: string): Record { if (config === null || typeof config !== 'object') return {} const value = (config as Record)[name] return value !== null && typeof value === 'object' ? (value as Record) : {} } const isTrue = (value: unknown) => value === true || value === 'true' /** * Reads the purge settings from runtime config. The current names are * `cachePurge.token` (NUXT_CACHE_PURGE_TOKEN) and `cachePurge.allowProxied` * (NUXT_CACHE_PURGE_ALLOW_PROXIED). Deprecated for one release: * `siteCache.purgeToken` (NUXT_SITE_CACHE_PURGE_TOKEN) and * `siteCache.allowProxied` (NUXT_SITE_CACHE_ALLOW_PROXIED). A non-empty new * token wins over the old one; allowProxied is on when either flag is true or * "true". `deprecated` lists the settings taken from an old name. */ export function resolveSitePurgeConfig(config: unknown): SitePurgeConfig { const current = section(config, 'cachePurge') const old = section(config, 'siteCache') const deprecated: SitePurgeDeprecated[] = [] let token: unknown = current.token if (typeof token !== 'string' || token === '') { if (typeof old.purgeToken === 'string' && old.purgeToken !== '') { token = old.purgeToken deprecated.push('token') } else if (token === undefined) { token = old.purgeToken } } let allowProxied = isTrue(current.allowProxied) if (!allowProxied && isTrue(old.allowProxied)) { allowProxied = true deprecated.push('allowProxied') } return { token, allowProxied, deprecated } } /** * Removes SITE_CONFIG_PURGE_KEYS from every entry of a nuxt-site-config stack * (the array of partial site configs it merges per request) and returns how * many keys it removed. Anything that is not an array of objects is left * alone and counts 0. */ export function redactSiteConfigStack(stack: unknown): number { if (!Array.isArray(stack)) return 0 let removed = 0 for (const entry of stack) { if (entry === null || typeof entry !== 'object') continue for (const key of SITE_CONFIG_PURGE_KEYS) { if (Object.prototype.hasOwnProperty.call(entry, key)) { delete (entry as Record)[key] removed++ } } } return removed } const DEPRECATED_NAMES: Record = { token: ['NUXT_SITE_CACHE_PURGE_TOKEN', 'NUXT_CACHE_PURGE_TOKEN'], allowProxied: ['NUXT_SITE_CACHE_ALLOW_PROXIED', 'NUXT_CACHE_PURGE_ALLOW_PROXIED'], } /** * The one-line deprecation warning for the settings resolveSitePurgeConfig * took from an old name, or '' when there are none. It names variables * only, never a value. */ export function sitePurgeDeprecationMessage(deprecated: readonly SitePurgeDeprecated[]): string { const names = [...new Set(deprecated)].filter((d) => d in DEPRECATED_NAMES).map((d) => `${DEPRECATED_NAMES[d][0]} -> ${DEPRECATED_NAMES[d][1]}`) if (names.length === 0) return '' return ( `[site-cache] deprecated purge settings in use, rename them: ${names.join(', ')}. ` + 'The old names work until the next minor release. Earlier layer versions published every NUXT_SITE_* value ' + 'in the page payload through nuxt-site-config, so rotate a token that was ever set under NUXT_SITE_CACHE_PURGE_TOKEN.' ) }