- POST /api/__site__/cache/purge, its library and the site config plugin moved from vue-journal-layer with the same contract (token rules, 404 for proxied requests unless allowProxied, deprecated siteCache names); the route also clears the resolve cache - server/plugins/seo-cache-purge.ts clears the SEO caches after any 200 purge, so an app whose journal, menu or FAQ layer copy of the route wins keeps fresh SEO answers until those copies are removed - cachePurge and siteCache runtime keys; /api/__site__/** never cached - testing/purge-check.mjs (CMS answer change, plus a redirect added for a route-cached page applying at once) and purge-token-check.mjs
209 lines
8.6 KiB
TypeScript
209 lines
8.6 KiB
TypeScript
// 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[]> | 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 }
|
|
}
|
|
|
|
/**
|
|
* 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<string, unknown> {
|
|
if (config === null || typeof config !== 'object') return {}
|
|
const value = (config as Record<string, unknown>)[name]
|
|
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
|
}
|
|
|
|
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<string, unknown>)[key]
|
|
removed++
|
|
}
|
|
}
|
|
}
|
|
return removed
|
|
}
|
|
|
|
const DEPRECATED_NAMES: Record<SitePurgeDeprecated, [string, string]> = {
|
|
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.'
|
|
)
|
|
}
|