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/.
57 lines
2.2 KiB
TypeScript
57 lines
2.2 KiB
TypeScript
/**
|
||
* Bidi embedding/override/isolate controls (U+202A–U+202E, U+2066–U+2069) can
|
||
* visually reorder text ("evil" + U+202E + "txt.exe"); they are always removed.
|
||
*/
|
||
const BIDI_CONTROLS = /[\u202A-\u202E\u2066-\u2069]/g;
|
||
/** Marks and zero-width characters used for spoofing look-alike names. */
|
||
const INVISIBLES = /[\u200B-\u200F\u061C\u180E\u2060-\u2064\uFEFF\u034F\u115F\u1160\u3164\uFFA0]/g;
|
||
/** C0/C1 controls except tab and newline; lone surrogates. */
|
||
const CONTROLS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
||
|
||
/** Normalises body text: strips controls and bidi overrides, keeps ZWJ (emoji sequences). */
|
||
export function neutralizeText(input: string): string {
|
||
return input.replace(CONTROLS, "").replace(BIDI_CONTROLS, "").replace(/\r\n?/g, "\n");
|
||
}
|
||
|
||
/**
|
||
* Display names, usernames, channel names: single line, no bidi or zero-width
|
||
* characters at all, whitespace collapsed, length-capped.
|
||
*/
|
||
export function neutralizeDisplayName(input: string, maxLength = 100): string {
|
||
const cleaned = input
|
||
.normalize("NFC")
|
||
.replace(CONTROLS, "")
|
||
.replace(BIDI_CONTROLS, "")
|
||
.replace(INVISIBLES, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
return truncateCodePoints(cleaned, maxLength);
|
||
}
|
||
|
||
export function truncateCodePoints(input: string, max: number): string {
|
||
if (input.length <= max) return input;
|
||
const chars = Array.from(input);
|
||
return chars.length <= max ? input : chars.slice(0, max).join("") + "…";
|
||
}
|
||
|
||
const SCRIPT_RUNS: readonly [string, RegExp][] = [
|
||
["Latin", /\p{Script=Latin}/u],
|
||
["Cyrillic", /\p{Script=Cyrillic}/u],
|
||
["Greek", /\p{Script=Greek}/u],
|
||
["Armenian", /\p{Script=Armenian}/u],
|
||
["Cherokee", /\p{Script=Cherokee}/u],
|
||
];
|
||
|
||
/**
|
||
* Homoglyph heuristic: true when a single word mixes Latin with a script that
|
||
* has Latin look-alikes ("pаypal" with Cyrillic "а"). The UI should flag such
|
||
* names rather than trust them; it is a warning, not a sanitiser.
|
||
*/
|
||
export function hasMixedScriptConfusables(input: string): boolean {
|
||
for (const word of input.normalize("NFKC").split(/[\s\p{P}]+/u)) {
|
||
let seen = 0;
|
||
for (const [, re] of SCRIPT_RUNS) if (re.test(word)) seen++;
|
||
if (seen > 1) return true;
|
||
}
|
||
return false;
|
||
}
|