Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25e9bfe8da | |||
| 03a90e952d | |||
| 4f55511ece | |||
| 0a01bfeae4 | |||
| a03e076c62 | |||
| 782aa4da7d | |||
| 759e684e86 | |||
| a6ee443885 | |||
| 294a8d8b19 | |||
| 5beb1c5634 | |||
| 3275a267f6 | |||
| b2069e10bd | |||
| a4dca42d77 | |||
| 783426c5d8 | |||
| c408eeb2ca |
@@ -3,6 +3,10 @@
|
||||
export default defineNuxtConfig({
|
||||
extends: ['..'],
|
||||
compatibilityDate: '2025-07-15',
|
||||
site: { url: 'https://acme.test', name: 'Acme Blog' },
|
||||
// Route-cached like a real host, so testing/purge-check.mjs can prove a
|
||||
// purge clears cached pages.
|
||||
routeRules: { '/**': { swr: 300 } },
|
||||
i18n: {
|
||||
strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
|
||||
205
README.md
Normal file
205
README.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# vue-journal-layer
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
The layer is consumed with `extends: ['vue-journal-layer']`. It reads content only from the anonymous GET routes of the Journal API (`/_journal/api/v1`) and never calls a write route. Pages render on the server (SSR), so every URL answers with full HTML.
|
||||
|
||||
The layer is neutral: it ships no site colours and no site copy. Its look comes from `--journal-*` CSS custom properties, which an app overrides, and every component can be replaced by a file of the same name in the app.
|
||||
|
||||
Route paths are configuration, not files. The layer's page components live in `app/journal-pages/` and a local module registers them at the paths in `journal.routes`, so an app moves the blog to any URL shape, or switches a view off, without copying pages. Route names stay stable, and links are always built from the names.
|
||||
|
||||
## Features
|
||||
|
||||
- **Post list** (`journal-index`): paginated with `?page=N`. A non-numeric page or a page past the last one answers 404.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **Embeds**: YouTube videos added in the Journal Blog Editor render as privacy-enhanced iframes (see [Embeds](#embeds)).
|
||||
- **i18n**: English and Polish UI strings, registered only for the locales the app enables.
|
||||
|
||||
## Embeds
|
||||
|
||||
The Journal Blog Editor never stores an iframe. It stores a placeholder:
|
||||
|
||||
```html
|
||||
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
|
||||
```
|
||||
|
||||
`expandEmbeds(html)` in `app/utils/embeds.ts` turns it into:
|
||||
|
||||
```html
|
||||
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="YouTube video" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe></figure>
|
||||
```
|
||||
|
||||
- **Allowlist:** `EMBED_PROVIDERS` holds one provider, `youtube`, whose id must match `^[A-Za-z0-9_-]{11}$`. The iframe `src` is always `https://www.youtube-nocookie.com/embed/` plus that id. `EMBED_PLACEHOLDER` matches the placeholder exactly as the editor writes it (`data-embed` then `data-id`, double quotes, empty figure). Any other figure, provider or id is left unchanged and renders as an empty figure.
|
||||
- **Order:** sanitize first, expand last. `JournalPostBody` renders `expandEmbeds(sanitizeJournalHtml(html))` and `JournalCategoryHeader` does the same with the category content. The payload transforms (`toPostView`, the category page) only sanitize, so the payload carries the placeholder. The expansion's output is never sanitized again, because the sanitizer removes every iframe, including this one. A raw `<iframe>` in stored HTML is removed by the sanitizer before the expansion runs, so the only iframe on a page is one built from a validated placeholder.
|
||||
- **Styles:** `.journal-embed` is a full-width block with a 16 / 9 aspect ratio; its iframe fills it without a border. `.journal-embed--youtube` marks the provider for app styles.
|
||||
- **Overrides:** an app that replaces `JournalPostBody` or `JournalCategoryHeader` and wants embeds calls `expandEmbeds` on the sanitized string, never before sanitizing.
|
||||
- **Content-Security-Policy:** a site that sends a CSP needs `frame-src https://www.youtube-nocookie.com`.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts of the app
|
||||
export default defineNuxtConfig({
|
||||
extends: ['vue-journal-layer'],
|
||||
|
||||
site: { url: 'https://blog.acme.test', name: 'Acme Blog' },
|
||||
|
||||
i18n: {
|
||||
strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
locales: [{ code: 'en', language: 'en-US' }],
|
||||
},
|
||||
|
||||
journal: {
|
||||
routes: {
|
||||
index: '/blog',
|
||||
post: '/blog/:slug',
|
||||
category: '/:category', // category pages at the root
|
||||
author: '/authors/:author',
|
||||
tag: false, // no tag pages
|
||||
},
|
||||
perPage: { index: 12, category: 10 },
|
||||
},
|
||||
|
||||
// SSR cache, so a new post shows up without a rebuild.
|
||||
routeRules: { '/**': { swr: 300 } },
|
||||
})
|
||||
```
|
||||
|
||||
The layer declares `@nuxt/fonts`, `@nuxtjs/i18n`, `@nuxtjs/seo` and `sanitize-html` itself. Do not list them again in the app; if the app must, pin the same exact versions. Static app pages such as `/about` take precedence over a `/:category` route.
|
||||
|
||||
Run the built app with `node .output/server/index.mjs` next to the SummerCMS application and point `NUXT_JOURNAL_API_BASE` at it.
|
||||
|
||||
## Configuration
|
||||
|
||||
Options under the `journal` key of `nuxt.config`:
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `journal.routes.index` | `/blog` | Post list path, or `false` |
|
||||
| `journal.routes.post` | `/blog/:slug` | Post path; must contain `:slug`, or `false` |
|
||||
| `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.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.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` |
|
||||
|
||||
A route path must be `false` or start with `/`; a missing param fails the build with an error naming `journal.routes.<key>`.
|
||||
|
||||
Runtime config, overridable by environment variables at start:
|
||||
|
||||
| Key | Env | Default | Meaning |
|
||||
|-----|-----|---------|---------|
|
||||
| `runtimeConfig.journal.apiBase` | `NUXT_JOURNAL_API_BASE` | `http://127.0.0.1:8096/_journal/api/v1` | Server-side API base for SSR, the sitemap and RSS. Use the internal address of the SummerCMS application, not its public URL. |
|
||||
| `runtimeConfig.public.journal.apiBase` | `NUXT_PUBLIC_JOURNAL_API_BASE` | `/_journal/api/v1` | Browser API base for client-side navigation; the reverse proxy routes it to the application. |
|
||||
| `runtimeConfig.journal.cachePurgeToken` | `NUXT_JOURNAL_CACHE_PURGE_TOKEN` | empty | Server-only bearer token of the [site cache purge](#site-cache-purge) route. With fewer than 32 characters the route answers 404. |
|
||||
|
||||
The module also copies `routes`, `perPage`, `sort`, `relatedCount` and `rss` into `runtimeConfig.public.journal`.
|
||||
|
||||
Server-side requests forward the visitor's `x-forwarded-for` and append the address of the peer that reached Nuxt. The Journal API rate-limits anonymous reads per client address; with a trusted proxy configured for the Nuxt server's address, each visitor keeps their own bucket instead of all SSR traffic sharing one. Under route caching (`swr` or `cache` route rules), Nitro renders a page from a copy of the request that carries no headers, so the layer's Nitro plugin `server/plugins/journal-client-address.ts` records the address in `event.context.journalForwardedFor` on the request hook, and `useJournalApi`, `/rss.xml` and the sitemap source forward that value. The API base always comes from runtime config, never from request input.
|
||||
|
||||
## Site cache purge
|
||||
|
||||
An app that caches its pages with Nitro route rules (`swr` or `cache`) would otherwise serve a page rendered before a Journal edit until the cache entry expires. The layer adds a purge route that the SummerCMS Journal plugin calls after an admin save of a post or category commits (its `golem15.journal.site_cache.purge_url` and `purge_token` settings), so the edit shows on the next page view.
|
||||
|
||||
**Request.** `POST /api/__journal__/cache/purge` with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"post"|"category","slug":"<slug>"}`. The answer is `200` with `{"purged":<n>}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[journal] cache purge: removed <n> route cache entries (<kind> <slug>)`; the slug loses control characters and is cut to 200 characters first.
|
||||
|
||||
**Guards.**
|
||||
|
||||
- POST only.
|
||||
- `404` when `runtimeConfig.journal.cachePurgeToken` holds fewer than 32 characters, so an app without a token has no purge route.
|
||||
- `404` for any request that carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the route is for a caller on the same host, never for traffic through the reverse proxy.
|
||||
- `401` for a missing or wrong bearer token. The comparison runs in constant time over sha256 digests.
|
||||
- The layer's `routeRules` set `'/api/__journal__/**': { cache: false }`. Nitro wraps every handler that matches an app rule such as `'/**': { swr }` in its cached handler, which keys by path, does not skip POST and hides the request headers from the handler, so without this rule the route would answer from the cache.
|
||||
|
||||
**What it removes.** Every entry of Nitro's route cache (the `nitro:routes` group of the `cache` storage): pages, their `_payload.json` and feeds. Other cache groups stay. One save can change many pages (the menu lists every category on every page, and the home, category, author and related-post lists show posts), and Nitro keys entries by a hash of the path, so a full purge is the reliable choice; it costs one fresh render per page on its next view.
|
||||
|
||||
**Browser revalidation.** Nitro answers a route-cached page with `s-maxage=<n>, stale-while-revalidate`, which gives browsers no `max-age`, so they guess a freshness lifetime from `Last-Modified`; its 304 answer carries `public, max-age=<n>, s-maxage=<n>`. Either way a browser could keep its copy past a purge. The layer's Nitro plugin `server/plugins/journal-revalidate.ts` rewrites every `cache-control` value that carries `s-maxage` to `max-age=0` for browsers (`privateRevalidate` in `app/utils/siteCache.ts`), so a browser asks on each view and gets a cheap 304 while the page is unchanged. Shared caches keep `s-maxage`. Static assets carry no `s-maxage` and are untouched.
|
||||
|
||||
**Deployment.**
|
||||
|
||||
- Generate the token on the server, at least 32 characters, and set the same value in the Nuxt app's `NUXT_JOURNAL_CACHE_PURGE_TOKEN` and in the backend's purge token setting. Keep it out of committed files.
|
||||
- The backend calls the route on the Nuxt server's loopback address (for example `http://127.0.0.1:3000/api/__journal__/cache/purge`), not through the public site.
|
||||
- The reverse proxy must not forward POST to Nuxt (for example nginx `limit_except GET HEAD { deny all; }`), so the route stays off the public path even before its own checks.
|
||||
- The purge clears the cache storage of the Nuxt process that answers. A server with several Nuxt processes needs a shared storage driver for the `cache` mount, or every process keeps its own entries.
|
||||
|
||||
## Theming
|
||||
|
||||
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`
|
||||
- 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`
|
||||
- Layout: `--journal-content-width`, `--journal-wide-width`, `--journal-card-min`, `--journal-image-ratio`
|
||||
|
||||
Override tokens in the app's CSS. The app's `css` entries load after the layer's; a `:root:root` selector wins regardless of order:
|
||||
|
||||
```css
|
||||
:root:root {
|
||||
--journal-color-link: #b4232a;
|
||||
--journal-font-heading: 'Acme Serif', serif;
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## i18n
|
||||
|
||||
The layer ships `i18n/locales/en.json` and `pl.json` under the `journal.*` key tree. It declares no locales itself: its module registers a string file only for a locale the app enables, so an app that enables only `pl` gets no `/en` routes. An app that enables another locale adds its own `journal.*` strings in its locale file. Dates are formatted with `Intl.DateTimeFormat` for the active locale's `language`, in UTC.
|
||||
|
||||
## SEO and sitemap
|
||||
|
||||
Set `site.url` and `site.name` in the app: titles take the form `<page title> | <site.name>`, and canonical and Open Graph URLs are absolute against `site.url`. Canonical URLs keep only the `page` query parameter (`seo.canonicalQueryWhitelist`).
|
||||
|
||||
`sitemap.xml` includes the app's static pages plus a dynamic source at `/api/__sitemap__/journal`, which pages through the posts (30 per request) and adds every category, building each URL from `journal.routes`. A disabled view adds no URLs.
|
||||
|
||||
The feed's item links come from the Journal API, which links posts under `/blog/<slug>` on the application's configured URL. An app that moves `journal.routes.post` away from `/blog/:slug` should keep that in mind.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # the .playground app, extending this layer
|
||||
pnpm build:playground # production build of the playground
|
||||
```
|
||||
|
||||
To work on the layer inside an app, link it instead of installing from git: `pnpm link ../vue-journal-layer`, or an `overrides` entry `vue-journal-layer: link:../vue-journal-layer` in `pnpm-workspace.yaml`. Never commit the link; the app's committed dependency stays the git tag.
|
||||
|
||||
## Releasing
|
||||
|
||||
A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.com. Apps pin it in `package.json` as `git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>`, and `pnpm-lock.yaml` records the tag's commit. To upgrade, change the tag and run `pnpm install`. The repository is public, so installing needs no credentials. The layer has no install-time build or `prepare` script, which a git-hosted dependency would need to run during `pnpm install`.
|
||||
|
||||
## Testing
|
||||
|
||||
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (`tests/siteCache.test.ts`: authorization, the route-cache purge, the purge body and browser revalidation), and pl/en key and placeholder parity. They need no build, network or database.
|
||||
- `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/purge-check.mjs --output <.output> --fixtures <dir> --kind post|category --slug <slug> --path <page path>` proves the [site cache purge](#site-cache-purge) on a built server whose route rules cache pages: it caches the page, changes the entity in a temporary copy of the fixtures, requires the cached page to stay unchanged, purges with a random token, and requires the change on the next request. It also checks `max-age=0` on the page and its 304, the purged `_payload.json`, a second purge after re-caching, `401` without or with a wrong token, `404` behind a proxy and `404` on a server without a token. The last line is `purge-check: ok (<n> checks)`.
|
||||
- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`, and `reload()` re-reads them; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server. Apps can import both from `node_modules/vue-journal-layer/testing/`.
|
||||
|
||||
```bash
|
||||
pnpm build:playground
|
||||
node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt
|
||||
node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme
|
||||
```
|
||||
|
||||
The playground caches every page with `routeRules: { '/**': { swr: 300 } }`, like a real host.
|
||||
289
app/assets/css/journal-base.css
Normal file
289
app/assets/css/journal-base.css
Normal file
@@ -0,0 +1,289 @@
|
||||
/* Neutral Journal styles. Only --journal-* tokens; no site colours. */
|
||||
|
||||
.journal-layout {
|
||||
box-sizing: border-box;
|
||||
max-width: var(--journal-wide-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--journal-space-5) var(--journal-space-4) var(--journal-space-6);
|
||||
color: var(--journal-color-text);
|
||||
background: var(--journal-color-bg);
|
||||
font-family: var(--journal-font-body);
|
||||
font-size: var(--journal-font-size-base);
|
||||
line-height: var(--journal-line-height);
|
||||
}
|
||||
|
||||
.journal-layout a {
|
||||
color: var(--journal-color-link);
|
||||
}
|
||||
|
||||
.journal-layout a:hover {
|
||||
color: var(--journal-color-link-hover);
|
||||
}
|
||||
|
||||
.journal-layout a:focus-visible {
|
||||
outline: 2px solid var(--journal-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.journal-layout h1,
|
||||
.journal-layout h2,
|
||||
.journal-layout h3 {
|
||||
font-family: var(--journal-font-heading);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
.journal-index-header,
|
||||
.journal-category-header {
|
||||
margin-bottom: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-index-header__title,
|
||||
.journal-category-header__title {
|
||||
margin: 0 0 var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-category-header__description {
|
||||
margin: 0 0 var(--journal-space-4);
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
.journal-category-header__content {
|
||||
max-width: var(--journal-content-width);
|
||||
}
|
||||
|
||||
/* Post page */
|
||||
.journal-post {
|
||||
max-width: var(--journal-content-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.journal-post__title {
|
||||
margin: 0 0 var(--journal-space-3);
|
||||
}
|
||||
|
||||
.journal-post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--journal-space-2) var(--journal-space-3);
|
||||
margin-bottom: var(--journal-space-4);
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.journal-post-meta__categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--journal-space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.journal-featured-image {
|
||||
margin: 0 0 var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-featured-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: var(--journal-image-ratio);
|
||||
object-fit: cover;
|
||||
border-radius: var(--journal-radius-lg);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post__siblings {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--journal-space-3);
|
||||
margin: var(--journal-space-5) 0;
|
||||
padding-top: var(--journal-space-4);
|
||||
border-top: 1px solid var(--journal-color-border);
|
||||
}
|
||||
|
||||
.journal-post__siblings span {
|
||||
display: block;
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.journal-post__next {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Rendered HTML bodies (posts and category content) */
|
||||
.journal-post-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.journal-post-body img,
|
||||
.journal-post-body video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--journal-radius-md);
|
||||
}
|
||||
|
||||
.journal-post-body ul,
|
||||
.journal-post-body ol {
|
||||
padding-left: var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-post-body blockquote {
|
||||
margin: var(--journal-space-4) 0;
|
||||
padding: var(--journal-space-3) var(--journal-space-4);
|
||||
border-left: 4px solid var(--journal-color-border);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post-body code {
|
||||
padding: 0 var(--journal-space-1);
|
||||
border-radius: var(--journal-radius-sm);
|
||||
background: var(--journal-color-surface);
|
||||
font-family: var(--journal-font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.journal-post-body pre {
|
||||
overflow-x: auto;
|
||||
padding: var(--journal-space-3);
|
||||
border-radius: var(--journal-radius-md);
|
||||
background: var(--journal-color-surface);
|
||||
}
|
||||
|
||||
.journal-post-body pre code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.journal-post-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: var(--journal-space-4) 0;
|
||||
}
|
||||
|
||||
.journal-post-body th,
|
||||
.journal-post-body td {
|
||||
padding: var(--journal-space-2) var(--journal-space-3);
|
||||
border: 1px solid var(--journal-color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Embeds expanded from the Blog Editor's placeholders (app/utils/embeds.ts) */
|
||||
.journal-embed {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: var(--journal-space-4) 0;
|
||||
}
|
||||
|
||||
.journal-embed iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Listings */
|
||||
.journal-post-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--journal-card-min), 1fr));
|
||||
gap: var(--journal-space-4);
|
||||
}
|
||||
|
||||
.journal-post-list__empty {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--journal-color-muted);
|
||||
}
|
||||
|
||||
.journal-post-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--journal-space-2);
|
||||
padding: var(--journal-space-4);
|
||||
border: 1px solid var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-md);
|
||||
background: var(--journal-color-bg);
|
||||
}
|
||||
|
||||
.journal-post-card .journal-featured-image {
|
||||
margin: calc(-1 * var(--journal-space-4)) calc(-1 * var(--journal-space-4)) var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-post-card .journal-featured-image img {
|
||||
border-radius: var(--journal-radius-md) var(--journal-radius-md) 0 0;
|
||||
}
|
||||
|
||||
.journal-post-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.journal-post-card__title a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.journal-post-card__date {
|
||||
color: var(--journal-color-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.journal-post-card__excerpt {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.journal-post-card__more {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.journal-related {
|
||||
margin-top: var(--journal-space-5);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.journal-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--journal-space-3);
|
||||
margin-top: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-pagination__pages {
|
||||
display: flex;
|
||||
gap: var(--journal-space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.journal-pagination__pages a,
|
||||
.journal-pagination__current {
|
||||
display: inline-block;
|
||||
min-width: 2.25em;
|
||||
padding: var(--journal-space-1) var(--journal-space-2);
|
||||
border-radius: var(--journal-radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.journal-pagination__current {
|
||||
background: var(--journal-color-accent);
|
||||
color: var(--journal-color-on-accent);
|
||||
}
|
||||
|
||||
/* Author */
|
||||
.journal-author-card {
|
||||
margin-bottom: var(--journal-space-5);
|
||||
}
|
||||
|
||||
.journal-author-card__name {
|
||||
margin: 0 0 var(--journal-space-2);
|
||||
}
|
||||
|
||||
.journal-rss-link {
|
||||
display: inline-block;
|
||||
margin-top: var(--journal-space-4);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
40
app/assets/css/journal-tokens.css
Normal file
40
app/assets/css/journal-tokens.css
Normal file
@@ -0,0 +1,40 @@
|
||||
/* Journal design tokens. Every visual value in the layer comes from these.
|
||||
An app overrides any of them in its own CSS, e.g. on :root:root. */
|
||||
:root {
|
||||
/* Colour */
|
||||
--journal-color-text: #1f2328; /* body text */
|
||||
--journal-color-muted: #59636e; /* dates, meta, captions */
|
||||
--journal-color-bg: #ffffff; /* page background */
|
||||
--journal-color-surface: #f6f8fa; /* cards, quotes, code blocks */
|
||||
--journal-color-border: #d1d9e0; /* card and table borders, rules */
|
||||
--journal-color-link: #0969da; /* links */
|
||||
--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 */
|
||||
|
||||
/* Type */
|
||||
--journal-font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* body copy */
|
||||
--journal-font-heading: var(--journal-font-body); /* headings */
|
||||
--journal-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; /* code */
|
||||
--journal-font-size-base: 1.0625rem; /* body size */
|
||||
--journal-line-height: 1.7; /* body leading */
|
||||
|
||||
/* Spacing scale */
|
||||
--journal-space-1: 0.25rem; /* hairline gaps */
|
||||
--journal-space-2: 0.5rem; /* inline gaps */
|
||||
--journal-space-3: 1rem; /* paragraph gap */
|
||||
--journal-space-4: 1.5rem; /* card padding */
|
||||
--journal-space-5: 2.5rem; /* section gap */
|
||||
--journal-space-6: 4rem; /* page padding */
|
||||
|
||||
/* Radii */
|
||||
--journal-radius-sm: 4px; /* code, tags */
|
||||
--journal-radius-md: 8px; /* cards, images */
|
||||
--journal-radius-lg: 16px; /* featured image */
|
||||
|
||||
/* Layout */
|
||||
--journal-content-width: 46rem; /* reading column */
|
||||
--journal-wide-width: 72rem; /* listings */
|
||||
--journal-card-min: 16rem; /* narrowest card in a grid */
|
||||
--journal-image-ratio: 1200 / 630; /* featured image crop */
|
||||
}
|
||||
18
app/components/Journal/AuthorCard.vue
Normal file
18
app/components/Journal/AuthorCard.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// The author heading of an author page: the author's name from the public
|
||||
// author endpoint. An app adds profile details by overriding this component
|
||||
// or filling the default slot.
|
||||
import type { JournalAuthor } from '../../utils/types.ts'
|
||||
|
||||
const props = defineProps<{ author: JournalAuthor; page?: number }>()
|
||||
const name = computed(() => `${props.author.first_name} ${props.author.last_name}`.trim() || props.author.slug)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="journal-author-card">
|
||||
<h1 class="journal-author-card__name">
|
||||
{{ $t('journal.author.title', { name }) }}<template v-if="page && page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
|
||||
</h1>
|
||||
<slot />
|
||||
</header>
|
||||
</template>
|
||||
22
app/components/Journal/CategoryHeader.vue
Normal file
22
app/components/Journal/CategoryHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// Heading of a category page: name, description and the category's rich
|
||||
// content, which goes through the Journal sanitizer before v-html. The page
|
||||
// payload keeps embed placeholders; expandEmbeds turns them into iframes as
|
||||
// the last step, after sanitizing, and its output is never sanitized again.
|
||||
import type { JournalCategory } from '../../utils/types.ts'
|
||||
import { expandEmbeds } from '../../utils/embeds.ts'
|
||||
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
|
||||
|
||||
const props = defineProps<{ category: JournalCategory; page?: number }>()
|
||||
const content = computed(() => expandEmbeds(sanitizeJournalHtml(props.category.content)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="journal-category-header">
|
||||
<h1 class="journal-category-header__title">
|
||||
{{ category.name }}<template v-if="page && page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
|
||||
</h1>
|
||||
<p v-if="category.description" class="journal-category-header__description">{{ category.description }}</p>
|
||||
<div v-if="content" class="journal-category-header__content journal-post-body" v-html="content" />
|
||||
</header>
|
||||
</template>
|
||||
11
app/components/Journal/FeaturedImage.vue
Normal file
11
app/components/Journal/FeaturedImage.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// A post's featured image. The intrinsic width/height reserve space before
|
||||
// the image loads; the rendered size and crop come from --journal-image-ratio.
|
||||
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure v-if="src" class="journal-featured-image">
|
||||
<img :src="src" :alt="alt" width="1200" height="630" :loading="eager ? 'eager' : 'lazy'" decoding="async" />
|
||||
</figure>
|
||||
</template>
|
||||
12
app/components/Journal/IndexHeader.vue
Normal file
12
app/components/Journal/IndexHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// Heading of the post index; pages after the first say which page they are.
|
||||
defineProps<{ page: number }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="journal-index-header">
|
||||
<h1 class="journal-index-header__title">
|
||||
{{ $t('journal.index.title') }}<template v-if="page > 1"> · {{ $t('journal.pagination.pageTitle', { page }) }}</template>
|
||||
</h1>
|
||||
</header>
|
||||
</template>
|
||||
33
app/components/Journal/Pagination.vue
Normal file
33
app/components/Journal/Pagination.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
// Previous/next and a window of page links for the current listing. Links
|
||||
// keep the current path and use ?page=N; page 1 drops the query.
|
||||
import { paginationWindow } from '../../utils/pagination.ts'
|
||||
|
||||
const props = defineProps<{ current: number; last: number }>()
|
||||
const route = useRoute()
|
||||
|
||||
const to = (page: number) => {
|
||||
const query = { ...route.query }
|
||||
if (page > 1) query.page = String(page)
|
||||
else delete query.page
|
||||
return { path: route.path, query }
|
||||
}
|
||||
const pages = computed(() => paginationWindow(props.current, props.last))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="last > 1" class="journal-pagination" :aria-label="$t('journal.pagination.label')">
|
||||
<NuxtLink v-if="current > 1" :to="to(current - 1)" rel="prev" class="journal-pagination__prev">
|
||||
{{ $t('journal.pagination.previous') }}
|
||||
</NuxtLink>
|
||||
<ol class="journal-pagination__pages">
|
||||
<li v-for="page in pages" :key="page">
|
||||
<span v-if="page === current" class="journal-pagination__current" aria-current="page">{{ page }}</span>
|
||||
<NuxtLink v-else :to="to(page)">{{ page }}</NuxtLink>
|
||||
</li>
|
||||
</ol>
|
||||
<NuxtLink v-if="current < last" :to="to(current + 1)" rel="next" class="journal-pagination__next">
|
||||
{{ $t('journal.pagination.next') }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,13 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// The post body. v-html only ever receives HTML that passed the Journal gate;
|
||||
// a blocked or unsafe body renders a neutral notice instead.
|
||||
import { gateJournalHtml } from '../../utils/htmlGate.ts'
|
||||
// The post body. v-html only ever receives HTML that went through the Journal
|
||||
// sanitizer, which removes what the policy does not allow and keeps the rest.
|
||||
// The page passes an already sanitized body (with any embed placeholder still
|
||||
// a placeholder); sanitizing again here guards a caller that does not, and
|
||||
// gives the same string on server and client. expandEmbeds runs last: it
|
||||
// turns the Blog Editor's embed placeholders into privacy-enhanced iframes,
|
||||
// and its output is never sanitized again (the sanitizer would strip them).
|
||||
import { expandEmbeds } from '../../utils/embeds.ts'
|
||||
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
|
||||
|
||||
const props = defineProps<{ html: string | null | undefined; blocked?: boolean }>()
|
||||
const gated = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(props.html)))
|
||||
const props = defineProps<{ html: string | null | undefined }>()
|
||||
const html = computed(() => expandEmbeds(sanitizeJournalHtml(props.html)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="gated.safe" class="journal-post-body" v-html="gated.html" />
|
||||
<p v-else class="journal-post-body journal-post-body--blocked">This content cannot be displayed.</p>
|
||||
<div class="journal-post-body" v-html="html" />
|
||||
</template>
|
||||
|
||||
26
app/components/Journal/PostCard.vue
Normal file
26
app/components/Journal/PostCard.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// One post in a listing: image, title link, date and excerpt.
|
||||
import type { JournalPost } from '../../utils/types.ts'
|
||||
import { formatJournalDate } from '../../utils/view.ts'
|
||||
|
||||
const props = defineProps<{ post: JournalPost }>()
|
||||
const { localeProperties } = useI18n()
|
||||
const links = useJournalLinks()
|
||||
const href = computed(() => links.postLink(props.post.slug))
|
||||
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="journal-post-card">
|
||||
<NuxtLink v-if="href && post.featured_image_url" :to="href" class="journal-post-card__image" tabindex="-1" aria-hidden="true">
|
||||
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" />
|
||||
</NuxtLink>
|
||||
<h2 class="journal-post-card__title">
|
||||
<NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink>
|
||||
<template v-else>{{ post.title }}</template>
|
||||
</h2>
|
||||
<time v-if="date" class="journal-post-card__date" :datetime="post.published_at ?? undefined">{{ date }}</time>
|
||||
<p v-if="post.excerpt" class="journal-post-card__excerpt">{{ post.excerpt }}</p>
|
||||
<NuxtLink v-if="href" :to="href" class="journal-post-card__more">{{ $t('journal.readMore') }}</NuxtLink>
|
||||
</article>
|
||||
</template>
|
||||
13
app/components/Journal/PostList.vue
Normal file
13
app/components/Journal/PostList.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// A grid of post cards, or the empty-state text when there are none.
|
||||
import type { JournalPost } from '../../utils/types.ts'
|
||||
|
||||
defineProps<{ posts: JournalPost[]; empty?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="journal-post-list">
|
||||
<JournalPostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||
<p v-if="posts.length === 0" class="journal-post-list__empty">{{ empty ?? $t('journal.index.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
39
app/components/Journal/PostMeta.vue
Normal file
39
app/components/Journal/PostMeta.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
// Date, reading time, categories and author of a post. Links go through
|
||||
// route names, so a disabled view renders its label as plain text.
|
||||
import type { JournalPost } from '../../utils/types.ts'
|
||||
import { formatJournalDate } from '../../utils/view.ts'
|
||||
|
||||
const props = defineProps<{ post: JournalPost }>()
|
||||
const { localeProperties } = useI18n()
|
||||
const links = useJournalLinks()
|
||||
|
||||
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
|
||||
const authorName = computed(() => {
|
||||
const a = props.post.author
|
||||
return a ? `${a.first_name} ${a.last_name}`.trim() || a.login : ''
|
||||
})
|
||||
const authorHref = computed(() => (props.post.author ? links.authorLink(props.post.author.login) : null))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="journal-post-meta">
|
||||
<time v-if="date" class="journal-post-meta__date" :datetime="post.published_at ?? undefined">
|
||||
{{ $t('journal.post.publishedOn', { date }) }}
|
||||
</time>
|
||||
<span v-if="post.reading_time > 0" class="journal-post-meta__reading">
|
||||
{{ $t('journal.post.readingTime', { minutes: post.reading_time }) }}
|
||||
</span>
|
||||
<span v-if="authorName" class="journal-post-meta__author">
|
||||
{{ $t('journal.post.by') }}
|
||||
<NuxtLink v-if="authorHref" :to="authorHref">{{ authorName }}</NuxtLink>
|
||||
<template v-else>{{ authorName }}</template>
|
||||
</span>
|
||||
<ul v-if="post.categories.length" class="journal-post-meta__categories">
|
||||
<li v-for="cat in post.categories" :key="cat.id">
|
||||
<NuxtLink v-if="links.categoryLink(cat.slug)" :to="links.categoryLink(cat.slug)!">{{ cat.name }}</NuxtLink>
|
||||
<span v-else>{{ cat.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
14
app/components/Journal/RelatedPosts.vue
Normal file
14
app/components/Journal/RelatedPosts.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// Related posts under a post. The page passes the API's related_posts, or
|
||||
// the latest posts when the post has no category.
|
||||
import type { JournalPost } from '../../utils/types.ts'
|
||||
|
||||
defineProps<{ posts: JournalPost[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="posts.length" class="journal-related">
|
||||
<h2 class="journal-related__title">{{ $t('journal.post.related') }}</h2>
|
||||
<JournalPostList :posts="posts" />
|
||||
</section>
|
||||
</template>
|
||||
16
app/components/Journal/RssLink.vue
Normal file
16
app/components/Journal/RssLink.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// Advertises /rss.xml in the head and shows a visible feed link. Renders
|
||||
// nothing when journal.rss is false.
|
||||
const rss = (useRuntimeConfig().public.journal as { rss?: boolean }).rss !== false
|
||||
const { t } = useI18n()
|
||||
|
||||
if (rss) {
|
||||
useHead({
|
||||
link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('journal.rss.title'), href: '/rss.xml', key: 'journal-rss' }],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="rss" href="/rss.xml" class="journal-rss-link" type="application/rss+xml">{{ $t('journal.rss.title') }}</a>
|
||||
</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,9 +12,13 @@ export function useJournalApi(): JournalClient {
|
||||
|
||||
if (import.meta.server) {
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
// Under route caching the request copy has no headers; the address the
|
||||
// layer's server/plugins/journal-client-address.ts recorded comes first.
|
||||
const event = useRequestEvent()
|
||||
const recorded = event?.context.journalForwardedFor as string | undefined
|
||||
const incoming = useRequestHeaders(['x-forwarded-for'])['x-forwarded-for']
|
||||
const peer = useRequestEvent()?.node?.req?.socket?.remoteAddress
|
||||
const forwarded = [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ')
|
||||
const peer = event?.node?.req?.socket?.remoteAddress
|
||||
const forwarded = recorded || [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ')
|
||||
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
|
||||
const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers })
|
||||
return createJournalClient(fetcher)
|
||||
|
||||
23
app/composables/useJournalLinks.ts
Normal file
23
app/composables/useJournalLinks.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Links to Journal views, built from stable route names through
|
||||
// useLocalePath() (i18n gives every route a per-locale name), never from
|
||||
// concatenated paths. A link to a disabled view (journal.routes.<key>: false)
|
||||
// is null; components then render plain text.
|
||||
|
||||
import { JOURNAL_ROUTE_NAMES } from '../utils/routes.ts'
|
||||
import type { JournalRoutes } from '../utils/types.ts'
|
||||
|
||||
export function useJournalLinks() {
|
||||
const localePath = useLocalePath()
|
||||
const routes = (useRuntimeConfig().public.journal as { routes: JournalRoutes }).routes
|
||||
|
||||
const link = (key: keyof JournalRoutes, params?: Record<string, string>, query?: Record<string, string>) =>
|
||||
routes[key] === false ? null : localePath({ name: JOURNAL_ROUTE_NAMES[key], params, query })
|
||||
|
||||
return {
|
||||
postLink: (slug: string) => link('post', { slug }),
|
||||
categoryLink: (slug: string) => link('category', { category: slug }),
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
// journal-author: an author and their posts; an unknown author is a 404.
|
||||
// journal-author: an author and their published posts, paginated with
|
||||
// ?page=N. {author} is the author slug, or a login with published posts. An
|
||||
// unknown author or an out-of-range page is a real 404. When the visitor came
|
||||
// in by login, the canonical points at the slug URL.
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
||||
import { absoluteUrl, 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 slug = String(route.params.author ?? '')
|
||||
const { data } = await useAsyncData(`journal-author-${slug}`, async () => {
|
||||
const author = await api.getAuthor(slug)
|
||||
const links = useJournalLinks()
|
||||
const site = useSiteConfig()
|
||||
const { t } = useI18n()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const param = String(route.params.author ?? '')
|
||||
|
||||
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-author-${param}-${page}`, async () => {
|
||||
const author = await api.getAuthor(param)
|
||||
if (!author) return null
|
||||
return { author, page: await api.listAuthorPosts(slug) }
|
||||
const posts = await api.listAuthorPosts(param, { page, perPage: cfg.perPage.author, sort: cfg.sort })
|
||||
if (!posts) return null
|
||||
return { author, posts: { ...posts, data: posts.data.map(toListPost) } }
|
||||
})
|
||||
if (!data.value) throw createError({ statusCode: 404, fatal: true })
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Author not found', fatal: true })
|
||||
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const author = data.value.author
|
||||
const name = `${author.first_name} ${author.last_name}`.trim() || author.slug
|
||||
const title = listTitle(t('journal.author.title', { name }), page, t('journal.pagination.pageTitle', { page }))
|
||||
useSeoMeta({ title, ogTitle: title, ogType: 'profile' })
|
||||
|
||||
if (author.slug !== param) {
|
||||
const path = links.authorLink(author.slug)
|
||||
if (path) {
|
||||
const href = absoluteUrl(page > 1 ? `${path}?page=${page}` : path, site.url || useRequestURL().origin)
|
||||
useHead({ link: [{ rel: 'canonical', href, key: 'canonical' }] })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="journal-author">
|
||||
<h1>{{ data.author.first_name }} {{ data.author.last_name }}</h1>
|
||||
<ul v-if="data.page">
|
||||
<li v-for="post in data.page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<JournalAuthorCard :author="data.author" :page="page" />
|
||||
<JournalPostList :posts="data.posts.data" :empty="$t('journal.author.empty')" />
|
||||
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
// journal-category: posts in one category; an unknown category is a 404.
|
||||
// journal-category: one category's header and rich content above its posts.
|
||||
// The slug resolves against GET /categories?include_empty=1, so an empty
|
||||
// category answers 200 with its content; an unknown slug is a real 404 (this
|
||||
// route may be a root-level catch-all in the app).
|
||||
import { sanitizeJournalHtml } from '../utils/htmlGate.ts'
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.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 cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const { t } = useI18n()
|
||||
const slug = String(route.params.category ?? '')
|
||||
const { data } = await useAsyncData(`journal-category-${slug}`, async () => {
|
||||
const category = await api.findCategory(slug)
|
||||
if (!category) return null
|
||||
return { category, page: await api.listPosts({ category: category.slug }) }
|
||||
|
||||
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-category-${slug}-${page}`, async () => {
|
||||
const found = await api.findCategory(slug)
|
||||
if (!found) return null
|
||||
const posts = await api.listPosts({ category: found.slug, page, perPage: cfg.perPage.category, sort: cfg.sort })
|
||||
// Only this category reaches the payload, with its content already sanitized.
|
||||
const { children: _children, ...category } = found
|
||||
return {
|
||||
category: { ...category, content: sanitizeJournalHtml(found.content) || null },
|
||||
posts: { ...posts, data: posts.data.map(toListPost) },
|
||||
}
|
||||
})
|
||||
if (!data.value) throw createError({ statusCode: 404, fatal: true })
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const title = listTitle(data.value.category.name, page, t('journal.pagination.pageTitle', { page }))
|
||||
const description = data.value.category.description ?? undefined
|
||||
useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="journal-category">
|
||||
<h1>{{ data.category.name }}</h1>
|
||||
<ul>
|
||||
<li v-for="post in data.page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<JournalCategoryHeader :category="data.category" :page="page" />
|
||||
<JournalPostList :posts="data.posts.data" :empty="$t('journal.category.empty')" />
|
||||
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// journal-index: the latest posts.
|
||||
// journal-index: the post list, paginated with ?page=N. A non-numeric page
|
||||
// or a page past the last one is a real 404.
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.ts'
|
||||
import { listTitle } from '../utils/seo.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
// Re-run setup (and its 404 checks) on every query change.
|
||||
definePageMeta({ key: (route) => route.fullPath })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const { data: page } = await useAsyncData('journal-index', () => api.listPosts({ perPage: 9 }))
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const { t } = useI18n()
|
||||
|
||||
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-index-${page}`,
|
||||
() => api.listPosts({ page, perPage: cfg.perPage.index, sort: cfg.sort }),
|
||||
{ transform: (res) => ({ ...res, data: res.data.map(toListPost) }) },
|
||||
)
|
||||
if (error.value || !data.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number } | undefined)?.statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (pageOutOfRange(page, data.value.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const title = listTitle(t('journal.index.title'), page, t('journal.pagination.pageTitle', { page }))
|
||||
useSeoMeta({ title, ogTitle: title })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="journal-index">
|
||||
<ul v-if="page">
|
||||
<li v-for="post in page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<section v-if="data" class="journal-index">
|
||||
<JournalIndexHeader :page="page" />
|
||||
<JournalPostList :posts="data.data" />
|
||||
<JournalPagination :current="data.meta.current_page" :last="data.meta.last_page" />
|
||||
<JournalRssLink />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
// journal-post: one published post by slug. An unknown, draft or future post
|
||||
// is a real 404. The transform gates the body before it reaches the payload.
|
||||
import { toPostView } from '../utils/view.ts'
|
||||
// is a real 404. The transform sanitizes the body before it reaches the payload.
|
||||
// Related posts fall back to the latest posts when the API has none (it
|
||||
// returns none for a post without a category).
|
||||
import { postSeoMeta } from '../utils/seo.ts'
|
||||
import { toPostView } from '../utils/postView.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const api = useJournalApi()
|
||||
const links = useJournalLinks()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const slug = computed(() => String(route.params.slug ?? ''))
|
||||
|
||||
const { data: post, error } = await useAsyncData(
|
||||
() => `journal-post-${slug.value}`,
|
||||
() => api.getPost(slug.value),
|
||||
async () => {
|
||||
const found = await api.getPost(slug.value)
|
||||
if (!found) return null
|
||||
const count = Math.max(0, cfg.relatedCount)
|
||||
let related = found.related_posts ?? []
|
||||
if (related.length === 0 && count > 0) {
|
||||
const latest = await api.listPosts({ perPage: count + 1, sort: cfg.sort })
|
||||
related = latest.data.filter((p) => p.slug !== found.slug)
|
||||
}
|
||||
return { ...found, related_posts: related.slice(0, count).map(toListPost) }
|
||||
},
|
||||
{ transform: (p) => (p ? toPostView(p) : null) },
|
||||
)
|
||||
|
||||
@@ -19,11 +36,37 @@ if (error.value) {
|
||||
if (!post.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
|
||||
}
|
||||
|
||||
useSeoMeta(postSeoMeta(post.value, useSiteConfig().url || useRequestURL().origin))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="post" class="journal-post">
|
||||
<h1 class="journal-post__title">{{ post.title }}</h1>
|
||||
<JournalPostBody :html="post.content_html" :blocked="post.content_blocked" />
|
||||
<header class="journal-post__header">
|
||||
<h1 class="journal-post__title">{{ post.title }}</h1>
|
||||
<JournalPostMeta :post="post" />
|
||||
</header>
|
||||
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
|
||||
<JournalPostBody :html="post.content_html" />
|
||||
<nav v-if="post.previous_post || post.next_post" class="journal-post__siblings">
|
||||
<NuxtLink
|
||||
v-if="post.previous_post && links.postLink(post.previous_post.slug)"
|
||||
:to="links.postLink(post.previous_post.slug)!"
|
||||
rel="prev"
|
||||
class="journal-post__previous"
|
||||
>
|
||||
<span>{{ $t('journal.post.previous') }}</span> {{ post.previous_post.title }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="post.next_post && links.postLink(post.next_post.slug)"
|
||||
:to="links.postLink(post.next_post.slug)!"
|
||||
rel="next"
|
||||
class="journal-post__next"
|
||||
>
|
||||
<span>{{ $t('journal.post.next') }}</span> {{ post.next_post.title }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<JournalRelatedPosts :posts="post.related_posts" />
|
||||
<JournalRssLink />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
// journal-tag: posts with one tag; an unknown tag is a 404.
|
||||
// journal-tag: posts with one tag, paginated with ?page=N. An unknown tag or
|
||||
// an out-of-range page is a real 404.
|
||||
import { pageOutOfRange, parsePageParam } from '../utils/pagination.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 } = useI18n()
|
||||
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
|
||||
const slug = String(route.params.tag ?? '')
|
||||
const { data } = await useAsyncData(`journal-tag-${slug}`, async () => {
|
||||
const tag = (await api.listTags()).find((t) => t.slug === slug)
|
||||
|
||||
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-tag-${slug}-${page}`, async () => {
|
||||
const tag = (await api.listTags()).find((row) => row.slug === slug)
|
||||
if (!tag) return null
|
||||
return { tag, page: await api.listPosts({ tag: tag.slug }) }
|
||||
const posts = await api.listPosts({ tag: tag.slug, page, perPage: cfg.perPage.tag, sort: cfg.sort })
|
||||
return { tag, posts: { ...posts, data: posts.data.map(toListPost) } }
|
||||
})
|
||||
if (!data.value) throw createError({ statusCode: 404, fatal: true })
|
||||
if (error.value) {
|
||||
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
|
||||
}
|
||||
if (!data.value) throw createError({ statusCode: 404, statusMessage: 'Tag not found', fatal: true })
|
||||
if (pageOutOfRange(page, data.value.posts.meta.last_page)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
const heading = t('journal.tag.title', { name: data.value.tag.name })
|
||||
const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page }))
|
||||
useSeoMeta({ title, ogTitle: title })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="journal-tag">
|
||||
<h1>{{ data.tag.name }}</h1>
|
||||
<ul>
|
||||
<li v-for="post in data.page.data" :key="post.id">{{ post.title }}</li>
|
||||
</ul>
|
||||
<header class="journal-index-header">
|
||||
<h1 class="journal-index-header__title">{{ title }}</h1>
|
||||
</header>
|
||||
<JournalPostList :posts="data.posts.data" :empty="$t('journal.tag.empty')" />
|
||||
<JournalPagination :current="data.posts.meta.current_page" :last="data.posts.meta.last_page" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
63
app/utils/embeds.ts
Normal file
63
app/utils/embeds.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Embed expansion for Journal HTML. The Blog Editor (Journal plugin,
|
||||
// assets/src/blogeditor/embed.ts) never stores an iframe: it writes the
|
||||
// placeholder <figure data-embed="youtube" data-id="ID"></figure>, and the
|
||||
// plugin's bluemonday policy (classes/format_html.go) keeps exactly that, with
|
||||
// data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$.
|
||||
// htmlGate.ts mirrors those two rules, so the placeholder survives the layer's
|
||||
// sanitizer too.
|
||||
//
|
||||
// expandEmbeds is the very last step before v-html, after sanitizeJournalHtml:
|
||||
// its input is always sanitizer output, and its output is never sanitized
|
||||
// again (the sanitizer strips every iframe, including the one built here). The
|
||||
// regex matches the serialization sanitize-html gives the placeholder:
|
||||
// attribute order kept, double quotes, empty figure. Only a known provider
|
||||
// with a valid id becomes an iframe, whose src is a fixed host plus that id;
|
||||
// anything else is returned unchanged and stays an inert empty figure.
|
||||
|
||||
/** One allowlisted embed provider. */
|
||||
export type EmbedProvider = {
|
||||
/** The id a placeholder must match to expand. */
|
||||
id: RegExp
|
||||
/** The iframe src for a validated id. */
|
||||
src: (id: string) => string
|
||||
/** The iframe's accessible title. */
|
||||
title: string
|
||||
}
|
||||
|
||||
/** Providers a placeholder may name. YouTube only, on its privacy-enhanced host. */
|
||||
export const EMBED_PROVIDERS: Readonly<Record<string, EmbedProvider>> = Object.freeze({
|
||||
youtube: {
|
||||
id: /^[A-Za-z0-9_-]{11}$/,
|
||||
src: (id: string) => `https://www.youtube-nocookie.com/embed/${id}`,
|
||||
title: 'YouTube video',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* The placeholder exactly as the editor writes it and sanitize-html
|
||||
* serializes it. Group 1 is the provider, group 2 the id; neither may hold a
|
||||
* quote or an angle bracket.
|
||||
*/
|
||||
export const EMBED_PLACEHOLDER = /<figure data-embed="([^"<>]*)" data-id="([^"<>]*)"><\/figure>/g
|
||||
|
||||
/**
|
||||
* Replaces every placeholder whose provider is in EMBED_PROVIDERS and whose
|
||||
* id passes the provider's pattern with a figure holding the provider's
|
||||
* iframe. Every other match is returned unchanged. Empty input gives "".
|
||||
* Call it on sanitizeJournalHtml output only, and never sanitize its result.
|
||||
*/
|
||||
export function expandEmbeds(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return html.replace(EMBED_PLACEHOLDER, (match: string, provider: string, id: string) => {
|
||||
if (!Object.hasOwn(EMBED_PROVIDERS, provider)) return match
|
||||
const p = EMBED_PROVIDERS[provider]!
|
||||
if (!p.id.test(id)) return match
|
||||
return (
|
||||
`<figure class="journal-embed journal-embed--${provider}">` +
|
||||
`<iframe src="${p.src(id)}" title="${p.title}" loading="lazy" ` +
|
||||
'referrerpolicy="strict-origin-when-cross-origin" ' +
|
||||
'allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe>' +
|
||||
'</figure>'
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,23 +1,272 @@
|
||||
// HTML gate for every v-html sink (post body, category content, author bio).
|
||||
// The three patterns are the verbatim regex sources of the Journal plugin's
|
||||
// FormatHTML / CheckHTML gate (classes/format_html.go), with the i flag for
|
||||
// Go's (?i). Imported content bypasses the Go gate, so the layer checks again.
|
||||
// HTML sanitizer for every v-html sink (post body, category content, and
|
||||
// any HTML copy an app renders the same way). It mirrors the Journal
|
||||
// plugin's bluemonday policy (classes/format_html.go): the UGC element and
|
||||
// attribute set, plus footer, class, schema.org microdata, a few validated
|
||||
// inline styles, goldmark's footnote roles, dir="auto" and two chat
|
||||
// transcript data attributes. URLs must be http, https, mailto or relative.
|
||||
//
|
||||
// The Blog Editor's own output adds exactly four rules, the same four the Go
|
||||
// policy gained in Phase 16.2: figure data-embed (youtube only) and data-id
|
||||
// (an 11-character video id), which carry the embed placeholder that
|
||||
// embeds.ts expands after sanitizing; img alt with any text (the editor's
|
||||
// free-text alt field); and ol start (an integer). tests/fixtures/
|
||||
// editor-corpus.json, a byte copy of the editor's corpus, pins them.
|
||||
//
|
||||
// The Journal API already sanitizes what FormatHTML writes. This is defence
|
||||
// in depth for rows written another way (direct SQL, legacy imports) and for
|
||||
// the API itself: anything outside the policy is stripped, never the whole
|
||||
// value, so legitimate text such as "Data: 12 maja 2024" always renders.
|
||||
//
|
||||
// sanitize-html runs the same parser and serializer on the server (SSR) and
|
||||
// in the browser, so a body sanitizes to the same string on both and
|
||||
// hydration never sees a difference. The output is always the serializer's,
|
||||
// never the input bytes: the browser parses exactly what the policy checked.
|
||||
// See docs/decisions/0001-sanitize-html.md.
|
||||
|
||||
export const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
|
||||
export const EVENT_HANDLER = /\son[a-z]+\s*=/i
|
||||
export const DANGEROUS_URL = /(?:javascript|vbscript|data):/i
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
|
||||
/** True when `html` matches none of the three Journal patterns. */
|
||||
export function isSafeJournalHtml(html: string | null | undefined): boolean {
|
||||
if (!html) return true
|
||||
return !(UNSAFE_TAG.test(html) || EVENT_HANDLER.test(html) || DANGEROUS_URL.test(html))
|
||||
/** Elements of bluemonday's UGC policy, plus footer. */
|
||||
export const JOURNAL_ALLOWED_TAGS: readonly string[] = [
|
||||
// Sectioning, headings, grouping
|
||||
'article', 'aside', 'details', 'figure', 'section', 'summary', 'footer',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup',
|
||||
'blockquote', 'br', 'div', 'hr', 'p', 'span', 'wbr',
|
||||
// Links and image maps
|
||||
'a', 'map', 'area',
|
||||
// Phrasing
|
||||
'abbr', 'acronym', 'cite', 'code', 'dfn', 'em', 'figcaption', 'mark', 's', 'samp',
|
||||
'strong', 'sub', 'sup', 'var', 'q', 'time',
|
||||
'b', 'i', 'pre', 'small', 'strike', 'tt', 'u',
|
||||
'bdi', 'bdo', 'rp', 'rt', 'ruby', 'del', 'ins',
|
||||
// Lists and tables
|
||||
'ol', 'ul', 'li', 'dl', 'dt', 'dd',
|
||||
'table', 'caption', 'col', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||||
// Data display and images
|
||||
'meter', 'progress', 'img',
|
||||
]
|
||||
|
||||
/** URL schemes an href, src or cite may use; relative URLs are allowed too. */
|
||||
export const JOURNAL_ALLOWED_SCHEMES: readonly string[] = ['http', 'https', 'mailto']
|
||||
|
||||
// bluemonday's value patterns (helpers.go), as JavaScript regexes. Several
|
||||
// are unanchored in bluemonday (lang, id, scope, nowrap) and stay unanchored
|
||||
// here, so the layer never strips what the API keeps.
|
||||
const PARAGRAPH = /^[\p{L}\p{N}\s\-_',[\]!./\\()]*$/u
|
||||
const SPACE_SEPARATED_TOKENS = /^[\s\p{L}\p{N}_-]+$/u
|
||||
const INTEGER = /^[0-9]+$/
|
||||
const NUMBER = /^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$/
|
||||
const NUMBER_OR_PERCENT = /^[0-9]+%?$/
|
||||
const LIST_TYPE = /^(circle|disc|square|a|A|i|I|1)$/i
|
||||
const CELL_ALIGN = /^(center|justify|left|right|char)$/i
|
||||
const CELL_VERTICAL_ALIGN = /^(baseline|bottom|middle|top)$/i
|
||||
const IMAGE_ALIGN = /^(left|right|top|texttop|middle|absmiddle|baseline|bottom|absbottom)$/i
|
||||
const ISO8601 = /^[0-9]{4}(-[0-9]{2}(-[0-9]{2}([ T][0-9]{2}(:[0-9]{2}){1,2}(.[0-9]{1,6})?Z?([+-][0-9]{2}:[0-9]{2})?)?)?)?$/
|
||||
const FOOTNOTE_ROLE = /^doc-(noteref|endnotes|backlink)$/
|
||||
// The Blog Editor's alt field takes free text, which PARAGRAPH cuts at ':',
|
||||
// '?', '"' and more. Any value is accepted, as bluemonday's p.AllowAttrs("alt")
|
||||
// does; sanitize-html escapes attribute values on output and alt carries no URL.
|
||||
const FREE_TEXT = /^[\s\S]*$/
|
||||
// The embed placeholder written by the Blog Editor (see embeds.ts).
|
||||
const EMBED_PROVIDER = /^youtube$/
|
||||
const EMBED_ID = /^[A-Za-z0-9_-]{11}$/
|
||||
|
||||
// A value pattern; null marks a URL attribute (checked by allowedUrl) or
|
||||
// style (filtered per property by allowedStyles).
|
||||
type ValueRule = RegExp | null
|
||||
|
||||
// Attributes allowed on every element.
|
||||
const GLOBAL_ATTRS: Record<string, ValueRule> = {
|
||||
dir: /^(ltr|rtl|auto)$/i,
|
||||
lang: /[a-zA-Z]{2,20}/,
|
||||
id: /[a-zA-Z0-9:\-_.]+/,
|
||||
title: PARAGRAPH,
|
||||
class: SPACE_SEPARATED_TOKENS,
|
||||
itemscope: /^(|itemscope)$/,
|
||||
itemtype: /^https?:\/\/schema\.org\/[A-Za-z]+$/,
|
||||
itemprop: SPACE_SEPARATED_TOKENS,
|
||||
style: null,
|
||||
}
|
||||
|
||||
const ALIGN_VALIGN: Record<string, ValueRule> = { align: CELL_ALIGN, valign: CELL_VERTICAL_ALIGN }
|
||||
const SIZE: Record<string, ValueRule> = { height: NUMBER_OR_PERCENT, width: NUMBER_OR_PERCENT }
|
||||
const CELL: Record<string, ValueRule> = {
|
||||
...ALIGN_VALIGN,
|
||||
...SIZE,
|
||||
abbr: PARAGRAPH,
|
||||
colspan: INTEGER,
|
||||
rowspan: INTEGER,
|
||||
headers: SPACE_SEPARATED_TOKENS,
|
||||
scope: /(?:row|col)(?:group)?/i,
|
||||
nowrap: /|nowrap/i,
|
||||
}
|
||||
|
||||
// Attributes allowed on one element, on top of GLOBAL_ATTRS.
|
||||
const ELEMENT_ATTRS: Record<string, Record<string, ValueRule>> = {
|
||||
details: { open: /^(|open)$/i },
|
||||
blockquote: { cite: null },
|
||||
q: { cite: null },
|
||||
a: { href: null, role: FOOTNOTE_ROLE },
|
||||
div: {
|
||||
role: FOOTNOTE_ROLE,
|
||||
'data-message-author-role': /^[A-Za-z0-9_-]+$/,
|
||||
'data-message-id': /^[A-Za-z0-9_-]+$/,
|
||||
},
|
||||
map: { name: /^[\p{L}\p{N}_-]+$/u },
|
||||
area: {
|
||||
alt: PARAGRAPH,
|
||||
coords: /^([0-9]+,)+[0-9]+$/,
|
||||
href: null,
|
||||
rel: SPACE_SEPARATED_TOKENS,
|
||||
shape: /^(default|circle|rect|poly)$/i,
|
||||
},
|
||||
figure: { 'data-embed': EMBED_PROVIDER, 'data-id': EMBED_ID },
|
||||
img: { src: null, alt: FREE_TEXT, align: IMAGE_ALIGN, ...SIZE, usemap: /^#[\p{L}\p{N}_-]+$/iu },
|
||||
time: { datetime: ISO8601 },
|
||||
del: { cite: PARAGRAPH, datetime: ISO8601 },
|
||||
ins: { cite: PARAGRAPH, datetime: ISO8601 },
|
||||
ol: { type: LIST_TYPE, start: INTEGER },
|
||||
ul: { type: LIST_TYPE },
|
||||
li: { type: LIST_TYPE, value: INTEGER },
|
||||
table: { ...SIZE, summary: PARAGRAPH },
|
||||
col: { ...ALIGN_VALIGN, ...SIZE, span: INTEGER },
|
||||
colgroup: { ...ALIGN_VALIGN, ...SIZE, span: INTEGER },
|
||||
thead: ALIGN_VALIGN,
|
||||
tbody: ALIGN_VALIGN,
|
||||
tfoot: ALIGN_VALIGN,
|
||||
tr: ALIGN_VALIGN,
|
||||
td: CELL,
|
||||
th: CELL,
|
||||
meter: { value: NUMBER, min: NUMBER, max: NUMBER, low: NUMBER, high: NUMBER, optimum: NUMBER },
|
||||
progress: { value: NUMBER, max: NUMBER },
|
||||
}
|
||||
|
||||
// Inline styles: bluemonday's css handlers for the four allowed properties.
|
||||
// A named colour is letters only, so it cannot carry url() or expression().
|
||||
const CSS_LENGTH = /^-?([0-9]+|[0-9]*\.[0-9]+)(%|cm|mm|in|px|pt|pc|em|ex|ch|rem|vw|vh|vmin|vmax|deg|rad|turn)?$/
|
||||
const RGB_PART = '(([0-9]{1,2}|100)%|([01]?[0-9]{1,2}|2[0-4][0-9]|25[0-5]))'
|
||||
const ALLOWED_STYLES: Record<string, RegExp[]> = {
|
||||
color: [
|
||||
/^[a-z]+$/i,
|
||||
/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i,
|
||||
new RegExp(`^rgb\\(( *${RGB_PART},){2} *${RGB_PART}\\)$`),
|
||||
new RegExp(`^rgba\\(( *${RGB_PART},){3} *(1(\\.0)?|0|0\\.[0-9]+)\\)$`),
|
||||
/^hsl\( *([012]?[0-9]{1,2}|3[0-5][0-9]|360), *([0-9]{0,2}|100)%, *([0-9]{0,2}|100)%\)$/,
|
||||
/^hsla\( *([012]?[0-9]{1,2}|3[0-5][0-9]|360), *([0-9]{0,2}|100)%, *([0-9]{0,2}|100)%, *(1|1\.0|0|0\.[0-9]+)\)$/,
|
||||
],
|
||||
'font-size': [CSS_LENGTH, /^(medium|xx-small|x-small|small|large|x-large|xx-large|smaller|larger|initial|inherit)$/i],
|
||||
'font-weight': [/^(normal|bold|bolder|lighter|[1-9]00|initial|inherit)$/i],
|
||||
'text-align': [/^(left|right|center|justify|initial|inherit)$/i],
|
||||
}
|
||||
|
||||
// Characters a browser drops from a URL before it reads the scheme.
|
||||
const URL_IGNORED = /[\x00-\x20\x7f]+/g
|
||||
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
|
||||
|
||||
/**
|
||||
* True when `url` (entity-decoded by the parser) is relative or uses an
|
||||
* allowed scheme once the characters a browser ignores are removed, and
|
||||
* parses as a URL (bluemonday requires a parseable URL too). sanitize-html
|
||||
* checks the scheme as well; checking here keeps the layer's rule from
|
||||
* depending on sanitize-html's defaults.
|
||||
*/
|
||||
function allowedUrl(url: string): boolean {
|
||||
const scheme = URL_SCHEME.exec(url.replace(URL_IGNORED, ''))
|
||||
if (scheme && !JOURNAL_ALLOWED_SCHEMES.includes(scheme[1]!.toLowerCase())) return false
|
||||
// new URL, not URL.canParse, which Safari before 17 lacks.
|
||||
try {
|
||||
new URL(url, 'https://journal.invalid/')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Runs before sanitize-html's own attribute filter: drops every attribute
|
||||
// whose value fails its pattern. sanitize-html then drops attributes that
|
||||
// are not allowed on the element at all. Lookups are own-property only, so
|
||||
// a tag or attribute named "constructor" or "toString" finds no rule.
|
||||
function filterAttributeValues(tagName: string, attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag {
|
||||
const own = Object.hasOwn(ELEMENT_ATTRS, tagName) ? ELEMENT_ATTRS[tagName] : undefined
|
||||
const kept: sanitizeHtml.Attributes = {}
|
||||
for (const [name, value] of Object.entries(attribs)) {
|
||||
let rule: ValueRule | undefined
|
||||
if (own && Object.hasOwn(own, name)) rule = own[name]
|
||||
else if (Object.hasOwn(GLOBAL_ATTRS, name)) rule = GLOBAL_ATTRS[name]
|
||||
if (rule === undefined) continue
|
||||
if (rule === null ? name === 'style' || allowedUrl(value) : rule.test(value)) kept[name] = value
|
||||
}
|
||||
return { tagName, attribs: kept }
|
||||
}
|
||||
|
||||
const allowedAttributes: Record<string, string[]> = { '*': Object.keys(GLOBAL_ATTRS) }
|
||||
for (const [tag, attrs] of Object.entries(ELEMENT_ATTRS)) allowedAttributes[tag] = Object.keys(attrs)
|
||||
|
||||
// allowedEmptyAttributes is a sanitize-html option that @types/sanitize-html
|
||||
// does not declare yet.
|
||||
type Options = sanitizeHtml.IOptions & { allowedEmptyAttributes: string[] }
|
||||
|
||||
// Attributes whose pattern accepts an empty value. Both configs below write
|
||||
// them as name="", so an empty value compares equal.
|
||||
const EMPTY_ATTRS = ['alt', 'title', 'summary', 'abbr', 'itemscope', 'open', 'nowrap']
|
||||
|
||||
const POLICY: Options = {
|
||||
allowedTags: [...JOURNAL_ALLOWED_TAGS],
|
||||
allowedAttributes,
|
||||
allowedStyles: { '*': ALLOWED_STYLES },
|
||||
allowedSchemes: [...JOURNAL_ALLOWED_SCHEMES],
|
||||
allowedSchemesByTag: {},
|
||||
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
|
||||
allowProtocolRelative: true,
|
||||
allowedEmptyAttributes: EMPTY_ATTRS,
|
||||
// A refused element is dropped and its text kept, except in these, whose
|
||||
// content is never shown as text (bluemonday skips the same content).
|
||||
disallowedTagsMode: 'discard',
|
||||
nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'title', 'iframe', 'noembed', 'noframes', 'object', 'xmp', 'template'],
|
||||
transformTags: { '*': filterAttributeValues },
|
||||
}
|
||||
|
||||
// The same parser and serializer with nothing refused: the reference that
|
||||
// isSafeJournalHtml compares the policy's output with.
|
||||
const KEEP_ALL: Options = {
|
||||
allowedTags: false,
|
||||
allowedAttributes: false,
|
||||
allowVulnerableTags: true,
|
||||
allowedSchemes: [],
|
||||
allowedSchemesAppliedToAttributes: [],
|
||||
nonTextTags: [],
|
||||
allowedEmptyAttributes: EMPTY_ATTRS,
|
||||
nonBooleanAttributes: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate result for a v-html sink: `html` is the input when safe and "" when
|
||||
* not, so the unsafe value never reaches the DOM. Null input is safe and "".
|
||||
* Sanitizes journal HTML with the Journal policy. Whatever the policy does
|
||||
* not allow is removed (the text of a removed element is kept, except for
|
||||
* script, style and similar), so the result is never blank because of one
|
||||
* stray attribute. Null or empty input gives "". Pure and deterministic: the
|
||||
* same input gives the same string on the server and in the browser.
|
||||
*/
|
||||
export function sanitizeJournalHtml(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return sanitizeHtml(html, POLICY)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the policy keeps `html` whole: sanitizing it removes no element,
|
||||
* attribute or text and only normalizes how the markup is written. The
|
||||
* counterpart of the plugin's CheckHTML. Null or empty input is safe.
|
||||
*/
|
||||
export function isSafeJournalHtml(html: string | null | undefined): boolean {
|
||||
return gateJournalHtml(html).safe
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate result for a v-html sink. `html` is always the sanitized value and is
|
||||
* what the sink renders; `safe` reports whether the policy kept the input
|
||||
* whole (isSafeJournalHtml). It never blanks: unsafe parts are removed, not
|
||||
* the value. Null or empty input is safe and "".
|
||||
*/
|
||||
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
|
||||
if (!html) return { safe: true, html: '' }
|
||||
return isSafeJournalHtml(html) ? { safe: true, html } : { safe: false, html: '' }
|
||||
const clean = sanitizeHtml(html, POLICY)
|
||||
return { safe: clean === sanitizeHtml(html, KEEP_ALL), html: clean }
|
||||
}
|
||||
|
||||
36
app/utils/pagination.ts
Normal file
36
app/utils/pagination.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Page-number rules for Journal listings. Pages use ?page=N (never a path
|
||||
// segment). Anything but a positive integer, and any page past the last one,
|
||||
// is a real 404.
|
||||
|
||||
/**
|
||||
* Parses the ?page query value: 1 when absent, the integer for a string of
|
||||
* digits greater than 0, and null (a 404) for anything else: non-numeric,
|
||||
* empty, 0, negative, decimal, repeated (array) or beyond the safe range.
|
||||
*/
|
||||
export function parsePageParam(raw: unknown): number | null {
|
||||
if (raw === undefined || raw === null) return 1
|
||||
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null
|
||||
const n = Number(raw)
|
||||
if (!Number.isSafeInteger(n) || n < 1) return null
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `page` lies past the last page. Page 1 is always in range, so an
|
||||
* empty listing renders its empty state instead of a 404.
|
||||
*/
|
||||
export function pageOutOfRange(page: number, lastPage: number): boolean {
|
||||
return page > 1 && page > lastPage
|
||||
}
|
||||
|
||||
/**
|
||||
* The page numbers to render around `current`: at most `size` consecutive
|
||||
* pages, clipped to 1..last and kept full-width near either end.
|
||||
*/
|
||||
export function paginationWindow(current: number, last: number, size: number = 5): number[] {
|
||||
if (last < 1 || size < 1) return []
|
||||
const width = Math.min(size, last)
|
||||
let start = Math.max(1, current - Math.floor(width / 2))
|
||||
if (start + width - 1 > last) start = last - width + 1
|
||||
return Array.from({ length: width }, (_, i) => start + i)
|
||||
}
|
||||
21
app/utils/postView.ts
Normal file
21
app/utils/postView.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// The post page's SSR payload: the post with its body sanitized, without the
|
||||
// raw `content` source or `translations`, and with related posts as list
|
||||
// rows. Kept apart from view.ts because it loads the sanitizer, which list
|
||||
// pages and cards do not need.
|
||||
|
||||
import { sanitizeJournalHtml } from './htmlGate.ts'
|
||||
import type { JournalPostDetail } from './types.ts'
|
||||
import { toListPost } from './view.ts'
|
||||
|
||||
/** A post page's data: the post with its body sanitized and no source or translations. */
|
||||
export type JournalPostView = JournalPostDetail
|
||||
|
||||
export function toPostView(post: JournalPostDetail): JournalPostView {
|
||||
return {
|
||||
...post,
|
||||
content: null,
|
||||
translations: null,
|
||||
content_html: sanitizeJournalHtml(post.content_html),
|
||||
related_posts: (post.related_posts ?? []).map(toListPost),
|
||||
}
|
||||
}
|
||||
70
app/utils/seo.ts
Normal file
70
app/utils/seo.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// SEO values for Journal pages, fed to useSeoMeta. The site name suffix comes
|
||||
// from the app's site.name through the SEO title template, not from here.
|
||||
|
||||
import type { JournalPost } from './types.ts'
|
||||
|
||||
/** Plain text of an HTML fragment: tags dropped, common entities decoded, whitespace collapsed. */
|
||||
export function htmlToText(html: string | null | undefined): string {
|
||||
if (!html) return ''
|
||||
return html
|
||||
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Cuts `text` to at most `max` characters, at a word boundary when one is near, with an ellipsis. */
|
||||
export function truncateText(text: string, max: number = 160): string {
|
||||
if (text.length <= max) return text
|
||||
const cut = text.slice(0, max - 1)
|
||||
const space = cut.lastIndexOf(' ')
|
||||
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`
|
||||
}
|
||||
|
||||
/** Makes `url` absolute against `siteUrl`; already absolute URLs and a missing siteUrl pass through. */
|
||||
export function absoluteUrl(url: string, siteUrl?: string | null): string {
|
||||
if (/^https?:\/\//i.test(url) || !siteUrl) return url
|
||||
return `${siteUrl.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
export interface JournalPostSeo {
|
||||
title: string
|
||||
ogTitle: string
|
||||
description: string
|
||||
ogDescription: string
|
||||
ogType: 'article'
|
||||
ogImage?: string
|
||||
articlePublishedTime?: string
|
||||
articleModifiedTime?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta for a post page: the post title, the excerpt (or the first 160
|
||||
* characters of the body text) as description, the featured image as
|
||||
* og:image, and the publish and update times.
|
||||
*/
|
||||
export function postSeoMeta(post: JournalPost, siteUrl?: string | null): JournalPostSeo {
|
||||
const description = truncateText(htmlToText(post.excerpt) || htmlToText(post.content_html), 160)
|
||||
const meta: JournalPostSeo = {
|
||||
title: post.title,
|
||||
ogTitle: post.title,
|
||||
description,
|
||||
ogDescription: description,
|
||||
ogType: 'article',
|
||||
}
|
||||
if (post.featured_image_url) meta.ogImage = absoluteUrl(post.featured_image_url, siteUrl)
|
||||
if (post.published_at) meta.articlePublishedTime = post.published_at
|
||||
if (post.updated_at) meta.articleModifiedTime = post.updated_at
|
||||
return meta
|
||||
}
|
||||
|
||||
/** A listing title; pages after the first append the "Page N" string. */
|
||||
export function listTitle(base: string, page: number, pageTitle: string): string {
|
||||
return page > 1 ? `${base} · ${pageTitle}` : base
|
||||
}
|
||||
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}`
|
||||
}
|
||||
40
app/utils/sitemap.ts
Normal file
40
app/utils/sitemap.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Sitemap entries for Journal content. Every loc is built from journal.routes,
|
||||
// so moving or disabling a view moves or drops its URLs; nothing hard-codes
|
||||
// a path.
|
||||
|
||||
import { flattenCategories } from './client.ts'
|
||||
import { fillJournalPath } from './routes.ts'
|
||||
import type { JournalCategory, JournalPost, JournalRoutes } from './types.ts'
|
||||
|
||||
export interface JournalSitemapEntry {
|
||||
loc: string
|
||||
lastmod?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per published post (lastmod = updated_at) at routes.post, and one
|
||||
* per category of the flattened tree at routes.category. A disabled route
|
||||
* contributes nothing.
|
||||
*/
|
||||
export function buildJournalSitemapEntries(
|
||||
posts: readonly JournalPost[],
|
||||
categories: readonly JournalCategory[],
|
||||
routes: JournalRoutes,
|
||||
): JournalSitemapEntry[] {
|
||||
const entries: JournalSitemapEntry[] = []
|
||||
if (routes.post !== false) {
|
||||
for (const post of posts) {
|
||||
if (!post.published || !post.slug) continue
|
||||
const entry: JournalSitemapEntry = { loc: fillJournalPath(routes.post, { slug: post.slug }) }
|
||||
if (post.updated_at) entry.lastmod = post.updated_at
|
||||
entries.push(entry)
|
||||
}
|
||||
}
|
||||
if (routes.category !== false) {
|
||||
for (const category of flattenCategories(categories)) {
|
||||
if (!category.slug) continue
|
||||
entries.push({ loc: fillJournalPath(routes.category, { category: category.slug }) })
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -1,29 +1,28 @@
|
||||
// View shaping for SSR payloads. Pages run API rows through these in
|
||||
// useAsyncData's transform, so the serialized payload carries only what the
|
||||
// page renders: no raw `content` source, no `translations`, and never an HTML
|
||||
// body that failed the Journal gate.
|
||||
// page renders: no raw `content` source and no `translations`. The post
|
||||
// page's view, which sanitizes the body, is in postView.ts, so list pages and
|
||||
// cards that import this file do not load the sanitizer.
|
||||
|
||||
import { gateJournalHtml } from './htmlGate.ts'
|
||||
import type { JournalPost, JournalPostDetail } from './types.ts'
|
||||
import type { JournalPost } from './types.ts'
|
||||
|
||||
/** A post row as a list or card renders it: without body, source or translations. */
|
||||
export function toListPost(post: JournalPost): JournalPost {
|
||||
return { ...post, content: null, content_html: null, translations: null }
|
||||
}
|
||||
|
||||
/** A post page's data: the gated body, with `content_blocked` set when the gate refused it. */
|
||||
export interface JournalPostView extends JournalPostDetail {
|
||||
content_blocked: boolean
|
||||
}
|
||||
|
||||
export function toPostView(post: JournalPostDetail): JournalPostView {
|
||||
const gated = gateJournalHtml(post.content_html)
|
||||
return {
|
||||
...post,
|
||||
content: null,
|
||||
translations: null,
|
||||
content_html: gated.html,
|
||||
content_blocked: !gated.safe,
|
||||
related_posts: (post.related_posts ?? []).map(toListPost),
|
||||
/**
|
||||
* Formats an API timestamp as a long date for `language` (a BCP 47 tag such
|
||||
* as "pl-PL"). UTC on both server and browser, so hydration matches. Returns
|
||||
* "" for a missing or invalid value.
|
||||
*/
|
||||
export function formatJournalDate(iso: string | null | undefined, language: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat(language || 'en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
|
||||
}
|
||||
}
|
||||
|
||||
54
docs/decisions/0001-sanitize-html.md
Normal file
54
docs/decisions/0001-sanitize-html.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Decision: sanitize-html sanitizes Journal HTML in the layer
|
||||
|
||||
**Date:** 2026-10-09
|
||||
**Status:** Accepted
|
||||
**Supersedes:** the three-regex gate in `app/utils/htmlGate.ts` (`UNSAFE_TAG`, `EVENT_HANDLER`, `DANGEROUS_URL`)
|
||||
|
||||
## Decision
|
||||
|
||||
The layer depends on `sanitize-html` 2.18.0 (Apostrophe, MIT; released 2026-09-30, the latest version) and on `@types/sanitize-html` 2.16.2 for development. `app/utils/htmlGate.ts` exports `sanitizeJournalHtml(html): string`. Every `v-html` sink and every payload transform passes Journal HTML through it: `JournalPostBody`, `JournalCategoryHeader`, `toPostView` (now in `app/utils/postView.ts`) and the category page.
|
||||
|
||||
The exported names stay. `gateJournalHtml(html)` still returns `{ safe, html }`, but `html` is now always the sanitized string and is never blanked. `safe` (and `isSafeJournalHtml`) report whether the policy kept the input whole. The `blocked` props, the `content_blocked` view field, the `journal.post.contentUnavailable` string and the `.journal-post-body--blocked` style are removed, because nothing is blocked any more.
|
||||
|
||||
## Policy
|
||||
|
||||
The policy mirrors the Journal plugin's bluemonday policy (`classes/format_html.go`, decision note `journal-html-sanitizing-bluemonday.md` in the framework repository):
|
||||
|
||||
- **Elements:** bluemonday's `UGCPolicy` set plus `footer`. These are the sectioning, heading, grouping, phrasing, list, table, `img`, `map`/`area`, `meter` and `progress` elements. `script`, `style`, `iframe`, `object`, `embed`, `form`, `input`, `base`, `meta`, `link`, `svg`, `math` and comments are removed. The text of a removed element is kept, except inside `script`, `style`, `textarea`, `option`, `noscript`, `title`, `iframe`, `noembed`, `noframes`, `object`, `xmp` and `template`.
|
||||
- **Attributes:** UGC's attributes with UGC's value patterns, ported from `helpers.go` (`dir`, `lang`, `id`, `title` globally; `href`, `src`, `cite`, `alt`, table and list attributes per element). The additions are `class`, `itemscope`, `itemtype` (`https://schema.org/<Type>`) and `itemprop` globally; `role` = `doc-noteref`, `doc-endnotes` or `doc-backlink` on `a` and `div`; `dir` = `ltr`, `rtl` or `auto`; and `data-message-author-role` and `data-message-id` on `div`. An attribute whose value fails its pattern is removed. Every `on*` attribute is removed, as are `target` and `rel` on `a`.
|
||||
- **Blog Editor output (Phase 16.2, amended 2026-10-10):** four rules for the Journal plugin's Blog Editor, the same four its Go policy gained: `data-embed` = `youtube` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder `<figure data-embed="youtube" data-id="ID"></figure>`, which `app/utils/embeds.ts` expands into a youtube-nocookie iframe after sanitizing), `alt` with any text on `img` (the editor's free-text alt field; UGC's pattern cuts it at `:`, `?` and `"`, and sanitize-html escapes the value), and `start` as an integer on `ol` (lists that do not begin at 1). They are the editor's own output, pinned by `tests/fixtures/editor-corpus.json`, a byte copy of the editor's corpus that the Go policy test reads too: every entry is safe here. No other data attribute, no `iframe`, no new URL scheme and no new style is allowed, and `alt` on `area` keeps UGC's pattern.
|
||||
- **Inline styles:** only `color`, `font-size`, `font-weight` and `text-align`, with the values that bluemonday's CSS handlers accept. A named colour must be letters only, so `url()` and `expression()` never pass.
|
||||
- **URLs** in `href`, `src` and `cite` must be `http`, `https`, `mailto`, relative or protocol-relative, and must parse as a URL. The scheme is read after entity decoding and after removing the characters a browser ignores (ASCII control characters, space and DEL). `javascript:`, `javascript:` and `java<TAB>script:` are all refused.
|
||||
|
||||
**Sanitize, don't blank.** A value is never refused as a whole. Text such as "Data: 12 maja 2024" or "metadata:" is ordinary text, not a URL, so it always renders.
|
||||
|
||||
**The output is always the sanitizer's own serialization, never the input bytes.** The Go side returns the input unchanged when the policy keeps it whole, which is safe there because `golang.org/x/net/html` tokenizes the way a browser does. htmlparser2 is not a spec parser, so returning the input could hand the browser markup it reads differently from how the policy read it. The browser only ever parses what the policy checked.
|
||||
|
||||
## Why sanitize-html
|
||||
|
||||
| | sanitize-html 2.18.0 | isomorphic-dompurify 4.5.0 (DOMPurify 3.4.16 + jsdom 30.1.2) |
|
||||
|---|---|---|
|
||||
| Server (Nitro) | Pure JavaScript (htmlparser2, postcss). No DOM. | Needs jsdom: about 5.5 MB unpacked plus its dependency tree, and a full window object in the Nitro process. jsdom 30 and isomorphic-dompurify 4.5 require Node `^22.22.2`, but `.nvmrc` pins 22.22.0. |
|
||||
| Server/client parity | The same parser and serializer run on both sides, so the same input gives the same string (checked in Chromium: the hydrated SSR body and the body after client-side navigation have identical `innerHTML`). | jsdom parses on the server and the browser's DOM on the client. The two can serialize edge cases and CSS differently (jsdom's `cssstyle` and the browser's CSSOM normalize colours differently). |
|
||||
| Policy fit | Declarative: per-element attribute lists, `allowedStyles` with a regex per CSS property, `allowedSchemes`, and `transformTags` to check attribute values. It maps onto bluemonday rule by rule. | No per-attribute value patterns and no CSS property filter. Mirroring the server policy would need hooks that the project writes and owns (`uponSanitizeAttribute` plus a hand-written style filter), which is exactly the security code the project prefers to leave to a library. |
|
||||
| Client bundle | About 157 KB minified, 59 KB gzipped in the playground build. It loads only on the post and category pages (see below). | About 30 KB minified, 11 KB gzipped (DOMPurify alone). |
|
||||
| Maintenance | Apostrophe; a release almost every month in 2026 (2.17.1 in February to 2.18.0 in September), including recent fixes such as the `meta refresh` URL check (GHSA-cv27-6wvh-8x7j). | Cure53 (DOMPurify) plus a single maintainer for the isomorphic wrapper. |
|
||||
|
||||
DOMPurify is the stronger choice for a browser-only sanitizer, and it costs about a fifth of the client bytes. For this layer, every page is rendered on the server first, so the server side decides. There, jsdom is a heavy runtime dependency that conflicts with the pinned Node version, and two different DOMs would sanitize the same body differently. sanitize-html is the same code everywhere and expresses the server policy directly.
|
||||
|
||||
**Bundle cost, contained.** `toPostView` lives in `app/utils/postView.ts`, apart from `view.ts`. List pages, cards and an app homepage that use `toListPost` or `formatJournalDate` therefore do not load the sanitizer. In the playground build, the chunk with sanitize-html is a dependency of `PostPage` and `CategoryPage` only. Most of its size is postcss (about 56 KB minified), which sanitize-html uses to parse `style` attributes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **isomorphic-dompurify:** see the table above.
|
||||
- **DOMPurify on the client and sanitize-html on the server:** this means two policies to keep in step and two different outputs for the same body. Rejected.
|
||||
- **xss (js-xss) 1.0.15:** small, but it has one maintainer, its tag-attribute whitelist cannot express per-value patterns without callbacks we would write, and its CSS filter is a separate package. Rejected for the same reason as hand-built hooks.
|
||||
- **Keep the regex gate and anchor it to attribute values:** this fixes the false refusal of "Data:" text but none of the bypasses. Rejected.
|
||||
- **Sanitize only on the server and proxy client-side API calls through Nitro:** this saves the client bytes, but it changes how client navigation reaches the API (today it goes straight to `/_journal/api/v1`). Worth revisiting if the 59 KB matters. It needs its own decision.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unsafe body now renders without its unsafe parts and with all of its safe text. The smoke expectation for `/blog/unsafe-sample` checks for `<p>Before</p><p>After</p>` instead of the blocked notice.
|
||||
- Content that relied on an embedded `<style>` block loses it, exactly as it does on the server's next save. Its CSS belongs in the app, scoped under `.journal-post-body` (the imported author footer of the first consuming application is an example).
|
||||
- An app that still renders `gateJournalHtml(...).html` keeps working and now gets sanitized HTML. An app test that expects `journal-post-body--blocked` or the `journal.post.contentUnavailable` string has to change when the app moves to this version.
|
||||
- `isSafeJournalHtml` compares the policy's output with the same serializer's output when nothing is refused. Comments are dropped by both, so a comment alone does not make a value unsafe.
|
||||
38
i18n/locales/en.json
Normal file
38
i18n/locales/en.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"journal": {
|
||||
"index": {
|
||||
"title": "Latest posts",
|
||||
"empty": "There are no posts yet."
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"previous": "Previous page",
|
||||
"next": "Next page",
|
||||
"pageTitle": "Page {page}"
|
||||
},
|
||||
"post": {
|
||||
"publishedOn": "Published on {date}",
|
||||
"readingTime": "{minutes} min read",
|
||||
"by": "By",
|
||||
"related": "Related posts",
|
||||
"previous": "Previous post",
|
||||
"next": "Next post"
|
||||
},
|
||||
"category": {
|
||||
"empty": "There are no posts in this category yet."
|
||||
},
|
||||
"tag": {
|
||||
"title": "Posts tagged {name}",
|
||||
"empty": "There are no posts with this tag yet."
|
||||
},
|
||||
"author": {
|
||||
"title": "Posts by {name}",
|
||||
"posts": "Posts",
|
||||
"empty": "This author has not published any posts yet."
|
||||
},
|
||||
"readMore": "Read more",
|
||||
"rss": {
|
||||
"title": "RSS feed"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
i18n/locales/pl.json
Normal file
38
i18n/locales/pl.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"journal": {
|
||||
"index": {
|
||||
"title": "Najnowsze wpisy",
|
||||
"empty": "Nie ma jeszcze żadnych wpisów."
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Stronicowanie",
|
||||
"previous": "Poprzednia strona",
|
||||
"next": "Następna strona",
|
||||
"pageTitle": "Strona {page}"
|
||||
},
|
||||
"post": {
|
||||
"publishedOn": "Opublikowano {date}",
|
||||
"readingTime": "{minutes} min czytania",
|
||||
"by": "Autor:",
|
||||
"related": "Powiązane wpisy",
|
||||
"previous": "Poprzedni wpis",
|
||||
"next": "Następny wpis"
|
||||
},
|
||||
"category": {
|
||||
"empty": "W tej kategorii nie ma jeszcze wpisów."
|
||||
},
|
||||
"tag": {
|
||||
"title": "Wpisy z tagiem {name}",
|
||||
"empty": "Nie ma jeszcze wpisów z tym tagiem."
|
||||
},
|
||||
"author": {
|
||||
"title": "Wpisy autora {name}",
|
||||
"posts": "Wpisy",
|
||||
"empty": "Ten autor nie opublikował jeszcze żadnych wpisów."
|
||||
},
|
||||
"readMore": "Czytaj dalej",
|
||||
"rss": {
|
||||
"title": "Kanał RSS"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,18 @@ const PAGE_FILES: Record<keyof JournalRoutes, string> = {
|
||||
}
|
||||
|
||||
const pagesDir = fileURLToPath(new URL('../app/journal-pages/', import.meta.url))
|
||||
const langDir = fileURLToPath(new URL('../i18n/locales/', import.meta.url))
|
||||
|
||||
/** UI string locales the layer ships, each in i18n/locales/<code>.json. */
|
||||
const LAYER_LOCALES = ['pl', 'en'] as const
|
||||
|
||||
/** Locale codes the project enables in its i18n config (strings or objects). */
|
||||
function enabledLocaleCodes(locales: unknown): string[] {
|
||||
if (!Array.isArray(locales)) return []
|
||||
return locales
|
||||
.map((l) => (typeof l === 'string' ? l : l && typeof l === 'object' ? (l as { code?: unknown }).code : undefined))
|
||||
.filter((c): c is string => typeof c === 'string')
|
||||
}
|
||||
|
||||
export default defineNuxtModule<JournalModuleOptions>({
|
||||
meta: {
|
||||
@@ -43,6 +55,20 @@ export default defineNuxtModule<JournalModuleOptions>({
|
||||
}
|
||||
})
|
||||
|
||||
// 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
|
||||
// enables only pl.
|
||||
const hook = nuxt.hook as unknown as (
|
||||
name: 'i18n:registerModule',
|
||||
fn: (register: (config: { langDir: string; locales: Array<{ code: string; file: string }> }) => void) => void,
|
||||
) => void
|
||||
hook('i18n:registerModule', (register) => {
|
||||
const enabled = enabledLocaleCodes((nuxt.options as { i18n?: { locales?: unknown } }).i18n?.locales)
|
||||
const locales = LAYER_LOCALES.filter((code) => enabled.includes(code)).map((code) => ({ code, file: `${code}.json` }))
|
||||
if (locales.length > 0) register({ langDir, locales })
|
||||
})
|
||||
|
||||
// The public runtime config carries the resolved options next to apiBase,
|
||||
// so pages, links and the sitemap source read one source of truth.
|
||||
const pub = nuxt.options.runtimeConfig.public as Record<string, any>
|
||||
|
||||
@@ -20,11 +20,19 @@ export default defineNuxtConfig({
|
||||
// @nuxtjs/i18n split locale state).
|
||||
modules: ['@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo', `${currentDir}lib/journal-module.ts`],
|
||||
|
||||
// Tokens first, then the neutral styles that use them. The app's own css
|
||||
// array loads after these, so its token overrides win.
|
||||
css: [`${currentDir}app/assets/css/journal-tokens.css`, `${currentDir}app/assets/css/journal-base.css`],
|
||||
|
||||
runtimeConfig: {
|
||||
journal: {
|
||||
// Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never
|
||||
// comes from request input.
|
||||
apiBase: 'http://127.0.0.1:8096/_journal/api/v1',
|
||||
// Server-only bearer token of the site cache purge route
|
||||
// (NUXT_JOURNAL_CACHE_PURGE_TOKEN). Shorter than 32 characters, the
|
||||
// route answers 404.
|
||||
cachePurgeToken: '',
|
||||
},
|
||||
public: {
|
||||
journal: {
|
||||
@@ -34,9 +42,25 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
|
||||
// The Journal's internal routes are never route-cached. Nitro wraps every
|
||||
// handler matching an app rule such as '/**': { swr } in its cached
|
||||
// handler, which keys by path, does not skip POST and hides request
|
||||
// headers, so the purge route would answer from the cache without running.
|
||||
routeRules: {
|
||||
'/api/__journal__/**': { cache: false },
|
||||
},
|
||||
|
||||
// No OG image rendering at runtime.
|
||||
ogImage: { enabled: false },
|
||||
|
||||
// Paginated listings keep ?page=N in their canonical URL. nuxt-seo-utils
|
||||
// already whitelists page by default; set explicitly to lock the behaviour.
|
||||
seo: { canonicalQueryWhitelist: ['page'] },
|
||||
|
||||
// Journal posts and categories come from the API through the layer's
|
||||
// sitemap source; locs are built from journal.routes. One flat sitemap.
|
||||
sitemap: { sources: ['/api/__sitemap__/journal'], autoI18n: false },
|
||||
|
||||
// i18n: the layer declares no locales. The journal module registers the
|
||||
// layer's pl/en strings only for locales the app enables.
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.0.1",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
"scripts": {
|
||||
"dev": "nuxi dev .playground",
|
||||
"build:playground": "nuxt build .playground",
|
||||
"test": "node --test --test-reporter=tap tests/*.test.ts",
|
||||
"dev:prepare": "nuxt prepare .playground",
|
||||
"check:git-install": "bash scripts/check-git-install.sh"
|
||||
},
|
||||
@@ -16,12 +17,14 @@
|
||||
"dependencies": {
|
||||
"@nuxt/fonts": "0.14.0",
|
||||
"@nuxtjs/i18n": "10.4.0",
|
||||
"@nuxtjs/seo": "5.2.1"
|
||||
"@nuxtjs/seo": "5.2.1",
|
||||
"sanitize-html": "2.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nuxt": "4.4.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "2.16.2",
|
||||
"nuxt": "4.4.8",
|
||||
"typescript": "5.9.3",
|
||||
"vue": "3.5.35"
|
||||
|
||||
101
pnpm-lock.yaml
generated
101
pnpm-lock.yaml
generated
@@ -17,7 +17,13 @@ importers:
|
||||
'@nuxtjs/seo':
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1(8fed33377b96602d2a15d56f31c71fb1)
|
||||
sanitize-html:
|
||||
specifier: 2.18.0
|
||||
version: 2.18.0
|
||||
devDependencies:
|
||||
'@types/sanitize-html':
|
||||
specifier: 2.16.2
|
||||
version: 2.16.2
|
||||
nuxt:
|
||||
specifier: 4.4.8
|
||||
version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@oxc-project/types@0.151.0)(@types/node@26.6.4)(@vue/compiler-sfc@3.5.43)(cac@6.7.14)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0))(giget@3.3.1)(ioredis@5.11.1)(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(rollup-plugin-visualizer@7.1.1(rollup@4.64.2))(rollup@4.64.2)(srvx@0.11.22)(terser@5.51.2)(typescript@5.9.3)(vite@7.3.7(@types/node@26.6.4)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.1))(yaml@2.9.1)
|
||||
@@ -2408,6 +2414,9 @@ packages:
|
||||
'@types/resolve@1.20.2':
|
||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||
|
||||
'@types/sanitize-html@2.16.2':
|
||||
resolution: {integrity: sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
@@ -2936,6 +2945,9 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
dayjs@1.11.23:
|
||||
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
|
||||
|
||||
db0@0.3.4:
|
||||
resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==}
|
||||
peerDependencies:
|
||||
@@ -3023,16 +3035,32 @@ packages:
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
dom-serializer@3.1.1:
|
||||
resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domelementtype@3.0.0:
|
||||
resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domhandler@6.0.1:
|
||||
resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
domutils@4.0.2:
|
||||
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
dot-prop@10.2.0:
|
||||
resolution: {integrity: sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3074,6 +3102,10 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@8.1.0:
|
||||
resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
error-stack-parser-es@1.0.5:
|
||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||
|
||||
@@ -3399,6 +3431,10 @@ packages:
|
||||
hookified@2.2.0:
|
||||
resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==}
|
||||
|
||||
htmlparser2@12.0.0:
|
||||
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3503,6 +3539,10 @@ packages:
|
||||
resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-plain-object@5.1.0:
|
||||
resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-reference@1.2.1:
|
||||
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
|
||||
|
||||
@@ -3589,6 +3629,9 @@ packages:
|
||||
launch-editor@2.14.2:
|
||||
resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==}
|
||||
|
||||
launder@1.7.2:
|
||||
resolution: {integrity: sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==}
|
||||
|
||||
lazystream@1.0.1:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
@@ -4114,6 +4157,9 @@ packages:
|
||||
package-manager-detector@1.9.0:
|
||||
resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==}
|
||||
|
||||
parse-srcset@1.0.2:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -4464,6 +4510,10 @@ packages:
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
sanitize-html@2.18.0:
|
||||
resolution: {integrity: sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
sax@1.6.1:
|
||||
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
@@ -7448,6 +7498,10 @@ snapshots:
|
||||
|
||||
'@types/resolve@1.20.2': {}
|
||||
|
||||
'@types/sanitize-html@2.16.2':
|
||||
dependencies:
|
||||
htmlparser2: 12.0.0
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@typescript-eslint/project-service@8.71.1(typescript@5.9.3)':
|
||||
@@ -8073,6 +8127,8 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
dayjs@1.11.23: {}
|
||||
|
||||
db0@0.3.4: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -8116,18 +8172,36 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
dom-serializer@3.1.1:
|
||||
dependencies:
|
||||
domelementtype: 3.0.0
|
||||
domhandler: 6.0.1
|
||||
entities: 8.1.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domelementtype@3.0.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domhandler@6.0.1:
|
||||
dependencies:
|
||||
domelementtype: 3.0.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
domutils@4.0.2:
|
||||
dependencies:
|
||||
dom-serializer: 3.1.1
|
||||
domelementtype: 3.0.0
|
||||
domhandler: 6.0.1
|
||||
|
||||
dot-prop@10.2.0:
|
||||
dependencies:
|
||||
type-fest: 5.10.0
|
||||
@@ -8154,6 +8228,8 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
entities@8.1.0: {}
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
error-stack-parser-es@2.0.1: {}
|
||||
@@ -8605,6 +8681,13 @@ snapshots:
|
||||
|
||||
hookified@2.2.0: {}
|
||||
|
||||
htmlparser2@12.0.0:
|
||||
dependencies:
|
||||
domelementtype: 3.0.0
|
||||
domhandler: 6.0.1
|
||||
domutils: 4.0.2
|
||||
entities: 8.1.0
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -8706,6 +8789,8 @@ snapshots:
|
||||
|
||||
is-path-inside@4.0.0: {}
|
||||
|
||||
is-plain-object@5.1.0: {}
|
||||
|
||||
is-reference@1.2.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -8776,6 +8861,10 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
shell-quote: 1.12.0
|
||||
|
||||
launder@1.7.2:
|
||||
dependencies:
|
||||
dayjs: 1.11.23
|
||||
|
||||
lazystream@1.0.1:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
@@ -9999,6 +10088,8 @@ snapshots:
|
||||
|
||||
package-manager-detector@1.9.0: {}
|
||||
|
||||
parse-srcset@1.0.2: {}
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
@@ -10340,6 +10431,16 @@ snapshots:
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
sanitize-html@2.18.0:
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
escape-string-regexp: 4.0.0
|
||||
htmlparser2: 12.0.0
|
||||
is-plain-object: 5.1.0
|
||||
launder: 1.7.2
|
||||
parse-srcset: 1.0.2
|
||||
postcss: 8.5.29
|
||||
|
||||
sax@1.6.1: {}
|
||||
|
||||
scule@1.3.0: {}
|
||||
|
||||
@@ -20,6 +20,12 @@ for tool in git pnpm node; do
|
||||
done
|
||||
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/journal-git-install.XXXXXX")"
|
||||
# Nuxt applies its auto-import transform to any path with a
|
||||
# /vue-journal-layer/ segment (how it finds a layer inside node_modules), so a
|
||||
# scratch app below such a directory would transform every dependency.
|
||||
case "$WORK/" in
|
||||
*/vue-journal-layer/*) rm -rf "$WORK"; die "TMPDIR must not be inside a directory named vue-journal-layer" ;;
|
||||
esac
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -56,6 +62,7 @@ cat >nuxt.config.ts <<'TS'
|
||||
export default defineNuxtConfig({
|
||||
extends: ['vue-journal-layer'],
|
||||
compatibilityDate: '2025-07-15',
|
||||
site: { url: 'https://acme.test', name: 'Acme Blog' },
|
||||
i18n: {
|
||||
strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
|
||||
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 }
|
||||
})
|
||||
40
server/api/__sitemap__/journal.ts
Normal file
40
server/api/__sitemap__/journal.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// Sitemap source for Journal content (sitemap.sources in the layer config).
|
||||
// It pages GET /posts at per_page=30 until meta.last_page, fetches the
|
||||
// category tree with include_empty=1, and builds every loc from
|
||||
// journal.routes. The API base is server runtime config, never request input.
|
||||
|
||||
import { createJournalClient, type JournalFetcher } from '../../../app/utils/client.ts'
|
||||
import { buildJournalSitemapEntries } from '../../../app/utils/sitemap.ts'
|
||||
import type { JournalPost, JournalRoutes } from '../../../app/utils/types.ts'
|
||||
|
||||
/** Hard stop for a runaway last_page (30 000 posts). */
|
||||
const MAX_PAGES = 1000
|
||||
|
||||
export default defineSitemapEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
const routes = (config.public.journal as { routes: JournalRoutes }).routes
|
||||
|
||||
// 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)
|
||||
|
||||
const posts: JournalPost[] = []
|
||||
if (routes.post !== false) {
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const res = await api.listPosts({ page, perPage: 30 })
|
||||
posts.push(...res.data)
|
||||
if (page >= res.meta.last_page) break
|
||||
}
|
||||
}
|
||||
const categories = routes.category !== false ? await api.listCategories() : []
|
||||
|
||||
return buildJournalSitemapEntries(posts, categories, routes)
|
||||
})
|
||||
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
|
||||
})
|
||||
})
|
||||
36
server/routes/rss.xml.ts
Normal file
36
server/routes/rss.xml.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// /rss.xml: the Journal API's RSS 2.0 feed, served from the site origin.
|
||||
// The visitor's x-forwarded-for is forwarded (plus the peer address) so the
|
||||
// API's rate limiter keeps one bucket per visitor. 404 when journal.rss is
|
||||
// false, 502 when the API does not answer.
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
if ((config.public.journal as { rss?: boolean }).rss === false) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not found' })
|
||||
}
|
||||
const base = (config.journal as { apiBase: string }).apiBase
|
||||
|
||||
// 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 {
|
||||
body = await $fetch<string>('/rss', {
|
||||
baseURL: base,
|
||||
responseType: 'text',
|
||||
headers: forwarded ? { 'x-forwarded-for': forwarded } : {},
|
||||
})
|
||||
} catch {
|
||||
setResponseStatus(event, 502)
|
||||
setResponseHeader(event, 'content-type', 'text/plain; charset=utf-8')
|
||||
return 'The feed is unavailable.'
|
||||
}
|
||||
|
||||
setResponseHeader(event, 'content-type', 'application/rss+xml; charset=utf-8')
|
||||
return body
|
||||
})
|
||||
@@ -1,9 +1,45 @@
|
||||
# SSR expectations against the acme fixtures (testing/fixtures/acme).
|
||||
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
|
||||
# The consumer enables en (default, no prefix) and pl (/pl prefix).
|
||||
# The consumer enables en (default, no prefix) and pl (/pl prefix), and sets
|
||||
# site.url to https://acme.test.
|
||||
|
||||
# Single post
|
||||
200 /blog/hello-acme contains=Hello Acme
|
||||
200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ
|
||||
404 /blog/missing-post
|
||||
200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(
|
||||
200 /blog/unsafe-sample contains=<p>Before</p><p>After</p> notcontains=alert(
|
||||
404 /blog/draft-note
|
||||
|
||||
# Post index and pagination (9 per page, 13 published posts: 2 pages)
|
||||
200 /blog contains=Hello Acme
|
||||
200 /blog?page=2
|
||||
404 /blog?page=3
|
||||
404 /blog?page=abc
|
||||
404 /blog?page=0
|
||||
200 /pl/blog contains=Najnowsze wpisy
|
||||
|
||||
# Categories (include_empty=1; content renders above the list)
|
||||
200 /blog/category/news contains=Acme news marker
|
||||
200 /blog/category/quiet contains=There are no posts in this category yet.
|
||||
404 /blog/category/nope
|
||||
|
||||
# Related posts fall back to the latest posts for a post without a category
|
||||
200 /blog/uncategorised-note contains=Hello Acme
|
||||
|
||||
# Author (slug and login resolve) and tag pages
|
||||
200 /blog/author/jane-doe contains=Jane Doe
|
||||
200 /blog/author/jdoe contains=<link rel="canonical" href="https://acme.test/blog/author/jane-doe"
|
||||
404 /blog/author/nobody
|
||||
200 /blog/tag/release contains=Hello Acme
|
||||
404 /blog/tag/nope
|
||||
|
||||
# RSS, sitemap, canonical and meta
|
||||
200 /rss.xml type=application/rss+xml
|
||||
200 /blog contains=href="/rss.xml"
|
||||
200 /sitemap.xml contains=/blog/hello-acme
|
||||
200 /sitemap.xml contains=/blog/category/quiet
|
||||
200 /sitemap.xml notcontains=/blog/draft-note
|
||||
200 /blog?page=2 contains=?page=2
|
||||
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.
|
||||
|
||||
@@ -18,7 +18,7 @@ const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title
|
||||
|
||||
/**
|
||||
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], reload: () => void, close: () => Promise<void> }} FixtureApi
|
||||
*/
|
||||
|
||||
/** @param {string} dir @param {string} name @param {any} fallback */
|
||||
@@ -118,19 +118,28 @@ function sibling(post) {
|
||||
*/
|
||||
export function startFixtureApi({ dir, port = 0 }) {
|
||||
/** @type {any[]} */
|
||||
const posts = readJson(dir, 'posts.json', [])
|
||||
let posts = []
|
||||
/** @type {any[]} */
|
||||
const categories = readJson(dir, 'categories.json', [])
|
||||
let categories = []
|
||||
/** @type {any[]} */
|
||||
const tags = readJson(dir, 'tags.json', [])
|
||||
let tags = []
|
||||
/** @type {any[]} */
|
||||
const authors = readJson(dir, 'authors.json', [])
|
||||
let authors = []
|
||||
let rss = ''
|
||||
try {
|
||||
rss = readFileSync(join(dir, 'rss.xml'), 'utf8')
|
||||
} catch {
|
||||
rss = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>'
|
||||
// Reads (or re-reads) the fixtures, so a check can change a fixture file
|
||||
// while the API runs.
|
||||
const load = () => {
|
||||
posts = readJson(dir, 'posts.json', [])
|
||||
categories = readJson(dir, 'categories.json', [])
|
||||
tags = readJson(dir, 'tags.json', [])
|
||||
authors = readJson(dir, 'authors.json', [])
|
||||
try {
|
||||
rss = readFileSync(join(dir, 'rss.xml'), 'utf8')
|
||||
} catch {
|
||||
rss = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>'
|
||||
}
|
||||
}
|
||||
load()
|
||||
|
||||
/** @type {FixtureRequest[]} */
|
||||
const requests = []
|
||||
@@ -240,6 +249,7 @@ export function startFixtureApi({ dir, port = 0 }) {
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
requests,
|
||||
reload: load,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"slug": "hello-acme",
|
||||
"excerpt": "Acme says hello and introduces its blog.",
|
||||
"content": "## Welcome to Acme\n\nAcme builds anvils, rockets and very fast roller skates.\n",
|
||||
"content_html": "<h2>Welcome to Acme</h2>\n<p>Acme builds anvils, rockets and very fast roller skates.</p>\n",
|
||||
"content_html": "<h2>Welcome to Acme</h2>\n<p>Acme builds anvils, rockets and very fast roller skates.</p>\n<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>\n",
|
||||
"reading_time": 1,
|
||||
"published": true,
|
||||
"published_at": "2026-01-14T09:00:00+00:00",
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -10,7 +10,8 @@
|
||||
// end of the line, so it may contain spaces. Requests are plain GETs that do
|
||||
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`. After the
|
||||
// lines, at least one fixture API request must have carried that address
|
||||
// (the SSR fetches forward the visitor's x-forwarded-for).
|
||||
// (the SSR fetches forward the visitor's x-forwarded-for), and every
|
||||
// categories request must have carried include_empty=1.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -114,6 +115,13 @@ async function main() {
|
||||
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
failures.push('x-forwarded-for: x-forwarded-for not forwarded')
|
||||
}
|
||||
// Categories are always requested with include_empty=1, so empty ones resolve.
|
||||
for (const r of api.requests) {
|
||||
const u = new URL(r.url, 'http://fixture.local')
|
||||
if (u.pathname.endsWith('/categories') && u.searchParams.get('include_empty') !== '1') {
|
||||
failures.push(`include_empty: ${r.url} requested without include_empty=1`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (nuxt) await nuxt.close()
|
||||
await api.close()
|
||||
|
||||
185
tests/client.test.ts
Normal file
185
tests/client.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// The Journal client against a fake fetcher: query building, include_empty,
|
||||
// category lookup and 404 handling. No network.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
JOURNAL_DEFAULT_PER_PAGE,
|
||||
JOURNAL_MAX_PER_PAGE,
|
||||
buildPostListQuery,
|
||||
clampPerPage,
|
||||
createJournalClient,
|
||||
encodeSlugPath,
|
||||
flattenCategories,
|
||||
isNotFound,
|
||||
} from '../app/utils/client.ts'
|
||||
import type { JournalCategory } from '../app/utils/types.ts'
|
||||
|
||||
type Call = { path: string; query?: Record<string, string | number> }
|
||||
|
||||
// A fetcher that records each call and answers from `routes` by path; a
|
||||
// function value may throw to simulate an HTTP error.
|
||||
function fakeFetcher(routes: Record<string, unknown | (() => unknown)>) {
|
||||
const calls: Call[] = []
|
||||
const fetcher = async (path: string, query?: Record<string, string | number>) => {
|
||||
calls.push(query === undefined ? { path } : { path, query })
|
||||
if (!(path in routes)) throw Object.assign(new Error(`no route ${path}`), { status: 404 })
|
||||
const value = routes[path]
|
||||
return typeof value === 'function' ? (value as () => unknown)() : value
|
||||
}
|
||||
return { fetcher, calls }
|
||||
}
|
||||
|
||||
function category(id: number, slug: string, children?: JournalCategory[]): JournalCategory {
|
||||
const row: JournalCategory = {
|
||||
id,
|
||||
name: slug,
|
||||
slug,
|
||||
description: null,
|
||||
content: null,
|
||||
parent_id: null,
|
||||
nest_depth: 0,
|
||||
post_count: 0,
|
||||
}
|
||||
if (children) row.children = children
|
||||
return row
|
||||
}
|
||||
|
||||
const TREE = [category(1, 'grzyby', [category(2, 'cordyceps', [category(3, 'militaris')])]), category(4, 'chaga')]
|
||||
|
||||
test('the per_page limits match the API', () => {
|
||||
assert.equal(JOURNAL_MAX_PER_PAGE, 30)
|
||||
assert.equal(JOURNAL_DEFAULT_PER_PAGE, 9)
|
||||
})
|
||||
|
||||
test('clampPerPage clamps to 1..30 and defaults to 9', () => {
|
||||
assert.equal(clampPerPage(0), 1)
|
||||
assert.equal(clampPerPage(-5), 1)
|
||||
assert.equal(clampPerPage(31), 30)
|
||||
assert.equal(clampPerPage(12), 12)
|
||||
assert.equal(clampPerPage(12.7), 12)
|
||||
assert.equal(clampPerPage('12'), 12)
|
||||
assert.equal(clampPerPage('abc'), 9)
|
||||
assert.equal(clampPerPage(''), 9)
|
||||
assert.equal(clampPerPage(undefined), 9)
|
||||
assert.equal(clampPerPage(Number.NaN), 9)
|
||||
})
|
||||
|
||||
test('buildPostListQuery always sends per_page and the rest only when set', () => {
|
||||
assert.deepEqual(buildPostListQuery(), { per_page: 9 })
|
||||
assert.deepEqual(
|
||||
buildPostListQuery({ page: 2, perPage: 12, sort: 'published_at desc', category: 'cordyceps', tag: 'zdrowie' }),
|
||||
{ per_page: 12, page: 2, sort: 'published_at desc', category: 'cordyceps', tag: 'zdrowie' },
|
||||
)
|
||||
})
|
||||
|
||||
test('buildPostListQuery drops an invalid page and empty strings', () => {
|
||||
assert.deepEqual(buildPostListQuery({ page: 0, sort: '', category: '', tag: '' }), { per_page: 9 })
|
||||
assert.deepEqual(buildPostListQuery({ page: 1.5 }), { per_page: 9 })
|
||||
assert.deepEqual(buildPostListQuery({ perPage: 100 }), { per_page: 30 })
|
||||
})
|
||||
|
||||
test('encodeSlugPath keeps / as a separator', () => {
|
||||
assert.equal(encodeSlugPath('a b/c?d'), 'a%20b/c%3Fd')
|
||||
})
|
||||
|
||||
test('flattenCategories walks parents before children', () => {
|
||||
assert.deepEqual(flattenCategories(TREE).map((c) => c.slug), ['grzyby', 'cordyceps', 'militaris', 'chaga'])
|
||||
assert.deepEqual(flattenCategories(null), [])
|
||||
})
|
||||
|
||||
test('isNotFound reads status, statusCode and response.status', () => {
|
||||
assert.equal(isNotFound({ status: 404 }), true)
|
||||
assert.equal(isNotFound({ statusCode: 404 }), true)
|
||||
assert.equal(isNotFound({ response: { status: 404 } }), true)
|
||||
assert.equal(isNotFound({ status: 500 }), false)
|
||||
assert.equal(isNotFound(new Error('boom')), false)
|
||||
assert.equal(isNotFound(null), false)
|
||||
assert.equal(isNotFound('404'), false)
|
||||
})
|
||||
|
||||
test('listCategories always sends include_empty=1', async () => {
|
||||
const { fetcher, calls } = fakeFetcher({ '/categories': { data: TREE } })
|
||||
const client = createJournalClient(fetcher)
|
||||
assert.deepEqual(await client.listCategories(), TREE)
|
||||
await client.findCategory('chaga')
|
||||
assert.equal(calls.length, 2)
|
||||
for (const call of calls) {
|
||||
assert.equal(call.path, '/categories')
|
||||
assert.deepEqual(call.query, { include_empty: 1 })
|
||||
}
|
||||
})
|
||||
|
||||
test('listCategories gives [] for a malformed body', async () => {
|
||||
const { fetcher } = fakeFetcher({ '/categories': { data: 'nope' } })
|
||||
assert.deepEqual(await createJournalClient(fetcher).listCategories(), [])
|
||||
})
|
||||
|
||||
test('findCategory finds a nested child and null for an unknown slug', async () => {
|
||||
const { fetcher } = fakeFetcher({ '/categories': { data: TREE } })
|
||||
const client = createJournalClient(fetcher)
|
||||
assert.equal((await client.findCategory('militaris'))?.id, 3)
|
||||
assert.equal(await client.findCategory('reishi'), null)
|
||||
})
|
||||
|
||||
test('listPosts sends the built query', async () => {
|
||||
const page = { data: [], meta: { current_page: 2, last_page: 2, per_page: 12, total: 13 } }
|
||||
const { fetcher, calls } = fakeFetcher({ '/posts': page })
|
||||
const res = await createJournalClient(fetcher).listPosts({ page: 2, perPage: 12, category: 'cordyceps' })
|
||||
assert.deepEqual(res, page)
|
||||
assert.deepEqual(calls, [{ path: '/posts', query: { per_page: 12, page: 2, category: 'cordyceps' } }])
|
||||
})
|
||||
|
||||
test('getPost returns the data and encodes the slug', async () => {
|
||||
const { fetcher, calls } = fakeFetcher({ '/posts/a%20b': { data: { id: 1, slug: 'a b' } } })
|
||||
const post = await createJournalClient(fetcher).getPost('a b')
|
||||
assert.equal(post?.id, 1)
|
||||
assert.equal(calls[0]?.path, '/posts/a%20b')
|
||||
})
|
||||
|
||||
test('getPost returns null for status 404 and statusCode 404', async () => {
|
||||
const byStatus = fakeFetcher({
|
||||
'/posts/gone': () => {
|
||||
throw Object.assign(new Error('Not Found'), { status: 404 })
|
||||
},
|
||||
})
|
||||
assert.equal(await createJournalClient(byStatus.fetcher).getPost('gone'), null)
|
||||
const byStatusCode = fakeFetcher({
|
||||
'/posts/gone': () => {
|
||||
throw Object.assign(new Error('Not Found'), { statusCode: 404 })
|
||||
},
|
||||
})
|
||||
assert.equal(await createJournalClient(byStatusCode.fetcher).getPost('gone'), null)
|
||||
})
|
||||
|
||||
test('getPost rethrows a 500', async () => {
|
||||
const { fetcher } = fakeFetcher({
|
||||
'/posts/boom': () => {
|
||||
throw Object.assign(new Error('Server Error'), { status: 500 })
|
||||
},
|
||||
})
|
||||
await assert.rejects(createJournalClient(fetcher).getPost('boom'), /Server Error/)
|
||||
})
|
||||
|
||||
test('listTags returns the data or []', async () => {
|
||||
const tags = [{ id: 1, name: 'zdrowie', slug: 'zdrowie' }]
|
||||
assert.deepEqual(await createJournalClient(fakeFetcher({ '/tags': { data: tags } }).fetcher).listTags(), tags)
|
||||
assert.deepEqual(await createJournalClient(fakeFetcher({ '/tags': {} }).fetcher).listTags(), [])
|
||||
})
|
||||
|
||||
test('getAuthor and listAuthorPosts encode the author and map 404 to null', async () => {
|
||||
const author = { id: 7, slug: 'jan kowalski', first_name: 'Jan', last_name: 'Kowalski' }
|
||||
const page = { data: [], meta: { current_page: 1, last_page: 1, per_page: 10, total: 0 } }
|
||||
const { fetcher, calls } = fakeFetcher({
|
||||
'/authors/jan%20kowalski': { data: author },
|
||||
'/authors/jan%20kowalski/posts': page,
|
||||
})
|
||||
const client = createJournalClient(fetcher)
|
||||
assert.deepEqual(await client.getAuthor('jan kowalski'), author)
|
||||
assert.deepEqual(await client.listAuthorPosts('jan kowalski', { page: 2, perPage: 10, sort: 'published_at desc' }), page)
|
||||
assert.deepEqual(calls[1], {
|
||||
path: '/authors/jan%20kowalski/posts',
|
||||
query: { per_page: 10, page: 2, sort: 'published_at desc' },
|
||||
})
|
||||
assert.equal(await client.getAuthor('nobody'), null)
|
||||
assert.equal(await client.listAuthorPosts('nobody'), null)
|
||||
})
|
||||
175
tests/embeds.test.ts
Normal file
175
tests/embeds.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Embed expansion (Phase 16.2, D-17). The Blog Editor stores the placeholder
|
||||
// <figure data-embed="youtube" data-id="ID"></figure>; the sinks render
|
||||
// expandEmbeds(sanitizeJournalHtml(html)), so the sanitizer runs first and the
|
||||
// only iframe in the output is the one built from a validated placeholder.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { EMBED_PLACEHOLDER, EMBED_PROVIDERS, expandEmbeds } from '../app/utils/embeds.ts'
|
||||
import { sanitizeJournalHtml } from '../app/utils/htmlGate.ts'
|
||||
|
||||
const ID = 'dQw4w9WgXcQ'
|
||||
const PLACEHOLDER = `<figure data-embed="youtube" data-id="${ID}"></figure>`
|
||||
const IFRAME =
|
||||
'<figure class="journal-embed journal-embed--youtube">' +
|
||||
`<iframe src="https://www.youtube-nocookie.com/embed/${ID}" title="YouTube video" loading="lazy" ` +
|
||||
'referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe>' +
|
||||
'</figure>'
|
||||
|
||||
// The sink: sanitize, then expand last.
|
||||
const sink = (html: string) => expandEmbeds(sanitizeJournalHtml(html))
|
||||
// The payload transform sanitizes, and the sink sanitizes again before expanding.
|
||||
const payloadThenSink = (html: string) => expandEmbeds(sanitizeJournalHtml(sanitizeJournalHtml(html)))
|
||||
|
||||
const iframeCount = (html: string) => (html.match(/<iframe/g) ?? []).length
|
||||
|
||||
test('the placeholder expands to the privacy-enhanced YouTube iframe', () => {
|
||||
assert.equal(expandEmbeds(PLACEHOLDER), IFRAME)
|
||||
assert.equal(expandEmbeds(`<p>a</p>${PLACEHOLDER}<p>b</p>`), `<p>a</p>${IFRAME}<p>b</p>`)
|
||||
})
|
||||
|
||||
test('the provider table holds YouTube only, on youtube-nocookie.com', () => {
|
||||
assert.deepEqual(Object.keys(EMBED_PROVIDERS), ['youtube'])
|
||||
assert.equal(EMBED_PROVIDERS.youtube!.src(ID), `https://www.youtube-nocookie.com/embed/${ID}`)
|
||||
assert.ok(EMBED_PLACEHOLDER.global)
|
||||
})
|
||||
|
||||
test('malformed or unknown placeholders stay unchanged and inert', () => {
|
||||
const inert = [
|
||||
`<figure data-embed="youtube" data-id="${ID.slice(0, 10)}"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}Q"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="dQw4w9WgX/Q"></figure>`,
|
||||
`<figure data-embed="vimeo" data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="constructor" data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="__proto__" data-id="${ID}"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}" class="x"></figure>`,
|
||||
`<figure class="x" data-embed="youtube" data-id="${ID}"></figure>`,
|
||||
`<figure data-id="${ID}" data-embed="youtube"></figure>`,
|
||||
`<figure data-embed="youtube" data-id="${ID}"><figcaption>c</figcaption></figure>`,
|
||||
`<figure data-embed='youtube' data-id='${ID}'></figure>`,
|
||||
]
|
||||
for (const html of inert) assert.equal(expandEmbeds(html), html, html)
|
||||
})
|
||||
|
||||
test('empty input gives ""', () => {
|
||||
for (const html of [null, undefined, '']) assert.equal(expandEmbeds(html), '')
|
||||
})
|
||||
|
||||
test('the sink turns a stored placeholder into the iframe', () => {
|
||||
assert.equal(sink(PLACEHOLDER), IFRAME)
|
||||
assert.equal(sink(`<h2>Title</h2>\n${PLACEHOLDER}\n<p>After</p>`), `<h2>Title</h2>\n${IFRAME}\n<p>After</p>`)
|
||||
})
|
||||
|
||||
test('a raw iframe in stored HTML is stripped; only the placeholder becomes an iframe', () => {
|
||||
const stored =
|
||||
'<p>Before</p>' +
|
||||
'<iframe src="https://evil.test/"></iframe>' +
|
||||
`<iframe src="https://www.youtube-nocookie.com/embed/${ID}"></iframe>` +
|
||||
PLACEHOLDER +
|
||||
'<p>After</p>'
|
||||
for (const run of [sink, payloadThenSink]) {
|
||||
const out = run(stored)
|
||||
assert.equal(out, `<p>Before</p>${IFRAME}<p>After</p>`)
|
||||
assert.equal(iframeCount(out), 1)
|
||||
assert.doesNotMatch(out, /evil\.test/)
|
||||
}
|
||||
})
|
||||
|
||||
test('a placeholder smuggling an iframe or extra attributes expands only its validated parts', () => {
|
||||
const stored = `<figure data-embed="youtube" data-id="${ID}" data-src="https://evil.test/" onload="alert(1)"><iframe src="https://evil.test/"></iframe></figure>`
|
||||
assert.equal(sink(stored), IFRAME)
|
||||
assert.equal(payloadThenSink(stored), IFRAME)
|
||||
})
|
||||
|
||||
test('a malformed placeholder stays an empty figure after the sink', () => {
|
||||
assert.equal(sink(`<figure data-embed="youtube" data-id="short"></figure>`), '<figure data-embed="youtube"></figure>')
|
||||
assert.equal(sink(`<figure data-embed="vimeo" data-id="${ID}"></figure>`), `<figure data-id="${ID}"></figure>`)
|
||||
assert.equal(iframeCount(sink(`<figure data-embed="vimeo" data-id="${ID}"></figure>`)), 0)
|
||||
})
|
||||
|
||||
test('the payload-then-sink pipeline gives the same result as the sink', () => {
|
||||
const stored = `<p>x</p>${PLACEHOLDER}<script>alert(1)</script><p>y</p>`
|
||||
assert.equal(payloadThenSink(stored), sink(stored))
|
||||
assert.equal(sink(stored), `<p>x</p>${IFRAME}<p>y</p>`)
|
||||
})
|
||||
|
||||
test('two placeholders in one body both expand', () => {
|
||||
const other = 'abcdefghijk'
|
||||
const out = sink(`${PLACEHOLDER}<p>between</p><figure data-embed="youtube" data-id="${other}"></figure>`)
|
||||
assert.equal(iframeCount(out), 2)
|
||||
assert.equal(out, `${IFRAME}<p>between</p>${IFRAME.replace(ID, other)}`)
|
||||
})
|
||||
|
||||
test('the editor corpus embed entries expand after sanitizing', () => {
|
||||
const corpus = JSON.parse(
|
||||
readFileSync(new URL('./fixtures/editor-corpus.json', import.meta.url), 'utf8'),
|
||||
) as Array<{ name: string; html: string }>
|
||||
const embed = corpus.find((e) => e.name === 'embed-youtube')
|
||||
assert.ok(embed)
|
||||
assert.equal(payloadThenSink(embed.html), IFRAME)
|
||||
const combined = corpus.find((e) => e.name === 'combined')
|
||||
assert.ok(combined)
|
||||
const out = payloadThenSink(combined.html)
|
||||
assert.ok(out.startsWith(IFRAME), out)
|
||||
assert.equal(iframeCount(out), 1)
|
||||
// Entries without a placeholder pass through the expansion unchanged.
|
||||
for (const entry of corpus.filter((e) => !e.html.includes('data-embed'))) {
|
||||
const clean = sanitizeJournalHtml(entry.html)
|
||||
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)/)
|
||||
})
|
||||
62
tests/fixtures/editor-corpus.json
vendored
Normal file
62
tests/fixtures/editor-corpus.json
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"name": "embed-youtube",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>"
|
||||
},
|
||||
{
|
||||
"name": "image-without-caption",
|
||||
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p>"
|
||||
},
|
||||
{
|
||||
"name": "image-with-caption",
|
||||
"html": "<figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>"
|
||||
},
|
||||
{
|
||||
"name": "table-header-row",
|
||||
"html": "<table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table>"
|
||||
},
|
||||
{
|
||||
"name": "links",
|
||||
"html": "<p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "headings",
|
||||
"html": "<h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4>"
|
||||
},
|
||||
{
|
||||
"name": "bullet-list",
|
||||
"html": "<ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list",
|
||||
"html": "<ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "blockquote",
|
||||
"html": "<blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list-start-3",
|
||||
"html": "<ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "inline-marks",
|
||||
"html": "<p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "code-block",
|
||||
"html": "<pre><code>dawka = 2 * 500 // mg</code></pre>"
|
||||
},
|
||||
{
|
||||
"name": "horizontal-rule",
|
||||
"html": "<p>Przed linią</p><hr><p>Po linii</p>"
|
||||
},
|
||||
{
|
||||
"name": "hard-break",
|
||||
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
||||
},
|
||||
{
|
||||
"name": "combined",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
|
||||
}
|
||||
]
|
||||
335
tests/htmlGate.test.ts
Normal file
335
tests/htmlGate.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
// The Journal HTML sanitizer mirrors the Journal plugin's bluemonday policy
|
||||
// (classes/format_html.go). The unsafe cases are the six of
|
||||
// TestFormatHTMLRejectsUnsafeHTML plus every bypass from the Phase 16.1 code
|
||||
// review (CR-01), as rendered HTML. Each one must come out without the
|
||||
// dangerous part while the safe text around it stays.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { gateJournalHtml, isSafeJournalHtml, sanitizeJournalHtml } from '../app/utils/htmlGate.ts'
|
||||
|
||||
// Signs that something executable or navigating survived.
|
||||
const DANGER = [
|
||||
/<script/i,
|
||||
/<svg/i,
|
||||
/<math/i,
|
||||
/<iframe/i,
|
||||
/<object/i,
|
||||
/<embed/i,
|
||||
/<base/i,
|
||||
/<meta/i,
|
||||
/<form/i,
|
||||
/<input/i,
|
||||
/<style/i,
|
||||
/\son[a-z]+\s*=/i,
|
||||
/javascript/i,
|
||||
/vbscript/i,
|
||||
/href="data:/i,
|
||||
/src="data:/i,
|
||||
/xlink:href/i,
|
||||
/evil\.test/i,
|
||||
]
|
||||
|
||||
function assertNeutralized(html: string, label: string): string {
|
||||
const out = sanitizeJournalHtml(html)
|
||||
for (const re of DANGER) assert.doesNotMatch(out, re, `${label}: ${out}`)
|
||||
assert.equal(isSafeJournalHtml(html), false, `${label} reported safe`)
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: false, html: out }, label)
|
||||
return out
|
||||
}
|
||||
|
||||
const GO_CASES: Array<[string, string]> = [
|
||||
['script', '<p>keep</p><script>alert(1)</script>'],
|
||||
['iframe', '<p>keep</p><iframe src="https://evil.test"></iframe>'],
|
||||
['event', '<p>keep<img src=x onerror="alert(1)"></p>'],
|
||||
['javascript', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['vbscript', '<a href="vbscript:msgbox(1)">keep</a>'],
|
||||
['data', '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">keep</a>'],
|
||||
]
|
||||
|
||||
// CR-01: payloads the three-regex gate let through, and their neighbours.
|
||||
const REVIEW_BYPASSES: Array<[string, string]> = [
|
||||
['attribute without a space', '<p>keep<img src="x"onerror="alert(1)"></p>'],
|
||||
['svg with slash separator', '<div>\n<svg/onload=alert(1)>\n</div><p>keep</p>'],
|
||||
['numeric entity colon', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['named entity colon', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['entity in the scheme', '<a href="javascript:alert(1)">keep</a>'],
|
||||
['tab in the scheme', '<a href="java\tscript:alert(1)">keep</a>'],
|
||||
['newline in the scheme', '<a href="java\nscript:alert(1)">keep</a>'],
|
||||
['leading control characters', '<a href="\u0001 javascript:alert(1)">keep</a>'],
|
||||
['upper case scheme', '<a href="JAVASCRIPT:alert(1)">keep</a>'],
|
||||
['MathML xlink:href', '<div>\n<math><maction actiontype=statusline xlink:href="javascript:alert(1)">keep</maction></math>\n</div>'],
|
||||
['base', '<base href="https://evil.test/"><p>keep</p>'],
|
||||
['meta refresh', '<meta http-equiv="refresh" content="0;url=https://evil.test"><p>keep</p>'],
|
||||
['form phish', '<form action="https://evil.test"><input type=password name=p></form><p>keep</p>'],
|
||||
['embed', '<embed src="https://evil.test/x.swf"><p>keep</p>'],
|
||||
['object', '<object data="https://evil.test/x"></object><p>keep</p>'],
|
||||
['data image src', '<p>keep<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="></p>'],
|
||||
['style element', '<style>body{background:url(https://evil.test/x)}</style><p>keep</p>'],
|
||||
['css url in style attribute', '<p style="background:url(https://evil.test/x)">keep</p>'],
|
||||
['css expression in colour', '<p style="color: expression(alert(1))">keep</p>'],
|
||||
['noscript title breakout', '<noscript><p title="</noscript><img src=x onerror=alert(1)>"></p></noscript><p>keep</p>'],
|
||||
['handler in upper case', '<P ONMOUSEOVER="alert(1)">keep</P>'],
|
||||
]
|
||||
|
||||
for (const [name, html] of [...GO_CASES, ...REVIEW_BYPASSES]) {
|
||||
test(`${name} is neutralized and the rest is kept`, () => {
|
||||
const out = assertNeutralized(html, name)
|
||||
assert.match(out, /keep/, `${name} lost the safe text: ${out}`)
|
||||
})
|
||||
}
|
||||
|
||||
test('the Go cases in upper case are neutralized', () => {
|
||||
for (const [name, html] of GO_CASES) assertNeutralized(html.toUpperCase(), `${name} upper`)
|
||||
})
|
||||
|
||||
test('comments are dropped; a comment is not content, so the input still counts as safe', () => {
|
||||
const html = '<!--<img src=x onerror=alert(1)>--><p>keep</p>'
|
||||
assert.equal(sanitizeJournalHtml(html), '<p>keep</p>')
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html: '<p>keep</p>' })
|
||||
})
|
||||
|
||||
test('unsafe parts are removed, not the whole value', () => {
|
||||
assert.equal(sanitizeJournalHtml('<p>Before</p><script>alert(1)</script><p>After</p>'), '<p>Before</p><p>After</p>')
|
||||
assert.equal(sanitizeJournalHtml('<p><img src="x"onerror="alert(1)"> tekst</p>'), '<p><img src="x" /> tekst</p>')
|
||||
assert.equal(sanitizeJournalHtml('<a href="javascript:alert(1)">link</a>'), '<a>link</a>')
|
||||
})
|
||||
|
||||
test('text that names a scheme is ordinary text (WR-01)', () => {
|
||||
const texts = [
|
||||
'<p>Data: 12 maja 2024</p>',
|
||||
'<p>Data publikacji: 3 czerwca 2025</p>',
|
||||
'<p>The metadata: title, author and date.</p>',
|
||||
'<p>Big data: what it means. JavaScript: a language. vbscript: legacy.</p>',
|
||||
]
|
||||
for (const html of texts) {
|
||||
assert.equal(sanitizeJournalHtml(html), html)
|
||||
assert.equal(isSafeJournalHtml(html), true, html)
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
|
||||
}
|
||||
})
|
||||
|
||||
// The author footer of an imported post (post 64), without its <style> block.
|
||||
const AUTHOR_FOOTER = `<footer class="author-footer" itemscope itemtype="https://schema.org/Person">
|
||||
<div class="author-info">
|
||||
<img src="https://blog.acme.test/storage/app/media/Jane%20Doe.png" alt="Zdjęcie autora Jane Doe" class="author-photo" itemprop="image">
|
||||
<div class="author-details">
|
||||
<div style="font-size: 1.5em; font-weight: bold;" itemprop="name">Jane Doe</div>
|
||||
<span style="color: #138c74;" itemprop="jobTitle">Ekspert Mykologii</span>
|
||||
<p itemprop="description">Jestem entuzjastką grzybów, szczególnie tych funkcjonalnych.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>`
|
||||
|
||||
test('an imported author footer keeps classes, microdata and inline styles', () => {
|
||||
const out = sanitizeJournalHtml(AUTHOR_FOOTER)
|
||||
assert.equal(isSafeJournalHtml(AUTHOR_FOOTER), true)
|
||||
for (const part of [
|
||||
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person">',
|
||||
'<div class="author-info">',
|
||||
'<img src="https://blog.acme.test/storage/app/media/Jane%20Doe.png" alt="Zdjęcie autora Jane Doe" class="author-photo" itemprop="image" />',
|
||||
'<div style="font-size:1.5em;font-weight:bold" itemprop="name">Jane Doe</div>',
|
||||
'<span style="color:#138c74" itemprop="jobTitle">Ekspert Mykologii</span>',
|
||||
'<p itemprop="description">',
|
||||
]) {
|
||||
assert.ok(out.includes(part), `missing ${part} in ${out}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the footer with its <style> block loses only the style block', () => {
|
||||
const html = `${AUTHOR_FOOTER}\n<style>\n .author-footer { padding: 20px; }\n</style>\n<hr>`
|
||||
const out = sanitizeJournalHtml(html)
|
||||
assert.equal(isSafeJournalHtml(html), false)
|
||||
assert.doesNotMatch(out, /<style|padding/)
|
||||
assert.equal(out, `${sanitizeJournalHtml(AUTHOR_FOOTER)}\n\n<hr />`)
|
||||
})
|
||||
|
||||
test('inline styles keep only the four allowed properties with valid values', () => {
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<p style="color: red; font-size: 12px; font-weight: 700; text-align: center; position: fixed">x</p>'),
|
||||
'<p style="color:red;font-size:12px;font-weight:700;text-align:center">x</p>',
|
||||
)
|
||||
assert.equal(sanitizeJournalHtml('<p style="color: rgb(1, 2, 3)">x</p>'), '<p style="color:rgb(1, 2, 3)">x</p>')
|
||||
assert.equal(sanitizeJournalHtml('<p style="font-size: calc(1px + 1em)">x</p>'), '<p>x</p>')
|
||||
assert.equal(sanitizeJournalHtml('<p style="position: fixed">x</p>'), '<p>x</p>')
|
||||
})
|
||||
|
||||
test('goldmark markup passes whole: headings, tables, footnotes, links and images', () => {
|
||||
const safe = [
|
||||
'<h1>Hello</h1>',
|
||||
'<table><thead><tr><th>a</th><th style="text-align:right">b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>',
|
||||
'<p>See footnote.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></p>',
|
||||
'<div class="footnotes" role="doc-endnotes"><hr /><ol><li id="fn:1"><p>Note. <a href="#fnref:1" class="footnote-backref" role="doc-backlink">↩︎</a></p></li></ol></div>',
|
||||
'<p><a href="https://blog.acme.test/blog">Artykuły</a> <img src="/storage/app/media/las.webp" alt="las" /></p>',
|
||||
'<p><a href="mailto:hello@acme.test">mail</a> <a href="//cdn.acme.test/x">cdn</a> <a href="?page=2">page</a></p>',
|
||||
'<blockquote cite="https://acme.test/source"><p>Quote</p></blockquote>',
|
||||
'<p class="content">one of the onions</p>',
|
||||
]
|
||||
for (const html of safe) {
|
||||
assert.equal(sanitizeJournalHtml(html), html)
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html })
|
||||
}
|
||||
})
|
||||
|
||||
test('pasted chat transcripts keep dir="auto" and the two data attributes only', () => {
|
||||
const html = '<div dir="auto" data-message-author-role="user" data-message-id="abc-123">Hi</div>'
|
||||
assert.equal(sanitizeJournalHtml(html), html)
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<div data-message-author-role="a b" data-other="x" dir="sideways">Hi</div>'),
|
||||
'<div>Hi</div>',
|
||||
)
|
||||
})
|
||||
|
||||
test('attribute values must match their patterns', () => {
|
||||
assert.equal(sanitizeJournalHtml('<p itemtype="https://evil.test/Person">x</p>'), '<p>x</p>')
|
||||
assert.equal(sanitizeJournalHtml('<p class="a"b">x</p>'), '<p>x</p>')
|
||||
assert.equal(sanitizeJournalHtml('<a role="button" href="/x">x</a>'), '<a href="/x">x</a>')
|
||||
assert.equal(sanitizeJournalHtml('<td colspan="2;">x</td>'), '<td>x</td>')
|
||||
assert.equal(sanitizeJournalHtml('<a href="http://[bad">x</a>'), '<a>x</a>')
|
||||
assert.equal(sanitizeJournalHtml('<a target="_blank" rel="opener" href="/x">x</a>'), '<a href="/x">x</a>')
|
||||
})
|
||||
|
||||
test('attribute and tag names from Object.prototype find no rule', () => {
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<a constructor="x" tostring="y" __proto__="z" href="/a">x</a><constructor>t</constructor>'),
|
||||
'<a href="/a">x</a>t',
|
||||
)
|
||||
})
|
||||
|
||||
test('sanitizing is idempotent, so server and client agree on a sanitized body', () => {
|
||||
const inputs = [AUTHOR_FOOTER, ...REVIEW_BYPASSES.map(([, html]) => html), ...GO_CASES.map(([, html]) => html)]
|
||||
for (const html of inputs) {
|
||||
const once = sanitizeJournalHtml(html)
|
||||
assert.equal(sanitizeJournalHtml(once), once, html)
|
||||
}
|
||||
})
|
||||
|
||||
test('empty input is safe and empty', () => {
|
||||
for (const html of [null, undefined, '']) {
|
||||
assert.equal(sanitizeJournalHtml(html), '')
|
||||
assert.equal(isSafeJournalHtml(html), true)
|
||||
assert.deepEqual(gateJournalHtml(html), { safe: true, html: '' })
|
||||
}
|
||||
})
|
||||
|
||||
// The Blog Editor's own output (Phase 16.2, D-05 amended). tests/fixtures/
|
||||
// editor-corpus.json is a byte copy of sm-journal-plugin
|
||||
// tests/blogeditor/fixtures/editor-corpus.json, the editor's corpus that also
|
||||
// pins the Go policy (TestEditorOutputPassesPolicy). Never edit the copy by
|
||||
// hand; copy it again when the editor's corpus changes.
|
||||
const CORPUS = JSON.parse(
|
||||
readFileSync(new URL('./fixtures/editor-corpus.json', import.meta.url), 'utf8'),
|
||||
) as Array<{ name: string; html: string }>
|
||||
const PLACEHOLDER = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
|
||||
|
||||
test('the editor corpus has the entries the mirror rules are for', () => {
|
||||
assert.equal(CORPUS.length, 15)
|
||||
const all = CORPUS.map((e) => e.html).join('\n')
|
||||
for (const part of ['data-embed="youtube"', 'start="3"', 'alt="Turkey Tail: czy to "grzyb odporności"?"']) {
|
||||
assert.ok(all.includes(part), `corpus lacks ${part}`)
|
||||
}
|
||||
})
|
||||
|
||||
for (const entry of CORPUS) {
|
||||
test(`editor corpus entry ${entry.name} is safe`, () => {
|
||||
const gate = gateJournalHtml(entry.html)
|
||||
assert.equal(gate.safe, true, `${entry.name}: ${gate.html}`)
|
||||
assert.equal(sanitizeJournalHtml(gate.html), gate.html, `${entry.name} not idempotent`)
|
||||
})
|
||||
}
|
||||
|
||||
test('the embed placeholder survives two sanitizer passes (payload, then sink)', () => {
|
||||
const once = sanitizeJournalHtml(PLACEHOLDER)
|
||||
assert.equal(once, PLACEHOLDER)
|
||||
assert.equal(sanitizeJournalHtml(once), PLACEHOLDER)
|
||||
assert.deepEqual(gateJournalHtml(PLACEHOLDER), { safe: true, html: PLACEHOLDER })
|
||||
})
|
||||
|
||||
test('embed attributes outside the four editor rules are still stripped', () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['other provider', '<figure data-embed="vimeo" data-id="dQw4w9WgXcQ"></figure>', '<figure data-id="dQw4w9WgXcQ"></figure>'],
|
||||
['10-character id', '<figure data-embed="youtube" data-id="dQw4w9WgXc"></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['12-character id', '<figure data-embed="youtube" data-id="dQw4w9WgXcQQ"></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['id with a quote', '<figure data-embed="youtube" data-id="dQw4w9WgX"Q"></figure>', '<figure data-embed="youtube"></figure>'],
|
||||
['data-id on div', '<div data-embed="youtube" data-id="dQw4w9WgXcQ">x</div>', '<div>x</div>'],
|
||||
['data-id on img', '<img src="/a.jpg" data-id="dQw4w9WgXcQ" />', '<img src="/a.jpg" />'],
|
||||
['other data attribute on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" data-src="https://evil.test/"></figure>', PLACEHOLDER],
|
||||
['src on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" src="https://evil.test/"></figure>', PLACEHOLDER],
|
||||
['onload on figure', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ" onload="alert(1)"></figure>', PLACEHOLDER],
|
||||
]
|
||||
for (const [name, html, want] of cases) {
|
||||
assert.equal(sanitizeJournalHtml(html), want, name)
|
||||
assert.equal(isSafeJournalHtml(html), false, `${name} reported safe`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an iframe is still stripped with its content', () => {
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<p>a</p><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ">fallback</iframe><p>b</p>'),
|
||||
'<p>a</p><p>b</p>',
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeJournalHtml('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"><iframe src="https://evil.test/"></iframe></figure>'),
|
||||
PLACEHOLDER,
|
||||
)
|
||||
})
|
||||
|
||||
test('ol start keeps an integer only, and only on ol', () => {
|
||||
assert.equal(sanitizeJournalHtml('<ol start="3"><li>x</li></ol>'), '<ol start="3"><li>x</li></ol>')
|
||||
assert.equal(sanitizeJournalHtml('<ol start="x"><li>x</li></ol>'), '<ol><li>x</li></ol>')
|
||||
assert.equal(sanitizeJournalHtml('<ol start="-1"><li>x</li></ol>'), '<ol><li>x</li></ol>')
|
||||
assert.equal(sanitizeJournalHtml('<ul start="3"><li>x</li></ul>'), '<ul><li>x</li></ul>')
|
||||
})
|
||||
|
||||
test('img alt keeps free text with a colon, a question mark and quotes', () => {
|
||||
const html = '<p><img src="/a.jpg" alt="Turkey Tail: czy to "grzyb odporności"?" /></p>'
|
||||
assert.equal(sanitizeJournalHtml(html), html)
|
||||
assert.equal(isSafeJournalHtml(html), true)
|
||||
// alt is text, never markup: an angle bracket stays inside the attribute.
|
||||
const out = sanitizeJournalHtml('<img src="/a.jpg" alt=""><script>alert(1)</script>" />')
|
||||
assert.doesNotMatch(out, /<script/)
|
||||
assert.match(out, /^<img src="\/a\.jpg" alt="[^"]*" \/>$/)
|
||||
// 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}`)
|
||||
}
|
||||
})
|
||||
56
tests/i18n.test.ts
Normal file
56
tests/i18n.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// pl and en strings have the same keys and the same {name} placeholders,
|
||||
// so a string added to one locale fails until the other has it too.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const LOCALES = join(dirname(fileURLToPath(import.meta.url)), '..', 'i18n', 'locales')
|
||||
|
||||
type Messages = { [key: string]: string | Messages }
|
||||
|
||||
function load(code: string): Messages {
|
||||
return JSON.parse(readFileSync(join(LOCALES, `${code}.json`), 'utf8')) as Messages
|
||||
}
|
||||
|
||||
// Every leaf string of the message tree, keyed by its dotted path.
|
||||
function leaves(tree: Messages, prefix = ''): Array<[string, string]> {
|
||||
const out: Array<[string, string]> = []
|
||||
for (const [key, value] of Object.entries(tree)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof value === 'string') out.push([path, value])
|
||||
else out.push(...leaves(value, path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function placeholders(value: string): string[] {
|
||||
return [...value.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]!).sort()
|
||||
}
|
||||
|
||||
const pl = new Map(leaves(load('pl')))
|
||||
const en = new Map(leaves(load('en')))
|
||||
|
||||
test('every string lives under the journal namespace', () => {
|
||||
for (const key of [...pl.keys(), ...en.keys()]) assert.ok(key.startsWith('journal.'), key)
|
||||
})
|
||||
|
||||
test('pl and en have identical leaf key sets', () => {
|
||||
assert.deepEqual([...pl.keys()].sort(), [...en.keys()].sort())
|
||||
assert.ok(pl.size > 0)
|
||||
})
|
||||
|
||||
test('no leaf is empty', () => {
|
||||
for (const [key, value] of [...pl, ...en]) assert.ok(value.trim() !== '', key)
|
||||
})
|
||||
|
||||
test('pl and en use the same placeholders per key', () => {
|
||||
for (const [key, value] of pl) {
|
||||
assert.deepEqual(placeholders(value), placeholders(en.get(key) ?? ''), key)
|
||||
}
|
||||
})
|
||||
|
||||
test('the pagination title carries {page}', () => {
|
||||
assert.deepEqual(placeholders(pl.get('journal.pagination.pageTitle') ?? ''), ['page'])
|
||||
})
|
||||
47
tests/pagination.test.ts
Normal file
47
tests/pagination.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// ?page=N parsing, out-of-range detection and the page window.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pageOutOfRange, paginationWindow, parsePageParam } from '../app/utils/pagination.ts'
|
||||
|
||||
test('a missing page is page 1', () => {
|
||||
assert.equal(parsePageParam(undefined), 1)
|
||||
assert.equal(parsePageParam(null), 1)
|
||||
})
|
||||
|
||||
test('a string of digits above 0 is that page', () => {
|
||||
assert.equal(parsePageParam('1'), 1)
|
||||
assert.equal(parsePageParam('2'), 2)
|
||||
assert.equal(parsePageParam('017'), 17)
|
||||
})
|
||||
|
||||
test('anything else is a 404 (null)', () => {
|
||||
for (const raw of ['0', '-1', '1.5', 'abc', '', ' 2', '2 ', '1e3', '0x2', '99999999999999999999']) {
|
||||
assert.equal(parsePageParam(raw), null, JSON.stringify(raw))
|
||||
}
|
||||
assert.equal(parsePageParam(['2']), null)
|
||||
assert.equal(parsePageParam(2), null)
|
||||
})
|
||||
|
||||
test('pageOutOfRange: past the last page only, page 1 never', () => {
|
||||
assert.equal(pageOutOfRange(3, 2), true)
|
||||
assert.equal(pageOutOfRange(2, 2), false)
|
||||
assert.equal(pageOutOfRange(1, 0), false)
|
||||
assert.equal(pageOutOfRange(1, 1), false)
|
||||
assert.equal(pageOutOfRange(2, 0), true)
|
||||
})
|
||||
|
||||
test('paginationWindow centres on the current page', () => {
|
||||
assert.deepEqual(paginationWindow(5, 10), [3, 4, 5, 6, 7])
|
||||
})
|
||||
|
||||
test('paginationWindow stays full width near either end', () => {
|
||||
assert.deepEqual(paginationWindow(1, 10), [1, 2, 3, 4, 5])
|
||||
assert.deepEqual(paginationWindow(10, 10), [6, 7, 8, 9, 10])
|
||||
assert.deepEqual(paginationWindow(9, 10, 3), [8, 9, 10])
|
||||
})
|
||||
|
||||
test('paginationWindow clips to the page count and handles empty input', () => {
|
||||
assert.deepEqual(paginationWindow(1, 2), [1, 2])
|
||||
assert.deepEqual(paginationWindow(1, 0), [])
|
||||
assert.deepEqual(paginationWindow(1, 5, 0), [])
|
||||
})
|
||||
55
tests/postView.test.ts
Normal file
55
tests/postView.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// The post page's payload: the body is sanitized, never blanked, and the raw
|
||||
// source, translations and related post bodies stay out of it.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { toPostView } from '../app/utils/postView.ts'
|
||||
import type { JournalPost, JournalPostDetail } from '../app/utils/types.ts'
|
||||
|
||||
function post(overrides: Partial<JournalPost> = {}): JournalPost {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Hello',
|
||||
slug: 'hello',
|
||||
excerpt: null,
|
||||
content: 'source',
|
||||
content_html: '<p>Hello</p>',
|
||||
reading_time: 1,
|
||||
published: true,
|
||||
published_at: '2026-01-01T00:00:00+00:00',
|
||||
is_pinned: false,
|
||||
metadata: null,
|
||||
sources: null,
|
||||
author: null,
|
||||
categories: [],
|
||||
tags: [],
|
||||
featured_images: [],
|
||||
featured_image_url: null,
|
||||
translations: { en: { title: 'Hello' } },
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(overrides: Partial<JournalPostDetail> = {}): JournalPostDetail {
|
||||
return { ...post(), previous_post: null, next_post: null, related_posts: [], ...overrides }
|
||||
}
|
||||
|
||||
test('the body is sanitized and the safe parts are kept', () => {
|
||||
const view = toPostView(detail({ content_html: '<p>Data: 12 maja 2024</p><script>alert(1)</script><p>After</p>' }))
|
||||
assert.equal(view.content_html, '<p>Data: 12 maja 2024</p><p>After</p>')
|
||||
})
|
||||
|
||||
test('source, translations and related post bodies stay out of the payload', () => {
|
||||
const view = toPostView(detail({ related_posts: [post({ id: 2, slug: 'other' })] }))
|
||||
assert.equal(view.content, null)
|
||||
assert.equal(view.translations, null)
|
||||
assert.equal(view.related_posts.length, 1)
|
||||
assert.equal(view.related_posts[0]!.content_html, null)
|
||||
assert.equal(view.related_posts[0]!.content, null)
|
||||
assert.equal(view.related_posts[0]!.translations, null)
|
||||
})
|
||||
|
||||
test('a missing body becomes an empty string', () => {
|
||||
assert.equal(toPostView(detail({ content_html: null })).content_html, '')
|
||||
})
|
||||
111
tests/routes.test.ts
Normal file
111
tests/routes.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// journal.routes: defaults, overrides, disabling, validation and path filling.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
DEFAULT_JOURNAL_ROUTES,
|
||||
JOURNAL_ROUTE_NAMES,
|
||||
fillJournalPath,
|
||||
normalizeJournalRoutes,
|
||||
} from '../app/utils/routes.ts'
|
||||
|
||||
test('no input gives the defaults', () => {
|
||||
assert.deepEqual(normalizeJournalRoutes(), { ...DEFAULT_JOURNAL_ROUTES })
|
||||
assert.deepEqual(normalizeJournalRoutes(null), { ...DEFAULT_JOURNAL_ROUTES })
|
||||
assert.deepEqual(normalizeJournalRoutes({}), { ...DEFAULT_JOURNAL_ROUTES })
|
||||
})
|
||||
|
||||
test('the defaults round-trip through normalization', () => {
|
||||
const once = normalizeJournalRoutes({ ...DEFAULT_JOURNAL_ROUTES })
|
||||
assert.deepEqual(normalizeJournalRoutes(once), once)
|
||||
})
|
||||
|
||||
test('the result is a copy, never the frozen defaults', () => {
|
||||
const routes = normalizeJournalRoutes()
|
||||
assert.notEqual(routes, DEFAULT_JOURNAL_ROUTES)
|
||||
assert.ok(Object.isFrozen(DEFAULT_JOURNAL_ROUTES))
|
||||
})
|
||||
|
||||
test('an override replaces one key and keeps the others', () => {
|
||||
const routes = normalizeJournalRoutes({ category: '/:category' })
|
||||
assert.equal(routes.category, '/:category')
|
||||
assert.equal(routes.index, DEFAULT_JOURNAL_ROUTES.index)
|
||||
assert.equal(routes.post, DEFAULT_JOURNAL_ROUTES.post)
|
||||
assert.equal(routes.tag, DEFAULT_JOURNAL_ROUTES.tag)
|
||||
assert.equal(routes.author, DEFAULT_JOURNAL_ROUTES.author)
|
||||
})
|
||||
|
||||
test('a site URL map normalizes as given (grzybyfunkcjonalne shape)', () => {
|
||||
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }
|
||||
assert.deepEqual(normalizeJournalRoutes(input), input)
|
||||
})
|
||||
|
||||
test('false disables a route; null and undefined keep the default', () => {
|
||||
const routes = normalizeJournalRoutes({ tag: false, author: null, post: undefined })
|
||||
assert.equal(routes.tag, false)
|
||||
assert.equal(routes.author, DEFAULT_JOURNAL_ROUTES.author)
|
||||
assert.equal(routes.post, DEFAULT_JOURNAL_ROUTES.post)
|
||||
})
|
||||
|
||||
test('unknown keys are ignored', () => {
|
||||
assert.deepEqual(normalizeJournalRoutes({ archive: '/archive' }), { ...DEFAULT_JOURNAL_ROUTES })
|
||||
})
|
||||
|
||||
test('a post path without :slug throws naming journal.routes.post', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ post: '/blog/:id' }), /journal\.routes\.post/)
|
||||
})
|
||||
|
||||
test('a param prefix is not the param (:slugs is not :slug)', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ post: '/blog/:slugs' }), /journal\.routes\.post/)
|
||||
assert.equal(normalizeJournalRoutes({ post: '/blog/:slug(.*)' }).post, '/blog/:slug(.*)')
|
||||
})
|
||||
|
||||
test('a category path without :category throws naming journal.routes.category', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ category: '/kategoria' }), /journal\.routes\.category/)
|
||||
})
|
||||
|
||||
test('tag and author paths need their params', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ tag: '/tags/:slug' }), /journal\.routes\.tag/)
|
||||
assert.throws(() => normalizeJournalRoutes({ author: '/autor' }), /journal\.routes\.author/)
|
||||
})
|
||||
|
||||
test('a path without a leading / throws naming the key', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ index: 'blog' }), /journal\.routes\.index/)
|
||||
assert.throws(() => normalizeJournalRoutes({ post: 'blog/:slug' }), /journal\.routes\.post/)
|
||||
})
|
||||
|
||||
test('true and non-string values throw', () => {
|
||||
assert.throws(() => normalizeJournalRoutes({ index: true }), /journal\.routes\.index/)
|
||||
assert.throws(() => normalizeJournalRoutes({ index: 42 }), /journal\.routes\.index/)
|
||||
})
|
||||
|
||||
test('route names are stable', () => {
|
||||
assert.deepEqual({ ...JOURNAL_ROUTE_NAMES }, {
|
||||
index: 'journal-index',
|
||||
post: 'journal-post',
|
||||
category: 'journal-category',
|
||||
tag: 'journal-tag',
|
||||
author: 'journal-author',
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
test('fillJournalPath encodes a slug with / per segment', () => {
|
||||
assert.equal(fillJournalPath('/blog/:slug', { slug: '2024/a b?' }), '/blog/2024/a%20b%3F')
|
||||
})
|
||||
|
||||
test('fillJournalPath fills several params and numbers', () => {
|
||||
assert.equal(fillJournalPath('/:category/:slug', { category: 'cordyceps', slug: 7 }), '/cordyceps/7')
|
||||
})
|
||||
|
||||
test('fillJournalPath drops a custom matcher and a modifier with the param', () => {
|
||||
assert.equal(fillJournalPath('/blog/:slug(.*)', { slug: 'a/b' }), '/blog/a/b')
|
||||
assert.equal(fillJournalPath('/blog/:slug?', { slug: 'x' }), '/blog/x')
|
||||
})
|
||||
|
||||
test('fillJournalPath leaves unknown params empty', () => {
|
||||
assert.equal(fillJournalPath('/autor/:author', {}), '/autor/')
|
||||
})
|
||||
94
tests/seo.test.ts
Normal file
94
tests/seo.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// SEO values for Journal pages: description fallback, absolute og:image and
|
||||
// list titles.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { absoluteUrl, htmlToText, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
|
||||
import type { JournalPost } from '../app/utils/types.ts'
|
||||
|
||||
function post(extra: Partial<JournalPost> = {}): JournalPost {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'Najnowsze Badania nad Cordycepsem',
|
||||
slug: 'blogbadania-nad-cordycepsem',
|
||||
excerpt: null,
|
||||
content: null,
|
||||
content_html: null,
|
||||
reading_time: 1,
|
||||
published: true,
|
||||
published_at: '2024-06-12T06:00:00+00:00',
|
||||
is_pinned: false,
|
||||
metadata: null,
|
||||
sources: null,
|
||||
author: null,
|
||||
categories: [],
|
||||
tags: [],
|
||||
featured_images: [],
|
||||
featured_image_url: null,
|
||||
translations: null,
|
||||
created_at: '2024-06-12T06:00:00+00:00',
|
||||
updated_at: '2024-06-13T06:00:00+00:00',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
test('htmlToText drops tags, scripts and styles and decodes entities', () => {
|
||||
assert.equal(
|
||||
htmlToText('<p>A &<b> "c" 'd'</p><script>x()</script><style>p{}</style>\n<p>e</p>'),
|
||||
'A &<b> "c" \'d\' e',
|
||||
)
|
||||
assert.equal(htmlToText(null), '')
|
||||
})
|
||||
|
||||
test('truncateText keeps short text and cuts long text at a word with an ellipsis', () => {
|
||||
assert.equal(truncateText('krótki', 160), 'krótki')
|
||||
const long = 'słowo '.repeat(40).trim()
|
||||
const cut = truncateText(long, 160)
|
||||
assert.ok(cut.length <= 160)
|
||||
assert.ok(cut.endsWith('…'))
|
||||
assert.ok(!cut.endsWith(' …'))
|
||||
assert.equal(truncateText('x'.repeat(200), 10), `${'x'.repeat(9)}…`)
|
||||
})
|
||||
|
||||
test('the excerpt is the description when set', () => {
|
||||
const meta = postSeoMeta(post({ excerpt: 'Co mówi nauka.', content_html: '<p>Treść</p>' }))
|
||||
assert.equal(meta.description, 'Co mówi nauka.')
|
||||
assert.equal(meta.ogDescription, 'Co mówi nauka.')
|
||||
assert.equal(meta.title, 'Najnowsze Badania nad Cordycepsem')
|
||||
assert.equal(meta.ogTitle, meta.title)
|
||||
assert.equal(meta.ogType, 'article')
|
||||
})
|
||||
|
||||
test('without an excerpt the description is the first 160 characters of the body text', () => {
|
||||
const body = `<p>${'Grzyby funkcjonalne wspierają zdrowie. '.repeat(10)}</p>`
|
||||
const meta = postSeoMeta(post({ excerpt: '', content_html: body }))
|
||||
assert.ok(meta.description.length <= 160)
|
||||
assert.ok(meta.description.startsWith('Grzyby funkcjonalne wspierają zdrowie.'))
|
||||
assert.ok(!meta.description.includes('<'))
|
||||
})
|
||||
|
||||
test('og:image becomes absolute against the site URL', () => {
|
||||
const url = '/storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png'
|
||||
assert.equal(postSeoMeta(post({ featured_image_url: url }), 'https://grzybyfunkcjonalne.pl/').ogImage, `https://grzybyfunkcjonalne.pl${url}`)
|
||||
assert.equal(postSeoMeta(post({ featured_image_url: 'https://cdn.test/a.png' }), 'https://x.test').ogImage, 'https://cdn.test/a.png')
|
||||
assert.equal(postSeoMeta(post()).ogImage, undefined)
|
||||
})
|
||||
|
||||
test('article times come from published_at and updated_at', () => {
|
||||
const meta = postSeoMeta(post())
|
||||
assert.equal(meta.articlePublishedTime, '2024-06-12T06:00:00+00:00')
|
||||
assert.equal(meta.articleModifiedTime, '2024-06-13T06:00:00+00:00')
|
||||
const bare = postSeoMeta(post({ published_at: null, updated_at: null }))
|
||||
assert.equal(bare.articlePublishedTime, undefined)
|
||||
assert.equal(bare.articleModifiedTime, undefined)
|
||||
})
|
||||
|
||||
test('absoluteUrl passes through absolute URLs and a missing site URL', () => {
|
||||
assert.equal(absoluteUrl('a.png', 'https://x.test'), 'https://x.test/a.png')
|
||||
assert.equal(absoluteUrl('HTTPS://y.test/a.png', 'https://x.test'), 'HTTPS://y.test/a.png')
|
||||
assert.equal(absoluteUrl('/a.png', null), '/a.png')
|
||||
})
|
||||
|
||||
test('listTitle adds the page title only after page 1', () => {
|
||||
assert.equal(listTitle('Artykuły', 1, 'Strona 1'), 'Artykuły')
|
||||
assert.equal(listTitle('Artykuły', 2, 'Strona 2'), 'Artykuły · Strona 2')
|
||||
})
|
||||
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(''), '')
|
||||
})
|
||||
98
tests/sitemap.test.ts
Normal file
98
tests/sitemap.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Sitemap entries are built from journal.routes, never from fixed paths.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildJournalSitemapEntries } from '../app/utils/sitemap.ts'
|
||||
import { DEFAULT_JOURNAL_ROUTES, normalizeJournalRoutes } from '../app/utils/routes.ts'
|
||||
import type { JournalCategory, JournalPost } from '../app/utils/types.ts'
|
||||
|
||||
function post(id: number, slug: string, extra: Partial<JournalPost> = {}): JournalPost {
|
||||
return {
|
||||
id,
|
||||
title: slug,
|
||||
slug,
|
||||
excerpt: null,
|
||||
content: null,
|
||||
content_html: null,
|
||||
reading_time: 1,
|
||||
published: true,
|
||||
published_at: '2024-06-12T06:00:00+00:00',
|
||||
is_pinned: false,
|
||||
metadata: null,
|
||||
sources: null,
|
||||
author: null,
|
||||
categories: [],
|
||||
tags: [],
|
||||
featured_images: [],
|
||||
featured_image_url: null,
|
||||
translations: null,
|
||||
created_at: '2024-06-12T06:00:00+00:00',
|
||||
updated_at: '2024-06-13T06:00:00+00:00',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function category(id: number, slug: string, children?: JournalCategory[]): JournalCategory {
|
||||
const row: JournalCategory = {
|
||||
id,
|
||||
name: slug,
|
||||
slug,
|
||||
description: null,
|
||||
content: null,
|
||||
parent_id: null,
|
||||
nest_depth: 0,
|
||||
post_count: 0,
|
||||
}
|
||||
if (children) row.children = children
|
||||
return row
|
||||
}
|
||||
|
||||
const POSTS = [post(1, 'blogbadania-nad-cordycepsem'), post(2, 'bez-daty', { updated_at: null })]
|
||||
const CATEGORIES = [category(1, 'cordyceps', [category(2, 'militaris')]), category(3, 'amanita-muscaria')]
|
||||
|
||||
test('default routes give /blog/:slug and /blog/category/:category', () => {
|
||||
assert.deepEqual(buildJournalSitemapEntries(POSTS, CATEGORIES, { ...DEFAULT_JOURNAL_ROUTES }), [
|
||||
{ loc: '/blog/blogbadania-nad-cordycepsem', lastmod: '2024-06-13T06:00:00+00:00' },
|
||||
{ loc: '/blog/bez-daty' },
|
||||
{ loc: '/blog/category/cordyceps' },
|
||||
{ loc: '/blog/category/militaris' },
|
||||
{ loc: '/blog/category/amanita-muscaria' },
|
||||
])
|
||||
})
|
||||
|
||||
test('a custom root category route gives /cordyceps', () => {
|
||||
const routes = normalizeJournalRoutes({ category: '/:category', tag: false })
|
||||
const locs = buildJournalSitemapEntries(POSTS, CATEGORIES, routes).map((e) => e.loc)
|
||||
assert.ok(locs.includes('/cordyceps'))
|
||||
assert.ok(locs.includes('/militaris'), 'nested categories are flattened')
|
||||
assert.ok(locs.includes('/amanita-muscaria'), 'empty categories are listed')
|
||||
})
|
||||
|
||||
test('a disabled route contributes nothing', () => {
|
||||
const noCategories = normalizeJournalRoutes({ category: false })
|
||||
assert.deepEqual(
|
||||
buildJournalSitemapEntries(POSTS, CATEGORIES, noCategories).map((e) => e.loc),
|
||||
['/blog/blogbadania-nad-cordycepsem', '/blog/bez-daty'],
|
||||
)
|
||||
const noPosts = normalizeJournalRoutes({ post: false })
|
||||
assert.equal(buildJournalSitemapEntries(POSTS, CATEGORIES, noPosts).filter((e) => e.loc.startsWith('/blog/b')).length, 0)
|
||||
})
|
||||
|
||||
test('unpublished posts and empty slugs are skipped', () => {
|
||||
const rows = [post(1, 'draft', { published: false }), post(2, ''), post(3, 'live')]
|
||||
assert.deepEqual(
|
||||
buildJournalSitemapEntries(rows, [category(9, '')], { ...DEFAULT_JOURNAL_ROUTES }).map((e) => e.loc),
|
||||
['/blog/live'],
|
||||
)
|
||||
})
|
||||
|
||||
test('lastmod comes from updated_at', () => {
|
||||
const [entry] = buildJournalSitemapEntries([post(1, 'x', { updated_at: '2025-01-02T03:04:05+00:00' })], [], {
|
||||
...DEFAULT_JOURNAL_ROUTES,
|
||||
})
|
||||
assert.equal(entry?.lastmod, '2025-01-02T03:04:05+00:00')
|
||||
})
|
||||
|
||||
test('slugs are encoded per segment', () => {
|
||||
const [entry] = buildJournalSitemapEntries([post(1, 'a b/c')], [], { ...DEFAULT_JOURNAL_ROUTES })
|
||||
assert.equal(entry?.loc, '/blog/a%20b/c')
|
||||
})
|
||||
Reference in New Issue
Block a user