T-HUB/packages/safe-content/src/validate.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

107 lines
4.7 KiB
TypeScript

import type { EmojiNode, LinkPreview, RichTextDocument, TextNode } from "./ast.js";
import { DocBuilder, normalizeHandle, normalizeTag, type ParseOptions } from "./builder.js";
import { decodeEntities } from "./entities.js";
import { LINK_PREVIEW_LIMITS } from "./limits.js";
import { neutralizeText, truncateCodePoints } from "./text.js";
import { DEFAULT_MEDIA_PROXY, isProxiedMediaUrl, parseSafeHttpUrl, toProxiedMediaUrl, type MediaProxyPolicy } from "./url.js";
type Unknown = Record<string, unknown>;
const isObj = (v: unknown): v is Unknown => typeof v === "object" && v !== null && !Array.isArray(v);
const arr = (v: unknown): readonly unknown[] => (Array.isArray(v) ? v : []);
/**
* Rebuilds a document from untrusted JSON (DB row, API payload, websocket
* frame). Unknown node types, bad URLs and non-proxied media are dropped;
* text is re-neutralised; limits are re-applied. The renderer always calls
* this, so a forged AST is as inert as a parsed one.
*/
export function coerceRichTextDocument(value: unknown, options: ParseOptions = {}): RichTextDocument {
const builder = new DocBuilder({ ...options, emojis: [] });
const policy = options.mediaProxy ?? DEFAULT_MEDIA_PROXY;
if (!isObj(value) || value.type !== "doc") return builder.finish();
if (value.truncated === true) builder.truncated = true;
const leaf = (n: unknown): (TextNode | EmojiNode)[] => {
if (!isObj(n)) return [];
if (n.type === "text" && typeof n.text === "string") return builder.inlineText(n.text);
if (n.type === "emoji" && typeof n.shortcode === "string" && /^[A-Za-z0-9_+-]{1,64}$/.test(n.shortcode)) {
return isProxiedMediaUrl(n.src, policy)
? [{ type: "emoji", shortcode: n.shortcode, src: n.src }]
: [{ type: "text", text: `:${n.shortcode}:` }];
}
return [];
};
const capped = (items: unknown): readonly unknown[] => {
const all = arr(items);
if (all.length <= builder.limits.maxNodes) return all;
builder.truncated = true;
return all.slice(0, builder.limits.maxNodes);
};
for (const p of capped(value.children)) {
if (!isObj(p) || p.type !== "paragraph") continue;
for (const n of capped(p.children)) {
if (!isObj(n)) continue;
switch (n.type) {
case "text":
if (typeof n.text === "string") builder.text(n.text);
break;
case "emoji":
for (const e of leaf(n)) builder.pushLeaf(e);
break;
case "linebreak":
builder.lineBreak();
break;
case "link":
builder.link(typeof n.href === "string" ? n.href : "", arr(n.children).slice(0, 64).flatMap(leaf));
break;
case "mention":
if (typeof n.handle === "string" && normalizeHandle(n.handle)) builder.mention(n.handle, typeof n.href === "string" ? n.href : undefined);
break;
case "hashtag":
if (typeof n.tag === "string" && normalizeTag(n.tag)) builder.hashtag(n.tag, typeof n.href === "string" ? n.href : undefined);
break;
}
}
builder.endParagraph();
}
return builder.finish();
}
export type RawLinkPreview = {
readonly url?: unknown;
readonly title?: unknown;
readonly description?: unknown;
readonly siteName?: unknown;
readonly image?: unknown;
};
function previewText(value: unknown, max: number): string | undefined {
if (typeof value !== "string") return undefined;
// OpenGraph fields are text: entities are decoded, tags are NOT interpreted.
const text = neutralizeText(decodeEntities(value.slice(0, max * 4))).replace(/\s+/g, " ").trim();
return text === "" ? undefined : truncateCodePoints(text, max);
}
/**
* Link-preview card data (OpenGraph/oEmbed/Mastodon `card`). Returns null when
* the target is not a safe http(s) URL. The image is rewritten to the proxy
* or dropped. Accepts either raw provider data or a previously built preview.
*/
export function parseLinkPreview(raw: unknown, mediaProxy: MediaProxyPolicy = DEFAULT_MEDIA_PROXY): LinkPreview | null {
if (!isObj(raw)) return null;
const safe = parseSafeHttpUrl(raw.type === "linkPreview" ? raw.href : raw.url);
if (!safe) return null;
const image = isProxiedMediaUrl(raw.image, mediaProxy) ? raw.image : toProxiedMediaUrl(raw.image, mediaProxy);
const title = previewText(raw.title, LINK_PREVIEW_LIMITS.title);
const description = previewText(raw.description, LINK_PREVIEW_LIMITS.description);
const siteName = previewText(raw.siteName, LINK_PREVIEW_LIMITS.siteName);
return {
type: "linkPreview",
href: safe.href,
host: safe.host,
...(title !== undefined && { title }),
...(description !== undefined && { description }),
...(siteName !== undefined && { siteName }),
...(image !== null && { image }),
};
}