mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
6442b59902
- Introduced a new step in the CI workflow to check browser JavaScript syntax using the `npm run check:frontend` command. - Updated package version to 3.5.14 and bumped dependencies: nodemailer to 9.0.4 and ws to 8.21.2. - Enhanced logging configuration to use NODE_ENV for determining log level. - Improved accessibility styles for UX 3.5 and added focus outlines for help panel elements.
1332 lines
52 KiB
JavaScript
1332 lines
52 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)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate favicon SVG from branding
|
|
* @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;
|
|
}
|
|
|
|
// Generate from branding colors (use accent color or default blue)
|
|
const bgColor = branding.colors.bgPrimary || '#0d1117';
|
|
const accentColor = branding.colors.accentBlue || '#58a6ff';
|
|
const greenColor = branding.colors.accentGreen || '#2ea44f';
|
|
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
|
<rect width="32" height="32" rx="6" fill="${bgColor}"/>
|
|
<path d="M8 10h16M8 16h16M8 22h12" stroke="${accentColor}" stroke-width="2.5" stroke-linecap="round"/>
|
|
<circle cx="24" cy="22" r="3" fill="${greenColor}"/>
|
|
</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
|
|
};
|