Add bot-driven room app UI via im.paarrot.ui state.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
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.
This commit is contained in:
91
src/app/features/room-app/sanitizeRoomAppCss.ts
Normal file
91
src/app/features/room-app/sanitizeRoomAppCss.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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://')
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user