fix(teams): four defects the live rig found in the forum
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

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>
This commit is contained in:
2026-08-18 09:54:05 -05:00
parent 16e31de087
commit 5baada08ef
6 changed files with 141 additions and 40 deletions

View File

@@ -82,7 +82,19 @@ const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']
// being shown, not offered.
const NO_LINKIFY = new Set(['a', 'code', 'pre'])
const BARE_URL = /\bhttps?:\/\/[^\s<>"']+/g
// 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
@@ -180,7 +192,15 @@ function renderForumBody(storedHtml, mode) {
// 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.
return `${anchor}<img src="${href}" loading="lazy" referrerpolicy="no-referrer" alt="">`
//
// `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="">`
})
}