- privateRevalidate sets max-age=0 on cache-control values with s-maxage - server/plugins/journal-revalidate.ts wraps res.setHeader per request, so Nitro's 200 and h3's 304 answers both carry it - purge-check: max-age=0 on 200 and 304, purged _payload.json, a second purge after re-caching, X-Real-IP refused, 404 on a server without a token - README: cachePurgeToken row and a Site cache purge section
101 lines
4.0 KiB
TypeScript
101 lines
4.0 KiB
TypeScript
// 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 }
|
|
}
|
|
|
|
/**
|
|
* 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=<n>
|
|
* 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}`
|
|
}
|