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

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import DOMPurify from 'dompurify'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
@@ -118,7 +119,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
Announcements
</h2>
{forum.canPost && !composing && (
<button type="button" className="btn-ghost sans" onClick={() => setComposing(true)}>
<button type="button" className="pill" onClick={() => setComposing(true)}>
Post an announcement
</button>
)}
@@ -225,7 +226,7 @@ function GuestManager({ slug }) {
if (!open) {
return (
<button type="button" className="btn-ghost sans" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
<button type="button" className="pill" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
Forum guests
</button>
)
@@ -235,7 +236,7 @@ function GuestManager({ slug }) {
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
<button type="button" className="btn-ghost sans" onClick={() => setOpen(false)}>Close</button>
<button type="button" className="pill" onClick={() => setOpen(false)}>Close</button>
</header>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
Guests read and post in this forum without being members of the Team. They do not appear on the
@@ -247,7 +248,7 @@ function GuestManager({ slug }) {
{(data?.guests || []).map((g) => (
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
<span>{g.username}</span>
<button type="button" className="btn-ghost sans" onClick={() => revoke(g.userId)}>Remove</button>
<button type="button" className="pill" onClick={() => revoke(g.userId)}>Remove</button>
</li>
))}
{data && data.guests.length === 0 && (
@@ -257,14 +258,14 @@ function GuestManager({ slug }) {
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
<input
className="sans"
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Account name"
maxLength={32}
required
/>
<button type="submit" className="btn sans">Add</button>
<button type="submit" className="btn btn-primary btn-sq">Add</button>
</form>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
</section>
@@ -274,7 +275,7 @@ function GuestManager({ slug }) {
function ThreadView({ thread, canModerate, onBack, onModerate }) {
return (
<section style={{ marginTop: 26 }}>
<button type="button" className="btn-ghost sans" onClick={onBack} style={{ marginBottom: 10 }}>
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
All announcements
</button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
@@ -288,24 +289,33 @@ function ThreadView({ thread, canModerate, onBack, onModerate }) {
{thread.posts.map((post) => (
<article key={post.id} style={{ marginBottom: 16 }}>
{/*
Rendered server-side under the operator's image policy, which is why
this is dangerouslySetInnerHTML and not a sanitizer call here. The body
was sanitised on write with the forum's own profile — one in which
`img` is never allowed — and any <img> in it was emitted by core's own
renderer with a fixed attribute set. A client-side sanitiser would have
to strip exactly the tag core just decided to add.
Sanitised on write with the forum's own profile, rendered server-side
under the operator's image policy, and re-sanitised here — the same
defence-in-depth every other body-HTML surface on this site applies
(FiveOnFriday, NewsletterIssue, the rich-text block).
`ADD_ATTR: ['referrerpolicy']` is load-bearing and not a preference.
DOMPurify's default allowlist carries `loading` but NOT
`referrerpolicy`, so a plain sanitize() call silently strips the one
attribute that limits what a remote embed leaks to the host serving it
— the privacy property the admin help text promises an operator. The
<img> itself is core's own output with a fixed attribute set, so
nothing here is widening what an author can write.
*/}
{/* eslint-disable-next-line react/no-danger */}
<div className="serif" dangerouslySetInnerHTML={{ __html: post.body }} />
<div
className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
/>
</article>
))}
{canModerate && (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button type="button" className="btn-ghost sans" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="btn-ghost sans" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</div>
@@ -352,7 +362,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
<input
className="sans"
className="input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
@@ -360,7 +370,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
required
/>
<textarea
className="sans"
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your announcement. Paste an image URL on its own line to share a picture."
@@ -374,8 +384,8 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn sans" disabled={busy}>Post</button>
<button type="button" className="btn-ghost sans" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)