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/.
107 lines
4.7 KiB
TypeScript
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 }),
|
|
};
|
|
}
|
|
|