diff --git a/README.md b/README.md index 2b200f7..1ed71c8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Nuxt 4 layer that renders a Journal blog (post list, post, category, tag and author pages, RSS link, SEO and sitemap) from the Journal API of a SummerCMS application. ```bash -pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0 +pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.4.0 ``` ## Overview @@ -108,7 +108,8 @@ Runtime config, overridable by environment variables at start: |-----|-----|---------|---------| | `runtimeConfig.journal.apiBase` | `NUXT_JOURNAL_API_BASE` | `http://127.0.0.1:8096/_journal/api/v1` | Server-side API base for SSR, the sitemap and RSS. Use the internal address of the SummerCMS application, not its public URL. | | `runtimeConfig.public.journal.apiBase` | `NUXT_PUBLIC_JOURNAL_API_BASE` | `/_journal/api/v1` | Browser API base for client-side navigation; the reverse proxy routes it to the application. | -| `runtimeConfig.journal.cachePurgeToken` | `NUXT_JOURNAL_CACHE_PURGE_TOKEN` | empty | Server-only bearer token of the [site cache purge](#site-cache-purge) route. With fewer than 32 characters the route answers 404. | +| `runtimeConfig.siteCache.purgeToken` | `NUXT_SITE_CACHE_PURGE_TOKEN` | empty | Server-only bearer token of the shared [site cache purge](#site-cache-purge) route `/api/__site__/cache/purge`. With fewer than 32 characters the route answers 404. | +| `runtimeConfig.journal.cachePurgeToken` | `NUXT_JOURNAL_CACHE_PURGE_TOKEN` | empty | Deprecated (one release): token of the old route `/api/__journal__/cache/purge`. With fewer than 32 characters that route answers 404. | The module also copies `routes`, `perPage`, `sort`, `relatedCount` and `rss` into `runtimeConfig.public.journal`. @@ -116,17 +117,19 @@ Server-side requests forward the visitor's `x-forwarded-for` and append the addr ## Site cache purge -An app that caches its pages with Nitro route rules (`swr` or `cache`) would otherwise serve a page rendered before a Journal edit until the cache entry expires. The layer adds a purge route that the SummerCMS Journal plugin calls after an admin save of a post or category commits (its `golem15.journal.site_cache.purge_url` and `purge_token` settings), so the edit shows on the next page view. +An app that caches its pages with Nitro route rules (`swr` or `cache`) would otherwise serve a page rendered before an admin edit until the cache entry expires. The layer adds the shared purge route that the SummerCMS backend calls after an admin save commits (the framework's `site_cache.purge_url` and `site_cache.purge_token` settings), so the edit shows on the next page view. The Journal plugin purges after post and category saves, the menu plugin after menu saves and reorders, the FAQ plugin after FAQ saves. -**Request.** `POST /api/__journal__/cache/purge` with `Authorization: Bearer `, `Content-Type: application/json` and the body `{"kind":"post"|"category","slug":""}`. The answer is `200` with `{"purged":}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[journal] cache purge: removed route cache entries ( )`; the slug loses control characters and is cut to 200 characters first. +**Shared route.** `POST /api/__site__/cache/purge` with `Authorization: Bearer ` (`NUXT_SITE_CACHE_PURGE_TOKEN`), `Content-Type: application/json` and the body `{"kind":"","slug":""}` (kinds such as `post`, `category`, `menu`, `menu_entry`, `faq`). The answer is `200` with `{"purged":}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[site-cache] purge: removed route cache entries ( )`; a kind other than lower-case letters and underscores (up to 40) logs as `unknown`, and the slug loses control characters and is cut to 200 characters first. vue-journal-layer, vue-menu-layer and vue-faq-layer ship `server/lib/siteCachePurge.ts` and `server/api/__site__/cache/purge.post.ts` byte for byte (`tests/sitePurge.test.ts` pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps. -**Guards.** +**Old route (one release).** `POST /api/__journal__/cache/purge` with the token in `runtimeConfig.journal.cachePurgeToken` (`NUXT_JOURNAL_CACHE_PURGE_TOKEN`) and the body `{"kind":"post"|"category","slug":""}` keeps working unchanged, with the log line `[journal] cache purge: …`, so a backend still configured with the Journal plugin's deprecated keys keeps purging. Move to the shared route and `NUXT_SITE_CACHE_PURGE_TOKEN` before the next release removes it. + +**Guards** (both routes). - POST only. -- `404` when `runtimeConfig.journal.cachePurgeToken` holds fewer than 32 characters, so an app without a token has no purge route. +- `404` when the route's token holds fewer than 32 characters, so an app without a token has no purge route. - `404` for any request that carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the route is for a caller on the same host, never for traffic through the reverse proxy. - `401` for a missing or wrong bearer token. The comparison runs in constant time over sha256 digests. -- The layer's `routeRules` set `'/api/__journal__/**': { cache: false }`. Nitro wraps every handler that matches an app rule such as `'/**': { swr }` in its cached handler, which keys by path, does not skip POST and hides the request headers from the handler, so without this rule the route would answer from the cache. +- The layer's `routeRules` set `'/api/__journal__/**': { cache: false }` and `'/api/__site__/**': { cache: false }`. Nitro wraps every handler that matches an app rule such as `'/**': { swr }` in its cached handler, which keys by path, does not skip POST and hides the request headers from the handler, so without this rule the route would answer from the cache. **What it removes.** Every entry of Nitro's route cache (the `nitro:routes` group of the `cache` storage): pages, their `_payload.json` and feeds. Other cache groups stay. One save can change many pages (the menu lists every category on every page, and the home, category, author and related-post lists show posts), and Nitro keys entries by a hash of the path, so a full purge is the reliable choice; it costs one fresh render per page on its next view. @@ -134,8 +137,8 @@ An app that caches its pages with Nitro route rules (`swr` or `cache`) would oth **Deployment.** -- Generate the token on the server, at least 32 characters, and set the same value in the Nuxt app's `NUXT_JOURNAL_CACHE_PURGE_TOKEN` and in the backend's purge token setting. Keep it out of committed files. -- The backend calls the route on the Nuxt server's loopback address (for example `http://127.0.0.1:3000/api/__journal__/cache/purge`), not through the public site. +- Generate the token on the server, at least 32 characters, and set the same value in the Nuxt app's `NUXT_SITE_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`. Keep it out of committed files. +- The backend calls the route on the Nuxt server's loopback address (for example `http://127.0.0.1:3000/api/__site__/cache/purge`), not through the public site. - The reverse proxy must not forward POST to Nuxt (for example nginx `limit_except GET HEAD { deny all; }`), so the route stays off the public path even before its own checks. - The purge clears the cache storage of the Nuxt process that answers. A server with several Nuxt processes needs a shared storage driver for the `cache` mount, or every process keeps its own entries. @@ -190,7 +193,7 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co ## Testing -- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (`tests/siteCache.test.ts`: authorization, the route-cache purge, the purge body and browser revalidation), and pl/en key and placeholder parity. They need no build, network or database. +- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (`tests/siteCache.test.ts` for the old route's helpers and browser revalidation, `tests/sitePurge.test.ts` for the shared route's helpers and the sha256 pins of the shared files), and pl/en key and placeholder parity. They need no build, network or database. - `scripts/check-git-install.sh ` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer). - `testing/smoke-ssr.mjs --output <.output> --fixtures --expect ` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`. - `testing/purge-check.mjs --output <.output> --fixtures --kind post|category --slug --path ` proves the [site cache purge](#site-cache-purge) on a built server whose route rules cache pages: it caches the page, changes the entity in a temporary copy of the fixtures, requires the cached page to stay unchanged, purges with a random token, and requires the change on the next request. It also checks `max-age=0` on the page and its 304, the purged `_payload.json`, a second purge after re-caching, `401` without or with a wrong token, `404` behind a proxy and `404` on a server without a token. The last line is `purge-check: ok ( checks)`. diff --git a/nuxt.config.ts b/nuxt.config.ts index a9e7f50..3cc106b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -29,11 +29,19 @@ export default defineNuxtConfig({ // Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never // comes from request input. apiBase: 'http://127.0.0.1:8096/_journal/api/v1', - // Server-only bearer token of the site cache purge route - // (NUXT_JOURNAL_CACHE_PURGE_TOKEN). Shorter than 32 characters, the - // route answers 404. + // Server-only bearer token of the old Journal purge route + // POST /api/__journal__/cache/purge (NUXT_JOURNAL_CACHE_PURGE_TOKEN), + // kept for one release; new sites use siteCache.purgeToken. Shorter + // than 32 characters, the route answers 404. cachePurgeToken: '', }, + siteCache: { + // Server-only bearer token of the shared site cache purge route + // POST /api/__site__/cache/purge (NUXT_SITE_CACHE_PURGE_TOKEN), the + // backend's site_cache.purge_token. Shorter than 32 characters, the + // route answers 404. + purgeToken: '', + }, public: { journal: { // Same-origin base for browser fetches (NUXT_PUBLIC_JOURNAL_API_BASE). @@ -42,12 +50,14 @@ export default defineNuxtConfig({ }, }, - // The Journal's internal routes are never route-cached. Nitro wraps every - // handler matching an app rule such as '/**': { swr } in its cached - // handler, which keys by path, does not skip POST and hides request - // headers, so the purge route would answer from the cache without running. + // The Journal's internal routes and the shared site routes are never + // route-cached. Nitro wraps every handler matching an app rule such as + // '/**': { swr } in its cached handler, which keys by path, does not skip + // POST and hides request headers, so the purge routes would answer from the + // cache without running. routeRules: { '/api/__journal__/**': { cache: false }, + '/api/__site__/**': { cache: false }, }, // No OG image rendering at runtime. diff --git a/package.json b/package.json index cf1c136..e31453a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vue-journal-layer", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "main": "./nuxt.config.ts", diff --git a/server/api/__site__/cache/purge.post.ts b/server/api/__site__/cache/purge.post.ts new file mode 100644 index 0000000..2409806 --- /dev/null +++ b/server/api/__site__/cache/purge.post.ts @@ -0,0 +1,39 @@ +// 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 ` and the body +// {"kind":"","slug":""}; the answer is {"purged":}. 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); +// 401 for a missing or wrong bearer token. The '/api/__site__/**' route rule +// keeps this route out of the route cache. +// +// Shared: several layers ship this file byte for byte, and Nitro keeps one +// handler per route and method. + +import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache } 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 config = useRuntimeConfig(event) + const verdict = authorizeSitePurge({ + token: (config.siteCache as { purgeToken?: unknown } | undefined)?.purgeToken, + authorization: getRequestHeader(event, 'authorization'), + proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined), + }) + 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 } +}) diff --git a/server/lib/siteCachePurge.ts b/server/lib/siteCachePurge.ts new file mode 100644 index 0000000..73ae62b --- /dev/null +++ b/server/lib/siteCachePurge.ts @@ -0,0 +1,91 @@ +// 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 +} + +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), `ok` when + * the Bearer value equals the token (compared in constant time over sha256 + * digests) and `unauthorized` otherwise. + */ +export function authorizeSitePurge({ token, authorization, proxied }: SitePurgeAuthInput): SitePurgeVerdict { + if (typeof token !== 'string' || token.length < MIN_SITE_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 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 } +} diff --git a/tests/sitePurge.test.ts b/tests/sitePurge.test.ts new file mode 100644 index 0000000..e66c68f --- /dev/null +++ b/tests/sitePurge.test.ts @@ -0,0 +1,97 @@ +// Shared site cache purge: route authorization, the route-cache purge, the +// purge body parsing and the byte-identity of the files every layer ships. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { + MIN_SITE_PURGE_TOKEN_LENGTH, + SITE_ROUTE_CACHE_BASE, + authorizeSitePurge, + parseSitePurgeEvent, + purgeSiteRouteCache, +} from '../server/lib/siteCachePurge.ts' + +// The sha256 of the two shared files. vue-journal-layer, vue-menu-layer and +// vue-faq-layer ship them byte for byte; a change lands in all three with +// new values here. +const SHARED_LIB_SHA256 = '7c34121ae06cf011b30a1dc5d787a94657b71b8768bed3a200be0f41566001b8' +const SHARED_ROUTE_SHA256 = 'c459012280cdf59551e57f0440fcaeb24566a5ba6e3dccf31133e67a683f1c06' + +const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) + +function sha256(relative: string): string { + return createHash('sha256').update(readFileSync(new URL(relative, import.meta.url))).digest('hex') +} + +test('sitePurge: the shared files match the pinned hashes', () => { + assert.equal(sha256('../server/lib/siteCachePurge.ts'), SHARED_LIB_SHA256, 'server/lib/siteCachePurge.ts') + assert.equal(sha256('../server/api/__site__/cache/purge.post.ts'), SHARED_ROUTE_SHA256, 'server/api/__site__/cache/purge.post.ts') +}) + +test('sitePurge: a missing or short token disables the route', () => { + for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) { + assert.equal(authorizeSitePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`) + } + assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled') + assert.equal(MIN_SITE_PURGE_TOKEN_LENGTH, 32) +}) + +test('sitePurge: a proxied request is refused before the token is checked', () => { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied') +}) + +test('sitePurge: authorize ok with the token and unauthorized otherwise', () => { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok') + for (const authorization of [undefined, null, '', 'Bearer', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`) + } +}) + +/** A fake unstorage prefix storage over a plain key list. */ +function fakeStorage(keys: string[]) { + const live = new Set(keys) + const removed: string[] = [] + return { + removed, + live, + async getKeys(base?: string) { + return [...live].filter((k) => !base || k === base || k.startsWith(base + ':')) + }, + async removeItem(key: string) { + live.delete(key) + removed.push(key) + }, + } +} + +test('sitePurge: purge removes only nitro:routes: keys', async () => { + const routes = [ + `${SITE_ROUTE_CACHE_BASE}:_:faq.abc123.json`, + `${SITE_ROUTE_CACHE_BASE}:_:faq_payloadjson.def456.json`, + `${SITE_ROUTE_CACHE_BASE}:_:index.0a1b2c.json`, + ] + const others = ['nitro:functions:_:faq.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json'] + const storage = fakeStorage([...routes, ...others]) + assert.equal(SITE_ROUTE_CACHE_BASE, 'nitro:routes') + assert.equal(await purgeSiteRouteCache(storage), routes.length) + assert.deepEqual([...storage.removed].sort(), [...routes].sort()) + assert.deepEqual([...storage.live].sort(), [...others].sort()) + assert.equal(await purgeSiteRouteCache(storage), 0) +}) + +test('sitePurge: purge event kinds and slugs', () => { + for (const kind of ['faq', 'menu', 'menu_entry', 'post', 'category']) { + assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind, slug: 'x' }) + } + for (const kind of ['FAQ', 'menu-entry', 'a'.repeat(41), '', 7, null]) { + assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind: 'unknown', slug: 'x' }, JSON.stringify(kind)) + } + assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 'a\nb\r\tc\u0000d\u007fe\u0085f' }), { kind: 'faq', slug: 'abcdef' }) + assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'x'.repeat(500) }).slug.length, 200) + for (const body of [null, undefined, 'faq', 42, ['faq', 'x']]) { + assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body)) + } +})