feat(teams): phase 4 — the forum access model, announcements and the operator's controls #153

Merged
whitlocktech merged 6 commits from feature/teams-phase4-forum-access into edge 2026-08-18 14:18:11 +00:00
Showing only changes of commit fb70013adf - Show all commits

View File

@@ -0,0 +1,193 @@
// ── 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'])
const BARE_URL = /\bhttps?:\/\/[^\s<>"']+/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.
return `${anchor}<img src="${href}" loading="lazy" referrerpolicy="no-referrer" alt="">`
})
}
module.exports = {
cleanForumBody,
renderForumBody,
isEmbeddableImageUrl,
IMAGE_EXTENSIONS,
FORUM_OPTIONS,
}