Files
website/server/src/utils/forumHtml.js
wtclaude 5baada08ef
All checks were successful
PR Checks / bot-install (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 34s
PR Checks / client-build (pull_request) Successful in 8m48s
fix(teams): four defects the live rig found in the forum
None of these could fail a unit test, and three of them break the feature for the
operator rather than for the code.

**The uploads acknowledgement was a one-way door.** A settings form sends every
field it owns, so once `teams_forum_images` was `uploads`, every later save
re-sent `uploads` — and the gate fired on the VALUE being present rather than on
the mode being SELECTED. The operator could never change a forum setting again,
and the thing they would reach for in a hurry, switching the forum off, was
exactly what came back 400. The gate now passes when an acknowledgement for the
version in force is already on record AND uploads is already the stored mode:
there is no new consent to take. A transition INTO uploads still asks, and a
reworded notice is still caught by assertSettingsWritable.

**An uploaded image could never become a picture.** `uploads` mode hands the
composer `/uploads/<name>.png`, the composer puts it in the body as text — the
author never writes markup, which is the whole design — and the renderer only
rewrites ANCHORS. The linkifier matched absolute http(s) URLs only, so the write
path could not produce the anchor the read path looks for, even though
`isEmbeddableImageUrl` had accepted those paths since the first commit. The two
halves disagreed and only a real upload showed it.

**The embed sat beside its link, not beneath it**, because an <img> is inline, and
nothing capped a remote image to the column — one post from a host serving a
4000px file would have blown the layout out. Core now emits `class="forum-embed"`
and the stylesheet owns both. A class rather than an inline style because the
style would then have to survive the client's DOMPurify pass, and its CSS
sanitiser is a larger thing to reason about than one class name.

**The panel's buttons had no button styling.** `btn-ghost` is a MODIFIER — every
other call site in this codebase pairs it with the base `btn` — so alone it
contributed colours and no geometry, and the controls rendered as bare boxes.
Small inline actions use `pill`, which is what the rest of the admin surface uses
for exactly these. Same class of mistake as the Material one in the Android M12
phase: the modifier carries no base.

Also: the post body now re-sanitises client-side like every other body-HTML
surface on this site, with `ADD_ATTR: ['referrerpolicy']`. That argument is
load-bearing — DOMPurify's default allowlist carries `loading` but not
`referrerpolicy`, so a plain sanitize() call silently strips the one attribute
limiting what a remote embed leaks to the host serving it, which is the privacy
property the admin help text promises.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 09:54:05 -05:00

214 lines
9.4 KiB
JavaScript

// ── The forum's own HTML profile, and core's image renderer ────────────────
//
// TEAMS.md §5.5.3, which is the load-bearing decision of the whole forum design
// and is deliberately NOT how the rest of the site works.
//
// **The author never writes an `<img>` tag.** Core's shared sanitizer
// (utils/sanitizeHtml.js) allows `<img>` from any http/https host — it is tuned
// for rich text from the ADMIN editor, where the author is already trusted.
// Handing that profile to arbitrary players would make `teams_forum_images`
// unenforceable: every post could hotlink in every mode and the setting would be
// decoration. So the forum derives its own profile in which `img` is never an
// allowed tag, in any mode.
//
// What an author writes is a URL. What decides whether it becomes a picture is
// this file's renderer, at READ time:
//
// author types: https://example.com/banner.png
// stored HTML: <a href="…" rel="noopener noreferrer nofollow">https://…</a>
// rendered: that link, and — in `remote`/`uploads` mode only — a
// core-generated <img> beneath it
//
// Five properties fall out, and they are the reason for the design:
//
// 1. The policy is ENFORCEABLE, because the only code that can emit an <img>
// is this file.
// 2. Flipping the setting back to `disabled` retroactively un-renders every
// image on every existing post, with NO data migration — the images were
// never in the stored HTML.
// 3. No attribute smuggling: no author-supplied srcset, onerror, width=99999
// or style. Core emits a fixed attribute set.
// 4. The link always survives. A blocked, dead or 404ing image degrades to the
// URL the author actually wrote, which is what the reader wanted anyway.
// 5. It matches how forums conventionally behave.
//
// **Never proxy or cache a remote image server-side.** The moment the server
// fetches a user-supplied URL it is an SSRF vector, and an allow-set is useless
// here because the whole point is arbitrary hosts. The browser fetches; the
// server never does. Written down so nobody adds a proxy "for performance".
const sanitizeHtml = require('sanitize-html')
// Derived from the shared profile with the image family removed. `figure` and
// `figcaption` go with `img` rather than surviving it: without an image inside,
// a figure is an empty box, and leaving them would let an author build a caption
// for a picture core decided not to render.
const FORUM_OPTIONS = {
allowedTags: [
'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li',
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
'a',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
allowedAttributes: {
// `rel` is allowed only so the transform below can WRITE it — an author's own
// rel is overwritten, not merged. Without it here, sanitize-html strips the
// very attribute the transform just added and every link ships without
// noopener.
a: ['href', 'title', 'rel'],
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan'],
},
// No `style` at all, and therefore no allowedStyles. The shared profile permits
// text-align for the admin editor's block alignment; a forum post has no such
// editor and every style attribute a player could send is one more thing to
// reason about.
allowedSchemes: ['http', 'https', 'mailto'],
allowProtocolRelative: false,
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
},
disallowedTagsMode: 'discard',
}
// What may become a picture. Conservative on purpose: guessing wrong renders an
// <img> pointed at something that is not an image, which reads as a broken site.
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']
// Tags whose text is left alone by the linkifier. Inside an anchor because
// nesting one is invalid; inside code/pre because a URL in a code sample is
// being shown, not offered.
const NO_LINKIFY = new Set(['a', 'code', 'pre'])
// Absolute http(s) URLs, and root-relative `/uploads/…` paths.
//
// The second alternative is not a nicety. In `uploads` mode the composer hands
// the author a path like `/uploads/1787…-ab12.png`, puts it in the body as TEXT
// (the author never writes markup — that is the whole design), and the renderer
// only ever rewrites ANCHORS. Without this branch the write path cannot produce
// the anchor the read path looks for, so an uploaded image could never become a
// picture — even though `isEmbeddableImageUrl` was written to accept exactly
// these paths. The two halves disagreed, and only a real upload showed it.
//
// Deliberately narrow: `/uploads/` and nothing else, so ordinary prose that
// happens to contain a slash is left alone.
const BARE_URL = /\bhttps?:\/\/[^\s<>"']+|(?:^|(?<=[\s(]))\/uploads\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*/g
/**
* Sanitise a forum post body. Runs on WRITE; the stored value is already safe and
* is served without re-sanitising — the same contract the wiki and the CMS follow.
*/
function cleanForumBody(html) {
if (html == null || html === '') return html
return linkify(sanitizeHtml(String(html), FORUM_OPTIONS))
}
/**
* Turn bare URLs in text into anchors.
*
* Runs AFTER sanitising, over the sanitiser's own output, and only on text
* outside tags. That ordering is what makes it safe: every text node has already
* been HTML-escaped, so the matched URL can go into both the href and the link
* text unchanged — `&` is already `&amp;`, which is what an attribute wants.
*/
function linkify(html) {
const tokens = String(html).split(/(<[^>]+>)/)
const openStack = []
return tokens
.map((token) => {
if (token.startsWith('<')) {
const match = /^<\s*(\/?)\s*([a-zA-Z0-9]+)/.exec(token)
if (match) {
const [, closing, name] = match
const tag = name.toLowerCase()
if (closing) {
const at = openStack.lastIndexOf(tag)
if (at !== -1) openStack.splice(at, 1)
} else if (!token.endsWith('/>')) {
openStack.push(tag)
}
}
return token
}
if (openStack.some((tag) => NO_LINKIFY.has(tag))) return token
return token.replace(BARE_URL, (url) => {
// Trailing punctuation is far more likely to be the sentence's than the
// URL's — "see https://example.com." should not link the full stop.
const trimmed = url.replace(/[.,;:!?)\]]+$/, '')
const tail = url.slice(trimmed.length)
return `<a href="${trimmed}" rel="noopener noreferrer nofollow">${trimmed}</a>${tail}`
})
})
.join('')
}
/**
* May this URL become a picture?
*
* `https:` only, because the CSP is `img-src 'self' data: https:` (config/csp.js)
* — an `http:` image is blocked by the browser and renders as a broken picture,
* so an `http:` URL stays a plain link. This is a real mismatch with the SHARED
* sanitizer, which permits `http` for `img`, and it is exactly the sort of thing
* that presents as "images are broken on my forum" with nothing in any log.
*
* Same-origin `/uploads/…` paths are embeddable too — that is where `uploads`
* mode puts a file, and `'self'` covers them under the same CSP.
*/
function isEmbeddableImageUrl(href) {
if (typeof href !== 'string' || href === '') return false
const decoded = href.replace(/&amp;/g, '&')
let pathname
if (decoded.startsWith('/uploads/')) {
pathname = decoded.split(/[?#]/)[0]
} else {
let url
try {
url = new URL(decoded)
} catch {
return false
}
if (url.protocol !== 'https:') return false
pathname = url.pathname
}
const lower = pathname.toLowerCase()
return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext))
}
/**
* Render a stored body for one viewer under one image policy.
*
* `disabled` returns the stored HTML byte-for-byte. The other two append a core-
* generated <img> after each anchor whose href looks like an image — which is why
* the stored HTML is identical between the three modes, the property this whole
* design exists to give.
*/
function renderForumBody(storedHtml, mode) {
if (storedHtml == null || storedHtml === '') return storedHtml
if (mode !== 'remote' && mode !== 'uploads') return storedHtml
return String(storedHtml).replace(/<a\s[^>]*href="([^"]*)"[^>]*>.*?<\/a>/gi, (anchor, href) => {
if (!isEmbeddableImageUrl(href)) return anchor
// A fixed attribute set, every time. `no-referrer` limits what leaks to the
// third-party host — it cannot prevent the request itself, which is the
// privacy cost stated in the admin help text rather than hidden.
//
// `class` rather than an inline style, for two things the live rig showed:
// the embed has to sit BENEATH the link (§5.5.3) and an <img> is inline, so
// without it the picture lands beside the URL; and a remote image is any size
// its host chooses, so it needs a max-width or one post can blow the column
// out. Both live in core's stylesheet (`.forum-embed`) because a style
// attribute would then have to survive the client's DOMPurify pass, and its
// CSS sanitiser is a larger thing to reason about than one class name.
return `${anchor}<img class="forum-embed" src="${href}" loading="lazy" referrerpolicy="no-referrer" alt="">`
})
}
module.exports = {
cleanForumBody,
renderForumBody,
isEmbeddableImageUrl,
IMAGE_EXTENSIONS,
FORUM_OPTIONS,
}