Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
102da43f76 | ||
|
|
e0cc87118c | ||
|
|
f5d7aacf11 | ||
|
|
5936f6611d | ||
|
|
3e0b37756e | ||
| 25e9bfe8da | |||
| 03a90e952d | |||
| 4f55511ece | |||
| 0a01bfeae4 | |||
| a03e076c62 | |||
| 782aa4da7d |
@@ -1,4 +1,7 @@
|
||||
<template>
|
||||
<header class="playground-header">
|
||||
<JournalSearchBox />
|
||||
</header>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
|
||||
@@ -4,6 +4,11 @@ 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 } },
|
||||
// The search page is opt-in; the playground turns it on for the smoke.
|
||||
journal: { routes: { search: '/search' } },
|
||||
i18n: {
|
||||
strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
|
||||
90
README.md
90
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
|
||||
@@ -20,6 +20,7 @@ Route paths are configuration, not files. The layer's page components live in `a
|
||||
- **Post** (`journal-post`): title, date, reading time, categories, author, featured image, body, previous and next links, and related posts. When the API returns no related posts (a post without a category), the latest posts are shown instead.
|
||||
- **Category** (`journal-category`): name, description and the category's rich HTML content above its posts. Categories are always requested with `include_empty=1`, so a category without posts still has a page. An unknown category answers 404, which makes the route safe as a root-level catch-all such as `/:category`.
|
||||
- **Tag** (`journal-tag`) and **author** (`journal-author`) pages. An author resolves by author slug, or by login once the author has a published post; a login URL sets the canonical to the slug URL.
|
||||
- **Search** (`journal-search`, opt-in): a results page for `?q=` and the `JournalSearchBox` header component with suggestions as the visitor types. Off until the app sets `journal.routes.search`. See [Search](#search).
|
||||
- **RSS**: `/rss.xml` proxies the API feed, and list and post pages link it in the head.
|
||||
- **SEO**: per-page title and description, Open Graph tags (`og:image` from the featured image, article times), canonical URLs that keep `?page=N`, robots and `sitemap.xml` through `@nuxtjs/seo`.
|
||||
- **HTML sanitizer**: post bodies and category content go through `sanitizeJournalHtml` before `v-html` and before they reach the SSR payload. It is [sanitize-html](https://github.com/apostrophecms/sanitize-html) with the Journal API's own allow-list: the usual content elements, `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` inline styles, and `http`, `https`, `mailto` or relative URLs. Four rules keep the Journal Blog Editor's own markup, mirroring the API's policy: `data-embed` (`youtube`) and `data-id` (an 11-character video id) on `figure`, `alt` with any text on `img`, and an integer `start` on `ol`. Anything else (`script`, `style`, `iframe`, `svg`, forms, event-handler attributes, `javascript:` URLs in any spelling) is removed and the rest of the value is kept, so a post never renders blank because of one stray tag. Server and browser produce the same string, so hydration matches. See `docs/decisions/0001-sanitize-html.md`.
|
||||
@@ -92,10 +93,12 @@ Options under the `journal` key of `nuxt.config`:
|
||||
| `journal.routes.category` | `/blog/category/:category` | Category path; must contain `:category`, or `false` |
|
||||
| `journal.routes.tag` | `/blog/tag/:tag` | Tag path; must contain `:tag`, or `false` |
|
||||
| `journal.routes.author` | `/blog/author/:author` | Author path; must contain `:author`, or `false` |
|
||||
| `journal.routes.search` | `false` | Search results path, such as `/search`, or `false`; takes no param (the term is `?q=`) |
|
||||
| `journal.perPage.index` | `9` | Posts per list page (the API caps at 30) |
|
||||
| `journal.perPage.category` | `9` | Posts per category page |
|
||||
| `journal.perPage.tag` | `9` | Posts per tag page |
|
||||
| `journal.perPage.author` | `9` | Posts per author page |
|
||||
| `journal.perPage.search` | `9` | Results per search page |
|
||||
| `journal.sort` | `published_at desc` | `<column> <asc\|desc>`; columns `published_at`, `created_at`, `updated_at`, `title`, `id` |
|
||||
| `journal.relatedCount` | `3` | Related posts under a post |
|
||||
| `journal.rss` | `true` | Serve and link `/rss.xml` |
|
||||
@@ -108,16 +111,83 @@ 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.cachePurge.token` | `NUXT_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.cachePurge.allowProxied` | `NUXT_CACHE_PURGE_ALLOW_PROXIED` | `false` | `true` lets the shared purge route accept requests that came through a reverse proxy, for a backend that reaches the site at an https URL on another host; the bearer token is then the only gate. |
|
||||
| `runtimeConfig.siteCache.purgeToken` | `NUXT_SITE_CACHE_PURGE_TOKEN` | empty | Deprecated (one release): the earlier name of `cachePurge.token`, read when that is empty. See [Upgrading from 0.5](#upgrading-from-05). |
|
||||
| `runtimeConfig.siteCache.allowProxied` | `NUXT_SITE_CACHE_ALLOW_PROXIED` | `false` | Deprecated (one release): the earlier name of `cachePurge.allowProxied`; either flag set to `true` turns it on. |
|
||||
| `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`.
|
||||
|
||||
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 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.
|
||||
|
||||
**Shared route.** `POST /api/__site__/cache/purge` with `Authorization: Bearer <runtimeConfig.cachePurge.token>` (`NUXT_CACHE_PURGE_TOKEN`), `Content-Type: application/json` and the body `{"kind":"<kind>","slug":"<slug>"}` (kinds such as `post`, `category`, `menu`, `menu_entry`, `faq`). The answer is `200` with `{"purged":<n>}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[site-cache] purge: removed <n> route cache entries (<kind> <slug>)`; 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`, `server/api/__site__/cache/purge.post.ts` and `server/plugins/site-cache-purge.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.
|
||||
|
||||
**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":"<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_CACHE_PURGE_TOKEN` before the next release removes it.
|
||||
|
||||
**Guards** (both routes).
|
||||
|
||||
- POST only.
|
||||
- `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. The shared route makes one exception, below.
|
||||
- `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 }` 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.
|
||||
|
||||
**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_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`. Keep it out of committed files. Never put a secret in a `NUXT_SITE_*` or `NUXT_PUBLIC_SITE_*` variable: nuxt-site-config publishes those in every page's payload.
|
||||
- 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.
|
||||
- A backend on another host calls an https `site_cache.purge_url` through the site's TLS-terminating proxy, which adds forwarding headers. Set `NUXT_CACHE_PURGE_ALLOW_PROXIED=true` (`runtimeConfig.cachePurge.allowProxied`) and the shared route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret, and let the proxy forward POST to this one path only. Unset, proxied requests stay refused. The old `/api/__journal__/cache/purge` route never accepts them.
|
||||
- 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.
|
||||
|
||||
**Site config stripping.** nuxt-site-config (through `@nuxtjs/seo`) copies every `NUXT_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 layer's Nitro plugin `server/plugins/site-cache-purge.ts` removes `cachePurgeToken` and `cacheAllowProxied` from the site config on nuxt-site-config's `site-config:init` hook, whatever names the site uses, and logs one `[site-cache]` deprecation warning per process when a setting comes from an old name (it names the variables, never a value). In an app without nuxt-site-config the plugin does nothing.
|
||||
|
||||
### Upgrading from 0.5
|
||||
|
||||
- Rename `NUXT_SITE_CACHE_PURGE_TOKEN` to `NUXT_CACHE_PURGE_TOKEN` and `NUXT_SITE_CACHE_ALLOW_PROXIED` to `NUXT_CACHE_PURGE_ALLOW_PROXIED` in the site's environment. The old names keep working through 0.6.x (a non-empty new token wins) and are removed in the next minor release.
|
||||
- Why: nuxt-site-config publishes every `NUXT_SITE_*` variable in the page payload, so up to 0.5 a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.6 the layer strips both keys from the site config even under the old names.
|
||||
- Rotate a token that was ever set under `NUXT_SITE_CACHE_PURGE_TOKEN`: earlier versions published it. Set the new value in the site's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`, then restart both.
|
||||
|
||||
## Search
|
||||
|
||||
Search is off by default: an app that does not set `journal.routes.search` gets no search page, and `JournalSearchBox` renders nothing. To turn it on:
|
||||
|
||||
```ts
|
||||
journal: { routes: { search: '/szukaj' } },
|
||||
```
|
||||
|
||||
The Journal API answers `GET /posts?search=` from Typesense in relevance order when the backend has it on, and from SQL otherwise; the layer sends no `sort` with a search, so the API's order is kept. Terms under 3 characters are never sent: the API would ignore them.
|
||||
|
||||
**Results page** (`journal-search`). Reads `?q=` and `?page=N`, renders the matches with `JournalPostList` and `JournalPagination` under a search form and a result count (plural forms through `Intl.PluralRules`: Polish one, few and many), and shows its own message for an empty term, a term under 3 characters and no results. It is fetched with `useAsyncData` on the server, so it works without JS. The title includes the term; the term is rendered as text everywhere.
|
||||
|
||||
**Header box** (`<JournalSearchBox />`). An icon button opens a search input; once the term has 3 or more characters, a suggestion request (`GET /posts?search=…&fields=suggest&per_page=5`) goes out 250 ms after the last keystroke, and a newer term aborts the running request and drops its answer. Up to `limit` suggestions show the title, the excerpt and a small featured image, with the query words in `<mark>` ignoring case and diacritics ("grzybow" marks "grzybów"); the marking splits text and never inserts API HTML. Under them, "see all results" links to the search page with `q`. Enter opens the active suggestion, or the search page when none is active. The input is a WAI-ARIA combobox: ArrowDown and ArrowUp move through the options (`aria-activedescendant`), Escape closes the box and returns focus to the button, and a click outside closes it. A spinner shows after 300 ms, "no results" when nothing matched, and a quiet message when the request failed. Without JS the box is a plain GET form to the search page, always shown. Reopening selects the previous term.
|
||||
|
||||
| Prop | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `limit` | `5` | Suggestions shown |
|
||||
| `minLength` | `3` | Shortest term that sends a request |
|
||||
| `debounce` | `250` | Wait after the last keystroke, in ms |
|
||||
| `placeholder`, `buttonLabel`, `seeAllLabel`, `emptyLabel` | the `journal.search.*` strings | Text overrides |
|
||||
|
||||
The `icon` slot replaces the magnifier. The root gets `journal-search--open` while open and `journal-search--enhanced` once mounted. The panel (`.journal-search__panel`) is inline next to the button, with the suggestions (`.journal-search__dropdown`) positioned below its left edge; an app makes it a full-width panel under its header, or anchors the suggestions on the right (`left: auto; right: 0`), in its own CSS.
|
||||
|
||||
**Caching, robots and sitemap.** Nitro's route cache keys by path, so under an app rule such as `'/**': { swr: 300 }` every `?q=` would get the first query's cached page. The journal module therefore adds `{ cache: false, robots: 'noindex, follow' }` route rules for the search path and its locale-prefixed copies (`/pl/szukaj`), and excludes them from `sitemap.xml`. An app's own rule for the same path wins key by key. The search page is not route-cached: every search renders on request.
|
||||
|
||||
## Theming
|
||||
|
||||
Every visual value is a token in `app/assets/css/journal-tokens.css`:
|
||||
|
||||
- Colour: `--journal-color-text`, `--journal-color-muted`, `--journal-color-bg`, `--journal-color-surface`, `--journal-color-border`, `--journal-color-link`, `--journal-color-link-hover`, `--journal-color-accent`, `--journal-color-on-accent`
|
||||
- Colour: `--journal-color-text`, `--journal-color-muted`, `--journal-color-bg`, `--journal-color-surface`, `--journal-color-border`, `--journal-color-link`, `--journal-color-link-hover`, `--journal-color-accent`, `--journal-color-on-accent`, `--journal-color-mark` (search term highlight)
|
||||
- Shadow: `--journal-shadow-dropdown` (search suggestions)
|
||||
- Type: `--journal-font-body`, `--journal-font-heading`, `--journal-font-mono`, `--journal-font-size-base`, `--journal-line-height`
|
||||
- Spacing: `--journal-space-1` to `--journal-space-6`
|
||||
- Radii: `--journal-radius-sm`, `--journal-radius-md`, `--journal-radius-lg`
|
||||
@@ -132,9 +202,9 @@ Override tokens in the app's CSS. The app's `css` entries load after the layer's
|
||||
}
|
||||
```
|
||||
|
||||
Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard` and `JournalRssLink`. A replacement that renders HTML from the API must pass it through `sanitizeJournalHtml` (or `gateJournalHtml`, whose `html` is the same sanitized string) before `v-html`. Style classes that imported content uses, such as an author footer, belong in the app's CSS scoped under `.journal-post-body`: the sanitizer removes `<style>` elements from the content.
|
||||
Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard`, `JournalRssLink` and `JournalSearchBox`. A replacement that renders HTML from the API must pass it through `sanitizeJournalHtml` (or `gateJournalHtml`, whose `html` is the same sanitized string) before `v-html`. Style classes that imported content uses, such as an author footer, belong in the app's CSS scoped under `.journal-post-body`: the sanitizer removes `<style>` elements from the content.
|
||||
|
||||
Pages and components can use the `useJournalApi()` client and the `useJournalLinks()` link helpers (`postLink`, `categoryLink`, `tagLink`, `authorLink`, `indexLink`), which return `null` for a disabled view.
|
||||
Pages and components can use the `useJournalApi()` client and the `useJournalLinks()` link helpers (`postLink`, `categoryLink`, `tagLink`, `authorLink`, `indexLink`, `searchLink`), which return `null` for a disabled view. The client's `suggestPosts(term, { perPage, signal })` returns the suggestion rows (`JournalPostSuggestion`).
|
||||
|
||||
## i18n
|
||||
|
||||
@@ -164,12 +234,18 @@ 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 (including the suggest query), the search helpers (term rules, diacritic-insensitive marking, plural forms, combobox keys, and the suggestion controller's debounce, abort and stale-answer handling under mocked timers), 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, the purge settings under their new and deprecated names, the site config stripping plugin 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 <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/purge-token-check.mjs --output <.output> --fixtures <dir> --path <page path>` proves on a built server that the purge token never reaches a page. It starts the server three times with fresh random tokens: under `NUXT_CACHE_PURGE_TOKEN`, under the deprecated `NUXT_SITE_CACHE_PURGE_TOKEN` with `NUXT_SITE_CACHE_ALLOW_PROXIED=true`, and under both names with different values. Each time `/`, the page and its `_payload.json` (when served) must contain no token value and neither `cachePurgeToken` nor `cacheAllowProxied`, the shared purge route must accept the expected bearer and refuse the others, and the deprecation warning must appear once with the old names only. The last line is `purge-token-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 and returns its `url`, `close()` and `output()`, the server's captured stdout and stderr so far. 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
|
||||
node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --path /blog/hello-acme
|
||||
```
|
||||
|
||||
The playground caches every page with `routeRules: { '/**': { swr: 300 } }`, like a real host, enables the search page at `/search` and places `JournalSearchBox` above the layout, so the smoke covers the search page, its cache bypass, robots and sitemap rules.
|
||||
|
||||
@@ -287,3 +287,187 @@
|
||||
margin-top: var(--journal-space-4);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Search box (header). Outside .journal-layout, so it sets its own type. The
|
||||
panel is inline; an app turns it into a drop-down panel by positioning
|
||||
.journal-search__panel. Without JS the panel is always shown. */
|
||||
.journal-search {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--journal-space-2);
|
||||
font-family: var(--journal-font-body);
|
||||
color: var(--journal-color-text);
|
||||
}
|
||||
|
||||
.journal-search__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--journal-space-2);
|
||||
border: 0;
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.journal-search__toggle:focus-visible,
|
||||
.journal-search__input:focus-visible,
|
||||
.journal-search-page__input:focus-visible,
|
||||
.journal-search-page__submit:focus-visible {
|
||||
outline: 2px solid var(--journal-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.journal-search--enhanced:not(.journal-search--open) .journal-search__panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.journal-search__panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.journal-search__input,
|
||||
.journal-search-page__input {
|
||||
box-sizing: border-box;
|
||||
width: 16rem;
|
||||
max-width: 100%;
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
border: 1px solid var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: var(--journal-color-bg);
|
||||
color: var(--journal-color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.journal-search__dropdown {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + var(--journal-space-1));
|
||||
left: 0; /* a right-aligned box sets left: auto; right: 0 */
|
||||
box-sizing: border-box;
|
||||
width: min(26rem, calc(100vw - 2 * var(--journal-space-3)));
|
||||
padding: var(--journal-space-2) 0;
|
||||
border: 1px solid var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-md);
|
||||
background: var(--journal-color-bg);
|
||||
box-shadow: var(--journal-shadow-dropdown);
|
||||
}
|
||||
|
||||
.journal-search__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.journal-search__link {
|
||||
display: flex;
|
||||
gap: var(--journal-space-3);
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.journal-search__option--active .journal-search__link {
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-search__thumb {
|
||||
flex: none;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: var(--journal-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.journal-search__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.journal-search__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.journal-search__excerpt {
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.875em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.journal-search mark,
|
||||
.journal-search-page mark {
|
||||
background: var(--journal-color-mark);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.journal-search__status {
|
||||
margin: 0;
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.journal-search__status--loading::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
margin-right: var(--journal-space-2);
|
||||
border: 2px solid var(--journal-color-border);
|
||||
border-top-color: var(--journal-color-accent);
|
||||
border-radius: 50%;
|
||||
vertical-align: -0.1em;
|
||||
animation: journal-search-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes journal-search-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.journal-search__status--loading::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.journal-search__all {
|
||||
display: block;
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
border-top: 1px solid var(--journal-color-border);
|
||||
color: var(--journal-color-link);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Search page */
|
||||
.journal-search-page__form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--journal-space-2);
|
||||
margin-bottom: var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-search-page__input {
|
||||
flex: 1 1 16rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.journal-search-page__submit {
|
||||
padding: var(--journal-space-2) var(--journal-space-4);
|
||||
border: 0;
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: var(--journal-color-accent);
|
||||
color: var(--journal-color-on-accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.journal-search-page__count,
|
||||
.journal-search-page__message {
|
||||
margin: 0 0 var(--journal-space-4);
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
--journal-color-link-hover: #0550ae; /* hovered links */
|
||||
--journal-color-accent: #0969da; /* current page, focus ring */
|
||||
--journal-color-on-accent: #ffffff; /* text on the accent colour */
|
||||
--journal-color-mark: #fff8c5; /* search term highlight */
|
||||
--journal-shadow-dropdown: 0 8px 24px rgb(0 0 0 / 0.12); /* search suggestions */
|
||||
|
||||
/* Type */
|
||||
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */
|
||||
|
||||
228
app/components/Journal/SearchBox.vue
Normal file
228
app/components/Journal/SearchBox.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
// The header search: an icon button opens a search input, up to `limit`
|
||||
// suggestions show as the visitor types, and "see all" opens the search page.
|
||||
// The input is a WAI-ARIA combobox: arrows move through the suggestions,
|
||||
// Enter opens the active one or the search page, Escape closes and returns
|
||||
// focus to the button, a click outside closes. Without JS it is a plain GET
|
||||
// form to the search page, always open. It renders nothing when
|
||||
// journal.routes.search is false.
|
||||
//
|
||||
// Layout hooks: .journal-search--open on the root while open, and the panel
|
||||
// (.journal-search__panel) for an inline input or a drop-down; the icon is
|
||||
// the `icon` slot.
|
||||
import { JOURNAL_SEARCH_MIN_LENGTH } from '../../utils/client.ts'
|
||||
import { createSuggestController, markTerms, moveActive, searchTermState, type SuggestController, type SuggestState } from '../../utils/search.ts'
|
||||
import type { JournalPostSuggestion } from '../../utils/types.ts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
limit?: number
|
||||
minLength?: number
|
||||
debounce?: number
|
||||
/** Overrides journal.search.placeholder. */
|
||||
placeholder?: string
|
||||
/** Overrides journal.search.button, the icon button's accessible label. */
|
||||
buttonLabel?: string
|
||||
/** Overrides journal.search.seeAll. */
|
||||
seeAllLabel?: string
|
||||
/** Overrides journal.search.empty. */
|
||||
emptyLabel?: string
|
||||
}>(),
|
||||
{ limit: 5, minLength: JOURNAL_SEARCH_MIN_LENGTH, debounce: 250 },
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const links = useJournalLinks()
|
||||
const api = useJournalApi()
|
||||
|
||||
const action = computed(() => links.searchLink())
|
||||
const id = useId()
|
||||
const panelId = `${id}-panel`
|
||||
const listId = `${id}-list`
|
||||
const optionId = (i: number) => `${id}-option-${i}`
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const toggleEl = ref<HTMLButtonElement | null>(null)
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
const enhanced = ref(false)
|
||||
const open = ref(false)
|
||||
const term = ref('')
|
||||
const active = ref(-1)
|
||||
const state = shallowRef<SuggestState<JournalPostSuggestion>>({ term: '', status: 'idle', loading: false, rows: [], total: 0 })
|
||||
|
||||
const rows = computed(() => state.value.rows)
|
||||
const listOpen = computed(
|
||||
() => enhanced.value && open.value && searchTermState(term.value, props.minLength) === 'ready' && state.value.status !== 'idle',
|
||||
)
|
||||
const seeAll = computed(() => links.searchLink(term.value.trim()))
|
||||
|
||||
let suggest: SuggestController | null = null
|
||||
|
||||
const onOutside = (e: PointerEvent) => {
|
||||
if (open.value && root.value && !root.value.contains(e.target as Node)) close(false)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
enhanced.value = true
|
||||
suggest = createSuggestController<JournalPostSuggestion>({
|
||||
fetch: (q, signal) => api.suggestPosts(q, { perPage: props.limit, signal }),
|
||||
onChange: (s) => {
|
||||
state.value = s
|
||||
active.value = -1
|
||||
},
|
||||
debounce: props.debounce,
|
||||
minLength: props.minLength,
|
||||
})
|
||||
document.addEventListener('pointerdown', onOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
suggest?.dispose()
|
||||
document.removeEventListener('pointerdown', onOutside)
|
||||
})
|
||||
|
||||
watch(term, (value) => suggest?.input(value))
|
||||
|
||||
// Opening selects the previous term, so typing replaces it.
|
||||
function openPanel() {
|
||||
open.value = true
|
||||
nextTick(() => {
|
||||
inputEl.value?.focus()
|
||||
inputEl.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function close(focusToggle: boolean) {
|
||||
open.value = false
|
||||
active.value = -1
|
||||
if (focusToggle) toggleEl.value?.focus()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (open.value) close(false)
|
||||
else openPanel()
|
||||
}
|
||||
|
||||
function go(to: string | null) {
|
||||
if (!to) return
|
||||
close(false)
|
||||
return navigateTo(to)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && listOpen.value && active.value >= 0) {
|
||||
const row = rows.value[active.value]
|
||||
if (row) {
|
||||
e.preventDefault()
|
||||
go(links.postLink(row.slug))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!listOpen.value) return
|
||||
const next = moveActive(active.value, rows.value.length, e.key)
|
||||
if (next !== null) {
|
||||
e.preventDefault()
|
||||
active.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(e: Event) {
|
||||
if (!enhanced.value) return
|
||||
e.preventDefault()
|
||||
const q = term.value.trim()
|
||||
if (q) go(links.searchLink(q))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="action"
|
||||
ref="root"
|
||||
class="journal-search"
|
||||
:class="{ 'journal-search--enhanced': enhanced, 'journal-search--open': open }"
|
||||
>
|
||||
<button
|
||||
v-if="enhanced"
|
||||
ref="toggleEl"
|
||||
type="button"
|
||||
class="journal-search__toggle"
|
||||
:aria-label="buttonLabel ?? t('journal.search.button')"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="panelId"
|
||||
@click="toggle"
|
||||
>
|
||||
<slot name="icon">
|
||||
<svg class="journal-search__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M15.5 15.5 21 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</slot>
|
||||
</button>
|
||||
<form :id="panelId" class="journal-search__panel" role="search" method="get" :action="action" @submit="onSubmit">
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="term"
|
||||
class="journal-search__input"
|
||||
type="search"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? t('journal.search.placeholder')"
|
||||
:aria-label="t('journal.search.input')"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="listOpen"
|
||||
:aria-controls="listId"
|
||||
:aria-activedescendant="listOpen && active >= 0 ? optionId(active) : undefined"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<div v-show="listOpen" class="journal-search__dropdown">
|
||||
<ul :id="listId" role="listbox" class="journal-search__list" :aria-label="t('journal.search.suggestions')">
|
||||
<li
|
||||
v-for="(row, i) in rows"
|
||||
:id="optionId(i)"
|
||||
:key="row.id"
|
||||
role="option"
|
||||
class="journal-search__option"
|
||||
:class="{ 'journal-search__option--active': i === active }"
|
||||
:aria-selected="i === active"
|
||||
@mousedown.prevent
|
||||
@mouseenter="active = i"
|
||||
>
|
||||
<NuxtLink :to="links.postLink(row.slug) ?? undefined" tabindex="-1" class="journal-search__link" @click="close(false)">
|
||||
<img v-if="row.featured_image_url" :src="row.featured_image_url" alt="" class="journal-search__thumb" width="48" height="48" loading="lazy" />
|
||||
<span class="journal-search__text">
|
||||
<span class="journal-search__title">
|
||||
<template v-for="(seg, k) in markTerms(row.title, state.term)" :key="k">
|
||||
<mark v-if="seg.mark">{{ seg.text }}</mark>
|
||||
<template v-else>{{ seg.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span v-if="row.excerpt" class="journal-search__excerpt">
|
||||
<template v-for="(seg, k) in markTerms(row.excerpt, state.term)" :key="k">
|
||||
<mark v-if="seg.mark">{{ seg.text }}</mark>
|
||||
<template v-else>{{ seg.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="state.loading" class="journal-search__status journal-search__status--loading" role="status">
|
||||
{{ t('journal.search.loading') }}
|
||||
</p>
|
||||
<p v-else-if="state.status === 'error'" class="journal-search__status" role="status">{{ t('journal.search.error') }}</p>
|
||||
<p v-else-if="state.status === 'done' && rows.length === 0" class="journal-search__status" role="status">
|
||||
{{ emptyLabel ?? t('journal.search.empty') }}
|
||||
</p>
|
||||
<NuxtLink v-if="seeAll && state.status !== 'error'" :to="seeAll" class="journal-search__all" @mousedown.prevent @click="close(false)">
|
||||
{{ seeAllLabel ?? t('journal.search.seeAll') }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,15 +12,19 @@ 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 })
|
||||
const fetcher: JournalFetcher = (path, query, init) => $fetch(path, { baseURL: base, query, headers, signal: init?.signal })
|
||||
return createJournalClient(fetcher)
|
||||
}
|
||||
|
||||
const base = (config.public.journal as { apiBase: string }).apiBase
|
||||
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query })
|
||||
const fetcher: JournalFetcher = (path, query, init) => $fetch(path, { baseURL: base, query, signal: init?.signal })
|
||||
return createJournalClient(fetcher)
|
||||
}
|
||||
|
||||
@@ -19,5 +19,7 @@ export function useJournalLinks() {
|
||||
tagLink: (slug: string) => link('tag', { tag: slug }),
|
||||
authorLink: (slug: string) => link('author', { author: slug }),
|
||||
indexLink: (page: number = 1) => link('index', undefined, page > 1 ? { page: String(page) } : undefined),
|
||||
/** The search page for `q`, or null when journal.routes.search is false. */
|
||||
searchLink: (q?: string) => link('search', undefined, q ? { q } : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
77
app/journal-pages/SearchPage.vue
Normal file
77
app/journal-pages/SearchPage.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// journal-search: the posts matching ?q, paginated with ?page=N, in the API's
|
||||
// relevance order (no sort is sent). Rendered on the server, so the form and
|
||||
// the results work without JS. The module keeps the page out of the route
|
||||
// cache and the sitemap and marks it noindex, follow. An invalid or
|
||||
// out-of-range page is a real 404.
|
||||
import { JOURNAL_SEARCH_MIN_LENGTH } from '../utils/client.ts'
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
||||
import { pluralForm, searchTermFromQuery, searchTermState } from '../utils/search.ts'
|
||||
import { listTitle } from '../utils/seo.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
definePageMeta({ key: (route) => route.fullPath })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const { t, localeProperties } = useI18n()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
|
||||
const q = searchTermFromQuery(route.query.q)
|
||||
const termState = searchTermState(q)
|
||||
const page = parsePageParam(route.query.page)
|
||||
if (page === null) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
|
||||
const { data, error } = await useAsyncData(`journal-search-${q}-${page}`, async () => {
|
||||
if (termState !== 'ready') return null
|
||||
const posts = await api.listPosts({ search: q, page, perPage: cfg.perPage.search ?? cfg.perPage.index })
|
||||
return { ...posts, data: posts.data.map(toListPost) }
|
||||
})
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (data.value && pageOutOfRange(page, data.value.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const total = data.value?.meta.total ?? 0
|
||||
const count = t(`journal.search.count.${pluralForm(localeProperties.value.language ?? 'en', total)}`, { count: total })
|
||||
const heading = q ? t('journal.search.titleQuery', { query: q }) : t('journal.search.title')
|
||||
const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page }))
|
||||
useSeoMeta({ title, ogTitle: title })
|
||||
|
||||
const term = ref(q)
|
||||
const onSubmit = () => {
|
||||
const value = term.value.trim()
|
||||
return navigateTo({ path: route.path, query: value ? { q: value } : {} })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="journal-search-page">
|
||||
<header class="journal-index-header">
|
||||
<h1 class="journal-index-header__title">{{ title }}</h1>
|
||||
</header>
|
||||
<form class="journal-search-page__form" role="search" method="get" :action="route.path" @submit.prevent="onSubmit">
|
||||
<input
|
||||
v-model="term"
|
||||
class="journal-search-page__input"
|
||||
type="search"
|
||||
name="q"
|
||||
:aria-label="$t('journal.search.input')"
|
||||
:placeholder="$t('journal.search.placeholder')"
|
||||
/>
|
||||
<button type="submit" class="journal-search-page__submit">{{ $t('journal.search.submit') }}</button>
|
||||
</form>
|
||||
<p v-if="termState === 'empty'" class="journal-search-page__message">{{ $t('journal.search.prompt') }}</p>
|
||||
<p v-else-if="termState === 'short'" class="journal-search-page__message">
|
||||
{{ $t('journal.search.short', { min: JOURNAL_SEARCH_MIN_LENGTH }) }}
|
||||
</p>
|
||||
<template v-else-if="data">
|
||||
<p v-if="total > 0" class="journal-search-page__count">{{ count }}</p>
|
||||
<JournalPostList :posts="data.data" :empty="$t('journal.search.none', { query: q })" />
|
||||
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
JournalPage,
|
||||
JournalPost,
|
||||
JournalPostDetail,
|
||||
JournalPostSuggestion,
|
||||
JournalTag,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -18,8 +19,18 @@ export const JOURNAL_MAX_PER_PAGE = 30
|
||||
/** The Journal API's per_page default. */
|
||||
export const JOURNAL_DEFAULT_PER_PAGE = 9
|
||||
|
||||
/** Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed JSON. */
|
||||
export type JournalFetcher = (path: string, query?: Record<string, string | number>) => Promise<unknown>
|
||||
/** Search terms shorter than this are ignored by the API, so the layer never sends them. */
|
||||
export const JOURNAL_SEARCH_MIN_LENGTH = 3
|
||||
|
||||
/**
|
||||
* Issues one GET for `path` (e.g. "/posts") with `query`, resolving the parsed
|
||||
* JSON. `init.signal` aborts the request.
|
||||
*/
|
||||
export type JournalFetcher = (
|
||||
path: string,
|
||||
query?: Record<string, string | number>,
|
||||
init?: { signal?: AbortSignal },
|
||||
) => Promise<unknown>
|
||||
|
||||
export interface PostListOptions {
|
||||
page?: number
|
||||
@@ -30,6 +41,13 @@ export interface PostListOptions {
|
||||
category?: string
|
||||
tag?: string
|
||||
search?: string
|
||||
/** "suggest" asks for JournalPostSuggestion rows instead of full posts. */
|
||||
fields?: 'suggest'
|
||||
}
|
||||
|
||||
export interface SuggestOptions {
|
||||
perPage?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface AuthorPostListOptions {
|
||||
@@ -56,6 +74,7 @@ export function buildPostListQuery(opts: PostListOptions = {}): Record<string, s
|
||||
if (opts.category) q.category = opts.category
|
||||
if (opts.tag) q.tag = opts.tag
|
||||
if (opts.search) q.search = opts.search
|
||||
if (opts.fields) q.fields = opts.fields
|
||||
return q
|
||||
}
|
||||
|
||||
@@ -90,6 +109,11 @@ export function isNotFound(err: unknown): boolean {
|
||||
export interface JournalClient {
|
||||
getPost(slug: string): Promise<JournalPostDetail | null>
|
||||
listPosts(opts?: PostListOptions): Promise<JournalPage<JournalPost>>
|
||||
/**
|
||||
* Suggestion rows for `term` in relevance order (no sort is sent). A term
|
||||
* shorter than JOURNAL_SEARCH_MIN_LENGTH sends nothing and gives an empty page.
|
||||
*/
|
||||
suggestPosts(term: string, opts?: SuggestOptions): Promise<JournalPage<JournalPostSuggestion>>
|
||||
listCategories(): Promise<JournalCategory[]>
|
||||
findCategory(slug: string): Promise<JournalCategory | null>
|
||||
listTags(): Promise<JournalTag[]>
|
||||
@@ -123,6 +147,17 @@ export function createJournalClient(fetcher: JournalFetcher): JournalClient {
|
||||
return (await fetcher('/posts', buildPostListQuery(opts))) as JournalPage<JournalPost>
|
||||
},
|
||||
|
||||
async suggestPosts(term, opts = {}) {
|
||||
const search = term.trim()
|
||||
const perPage = clampPerPage(opts.perPage ?? 5)
|
||||
if ([...search].length < JOURNAL_SEARCH_MIN_LENGTH) {
|
||||
return { data: [], meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 } }
|
||||
}
|
||||
const query = buildPostListQuery({ search, fields: 'suggest', perPage })
|
||||
const init = opts.signal ? { signal: opts.signal } : undefined
|
||||
return (await fetcher('/posts', query, init)) as JournalPage<JournalPostSuggestion>
|
||||
},
|
||||
|
||||
listCategories,
|
||||
|
||||
async findCategory(slug) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const DEFAULT_JOURNAL_ROUTES: Readonly<JournalRoutes> = Object.freeze({
|
||||
category: '/blog/category/:category',
|
||||
tag: '/blog/tag/:tag',
|
||||
author: '/blog/author/:author',
|
||||
search: false,
|
||||
})
|
||||
|
||||
/** Route names stay stable whatever the paths are, so links never hard-code a path. */
|
||||
@@ -20,6 +21,7 @@ export const JOURNAL_ROUTE_NAMES: Readonly<Record<keyof JournalRoutes, string>>
|
||||
category: 'journal-category',
|
||||
tag: 'journal-tag',
|
||||
author: 'journal-author',
|
||||
search: 'journal-search',
|
||||
})
|
||||
|
||||
/** The route param each path must carry. */
|
||||
@@ -29,6 +31,7 @@ const REQUIRED_PARAM: Readonly<Record<keyof JournalRoutes, string | null>> = {
|
||||
category: 'category',
|
||||
tag: 'tag',
|
||||
author: 'author',
|
||||
search: null,
|
||||
}
|
||||
|
||||
const ROUTE_KEYS = Object.keys(DEFAULT_JOURNAL_ROUTES) as Array<keyof JournalRoutes>
|
||||
|
||||
231
app/utils/search.ts
Normal file
231
app/utils/search.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// Search helpers for JournalSearchBox and the search page: the term rules,
|
||||
// query-term marking that ignores case and diacritics, plural forms, listbox
|
||||
// keys and the suggestion request controller. Pure TypeScript, so
|
||||
// `node --test` imports it directly.
|
||||
|
||||
import { JOURNAL_SEARCH_MIN_LENGTH } from './client.ts'
|
||||
|
||||
/** Longer search terms are cut to this many characters. */
|
||||
export const JOURNAL_SEARCH_MAX_LENGTH = 200
|
||||
|
||||
/** The ?q value as a trimmed term: the first value of a repeated parameter, "" when absent. */
|
||||
export function searchTermFromQuery(raw: unknown): string {
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
if (typeof value !== 'string') return ''
|
||||
return [...value.trim()].slice(0, JOURNAL_SEARCH_MAX_LENGTH).join('').trim()
|
||||
}
|
||||
|
||||
export type SearchTermState = 'empty' | 'short' | 'ready'
|
||||
|
||||
/** Whether `term` is empty, too short for the API, or ready to search. */
|
||||
export function searchTermState(term: string, minLength: number = JOURNAL_SEARCH_MIN_LENGTH): SearchTermState {
|
||||
const n = [...term.trim()].length
|
||||
if (n === 0) return 'empty'
|
||||
return n < minLength ? 'short' : 'ready'
|
||||
}
|
||||
|
||||
// Letters NFD does not decompose into a base letter and a mark.
|
||||
const FOLD_EXTRA: Readonly<Record<string, string>> = {
|
||||
ł: 'l',
|
||||
Ł: 'l',
|
||||
ø: 'o',
|
||||
Ø: 'o',
|
||||
đ: 'd',
|
||||
Đ: 'd',
|
||||
ß: 'ss',
|
||||
æ: 'ae',
|
||||
Æ: 'ae',
|
||||
œ: 'oe',
|
||||
Œ: 'oe',
|
||||
}
|
||||
|
||||
function foldChar(ch: string): string {
|
||||
return FOLD_EXTRA[ch] ?? ch.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds `text` to lower case without diacritics. `start[i]` and `end[i]` are
|
||||
* the bounds, in `text`, of the character that produced folded[i].
|
||||
*/
|
||||
function fold(text: string): { folded: string; start: number[]; end: number[] } {
|
||||
let folded = ''
|
||||
const start: number[] = []
|
||||
const end: number[] = []
|
||||
let pos = 0
|
||||
for (const ch of text) {
|
||||
const f = foldChar(ch)
|
||||
for (let k = 0; k < f.length; k++) {
|
||||
start.push(pos)
|
||||
end.push(pos + ch.length)
|
||||
}
|
||||
folded += f
|
||||
pos += ch.length
|
||||
}
|
||||
return { folded, start, end }
|
||||
}
|
||||
|
||||
export interface TextSegment {
|
||||
text: string
|
||||
mark: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `text` into plain and marked segments, marking every occurrence of
|
||||
* each word of `query` (2 or more characters) while ignoring case and
|
||||
* diacritics, so "grzybow" marks "grzybów". The segments are text: render
|
||||
* them as text nodes, never as HTML.
|
||||
*/
|
||||
export function markTerms(text: string | null | undefined, query: string | null | undefined): TextSegment[] {
|
||||
if (!text) return []
|
||||
const words = [...new Set((query ?? '').split(/\s+/).map((w) => fold(w).folded))].filter((w) => w.length >= 2)
|
||||
if (words.length === 0) return [{ text, mark: false }]
|
||||
const { folded, start, end } = fold(text)
|
||||
const ranges: Array<[number, number]> = []
|
||||
for (const word of words) {
|
||||
for (let i = folded.indexOf(word); i !== -1; i = folded.indexOf(word, i + 1)) {
|
||||
ranges.push([start[i]!, end[i + word.length - 1]!])
|
||||
}
|
||||
}
|
||||
if (ranges.length === 0) return [{ text, mark: false }]
|
||||
ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1])
|
||||
const merged: Array<[number, number]> = []
|
||||
for (const r of ranges) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1])
|
||||
else merged.push([r[0], r[1]])
|
||||
}
|
||||
const out: TextSegment[] = []
|
||||
let pos = 0
|
||||
for (const [a, b] of merged) {
|
||||
if (a > pos) out.push({ text: text.slice(pos, a), mark: false })
|
||||
out.push({ text: text.slice(a, b), mark: true })
|
||||
pos = b
|
||||
}
|
||||
if (pos < text.length) out.push({ text: text.slice(pos), mark: false })
|
||||
return out
|
||||
}
|
||||
|
||||
export type PluralForm = 'one' | 'few' | 'many' | 'other'
|
||||
|
||||
/**
|
||||
* The plural form of `n` in `language` (a BCP 47 tag), from Intl.PluralRules:
|
||||
* Polish gives one (1), few (2-4, 22-24) and many (0, 5-21). Forms a message
|
||||
* file does not carry (zero, two) fall back to other.
|
||||
*/
|
||||
export function pluralForm(language: string, n: number): PluralForm {
|
||||
let form: string
|
||||
try {
|
||||
form = new Intl.PluralRules(language || 'en').select(n)
|
||||
} catch {
|
||||
form = new Intl.PluralRules('en').select(n)
|
||||
}
|
||||
return form === 'one' || form === 'few' || form === 'many' ? form : 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* The option a combobox key moves to among `count` options, from `current`
|
||||
* (-1 for none): ArrowDown and ArrowUp move one step and wrap. Any other key,
|
||||
* or no options, gives null.
|
||||
*/
|
||||
export function moveActive(current: number, count: number, key: string): number | null {
|
||||
if (count <= 0) return null
|
||||
if (key === 'ArrowDown') return current < 0 || current >= count - 1 ? 0 : current + 1
|
||||
if (key === 'ArrowUp') return current <= 0 ? count - 1 : current - 1
|
||||
return null
|
||||
}
|
||||
|
||||
export type SuggestStatus = 'idle' | 'loading' | 'done' | 'error'
|
||||
|
||||
export interface SuggestState<T> {
|
||||
/** The term the rows (or the pending request) belong to. */
|
||||
term: string
|
||||
status: SuggestStatus
|
||||
/** True once a request has run longer than the spinner delay. */
|
||||
loading: boolean
|
||||
rows: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SuggestControllerOptions<T> {
|
||||
/** One suggestion request; it must stop when `signal` aborts. */
|
||||
fetch: (term: string, signal: AbortSignal) => Promise<{ data: T[]; meta?: { total?: number } }>
|
||||
onChange: (state: SuggestState<T>) => void
|
||||
/** Wait after the last keystroke before a request, in ms. Default 250. */
|
||||
debounce?: number
|
||||
/** Shorter terms send nothing. Default JOURNAL_SEARCH_MIN_LENGTH. */
|
||||
minLength?: number
|
||||
/** A request shows `loading` only after this many ms, to avoid flicker. Default 300. */
|
||||
spinnerDelay?: number
|
||||
}
|
||||
|
||||
export interface SuggestController {
|
||||
/** The input's current value. */
|
||||
input(value: string): void
|
||||
/** Cancels the pending and running request. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced suggestion requests. Each new term cancels the pending timer,
|
||||
* aborts the running request and ignores any answer that still arrives, so
|
||||
* the rows always belong to the latest term. A failed request ends in status
|
||||
* "error" and never throws.
|
||||
*/
|
||||
export function createSuggestController<T>(opts: SuggestControllerOptions<T>): SuggestController {
|
||||
const debounce = opts.debounce ?? 250
|
||||
const minLength = opts.minLength ?? JOURNAL_SEARCH_MIN_LENGTH
|
||||
const spinnerDelay = opts.spinnerDelay ?? 300
|
||||
let state: SuggestState<T> = { term: '', status: 'idle', loading: false, rows: [], total: 0 }
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let spinner: ReturnType<typeof setTimeout> | undefined
|
||||
let abort: AbortController | null = null
|
||||
let seq = 0
|
||||
|
||||
const emit = (patch: Partial<SuggestState<T>>) => {
|
||||
state = { ...state, ...patch }
|
||||
opts.onChange(state)
|
||||
}
|
||||
const cancel = () => {
|
||||
clearTimeout(timer)
|
||||
clearTimeout(spinner)
|
||||
abort?.abort()
|
||||
abort = null
|
||||
seq++
|
||||
}
|
||||
const run = async (term: string) => {
|
||||
const id = ++seq
|
||||
const ctl = new AbortController()
|
||||
abort = ctl
|
||||
emit({ status: 'loading', loading: false })
|
||||
spinner = setTimeout(() => {
|
||||
if (id === seq) emit({ loading: true })
|
||||
}, spinnerDelay)
|
||||
try {
|
||||
const page = await opts.fetch(term, ctl.signal)
|
||||
if (id !== seq) return
|
||||
clearTimeout(spinner)
|
||||
emit({ status: 'done', loading: false, rows: page.data ?? [], total: page.meta?.total ?? 0 })
|
||||
} catch {
|
||||
if (id !== seq) return
|
||||
clearTimeout(spinner)
|
||||
emit({ status: 'error', loading: false, rows: [], total: 0 })
|
||||
} finally {
|
||||
if (abort === ctl) abort = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
input(value) {
|
||||
const term = value.trim()
|
||||
if (term === state.term && (state.status === 'loading' || state.status === 'done')) return
|
||||
cancel()
|
||||
if (searchTermState(term, minLength) !== 'ready') {
|
||||
emit({ term, status: 'idle', loading: false, rows: [], total: 0 })
|
||||
return
|
||||
}
|
||||
emit({ term })
|
||||
timer = setTimeout(() => void run(term), debounce)
|
||||
},
|
||||
dispose: cancel,
|
||||
}
|
||||
}
|
||||
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}`
|
||||
}
|
||||
@@ -49,6 +49,19 @@ export interface JournalPost {
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of GET /posts?fields=suggest: a subset of JournalPost for a search
|
||||
* box. `excerpt` is plain text of about 160 characters.
|
||||
*/
|
||||
export interface JournalPostSuggestion {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
excerpt: string
|
||||
featured_image_url: string | null
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
/** previous_post / next_post on GET /posts/{slug}. */
|
||||
export interface JournalPostSibling {
|
||||
id: number
|
||||
@@ -121,6 +134,8 @@ export interface JournalRoutes {
|
||||
category: string | false
|
||||
tag: string | false
|
||||
author: string | false
|
||||
/** Search results page; the term is the `q` query parameter. Off by default. */
|
||||
search: string | false
|
||||
}
|
||||
|
||||
export interface JournalPerPage {
|
||||
@@ -128,6 +143,7 @@ export interface JournalPerPage {
|
||||
category: number
|
||||
tag: number
|
||||
author: number
|
||||
search: number
|
||||
}
|
||||
|
||||
/** Options of the `journal` config key in nuxt.config. */
|
||||
|
||||
@@ -33,6 +33,28 @@
|
||||
"readMore": "Read more",
|
||||
"rss": {
|
||||
"title": "RSS feed"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"titleQuery": "Search results: {query}",
|
||||
"placeholder": "Search posts…",
|
||||
"button": "Search",
|
||||
"input": "Search the posts",
|
||||
"submit": "Search",
|
||||
"suggestions": "Suggestions",
|
||||
"seeAll": "See all results",
|
||||
"empty": "No results",
|
||||
"none": "Nothing found for “{query}”.",
|
||||
"short": "Type at least {min} characters.",
|
||||
"prompt": "Type what you are looking for.",
|
||||
"loading": "Searching…",
|
||||
"error": "Search is unavailable right now.",
|
||||
"count": {
|
||||
"one": "{count} result",
|
||||
"few": "{count} results",
|
||||
"many": "{count} results",
|
||||
"other": "{count} results"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,28 @@
|
||||
"readMore": "Czytaj dalej",
|
||||
"rss": {
|
||||
"title": "Kanał RSS"
|
||||
},
|
||||
"search": {
|
||||
"title": "Szukaj",
|
||||
"titleQuery": "Wyniki wyszukiwania: {query}",
|
||||
"placeholder": "Szukaj wpisów…",
|
||||
"button": "Szukaj",
|
||||
"input": "Szukaj we wpisach",
|
||||
"submit": "Szukaj",
|
||||
"suggestions": "Podpowiedzi",
|
||||
"seeAll": "Zobacz więcej",
|
||||
"empty": "Brak wyników",
|
||||
"none": "Nic nie znaleźliśmy dla „{query}”.",
|
||||
"short": "Wpisz co najmniej {min} znaki.",
|
||||
"prompt": "Wpisz, czego szukasz.",
|
||||
"loading": "Szukam…",
|
||||
"error": "Wyszukiwanie jest chwilowo niedostępne.",
|
||||
"count": {
|
||||
"one": "{count} wynik",
|
||||
"few": "{count} wyniki",
|
||||
"many": "{count} wyników",
|
||||
"other": "{count} wyniku"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const PAGE_FILES: Record<keyof JournalRoutes, string> = {
|
||||
category: 'CategoryPage',
|
||||
tag: 'TagPage',
|
||||
author: 'AuthorPage',
|
||||
search: 'SearchPage',
|
||||
}
|
||||
|
||||
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
|
||||
@@ -39,7 +40,7 @@ export default defineNuxtModule<JournalModuleOptions>({
|
||||
},
|
||||
defaults: {
|
||||
routes: { ...DEFAULT_JOURNAL_ROUTES },
|
||||
perPage: { index: 9, category: 9, tag: 9, author: 9 },
|
||||
perPage: { index: 9, category: 9, tag: 9, author: 9, search: 9 },
|
||||
sort: 'published_at desc',
|
||||
relatedCount: 3,
|
||||
rss: true,
|
||||
@@ -55,6 +56,21 @@ export default defineNuxtModule<JournalModuleOptions>({
|
||||
}
|
||||
})
|
||||
|
||||
// The search page renders per ?q, so it is never route-cached: Nitro's
|
||||
// cache keys by path, and an app rule such as '/**': { swr } would serve
|
||||
// one query's results for every query. It is noindex, follow and left
|
||||
// out of the sitemap. The locale-prefixed copies get the same rules. An
|
||||
// app's own rule for the path wins key by key.
|
||||
if (routes.search !== false) {
|
||||
const i18n = (nuxt.options as { i18n?: { locales?: unknown } }).i18n
|
||||
const paths = [routes.search, ...enabledLocaleCodes(i18n?.locales).map((code) => `/${code}${routes.search}`)]
|
||||
const options = nuxt.options as { routeRules?: Record<string, Record<string, unknown>> }
|
||||
const rules = (options.routeRules ??= {})
|
||||
for (const path of paths) rules[path] = { cache: false, robots: 'noindex, follow', ...(rules[path] ?? {}) }
|
||||
const opts = nuxt.options as { sitemap?: { exclude?: Array<string | RegExp> } }
|
||||
opts.sitemap = { ...(opts.sitemap ?? {}), exclude: [...(opts.sitemap?.exclude ?? []), ...paths] }
|
||||
}
|
||||
|
||||
// Register the layer's strings only for locales the app enables. The
|
||||
// layer config declares no locales: @nuxtjs/i18n merges every locale a
|
||||
// layer declares into the app, which would add /en routes to an app that
|
||||
|
||||
@@ -29,6 +29,36 @@ 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 old Journal purge route
|
||||
// POST /api/__journal__/cache/purge (NUXT_JOURNAL_CACHE_PURGE_TOKEN),
|
||||
// kept for one release; new sites use cachePurge.token. Shorter
|
||||
// than 32 characters, the route answers 404.
|
||||
cachePurgeToken: '',
|
||||
},
|
||||
cachePurge: {
|
||||
// Server-only bearer token of the shared site cache purge route
|
||||
// POST /api/__site__/cache/purge (NUXT_CACHE_PURGE_TOKEN), the
|
||||
// backend's site_cache.purge_token. Shorter than 32 characters, the
|
||||
// route answers 404. Not under NUXT_SITE_: nuxt-site-config copies
|
||||
// every NUXT_SITE_* variable into the site config, which is published
|
||||
// in every page's payload.
|
||||
token: '',
|
||||
// NUXT_CACHE_PURGE_ALLOW_PROXIED: set it to true when the backend
|
||||
// reaches the site through a TLS-terminating proxy (an https
|
||||
// site_cache.purge_url on another host), which adds X-Forwarded-For,
|
||||
// X-Real-IP or Forwarded. The route then accepts proxied requests and
|
||||
// the bearer token is the only gate; unset, it answers 404 to any
|
||||
// proxied request.
|
||||
allowProxied: false,
|
||||
},
|
||||
// Deprecated (one release): the earlier names of cachePurge.token
|
||||
// (NUXT_SITE_CACHE_PURGE_TOKEN) and cachePurge.allowProxied
|
||||
// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new ones are unset. The
|
||||
// layer's server/plugins/site-cache-purge.ts strips them from the site
|
||||
// config and logs a deprecation warning.
|
||||
siteCache: {
|
||||
purgeToken: '',
|
||||
allowProxied: false,
|
||||
},
|
||||
public: {
|
||||
journal: {
|
||||
@@ -38,6 +68,16 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
|
||||
// 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.
|
||||
ogImage: { enabled: false },
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.7.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 }
|
||||
})
|
||||
48
server/api/__site__/cache/purge.post.ts
vendored
Normal file
48
server/api/__site__/cache/purge.post.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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 }
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
208
server/lib/siteCachePurge.ts
Normal file
208
server/lib/siteCachePurge.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// 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.'
|
||||
)
|
||||
}
|
||||
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
|
||||
})
|
||||
})
|
||||
48
server/plugins/site-cache-purge.ts
Normal file
48
server/plugins/site-cache-purge.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,3 +43,26 @@
|
||||
200 /blog?page=2 contains=<link rel="canonical" href="https://acme.test/blog?page=2">
|
||||
200 /blog/hello-acme contains=og:title
|
||||
200 /blog/hello-acme contains=Acme says hello and introduces its blog.
|
||||
|
||||
# Search (the playground sets journal.routes.search: '/search'). 11 posts
|
||||
# match "acme": 2 pages of 9. Not route-cached (the playground caches /**),
|
||||
# noindex, follow, and left out of the sitemap.
|
||||
200 /search?q=acme contains=Hello Acme
|
||||
200 /search?q=acme contains=11 results
|
||||
200 /search?q=acme contains=noindex, follow
|
||||
200 /search?q=acme&page=2 contains=Acme Note 12
|
||||
404 /search?q=acme&page=3
|
||||
404 /search?q=acme&page=abc
|
||||
200 /search?q=unsafe contains=Unsafe Sample notcontains=Hello Acme
|
||||
200 /search?q=unsafe contains=1 result
|
||||
200 /search?q=zzzzzz contains=Nothing found for “zzzzzz”.
|
||||
200 /search?q=ac contains=Type at least 3 characters.
|
||||
200 /search contains=Type what you are looking for.
|
||||
# The term is text everywhere it is rendered (attributes may keep a bare <).
|
||||
200 /search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E contains=Search results: <script>alert(1) notcontains=>Search results: <script>
|
||||
200 /pl/search?q=acme contains=11 wyników
|
||||
200 /pl/search?q=unsafe contains=1 wynik
|
||||
200 /sitemap.xml notcontains=/search
|
||||
# The header box renders its no-JS form to the search route on every page.
|
||||
200 /blog contains=action="/search"
|
||||
200 /pl/blog contains=action="/pl/search"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// A small node:http stand-in for the anonymous Journal API
|
||||
// (/_journal/api/v1), answering from JSON fixtures. It follows the Go
|
||||
// handlers' query rules (page, per_page clamp, sort allow-list, category with
|
||||
// nested children, tag, search, include_empty) and the author routes, and it
|
||||
// nested children, tag, search, fields=suggest, include_empty) and the author routes, and it
|
||||
// records every request so a smoke run can assert header forwarding.
|
||||
//
|
||||
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
|
||||
@@ -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 */
|
||||
@@ -106,6 +106,19 @@ function paginate(rows, page, perPage) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The ?fields=suggest row of the Go serializer. @param {any} post */
|
||||
function suggestion(post) {
|
||||
const text = String(post.excerpt || post.content_html || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
return {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: text.length > 160 ? `${text.slice(0, 160).replace(/\s+\S*$/, '')}…` : text,
|
||||
featured_image_url: post.featured_image_url ?? null,
|
||||
published_at: post.published_at,
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {any} post */
|
||||
function sibling(post) {
|
||||
return post ? { id: post.id, title: post.title, slug: post.slug } : null
|
||||
@@ -118,19 +131,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 = []
|
||||
@@ -163,7 +185,9 @@ export function startFixtureApi({ dir, port = 0 }) {
|
||||
rows = rows.filter((p) => p.title.toLowerCase().includes(s))
|
||||
}
|
||||
rows = sortRows(rows, parseSort(q.get('sort')))
|
||||
return paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page')))
|
||||
const out = paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page')))
|
||||
if (q.get('fields') !== 'suggest') return out
|
||||
return { ...out, data: out.data.map(suggestion) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,6 +264,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())),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,8 +20,11 @@ function freePort() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The returned `output()` gives the server's captured stdout and stderr so
|
||||
* far, for checks on what the server logged.
|
||||
*
|
||||
* @param {{ outputDir: string, apiBase: string, port?: number, env?: Record<string, string>, timeoutMs?: number }} opts
|
||||
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
|
||||
* @returns {Promise<{ url: string, close: () => Promise<void>, output: () => string }>}
|
||||
*/
|
||||
export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) {
|
||||
const entry = join(resolve(outputDir), 'server', 'index.mjs')
|
||||
@@ -61,7 +64,7 @@ export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {},
|
||||
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
|
||||
try {
|
||||
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
||||
return { url, close }
|
||||
return { url, close, output: () => log }
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
194
testing/purge-token-check.mjs
Normal file
194
testing/purge-token-check.mjs
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
// Purge token check: proves on a built Nuxt server that the site cache purge
|
||||
// token never reaches a page, whichever environment variable carries it, and
|
||||
// that the shared purge route reads the new and the deprecated names.
|
||||
//
|
||||
// node purge-token-check.mjs --output <.output dir> --fixtures <dir> --path <page path>
|
||||
//
|
||||
// nuxt-site-config (through @nuxtjs/seo) copies every NUXT_SITE_* variable
|
||||
// into the public site config, which Nuxt serializes into each page's
|
||||
// payload. Three scenarios, each on a fresh server with fresh random tokens:
|
||||
//
|
||||
// A NUXT_CACHE_PURGE_TOKEN only
|
||||
// B the deprecated NUXT_SITE_CACHE_PURGE_TOKEN and
|
||||
// NUXT_SITE_CACHE_ALLOW_PROXIED=true only
|
||||
// C both token names, with different values (the new one wins)
|
||||
//
|
||||
// In each, `/`, the page and its _payload.json (when the build serves one)
|
||||
// must contain no token value and neither cachePurgeToken nor
|
||||
// cacheAllowProxied, and neither must the server's log. The purge route must
|
||||
// answer 200 to the expected bearer, 401 to the other or a wrong one, and a
|
||||
// proxied request with the bearer 200 in B (allowProxied) and 404 in A. B
|
||||
// logs the deprecation warning exactly once, A and C never.
|
||||
//
|
||||
// Failure messages name a key or say "token value"; they never print a
|
||||
// value. The last line is "purge-token-check: ok (<n> checks)" on success;
|
||||
// every failure prints "purge-token-check: FAIL ..." and exits 1.
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import { startFixtureApi } from './fixture-api.mjs'
|
||||
import { startNuxtServer } from './nuxt-server.mjs'
|
||||
|
||||
const PURGE_PATH = '/api/__site__/cache/purge'
|
||||
const PURGE_KEYS = ['cachePurgeToken', 'cacheAllowProxied']
|
||||
const DEPRECATION_LINE = /^\[site-cache\].*NUXT_CACHE_PURGE_TOKEN/gm
|
||||
|
||||
/** @param {string[]} argv */
|
||||
function parseArgs(argv) {
|
||||
/** @type {Record<string, string>} */
|
||||
const out = {}
|
||||
const names = new Set(['--output', '--fixtures', '--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 and --path are required')
|
||||
}
|
||||
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
|
||||
return /** @type {{ output: string, fixtures: string, path: string }} */ (out)
|
||||
}
|
||||
|
||||
const token = () => randomBytes(32).toString('hex')
|
||||
|
||||
/** @param {string} base @param {string} path */
|
||||
async function get(base, path) {
|
||||
const res = await fetch(base + path, { redirect: 'manual', signal: AbortSignal.timeout(20000) })
|
||||
return { status: res.status, body: await res.text() }
|
||||
}
|
||||
|
||||
/** @param {string} base @param {Record<string, string>} headers */
|
||||
async function purge(base, headers) {
|
||||
const res = await fetch(base + PURGE_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ kind: 'post', slug: 'purge-token-check' }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
await res.text()
|
||||
return res.status
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
const bearer = (value) => ({ authorization: `Bearer ${value}` })
|
||||
|
||||
/** The payload path Nuxt serves for a page path. @param {string} path */
|
||||
function payloadPath(path) {
|
||||
return `${path.replace(/\/+$/, '')}/_payload.json`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
/** @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 a = token()
|
||||
const b = token()
|
||||
const cNew = token()
|
||||
const cOld = token()
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'A (NUXT_CACHE_PURGE_TOKEN)',
|
||||
env: { NUXT_CACHE_PURGE_TOKEN: a },
|
||||
tokens: [a],
|
||||
accepted: a,
|
||||
refused: [token()],
|
||||
proxied: 404,
|
||||
warnings: 0,
|
||||
},
|
||||
{
|
||||
name: 'B (deprecated NUXT_SITE_CACHE_PURGE_TOKEN, NUXT_SITE_CACHE_ALLOW_PROXIED=true)',
|
||||
env: { NUXT_SITE_CACHE_PURGE_TOKEN: b, NUXT_SITE_CACHE_ALLOW_PROXIED: 'true' },
|
||||
tokens: [b],
|
||||
accepted: b,
|
||||
refused: [token()],
|
||||
proxied: 200,
|
||||
warnings: 1,
|
||||
},
|
||||
{
|
||||
name: 'C (both names, different values)',
|
||||
env: { NUXT_CACHE_PURGE_TOKEN: cNew, NUXT_SITE_CACHE_PURGE_TOKEN: cOld },
|
||||
tokens: [cNew, cOld],
|
||||
accepted: cNew,
|
||||
refused: [cOld, token()],
|
||||
proxied: 404,
|
||||
warnings: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
|
||||
/** @type {{ url: string, close: () => Promise<void>, output: () => string } | null} */
|
||||
let nuxt = null
|
||||
try {
|
||||
for (const s of scenarios) {
|
||||
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: s.env })
|
||||
const base = nuxt.url
|
||||
|
||||
// 1. No token value and no purge key in any page or payload.
|
||||
const paths = ['/', args.path]
|
||||
const page = await get(base, args.path)
|
||||
check(page.status === 200, `${s.name}: GET ${args.path}: status ${page.status}, want 200`)
|
||||
const payload = payloadPath(args.path)
|
||||
if ((await get(base, payload)).status === 200) paths.push(payload)
|
||||
for (const path of paths) {
|
||||
const { body } = await get(base, path)
|
||||
for (const [i, value] of s.tokens.entries()) {
|
||||
check(!body.includes(value), `${s.name}: GET ${path} contains token value #${i + 1}`)
|
||||
}
|
||||
for (const key of PURGE_KEYS) {
|
||||
check(!body.includes(key), `${s.name}: GET ${path} contains the key ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. The purge route reads the expected token.
|
||||
const ok = await purge(base, bearer(s.accepted))
|
||||
check(ok === 200, `${s.name}: purge with the expected bearer: status ${ok}, want 200`)
|
||||
for (const [i, value] of s.refused.entries()) {
|
||||
const status = await purge(base, bearer(value))
|
||||
check(status === 401, `${s.name}: purge with refused bearer #${i + 1}: status ${status}, want 401`)
|
||||
}
|
||||
const none = await purge(base, {})
|
||||
check(none === 401, `${s.name}: purge without a bearer: status ${none}, want 401`)
|
||||
const proxied = await purge(base, { ...bearer(s.accepted), 'x-forwarded-for': '203.0.113.7' })
|
||||
check(proxied === s.proxied, `${s.name}: proxied purge with the expected bearer: status ${proxied}, want ${s.proxied}`)
|
||||
|
||||
// 3. The log: the deprecation warning count, no token value, no key.
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
const log = nuxt.output()
|
||||
const warnings = (log.match(DEPRECATION_LINE) ?? []).length
|
||||
check(warnings === s.warnings, `${s.name}: ${warnings} deprecation warnings in the server log, want ${s.warnings}`)
|
||||
for (const [i, value] of s.tokens.entries()) {
|
||||
check(!log.includes(value), `${s.name}: the server log contains token value #${i + 1}`)
|
||||
}
|
||||
|
||||
await nuxt.close()
|
||||
nuxt = null
|
||||
}
|
||||
} finally {
|
||||
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
|
||||
await api.close()
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const f of failures) console.error(`purge-token-check: FAIL ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`purge-token-check: ok (${checks} checks)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`purge-token-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -183,3 +183,38 @@ test('getAuthor and listAuthorPosts encode the author and map 404 to null', asyn
|
||||
assert.equal(await client.getAuthor('nobody'), null)
|
||||
assert.equal(await client.listAuthorPosts('nobody'), null)
|
||||
})
|
||||
|
||||
test('buildPostListQuery passes fields through only when set', () => {
|
||||
assert.deepEqual(buildPostListQuery({ search: 'kurki', fields: 'suggest', perPage: 5 }), {
|
||||
per_page: 5,
|
||||
search: 'kurki',
|
||||
fields: 'suggest',
|
||||
})
|
||||
assert.equal('fields' in buildPostListQuery({ search: 'kurki' }), false)
|
||||
})
|
||||
|
||||
test('suggestPosts sends search, fields=suggest and per_page, no sort, with the signal', async () => {
|
||||
const page = { data: [{ id: 1, title: 'Kurki', slug: 'kurki', excerpt: 'x', featured_image_url: null, published_at: null }], meta: { current_page: 1, last_page: 1, per_page: 5, total: 1 } }
|
||||
const inits: unknown[] = []
|
||||
const calls: Call[] = []
|
||||
const fetcher = async (path: string, query?: Record<string, string | number>, init?: { signal?: AbortSignal }) => {
|
||||
calls.push({ path, query })
|
||||
inits.push(init)
|
||||
return page
|
||||
}
|
||||
const signal = new AbortController().signal
|
||||
const client = createJournalClient(fetcher)
|
||||
assert.deepEqual(await client.suggestPosts(' kurki ', { signal }), page)
|
||||
assert.deepEqual(calls[0], { path: '/posts', query: { per_page: 5, search: 'kurki', fields: 'suggest' } })
|
||||
assert.equal((inits[0] as { signal: AbortSignal }).signal, signal)
|
||||
await client.suggestPosts('kurki', { perPage: 99 })
|
||||
assert.equal(calls[1]!.query!.per_page, JOURNAL_MAX_PER_PAGE)
|
||||
})
|
||||
|
||||
test('suggestPosts sends nothing for a term under 3 characters', async () => {
|
||||
const { fetcher, calls } = fakeFetcher({})
|
||||
const res = await createJournalClient(fetcher).suggestPosts('ąę')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.deepEqual(res.data, [])
|
||||
assert.equal(res.meta.total, 0)
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ test('an override replaces one key and keeps the others', () => {
|
||||
})
|
||||
|
||||
test('a site URL map normalizes as given (grzybyfunkcjonalne shape)', () => {
|
||||
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }
|
||||
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false, search: '/szukaj' }
|
||||
assert.deepEqual(normalizeJournalRoutes(input), input)
|
||||
})
|
||||
|
||||
@@ -85,9 +85,17 @@ test('route names are stable', () => {
|
||||
category: 'journal-category',
|
||||
tag: 'journal-tag',
|
||||
author: 'journal-author',
|
||||
search: 'journal-search',
|
||||
})
|
||||
})
|
||||
|
||||
test('the search route is off by default and takes a plain path', () => {
|
||||
assert.equal(DEFAULT_JOURNAL_ROUTES.search, false)
|
||||
assert.equal(normalizeJournalRoutes().search, false)
|
||||
assert.equal(normalizeJournalRoutes({ search: '/szukaj' }).search, '/szukaj')
|
||||
assert.throws(() => normalizeJournalRoutes({ search: 'szukaj' }), /journal\.routes\.search/)
|
||||
})
|
||||
|
||||
test('fillJournalPath encodes a value', () => {
|
||||
assert.equal(fillJournalPath('/blog/:slug', { slug: 'a b' }), '/blog/a%20b')
|
||||
assert.equal(fillJournalPath('/blog/:slug', { slug: 'zażółć' }), '/blog/za%C5%BC%C3%B3%C5%82%C4%87')
|
||||
|
||||
203
tests/search.test.ts
Normal file
203
tests/search.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// Search helpers: term rules, diacritic-insensitive marking, plural forms,
|
||||
// listbox keys, and the suggestion controller's debounce, abort and
|
||||
// stale-answer handling under mocked timers. No network.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
createSuggestController,
|
||||
markTerms,
|
||||
moveActive,
|
||||
pluralForm,
|
||||
searchTermFromQuery,
|
||||
searchTermState,
|
||||
type SuggestState,
|
||||
} from '../app/utils/search.ts'
|
||||
|
||||
const marked = (text: string, q: string) =>
|
||||
markTerms(text, q)
|
||||
.map((s) => (s.mark ? `[${s.text}]` : s.text))
|
||||
.join('')
|
||||
|
||||
test('searchTermFromQuery trims, takes the first repeated value and ignores non-strings', () => {
|
||||
assert.equal(searchTermFromQuery(' borowik '), 'borowik')
|
||||
assert.equal(searchTermFromQuery(['kurki', 'x']), 'kurki')
|
||||
assert.equal(searchTermFromQuery(undefined), '')
|
||||
assert.equal(searchTermFromQuery(null), '')
|
||||
assert.equal(searchTermFromQuery([null]), '')
|
||||
assert.equal([...searchTermFromQuery('ż'.repeat(500))].length, 200)
|
||||
})
|
||||
|
||||
test('searchTermState counts characters, not UTF-16 units', () => {
|
||||
assert.equal(searchTermState(''), 'empty')
|
||||
assert.equal(searchTermState(' '), 'empty')
|
||||
assert.equal(searchTermState('ab'), 'short')
|
||||
assert.equal(searchTermState('żół'), 'ready')
|
||||
assert.equal(searchTermState('ab', 2), 'ready')
|
||||
})
|
||||
|
||||
test('markTerms ignores case and diacritics', () => {
|
||||
assert.equal(marked('Sezon na grzybów zbieranie', 'grzybow'), 'Sezon na [grzybów] zbieranie')
|
||||
assert.equal(marked('Łysiczki i ŁOSIE', 'lysiczki losie'), '[Łysiczki] i [ŁOSIE]')
|
||||
assert.equal(marked('Kurki', 'KURKI'), '[Kurki]')
|
||||
assert.equal(marked('Straße', 'strasse'), '[Straße]')
|
||||
})
|
||||
|
||||
test('markTerms marks every occurrence and merges overlaps', () => {
|
||||
assert.equal(marked('grzyb, grzyby, grzybów', 'grzyb'), '[grzyb], [grzyb]y, [grzyb]ów')
|
||||
assert.equal(marked('borowik', 'bor borowik'), '[borowik]')
|
||||
})
|
||||
|
||||
test('markTerms returns text segments only and leaves markup as text', () => {
|
||||
const segs = markTerms('<b>kurki</b> & więcej', 'kurki')
|
||||
assert.deepEqual(segs, [
|
||||
{ text: '<b>', mark: false },
|
||||
{ text: 'kurki', mark: true },
|
||||
{ text: '</b> & więcej', mark: false },
|
||||
])
|
||||
})
|
||||
|
||||
test('markTerms skips one-letter words and handles empty input', () => {
|
||||
assert.deepEqual(markTerms('a b c', 'a'), [{ text: 'a b c', mark: false }])
|
||||
assert.deepEqual(markTerms('', 'kurki'), [])
|
||||
assert.deepEqual(markTerms(null, 'kurki'), [])
|
||||
assert.deepEqual(markTerms('kurki', ''), [{ text: 'kurki', mark: false }])
|
||||
})
|
||||
|
||||
test('pluralForm gives the Polish one/few/many forms', () => {
|
||||
assert.deepEqual(
|
||||
[0, 1, 2, 4, 5, 12, 21, 22, 25, 102].map((n) => pluralForm('pl-PL', n)),
|
||||
['many', 'one', 'few', 'few', 'many', 'many', 'many', 'few', 'many', 'few'],
|
||||
)
|
||||
assert.deepEqual([0, 1, 2].map((n) => pluralForm('en-US', n)), ['other', 'one', 'other'])
|
||||
assert.equal(pluralForm('not a locale!!', 1), 'one')
|
||||
})
|
||||
|
||||
test('moveActive wraps through the options', () => {
|
||||
assert.equal(moveActive(-1, 3, 'ArrowDown'), 0)
|
||||
assert.equal(moveActive(2, 3, 'ArrowDown'), 0)
|
||||
assert.equal(moveActive(-1, 3, 'ArrowUp'), 2)
|
||||
assert.equal(moveActive(0, 3, 'ArrowUp'), 2)
|
||||
assert.equal(moveActive(1, 3, 'ArrowUp'), 0)
|
||||
assert.equal(moveActive(0, 0, 'ArrowDown'), null)
|
||||
assert.equal(moveActive(0, 3, 'Enter'), null)
|
||||
})
|
||||
|
||||
type Row = { id: number; term: string }
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const flush = () => new Promise<void>((r) => setImmediate(r))
|
||||
|
||||
function harness(t: import('node:test').TestContext) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const calls: Array<{ term: string; signal: AbortSignal; answer: ReturnType<typeof deferred<{ data: Row[]; meta: { total: number } }>> }> = []
|
||||
const states: Array<SuggestState<Row>> = []
|
||||
const ctl = createSuggestController<Row>({
|
||||
fetch: (term, signal) => {
|
||||
const answer = deferred<{ data: Row[]; meta: { total: number } }>()
|
||||
calls.push({ term, signal, answer })
|
||||
return answer.promise
|
||||
},
|
||||
onChange: (s) => states.push(s),
|
||||
})
|
||||
const last = () => states[states.length - 1]!
|
||||
return { ctl, calls, last, tick: (ms: number) => t.mock.timers.tick(ms) }
|
||||
}
|
||||
|
||||
test('suggest: one request per pause, after the debounce', async (t) => {
|
||||
const h = harness(t)
|
||||
h.ctl.input('bor')
|
||||
h.tick(100)
|
||||
h.ctl.input('boro')
|
||||
h.tick(100)
|
||||
h.ctl.input('borow')
|
||||
h.tick(249)
|
||||
assert.equal(h.calls.length, 0)
|
||||
h.tick(1)
|
||||
assert.deepEqual(
|
||||
h.calls.map((c) => c.term),
|
||||
['borow'],
|
||||
)
|
||||
assert.equal(h.last().status, 'loading')
|
||||
h.calls[0]!.answer.resolve({ data: [{ id: 1, term: 'borow' }], meta: { total: 7 } })
|
||||
await flush()
|
||||
assert.equal(h.last().status, 'done')
|
||||
assert.deepEqual(h.last().rows, [{ id: 1, term: 'borow' }])
|
||||
assert.equal(h.last().total, 7)
|
||||
})
|
||||
|
||||
test('suggest: short terms send nothing and clear the rows', (t) => {
|
||||
const h = harness(t)
|
||||
h.ctl.input('bo')
|
||||
h.tick(1000)
|
||||
assert.equal(h.calls.length, 0)
|
||||
assert.equal(h.last().status, 'idle')
|
||||
})
|
||||
|
||||
test('suggest: a newer term aborts the running request and drops its answer', async (t) => {
|
||||
const h = harness(t)
|
||||
h.ctl.input('kurki')
|
||||
h.tick(250)
|
||||
h.ctl.input('kurkiw')
|
||||
assert.equal(h.calls[0]!.signal.aborted, true)
|
||||
h.tick(250)
|
||||
h.calls[1]!.answer.resolve({ data: [{ id: 2, term: 'kurkiw' }], meta: { total: 1 } })
|
||||
await flush()
|
||||
h.calls[0]!.answer.resolve({ data: [{ id: 1, term: 'kurki' }], meta: { total: 1 } })
|
||||
await flush()
|
||||
assert.deepEqual(h.last().rows, [{ id: 2, term: 'kurkiw' }])
|
||||
assert.equal(h.last().term, 'kurkiw')
|
||||
})
|
||||
|
||||
test('suggest: the spinner shows only after the delay', async (t) => {
|
||||
const h = harness(t)
|
||||
h.ctl.input('rydze')
|
||||
h.tick(250)
|
||||
h.tick(299)
|
||||
assert.equal(h.last().loading, false)
|
||||
h.tick(1)
|
||||
assert.equal(h.last().loading, true)
|
||||
h.calls[0]!.answer.resolve({ data: [], meta: { total: 0 } })
|
||||
await flush()
|
||||
assert.equal(h.last().loading, false)
|
||||
assert.equal(h.last().status, 'done')
|
||||
})
|
||||
|
||||
test('suggest: a failed request ends in error without throwing', async (t) => {
|
||||
const h = harness(t)
|
||||
h.ctl.input('opieńki')
|
||||
h.tick(250)
|
||||
h.calls[0]!.answer.reject(new Error('429'))
|
||||
await flush()
|
||||
assert.equal(h.last().status, 'error')
|
||||
assert.deepEqual(h.last().rows, [])
|
||||
})
|
||||
|
||||
test('suggest: the same term again sends no second request', async (t) => {
|
||||
const h = harness(t)
|
||||
h.ctl.input('maślaki')
|
||||
h.tick(250)
|
||||
h.ctl.input('maślaki ')
|
||||
h.tick(1000)
|
||||
assert.equal(h.calls.length, 1)
|
||||
})
|
||||
|
||||
test('suggest: dispose aborts and ignores the answer', async (t) => {
|
||||
const h = harness(t)
|
||||
h.ctl.input('gąski')
|
||||
h.tick(250)
|
||||
h.ctl.dispose()
|
||||
assert.equal(h.calls[0]!.signal.aborted, true)
|
||||
const before = h.last()
|
||||
h.calls[0]!.answer.resolve({ data: [{ id: 9, term: 'gąski' }], meta: { total: 1 } })
|
||||
await flush()
|
||||
assert.equal(h.last(), before)
|
||||
})
|
||||
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(''), '')
|
||||
})
|
||||
438
tests/sitePurge.test.ts
Normal file
438
tests/sitePurge.test.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
// Shared site cache purge: route authorization, the route-cache purge, the
|
||||
// purge body parsing, the purge settings under their new and deprecated
|
||||
// names, the site config stripping plugin 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_CONFIG_PURGE_KEYS,
|
||||
SITE_PURGE_WARNED,
|
||||
SITE_ROUTE_CACHE_BASE,
|
||||
authorizeSitePurge,
|
||||
parseSitePurgeEvent,
|
||||
purgeSiteRouteCache,
|
||||
redactSiteConfigStack,
|
||||
resolveSitePurgeConfig,
|
||||
sitePurgeDeprecationMessage,
|
||||
} from '../server/lib/siteCachePurge.ts'
|
||||
|
||||
// The sha256 of the three 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 = 'da5e588b94de6a07f99f203d51cc953f497af886d0fad03709d67064a7b09b2a'
|
||||
const SHARED_ROUTE_SHA256 = 'b52189cd8b48586ebeade602d2e23951a8b8156fcc319974a5b3b09f1b409d93'
|
||||
const SHARED_PLUGIN_SHA256 = 'bd4e00888d3121ae9b07cf4f8e130dd54a4fc575ca0b42771d744c36949633a6'
|
||||
|
||||
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')
|
||||
assert.equal(sha256('../server/plugins/site-cache-purge.ts'), SHARED_PLUGIN_SHA256, 'server/plugins/site-cache-purge.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))
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: ok in constant time over sha256 digests, whatever the bearer length', () => {
|
||||
// timingSafeEqual on raw strings throws on a length mismatch; over digests
|
||||
// every length compares in constant time and answers unauthorized.
|
||||
for (const length of [1, 31, 33, 64, 4096]) {
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${'a'.repeat(length)}`, proxied: false }), 'unauthorized', `length ${length}`)
|
||||
}
|
||||
const long = 'k'.repeat(200)
|
||||
assert.equal(authorizeSitePurge({ token: long, authorization: `Bearer ${long}`, proxied: false }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer\t${TOKEN}\t`, proxied: false }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `BEARER ${TOKEN} `, proxied: false }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: ` Bearer ${TOKEN}`, proxied: false }), 'unauthorized')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}\n`, proxied: false }), 'unauthorized')
|
||||
})
|
||||
|
||||
test('sitePurge: purge works with a synchronous storage and skips the bare base key', async () => {
|
||||
const live = new Set([`${SITE_ROUTE_CACHE_BASE}:_:a.json`, SITE_ROUTE_CACHE_BASE, 'nitro:other:_:b.json'])
|
||||
const removed: string[] = []
|
||||
const storage = {
|
||||
getKeys: (base?: string) => [...live].filter((k) => !base || k === base || k.startsWith(base + ':')),
|
||||
removeItem: (key: string) => {
|
||||
live.delete(key)
|
||||
removed.push(key)
|
||||
},
|
||||
}
|
||||
assert.equal(await purgeSiteRouteCache(storage), 1)
|
||||
assert.deepEqual(removed, [`${SITE_ROUTE_CACHE_BASE}:_:a.json`])
|
||||
assert.ok(live.has(SITE_ROUTE_CACHE_BASE))
|
||||
assert.equal(await purgeSiteRouteCache({ getKeys: () => [], removeItem: () => assert.fail('nothing to remove') }), 0)
|
||||
})
|
||||
|
||||
test('sitePurge: purge event edge cases', () => {
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'a'.repeat(40), slug: '' }), { kind: 'a'.repeat(40), slug: '' })
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq' }), { kind: 'faq', slug: '' })
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 42 }), { kind: 'faq', slug: '' })
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'post', slug: 'x', extra: 'ignored' }), { kind: 'post', slug: 'x' })
|
||||
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'y'.repeat(200) }).slug.length, 200)
|
||||
// Control characters are stripped before the cut.
|
||||
const slug = '\u0001'.repeat(50) + 'z'.repeat(200)
|
||||
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug }).slug, 'z'.repeat(200))
|
||||
assert.deepEqual(parseSitePurgeEvent({}), { kind: 'unknown', slug: '' })
|
||||
})
|
||||
|
||||
/** One call of the shared route with Nitro's auto-imports stubbed. */
|
||||
async function callRoute(opts: {
|
||||
token?: unknown
|
||||
allowProxied?: unknown
|
||||
cachePurge?: { token?: unknown; allowProxied?: unknown }
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
bodyError?: boolean
|
||||
keys?: string[]
|
||||
}) {
|
||||
const responseHeaders: Record<string, string> = {}
|
||||
const logs: string[] = []
|
||||
const live = new Set(opts.keys ?? [])
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.defineEventHandler = (fn: unknown) => fn
|
||||
g.setResponseHeader = (_e: unknown, name: string, value: string) => {
|
||||
responseHeaders[name] = value
|
||||
}
|
||||
g.useRuntimeConfig = () => ({
|
||||
siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token, allowProxied: opts.allowProxied },
|
||||
cachePurge: opts.cachePurge,
|
||||
})
|
||||
g.getRequestHeader = (_e: unknown, name: string) => opts.headers?.[name]
|
||||
g.createError = (input: { statusCode: number; statusMessage: string }) => Object.assign(new Error(input.statusMessage), input)
|
||||
g.readBody = async () => {
|
||||
if (opts.bodyError) throw new Error('invalid JSON')
|
||||
return opts.body
|
||||
}
|
||||
g.useStorage = (base: string) => {
|
||||
assert.equal(base, 'cache')
|
||||
return {
|
||||
getKeys: async (prefix?: string) => [...live].filter((k) => !prefix || k.startsWith(prefix + ':')),
|
||||
removeItem: async (key: string) => {
|
||||
live.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
const info = console.info
|
||||
console.info = (line: string) => {
|
||||
logs.push(line)
|
||||
}
|
||||
try {
|
||||
const route = (await import('../server/api/__site__/cache/purge.post.ts')).default as (e: unknown) => Promise<unknown>
|
||||
try {
|
||||
return { result: await route({}), status: 200, responseHeaders, logs, live }
|
||||
} catch (err) {
|
||||
return { result: undefined, status: (err as { statusCode?: number }).statusCode, responseHeaders, logs, live }
|
||||
}
|
||||
} finally {
|
||||
console.info = info
|
||||
}
|
||||
}
|
||||
|
||||
test('sitePurge: route answers 404 when disabled or proxied and 401 for a wrong bearer', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
|
||||
for (const token of [undefined, '', 'short']) {
|
||||
const r = await callRoute({ token, headers: { authorization: `Bearer ${String(token)}` }, keys })
|
||||
assert.equal(r.status, 404, `token ${JSON.stringify(token)}`)
|
||||
assert.equal(r.live.size, 1)
|
||||
}
|
||||
for (const name of ['x-forwarded-for', 'x-real-ip', 'forwarded']) {
|
||||
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}`, [name]: '203.0.113.9' }, keys })
|
||||
assert.equal(r.status, 404, name)
|
||||
assert.equal(r.live.size, 1)
|
||||
}
|
||||
for (const authorization of [undefined, 'Bearer wrong', `Basic ${TOKEN}`]) {
|
||||
const r = await callRoute({ token: TOKEN, headers: authorization ? { authorization } : {}, keys })
|
||||
assert.equal(r.status, 401, String(authorization))
|
||||
assert.equal(r.live.size, 1)
|
||||
assert.equal(r.responseHeaders['cache-control'], 'no-store')
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: route purges, answers the count and logs kind and slug only', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`, 'nitro:functions:_:x.json']
|
||||
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, body: { kind: 'faq', slug: 'ship\nping' }, keys })
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual(r.result, { purged: 2 })
|
||||
assert.deepEqual([...r.live], ['nitro:functions:_:x.json'])
|
||||
assert.equal(r.responseHeaders['cache-control'], 'no-store')
|
||||
assert.deepEqual(r.logs, ['[site-cache] purge: removed 2 route cache entries (faq shipping)'])
|
||||
assert.ok(!r.logs.join('').includes(TOKEN))
|
||||
|
||||
const bad = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, bodyError: true, keys: [] })
|
||||
assert.equal(bad.status, 200)
|
||||
assert.deepEqual(bad.result, { purged: 0 })
|
||||
assert.deepEqual(bad.logs, ['[site-cache] purge: removed 0 route cache entries (unknown )'])
|
||||
})
|
||||
|
||||
test('sitePurge: allowProxied lets a proxied request reach the token check', () => {
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: true }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: 'Bearer ' + 'b'.repeat(32), proxied: true, allowProxied: true }), 'unauthorized')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true, allowProxied: true }), 'unauthorized')
|
||||
assert.equal(authorizeSitePurge({ token: 'short', authorization: 'Bearer short', proxied: true, allowProxied: true }), 'disabled')
|
||||
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true, allowProxied: true }), 'disabled')
|
||||
// Without the flag, or with anything but true, a proxied request stays refused.
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: false }), 'proxied')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
|
||||
})
|
||||
|
||||
test('sitePurge: route accepts a proxied request with allowProxied and the right bearer', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`]
|
||||
const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9', forwarded: 'for=203.0.113.9;proto=https' }
|
||||
for (const allowProxied of [true, 'true']) {
|
||||
const r = await callRoute({ token: TOKEN, allowProxied, headers, body: { kind: 'faq', slug: 'shipping' }, keys })
|
||||
assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`)
|
||||
assert.deepEqual(r.result, { purged: 2 })
|
||||
assert.equal(r.live.size, 0)
|
||||
}
|
||||
const wrong = await callRoute({ token: TOKEN, allowProxied: true, headers: { ...headers, authorization: 'Bearer wrong' }, keys })
|
||||
assert.equal(wrong.status, 401)
|
||||
assert.equal(wrong.live.size, 2)
|
||||
for (const allowProxied of [false, undefined, 'false', 1]) {
|
||||
const r = await callRoute({ token: TOKEN, allowProxied, headers, keys })
|
||||
assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`)
|
||||
assert.equal(r.live.size, 2)
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: resolve config prefers a non-empty new token over the deprecated one', () => {
|
||||
const A = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A }, siteCache: { purgeToken: B } }), { token: A, allowProxied: false, deprecated: [] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A, allowProxied: false }, siteCache: { purgeToken: '' } }), { token: A, allowProxied: false, deprecated: [] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: null, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: '' } }), { token: '', allowProxied: false, deprecated: [] })
|
||||
})
|
||||
|
||||
test('sitePurge: resolve config turns allowProxied on for either flag, true or "true" only', () => {
|
||||
for (const value of [true, 'true']) {
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).allowProxied, true, `new ${JSON.stringify(value)}`)
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).deprecated, [])
|
||||
const old = resolveSitePurgeConfig({ siteCache: { allowProxied: value } })
|
||||
assert.equal(old.allowProxied, true, `old ${JSON.stringify(value)}`)
|
||||
assert.deepEqual(old.deprecated, ['allowProxied'])
|
||||
// The new flag on: the old one is not counted as in use.
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } }).deprecated, [])
|
||||
}
|
||||
for (const value of [false, undefined, 'false', 1, null, 'TRUE']) {
|
||||
const r = resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } })
|
||||
assert.equal(r.allowProxied, false, JSON.stringify(value))
|
||||
assert.deepEqual(r.deprecated, [])
|
||||
}
|
||||
const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B, allowProxied: 'true' } }).deprecated, ['token', 'allowProxied'])
|
||||
})
|
||||
|
||||
test('sitePurge: resolve config tolerates a missing or malformed config', () => {
|
||||
for (const config of [undefined, null, 'x', 42, {}, { cachePurge: 'x', siteCache: 7 }]) {
|
||||
assert.deepEqual(resolveSitePurgeConfig(config), { token: undefined, allowProxied: false, deprecated: [] }, JSON.stringify(config))
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: route reads the new token, falls back to the deprecated one, and the new one wins', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
|
||||
const NEW = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
const r = await callRoute({ cachePurge: { token: TOKEN }, headers: { authorization: `Bearer ${TOKEN}` }, keys })
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual(r.result, { purged: 1 })
|
||||
const old = await callRoute({ token: TOKEN, cachePurge: { token: '' }, headers: { authorization: `Bearer ${TOKEN}` }, keys })
|
||||
assert.equal(old.status, 200)
|
||||
const both = { token: TOKEN, cachePurge: { token: NEW } }
|
||||
assert.equal((await callRoute({ ...both, headers: { authorization: `Bearer ${NEW}` }, keys })).status, 200)
|
||||
const refused = await callRoute({ ...both, headers: { authorization: `Bearer ${TOKEN}` }, keys })
|
||||
assert.equal(refused.status, 401)
|
||||
assert.equal(refused.live.size, 1)
|
||||
const short = await callRoute({ cachePurge: { token: 'short' }, headers: { authorization: 'Bearer short' }, keys })
|
||||
assert.equal(short.status, 404)
|
||||
})
|
||||
|
||||
test('sitePurge: route lets a proxied request reach the token check with cachePurge.allowProxied', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
|
||||
const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9' }
|
||||
for (const allowProxied of [true, 'true']) {
|
||||
const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys })
|
||||
assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`)
|
||||
const wrong = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers: { ...headers, authorization: 'Bearer wrong' }, keys })
|
||||
assert.equal(wrong.status, 401)
|
||||
}
|
||||
// The deprecated flag with the new token still opens it.
|
||||
assert.equal((await callRoute({ token: '', allowProxied: true, cachePurge: { token: TOKEN }, headers, keys })).status, 200)
|
||||
for (const allowProxied of [false, undefined, 'false', 1]) {
|
||||
const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys })
|
||||
assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: redactSiteConfigStack removes the purge keys from every stack entry', () => {
|
||||
assert.deepEqual([...SITE_CONFIG_PURGE_KEYS], ['cachePurgeToken', 'cacheAllowProxied'])
|
||||
const stack: Record<string, unknown>[] = [
|
||||
{ url: 'https://acme.test', name: 'Acme', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' },
|
||||
{ _context: 'x' },
|
||||
]
|
||||
assert.equal(redactSiteConfigStack(stack), 2)
|
||||
assert.deepEqual(stack, [{ url: 'https://acme.test', name: 'Acme' }, { _context: 'x' }])
|
||||
assert.equal(redactSiteConfigStack(stack), 0)
|
||||
const spread = [{ cachePurgeToken: TOKEN }, { cachePurgeToken: TOKEN, indexable: true }]
|
||||
assert.equal(redactSiteConfigStack(spread), 2)
|
||||
assert.deepEqual(spread, [{}, { indexable: true }])
|
||||
for (const value of [undefined, null, 'stack', 7, {}, { cachePurgeToken: TOKEN }]) {
|
||||
assert.equal(redactSiteConfigStack(value), 0, JSON.stringify(value))
|
||||
}
|
||||
assert.equal(redactSiteConfigStack([null, undefined, 'x', 3]), 0)
|
||||
})
|
||||
|
||||
test('sitePurge: the deprecation message names variables, never a value', () => {
|
||||
assert.equal(sitePurgeDeprecationMessage([]), '')
|
||||
const token = sitePurgeDeprecationMessage(['token'])
|
||||
assert.match(token, /^\[site-cache\] /)
|
||||
assert.ok(token.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN'))
|
||||
assert.ok(!token.includes('NUXT_SITE_CACHE_ALLOW_PROXIED'))
|
||||
assert.match(token, /rotate/)
|
||||
assert.ok(!token.includes('\n'))
|
||||
const both = sitePurgeDeprecationMessage(['token', 'allowProxied'])
|
||||
assert.ok(both.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN'))
|
||||
assert.ok(both.includes('NUXT_SITE_CACHE_ALLOW_PROXIED -> NUXT_CACHE_PURGE_ALLOW_PROXIED'))
|
||||
assert.ok(sitePurgeDeprecationMessage(['allowProxied']).includes('NUXT_CACHE_PURGE_ALLOW_PROXIED'))
|
||||
})
|
||||
|
||||
type SiteConfigHook = (ctx: unknown) => void
|
||||
|
||||
/** Runs the shared Nitro plugin on a fake nitroApp with the given runtime config. */
|
||||
async function runPlugin(config: unknown) {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.defineNitroPlugin = (fn: unknown) => fn
|
||||
g.useRuntimeConfig = () => config
|
||||
const hooks: Record<string, SiteConfigHook[]> = {}
|
||||
const nitroApp = {
|
||||
hooks: {
|
||||
hook(name: string, fn: SiteConfigHook) {
|
||||
;(hooks[name] ??= []).push(fn)
|
||||
},
|
||||
},
|
||||
}
|
||||
const warnings: string[] = []
|
||||
const warn = console.warn
|
||||
console.warn = (line: string) => {
|
||||
warnings.push(line)
|
||||
}
|
||||
try {
|
||||
const plugin = (await import('../server/plugins/site-cache-purge.ts')).default as (app: unknown) => void
|
||||
plugin(nitroApp)
|
||||
} finally {
|
||||
console.warn = warn
|
||||
}
|
||||
return { hooks, warnings }
|
||||
}
|
||||
|
||||
function resetWarned() {
|
||||
delete (globalThis as Record<symbol, unknown>)[SITE_PURGE_WARNED]
|
||||
}
|
||||
|
||||
test('sitePurge: plugin strips the purge keys on site-config:init', async () => {
|
||||
resetWarned()
|
||||
const { hooks, warnings } = await runPlugin({ cachePurge: { token: TOKEN } })
|
||||
assert.equal(hooks['site-config:init']?.length, 1)
|
||||
assert.deepEqual(warnings, [])
|
||||
const stack = [{ url: 'https://acme.test', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' }, { _context: 'x' }]
|
||||
hooks['site-config:init'][0]({ siteConfig: { stack }, event: {} })
|
||||
assert.deepEqual(stack, [{ url: 'https://acme.test' }, { _context: 'x' }])
|
||||
for (const ctx of [undefined, null, {}, { siteConfig: {} }, { siteConfig: { stack: 'x' } }]) {
|
||||
hooks['site-config:init'][0](ctx)
|
||||
}
|
||||
resetWarned()
|
||||
})
|
||||
|
||||
test('sitePurge: plugin warns once per process about a deprecated name, without the value', async () => {
|
||||
resetWarned()
|
||||
const config = { siteCache: { purgeToken: TOKEN, allowProxied: 'true' } }
|
||||
const first = await runPlugin(config)
|
||||
assert.equal(first.warnings.length, 1)
|
||||
assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_TOKEN'))
|
||||
assert.ok(first.warnings[0].includes('NUXT_SITE_CACHE_PURGE_TOKEN'))
|
||||
assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_ALLOW_PROXIED'))
|
||||
assert.ok(!first.warnings[0].includes(TOKEN))
|
||||
// Another layer's copy of the plugin in the same process stays quiet.
|
||||
const second = await runPlugin(config)
|
||||
assert.deepEqual(second.warnings, [])
|
||||
assert.equal(second.hooks['site-config:init']?.length, 1)
|
||||
resetWarned()
|
||||
|
||||
const fresh = await runPlugin({ cachePurge: { token: TOKEN }, siteCache: { purgeToken: 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) } })
|
||||
assert.deepEqual(fresh.warnings, [], 'the new name wins: no warning')
|
||||
const none = await runPlugin({})
|
||||
assert.deepEqual(none.warnings, [])
|
||||
resetWarned()
|
||||
})
|
||||
Reference in New Issue
Block a user