Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 759e684e86 | |||
| a6ee443885 | |||
| 294a8d8b19 | |||
| 5beb1c5634 | |||
| 3275a267f6 | |||
| b2069e10bd | |||
| a4dca42d77 |
30
README.md
30
README.md
@@ -3,7 +3,7 @@
|
||||
A Nuxt 4 layer that renders a Journal blog (post list, post, category, tag and author pages, RSS link, SEO and sitemap) from the Journal API of a SummerCMS application.
|
||||
|
||||
```bash
|
||||
pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0
|
||||
pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0
|
||||
```
|
||||
|
||||
## Overview
|
||||
@@ -22,9 +22,30 @@ Route paths are configuration, not files. The layer's page components live in `a
|
||||
- **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 gate**: post bodies and category content pass the Journal API's three-pattern gate (raw script, iframe, object or embed tags; event-handler attributes; javascript, vbscript or data URLs) before `v-html`. A value that fails it renders a neutral notice and never reaches the page or the SSR payload.
|
||||
- **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
|
||||
@@ -56,7 +77,7 @@ export default defineNuxtConfig({
|
||||
})
|
||||
```
|
||||
|
||||
The layer declares `@nuxt/fonts`, `@nuxtjs/i18n` and `@nuxtjs/seo` 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.
|
||||
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.
|
||||
|
||||
@@ -111,7 +132,7 @@ Override tokens in the app's CSS. The app's `css` entries load after the layer's
|
||||
}
|
||||
```
|
||||
|
||||
Override a component by creating the same file in the app, for example `app/components/Journal/PostCard.vue`. The overridable components are `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard` and `JournalRssLink`. A replacement that renders HTML from the API must keep the gate (`gateJournalHtml`) in front of `v-html`.
|
||||
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.
|
||||
|
||||
@@ -143,6 +164,7 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co
|
||||
|
||||
## Testing
|
||||
|
||||
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, and pl/en key and placeholder parity. They need no build, network or database.
|
||||
- `scripts/check-git-install.sh <tag>` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer).
|
||||
- `testing/smoke-ssr.mjs --output <.output> --fixtures <dir> --expect <file>` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`.
|
||||
- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server. Apps can import both from `node_modules/vue-journal-layer/testing/`.
|
||||
|
||||
@@ -170,11 +170,19 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.journal-post-body--blocked {
|
||||
padding: var(--journal-space-3);
|
||||
border: 1px dashed var(--journal-color-border);
|
||||
border-radius: var(--journal-radius-md);
|
||||
color: var(--journal-color-muted);
|
||||
/* 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 */
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// Heading of a category page: name, description and the category's rich
|
||||
// content, which passes the Journal HTML gate before v-html.
|
||||
// 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 { gateJournalHtml } from '../../utils/htmlGate.ts'
|
||||
import { expandEmbeds } from '../../utils/embeds.ts'
|
||||
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
|
||||
|
||||
const props = defineProps<{ category: JournalCategory; page?: number; blocked?: boolean }>()
|
||||
const content = computed(() => (props.blocked ? { safe: false, html: '' } : gateJournalHtml(props.category.content)))
|
||||
const props = defineProps<{ category: JournalCategory; page?: number }>()
|
||||
const content = computed(() => expandEmbeds(sanitizeJournalHtml(props.category.content)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -14,7 +17,6 @@ const content = computed(() => (props.blocked ? { safe: false, html: '' } : gate
|
||||
{{ 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.safe && content.html" class="journal-category-header__content journal-post-body" v-html="content.html" />
|
||||
<p v-else-if="!content.safe" class="journal-post-body--blocked">{{ $t('journal.post.contentUnavailable') }}</p>
|
||||
<div v-if="content" class="journal-category-header__content journal-post-body" v-html="content" />
|
||||
</header>
|
||||
</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">{{ $t('journal.post.contentUnavailable') }}</p>
|
||||
<div class="journal-post-body" v-html="html" />
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 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 { gateJournalHtml } from '../utils/htmlGate.ts'
|
||||
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'
|
||||
@@ -24,12 +24,10 @@ const { data, error } = await useAsyncData(`journal-category-${slug}-${page}`, a
|
||||
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 gated.
|
||||
const gated = gateJournalHtml(found.content)
|
||||
// Only this category reaches the payload, with its content already sanitized.
|
||||
const { children: _children, ...category } = found
|
||||
return {
|
||||
category: { ...category, content: gated.html || null },
|
||||
contentBlocked: !gated.safe,
|
||||
category: { ...category, content: sanitizeJournalHtml(found.content) || null },
|
||||
posts: { ...posts, data: posts.data.map(toListPost) },
|
||||
}
|
||||
})
|
||||
@@ -48,7 +46,7 @@ useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="journal-category">
|
||||
<JournalCategoryHeader :category="data.category" :page="page" :blocked="data.contentBlocked" />
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<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.
|
||||
// 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 { toListPost, toPostView } from '../utils/view.ts'
|
||||
import { toPostView } from '../utils/postView.ts'
|
||||
import { toListPost } from '../utils/view.ts'
|
||||
import type { JournalModuleOptions } from '../utils/types.ts'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -46,7 +47,7 @@ useSeoMeta(postSeoMeta(post.value, useSiteConfig().url || useRequestURL().origin
|
||||
<JournalPostMeta :post="post" />
|
||||
</header>
|
||||
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
|
||||
<JournalPostBody :html="post.content_html" :blocked="post.content_blocked" />
|
||||
<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)"
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -31,15 +26,3 @@ export function formatJournalDate(iso: string | null | undefined, language: stri
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'long', timeZone: 'UTC' }).format(d)
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -16,8 +16,7 @@
|
||||
"by": "By",
|
||||
"related": "Related posts",
|
||||
"previous": "Previous post",
|
||||
"next": "Next post",
|
||||
"contentUnavailable": "This content cannot be displayed."
|
||||
"next": "Next post"
|
||||
},
|
||||
"category": {
|
||||
"empty": "There are no posts in this category yet."
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
"by": "Autor:",
|
||||
"related": "Powiązane wpisy",
|
||||
"previous": "Poprzedni wpis",
|
||||
"next": "Następny wpis",
|
||||
"contentUnavailable": "Nie można wyświetlić tej treści."
|
||||
"next": "Następny wpis"
|
||||
},
|
||||
"category": {
|
||||
"empty": "W tej kategorii nie ma jeszcze wpisów."
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.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: {}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
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)
|
||||
})
|
||||
120
tests/embeds.test.ts
Normal file
120
tests/embeds.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
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>"
|
||||
}
|
||||
]
|
||||
294
tests/htmlGate.test.ts
Normal file
294
tests/htmlGate.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// 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>')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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