feat!: extend the SEO layer and drop the copied purge route
- extend vue-seo-layer (link dependency during development); every page takes its head from the SEO plugin through it - remove the copied shared purge route, library, plugin and their test: POST /api/__site__/cache/purge comes from vue-seo-layer with the same contract and environment variables - the page-less playground sets seo.htmlSitemap false; the smoke points NUXT_SEO_API_BASE at the fixture API (fails open) BREAKING CHANGE: server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts and server/plugins/site-cache-purge.ts are no longer in this layer, and the runtime keys cachePurge and siteCache are declared by vue-seo-layer. An app without pages sets seo.htmlSitemap false.
This commit is contained in:
48
server/api/__site__/cache/purge.post.ts
vendored
48
server/api/__site__/cache/purge.post.ts
vendored
@@ -1,48 +0,0 @@
|
||||
// POST /api/__site__/cache/purge: removes every Nitro route-cache entry
|
||||
// (pages, _payload.json and feeds), so an admin save shows on the next page
|
||||
// view. The backend calls it on loopback after the save commits, with
|
||||
// `Authorization: Bearer <runtimeConfig.cachePurge.token>`
|
||||
// (NUXT_CACHE_PURGE_TOKEN) and the body {"kind":"<kind>","slug":"<slug>"};
|
||||
// the answer is {"purged":<n>}. The kind and slug only reach the log line.
|
||||
//
|
||||
// 404 without a token of at least 32 characters, and for any request that
|
||||
// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded)
|
||||
// unless runtimeConfig.cachePurge.allowProxied
|
||||
// (NUXT_CACHE_PURGE_ALLOW_PROXIED) is true or "true": a backend that reaches
|
||||
// the site through a TLS-terminating proxy opts in, and the bearer token is
|
||||
// then the only gate. 401 for a missing or wrong bearer token. The
|
||||
// '/api/__site__/**' route rule keeps this route out of the route cache.
|
||||
//
|
||||
// Deprecated for one release: siteCache.purgeToken
|
||||
// (NUXT_SITE_CACHE_PURGE_TOKEN) and siteCache.allowProxied
|
||||
// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new names are unset (see
|
||||
// resolveSitePurgeConfig).
|
||||
//
|
||||
// Shared: several layers ship this file byte for byte, and Nitro keeps one
|
||||
// handler per route and method.
|
||||
|
||||
import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache, resolveSitePurgeConfig } from '../../../lib/siteCachePurge.ts'
|
||||
|
||||
const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded']
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
setResponseHeader(event, 'cache-control', 'no-store')
|
||||
const { token, allowProxied } = resolveSitePurgeConfig(useRuntimeConfig(event))
|
||||
const verdict = authorizeSitePurge({
|
||||
token,
|
||||
authorization: getRequestHeader(event, 'authorization'),
|
||||
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
|
||||
allowProxied,
|
||||
})
|
||||
if (verdict === 'disabled' || verdict === 'proxied') {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
||||
}
|
||||
if (verdict === 'unauthorized') {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const { kind, slug } = parseSitePurgeEvent(await readBody(event).catch(() => null))
|
||||
const purged = await purgeSiteRouteCache(useStorage('cache'))
|
||||
console.info(`[site-cache] purge: removed ${purged} route cache entries (${kind} ${slug})`)
|
||||
return { purged }
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
// 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.'
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Keeps the site cache purge settings out of the public site config.
|
||||
//
|
||||
// nuxt-site-config (pulled in by @nuxtjs/seo) copies every NUXT_SITE_* and
|
||||
// NUXT_PUBLIC_SITE_* environment variable into the site config, camel-cased,
|
||||
// and Nuxt serializes the site config into every page's payload (and into
|
||||
// window.__NUXT_SITE_CONFIG__ on client-only pages). The purge token's
|
||||
// earlier name NUXT_SITE_CACHE_PURGE_TOKEN therefore reached every page as
|
||||
// cachePurgeToken, and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied.
|
||||
// The settings now live under NUXT_CACHE_PURGE_TOKEN and
|
||||
// NUXT_CACHE_PURGE_ALLOW_PROXIED, outside that prefix. The old names keep
|
||||
// working for one release, so this plugin also removes the two keys from
|
||||
// every entry of the site config stack on nuxt-site-config's
|
||||
// `site-config:init` hook, before anything reads it. The stripping stays
|
||||
// with the new names too: a site that still sets an old variable, or sets
|
||||
// both, must not publish it.
|
||||
//
|
||||
// When a setting comes from an old name, the plugin logs one deprecation
|
||||
// warning per process (globalThis[SITE_PURGE_WARNED], so a site extending
|
||||
// several layers that ship this file warns once). The warning names
|
||||
// variables, never a value.
|
||||
//
|
||||
// Without nuxt-site-config the hook is never called and the plugin does
|
||||
// nothing else. Shared: several layers ship this file byte for byte;
|
||||
// running it more than once is harmless.
|
||||
|
||||
import { SITE_PURGE_WARNED, redactSiteConfigStack, resolveSitePurgeConfig, sitePurgeDeprecationMessage } from '../lib/siteCachePurge.ts'
|
||||
|
||||
interface SiteConfigInitContext {
|
||||
siteConfig?: { stack?: unknown }
|
||||
}
|
||||
|
||||
type LooseHooks = { hook: (name: string, fn: (ctx: SiteConfigInitContext | undefined) => void) => unknown }
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
const g = globalThis as Record<symbol, unknown>
|
||||
if (!g[SITE_PURGE_WARNED]) {
|
||||
const message = sitePurgeDeprecationMessage(resolveSitePurgeConfig(useRuntimeConfig()).deprecated)
|
||||
if (message) {
|
||||
g[SITE_PURGE_WARNED] = true
|
||||
console.warn(message)
|
||||
}
|
||||
}
|
||||
|
||||
// The hook name is untyped in an app without nuxt-site-config.
|
||||
;(nitroApp.hooks as unknown as LooseHooks).hook('site-config:init', (ctx) => {
|
||||
redactSiteConfigStack(ctx?.siteConfig?.stack)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user