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/.
179 lines
8.1 KiB
TypeScript
179 lines
8.1 KiB
TypeScript
import { Linter } from "eslint";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
import { describe, expect, it } from "vitest";
|
||
import { noRawHtmlSinks } from "./eslint.js";
|
||
import { parseProviderHtml } from "./html.js";
|
||
import { parsePlainText } from "./plain.js";
|
||
import { RichText } from "./react.js";
|
||
import { isProxiedMediaUrl, parseSafeHttpUrl, toProxiedMediaUrl } from "./url.js";
|
||
import { coerceRichTextDocument, parseLinkPreview } from "./validate.js";
|
||
|
||
describe("Mastodon HTML", () => {
|
||
const status =
|
||
'<p><span class="h-card" translate="no"><a href="https://social.example/@alice" class="u-url mention">@<span>alice</span></a></span> ' +
|
||
'look :blobcat: <a href="https://social.example/tags/fediverse" class="mention hashtag" rel="tag">#<span>fediverse</span></a></p>' +
|
||
'<p>read <a href="https://news.example/articles/2026/very-long-path" target="_blank" rel="nofollow noopener noreferrer" translate="no">' +
|
||
'<span class="invisible">https://</span><span class="ellipsis">news.example/articles/2026/v</span><span class="invisible">ery-long-path</span></a><br>bye</p>';
|
||
|
||
it("maps mentions, hashtags, links, emoji and breaks to the AST", () => {
|
||
const doc = parseProviderHtml(status, { emojis: [{ shortcode: "blobcat", url: "https://cdn.example/blobcat.png" }] });
|
||
expect(doc).toEqual({
|
||
type: "doc",
|
||
version: 1,
|
||
truncated: false,
|
||
children: [
|
||
{
|
||
type: "paragraph",
|
||
children: [
|
||
{ type: "mention", handle: "alice@social.example", href: "https://social.example/@alice" },
|
||
{ type: "text", text: " look " },
|
||
{ type: "emoji", shortcode: "blobcat", src: "/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fblobcat.png" },
|
||
{ type: "text", text: " " },
|
||
{ type: "hashtag", tag: "fediverse", href: "https://social.example/tags/fediverse" },
|
||
],
|
||
},
|
||
{
|
||
type: "paragraph",
|
||
children: [
|
||
{ type: "text", text: "read " },
|
||
{
|
||
type: "link",
|
||
href: "https://news.example/articles/2026/very-long-path",
|
||
host: "news.example",
|
||
children: [{ type: "text", text: "news.example/articles/2026/v…" }],
|
||
},
|
||
{ type: "linebreak" },
|
||
{ type: "text", text: "bye" },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
it("renders the expected markup", () => {
|
||
const html = renderToStaticMarkup(<RichText doc={parseProviderHtml(status)} />);
|
||
expect(html).toContain('<a href="https://social.example/@alice" rel="noopener noreferrer nofollow ugc" target="_blank" referrerPolicy="no-referrer" class="sc-mention">@alice@social.example</a>');
|
||
expect(html).toContain(":blobcat:");
|
||
expect(html).not.toContain("sc-link-host");
|
||
});
|
||
});
|
||
|
||
describe("plain text", () => {
|
||
it("linkifies http(s) URLs, mentions, hashtags and newlines", () => {
|
||
const doc = parsePlainText("hi @bob@x.example see https://a.example/p?q=1. #tag\nline2\n\npara2");
|
||
expect(doc.children).toHaveLength(2);
|
||
expect(doc.children[0]!.children).toEqual([
|
||
{ type: "text", text: "hi " },
|
||
{ type: "mention", handle: "bob@x.example" },
|
||
{ type: "text", text: " see " },
|
||
{ type: "link", href: "https://a.example/p?q=1", host: "a.example", children: [{ type: "text", text: "a.example/p?q=1" }] },
|
||
{ type: "text", text: ". " },
|
||
{ type: "hashtag", tag: "tag" },
|
||
{ type: "linebreak" },
|
||
{ type: "text", text: "line2" },
|
||
]);
|
||
});
|
||
|
||
it("ignores non-string input", () => {
|
||
expect(parsePlainText(42).children).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("URL policy", () => {
|
||
it.each([
|
||
"javascript:alert(1)", " javascript:alert(1)", "JAVASCRIPT:alert(1)", "java\tscript:alert(1)", "data:text/html,x",
|
||
"vbscript:x", "file:///etc/passwd", "//evil.example", "/relative", "https:evil.example", "https:/evil.example",
|
||
"https://user@evil.example/", "https://:pw@evil.example/", "http://evil.example/\u0000", "https://evil.example/",
|
||
"ftp://evil.example/", "https://" + "a".repeat(3_000) + ".example/", "",
|
||
])("rejects %j", (raw) => {
|
||
expect(parseSafeHttpUrl(raw)).toBeNull();
|
||
});
|
||
|
||
it("accepts http(s) and normalises", () => {
|
||
expect(parseSafeHttpUrl("HTTPS://Ok.Example/a b".replace(" ", "%20"))).toEqual({ href: "https://ok.example/a%20b", host: "ok.example" });
|
||
});
|
||
|
||
it("media proxy URLs are canonical and same-origin", () => {
|
||
const src = toProxiedMediaUrl("https://cdn.example/a.png");
|
||
expect(src).toBe("/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fa.png");
|
||
expect(isProxiedMediaUrl(src)).toBe(true);
|
||
expect(isProxiedMediaUrl("https://cdn.example/a.png")).toBe(false);
|
||
expect(isProxiedMediaUrl("/api/media-proxy?url=https://cdn.example/a.png")).toBe(false);
|
||
expect(isProxiedMediaUrl("/api/media-proxy?url=javascript%3Aalert(1)")).toBe(false);
|
||
expect(toProxiedMediaUrl("data:image/png;base64,AAAA")).toBeNull();
|
||
expect(() => toProxiedMediaUrl("https://cdn.example/a.png", { proxyPath: "//evil.example/p" })).toThrow();
|
||
expect(() => toProxiedMediaUrl("https://cdn.example/a.png", { proxyPath: "https://evil.example/p" })).toThrow();
|
||
});
|
||
|
||
it("supports a custom proxy path end to end", () => {
|
||
const mediaProxy = { proxyPath: "/media/p" };
|
||
const doc = parseProviderHtml("hi :e:", { mediaProxy, emojis: [{ shortcode: "e", url: "https://cdn.example/e.png" }] });
|
||
const html = renderToStaticMarkup(<RichText doc={doc} mediaProxy={mediaProxy} />);
|
||
expect(html).toContain('src="/media/p?url=https%3A%2F%2Fcdn.example%2Fe.png"');
|
||
// Same doc rendered against a different proxy: emoji degrades to text.
|
||
expect(renderToStaticMarkup(<RichText doc={doc} />)).not.toContain("<img");
|
||
});
|
||
});
|
||
|
||
describe("coerceRichTextDocument", () => {
|
||
it("round-trips a valid document unchanged", () => {
|
||
const doc = parseProviderHtml('<p>a <a href="https://ok.example/">ok.example</a> @x #y</p>');
|
||
expect(coerceRichTextDocument(JSON.parse(JSON.stringify(doc)))).toEqual(doc);
|
||
});
|
||
|
||
it("re-applies node limits", () => {
|
||
const forged = { type: "doc", children: [{ type: "paragraph", children: Array.from({ length: 10_000 }, (_, i) => (i % 2 ? { type: "linebreak" } : { type: "mention", handle: "a" })) }] };
|
||
const doc = coerceRichTextDocument(forged);
|
||
expect(doc.truncated).toBe(true);
|
||
expect(doc.children[0]!.children.length).toBeLessThanOrEqual(2_000);
|
||
});
|
||
|
||
it("bounds total text from forged nodes", () => {
|
||
const forged = { type: "doc", children: [{ type: "paragraph", children: [{ type: "text", text: "x".repeat(5_000_000) }] }] };
|
||
const doc = coerceRichTextDocument(forged);
|
||
expect(doc.truncated).toBe(true);
|
||
expect(JSON.stringify(doc).length).toBeLessThan(25_000);
|
||
});
|
||
});
|
||
|
||
describe("parseLinkPreview", () => {
|
||
it("builds a preview with proxied image and plain-text fields", () => {
|
||
expect(parseLinkPreview({ url: "https://ok.example/a", title: "A & <b>B</b>", siteName: "Ok", image: "https://cdn.example/i.png" })).toEqual({
|
||
type: "linkPreview",
|
||
href: "https://ok.example/a",
|
||
host: "ok.example",
|
||
title: "A & <b>B</b>",
|
||
siteName: "Ok",
|
||
image: "/api/media-proxy?url=https%3A%2F%2Fcdn.example%2Fi.png",
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("eslint raw-HTML sink rule", () => {
|
||
const linter = new Linter({ configType: "flat" });
|
||
const lint = (code: string) =>
|
||
linter.verify(code, [
|
||
{
|
||
languageOptions: { ecmaVersion: "latest", sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } },
|
||
rules: noRawHtmlSinks.rules,
|
||
},
|
||
]);
|
||
|
||
it.each([
|
||
"const x = <div dangerouslySetInnerHTML={{ __html: post.html }} />;",
|
||
"React.createElement('div', { dangerouslySetInnerHTML: { __html: s } });",
|
||
"el.innerHTML = s;",
|
||
"el.outerHTML = s;",
|
||
"el.insertAdjacentHTML('beforeend', s);",
|
||
"document.write(s);",
|
||
"new Function(s)();",
|
||
"eval(s);",
|
||
"new DOMParser().parseFromString(s, 'text/html');",
|
||
])("flags %s", (code) => {
|
||
expect(lint(code).map((m) => m.ruleId)).toContain("no-restricted-syntax");
|
||
});
|
||
|
||
it("allows text rendering", () => {
|
||
expect(lint("const x = <div>{post.text}</div>; el.textContent = s;")).toEqual([]);
|
||
});
|
||
});
|