Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25e9bfe8da | |||
| 03a90e952d | |||
| 4f55511ece | |||
| 0a01bfeae4 | |||
| a03e076c62 | |||
| 782aa4da7d |
@@ -4,6 +4,9 @@ export default defineNuxtConfig({
|
||||
extends: ['..'],
|
||||
compatibilityDate: '2025-07-15',
|
||||
site: { url: 'https://acme.test', name: 'Acme Blog' },
|
||||
// Route-cached like a real host, so testing/purge-check.mjs can prove a
|
||||
// purge clears cached pages.
|
||||
routeRules: { '/**': { swr: 300 } },
|
||||
i18n: {
|
||||
strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
|
||||
36
README.md
36
README.md
@@ -108,10 +108,36 @@ 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. |
|
||||
|
||||
The module also copies `routes`, `perPage`, `sort`, `relatedCount` and `rss` into `runtimeConfig.public.journal`.
|
||||
|
||||
Server-side requests forward the visitor's `x-forwarded-for` and append the address of the peer that reached Nuxt. The Journal API rate-limits anonymous reads per client address; with a trusted proxy configured for the Nuxt server's address, each visitor keeps their own bucket instead of all SSR traffic sharing one. The API base always comes from runtime config, never from request input.
|
||||
Server-side requests forward the visitor's `x-forwarded-for` and append the address of the peer that reached Nuxt. The Journal API rate-limits anonymous reads per client address; with a trusted proxy configured for the Nuxt server's address, each visitor keeps their own bucket instead of all SSR traffic sharing one. Under route caching (`swr` or `cache` route rules), Nitro renders a page from a copy of the request that carries no headers, so the layer's Nitro plugin `server/plugins/journal-client-address.ts` records the address in `event.context.journalForwardedFor` on the request hook, and `useJournalApi`, `/rss.xml` and the sitemap source forward that value. The API base always comes from runtime config, never from request input.
|
||||
|
||||
## 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.
|
||||
|
||||
**Request.** `POST /api/__journal__/cache/purge` with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"post"|"category","slug":"<slug>"}`. The answer is `200` with `{"purged":<n>}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[journal] cache purge: removed <n> route cache entries (<kind> <slug>)`; the slug loses control characters and is cut to 200 characters first.
|
||||
|
||||
**Guards.**
|
||||
|
||||
- POST only.
|
||||
- `404` when `runtimeConfig.journal.cachePurgeToken` 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.
|
||||
|
||||
**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.
|
||||
|
||||
**Browser revalidation.** Nitro answers a route-cached page with `s-maxage=<n>, stale-while-revalidate`, which gives browsers no `max-age`, so they guess a freshness lifetime from `Last-Modified`; its 304 answer carries `public, max-age=<n>, s-maxage=<n>`. Either way a browser could keep its copy past a purge. The layer's Nitro plugin `server/plugins/journal-revalidate.ts` rewrites every `cache-control` value that carries `s-maxage` to `max-age=0` for browsers (`privateRevalidate` in `app/utils/siteCache.ts`), so a browser asks on each view and gets a cheap 304 while the page is unchanged. Shared caches keep `s-maxage`. Static assets carry no `s-maxage` and are untouched.
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
|
||||
## Theming
|
||||
|
||||
@@ -164,12 +190,16 @@ 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, 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`: 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.
|
||||
- `scripts/check-git-install.sh <tag>` 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 <dir> --expect <file>` 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/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server. Apps can import both from `node_modules/vue-journal-layer/testing/`.
|
||||
- `testing/purge-check.mjs --output <.output> --fixtures <dir> --kind post|category --slug <slug> --path <page 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 (<n> checks)`.
|
||||
- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`, and `reload()` re-reads them; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server. Apps can import both from `node_modules/vue-journal-layer/testing/`.
|
||||
|
||||
```bash
|
||||
pnpm build:playground
|
||||
node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt
|
||||
node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme
|
||||
```
|
||||
|
||||
The playground caches every page with `routeRules: { '/**': { swr: 300 } }`, like a real host.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// the internal base (runtimeConfig.journal.apiBase) and forwards the visitor's
|
||||
// x-forwarded-for, plus the address of the peer that reached Nuxt, so the Go
|
||||
// rate limiter keeps one bucket per visitor instead of one for the Nuxt
|
||||
// server. In the browser it calls the same-origin public base. The base is
|
||||
// server, also on route-cached renders. In the browser it calls the same-origin public base. The base is
|
||||
// always runtime config, never request input.
|
||||
|
||||
import { createJournalClient, type JournalClient, type JournalFetcher } from '../utils/client.ts'
|
||||
@@ -12,9 +12,13 @@ export function useJournalApi(): JournalClient {
|
||||
|
||||
if (import.meta.server) {
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
// Under route caching the request copy has no headers; the address the
|
||||
// layer's server/plugins/journal-client-address.ts recorded comes first.
|
||||
const event = useRequestEvent()
|
||||
const recorded = event?.context.journalForwardedFor as string | undefined
|
||||
const incoming = useRequestHeaders(['x-forwarded-for'])['x-forwarded-for']
|
||||
const peer = useRequestEvent()?.node?.req?.socket?.remoteAddress
|
||||
const forwarded = [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ')
|
||||
const peer = event?.node?.req?.socket?.remoteAddress
|
||||
const forwarded = recorded || [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ')
|
||||
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
|
||||
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
|
||||
return createJournalClient(fetcher)
|
||||
|
||||
100
app/utils/siteCache.ts
Normal file
100
app/utils/siteCache.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// 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}`
|
||||
}
|
||||
@@ -29,6 +29,10 @@ 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.
|
||||
cachePurgeToken: '',
|
||||
},
|
||||
public: {
|
||||
journal: {
|
||||
@@ -38,6 +42,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.
|
||||
routeRules: {
|
||||
'/api/__journal__/**': { cache: false },
|
||||
},
|
||||
|
||||
// No OG image rendering at runtime.
|
||||
ogImage: { enabled: false },
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
|
||||
36
server/api/__journal__/cache/purge.post.ts
vendored
Normal file
36
server/api/__journal__/cache/purge.post.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
// POST /api/__journal__/cache/purge: removes every Nitro route-cache entry
|
||||
// (pages, _payload.json and feeds), so a Journal save in the admin shows on
|
||||
// the next page view. The backend calls it on loopback after the save
|
||||
// commits, with `Authorization: Bearer <runtimeConfig.journal.cachePurgeToken>`
|
||||
// and the body {"kind":"post"|"category","slug":"<slug>"}; the answer is
|
||||
// {"purged":<n>}.
|
||||
//
|
||||
// 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 layer's routeRules keep this
|
||||
// route out of the route cache.
|
||||
|
||||
import { authorizePurge, parsePurgeEvent, purgeRouteCache } from '../../../../app/utils/siteCache.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 = authorizePurge({
|
||||
token: (config.journal as { cachePurgeToken?: unknown }).cachePurgeToken,
|
||||
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 } = parsePurgeEvent(await readBody(event).catch(() => null))
|
||||
const purged = await purgeRouteCache(useStorage('cache'))
|
||||
console.info(`[journal] cache purge: removed ${purged} route cache entries (${kind} ${slug})`)
|
||||
return { purged }
|
||||
})
|
||||
@@ -15,9 +15,13 @@ export default defineSitemapEventHandler(async (event) => {
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
const routes = (config.public.journal as { routes: JournalRoutes }).routes
|
||||
|
||||
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
// Recorded by server/plugins/journal-client-address.ts before any route
|
||||
// cache hides the request headers.
|
||||
const forwarded =
|
||||
(event.context.journalForwardedFor as string | undefined) ||
|
||||
[getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
|
||||
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
|
||||
const api = createJournalClient(fetcher)
|
||||
|
||||
20
server/plugins/journal-client-address.ts
Normal file
20
server/plugins/journal-client-address.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Keeps the visitor's address available to route-cached SSR renders.
|
||||
//
|
||||
// An app rule such as `'/**': { swr }` wraps the page renderer (and any
|
||||
// matching server route) in Nitro's cached handler, which renders from a copy
|
||||
// of the request that carries no headers (only `varies` headers survive, so a
|
||||
// cached page never depends on one visitor's request). Reading
|
||||
// x-forwarded-for there would forward only the Nuxt peer, and every cache
|
||||
// miss would share the Journal API's rate-limit bucket. The event context is
|
||||
// shared with that copy, so the incoming x-forwarded-for plus the peer
|
||||
// address is recorded on the request hook, before the cached handler runs;
|
||||
// useJournalApi, /rss.xml and the sitemap source forward it.
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), event.node.req.socket?.remoteAddress]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
event.context.journalForwardedFor = forwarded
|
||||
})
|
||||
})
|
||||
29
server/plugins/journal-revalidate.ts
Normal file
29
server/plugins/journal-revalidate.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Browser revalidation of route-cached pages. Nitro's route cache answers
|
||||
// 200 with `cache-control: s-maxage=<n>, stale-while-revalidate`, which
|
||||
// gives browsers no max-age, so they apply heuristic freshness from
|
||||
// Last-Modified; a conditional request it answers 304 with
|
||||
// `public, max-age=<n>, s-maxage=<n>`, so after one revalidation a browser
|
||||
// could reuse its copy for the whole TTL, past any site cache purge.
|
||||
//
|
||||
// This plugin passes every cache-control value a response sets through
|
||||
// privateRevalidate: values with s-maxage get max-age=0, so browsers ask on
|
||||
// each view (a cheap 304 while the page is unchanged) and shared caches keep
|
||||
// s-maxage. The 304 path ends the response before any beforeResponse hook
|
||||
// runs, so the rewrite wraps the request's res.setHeader instead. Static
|
||||
// assets carry no s-maxage and are untouched.
|
||||
|
||||
import { privateRevalidate } from '../../app/utils/siteCache.ts'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
const res = event.node.res
|
||||
const setHeader = res.setHeader
|
||||
res.setHeader = function (name, value) {
|
||||
if (typeof name === 'string' && name.toLowerCase() === 'cache-control') {
|
||||
if (typeof value === 'string') value = privateRevalidate(value)
|
||||
else if (Array.isArray(value)) value = value.map((v) => privateRevalidate(String(v)))
|
||||
}
|
||||
return setHeader.call(this, name, value)
|
||||
} as typeof res.setHeader
|
||||
})
|
||||
})
|
||||
@@ -10,9 +10,13 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
|
||||
const forwarded = [getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
// Recorded by server/plugins/journal-client-address.ts before any route
|
||||
// cache hides the request headers.
|
||||
const forwarded =
|
||||
(event.context.journalForwardedFor as string | undefined) ||
|
||||
[getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)]
|
||||
.filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
.join(', ')
|
||||
|
||||
let body: string
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@ const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title
|
||||
|
||||
/**
|
||||
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], reload: () => void, close: () => Promise<void> }} FixtureApi
|
||||
*/
|
||||
|
||||
/** @param {string} dir @param {string} name @param {any} fallback */
|
||||
@@ -118,19 +118,28 @@ function sibling(post) {
|
||||
*/
|
||||
export function startFixtureApi({ dir, port = 0 }) {
|
||||
/** @type {any[]} */
|
||||
const posts = readJson(dir, 'posts.json', [])
|
||||
let posts = []
|
||||
/** @type {any[]} */
|
||||
const categories = readJson(dir, 'categories.json', [])
|
||||
let categories = []
|
||||
/** @type {any[]} */
|
||||
const tags = readJson(dir, 'tags.json', [])
|
||||
let tags = []
|
||||
/** @type {any[]} */
|
||||
const authors = readJson(dir, 'authors.json', [])
|
||||
let authors = []
|
||||
let rss = ''
|
||||
try {
|
||||
rss = readFileSync(join(dir, 'rss.xml'), 'utf8')
|
||||
} catch {
|
||||
rss = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>'
|
||||
// Reads (or re-reads) the fixtures, so a check can change a fixture file
|
||||
// while the API runs.
|
||||
const load = () => {
|
||||
posts = readJson(dir, 'posts.json', [])
|
||||
categories = readJson(dir, 'categories.json', [])
|
||||
tags = readJson(dir, 'tags.json', [])
|
||||
authors = readJson(dir, 'authors.json', [])
|
||||
try {
|
||||
rss = readFileSync(join(dir, 'rss.xml'), 'utf8')
|
||||
} catch {
|
||||
rss = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>'
|
||||
}
|
||||
}
|
||||
load()
|
||||
|
||||
/** @type {FixtureRequest[]} */
|
||||
const requests = []
|
||||
@@ -240,6 +249,7 @@ export function startFixtureApi({ dir, port = 0 }) {
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
requests,
|
||||
reload: load,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
})
|
||||
})
|
||||
|
||||
220
testing/purge-check.mjs
Normal file
220
testing/purge-check.mjs
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
// Site cache purge check: proves on a built Nuxt server with swr route rules
|
||||
// that a page cached before a content change shows the change on the first
|
||||
// request after a purge, and that the purge route refuses unauthenticated,
|
||||
// wrong-token and proxied requests.
|
||||
//
|
||||
// node purge-check.mjs --output <.output dir> --fixtures <dir> --kind post|category --slug <slug> --path <page path>
|
||||
//
|
||||
// It also checks that route-cached pages and their 304 revalidations tell
|
||||
// browsers max-age=0, that a page's _payload.json (when the build serves
|
||||
// one) is purged too, that a second purge after re-caching runs (the route
|
||||
// is never cached itself) and that a server without a token answers 404.
|
||||
// The fixtures are copied to a temporary directory, so the source fixtures
|
||||
// never change. The last line is "purge-check: ok (<n> checks)" on success;
|
||||
// every failure prints "purge-check: FAIL ..." and exits 1.
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { startFixtureApi } from './fixture-api.mjs'
|
||||
import { startNuxtServer } from './nuxt-server.mjs'
|
||||
|
||||
const PURGE_PATH = '/api/__journal__/cache/purge'
|
||||
|
||||
/** @param {string[]} argv */
|
||||
function parseArgs(argv) {
|
||||
/** @type {Record<string, string>} */
|
||||
const out = {}
|
||||
const names = new Set(['--output', '--fixtures', '--kind', '--slug', '--path'])
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
if (!names.has(arg)) throw new Error(`unknown argument ${arg}`)
|
||||
const v = argv[++i]
|
||||
if (v === undefined) throw new Error(`${arg} needs a value`)
|
||||
out[arg.slice(2)] = v
|
||||
}
|
||||
for (const name of names) {
|
||||
if (!out[name.slice(2)]) throw new Error('--output, --fixtures, --kind, --slug and --path are required')
|
||||
}
|
||||
if (out.kind !== 'post' && out.kind !== 'category') throw new Error(`--kind must be post or category, got ${out.kind}`)
|
||||
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
|
||||
return /** @type {{ output: string, fixtures: string, kind: 'post' | 'category', slug: string, path: string }} */ (out)
|
||||
}
|
||||
|
||||
/** @param {any[]} nodes @returns {any[]} */
|
||||
function flatten(nodes) {
|
||||
return (nodes ?? []).flatMap((n) => [n, ...flatten(n.children)])
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a marker paragraph to the post's content and content_html, or to
|
||||
* the category's content, in the fixture directory.
|
||||
* @param {string} dir @param {'post' | 'category'} kind @param {string} slug @param {string} marker
|
||||
*/
|
||||
function appendMarker(dir, kind, slug, marker) {
|
||||
const file = join(dir, kind === 'post' ? 'posts.json' : 'categories.json')
|
||||
const rows = JSON.parse(readFileSync(file, 'utf8'))
|
||||
const row = (kind === 'post' ? rows : flatten(rows)).find((/** @type {any} */ r) => r.slug === slug)
|
||||
if (!row) throw new Error(`no ${kind} with slug ${slug} in ${file}`)
|
||||
const para = `\n<p>${marker}</p>\n`
|
||||
if (kind === 'post') {
|
||||
row.content = (row.content ?? '') + `\n\n${marker}\n`
|
||||
row.content_html = (row.content_html ?? '') + para
|
||||
} else {
|
||||
row.content = (row.content ?? '') + para
|
||||
}
|
||||
writeFileSync(file, JSON.stringify(rows, null, 2))
|
||||
}
|
||||
|
||||
/** @param {string} base @param {string} path @param {Record<string, string>} [headers] */
|
||||
async function get(base, path, headers = {}) {
|
||||
const res = await fetch(base + path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) })
|
||||
return { status: res.status, headers: res.headers, body: await res.text() }
|
||||
}
|
||||
|
||||
/** @param {string} base @param {{ kind: string, slug: string }} event @param {Record<string, string>} headers */
|
||||
async function purge(base, event, headers) {
|
||||
const res = await fetch(base + PURGE_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: JSON.stringify(event),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
const text = await res.text()
|
||||
/** @type {any} */
|
||||
let json = null
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch {
|
||||
json = null
|
||||
}
|
||||
return { status: res.status, json, text }
|
||||
}
|
||||
|
||||
/** The payload path Nuxt serves for a page path. @param {string} path */
|
||||
function payloadPath(path) {
|
||||
return `${path.replace(/\/+$/, '')}/_payload.json`
|
||||
}
|
||||
|
||||
/** @param {string | null} value */
|
||||
function revalidates(value) {
|
||||
return /(^|[\s,])max-age=0(\s*,|\s*$)/.test(value ?? '') && /(^|[\s,])s-maxage=/.test(value ?? '')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const event = { kind: args.kind, slug: args.slug }
|
||||
const dir = mkdtempSync(join(tmpdir(), 'purge-check-'))
|
||||
cpSync(resolve(args.fixtures), dir, { recursive: true })
|
||||
const token = randomBytes(24).toString('hex')
|
||||
const auth = { authorization: `Bearer ${token}` }
|
||||
const marker = `purge-check-marker-${randomBytes(6).toString('hex')}`
|
||||
const second = `purge-check-second-${randomBytes(6).toString('hex')}`
|
||||
|
||||
/** @type {string[]} */
|
||||
const failures = []
|
||||
let checks = 0
|
||||
/** @param {boolean} ok @param {string} what */
|
||||
const check = (ok, what) => {
|
||||
checks++
|
||||
if (!ok) failures.push(what)
|
||||
return ok
|
||||
}
|
||||
|
||||
const api = await startFixtureApi({ dir })
|
||||
/** @type {{ url: string, close: () => Promise<void> } | null} */
|
||||
let nuxt = null
|
||||
// The steps stop at the first failure that makes the later ones
|
||||
// meaningless; the failures are reported after the servers stop.
|
||||
const steps = async () => {
|
||||
nuxt = await startNuxtServer({
|
||||
outputDir: args.output,
|
||||
apiBase: `${api.url}/_journal/api/v1`,
|
||||
env: { NUXT_JOURNAL_CACHE_PURGE_TOKEN: token },
|
||||
})
|
||||
const base = nuxt.url
|
||||
const page = args.path
|
||||
const payload = payloadPath(page)
|
||||
|
||||
// 1. The page (and its payload, when the build has one) render and are cached.
|
||||
const first = await get(base, page)
|
||||
if (!check(first.status === 200, `GET ${page} before the change: status ${first.status}, want 200`)) return
|
||||
const hasPayload = (await get(base, payload)).status === 200
|
||||
|
||||
// 2. Change the content behind the API; the cached page must not show it.
|
||||
appendMarker(dir, args.kind, args.slug, marker)
|
||||
api.reload()
|
||||
const cached = await get(base, page)
|
||||
check(cached.status === 200, `GET ${page} after the change: status ${cached.status}, want 200`)
|
||||
if (!check(!cached.body.includes(marker), `GET ${page} shows the change before any purge: the page is not route-cached (missing swr route rules?)`)) return
|
||||
|
||||
// 3. An authorized purge removes the cached entries.
|
||||
const purged = await purge(base, event, auth)
|
||||
check(purged.status === 200, `authorized purge: status ${purged.status}, want 200 (${purged.text.slice(0, 200)})`)
|
||||
check(typeof purged.json?.purged === 'number' && purged.json.purged >= 1, `authorized purge: answer ${purged.text.slice(0, 200)}, want purged >= 1`)
|
||||
|
||||
// 4. The next request shows the change and tells browsers to revalidate.
|
||||
const fresh = await get(base, page)
|
||||
check(fresh.status === 200 && fresh.body.includes(marker), `GET ${page} after the purge: status ${fresh.status}, change shown: ${fresh.body.includes(marker)}`)
|
||||
const cc = fresh.headers.get('cache-control')
|
||||
check(revalidates(cc), `GET ${page} after the purge: cache-control "${cc}", want max-age=0 and s-maxage`)
|
||||
const etag = fresh.headers.get('etag')
|
||||
if (check(!!etag, `GET ${page} after the purge: no ETag`)) {
|
||||
const revalidated = await get(base, page, { 'if-none-match': /** @type {string} */ (etag) })
|
||||
check(revalidated.status === 304, `conditional GET ${page}: status ${revalidated.status}, want 304`)
|
||||
const cc304 = revalidated.headers.get('cache-control')
|
||||
check(revalidates(cc304), `conditional GET ${page}: cache-control "${cc304}", want max-age=0 and s-maxage`)
|
||||
}
|
||||
if (hasPayload) {
|
||||
const p = await get(base, payload)
|
||||
check(p.status === 200 && p.body.includes(marker), `GET ${payload} after the purge: status ${p.status}, change shown: ${p.body.includes(marker)}`)
|
||||
}
|
||||
|
||||
// 5. A second purge after the page was cached again runs too: the purge
|
||||
// route itself is never route-cached.
|
||||
appendMarker(dir, args.kind, args.slug, second)
|
||||
api.reload()
|
||||
const recached = await get(base, page)
|
||||
check(!recached.body.includes(second), `GET ${page} after the second change: shown before the second purge (page not re-cached)`)
|
||||
const again = await purge(base, event, auth)
|
||||
check(again.status === 200 && typeof again.json?.purged === 'number' && again.json.purged >= 1, `second purge: status ${again.status}, answer ${again.text.slice(0, 200)}, want 200 with purged >= 1`)
|
||||
const fresh2 = await get(base, page)
|
||||
check(fresh2.body.includes(second), `GET ${page} after the second purge: the second change is not shown (the purge answer came from a cache)`)
|
||||
|
||||
// 6. Refused purges.
|
||||
const none = await purge(base, event, {})
|
||||
check(none.status === 401, `purge without a token: status ${none.status}, want 401`)
|
||||
const wrong = await purge(base, event, { authorization: `Bearer ${randomBytes(24).toString('hex')}` })
|
||||
check(wrong.status === 401, `purge with a wrong token: status ${wrong.status}, want 401`)
|
||||
const proxied = await purge(base, event, { ...auth, 'x-forwarded-for': '203.0.113.7' })
|
||||
check(proxied.status === 404, `proxied purge with the right token: status ${proxied.status}, want 404`)
|
||||
const realIP = await purge(base, event, { ...auth, 'x-real-ip': '203.0.113.7' })
|
||||
check(realIP.status === 404, `purge with X-Real-IP and the right token: status ${realIP.status}, want 404`)
|
||||
|
||||
// 7. A server without a token has no purge route.
|
||||
await nuxt.close()
|
||||
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: { NUXT_JOURNAL_CACHE_PURGE_TOKEN: '' } })
|
||||
const disabled = await purge(nuxt.url, event, auth)
|
||||
check(disabled.status === 404, `purge on a server without a token: status ${disabled.status}, want 404`)
|
||||
}
|
||||
try {
|
||||
await steps()
|
||||
} finally {
|
||||
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
|
||||
await api.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) console.error(`purge-check: FAIL ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`purge-check: ok (${checks} checks)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`purge-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -118,3 +118,58 @@ test('the editor corpus embed entries expand after sanitizing', () => {
|
||||
assert.equal(expandEmbeds(clean), clean, entry.name)
|
||||
}
|
||||
})
|
||||
|
||||
test('expansion is last: sanitizing the expanded output would strip the iframe', () => {
|
||||
// Why the order matters: the sanitizer never keeps an iframe, so a sink that
|
||||
// sanitized after expanding would lose every video.
|
||||
const again = sanitizeJournalHtml(IFRAME)
|
||||
assert.equal(iframeCount(again), 0)
|
||||
assert.doesNotMatch(again, /youtube-nocookie/)
|
||||
})
|
||||
|
||||
test('the sinks expand after sanitizing and the payload transforms never expand', () => {
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
for (const sink of ['../app/components/Journal/PostBody.vue', '../app/components/Journal/CategoryHeader.vue']) {
|
||||
const source = read(sink)
|
||||
assert.match(source, /expandEmbeds\(sanitizeJournalHtml\(/, `${sink} must render expandEmbeds(sanitizeJournalHtml(...))`)
|
||||
assert.equal((source.match(/expandEmbeds\(/g) ?? []).length, 1, `${sink} expands once`)
|
||||
}
|
||||
for (const transform of ['../app/utils/postView.ts', '../app/journal-pages/CategoryPage.vue']) {
|
||||
assert.doesNotMatch(read(transform), /expandEmbeds/, `${transform} must not expand`)
|
||||
}
|
||||
})
|
||||
|
||||
test('only the exact serialization expands: case, spacing and entities stay inert', () => {
|
||||
const inert = [
|
||||
`<figure data-embed="YouTube" data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="youtube " data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}" ></figure>`,
|
||||
`<FIGURE data-embed="youtube" data-id="${ID}"></FIGURE>`,
|
||||
`<figure data-embed="youtube" data-id="javascript:1"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}">\n</figure>`,
|
||||
`<figure data-embed="youtube" data-id=""></figure>`,
|
||||
]
|
||||
for (const html of inert) {
|
||||
assert.equal(expandEmbeds(html), html, html)
|
||||
assert.equal(iframeCount(expandEmbeds(html)), 0, html)
|
||||
}
|
||||
})
|
||||
|
||||
test('expansion is stable across calls (the global pattern keeps no state)', () => {
|
||||
const body = `<p>a</p>${PLACEHOLDER}<p>b</p>${PLACEHOLDER}`
|
||||
const first = expandEmbeds(body)
|
||||
assert.equal(expandEmbeds(body), first)
|
||||
assert.equal(EMBED_PLACEHOLDER.lastIndex, 0)
|
||||
assert.equal(iframeCount(first), 2)
|
||||
})
|
||||
|
||||
test('the iframe src is a fixed host plus the validated id, never stored text', () => {
|
||||
const out = sink(`<figure data-embed="youtube" data-id="${ID}"></figure>`)
|
||||
const src = /<iframe src="([^"]+)"/.exec(out)?.[1]
|
||||
assert.equal(src, `https://www.youtube-nocookie.com/embed/${ID}`)
|
||||
assert.match(out, /referrerpolicy="strict-origin-when-cross-origin"/)
|
||||
assert.match(out, /loading="lazy"/)
|
||||
assert.doesNotMatch(out, /allow="[^"]*(autoplay|camera|microphone|geolocation)/)
|
||||
})
|
||||
|
||||
@@ -292,3 +292,44 @@ test('img alt keeps free text with a colon, a question mark and quotes', () => {
|
||||
// area alt keeps the UGC pattern; only img alt is widened.
|
||||
assert.equal(sanitizeJournalHtml('<map name="m"><area alt="a: b?" href="/x" /></map>'), '<map name="m"><area href="/x" /></map>')
|
||||
})
|
||||
|
||||
test('the four editor rules accept exactly the editor values (D-05 amended mirror)', () => {
|
||||
const kept = [
|
||||
PLACEHOLDER,
|
||||
'<p><img src="/a.jpg" alt="any text: with ? and "quotes"" /></p>',
|
||||
'<ol start="1"><li>a</li></ol>',
|
||||
'<ol start="42"><li>a</li></ol>',
|
||||
]
|
||||
for (const html of kept) {
|
||||
assert.equal(sanitizeJournalHtml(html), html, html)
|
||||
assert.equal(isSafeJournalHtml(html), true, html)
|
||||
}
|
||||
})
|
||||
|
||||
test('the Go policy negatives are stripped the same way here', () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['upper-case provider', '<figure data-embed="YouTube" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
|
||||
['provider with a suffix', '<figure data-embed="youtube2" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
|
||||
['empty id', '<figure data-embed="youtube" data-id=""></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['id with a URL', '<figure data-embed="youtube" data-id="javascript:1"></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['id with a space', '<figure data-embed="youtube" data-id="dQw4w9 gXcQ"></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['embed attributes on p', '<p data-embed="youtube" data-id="dQw4w9WgXcQ">x</p>', '<p>x</p>'],
|
||||
['embed attributes on a', '<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>', '<a href="/x">x</a>'],
|
||||
['data attribute alone on figure', '<figure data-url="https://evil.test/"></figure>', '<figure></figure>'],
|
||||
['script inside the placeholder', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"><script>alert(1)</script></figure>', PLACEHOLDER],
|
||||
['iframe alone', '<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>', ''],
|
||||
['start with a fraction', '<ol start="1.5"><li>a</li></ol>', '<ol><li>a</li></ol>'],
|
||||
['alt on p', '<p alt="x">y</p>', '<p>y</p>'],
|
||||
]
|
||||
for (const [name, html, want] of cases) {
|
||||
assert.equal(sanitizeJournalHtml(html), want, name)
|
||||
assert.equal(isSafeJournalHtml(html), false, `${name} reported safe`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every corpus entry stays free of danger after the sink pipeline', () => {
|
||||
for (const entry of CORPUS) {
|
||||
const out = sanitizeJournalHtml(sanitizeJournalHtml(entry.html))
|
||||
for (const pattern of DANGER) assert.doesNotMatch(out, pattern, `${entry.name}: ${pattern}`)
|
||||
}
|
||||
})
|
||||
|
||||
129
tests/siteCache.test.ts
Normal file
129
tests/siteCache.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Site cache purge: route authorization, the route-cache purge, the purge
|
||||
// body parsing and browser revalidation of route-cached responses.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
MIN_PURGE_TOKEN_LENGTH,
|
||||
ROUTE_CACHE_BASE,
|
||||
authorizePurge,
|
||||
parsePurgeEvent,
|
||||
privateRevalidate,
|
||||
purgeRouteCache,
|
||||
} from '../app/utils/siteCache.ts'
|
||||
|
||||
const TOKEN = 'a'.repeat(MIN_PURGE_TOKEN_LENGTH)
|
||||
|
||||
test('purge auth: a missing or short token disables the route', () => {
|
||||
for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) {
|
||||
assert.equal(authorizePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`)
|
||||
}
|
||||
// Disabled wins over proxied: an app without a token has no route at all.
|
||||
assert.equal(authorizePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled')
|
||||
assert.equal(MIN_PURGE_TOKEN_LENGTH, 32)
|
||||
})
|
||||
|
||||
test('purge auth: a proxied request is refused before the token is checked', () => {
|
||||
assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
|
||||
assert.equal(authorizePurge({ token: TOKEN, authorization: 'Bearer wrong', proxied: true }), 'proxied')
|
||||
assert.equal(authorizePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied')
|
||||
})
|
||||
|
||||
test('purge auth: a missing or wrong bearer token is unauthorized', () => {
|
||||
for (const authorization of [undefined, null, '', 'Bearer', 'Bearer ', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) {
|
||||
assert.equal(authorizePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`)
|
||||
}
|
||||
assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok')
|
||||
assert.equal(authorizePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok')
|
||||
assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN} `, proxied: false }), 'ok')
|
||||
})
|
||||
|
||||
/** 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('purge removes every route cache entry and only those', async () => {
|
||||
const routes = [
|
||||
`${ROUTE_CACHE_BASE}:_:cordyceps.abc123.json`,
|
||||
`${ROUTE_CACHE_BASE}:_:cordyceps_payloadjson.def456.json`,
|
||||
`${ROUTE_CACHE_BASE}:_:index.0a1b2c.json`,
|
||||
`${ROUTE_CACHE_BASE}:_:rssxml.99.json`,
|
||||
]
|
||||
const others = ['nitro:functions:_:journal.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json']
|
||||
const storage = fakeStorage([...routes, ...others])
|
||||
assert.equal(ROUTE_CACHE_BASE, 'nitro:routes')
|
||||
assert.equal(await purgeRouteCache(storage), routes.length)
|
||||
assert.deepEqual([...storage.removed].sort(), [...routes].sort())
|
||||
assert.deepEqual([...storage.live].sort(), [...others].sort())
|
||||
assert.equal(await purgeRouteCache(storage), 0)
|
||||
})
|
||||
|
||||
test('purge ignores keys a storage returns outside the route cache', async () => {
|
||||
const storage = {
|
||||
removed: [] as string[],
|
||||
getKeys: () => ['nitro:routes:_:a.json', 'nitro:functions:_:b.json'],
|
||||
removeItem(key: string) {
|
||||
this.removed.push(key)
|
||||
},
|
||||
}
|
||||
assert.equal(await purgeRouteCache(storage), 1)
|
||||
assert.deepEqual(storage.removed, ['nitro:routes:_:a.json'])
|
||||
})
|
||||
|
||||
test('purge event: kinds', () => {
|
||||
assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'hello' }), { kind: 'post', slug: 'hello' })
|
||||
assert.deepEqual(parsePurgeEvent({ kind: 'category', slug: 'cordyceps' }), { kind: 'category', slug: 'cordyceps' })
|
||||
assert.deepEqual(parsePurgeEvent({ kind: 'tag', slug: 'x' }), { kind: 'unknown', slug: 'x' })
|
||||
assert.deepEqual(parsePurgeEvent({ kind: 'POST', slug: 'x' }), { kind: 'unknown', slug: 'x' })
|
||||
assert.deepEqual(parsePurgeEvent({ slug: 'x' }), { kind: 'unknown', slug: 'x' })
|
||||
})
|
||||
|
||||
test('purge event: control characters are removed from the slug', () => {
|
||||
assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'a\nb\r\tc\u0000d\u001be\u007ff\u0085g' }), { kind: 'post', slug: 'abcdefg' })
|
||||
assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'zażółć-gęślą' }), { kind: 'post', slug: 'zażółć-gęślą' })
|
||||
})
|
||||
|
||||
test('purge event: the slug is cut to 200 characters', () => {
|
||||
assert.equal(parsePurgeEvent({ kind: 'post', slug: 'x'.repeat(500) }).slug.length, 200)
|
||||
assert.equal(parsePurgeEvent({ kind: 'post', slug: 'x'.repeat(200) }).slug.length, 200)
|
||||
})
|
||||
|
||||
test('purge event: a body that is not an object', () => {
|
||||
for (const body of [null, undefined, 'post', 42, true, ['post', 'x']]) {
|
||||
assert.deepEqual(parsePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body))
|
||||
}
|
||||
assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 7 }), { kind: 'post', slug: '' })
|
||||
})
|
||||
|
||||
test('revalidate: s-maxage without max-age gets max-age=0', () => {
|
||||
assert.equal(privateRevalidate('s-maxage=300, stale-while-revalidate'), 'max-age=0, s-maxage=300, stale-while-revalidate')
|
||||
assert.equal(privateRevalidate('s-maxage=120'), 'max-age=0, s-maxage=120')
|
||||
})
|
||||
|
||||
test('revalidate: s-maxage with max-age replaces max-age', () => {
|
||||
assert.equal(privateRevalidate('max-age=300, s-maxage=300, stale-while-revalidate'), 'max-age=0, s-maxage=300, stale-while-revalidate')
|
||||
assert.equal(privateRevalidate('s-maxage=300,max-age=60'), 's-maxage=300,max-age=0')
|
||||
})
|
||||
|
||||
test('revalidate: the 304 form of the route cache', () => {
|
||||
assert.equal(privateRevalidate('public, max-age=300, s-maxage=300'), 'public, max-age=0, s-maxage=300')
|
||||
})
|
||||
|
||||
test('revalidate: values without s-maxage are unchanged', () => {
|
||||
assert.equal(privateRevalidate('public, max-age=31536000, immutable'), 'public, max-age=31536000, immutable')
|
||||
assert.equal(privateRevalidate('no-store'), 'no-store')
|
||||
assert.equal(privateRevalidate('no-cache'), 'no-cache')
|
||||
assert.equal(privateRevalidate(''), '')
|
||||
})
|
||||
Reference in New Issue
Block a user