- Link previews for web links in chat, fetched by the f0ckm upload host by default (linked sites never see the user's IP), or directly, or off - Voice channel tiles above the chat on windows too narrow for the member list - Per-person volume slider in the user context menu - Profile panel: channel on its own line under the name, no Guest badge, voice actions moved to the context menu - User information dialog refreshes every second - Rate limiting mirrors Murmur's whole-second bucket leak, so irregular bursts are queued instead of dropped - CHANGELOG.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
8.1 KiB
TypeScript
172 lines
8.1 KiB
TypeScript
import DOMPurify, { type Config } from 'dompurify';
|
|
|
|
export type EmbedKind = 'image' | 'video' | 'audio' | 'youtube' | 'link';
|
|
export interface Embed { kind: EmbedKind; url: string; name: string; videoId?: string; start?: number }
|
|
|
|
// YouTube links: watch, youtu.be, shorts, embed, live, music, mobile
|
|
export function youtubeVideo(href: string): { id: string; start: number } | null {
|
|
let u: URL;
|
|
try { u = new URL(href); } catch { return null; }
|
|
const host = u.hostname.replace(/^(www|m|music)\./, '');
|
|
let id = '';
|
|
if (host === 'youtu.be') id = u.pathname.slice(1).split('/')[0];
|
|
else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
|
|
if (u.pathname === '/watch') id = u.searchParams.get('v') ?? '';
|
|
else id = /^\/(?:shorts|embed|live|v)\/([^/?#]+)/.exec(u.pathname)?.[1] ?? '';
|
|
}
|
|
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return null;
|
|
// t=90, t=1m30s, start=90
|
|
const t = u.searchParams.get('t') ?? u.searchParams.get('start') ?? '';
|
|
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/.exec(t);
|
|
const start = m ? (Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) : 0;
|
|
return { id, start };
|
|
}
|
|
|
|
const EXT: Record<string, EmbedKind> = {
|
|
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', avif: 'image',
|
|
mp4: 'video', webm: 'video', mov: 'video', m4v: 'video',
|
|
mp3: 'audio', ogg: 'audio', opus: 'audio', wav: 'audio', flac: 'audio', m4a: 'audio'
|
|
};
|
|
|
|
export function mediaKind(name: string): EmbedKind | null {
|
|
const m = /\.([a-z0-9]+)$/i.exec(name);
|
|
return m ? EXT[m[1].toLowerCase()] ?? null : null;
|
|
}
|
|
|
|
const purifyConfig: Config & { RETURN_DOM_FRAGMENT: true } = {
|
|
ALLOWED_TAGS: ['a', 'b', 'i', 'u', 's', 'strong', 'em', 'br', 'p', 'span', 'div', 'ul', 'ol', 'li',
|
|
'blockquote', 'code', 'pre', 'img', 'h1', 'h2', 'h3', 'h4', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
|
|
'hr', 'sub', 'sup', 'font'],
|
|
// style is reduced to a safe subset below; color is the <font color> attribute older clients send
|
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'style', 'color'],
|
|
ALLOW_DATA_ATTR: false,
|
|
RETURN_DOM_FRAGMENT: true
|
|
};
|
|
|
|
// Text styling that Mumble clients (Qt rich text) use; anything else is dropped
|
|
const STYLE_PROPS: Record<string, RegExp> = {
|
|
'color': /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]{3,20})$/i,
|
|
'background-color': /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]{3,20})$/i,
|
|
'font-weight': /^(normal|bold|bolder|lighter|[1-9]00)$/i,
|
|
'font-style': /^(normal|italic|oblique)$/i,
|
|
'text-decoration': /^(none|underline|line-through|overline)( (underline|line-through|overline))*$/i,
|
|
'font-size': /^(\d{1,2}(\.\d+)?(pt|px|em|%)|small|medium|large|x-large|xx-large|smaller|larger)$/i,
|
|
'text-align': /^(left|right|center|justify)$/i
|
|
};
|
|
|
|
// rgb(1, 2, 3) -> #010203, which every Mumble client understands
|
|
function normalizeColor(v: string): string {
|
|
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(v);
|
|
return m ? '#' + m.slice(1, 4).map(n => Math.min(255, Number(n)).toString(16).padStart(2, '0')).join('') : v;
|
|
}
|
|
|
|
export function cleanStyle(style: string): string {
|
|
const out: string[] = [];
|
|
for (const decl of style.split(';')) {
|
|
const i = decl.indexOf(':');
|
|
if (i < 0) continue;
|
|
const prop = decl.slice(0, i).trim().toLowerCase();
|
|
let value = decl.slice(i + 1).trim().replace(/\s*!important$/i, '');
|
|
if (!STYLE_PROPS[prop]?.test(value)) continue;
|
|
if (prop.endsWith('color')) value = normalizeColor(value);
|
|
out.push(`${prop}:${value}`);
|
|
}
|
|
return out.join(';');
|
|
}
|
|
|
|
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
|
const el = node as Element;
|
|
if (el.hasAttribute('style')) {
|
|
const clean = cleanStyle(el.getAttribute('style') ?? '');
|
|
if (clean) el.setAttribute('style', clean);
|
|
else el.removeAttribute('style');
|
|
}
|
|
if (el.hasAttribute('color') && !STYLE_PROPS.color.test(el.getAttribute('color') ?? '')) el.removeAttribute('color');
|
|
if (el.tagName === 'A') {
|
|
const href = el.getAttribute('href') ?? '';
|
|
if (!/^https?:\/\//i.test(href)) el.removeAttribute('href');
|
|
el.setAttribute('target', '_blank');
|
|
el.setAttribute('rel', 'noopener noreferrer');
|
|
}
|
|
// Only inline data: images; remote images would leak our IP to arbitrary hosts
|
|
if (el.tagName === 'IMG' && !/^data:image\/(png|jpe?g|gif|webp);/i.test(el.getAttribute('src') ?? '')) {
|
|
el.replaceWith(document.createTextNode('[image]'));
|
|
}
|
|
});
|
|
|
|
// Sanitize an incoming Mumble HTML message and pull out media links from trusted hosts.
|
|
export function renderIncoming(html: string, trustedHosts: string[]): { html: string; embeds: Embed[] } {
|
|
const frag = DOMPurify.sanitize(html, purifyConfig);
|
|
const embeds: Embed[] = [];
|
|
for (const a of frag.querySelectorAll('a[href]')) {
|
|
const yt = youtubeVideo(a.getAttribute('href')!);
|
|
if (yt) {
|
|
if (!embeds.some(e => e.videoId === yt.id) && embeds.filter(e => e.kind === 'youtube').length < 3) {
|
|
embeds.push({ kind: 'youtube', url: a.getAttribute('href')!, name: a.textContent ?? '', videoId: yt.id, start: yt.start });
|
|
}
|
|
continue;
|
|
}
|
|
let url: URL;
|
|
try { url = new URL(a.getAttribute('href')!); } catch { continue; }
|
|
const trusted = trustedHosts.includes(url.host.toLowerCase());
|
|
const name = decodeURIComponent(url.pathname.split('/').pop() ?? '');
|
|
const kind = trusted ? mediaKind(name) : null;
|
|
if (kind) embeds.push({ kind, url: url.href, name });
|
|
// Other web links get a preview card (not for the upload host's own files)
|
|
else if (!trusted && /^https?:$/.test(url.protocol) && embeds.filter(e => e.kind === 'link').length < 2 && !embeds.some(e => e.url === url.href)) {
|
|
embeds.push({ kind: 'link', url: url.href, name: a.textContent ?? '' });
|
|
}
|
|
}
|
|
// The inline thumbnail is only there for vanilla clients; we show the real media instead
|
|
if (embeds.some(e => e.kind === 'image' || e.kind === 'video' || e.kind === 'audio')) frag.querySelectorAll('img').forEach(img => img.remove());
|
|
const div = document.createElement('div');
|
|
div.append(frag);
|
|
return { html: div.innerHTML.replace(/^(<br\s*\/?>)+|(<br\s*\/?>)+$/g, ''), embeds };
|
|
}
|
|
|
|
export function escapeHtml(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// Plain composer text to the HTML Mumble clients expect: escaped, links clickable, newlines as <br/>.
|
|
// Output must stay well-formed XML: Murmur parses long messages as XML and denies them otherwise.
|
|
export function textToHtml(text: string): string {
|
|
return escapeHtml(text)
|
|
.replace(/https?:\/\/[^\s<]+[^\s<.,:;"')\]]/g, url => `<a href="${url}">${url}</a>`)
|
|
.replace(/\r?\n/g, '<br/>');
|
|
}
|
|
|
|
export function formatBytes(n: number): string {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
|
|
if (n < 1073741824) return `${(n / 1048576).toFixed(1)} MB`;
|
|
return `${(n / 1073741824).toFixed(2)} GB`;
|
|
}
|
|
|
|
// ─── Outgoing rich text (comments) ────────────────────────────────────────────
|
|
|
|
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
|
const OUT_ATTRS = new Set(['href', 'src', 'alt', 'title', 'style', 'color']);
|
|
const escText = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const escAttr = (t: string) => escText(t).replace(/"/g, '"');
|
|
|
|
function serialize(node: Node): string {
|
|
if (node.nodeType === Node.TEXT_NODE) return escText(node.textContent ?? '');
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
const el = node as Element;
|
|
const tag = el.tagName.toLowerCase();
|
|
const attrs = [...el.attributes].filter(a => OUT_ATTRS.has(a.name)).map(a => ` ${a.name}="${escAttr(a.value)}"`).join('');
|
|
if (VOID_TAGS.has(tag)) return `<${tag}${attrs}/>`;
|
|
return `<${tag}${attrs}>${[...el.childNodes].map(serialize).join('')}</${tag}>`;
|
|
}
|
|
|
|
// Sanitized, well-formed XHTML that Murmur's XML check accepts and Mumble clients render
|
|
export function toMumbleHtml(html: string): string {
|
|
const frag = DOMPurify.sanitize(html, purifyConfig);
|
|
return [...frag.childNodes].map(serialize).join('')
|
|
.replace(/(<p><br\/><\/p>)+$/, '')
|
|
.replace(/^(<br\/>)+|(<br\/>)+$/g, '')
|
|
.trim();
|
|
}
|
|
|