mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-10-26 06:19:53 +00:00
32ac37da7b
Default tab icon used thin lines; the PNG mark is letterboxed so the B looked tiny in the rail and browser chrome. Prefer a large B favicon and object-fit cover on icon slots. Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow)
1355 lines
53 KiB
JavaScript
1355 lines
53 KiB
JavaScript
/**
|
||
* BetterDesk Console - Branding Service
|
||
* Manages white-label branding configuration (name, logo, colors, favicon)
|
||
* Stored in auth.db branding_config table
|
||
*/
|
||
|
||
const db = require('./database');
|
||
const fontService = require('./fontService');
|
||
const { stripUntilStable, stripTagName } = require('../lib/stripUntilStable');
|
||
|
||
// Dangerous SVG elements that can execute scripts or fetch external resources.
|
||
// Includes <style> (CSS @import/expression XSS vectors) and <use> (xlink:href external SVG inclusion).
|
||
const SVG_DANGEROUS_TAGS = /<\s*(script|foreignobject|iframe|embed|object|applet|animate|set|style|use|image)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi;
|
||
const SVG_DANGEROUS_TAGS_SELFCLOSING = /<\s*(script|foreignobject|iframe|embed|object|applet|style|use|image)\b[^>]*\/>/gi;
|
||
|
||
// Dangerous attributes that can execute JavaScript or trigger external fetches.
|
||
const SVG_DANGEROUS_ATTRS = /\s(on\w+|xlink:href\s*=\s*["']\s*(?:javascript|data|vbscript|file):)[^>]*/gi;
|
||
// Residual HTML injection tokens stripped in a final pass (obfuscated/nested tags).
|
||
const SVG_ON_ATTR = /\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi;
|
||
// Strip javascript:/data:/vbscript: URLs in href / xlink:href.
|
||
const SVG_JAVASCRIPT_HREF = /\b(?:xlink:)?href\s*=\s*["']\s*(?:javascript|data|vbscript|file):[^"']*/gi;
|
||
// Strip CSS expression() and @import inside style attributes (legacy IE / SVG abuse).
|
||
const SVG_CSS_EXPRESSION = /\b(?:expression|@import|url\s*\(\s*["']?\s*(?:javascript|data|vbscript|file):)/gi;
|
||
|
||
/**
|
||
* Sanitize SVG content to prevent XSS / SSRF attacks.
|
||
* Removes script tags, event handlers, dangerous URL schemes, external references.
|
||
* @param {string} svg - Raw SVG string
|
||
* @returns {string} - Sanitized SVG string
|
||
*/
|
||
function sanitizeSvg(svg) {
|
||
if (!svg || typeof svg !== 'string') return '';
|
||
|
||
let sanitized = svg;
|
||
|
||
// Strip XML processing instructions and DOCTYPE (entity expansion / external DTD).
|
||
sanitized = sanitized.replace(/<\?[\s\S]*?\?>/g, '');
|
||
sanitized = sanitized.replace(/<!DOCTYPE[\s\S]*?>/gi, '');
|
||
|
||
// Remove dangerous elements (repeat until stable — multi-pass tag stripping).
|
||
let prev;
|
||
do {
|
||
prev = sanitized;
|
||
sanitized = sanitized.replace(SVG_DANGEROUS_TAGS, '');
|
||
sanitized = sanitized.replace(SVG_DANGEROUS_TAGS_SELFCLOSING, '');
|
||
} while (sanitized !== prev);
|
||
|
||
// Remove event handler attributes & dangerous href schemes (repeat until stable).
|
||
do {
|
||
prev = sanitized;
|
||
sanitized = sanitized.replace(SVG_DANGEROUS_ATTRS, '');
|
||
sanitized = sanitized.replace(SVG_JAVASCRIPT_HREF, ' href="#"');
|
||
sanitized = sanitized.replace(SVG_CSS_EXPRESSION, 'blocked-');
|
||
} while (sanitized !== prev);
|
||
|
||
sanitized = stripUntilStable(sanitized, [
|
||
(s) => stripTagName(s, 'script'),
|
||
(s) => stripTagName(s, 'style'),
|
||
(s) => s.replace(SVG_ON_ATTR, ' '),
|
||
]);
|
||
|
||
return sanitized;
|
||
}
|
||
|
||
/**
|
||
* Validate a logo / favicon URL.
|
||
* Accepts only:
|
||
* - https:// or http:// absolute URLs (parseable, hostname present)
|
||
* - same-origin relative paths starting with a single "/" (rejects "//evil.com" protocol-relative)
|
||
* - empty string (clears the field)
|
||
*
|
||
* Rejects: javascript:, data:, vbscript:, file:, blob:, and protocol-relative ("//") URLs.
|
||
*
|
||
* @param {string} value
|
||
* @returns {string|null} normalized URL or null if invalid
|
||
*/
|
||
function validateBrandingUrl(value) {
|
||
if (value === undefined || value === null) return '';
|
||
const trimmed = String(value).trim();
|
||
if (trimmed === '') return '';
|
||
|
||
// Reject protocol-relative ("//host/path") explicitly — bypass for the "/" relative check.
|
||
if (trimmed.startsWith('//')) return null;
|
||
|
||
// Relative path: must start with a single "/" and contain no scheme.
|
||
if (trimmed.startsWith('/')) {
|
||
// Disallow ".." path traversal hints.
|
||
if (trimmed.includes('..')) return null;
|
||
return trimmed;
|
||
}
|
||
|
||
// Absolute URL: must parse and be http(s).
|
||
try {
|
||
const u = new URL(trimmed);
|
||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||
if (!u.hostname) return null;
|
||
return u.toString();
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitize a free-form CSS color or gradient value used in generated stylesheets.
|
||
* Allows only a safe character set so the value cannot break out of its CSS
|
||
* declaration or inject additional rules. Strips characters that could be used
|
||
* for CSS injection (`{`, `}`, `;`, `<`, `>`, `@`, backslash, quotes) and
|
||
* neutralizes `expression(` / `javascript:` / `url(` patterns.
|
||
* @param {string} value
|
||
* @returns {string} sanitized value (may be empty)
|
||
*/
|
||
function sanitizeCssColorValue(value) {
|
||
if (value === undefined || value === null) return '';
|
||
let v = String(value).trim();
|
||
if (v === '') return '';
|
||
// Drop anything outside a conservative allowlist for colors/gradients.
|
||
v = v.replace(/[^a-zA-Z0-9#%.,()\s/-]/g, '');
|
||
// Defuse function-based CSS abuse even within the allowed charset.
|
||
v = v.replace(/expression\s*\(/gi, '');
|
||
v = v.replace(/url\s*\(/gi, '');
|
||
return v.substring(0, 400);
|
||
}
|
||
|
||
/**
|
||
* Build a CSS background shorthand value from a configured background spec.
|
||
* @param {string} type - 'color' | 'gradient' | 'image' | anything else => ''
|
||
* @param {string} color
|
||
* @param {string} gradient
|
||
* @param {string} imageUrl - already validated via validateBrandingUrl
|
||
* @returns {string} CSS background value or '' when nothing is configured
|
||
*/
|
||
function buildBackgroundValue(type, color, gradient, imageUrl) {
|
||
switch (type) {
|
||
case 'color':
|
||
return sanitizeCssColorValue(color);
|
||
case 'gradient':
|
||
return sanitizeCssColorValue(gradient);
|
||
case 'image': {
|
||
const url = validateBrandingUrl(imageUrl);
|
||
if (!url) return '';
|
||
// Encode quotes/parens defensively even though validation already ran.
|
||
const safe = url.replace(/["()\\]/g, encodeURIComponent);
|
||
return `url("${safe}")`;
|
||
}
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function normalizeBackgroundSize(size) {
|
||
switch (String(size || '').trim()) {
|
||
case 'contain':
|
||
case 'repeat':
|
||
case 'center':
|
||
case 'auto':
|
||
case 'cover':
|
||
return String(size).trim();
|
||
default:
|
||
return 'cover';
|
||
}
|
||
}
|
||
|
||
function flattenAppearanceInput(input) {
|
||
if (!input || typeof input !== 'object') return {};
|
||
const flat = {};
|
||
const source = input.appearance && typeof input.appearance === 'object' ? input.appearance : input;
|
||
|
||
for (const [section, keys] of Object.entries(APPEARANCE_FLAT_MAP)) {
|
||
const sectionData = source[section];
|
||
if (!sectionData || typeof sectionData !== 'object') continue;
|
||
for (const key of keys) {
|
||
if (key in sectionData) flat[key] = sectionData[key];
|
||
}
|
||
}
|
||
|
||
// Accept both the flat legacy shape and the v2 nested shape.
|
||
for (const key of Object.keys(DEFAULT_BRANDING)) {
|
||
if (key in input && key !== 'appearance') flat[key] = input[key];
|
||
}
|
||
|
||
if (source.palette && typeof source.palette === 'object') {
|
||
if (source.palette.colors && typeof source.palette.colors === 'object') {
|
||
flat.colors = source.palette.colors;
|
||
}
|
||
if (source.palette.semantic && typeof source.palette.semantic === 'object') {
|
||
flat.colors = {
|
||
...(flat.colors || {}),
|
||
...Object.fromEntries(Object.entries(source.palette.semantic)
|
||
.map(([semanticKey, value]) => [SEMANTIC_COLOR_ALIASES[semanticKey], value])
|
||
.filter(([legacyKey]) => legacyKey))
|
||
};
|
||
}
|
||
}
|
||
|
||
flat.appearanceSchemaVersion = '2';
|
||
if (flat.bgSize) flat.bgSize = normalizeBackgroundSize(flat.bgSize);
|
||
if (flat.rdclientBgSize) flat.rdclientBgSize = normalizeBackgroundSize(flat.rdclientBgSize);
|
||
return flat;
|
||
}
|
||
|
||
function backgroundSpecFromBranding(branding, prefix, defaultType = 'none') {
|
||
const typeKey = `${prefix}BgType`;
|
||
const colorKey = `${prefix}BgColor`;
|
||
const gradientKey = `${prefix}BgGradient`;
|
||
const imageKey = `${prefix}BgImageUrl`;
|
||
const overlayKey = `${prefix}BgOverlay`;
|
||
const sizeKey = `${prefix}BgSize`;
|
||
return {
|
||
type: branding[typeKey] || defaultType,
|
||
color: branding[colorKey] || '',
|
||
gradient: branding[gradientKey] || '',
|
||
imageUrl: branding[imageKey] || '',
|
||
overlay: branding[overlayKey] || '',
|
||
size: normalizeBackgroundSize(branding[sizeKey] || 'cover')
|
||
};
|
||
}
|
||
|
||
function getAppearanceModel(brandingInput = null) {
|
||
const branding = brandingInput || getBranding();
|
||
const semantic = {};
|
||
for (const [semanticKey, legacyKey] of Object.entries(SEMANTIC_COLOR_ALIASES)) {
|
||
semantic[semanticKey] = branding.colors?.[legacyKey] || '';
|
||
}
|
||
return {
|
||
version: '2.0',
|
||
type: 'betterdesk-appearance',
|
||
revision: getBrandingRevision(),
|
||
identity: {
|
||
appName: branding.appName,
|
||
appDescription: branding.appDescription,
|
||
logoType: branding.logoType,
|
||
logoIcon: branding.logoIcon,
|
||
logoSvg: branding.logoSvg,
|
||
logoUrl: branding.logoUrl,
|
||
logoText: branding.logoText,
|
||
logoTextAccent: branding.logoTextAccent,
|
||
faviconSvg: branding.faviconSvg
|
||
},
|
||
typography: {
|
||
fontHeading: branding.fontHeading,
|
||
fontBody: branding.fontBody
|
||
},
|
||
palette: {
|
||
mode: branding.themeMode || 'dark',
|
||
colors: { ...(branding.colors || {}) },
|
||
semantic
|
||
},
|
||
backgrounds: {
|
||
console: {
|
||
type: branding.bgType || 'none',
|
||
color: branding.bgColor || '',
|
||
gradient: branding.bgGradient || '',
|
||
imageUrl: branding.bgImageUrl || '',
|
||
blur: branding.bgBlur || '',
|
||
overlay: branding.bgOverlay || '',
|
||
size: normalizeBackgroundSize(branding.bgSize || 'cover')
|
||
},
|
||
login: backgroundSpecFromBranding(branding, 'login', 'inherit'),
|
||
agent: backgroundSpecFromBranding(branding, 'agent', 'none'),
|
||
rdclient: backgroundSpecFromBranding(branding, 'rdclient', 'inherit')
|
||
},
|
||
surfaces: {
|
||
glassEnabled: branding.glassEnabled !== 'false',
|
||
glassColor: branding.glassColor,
|
||
glassBlur: branding.glassBlur,
|
||
glassOpacity: branding.glassOpacity
|
||
},
|
||
login: {
|
||
title: branding.loginTitle,
|
||
subtitle: branding.loginSubtitle
|
||
},
|
||
footer: {
|
||
text: branding.footerText,
|
||
showPoweredBy: branding.showPoweredBy !== 'false',
|
||
agentShowPoweredBy: branding.agentShowPoweredBy !== 'false'
|
||
},
|
||
advanced: {
|
||
customCssEnabled: !!(branding.customCss && branding.customCss.trim())
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Sanitize operator-supplied custom CSS (advanced escape hatch).
|
||
* Removes constructs that could lead to XSS / data exfiltration:
|
||
* - </style> breakouts, HTML tags
|
||
* - @import (external stylesheet loading)
|
||
* - expression() (legacy IE script execution)
|
||
* - behavior: / -moz-binding: (HTC / XBL script binding)
|
||
* - url(javascript:|data:|vbscript:) schemes
|
||
* @param {string} value
|
||
* @returns {string} sanitized CSS (capped length)
|
||
*/
|
||
function sanitizeCustomCss(value) {
|
||
if (value === undefined || value === null) return '';
|
||
let css = String(value);
|
||
css = stripUntilStable(css, [
|
||
(s) => s.replace(/<\s*\/?\s*style[^>]*>/gi, ''),
|
||
(s) => s.replace(/<[^>]*>/g, ''),
|
||
(s) => stripTagName(s, 'script'),
|
||
(s) => stripTagName(s, 'style'),
|
||
(s) => s.replace(SVG_ON_ATTR, ' '),
|
||
]);
|
||
css = css.replace(/@import\b[^;]*;?/gi, ''); // external imports
|
||
css = css.replace(/expression\s*\(/gi, ''); // IE expression()
|
||
css = css.replace(/(?:-\w+-)?behavior\s*:/gi, ''); // HTC/XBL binding
|
||
css = css.replace(/-moz-binding\s*:/gi, ''); // Firefox XBL binding
|
||
css = css.replace(/url\s*\(\s*["']?\s*(?:javascript|data|vbscript|file):[^)]*\)/gi, 'none');
|
||
return css.substring(0, 20000);
|
||
}
|
||
|
||
// Default branding (BetterDesk original theme)
|
||
const DEFAULT_BRANDING = {
|
||
// Brand identity
|
||
appName: 'BetterDesk',
|
||
appDescription: 'BetterDesk Server Management',
|
||
|
||
// Logo configuration
|
||
logoType: 'image', // 'icon' | 'svg' | 'image' | 'text'
|
||
logoIcon: 'dns', // Material Icons name (when logoType === 'icon')
|
||
logoSvg: '', // Raw SVG markup or SVG path data (when logoType === 'svg')
|
||
logoUrl: '/img/betterdesk_icon.png', // URL to image file (when logoType === 'image')
|
||
logoText: '', // Text to display as logo (when logoType === 'text')
|
||
logoTextAccent: '', // Accent text (different color, e.g. product name after brand)
|
||
|
||
// Typography (Google Fonts)
|
||
fontHeading: '', // Font family for headings / logo text (empty = system default)
|
||
fontBody: '', // Font family for body text (empty = system default)
|
||
|
||
// Favicon (SVG)
|
||
faviconSvg: '', // Custom favicon SVG (empty = default)
|
||
|
||
// Console background & appearance
|
||
bgType: 'none', // 'none' | 'color' | 'gradient' | 'image'
|
||
bgColor: '', // solid color (when bgType === 'color')
|
||
bgGradient: '', // CSS gradient (when bgType === 'gradient')
|
||
bgImageUrl: '', // uploaded/linked image (when bgType === 'image')
|
||
bgBlur: '', // blur radius in px applied to the wallpaper layer
|
||
bgOverlay: '', // dark overlay opacity 0-100 (%) for readability
|
||
bgSize: 'cover', // 'cover' | 'contain' | 'repeat' | 'center'
|
||
|
||
// Glass surfaces (cards, modals, forms, navbar, etc.)
|
||
glassEnabled: 'true', // 'true' | 'false'
|
||
glassColor: '#161b22', // tint color (hex); empty = use bgSecondary
|
||
glassBlur: '16', // backdrop-filter blur px (0-40)
|
||
glassOpacity: '55', // tint opacity 0-100 (%)
|
||
|
||
// Login page branding
|
||
loginBgType: 'inherit', // 'inherit' | 'none' | 'color' | 'gradient' | 'image'
|
||
loginBgColor: '',
|
||
loginBgGradient: '',
|
||
loginBgImageUrl: '',
|
||
loginBgOverlay: '', // dark overlay opacity 0-100 (%)
|
||
loginTitle: '', // overrides the login heading
|
||
loginSubtitle: '', // overrides the login subtitle
|
||
|
||
// Footer / attribution
|
||
footerText: '', // custom footer / copyright text
|
||
showPoweredBy: 'true', // 'true' | 'false' — show "Powered by BetterDesk"
|
||
|
||
// Agent download portal (global defaults shared by all bundles)
|
||
agentBgType: 'none', // 'none' | 'color' | 'gradient' | 'image'
|
||
agentBgColor: '',
|
||
agentBgGradient: '',
|
||
agentBgImageUrl: '',
|
||
agentShowPoweredBy: 'true', // 'true' | 'false'
|
||
|
||
// Advanced — custom CSS escape hatch (sanitized)
|
||
customCss: '',
|
||
|
||
// Appearance model metadata (v2 keeps flat keys for backward compatibility)
|
||
appearanceSchemaVersion: '2',
|
||
themeMode: 'dark', // 'dark' | 'light' | 'custom' (auto kept for backward compat → dark)
|
||
|
||
// Color scheme overrides (empty = use defaults from variables.css)
|
||
colors: {
|
||
bgPrimary: '',
|
||
bgSecondary: '',
|
||
bgTertiary: '',
|
||
bgElevated: '',
|
||
textPrimary: '',
|
||
textSecondary: '',
|
||
accentBlue: '',
|
||
accentBlueHover: '',
|
||
accentBlueMuted: '',
|
||
accentGreen: '',
|
||
accentGreenHover: '',
|
||
accentGreenMuted: '',
|
||
accentRed: '',
|
||
accentRedHover: '',
|
||
accentRedMuted: '',
|
||
accentYellow: '',
|
||
accentYellowHover: '',
|
||
accentYellowMuted: '',
|
||
accentPurple: '',
|
||
accentPurpleHover: '',
|
||
accentPurpleMuted: '',
|
||
borderPrimary: '',
|
||
borderSecondary: ''
|
||
},
|
||
|
||
// RdClient / remote dashboard background. The desktop shell loads /remote
|
||
// from the panel, so this controls the web content used by Tauri as well.
|
||
rdclientBgType: 'inherit', // 'inherit' | 'none' | 'color' | 'gradient' | 'image'
|
||
rdclientBgColor: '',
|
||
rdclientBgGradient: '',
|
||
rdclientBgImageUrl: '',
|
||
rdclientBgOverlay: '',
|
||
rdclientBgSize: 'cover'
|
||
};
|
||
|
||
/** Built-in palettes for themeMode light/dark (custom uses branding.colors). */
|
||
const BUILTIN_THEME_PALETTES = {
|
||
dark: {
|
||
bgPrimary: '#0d1117',
|
||
bgSecondary: '#161b22',
|
||
bgTertiary: '#21262d',
|
||
bgElevated: '#30363d',
|
||
textPrimary: '#e6edf3',
|
||
textSecondary: '#8b949e',
|
||
accentBlue: '#58a6ff',
|
||
accentBlueHover: '#79c0ff',
|
||
accentBlueMuted: '#58a6ff',
|
||
accentGreen: '#2ea44f',
|
||
accentGreenHover: '#3fb950',
|
||
accentGreenMuted: '#2ea44f',
|
||
accentRed: '#f85149',
|
||
accentRedHover: '#ff6b6b',
|
||
accentRedMuted: '#f85149',
|
||
accentYellow: '#d29922',
|
||
accentYellowHover: '#e3b341',
|
||
accentYellowMuted: '#d29922',
|
||
accentPurple: '#a371f7',
|
||
accentPurpleHover: '#bc8cff',
|
||
accentPurpleMuted: '#a371f7',
|
||
borderPrimary: '#30363d',
|
||
borderSecondary: '#21262d'
|
||
},
|
||
light: {
|
||
bgPrimary: '#f0f2f5',
|
||
bgSecondary: '#ffffff',
|
||
bgTertiary: '#eaeef2',
|
||
bgElevated: '#ffffff',
|
||
textPrimary: '#1f2328',
|
||
textSecondary: '#656d76',
|
||
accentBlue: '#0969da',
|
||
accentBlueHover: '#0550ae',
|
||
accentBlueMuted: '#0969da',
|
||
accentGreen: '#1a7f37',
|
||
accentGreenHover: '#116329',
|
||
accentGreenMuted: '#1a7f37',
|
||
accentRed: '#cf222e',
|
||
accentRedHover: '#a40e26',
|
||
accentRedMuted: '#cf222e',
|
||
accentYellow: '#9a6700',
|
||
accentYellowHover: '#7d4e00',
|
||
accentYellowMuted: '#9a6700',
|
||
accentPurple: '#8250df',
|
||
accentPurpleHover: '#6639ba',
|
||
accentPurpleMuted: '#8250df',
|
||
borderPrimary: '#d0d7de',
|
||
borderSecondary: '#eaeef2'
|
||
}
|
||
};
|
||
|
||
function normalizeThemeMode(mode) {
|
||
let m = String(mode || 'dark');
|
||
if (m === 'auto') m = 'dark';
|
||
if (!['dark', 'light', 'custom'].includes(m)) m = 'dark';
|
||
return m;
|
||
}
|
||
|
||
/**
|
||
* Resolve effective color map for CSS generation.
|
||
* light/dark ignore stale DB custom colors so themeMode always matches the UI.
|
||
*/
|
||
function resolveThemeColors(branding) {
|
||
const mode = normalizeThemeMode(branding && branding.themeMode);
|
||
if (mode === 'light' || mode === 'dark') {
|
||
return { ...BUILTIN_THEME_PALETTES[mode] };
|
||
}
|
||
const stored = (branding && branding.colors) || {};
|
||
const merged = { ...BUILTIN_THEME_PALETTES.dark };
|
||
for (const [key, value] of Object.entries(stored)) {
|
||
if (value && String(value).trim()) merged[key] = String(value).trim();
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
// CSS variable name mapping
|
||
const COLOR_TO_CSS_VAR = {
|
||
bgPrimary: '--bg-primary',
|
||
bgSecondary: '--bg-secondary',
|
||
bgTertiary: '--bg-tertiary',
|
||
bgElevated: '--bg-elevated',
|
||
textPrimary: '--text-primary',
|
||
textSecondary: '--text-secondary',
|
||
accentBlue: '--accent-blue',
|
||
accentBlueHover: '--accent-blue-hover',
|
||
accentBlueMuted: '--accent-blue-muted',
|
||
accentGreen: '--accent-green',
|
||
accentGreenHover: '--accent-green-hover',
|
||
accentGreenMuted: '--accent-green-muted',
|
||
accentRed: '--accent-red',
|
||
accentRedHover: '--accent-red-hover',
|
||
accentRedMuted: '--accent-red-muted',
|
||
accentYellow: '--accent-yellow',
|
||
accentYellowHover: '--accent-yellow-hover',
|
||
accentYellowMuted: '--accent-yellow-muted',
|
||
accentPurple: '--accent-purple',
|
||
accentPurpleHover: '--accent-purple-hover',
|
||
accentPurpleMuted: '--accent-purple-muted',
|
||
borderPrimary: '--border-primary',
|
||
borderSecondary: '--border-secondary'
|
||
};
|
||
|
||
const SEMANTIC_COLOR_ALIASES = {
|
||
primary: 'accentBlue',
|
||
primaryHover: 'accentBlueHover',
|
||
primaryMuted: 'accentBlueMuted',
|
||
success: 'accentGreen',
|
||
successHover: 'accentGreenHover',
|
||
successMuted: 'accentGreenMuted',
|
||
danger: 'accentRed',
|
||
dangerHover: 'accentRedHover',
|
||
dangerMuted: 'accentRedMuted',
|
||
warning: 'accentYellow',
|
||
warningHover: 'accentYellowHover',
|
||
warningMuted: 'accentYellowMuted',
|
||
info: 'accentPurple',
|
||
infoHover: 'accentPurpleHover',
|
||
infoMuted: 'accentPurpleMuted',
|
||
surface: 'bgSecondary',
|
||
surfaceRaised: 'bgTertiary',
|
||
surfaceElevated: 'bgElevated',
|
||
textPrimary: 'textPrimary',
|
||
textMuted: 'textSecondary',
|
||
border: 'borderPrimary'
|
||
};
|
||
|
||
const APPEARANCE_FLAT_MAP = {
|
||
identity: ['appName', 'appDescription', 'logoType', 'logoIcon', 'logoSvg', 'logoUrl', 'logoText', 'logoTextAccent', 'faviconSvg'],
|
||
typography: ['fontHeading', 'fontBody'],
|
||
palette: ['themeMode', 'colors'],
|
||
console: ['bgType', 'bgColor', 'bgGradient', 'bgImageUrl', 'bgBlur', 'bgOverlay', 'bgSize'],
|
||
surfaces: ['glassEnabled', 'glassColor', 'glassBlur', 'glassOpacity'],
|
||
login: ['loginBgType', 'loginBgColor', 'loginBgGradient', 'loginBgImageUrl', 'loginBgOverlay', 'loginTitle', 'loginSubtitle'],
|
||
agent: ['agentBgType', 'agentBgColor', 'agentBgGradient', 'agentBgImageUrl', 'agentShowPoweredBy'],
|
||
rdclient: ['rdclientBgType', 'rdclientBgColor', 'rdclientBgGradient', 'rdclientBgImageUrl', 'rdclientBgOverlay', 'rdclientBgSize'],
|
||
footer: ['footerText', 'showPoweredBy'],
|
||
advanced: ['customCss']
|
||
};
|
||
|
||
// In-memory cache
|
||
let brandingCache = null;
|
||
let brandingRevision = '0';
|
||
|
||
/**
|
||
* Bump cache-bust token after branding mutations.
|
||
*/
|
||
function bumpBrandingRevision() {
|
||
brandingRevision = Date.now().toString(36);
|
||
}
|
||
|
||
/**
|
||
* Revision string for branding.css cache busting (EJS + autosave).
|
||
* @returns {string}
|
||
*/
|
||
function getBrandingRevision() {
|
||
return brandingRevision;
|
||
}
|
||
|
||
/**
|
||
* Load branding configuration from database into cache (async).
|
||
* Must be called once at startup before any request is served.
|
||
* @returns {Promise<Object>} Merged branding config
|
||
*/
|
||
async function loadBranding() {
|
||
try {
|
||
const rows = await db.getBrandingConfig();
|
||
|
||
// Start with defaults
|
||
const branding = JSON.parse(JSON.stringify(DEFAULT_BRANDING));
|
||
|
||
for (const row of rows) {
|
||
if (row.key === 'colors') {
|
||
try {
|
||
const savedColors = JSON.parse(row.value);
|
||
Object.assign(branding.colors, savedColors);
|
||
} catch (e) {
|
||
// Ignore invalid JSON
|
||
}
|
||
} else if (row.key in branding) {
|
||
branding[row.key] = row.value;
|
||
}
|
||
}
|
||
|
||
brandingCache = branding;
|
||
try {
|
||
const rev = await db.getBrandingConfigRevision();
|
||
brandingRevision = rev ? String(rev).replace(/[^a-zA-Z0-9._-]/g, '') : Date.now().toString(36);
|
||
} catch (_) {
|
||
bumpBrandingRevision();
|
||
}
|
||
return branding;
|
||
} catch (err) {
|
||
console.error('[Branding] Failed to load from DB, using defaults:', err.message);
|
||
brandingCache = JSON.parse(JSON.stringify(DEFAULT_BRANDING));
|
||
bumpBrandingRevision();
|
||
return brandingCache;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get branding configuration (synchronous, from cache).
|
||
* Returns defaults if cache has not been warmed yet.
|
||
* @returns {Object} Merged branding config (defaults + overrides)
|
||
*/
|
||
function getBranding() {
|
||
if (brandingCache) return brandingCache;
|
||
// Cache not yet loaded — return defaults (startup race condition safety)
|
||
return JSON.parse(JSON.stringify(DEFAULT_BRANDING));
|
||
}
|
||
|
||
/**
|
||
* Save branding configuration (async — uses database adapter)
|
||
* @param {Object} updates - Partial branding config to save
|
||
*/
|
||
async function saveBranding(updates) {
|
||
const entries = [];
|
||
const normalizedUpdates = flattenAppearanceInput(updates);
|
||
for (const [key, value] of Object.entries(normalizedUpdates)) {
|
||
if (key === 'colors') {
|
||
const sanitizedColors = {};
|
||
for (const colorKey of Object.keys(DEFAULT_BRANDING.colors)) {
|
||
if (value && Object.prototype.hasOwnProperty.call(value, colorKey)) {
|
||
sanitizedColors[colorKey] = sanitizeCssColorValue(value[colorKey]).substring(0, 100);
|
||
}
|
||
}
|
||
entries.push({ key, value: JSON.stringify(sanitizedColors) });
|
||
} else if (key in DEFAULT_BRANDING) {
|
||
// Security: Sanitize SVG content to prevent XSS
|
||
if (key === 'logoSvg' || key === 'faviconSvg') {
|
||
entries.push({ key, value: sanitizeSvg(String(value)) }); } else if (key === 'logoUrl' || key === 'faviconUrl' ||
|
||
key === 'bgImageUrl' || key === 'loginBgImageUrl' || key === 'agentBgImageUrl' ||
|
||
key === 'rdclientBgImageUrl') {
|
||
// Security: Validate URL scheme to prevent javascript:/data:/file:/protocol-relative XSS/SSRF.
|
||
const normalized = validateBrandingUrl(value);
|
||
if (normalized === null) continue; // skip invalid value, keep previous DB value
|
||
entries.push({ key, value: normalized });
|
||
} else if (key === 'bgColor' || key === 'bgGradient' ||
|
||
key === 'loginBgColor' || key === 'loginBgGradient' ||
|
||
key === 'agentBgColor' || key === 'agentBgGradient' ||
|
||
key === 'rdclientBgColor' || key === 'rdclientBgGradient' ||
|
||
key === 'glassColor') {
|
||
// Security: Restrict to a safe CSS color/gradient charset.
|
||
entries.push({ key, value: sanitizeCssColorValue(value) });
|
||
} else if (key === 'bgSize' || key === 'rdclientBgSize') {
|
||
entries.push({ key, value: normalizeBackgroundSize(value) });
|
||
} else if (key === 'themeMode') {
|
||
entries.push({ key, value: normalizeThemeMode(value) });
|
||
} else if (key === 'customCss') {
|
||
// Security: Neutralize CSS-based XSS / external resource loading.
|
||
entries.push({ key, value: sanitizeCustomCss(value) });
|
||
} else {
|
||
entries.push({ key, value: String(value) });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (entries.length > 0) {
|
||
await db.saveBrandingConfigBatch(entries);
|
||
}
|
||
|
||
// Reload cache from DB
|
||
await loadBranding();
|
||
}
|
||
|
||
/**
|
||
* Reset branding to defaults (async — uses database adapter)
|
||
*/
|
||
async function resetBranding() {
|
||
await db.resetBrandingConfig();
|
||
brandingCache = null;
|
||
await loadBranding();
|
||
}
|
||
|
||
// ==================== Branding Profiles ====================
|
||
|
||
/**
|
||
* List saved branding profiles (metadata only).
|
||
* @returns {Promise<Array>}
|
||
*/
|
||
async function listProfiles() {
|
||
return db.listBrandingProfiles();
|
||
}
|
||
|
||
/**
|
||
* Create a profile snapshot from branding data.
|
||
* @param {string} name
|
||
* @param {string} [description]
|
||
* @param {Object} [brandingData] - defaults to current branding export
|
||
* @returns {Promise<number>} profile id
|
||
*/
|
||
async function createProfile(name, description = '', brandingData = null) {
|
||
const trimmed = String(name || '').trim().substring(0, 80);
|
||
if (!trimmed) throw new Error('Profile name is required');
|
||
const preset = brandingData && brandingData.type === 'betterdesk-theme'
|
||
? brandingData
|
||
: exportPreset();
|
||
return db.createBrandingProfile(trimmed, String(description || '').substring(0, 200), preset);
|
||
}
|
||
|
||
/**
|
||
* Update profile data from current or supplied branding.
|
||
* @param {number} id
|
||
* @param {Object} updates - { name?, description?, branding? }
|
||
*/
|
||
async function updateProfile(id, updates = {}) {
|
||
const profile = await db.getBrandingProfile(id);
|
||
if (!profile) throw new Error('Profile not found');
|
||
const name = updates.name != null ? String(updates.name).trim().substring(0, 80) : profile.name;
|
||
const description = updates.description != null
|
||
? String(updates.description).substring(0, 200)
|
||
: (profile.description || '');
|
||
const data = updates.branding
|
||
? { version: '1.0', type: 'betterdesk-theme', branding: updates.branding }
|
||
: JSON.parse(profile.data);
|
||
await db.updateBrandingProfile(id, name, description, data);
|
||
}
|
||
|
||
/**
|
||
* Apply a saved profile to active branding_config.
|
||
* @param {number} id
|
||
*/
|
||
async function applyProfile(id) {
|
||
const profile = await db.getBrandingProfile(id);
|
||
if (!profile) return false;
|
||
let preset;
|
||
try {
|
||
preset = JSON.parse(profile.data);
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
const ok = await importPreset(preset);
|
||
if (!ok) return false;
|
||
await db.setActiveBrandingProfile(id);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Delete a branding profile (not allowed for active profile).
|
||
* @param {number} id
|
||
*/
|
||
async function deleteProfile(id) {
|
||
const profile = await db.getBrandingProfile(id);
|
||
if (!profile) return false;
|
||
if (profile.is_active) throw new Error('Cannot delete the active profile');
|
||
await db.deleteBrandingProfile(id);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Duplicate an existing profile.
|
||
* @param {number} id
|
||
* @param {string} [newName]
|
||
*/
|
||
async function duplicateProfile(id, newName = '') {
|
||
const profile = await db.getBrandingProfile(id);
|
||
if (!profile) throw new Error('Profile not found');
|
||
const baseName = newName.trim() || `${profile.name} (copy)`;
|
||
let name = baseName.substring(0, 80);
|
||
const existing = await db.listBrandingProfiles();
|
||
let suffix = 2;
|
||
while (existing.some(p => p.name === name)) {
|
||
name = `${baseName.substring(0, 70)} ${suffix}`;
|
||
suffix += 1;
|
||
}
|
||
let data;
|
||
try {
|
||
data = JSON.parse(profile.data);
|
||
} catch (_) {
|
||
data = exportPreset();
|
||
}
|
||
return db.createBrandingProfile(name, profile.description || '', data);
|
||
}
|
||
|
||
function generateSemanticAliasCss(branding) {
|
||
const lines = [
|
||
' --color-primary: var(--accent-blue);',
|
||
' --color-primary-hover: var(--accent-blue-hover);',
|
||
' --color-primary-muted: var(--accent-blue-muted);',
|
||
' --accent-color: var(--accent-blue);',
|
||
' --color-success: var(--accent-green);',
|
||
' --color-success-hover: var(--accent-green-hover);',
|
||
' --color-success-muted: var(--accent-green-muted);',
|
||
' --color-danger: var(--accent-red);',
|
||
' --color-danger-hover: var(--accent-red-hover);',
|
||
' --color-danger-muted: var(--accent-red-muted);',
|
||
' --color-warning: var(--accent-yellow);',
|
||
' --color-warning-hover: var(--accent-yellow-hover);',
|
||
' --color-warning-muted: var(--accent-yellow-muted);',
|
||
' --color-info: var(--accent-purple);',
|
||
' --color-info-hover: var(--accent-purple-hover);',
|
||
' --color-info-muted: var(--accent-purple-muted);',
|
||
' --color-surface: var(--bg-secondary);',
|
||
' --color-surface-alt: var(--bg-primary);',
|
||
' --color-surface-hover: var(--bg-tertiary);',
|
||
' --color-surface-raised: var(--bg-elevated);',
|
||
' --color-text: var(--text-primary);',
|
||
' --color-text-muted: var(--text-secondary);',
|
||
' --color-border: var(--border-primary);',
|
||
' --focus-ring-color: var(--accent-blue-muted);',
|
||
` color-scheme: ${normalizeThemeMode(branding.themeMode) === 'light' ? 'light' : 'dark'};`,
|
||
' /* UX 3.5 chrome aliases — solid surfaces (no glass/blur in this shell) */',
|
||
' --ux35-bg: var(--bg-primary);',
|
||
' --ux35-sidebar-bg: var(--bg-secondary);',
|
||
' --ux35-card-bg: var(--bg-secondary);',
|
||
' --ux35-border: var(--border-primary);',
|
||
' --ux35-border-light: var(--border-secondary);',
|
||
' --ux35-text: var(--text-primary);',
|
||
' --ux35-muted: var(--text-secondary);',
|
||
' --ux35-hover: var(--bg-hover);',
|
||
' --ux35-primary: var(--accent-blue);',
|
||
' --ux35-focus-ring: var(--accent-blue);',
|
||
' --ux35-active-bg: var(--accent-blue-muted);',
|
||
' --ux35-glass-blur: 0px;',
|
||
' --ux35-glass-saturate: 1;',
|
||
' /* UX 3.5 topbar chrome — theme-invariant (always dark) */',
|
||
' --ux35-topbar-bg: #161b22;',
|
||
' --ux35-topbar-fg: #e6edf3;',
|
||
' --ux35-topbar-fg-muted: #8b949e;',
|
||
' --ux35-topbar-border: #30363d;'
|
||
];
|
||
return `:root {\n${lines.join('\n')}\n}\n`;
|
||
}
|
||
|
||
/**
|
||
* Convert a solid hex accent to a translucent muted rgba (matches branding.css + theme preview).
|
||
* @param {string} hex - #RRGGBB or RRGGBB
|
||
* @param {number} [alpha=0.15]
|
||
* @returns {string|null} rgba(...) or null if hex is invalid
|
||
*/
|
||
function hexToMutedRgba(hex, alpha = 0.15) {
|
||
if (hex == null) return null;
|
||
const raw = String(hex).trim().replace(/^#/, '');
|
||
if (!/^[0-9a-fA-F]{6}$/.test(raw)) return null;
|
||
const r = parseInt(raw.substring(0, 2), 16);
|
||
const g = parseInt(raw.substring(2, 4), 16);
|
||
const b = parseInt(raw.substring(4, 6), 16);
|
||
const a = Number.isFinite(alpha) ? alpha : 0.15;
|
||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||
}
|
||
|
||
/**
|
||
* Generate CSS :root overrides from branding colors and fonts
|
||
* @returns {string} CSS string with @font-face imports and :root variable overrides
|
||
*/
|
||
function generateThemeCss() {
|
||
const branding = getBranding();
|
||
const themeMode = normalizeThemeMode(branding.themeMode);
|
||
const colors = resolveThemeColors(branding);
|
||
const overrides = [];
|
||
|
||
for (const [key, cssVar] of Object.entries(COLOR_TO_CSS_VAR)) {
|
||
const value = colors[key];
|
||
if (value && String(value).trim()) {
|
||
// For muted colors, auto-generate rgba if a hex color is provided
|
||
if (key.endsWith('Muted') && String(value).startsWith('#')) {
|
||
const muted = hexToMutedRgba(value, 0.15);
|
||
overrides.push(` ${cssVar}: ${muted || value};`);
|
||
} else {
|
||
overrides.push(` ${cssVar}: ${value};`);
|
||
}
|
||
}
|
||
}
|
||
|
||
let css = '';
|
||
|
||
// Font CSS (imports + heading/body font variables)
|
||
const fontCss = fontService.generateFontCss(branding.fontHeading, branding.fontBody);
|
||
if (fontCss) {
|
||
css += fontCss + '\n';
|
||
}
|
||
|
||
// Color overrides — always emit effective palette for the active themeMode
|
||
if (overrides.length > 0) {
|
||
css += `:root {\n${overrides.join('\n')}\n}\n`;
|
||
}
|
||
|
||
// Keep data-theme attribute in sync for component selectors (ui-polish / theme.css)
|
||
css += `html { color-scheme: ${themeMode === 'light' ? 'light' : 'dark'}; }\n`;
|
||
|
||
// Semantic aliases consumed by newer UI and legacy components that use
|
||
// --color-* names instead of the original BetterDesk token names.
|
||
css += generateSemanticAliasCss({ ...branding, themeMode, colors });
|
||
|
||
// Glass surface tokens (light mode uses light glass base when unset)
|
||
css += generateGlassCss({ ...branding, themeMode, colors });
|
||
|
||
// Background wallpaper (console + login) and custom CSS
|
||
css += generateBackgroundCss(branding);
|
||
|
||
const customCss = sanitizeCustomCss(branding.customCss);
|
||
if (customCss.trim()) {
|
||
css += `\n/* --- custom branding CSS --- */\n${customCss}\n`;
|
||
}
|
||
|
||
return css;
|
||
}
|
||
|
||
/** Clamp a numeric branding input (blur px / overlay %) to a safe range. */
|
||
function clampNumber(value, min, max) {
|
||
const n = parseFloat(value);
|
||
if (!Number.isFinite(n)) return null;
|
||
return Math.min(max, Math.max(min, n));
|
||
}
|
||
|
||
/** Parse a 6-digit hex color into RGB components. */
|
||
function hexToRgb(hex) {
|
||
const h = String(hex || '').replace('#', '').trim();
|
||
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
|
||
return {
|
||
r: parseInt(h.substring(0, 2), 16),
|
||
g: parseInt(h.substring(2, 4), 16),
|
||
b: parseInt(h.substring(4, 6), 16)
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Generate CSS variables for frosted-glass panel surfaces.
|
||
* @param {Object} branding
|
||
* @returns {string}
|
||
*/
|
||
function generateGlassCss(branding) {
|
||
const enabled = branding.glassEnabled !== 'false';
|
||
if (!enabled) {
|
||
return `:root {
|
||
--surface-glass-blur: 0px;
|
||
--surface-glass-saturate: 1;
|
||
--surface-glass-bg-secondary: var(--bg-secondary);
|
||
--surface-glass-bg-tertiary: var(--bg-tertiary);
|
||
--surface-glass-bg-elevated: var(--bg-elevated);
|
||
--surface-glass-border: var(--border-primary);
|
||
--card-bg: var(--bg-secondary);
|
||
--sidebar-glass-bg-rail: var(--sidebar-rail-bg);
|
||
--sidebar-glass-bg-flyout: var(--sidebar-flyout-bg);
|
||
}\n`;
|
||
}
|
||
|
||
const blur = clampNumber(branding.glassBlur, 0, 40) ?? 16;
|
||
const opacity = (clampNumber(branding.glassOpacity, 0, 100) ?? 55) / 100;
|
||
let color = (branding.glassColor || '').trim();
|
||
const mode = normalizeThemeMode(branding.themeMode);
|
||
if (!color || !/^#[0-9a-fA-F]{6}$/.test(color)) {
|
||
const fromColors = branding.colors && branding.colors.bgSecondary;
|
||
if (/^#[0-9a-fA-F]{6}$/.test(fromColors || '')) {
|
||
color = fromColors;
|
||
} else {
|
||
color = mode === 'light' ? '#ffffff' : '#161b22';
|
||
}
|
||
}
|
||
const rgb = hexToRgb(color);
|
||
if (!rgb) return '';
|
||
|
||
const tertiaryAlpha = Math.min(1, opacity + 0.08).toFixed(2);
|
||
const elevatedAlpha = Math.min(1, opacity + 0.15).toFixed(2);
|
||
const borderAlpha = Math.min(1, opacity + 0.35).toFixed(2);
|
||
const railAlpha = Math.max(0.32, Math.min(0.78, opacity - 0.07)).toFixed(2);
|
||
const flyoutAlpha = Math.max(0.42, Math.min(0.84, opacity + 0.03)).toFixed(2);
|
||
|
||
return `:root {
|
||
--surface-glass-blur: ${blur}px;
|
||
--surface-glass-saturate: 1.2;
|
||
--surface-glass-bg-secondary: rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity.toFixed(2)});
|
||
--surface-glass-bg-tertiary: rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${tertiaryAlpha});
|
||
--surface-glass-bg-elevated: rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${elevatedAlpha});
|
||
--surface-glass-border: rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${borderAlpha});
|
||
--card-bg: var(--surface-glass-bg-secondary);
|
||
--sidebar-glass-bg-rail: rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${railAlpha});
|
||
--sidebar-glass-bg-flyout: rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${flyoutAlpha});
|
||
}\n`;
|
||
}
|
||
|
||
/**
|
||
* Generate the wallpaper / overlay CSS for the console (.app-page) and the
|
||
* login page (.login-page). The wallpaper sits on a fixed pseudo-element behind
|
||
* all content; the scrollable content area is made transparent so cards float
|
||
* over it. Login background can either inherit the console one or override it.
|
||
* @param {Object} branding
|
||
* @returns {string}
|
||
*/
|
||
function generateBackgroundCss(branding) {
|
||
let out = '';
|
||
|
||
const sizeRule = (size) => {
|
||
switch (size) {
|
||
case 'contain': return 'background-size: contain; background-repeat: no-repeat; background-position: center;';
|
||
case 'repeat': return 'background-repeat: repeat;';
|
||
case 'auto': return 'background-size: auto; background-repeat: no-repeat; background-position: center;';
|
||
case 'center': return 'background-size: auto; background-repeat: no-repeat; background-position: center;';
|
||
case 'cover':
|
||
default: return 'background-size: cover; background-repeat: no-repeat; background-position: center;';
|
||
}
|
||
};
|
||
|
||
// ---- Console wallpaper ----
|
||
const consoleBg = buildBackgroundValue(branding.bgType, branding.bgColor, branding.bgGradient, branding.bgImageUrl);
|
||
if (consoleBg) {
|
||
const blur = clampNumber(branding.bgBlur, 0, 40);
|
||
const overlay = clampNumber(branding.bgOverlay, 0, 95);
|
||
out += `body.app-page::before {\n` +
|
||
` content: '';\n position: fixed;\n inset: 0;\n z-index: 0;\n pointer-events: none;\n` +
|
||
` background: ${consoleBg};\n ${branding.bgType === 'image' ? sizeRule(branding.bgSize) : ''}\n` +
|
||
(blur ? ` filter: blur(${blur}px);\n transform: scale(1.05);\n` : '') +
|
||
`}\n`;
|
||
if (overlay) {
|
||
out += `body.app-page::after {\n` +
|
||
` content: '';\n position: fixed;\n inset: 0;\n z-index: 0;\n` +
|
||
` background: rgba(0, 0, 0, ${(overlay / 100).toFixed(2)});\n pointer-events: none;\n}\n`;
|
||
}
|
||
// Let the wallpaper show behind floating cards in the content area.
|
||
out += `body.app-page { background-color: transparent; position: relative; isolation: isolate; }\n`;
|
||
out += `body.app-page .app-layout,\nbody.app-page .ux35-shell,\nbody.app-page #desktop-shell,\nbody.app-page #modal-container,\nbody.app-page #toast-container { position: relative; z-index: 1; }\n`;
|
||
out += `body.app-page .main-wrapper,\nbody.app-page .app-layout,\nbody.app-page .ux35-shell { background: transparent; }\n`;
|
||
out += `body.app-page .main-content,\nbody.app-page .ux35-content { background: transparent; }\n`;
|
||
}
|
||
|
||
// ---- Login wallpaper ----
|
||
let loginBg = '';
|
||
let loginOverlay = null;
|
||
if (branding.loginBgType === 'inherit') {
|
||
loginBg = consoleBg;
|
||
loginOverlay = clampNumber(branding.bgOverlay, 0, 95);
|
||
} else if (branding.loginBgType && branding.loginBgType !== 'none') {
|
||
loginBg = buildBackgroundValue(branding.loginBgType, branding.loginBgColor, branding.loginBgGradient, branding.loginBgImageUrl);
|
||
loginOverlay = clampNumber(branding.loginBgOverlay, 0, 95);
|
||
}
|
||
if (loginBg) {
|
||
out += `body.login-page::before {\n` +
|
||
` content: '';\n position: fixed;\n inset: 0;\n z-index: -2;\n` +
|
||
` background: ${loginBg};\n background-size: cover;\n background-position: center;\n background-repeat: no-repeat;\n}\n`;
|
||
if (loginOverlay) {
|
||
out += `body.login-page::after {\n` +
|
||
` content: '';\n position: fixed;\n inset: 0;\n z-index: -1;\n` +
|
||
` background: rgba(0, 0, 0, ${(loginOverlay / 100).toFixed(2)});\n pointer-events: none;\n}\n`;
|
||
}
|
||
out += `body.login-page { background-color: transparent; }\n`;
|
||
}
|
||
|
||
// ---- RdClient / remote dashboard wallpaper ----
|
||
let rdclientBg = '';
|
||
let rdclientOverlay = null;
|
||
let rdclientType = branding.rdclientBgType || 'inherit';
|
||
if (rdclientType === 'inherit') {
|
||
rdclientBg = consoleBg;
|
||
rdclientType = branding.bgType;
|
||
rdclientOverlay = clampNumber(branding.bgOverlay, 0, 95);
|
||
} else if (rdclientType && rdclientType !== 'none') {
|
||
rdclientBg = buildBackgroundValue(
|
||
rdclientType,
|
||
branding.rdclientBgColor,
|
||
branding.rdclientBgGradient,
|
||
branding.rdclientBgImageUrl
|
||
);
|
||
rdclientOverlay = clampNumber(branding.rdclientBgOverlay, 0, 95);
|
||
}
|
||
if (rdclientBg) {
|
||
out += `body.rd-desk-body::before {\n` +
|
||
` content: '';\n position: fixed;\n inset: 0;\n z-index: 0;\n pointer-events: none;\n` +
|
||
` background: ${rdclientBg};\n ${rdclientType === 'image' ? sizeRule(branding.rdclientBgSize || branding.bgSize) : ''}\n}\n`;
|
||
if (rdclientOverlay) {
|
||
out += `body.rd-desk-body::after {\n` +
|
||
` content: '';\n position: fixed;\n inset: 0;\n z-index: 0;\n` +
|
||
` background: rgba(0, 0, 0, ${(rdclientOverlay / 100).toFixed(2)});\n pointer-events: none;\n}\n`;
|
||
}
|
||
out += `body.rd-desk-body { background-color: transparent; position: relative; isolation: isolate; }\n`;
|
||
out += `body.rd-desk-body .rd-desk-app { position: relative; z-index: 1; }\n`;
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
function luminanceComponent(value) {
|
||
const srgb = value / 255;
|
||
return srgb <= 0.03928 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4);
|
||
}
|
||
|
||
function contrastRatio(hexA, hexB) {
|
||
const a = hexToRgb(hexA);
|
||
const b = hexToRgb(hexB);
|
||
if (!a || !b) return null;
|
||
const lumA = 0.2126 * luminanceComponent(a.r) + 0.7152 * luminanceComponent(a.g) + 0.0722 * luminanceComponent(a.b);
|
||
const lumB = 0.2126 * luminanceComponent(b.r) + 0.7152 * luminanceComponent(b.g) + 0.0722 * luminanceComponent(b.b);
|
||
const lighter = Math.max(lumA, lumB);
|
||
const darker = Math.min(lumA, lumB);
|
||
return (lighter + 0.05) / (darker + 0.05);
|
||
}
|
||
|
||
function assessAppearanceReadability(brandingInput = null) {
|
||
const branding = brandingInput || getBranding();
|
||
const colors = { ...DEFAULT_BRANDING.colors, ...(branding.colors || {}) };
|
||
const issues = [];
|
||
const checkPair = (id, foreground, background, minimum = 4.5) => {
|
||
const ratio = contrastRatio(foreground, background);
|
||
if (ratio !== null && ratio < minimum) {
|
||
issues.push({
|
||
id,
|
||
severity: ratio < 3 ? 'error' : 'warning',
|
||
ratio: Number(ratio.toFixed(2)),
|
||
minimum,
|
||
message: `${id} contrast ratio ${ratio.toFixed(2)} is below ${minimum}:1`
|
||
});
|
||
}
|
||
};
|
||
|
||
checkPair('page-text', colors.textPrimary, colors.bgPrimary);
|
||
checkPair('card-text', colors.textPrimary, colors.bgSecondary);
|
||
checkPair('muted-text', colors.textSecondary, colors.bgSecondary, 3);
|
||
checkPair('button-primary', '#ffffff', colors.accentBlue, 3);
|
||
|
||
[
|
||
{ id: 'console-background', type: branding.bgType, overlay: branding.bgOverlay },
|
||
{ id: 'login-background', type: branding.loginBgType, overlay: branding.loginBgOverlay || branding.bgOverlay },
|
||
{ id: 'rdclient-background', type: branding.rdclientBgType, overlay: branding.rdclientBgOverlay || branding.bgOverlay }
|
||
].forEach((item) => {
|
||
if ((item.type === 'image' || item.type === 'gradient') && (clampNumber(item.overlay, 0, 95) || 0) < 25) {
|
||
issues.push({
|
||
id: item.id,
|
||
severity: 'warning',
|
||
message: `${item.id} uses ${item.type} with a low overlay; text may be hard to read.`
|
||
});
|
||
}
|
||
});
|
||
|
||
return {
|
||
ok: !issues.some((issue) => issue.severity === 'error'),
|
||
issues
|
||
};
|
||
}
|
||
|
||
function getPublicAppearance() {
|
||
const branding = getBranding();
|
||
const appearance = getAppearanceModel(branding);
|
||
const colors = appearance.palette.colors || {};
|
||
const fallback = {
|
||
bgPrimary: '#0d1117',
|
||
bgSecondary: '#161b22',
|
||
bgTertiary: '#21262d',
|
||
textPrimary: '#e6edf3',
|
||
textSecondary: '#8b949e',
|
||
accentBlue: '#58a6ff',
|
||
accentBlueHover: '#79c0ff',
|
||
accentGreen: '#2ea44f',
|
||
accentRed: '#f85149',
|
||
accentYellow: '#d29922',
|
||
borderPrimary: '#30363d'
|
||
};
|
||
const rdclientBackground = appearance.backgrounds.rdclient.type === 'inherit'
|
||
? appearance.backgrounds.console
|
||
: appearance.backgrounds.rdclient;
|
||
|
||
return {
|
||
version: '2.0',
|
||
revision: getBrandingRevision(),
|
||
product: 'betterdesk-appearance',
|
||
identity: {
|
||
appName: appearance.identity.appName || 'BetterDesk',
|
||
appDescription: appearance.identity.appDescription || '',
|
||
logoType: appearance.identity.logoType || 'icon',
|
||
logoIcon: appearance.identity.logoIcon || 'dns',
|
||
logoUrl: appearance.identity.logoType === 'image' ? appearance.identity.logoUrl : '',
|
||
logoText: appearance.identity.logoType === 'text' ? appearance.identity.logoText : '',
|
||
logoTextAccent: appearance.identity.logoType === 'text' ? appearance.identity.logoTextAccent : ''
|
||
},
|
||
palette: {
|
||
mode: appearance.palette.mode || 'dark',
|
||
primary: colors.accentBlue || fallback.accentBlue,
|
||
primaryHover: colors.accentBlueHover || fallback.accentBlueHover,
|
||
background: colors.bgPrimary || fallback.bgPrimary,
|
||
surface: colors.bgSecondary || fallback.bgSecondary,
|
||
surfaceRaised: colors.bgTertiary || fallback.bgTertiary,
|
||
text: colors.textPrimary || fallback.textPrimary,
|
||
muted: colors.textSecondary || fallback.textSecondary,
|
||
border: colors.borderPrimary || fallback.borderPrimary,
|
||
danger: colors.accentRed || fallback.accentRed,
|
||
warning: colors.accentYellow || fallback.accentYellow,
|
||
success: colors.accentGreen || fallback.accentGreen
|
||
},
|
||
surfaces: {
|
||
glassEnabled: appearance.surfaces.glassEnabled,
|
||
glassBlur: appearance.surfaces.glassBlur,
|
||
glassOpacity: appearance.surfaces.glassOpacity
|
||
},
|
||
background: {
|
||
type: rdclientBackground.type || 'none',
|
||
color: rdclientBackground.color || '',
|
||
gradient: rdclientBackground.gradient || '',
|
||
imageUrl: rdclientBackground.imageUrl || '',
|
||
overlay: rdclientBackground.overlay || '',
|
||
size: normalizeBackgroundSize(rdclientBackground.size || 'cover')
|
||
},
|
||
readability: assessAppearanceReadability(branding)
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Sanitize a CSS hex/rgb color for safe SVG attribute injection.
|
||
* Falls back when the value is not a simple color token.
|
||
* @param {string} value
|
||
* @param {string} fallback
|
||
* @returns {string}
|
||
*/
|
||
function sanitizeSvgColor(value, fallback) {
|
||
const raw = String(value || '').trim();
|
||
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(raw)) return raw;
|
||
if (/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/.test(raw)) return raw;
|
||
return fallback;
|
||
}
|
||
|
||
/**
|
||
* Generate favicon SVG from branding
|
||
* Default mark: large letter "B" on dark tile (readable at 16×16 tab size).
|
||
* @returns {string} SVG markup for favicon
|
||
*/
|
||
function generateFavicon() {
|
||
const branding = getBranding();
|
||
|
||
// If custom favicon SVG is set, use it
|
||
if (branding.faviconSvg && branding.faviconSvg.trim()) {
|
||
return branding.faviconSvg;
|
||
}
|
||
|
||
// Large "B" mark — same idea as Flutter contactRow fontSize bump: fill the
|
||
// small viewport so the glyph stays legible in browser tabs / PWA icons.
|
||
const bgColor = sanitizeSvgColor(branding.colors?.bgPrimary, '#0d1117');
|
||
const accentColor = sanitizeSvgColor(branding.colors?.accentBlue, '#58a6ff');
|
||
const greenColor = sanitizeSvgColor(branding.colors?.accentGreen, '#a3e635');
|
||
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||
<defs>
|
||
<linearGradient id="bdMark" x1="6" y1="4" x2="26" y2="28" gradientUnits="userSpaceOnUse">
|
||
<stop stop-color="${accentColor}"/>
|
||
<stop offset="1" stop-color="${greenColor}"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width="32" height="32" rx="7" fill="${bgColor}"/>
|
||
<text x="16" y="16" text-anchor="middle" dominant-baseline="central"
|
||
font-family="Arial Black, Arial, Helvetica, sans-serif"
|
||
font-size="23" font-weight="800" fill="url(#bdMark)">B</text>
|
||
</svg>`;
|
||
}
|
||
|
||
/**
|
||
* Export a branding preset as JSON (for import/export)
|
||
* @returns {Object} Full branding config for export
|
||
*/
|
||
function exportPreset() {
|
||
const branding = getBranding();
|
||
return {
|
||
version: '2.0',
|
||
type: 'betterdesk-theme',
|
||
appearance: getAppearanceModel(branding),
|
||
// Keep the flat shape in exports so older panels can still import most
|
||
// fields without understanding the v2 appearance model.
|
||
branding
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Import a branding preset from JSON
|
||
* @param {Object} preset - Preset object with version + branding fields
|
||
* @returns {boolean} Success
|
||
*/
|
||
async function importPreset(preset) {
|
||
if (!preset || preset.type !== 'betterdesk-theme' || (!preset.branding && !preset.appearance)) {
|
||
return false;
|
||
}
|
||
|
||
// Validate and sanitize
|
||
const allowed = Object.keys(DEFAULT_BRANDING);
|
||
const sanitized = {};
|
||
const input = preset.appearance
|
||
? flattenAppearanceInput({ appearance: preset.appearance })
|
||
: flattenAppearanceInput(preset.branding);
|
||
|
||
for (const key of allowed) {
|
||
if (key in input) {
|
||
if (key === 'colors') {
|
||
const allowedColors = Object.keys(DEFAULT_BRANDING.colors);
|
||
const colors = {};
|
||
for (const ck of allowedColors) {
|
||
if (input.colors && ck in input.colors) {
|
||
colors[ck] = String(input.colors[ck]).substring(0, 100);
|
||
}
|
||
}
|
||
sanitized.colors = colors;
|
||
} else {
|
||
// Limit string length for safety (larger caps for markup/CSS fields)
|
||
let cap = 500;
|
||
if (key === 'logoSvg' || key === 'faviconSvg') cap = 50000;
|
||
else if (key === 'customCss') cap = 20000;
|
||
else if (key === 'bgGradient' || key === 'loginBgGradient' ||
|
||
key === 'agentBgGradient' || key === 'rdclientBgGradient') cap = 1000;
|
||
sanitized[key] = String(input[key]).substring(0, cap);
|
||
}
|
||
}
|
||
}
|
||
|
||
await saveBranding(sanitized);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Invalidate the branding cache (call after DB changes)
|
||
*/
|
||
function invalidateCache() {
|
||
brandingCache = null;
|
||
}
|
||
|
||
module.exports = {
|
||
DEFAULT_BRANDING,
|
||
COLOR_TO_CSS_VAR,
|
||
BUILTIN_THEME_PALETTES,
|
||
normalizeThemeMode,
|
||
resolveThemeColors,
|
||
hexToMutedRgba,
|
||
loadBranding,
|
||
getBranding,
|
||
saveBranding,
|
||
resetBranding,
|
||
generateThemeCss,
|
||
generateFavicon,
|
||
exportPreset,
|
||
importPreset,
|
||
invalidateCache,
|
||
getBrandingRevision,
|
||
listProfiles,
|
||
createProfile,
|
||
updateProfile,
|
||
applyProfile,
|
||
deleteProfile,
|
||
duplicateProfile,
|
||
getAppearanceModel,
|
||
getPublicAppearance,
|
||
assessAppearanceReadability,
|
||
flattenAppearanceInput,
|
||
sanitizeSvg,
|
||
sanitizeCssColorValue,
|
||
sanitizeCustomCss,
|
||
buildBackgroundValue,
|
||
normalizeBackgroundSize,
|
||
validateBrandingUrl
|
||
};
|