Files
BetterDesk/web-nodejs/services/brandingService.js
T
UNITRONIX 32ac37da7b fix(web): enlarge BetterDesk B mark in favicon and logo slots
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)
2026-09-11 15:16:44 +02:00

1355 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
};