2 Commits

Author SHA1 Message Date
759e684e86 chore: release v0.2.0 2026-10-10 12:19:24 +02:00
a6ee443885 feat: keep Blog Editor markup in the sanitizer and expand Journal embed placeholders into privacy-enhanced iframes
- htmlGate.ts mirrors the four editor-output rules of the Go policy:
  figure data-embed (^youtube$) and data-id (^[A-Za-z0-9_-]{11}$),
  img alt with any text, ol start (integer)
- embeds.ts: EMBED_PROVIDERS (youtube on youtube-nocookie.com),
  EMBED_PLACEHOLDER and expandEmbeds
- JournalPostBody and JournalCategoryHeader render
  expandEmbeds(sanitizeJournalHtml(html)); payload transforms only sanitize
- .journal-embed styles (16 / 9 block, borderless iframe)
- tests: byte copy of the editor corpus, mirror and pipeline cases;
  acme hello-acme carries a placeholder and the SSR smoke checks the iframe
- README Embeds section and decision note Policy amendment
2026-10-10 12:19:00 +02:00
13 changed files with 399 additions and 12 deletions

View File

@@ -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 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. 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`.
- **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
@@ -143,7 +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 and the bypasses from its code review), the post page view, sitemap entries, SEO values, and pl/en key and placeholder parity. They need no build, network or database.
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, 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/`.

View File

@@ -170,6 +170,21 @@
text-align: left;
}
/* Embeds expanded from the Blog Editor's placeholders (app/utils/embeds.ts) */
.journal-embed {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
margin: var(--journal-space-4) 0;
}
.journal-embed iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}
/* Listings */
.journal-post-list {
display: grid;

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
// Heading of a category page: name, description and the category's rich
// content, which goes through the Journal sanitizer before v-html.
// content, which goes through the Journal sanitizer before v-html. The page
// payload keeps embed placeholders; expandEmbeds turns them into iframes as
// the last step, after sanitizing, and its output is never sanitized again.
import type { JournalCategory } from '../../utils/types.ts'
import { expandEmbeds } from '../../utils/embeds.ts'
import { sanitizeJournalHtml } from '../../utils/htmlGate.ts'
const props = defineProps<{ category: JournalCategory; page?: number }>()
const content = computed(() => sanitizeJournalHtml(props.category.content))
const content = computed(() => expandEmbeds(sanitizeJournalHtml(props.category.content)))
</script>
<template>

View File

@@ -1,12 +1,16 @@
<script setup lang="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; sanitizing again here guards a
// caller that does not, and gives the same string on server and client.
// 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 }>()
const html = computed(() => sanitizeJournalHtml(props.html))
const html = computed(() => expandEmbeds(sanitizeJournalHtml(props.html)))
</script>
<template>

63
app/utils/embeds.ts Normal file
View 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>'
)
})
}

View File

@@ -5,6 +5,13 @@
// 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
@@ -55,6 +62,13 @@ 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).
@@ -105,11 +119,12 @@ const ELEMENT_ATTRS: Record<string, Record<string, ValueRule>> = {
rel: SPACE_SEPARATED_TOKENS,
shape: /^(default|circle|rect|poly)$/i,
},
img: { src: null, alt: PARAGRAPH, align: IMAGE_ALIGN, ...SIZE, usemap: /^#[\p{L}\p{N}_-]+$/iu },
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 },
ol: { type: LIST_TYPE, start: INTEGER },
ul: { type: LIST_TYPE },
li: { type: LIST_TYPE, value: INTEGER },
table: { ...SIZE, summary: PARAGRAPH },

View File

@@ -16,6 +16,7 @@ The policy mirrors the Journal plugin's bluemonday policy (`classes/format_html.
- **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&#58;`, `&#106;avascript:` and `java<TAB>script:` are all refused.

View File

@@ -1,6 +1,6 @@
{
"name": "vue-journal-layer",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "./nuxt.config.ts",

View File

@@ -5,6 +5,7 @@
# 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=<p>Before</p><p>After</p> notcontains=alert(
404 /blog/draft-note

View File

@@ -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",

120
tests/embeds.test.ts Normal file
View 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
View 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 &quot;grzyb odporności&quot;?\"></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 &quot;grzyb odporności&quot;?\"></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>"
}
]

View File

@@ -5,6 +5,7 @@
// 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.
@@ -210,3 +211,84 @@ test('empty input is safe and empty', () => {
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 &quot;grzyb odporności&quot;?"']) {
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&quot;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 &quot;grzyb odporności&quot;?" /></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="&quot;><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>')
})