diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index d20e3bcc..dd251dba 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -853,13 +853,8 @@ "record": "Record Screen", "view_only": "View Only", "pin_toolbar": "Pin Toolbar", - "move_toolbar": "Move Toolbar", - "toggle_menu": "Show/Hide Toolbar", "monitors": "Monitors", "select_monitor": "Select Monitor", - "virtual_displays": "Virtual displays", - "virtual_display": "Virtual display", - "plug_out_all": "Plug out all", "quality_speed": "Speed (Low Quality, 60fps)", "quality_quality": "Quality (Best, 30fps)", "recording_started": "Recording started", @@ -956,7 +951,51 @@ "imported": "Theme imported successfully. Reloading...", "import_error": "Failed to import theme. Invalid file format.", "reset_confirm": "Are you sure you want to reset branding to defaults? This cannot be undone.", - "reset_success": "Branding reset to defaults. Reloading..." + "reset_success": "Branding reset to defaults. Reloading...", + "powered_by": "Powered by", + "background_title": "Background & Appearance", + "background_desc": "Set a console wallpaper, gradient or solid background. Cards float above it.", + "bg_type_none": "None", + "bg_type_color": "Solid Color", + "bg_type_gradient": "Gradient", + "bg_type_image": "Image", + "bg_color": "Background Color", + "bg_gradient": "CSS Gradient", + "bg_gradient_hint": "Any valid CSS gradient, e.g. linear-gradient(135deg, #1e293b, #0f172a)", + "bg_image_upload": "Upload Background Image", + "bg_image_choose": "Choose image file", + "bg_image_hint": "PNG, JPG, WebP or GIF. Max 8 MB. Stored on the server.", + "bg_image_url": "Image Path / URL", + "bg_size": "Image Sizing", + "bg_size_cover": "Cover (fill screen)", + "bg_size_contain": "Contain (fit)", + "bg_size_auto": "Auto (original)", + "bg_blur": "Background Blur", + "bg_overlay": "Dark Overlay", + "bg_overlay_hint": "Darkens the background for better text readability.", + "bg_image_too_large": "Image is too large. Maximum size is 8 MB.", + "bg_image_invalid_type": "Invalid file type. Use PNG, JPG, WebP or GIF.", + "bg_upload_success": "Background image uploaded successfully.", + "login_title": "Login Page", + "login_desc": "Customize the login screen heading, subtitle and background.", + "login_heading": "Login Heading", + "login_heading_placeholder": "Welcome back", + "login_subheading": "Login Subtitle", + "login_subheading_placeholder": "Sign in to your account", + "login_bg_inherit": "Same as console", + "agent_title": "Agent Download Page", + "agent_desc": "Customize the public agent download portal shown to end users.", + "agent_show_powered": "Show \"Powered by BetterDesk\"", + "agent_show_powered_hint": "Display the BetterDesk attribution in the download portal footer.", + "footer_title": "Footer & Copyright", + "footer_desc": "Add a custom footer line shown on the login screen and console sidebar.", + "footer_text": "Footer Text", + "footer_text_hint": "e.g. a copyright notice or company name.", + "show_powered": "Show \"BetterDesk\" attribution", + "show_powered_hint": "Display the BetterDesk name and version in the login footer and sidebar.", + "custom_css_title": "Custom CSS", + "custom_css_desc": "Advanced: inject custom CSS applied across the console and login page.", + "custom_css_hint": "Use with care. Invalid or unsafe rules are sanitized. Max 20,000 characters." }, "device_detail": { "tab_overview": "Overview", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 1b18c1dc..489e9d7c 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -853,13 +853,8 @@ "record": "Nagrywaj ekran", "view_only": "Tylko podgląd", "pin_toolbar": "Przypnij pasek", - "move_toolbar": "Przesuń pasek", - "toggle_menu": "Pokaż/ukryj pasek", "monitors": "Monitory", "select_monitor": "Wybierz monitor", - "virtual_displays": "Wirtualne ekrany", - "virtual_display": "Wirtualny ekran", - "plug_out_all": "Odłącz wszystkie", "quality_speed": "Szybkość (niska jakość, 60fps)", "quality_quality": "Jakość (najlepsza, 30fps)", "recording_started": "Nagrywanie rozpoczęte", @@ -956,7 +951,51 @@ "imported": "Motyw zaimportowany pomyślnie. Przeładowywanie...", "import_error": "Nie udało się zaimportować motywu. Nieprawidłowy format pliku.", "reset_confirm": "Czy na pewno chcesz przywrócić domyślny wygląd? Tej operacji nie można cofnąć.", - "reset_success": "Wygląd przywrócony do domyślnych. Przeładowywanie..." + "reset_success": "Wygląd przywrócony do domyślnych. Przeładowywanie...", + "powered_by": "Napędzane przez", + "background_title": "Tło i wygląd", + "background_desc": "Ustaw tapetę konsoli, gradient lub jednolite tło. Karty unoszą się nad nim.", + "bg_type_none": "Brak", + "bg_type_color": "Jednolity kolor", + "bg_type_gradient": "Gradient", + "bg_type_image": "Obraz", + "bg_color": "Kolor tła", + "bg_gradient": "Gradient CSS", + "bg_gradient_hint": "Dowolny prawidłowy gradient CSS, np. linear-gradient(135deg, #1e293b, #0f172a)", + "bg_image_upload": "Prześlij obraz tła", + "bg_image_choose": "Wybierz plik obrazu", + "bg_image_hint": "PNG, JPG, WebP lub GIF. Maks. 8 MB. Zapisywane na serwerze.", + "bg_image_url": "Ścieżka / URL obrazu", + "bg_size": "Skalowanie obrazu", + "bg_size_cover": "Wypełnij (cały ekran)", + "bg_size_contain": "Dopasuj (zmieść)", + "bg_size_auto": "Auto (oryginał)", + "bg_blur": "Rozmycie tła", + "bg_overlay": "Ciemna nakładka", + "bg_overlay_hint": "Przyciemnia tło dla lepszej czytelności tekstu.", + "bg_image_too_large": "Obraz jest za duży. Maksymalny rozmiar to 8 MB.", + "bg_image_invalid_type": "Nieprawidłowy typ pliku. Użyj PNG, JPG, WebP lub GIF.", + "bg_upload_success": "Obraz tła przesłany pomyślnie.", + "login_title": "Strona logowania", + "login_desc": "Dostosuj nagłówek, podtytuł i tło ekranu logowania.", + "login_heading": "Nagłówek logowania", + "login_heading_placeholder": "Witaj ponownie", + "login_subheading": "Podtytuł logowania", + "login_subheading_placeholder": "Zaloguj się do swojego konta", + "login_bg_inherit": "Tak jak konsola", + "agent_title": "Strona pobierania agenta", + "agent_desc": "Dostosuj publiczny portal pobierania agenta widoczny dla użytkowników.", + "agent_show_powered": "Pokaż \"Powered by BetterDesk\"", + "agent_show_powered_hint": "Wyświetla informację o BetterDesk w stopce portalu pobierania.", + "footer_title": "Stopka i prawa autorskie", + "footer_desc": "Dodaj własny wiersz stopki widoczny na ekranie logowania i w panelu bocznym.", + "footer_text": "Tekst stopki", + "footer_text_hint": "np. informacja o prawach autorskich lub nazwa firmy.", + "show_powered": "Pokaż informację \"BetterDesk\"", + "show_powered_hint": "Wyświetla nazwę i wersję BetterDesk w stopce logowania i panelu bocznym.", + "custom_css_title": "Własny CSS", + "custom_css_desc": "Zaawansowane: wstrzyknij własny CSS stosowany w całej konsoli i na stronie logowania.", + "custom_css_hint": "Używaj ostrożnie. Nieprawidłowe lub niebezpieczne reguły są filtrowane. Maks. 20 000 znaków." }, "device_detail": { "tab_overview": "Przegląd", diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index e6d2a8e4..6bd4508c 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -839,13 +839,8 @@ "record": "录制屏幕", "view_only": "仅查看", "pin_toolbar": "固定工具栏", - "move_toolbar": "移动工具栏", - "toggle_menu": "展开/折叠菜单", "monitors": "显示器", "select_monitor": "选择显示器", - "virtual_displays": "虚拟显示器", - "virtual_display": "虚拟显示器", - "plug_out_all": "全部拔出", "quality_speed": "速度(低质量,60fps)", "quality_quality": "质量(最佳,30fps)", "recording_started": "录制已开始", @@ -946,7 +941,51 @@ "imported": "主题导入成功。正在重新加载...", "import_error": "主题导入失败。文件格式无效。", "reset_confirm": "确定要将品牌设置恢复为默认值吗?此操作无法撤销。", - "reset_success": "品牌设置已恢复为默认值。正在重新加载..." + "reset_success": "品牌设置已恢复为默认值。正在重新加载...", + "powered_by": "技术支持", + "background_title": "背景与外观", + "background_desc": "设置控制台壁纸、渐变或纯色背景。卡片悬浮其上。", + "bg_type_none": "无", + "bg_type_color": "纯色", + "bg_type_gradient": "渐变", + "bg_type_image": "图片", + "bg_color": "背景颜色", + "bg_gradient": "CSS 渐变", + "bg_gradient_hint": "任何有效的 CSS 渐变,例如 linear-gradient(135deg, #1e293b, #0f172a)", + "bg_image_upload": "上传背景图片", + "bg_image_choose": "选择图片文件", + "bg_image_hint": "PNG、JPG、WebP 或 GIF。最大 8 MB。存储在服务器上。", + "bg_image_url": "图片路径 / URL", + "bg_size": "图片尺寸", + "bg_size_cover": "覆盖(填满屏幕)", + "bg_size_contain": "包含(适应)", + "bg_size_auto": "自动(原始)", + "bg_blur": "背景模糊", + "bg_overlay": "深色遮罩", + "bg_overlay_hint": "使背景变暗以提高文字可读性。", + "bg_image_too_large": "图片过大。最大尺寸为 8 MB。", + "bg_image_invalid_type": "文件类型无效。请使用 PNG、JPG、WebP 或 GIF。", + "bg_upload_success": "背景图片上传成功。", + "login_title": "登录页面", + "login_desc": "自定义登录界面的标题、副标题和背景。", + "login_heading": "登录标题", + "login_heading_placeholder": "欢迎回来", + "login_subheading": "登录副标题", + "login_subheading_placeholder": "登录您的账户", + "login_bg_inherit": "与控制台相同", + "agent_title": "代理下载页面", + "agent_desc": "自定义向最终用户显示的公共代理下载门户。", + "agent_show_powered": "显示 \"Powered by BetterDesk\"", + "agent_show_powered_hint": "在下载门户页脚显示 BetterDesk 署名。", + "footer_title": "页脚与版权", + "footer_desc": "添加显示在登录界面和控制台侧边栏的自定义页脚行。", + "footer_text": "页脚文字", + "footer_text_hint": "例如版权声明或公司名称。", + "show_powered": "显示 \"BetterDesk\" 署名", + "show_powered_hint": "在登录页脚和侧边栏显示 BetterDesk 名称和版本。", + "custom_css_title": "自定义 CSS", + "custom_css_desc": "高级:注入应用于整个控制台和登录页面的自定义 CSS。", + "custom_css_hint": "请谨慎使用。无效或不安全的规则会被过滤。最多 20,000 个字符。" }, "device_detail": { "tab_overview": "概览", diff --git a/web-nodejs/public/css/main.css b/web-nodejs/public/css/main.css index 79245854..a92ef590 100644 --- a/web-nodejs/public/css/main.css +++ b/web-nodejs/public/css/main.css @@ -428,6 +428,32 @@ table { background: var(--bg-tertiary); } +.sidebar-attribution { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-xs) var(--space-sm) 0; + margin-top: var(--space-xs); + font-size: 11px; + line-height: 1.4; + color: var(--text-tertiary); + text-align: center; +} + +.sidebar-attribution-text { + color: var(--text-secondary); + word-break: break-word; +} + +.sidebar-attribution-powered { + opacity: 0.7; +} + +/* Hide attribution in collapsed rail to avoid cramped layout */ +#sidebar[data-collapsed="true"] .sidebar-attribution { + display: none; +} + .sidebar-user-avatar { width: 32px; height: 32px; diff --git a/web-nodejs/public/js/settings.js b/web-nodejs/public/js/settings.js index ce3d5d74..538a326e 100644 --- a/web-nodejs/public/js/settings.js +++ b/web-nodejs/public/js/settings.js @@ -489,6 +489,7 @@ initLogoTypeSelector(); initColorPickers(); initFontPickers(); + initBackgroundSelectors(); initBrandingActions(); } catch (error) { @@ -544,10 +545,163 @@ } } + // Background & appearance (console) + const setVal = (id, val) => { const el = document.getElementById(id); if (el != null && el) el.value = val; }; + const setChecked = (id, val) => { const el = document.getElementById(id); if (el) el.checked = val; }; + const checkRadio = (name, val) => { + const r = document.querySelector(`input[name="${name}"][value="${val}"]`); + if (r) r.checked = true; + }; + checkRadio('bg-type', data.bgType || 'none'); + setVal('bg-color', data.bgColor || ''); + setVal('bg-gradient', data.bgGradient || ''); + setVal('bg-image-url', data.bgImageUrl || ''); + setVal('bg-blur', data.bgBlur || '0'); + setVal('bg-overlay', data.bgOverlay || '0'); + setVal('bg-size', data.bgSize || 'cover'); + if (data.bgColor && document.getElementById('bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.bgColor)) { + document.getElementById('bg-color-picker').value = data.bgColor; + } + const bgBlurVal = document.getElementById('bg-blur-value'); + if (bgBlurVal) bgBlurVal.textContent = data.bgBlur || '0'; + const bgOverlayVal = document.getElementById('bg-overlay-value'); + if (bgOverlayVal) bgOverlayVal.textContent = data.bgOverlay || '0'; + showBackgroundPanel('bg', data.bgType || 'none'); + + // Login page + setVal('login-title', data.loginTitle || ''); + setVal('login-subtitle', data.loginSubtitle || ''); + checkRadio('login-bg-type', data.loginBgType || 'inherit'); + setVal('login-bg-color', data.loginBgColor || ''); + setVal('login-bg-gradient', data.loginBgGradient || ''); + setVal('login-bg-image-url', data.loginBgImageUrl || ''); + setVal('login-bg-overlay', data.loginBgOverlay || '0'); + if (data.loginBgColor && document.getElementById('login-bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.loginBgColor)) { + document.getElementById('login-bg-color-picker').value = data.loginBgColor; + } + const loginOverlayVal = document.getElementById('login-bg-overlay-value'); + if (loginOverlayVal) loginOverlayVal.textContent = data.loginBgOverlay || '0'; + showBackgroundPanel('login-bg', data.loginBgType || 'inherit'); + + // Agent download page + checkRadio('agent-bg-type', data.agentBgType || 'none'); + setVal('agent-bg-color', data.agentBgColor || ''); + setVal('agent-bg-gradient', data.agentBgGradient || ''); + setVal('agent-bg-image-url', data.agentBgImageUrl || ''); + setChecked('agent-show-powered', data.agentShowPoweredBy !== 'false'); + if (data.agentBgColor && document.getElementById('agent-bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.agentBgColor)) { + document.getElementById('agent-bg-color-picker').value = data.agentBgColor; + } + showBackgroundPanel('agent-bg', data.agentBgType || 'none'); + + // Footer & custom CSS + setVal('footer-text', data.footerText || ''); + setChecked('show-powered', data.showPoweredBy !== 'false'); + setVal('custom-css', data.customCss || ''); + // Update preview updateLogoPreview(); } + /** + * Show the active panel for a background group (bg / login-bg / agent-bg) + * and hide the rest. "none"/"inherit" hide all detail panels. + */ + function showBackgroundPanel(prefix, type) { + ['none', 'inherit', 'color', 'gradient', 'image'].forEach(t => { + const panel = document.getElementById(`${prefix}-${t}-panel`); + if (panel) panel.classList.add('hidden'); + }); + const active = document.getElementById(`${prefix}-${type}-panel`); + if (active) active.classList.remove('hidden'); + } + + /** + * Wire background selectors: radio toggles, range value labels, + * color picker sync and image uploads for console/login/agent groups. + */ + function initBackgroundSelectors() { + // Radio toggles for the three background groups + [['bg-type', 'bg'], ['login-bg-type', 'login-bg'], ['agent-bg-type', 'agent-bg']].forEach(([name, prefix]) => { + document.querySelectorAll(`input[name="${name}"]`).forEach(radio => { + radio.addEventListener('change', () => showBackgroundPanel(prefix, radio.value)); + }); + }); + + // Color picker <-> hex text sync for the standalone background pickers + [['bg-color-picker', 'bg-color'], ['login-bg-color-picker', 'login-bg-color'], ['agent-bg-color-picker', 'agent-bg-color']].forEach(([pickerId, textId]) => { + const picker = document.getElementById(pickerId); + const text = document.getElementById(textId); + if (picker && text) { + picker.addEventListener('input', () => { text.value = picker.value; }); + text.addEventListener('input', () => { + if (/^#[0-9a-fA-F]{6}$/.test(text.value)) picker.value = text.value; + }); + } + }); + + // Range value labels + [['bg-blur', 'bg-blur-value'], ['bg-overlay', 'bg-overlay-value'], ['login-bg-overlay', 'login-bg-overlay-value']].forEach(([rangeId, labelId]) => { + const range = document.getElementById(rangeId); + const label = document.getElementById(labelId); + if (range && label) { + range.addEventListener('input', () => { label.textContent = range.value; }); + } + }); + + // Background image uploads (shared route, data-target picks the URL field) + document.querySelectorAll('.branding-bg-file').forEach(input => { + input.addEventListener('change', handleBackgroundFileUpload); + }); + } + + /** + * Handle background image upload — uploads to the shared background route + * and fills the URL field referenced by the input's data-target attribute. + */ + async function handleBackgroundFileUpload(e) { + const file = e.target.files[0]; + if (!file) return; + + const maxSize = 8 * 1024 * 1024; // 8 MB + if (file.size > maxSize) { + Utils.showNotification(_('branding.bg_image_too_large'), 'error'); + e.target.value = ''; + return; + } + const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + Utils.showNotification(_('branding.bg_image_invalid_type'), 'error'); + e.target.value = ''; + return; + } + + const targetId = e.target.dataset.target; + const nameElId = e.target.id.replace('-file', '-file-name'); + const nameEl = document.getElementById(nameElId); + if (nameEl) nameEl.textContent = file.name; + + const formData = new FormData(); + formData.append('background', file); + + try { + const resp = await fetch('/api/settings/branding/upload-background', { + method: 'POST', + headers: { 'x-csrf-token': window.BetterDesk?.csrfToken || '' }, + body: formData + }); + const result = await resp.json(); + if (!resp.ok || !result.success) { + throw new Error(result.error || 'Upload failed'); + } + const urlInput = document.getElementById(targetId); + if (urlInput) urlInput.value = result.url; + Notifications.success(_('branding.bg_upload_success')); + } catch (err) { + Utils.showNotification(err.message || _('errors.server_error'), 'error'); + } + } + /** * Initialize logo type selector */ @@ -765,6 +919,36 @@ } }); + // Background & appearance (console) + data.bgType = document.querySelector('input[name="bg-type"]:checked')?.value || 'none'; + data.bgColor = document.getElementById('bg-color')?.value.trim() || ''; + data.bgGradient = document.getElementById('bg-gradient')?.value.trim() || ''; + data.bgImageUrl = document.getElementById('bg-image-url')?.value.trim() || ''; + data.bgBlur = document.getElementById('bg-blur')?.value || ''; + data.bgOverlay = document.getElementById('bg-overlay')?.value || ''; + data.bgSize = document.getElementById('bg-size')?.value || 'cover'; + + // Login page + data.loginBgType = document.querySelector('input[name="login-bg-type"]:checked')?.value || 'inherit'; + data.loginBgColor = document.getElementById('login-bg-color')?.value.trim() || ''; + data.loginBgGradient = document.getElementById('login-bg-gradient')?.value.trim() || ''; + data.loginBgImageUrl = document.getElementById('login-bg-image-url')?.value.trim() || ''; + data.loginBgOverlay = document.getElementById('login-bg-overlay')?.value || ''; + data.loginTitle = document.getElementById('login-title')?.value.trim() || ''; + data.loginSubtitle = document.getElementById('login-subtitle')?.value.trim() || ''; + + // Agent download page + data.agentBgType = document.querySelector('input[name="agent-bg-type"]:checked')?.value || 'none'; + data.agentBgColor = document.getElementById('agent-bg-color')?.value.trim() || ''; + data.agentBgGradient = document.getElementById('agent-bg-gradient')?.value.trim() || ''; + data.agentBgImageUrl = document.getElementById('agent-bg-image-url')?.value.trim() || ''; + data.agentShowPoweredBy = document.getElementById('agent-show-powered')?.checked ? 'true' : 'false'; + + // Footer & custom CSS + data.footerText = document.getElementById('footer-text')?.value.trim() || ''; + data.showPoweredBy = document.getElementById('show-powered')?.checked ? 'true' : 'false'; + data.customCss = document.getElementById('custom-css')?.value || ''; + return data; } diff --git a/web-nodejs/routes/generator.routes.js b/web-nodejs/routes/generator.routes.js index ceb5e02f..8f9743c5 100644 --- a/web-nodejs/routes/generator.routes.js +++ b/web-nodejs/routes/generator.routes.js @@ -19,6 +19,7 @@ const bundleService = require('../services/agentBundleService'); const buildWorker = require('../services/agentBuildWorker'); const db = require('../services/database'); const config = require('../config/config'); +const brandingService = require('../services/brandingService'); // Branding payloads may carry a base64-encoded logo up to 10 MB; expand the // default 2 MB JSON body limit on the bundle CRUD + preview endpoints only. @@ -238,9 +239,19 @@ router.get('/d/:bundleId', async (req, res) => { ...p, status: buildMap[`${p.platform}/${p.arch}/${p.format}`] || 'pending', })); + // Global console branding provides portal-wide defaults (wallpaper, + // attribution) shared by every bundle that does not override them. + const gb = brandingService.getBranding(); + const globalBranding = { + background: brandingService.buildBackgroundValue(gb.agentBgType, gb.agentBgColor, gb.agentBgGradient, gb.agentBgImageUrl), + showPoweredBy: gb.agentShowPoweredBy !== 'false', + appName: gb.appName || 'BetterDesk', + logoUrl: gb.logoType === 'image' ? gb.logoUrl : '', + }; res.render('agent-download', { bundle, platforms, + globalBranding, t: req.t.bind(req), }); } catch (err) { diff --git a/web-nodejs/routes/settings.routes.js b/web-nodejs/routes/settings.routes.js index ccd06ac9..61e423d6 100644 --- a/web-nodejs/routes/settings.routes.js +++ b/web-nodejs/routes/settings.routes.js @@ -247,6 +247,49 @@ router.post('/api/settings/branding/upload-logo', requireAuth, requirePermission }); }); +// ── Background image upload (disk storage) ─────────────────────────────────── +const bgUpload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, UPLOADS_DIR), + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; + const hash = crypto.randomBytes(8).toString('hex'); + cb(null, `bg-${hash}${ext}`); + } + }), + limits: { fileSize: 8 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = /^image\/(png|jpeg|gif|webp|svg\+xml)$/; + if (allowed.test(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type')); + } + } +}); + +/** + * POST /api/settings/branding/upload-background - Upload a background image. + * Used by the console / login / agent-portal wallpaper pickers. Returns the + * served URL; the caller decides which branding field to assign it to. Old + * background files are not auto-removed because a single image may be shared + * across console/login/agent backgrounds. + */ +router.post('/api/settings/branding/upload-background', requireAuth, requirePermission('branding.edit'), (req, res) => { + bgUpload.single('background')(req, res, async (err) => { + if (err) { + const msg = err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 8 MB)' : (err.message || 'Upload failed'); + return res.status(400).json({ success: false, error: msg }); + } + if (!req.file) { + return res.status(400).json({ success: false, error: 'No file provided' }); + } + const url = `/uploads/${req.file.filename}`; + await db.logAction(req.session?.userId, 'branding_bg_upload', `Uploaded background: ${req.file.filename}`, req.ip); + res.json({ success: true, url }); + }); +}); + /** * POST /api/settings/branding/reset - Reset branding to defaults (admin only) */ diff --git a/web-nodejs/services/brandingService.js b/web-nodejs/services/brandingService.js index 01bdd6de..a985768a 100644 --- a/web-nodejs/services/brandingService.js +++ b/web-nodejs/services/brandingService.js @@ -86,6 +86,77 @@ function validateBrandingUrl(value) { } } +/** + * 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 ''; + } +} + +/** + * Sanitize operator-supplied custom CSS (advanced escape hatch). + * Removes constructs that could lead to XSS / data exfiltration: + * - 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 = css.replace(/<\s*\/?\s*style[^>]*>/gi, ''); // breakout + css = css.replace(/<[^>]*>/g, ''); // any HTML tags + 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 @@ -106,7 +177,39 @@ const DEFAULT_BRANDING = { // 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' + + // 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: '', + // Color scheme overrides (empty = use defaults from variables.css) colors: { bgPrimary: '', @@ -222,11 +325,21 @@ async function saveBranding(updates) { } 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') { + entries.push({ key, value: sanitizeSvg(String(value)) }); } else if (key === 'logoUrl' || key === 'faviconUrl' || + key === 'bgImageUrl' || key === 'loginBgImageUrl' || key === 'agentBgImageUrl') { // 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 { + entries.push({ key, value: normalized }); + } else if (key === 'bgColor' || key === 'bgGradient' || + key === 'loginBgColor' || key === 'loginBgGradient' || + key === 'agentBgColor' || key === 'agentBgGradient') { + // Security: Restrict to a safe CSS color/gradient charset. + entries.push({ key, value: sanitizeCssColorValue(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) }); } } @@ -286,10 +399,91 @@ function generateThemeCss() { if (overrides.length > 0) { css += `:root {\n${overrides.join('\n')}\n}\n`; } - + + // 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)); +} + +/** + * 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 '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: -2;\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: -1;\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; }\n`; + out += `body.app-page .main-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`; + } + + return out; +} + /** * Generate favicon SVG from branding * @returns {string} SVG markup for favicon @@ -353,8 +547,12 @@ async function importPreset(preset) { } sanitized.colors = colors; } else { - // Limit string length for safety - sanitized[key] = String(preset.branding[key]).substring(0, key === 'logoSvg' || key === 'faviconSvg' ? 50000 : 500); + // 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') cap = 1000; + sanitized[key] = String(preset.branding[key]).substring(0, cap); } } } @@ -383,5 +581,8 @@ module.exports = { importPreset, invalidateCache, sanitizeSvg, + sanitizeCssColorValue, + sanitizeCustomCss, + buildBackgroundValue, validateBrandingUrl }; diff --git a/web-nodejs/views/agent-download.ejs b/web-nodejs/views/agent-download.ejs index e8faa4c3..817a8f95 100644 --- a/web-nodejs/views/agent-download.ejs +++ b/web-nodejs/views/agent-download.ejs @@ -2,14 +2,17 @@ // Public download portal — branded per bundle. No auth, no shared layout. // Bundle ID itself is the access token. const brand = bundle.branding || {}; +const gb = typeof globalBranding !== 'undefined' && globalBranding ? globalBranding : {}; const primary = brand.primary_color || '#2563eb'; const accent = brand.accent_color || '#1e293b'; -const companyName = brand.company_name || 'BetterDesk Support'; +const companyName = brand.company_name || gb.appName || 'BetterDesk Support'; const shortText = brand.short_text || ''; -const logo = brand.logo_data_url || ''; +const logo = brand.logo_data_url || gb.logoUrl || ''; const contactEmail = brand.contact_email || ''; const contactPhone = brand.contact_phone || ''; const contactUrl = brand.contact_url || ''; +const portalBackground = gb.background || ''; +const showPoweredBy = gb.showPoweredBy !== false; function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } %> @@ -25,6 +28,15 @@ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({' --brand-primary: <%= esc(primary) %>; --brand-accent: <%= esc(accent) %>; } + <% if (portalBackground) { %> + body { + background: <%- portalBackground %>; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-attachment: fixed; + } + <% } %>
@@ -100,7 +112,9 @@ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({' <% } %> diff --git a/web-nodejs/views/login.ejs b/web-nodejs/views/login.ejs index 241f6541..d55d238c 100644 --- a/web-nodejs/views/login.ejs +++ b/web-nodejs/views/login.ejs @@ -36,8 +36,8 @@ <%= appName %> <% } %> -<%= _('auth.login_subtitle') %>
+<%= branding.loginSubtitle || _('auth.login_subtitle') %>
@@ -171,7 +171,12 @@ diff --git a/web-nodejs/views/partials/sidebar.ejs b/web-nodejs/views/partials/sidebar.ejs index f41397b1..887570ab 100644 --- a/web-nodejs/views/partials/sidebar.ejs +++ b/web-nodejs/views/partials/sidebar.ejs @@ -295,6 +295,12 @@ + <% if ((branding && branding.footerText) || (branding && branding.showPoweredBy !== 'false')) { %> + + <% } %> diff --git a/web-nodejs/views/settings.ejs b/web-nodejs/views/settings.ejs index d1fc6e2a..dbb79060 100644 --- a/web-nodejs/views/settings.ejs +++ b/web-nodejs/views/settings.ejs @@ -543,6 +543,346 @@ + +${_('branding.background_desc')}
+${_('branding.login_desc')}
+${_('branding.agent_desc')}
+${_('branding.footer_desc')}
+${_('branding.custom_css_desc')}
+