d418c367cb
Security fixes: - Validate theme background image URLs against whitelist pattern - Add board permission check to Socket.IO join-board handler - Disable raw HTML passthrough in markdown parser (XSS prevention) - Add UUID validation on avatar and branding route params (path traversal) - Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) - Validate webhook URLs (HTTPS only, block internal addresses) - Add import payload size limit (5MB) - Remove server uptime from health endpoint - Make seed password configurable via SEED_ADMIN_PASSWORD env var - Patch DOMPurify dependency vulnerability via npm audit fix Warning fixes: - Convert $state(prop) to $effect.pre sync pattern (state_referenced_locally) - Add for/id pairings to form labels (a11y_label_has_associated_control) - Add svelte-ignore for intentional autofocus usage (a11y_autofocus) - Add ARIA roles to interactive/overlay divs (a11y_no_static_element_interactions) - Add aria-label to icon-only buttons (a11y_consider_explicit_label) - Add keyboard handler alongside click events (a11y_click_events_have_key_events) - Fix non-reactive fileInput declaration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
118 lines
4.7 KiB
TypeScript
118 lines
4.7 KiB
TypeScript
import type { TenantThemeData } from '../../app.d.js';
|
|
|
|
/** Validates a theme background image URL is safe for CSS injection. */
|
|
function isSafeCssUrl(url: string): boolean {
|
|
return /^\/api\/branding\/[0-9a-f-]+\/[a-z-]+$/.test(url);
|
|
}
|
|
|
|
/**
|
|
* Client-side CSS variable mapping — mirrors server-side theme-css.ts
|
|
*/
|
|
const FIELD_MAP: Record<string, { cssVar: string; mode: 'light' | 'dark' }> = {};
|
|
|
|
const vars = [
|
|
['PageBg', '--color-page-bg'],
|
|
['NavBg', '--color-nav-bg'],
|
|
['ColumnBg', '--color-column-bg'],
|
|
['ColumnBorder', '--color-column-border'],
|
|
['ColumnText', '--color-column-text'],
|
|
['CardBg', '--color-card-bg'],
|
|
['CardBorder', '--color-card-border'],
|
|
['CardText', '--color-card-text'],
|
|
['CardRadius', '--color-card-radius'],
|
|
['Primary', '--color-primary'],
|
|
['PrimaryHover', '--color-primary-hover'],
|
|
['Danger', '--color-danger'],
|
|
['DangerHover', '--color-danger-hover'],
|
|
['Success', '--color-success'],
|
|
['Warning', '--color-warning']
|
|
] as const;
|
|
|
|
for (const [suffix, cssVar] of vars) {
|
|
FIELD_MAP[`light${suffix}`] = { cssVar, mode: 'light' };
|
|
FIELD_MAP[`dark${suffix}`] = { cssVar, mode: 'dark' };
|
|
}
|
|
|
|
function hexToRgba(hex: string, opacity: number): string {
|
|
const h = hex.replace('#', '');
|
|
const r = parseInt(h.substring(0, 2), 16);
|
|
const g = parseInt(h.substring(2, 4), 16);
|
|
const b = parseInt(h.substring(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
|
}
|
|
|
|
/**
|
|
* Generates CSS from theme data and writes it into the <style id="tenant-theme"> element.
|
|
* This mirrors what generateTenantCSS does server-side, but runs in the browser
|
|
* so theme changes take effect immediately without a full page reload.
|
|
*/
|
|
export function persistThemeCSS(theme: TenantThemeData) {
|
|
if (typeof document === 'undefined') return;
|
|
const lightVars: string[] = [];
|
|
const darkVars: string[] = [];
|
|
|
|
for (const [field, { cssVar, mode }] of Object.entries(FIELD_MAP)) {
|
|
const value = (theme as any)[field];
|
|
if (value == null) continue;
|
|
const target = mode === 'dark' ? darkVars : lightVars;
|
|
target.push(` ${cssVar}: ${value};`);
|
|
}
|
|
|
|
// Handle opacity for columns
|
|
if (theme.lightColumnOpacity != null && theme.lightColumnBg?.startsWith('#')) {
|
|
const idx = lightVars.findIndex((v) => v.includes('--color-column-bg:'));
|
|
if (idx !== -1) lightVars[idx] = ` --color-column-bg: ${hexToRgba(theme.lightColumnBg, theme.lightColumnOpacity)};`;
|
|
if (theme.lightColumnOpacity < 1) lightVars.push(` --column-backdrop-blur: blur(8px);`);
|
|
}
|
|
if (theme.lightCardOpacity != null && theme.lightCardBg?.startsWith('#')) {
|
|
const idx = lightVars.findIndex((v) => v.includes('--color-card-bg:'));
|
|
if (idx !== -1) lightVars[idx] = ` --color-card-bg: ${hexToRgba(theme.lightCardBg, theme.lightCardOpacity)};`;
|
|
if (theme.lightCardOpacity < 1) lightVars.push(` --card-backdrop-blur: blur(8px);`);
|
|
}
|
|
if (theme.darkColumnOpacity != null && theme.darkColumnBg?.startsWith('#')) {
|
|
const idx = darkVars.findIndex((v) => v.includes('--color-column-bg:'));
|
|
if (idx !== -1) darkVars[idx] = ` --color-column-bg: ${hexToRgba(theme.darkColumnBg, theme.darkColumnOpacity)};`;
|
|
if (theme.darkColumnOpacity < 1) darkVars.push(` --column-backdrop-blur: blur(8px);`);
|
|
}
|
|
if (theme.darkCardOpacity != null && theme.darkCardBg?.startsWith('#')) {
|
|
const idx = darkVars.findIndex((v) => v.includes('--color-card-bg:'));
|
|
if (idx !== -1) darkVars[idx] = ` --color-card-bg: ${hexToRgba(theme.darkCardBg, theme.darkCardOpacity)};`;
|
|
if (theme.darkCardOpacity < 1) darkVars.push(` --card-backdrop-blur: blur(8px);`);
|
|
}
|
|
|
|
// Handle background images
|
|
const extraRules: string[] = [];
|
|
if (theme.lightPageBgImage && isSafeCssUrl(theme.lightPageBgImage)) {
|
|
extraRules.push(`.layout-page-bg { background-image: url('${theme.lightPageBgImage}'); }`);
|
|
}
|
|
if (theme.lightNavBgImage && isSafeCssUrl(theme.lightNavBgImage)) {
|
|
extraRules.push(`.layout-nav-bg { background-image: url('${theme.lightNavBgImage}'); }`);
|
|
}
|
|
if (theme.darkPageBgImage && isSafeCssUrl(theme.darkPageBgImage)) {
|
|
extraRules.push(`html.dark .layout-page-bg { background-image: url('${theme.darkPageBgImage}'); }`);
|
|
}
|
|
if (theme.darkNavBgImage && isSafeCssUrl(theme.darkNavBgImage)) {
|
|
extraRules.push(`html.dark .layout-nav-bg { background-image: url('${theme.darkNavBgImage}'); }`);
|
|
}
|
|
|
|
let css = '';
|
|
if (lightVars.length > 0) {
|
|
css += `:root {\n${lightVars.join('\n')}\n}\n`;
|
|
}
|
|
if (darkVars.length > 0) {
|
|
css += `html.dark {\n${darkVars.join('\n')}\n}\n`;
|
|
}
|
|
if (extraRules.length > 0) {
|
|
css += extraRules.join('\n') + '\n';
|
|
}
|
|
|
|
// Find or create the <style id="tenant-theme"> element
|
|
let styleEl = document.getElementById('tenant-theme') as HTMLStyleElement | null;
|
|
if (!styleEl) {
|
|
styleEl = document.createElement('style');
|
|
styleEl.id = 'tenant-theme';
|
|
document.head.appendChild(styleEl);
|
|
}
|
|
styleEl.textContent = css;
|
|
}
|