Files
SRCmail/lib/theme-loader.ts
T
Linus Rath 76b21147e4 feat: add plugin/theme harness and admin dashboard
Plugin & Theme System:
- Add plugin type definitions, permissions (30+), and validation constants
- Add IndexedDB storage layer for plugin code, theme CSS, and previews
- Add theme CSS sanitization, injection, and safety validation
- Add HookBus event system with 130+ hooks across 20 domains
- Add plugin ZIP extraction and manifest validation with JS security checks
- Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating
- Add plugin loader with blob URL dynamic import and auto-disable circuit breaker
- Add 3 built-in themes (Nord, Catppuccin, Solarized)
- Add Zustand plugin store with install/uninstall/enable/disable lifecycle
- Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components
- Add plugins and themes settings UI panels
- Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu
- Extend theme store with custom theme installation and activation

Admin Dashboard:
- Add admin authentication with scrypt password hashing and AES-256-GCM sessions
- Add rate-limited login (5 attempts/15min per IP)
- Add config manager with admin override > env var > default priority
- Add settings policy system with feature gates and per-setting restrictions
- Add audit logging with rotation
- Add admin API routes (login, logout, config, policy, audit, password change)
- Add admin UI pages (login, dashboard, config, policy, audit)
- Add policy store for client-side feature gate enforcement
- Wire admin password initialization into server instrumentation

Tests:
- Add 139 tests across 10 test files covering all plugin/theme modules
2026-03-25 00:44:03 +01:00

116 lines
3.1 KiB
TypeScript

// Theme CSS injection and sanitization
import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
const THEME_STYLE_ID = 'active-theme';
/**
* Sanitize theme CSS: strip dangerous patterns like @import, external url(),
* JavaScript expressions, and -moz-binding. Returns cleaned CSS.
*/
export function sanitizeThemeCSS(css: string): { css: string; warnings: string[] } {
const warnings: string[] = [];
let cleaned = css;
for (const pattern of DISALLOWED_CSS_PATTERNS) {
if (pattern.test(cleaned)) {
warnings.push(`Removed disallowed pattern: ${pattern.source}`);
cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */');
}
}
return { css: cleaned, warnings };
}
/**
* Validate that theme CSS only targets :root and .dark selectors.
* Returns warnings for any other selectors found.
*/
export function validateThemeSelectors(css: string): string[] {
const warnings: string[] = [];
// Remove comments
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
// Find selector blocks (text before { that isn't inside a value)
const selectorRegex = /([^{}]+)\{/g;
let match;
while ((match = selectorRegex.exec(noComments)) !== null) {
const selector = match[1].trim();
// Allow :root, .dark, @font-face, @keyframes, @media
if (
selector === ':root' ||
selector === '.dark' ||
selector.startsWith('@font-face') ||
selector.startsWith('@keyframes') ||
selector.startsWith('@media') ||
selector === ''
) {
continue;
}
// Inside @media blocks, also allow :root and .dark
if (selector === ':root' || selector === '.dark') continue;
warnings.push(`Non-standard selector "${selector}" — themes should only use :root and .dark`);
}
return warnings;
}
/**
* Inject theme CSS into the document head.
* Inserted after globals.css so theme variables win specificity.
*/
export function injectThemeCSS(css: string): void {
if (typeof document === 'undefined') return;
let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = THEME_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
}
/**
* Remove injected theme CSS, reverting to default.
*/
export function removeThemeCSS(): void {
if (typeof document === 'undefined') return;
const styleEl = document.getElementById(THEME_STYLE_ID);
if (styleEl) {
styleEl.remove();
}
}
/**
* Check if a theme CSS string is valid and safe.
*/
export function validateThemeCSSSafety(css: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!css.trim()) {
errors.push('Theme CSS is empty');
return { valid: false, errors };
}
// Check for dangerous patterns
for (const pattern of DISALLOWED_CSS_PATTERNS) {
if (pattern.test(css)) {
errors.push(`Contains disallowed pattern: ${pattern.source}`);
}
}
// Check the CSS actually sets some variables
if (!css.includes('--color-')) {
errors.push('Theme CSS should set at least one --color-* variable');
}
return { valid: errors.length === 0, errors };
}