diff --git a/server/src/utils/forumHtml.js b/server/src/utils/forumHtml.js
new file mode 100644
index 0000000..d6e0b4c
--- /dev/null
+++ b/server/src/utils/forumHtml.js
@@ -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 `
` tag.** Core's shared sanitizer
+// (utils/sanitizeHtml.js) allows `
` 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: https://…
+// rendered: that link, and — in `remote`/`uploads` mode only — a
+// core-generated
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
+// 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
+//
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 `&`, 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 `${trimmed}${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(/&/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
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(/]*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}
`
+ })
+}
+
+module.exports = {
+ cleanForumBody,
+ renderForumBody,
+ isEmbeddableImageUrl,
+ IMAGE_EXTENSIONS,
+ FORUM_OPTIONS,
+}