All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Bots can publish a declarative panel UI that takes over a room; user actions are sent as im.paarrot.ui.action timeline events.
92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
/**
|
|
* Strip dangerous CSS constructs from bot-supplied stylesheets.
|
|
* Not a full CSS parser — best-effort for MVP scoped injection.
|
|
*/
|
|
export function sanitizeRoomAppCss(css: string): string {
|
|
let out = css;
|
|
out = out.replace(/@import\b[^;{]*;?/gi, '');
|
|
out = out.replace(/url\s*\(\s*['"]?\s*javascript:[^)]*\)/gi, 'url(about:blank)');
|
|
out = out.replace(/expression\s*\([^)]*\)/gi, 'initial');
|
|
out = out.replace(/-moz-binding\s*:[^;]+;?/gi, '');
|
|
out = out.replace(/behavior\s*:[^;]+;?/gi, '');
|
|
out = out.replace(/@charset\b[^;]*;?/gi, '');
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Prefix plain selectors with a scope so bot CSS cannot escape the room app root.
|
|
* At-rules (@media, @supports, @keyframes) are kept with nested rules re-scoped where possible.
|
|
*/
|
|
export function scopeRoomAppCss(css: string, scopeSelector: string): string {
|
|
const sanitized = sanitizeRoomAppCss(css).trim();
|
|
if (!sanitized) return '';
|
|
|
|
const scopeRule = (selectors: string, body: string): string => {
|
|
const scoped = selectors
|
|
.split(',')
|
|
.map((s) => {
|
|
const sel = s.trim();
|
|
if (!sel) return '';
|
|
if (sel.startsWith(scopeSelector)) return sel;
|
|
return `${scopeSelector} ${sel}`;
|
|
})
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
return `${scoped}{${body}}`;
|
|
};
|
|
|
|
const rewriteBlock = (block: string): string => {
|
|
const trimmed = block.trim();
|
|
if (!trimmed) return '';
|
|
|
|
if (trimmed.startsWith('@')) {
|
|
const open = trimmed.indexOf('{');
|
|
if (open === -1) return `${trimmed};`;
|
|
const header = trimmed.slice(0, open).trim();
|
|
const inner = trimmed.slice(open + 1);
|
|
// @keyframes / @font-face: keep as-is (namespaced risk is low for MVP)
|
|
if (/^@(keyframes|font-face)\b/i.test(header)) {
|
|
return `${header}{${inner}}`;
|
|
}
|
|
// @media / @supports: re-scope nested rules
|
|
const nested = rewriteCssChunk(inner);
|
|
return `${header}{${nested}}`;
|
|
}
|
|
|
|
const open = trimmed.indexOf('{');
|
|
if (open === -1) return '';
|
|
const selectors = trimmed.slice(0, open);
|
|
const body = trimmed.slice(open + 1);
|
|
return scopeRule(selectors, body);
|
|
};
|
|
|
|
const rewriteCssChunk = (chunk: string): string => {
|
|
const parts: string[] = [];
|
|
let depth = 0;
|
|
let start = 0;
|
|
for (let i = 0; i < chunk.length; i += 1) {
|
|
const ch = chunk[i];
|
|
if (ch === '{') depth += 1;
|
|
else if (ch === '}') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
parts.push(rewriteBlock(chunk.slice(start, i)));
|
|
start = i + 1;
|
|
}
|
|
}
|
|
}
|
|
return parts.filter(Boolean).join('\n');
|
|
};
|
|
|
|
return rewriteCssChunk(sanitized);
|
|
}
|
|
|
|
export function isAllowedRoomAppImageSrc(src: string): boolean {
|
|
const trimmed = src.trim();
|
|
return (
|
|
trimmed.startsWith('mxc://') ||
|
|
trimmed.startsWith('https://') ||
|
|
trimmed.startsWith('http://')
|
|
);
|
|
}
|