feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
193
server/src/utils/forumHtml.js
Normal file
193
server/src/utils/forumHtml.js
Normal 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 `&`, 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(/&/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,
|
||||
}
|
||||
Reference in New Issue
Block a user