From 7116c96c8372d3346c3ed09ea4d028459921173d Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:11:39 +0200 Subject: [PATCH] feat(branding): implement background image library and selection functionality Added a new API endpoint to list uploaded background images, enhancing the branding settings page with a background image library. Implemented UI components for selecting and displaying background images, including improved handling of background size and position. Updated tests to validate the new functionality and ensure proper integration with existing branding features. --- betterdesk-server/signal/handler.go | 4 + betterdesk-server/signal/handler_test.go | 24 ++++++ rdclient-desktop/src/settings.js | 19 ++++- rdclient-desktop/src/setup.js | 19 ++++- web-nodejs/public/css/pages.css | 93 +++++++++++++++++++++++- web-nodejs/public/js/desktop-widgets.js | 17 ++++- web-nodejs/public/js/settings.js | 66 +++++++++++++++++ web-nodejs/routes/settings.routes.js | 31 ++++++++ web-nodejs/tests/branding.routes.test.js | 23 ++++++ web-nodejs/views/settings.ejs | 27 ++++--- 10 files changed, 304 insertions(+), 19 deletions(-) diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index 24ce97cf..f2281e69 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -474,6 +474,10 @@ func (s *Server) processIDChange(msg *pb.RegisterPk) *pb.RendezvousMessage { log.Printf("[signal] Rejected invalid old peer ID in ID change: %q", oldID) return registerPkResponse(pb.RegisterPkResponse_NOT_SUPPORT) } + if !isValidPeerID(newID) { + log.Printf("[signal] Rejected invalid new peer ID in ID change: %q", newID) + return registerPkResponse(pb.RegisterPkResponse_NOT_SUPPORT) + } oldPeer, err := s.db.GetPeer(oldID) if err != nil { diff --git a/betterdesk-server/signal/handler_test.go b/betterdesk-server/signal/handler_test.go index d02c8755..a5584b54 100644 --- a/betterdesk-server/signal/handler_test.go +++ b/betterdesk-server/signal/handler_test.go @@ -256,6 +256,30 @@ func TestProcessIDChangeSoftDeletedTargetReturnsIDExists(t *testing.T) { } } +func TestProcessIDChangeRejectsInvalidNewID(t *testing.T) { + srv, database := newTestSignalServer(t, config.EnrollmentModeOpen) + + if err := database.UpsertPeer(&db.Peer{ID: "OLD213", Status: "ONLINE"}); err != nil { + t.Fatal(err) + } + + msg := newRegisterPk("bad/id") + msg.OldId = "OLD213" + resp := srv.processIDChange(msg) + if got := registerPkResult(resp); got != pb.RegisterPkResponse_NOT_SUPPORT { + t.Fatalf("ID change result = %v, want %v", got, pb.RegisterPkResponse_NOT_SUPPORT) + } + + if peer, err := database.GetPeer("OLD213"); err != nil { + t.Fatal(err) + } else if peer == nil { + t.Fatal("OLD213 should remain active after rejected invalid target ID") + } + if entry := srv.peers.Get("bad/id"); entry != nil { + t.Fatalf("invalid target ID must not enter memory map: %+v", entry) + } +} + func TestProcessIDChangeRejectsSoftDeletedSource(t *testing.T) { srv, database := newTestSignalServer(t, config.EnrollmentModeOpen) diff --git a/rdclient-desktop/src/settings.js b/rdclient-desktop/src/settings.js index eb9b3b7e..6447523a 100644 --- a/rdclient-desktop/src/settings.js +++ b/rdclient-desktop/src/settings.js @@ -56,6 +56,14 @@ return /^#[0-9a-fA-F]{6}$/.test(v) ? v : fallback; } + function applyBackgroundSizeMode(target, size) { + var mode = String(size || 'cover').trim(); + if (['cover', 'contain', 'auto', 'center', 'repeat'].indexOf(mode) === -1) mode = 'cover'; + target.style.backgroundSize = (mode === 'cover' || mode === 'contain') ? mode : 'auto'; + target.style.backgroundRepeat = mode === 'repeat' ? 'repeat' : 'no-repeat'; + target.style.backgroundPosition = mode === 'repeat' ? 'top left' : 'center'; + } + function applyAppearancePayload(payload, baseUrl) { var data = payload && (payload.data || payload.appearance || payload); if (!data || typeof data !== 'object') return; @@ -79,16 +87,23 @@ if (bg.type === 'image' && bg.imageUrl && baseUrl) { var absolute = new URL(bg.imageUrl, baseUrl).toString(); document.body.style.backgroundImage = 'linear-gradient(rgba(0,0,0,.55), rgba(0,0,0,.55)), url("' + absolute.replace(/"/g, '%22') + '")'; - document.body.style.backgroundSize = bg.size === 'contain' ? 'contain' : 'cover'; - document.body.style.backgroundPosition = 'center'; + document.body.style.backgroundColor = ''; + applyBackgroundSizeMode(document.body, bg.size); document.querySelectorAll('.card').forEach(function (card) { card.style.backdropFilter = 'blur(18px)'; }); } else if (bg.type === 'gradient' && bg.gradient) { document.body.style.backgroundImage = bg.gradient; + document.body.style.backgroundColor = ''; + document.body.style.backgroundSize = 'cover'; + document.body.style.backgroundRepeat = 'no-repeat'; + document.body.style.backgroundPosition = 'center'; } else if (bg.type === 'color' && bg.color) { document.body.style.backgroundImage = 'none'; document.body.style.backgroundColor = safeColor(bg.color, 'var(--bg)'); + document.body.style.backgroundSize = ''; + document.body.style.backgroundRepeat = ''; + document.body.style.backgroundPosition = ''; } } diff --git a/rdclient-desktop/src/setup.js b/rdclient-desktop/src/setup.js index 2c94cf94..99c0048b 100644 --- a/rdclient-desktop/src/setup.js +++ b/rdclient-desktop/src/setup.js @@ -38,6 +38,14 @@ return /^#[0-9a-fA-F]{6}$/.test(v) ? v : fallback; } + function applyBackgroundSizeMode(target, size) { + var mode = String(size || 'cover').trim(); + if (['cover', 'contain', 'auto', 'center', 'repeat'].indexOf(mode) === -1) mode = 'cover'; + target.style.backgroundSize = (mode === 'cover' || mode === 'contain') ? mode : 'auto'; + target.style.backgroundRepeat = mode === 'repeat' ? 'repeat' : 'no-repeat'; + target.style.backgroundPosition = mode === 'repeat' ? 'top left' : 'center'; + } + function applyAppearancePayload(payload, baseUrl) { var data = payload && (payload.data || payload.appearance || payload); if (!data || typeof data !== 'object') return; @@ -63,14 +71,21 @@ if (bg.type === 'image' && bg.imageUrl && baseUrl) { var absolute = new URL(bg.imageUrl, baseUrl).toString(); document.body.style.backgroundImage = 'linear-gradient(rgba(0,0,0,.55), rgba(0,0,0,.55)), url("' + absolute.replace(/"/g, '%22') + '")'; - document.body.style.backgroundSize = bg.size === 'contain' ? 'contain' : 'cover'; - document.body.style.backgroundPosition = 'center'; + document.body.style.backgroundColor = ''; + applyBackgroundSizeMode(document.body, bg.size); if (card) card.style.backdropFilter = 'blur(18px)'; } else if (bg.type === 'gradient' && bg.gradient) { document.body.style.backgroundImage = bg.gradient; + document.body.style.backgroundColor = ''; + document.body.style.backgroundSize = 'cover'; + document.body.style.backgroundRepeat = 'no-repeat'; + document.body.style.backgroundPosition = 'center'; } else if (bg.type === 'color' && bg.color) { document.body.style.backgroundImage = 'none'; document.body.style.backgroundColor = safeColor(bg.color, 'var(--bg)'); + document.body.style.backgroundSize = ''; + document.body.style.backgroundRepeat = ''; + document.body.style.backgroundPosition = ''; } } diff --git a/web-nodejs/public/css/pages.css b/web-nodejs/public/css/pages.css index d246ada8..e61cbbd9 100644 --- a/web-nodejs/public/css/pages.css +++ b/web-nodejs/public/css/pages.css @@ -1033,6 +1033,96 @@ background: var(--accent-red); } +.background-picker { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.background-picker-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); + flex-wrap: wrap; +} + +.background-library { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--space-sm); +} + +.background-library-item { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-xs); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + text-align: left; + transition: border-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.background-library-item:hover { + border-color: var(--accent-blue); + transform: translateY(-1px); +} + +.background-library-item.active { + border-color: var(--accent-blue); + box-shadow: 0 0 0 2px var(--accent-blue-muted); + color: var(--text-primary); +} + +.background-library-thumb { + display: block; + aspect-ratio: 16 / 9; + border-radius: var(--radius-md); + background-color: var(--bg-secondary); + background-position: center; + background-repeat: no-repeat; + background-size: cover; +} + +.background-library-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-size-xs); +} + +.background-library-empty { + grid-column: 1 / -1; + padding: var(--space-md); + border: 1px dashed var(--border-primary); + border-radius: var(--radius-lg); + color: var(--text-secondary); + background: var(--bg-tertiary); +} + +.background-current-file { + max-width: none; +} + +.background-advanced-url { + border-top: 1px solid var(--border-primary); + padding-top: var(--space-sm); +} + +.background-advanced-url summary { + cursor: pointer; + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +.background-advanced-url .form-group { + margin-top: var(--space-sm); +} + /* Color grid */ .color-grid { display: grid; @@ -1216,9 +1306,6 @@ background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary)); border: 1px solid var(--border-primary); border-radius: var(--radius-lg); - position: sticky; - top: 0; - z-index: 5; box-shadow: var(--shadow-sm); } diff --git a/web-nodejs/public/js/desktop-widgets.js b/web-nodejs/public/js/desktop-widgets.js index 2a30ed20..68d1b2cc 100644 --- a/web-nodejs/public/js/desktop-widgets.js +++ b/web-nodejs/public/js/desktop-widgets.js @@ -227,6 +227,7 @@ el.style.backgroundImage = String(branding.bgGradient); el.style.backgroundColor = ''; el.style.backgroundSize = 'cover'; + el.style.backgroundRepeat = 'no-repeat'; el.style.backgroundPosition = 'center'; return true; } @@ -234,13 +235,20 @@ _wallpaperPath = branding.bgImageUrl; el.style.backgroundImage = 'url("' + String(branding.bgImageUrl).replace(/"/g, '%22') + '")'; el.style.backgroundColor = ''; - el.style.backgroundSize = (branding.bgSize === 'contain') ? 'contain' : 'cover'; - el.style.backgroundPosition = 'center'; + applyBackgroundSizeMode(el, branding.bgSize); return true; } return false; } + function applyBackgroundSizeMode(el, size) { + var mode = String(size || 'cover').trim(); + if (['cover', 'contain', 'auto', 'center', 'repeat'].indexOf(mode) === -1) mode = 'cover'; + el.style.backgroundSize = (mode === 'cover' || mode === 'contain') ? mode : 'auto'; + el.style.backgroundRepeat = mode === 'repeat' ? 'repeat' : 'no-repeat'; + el.style.backgroundPosition = mode === 'repeat' ? 'top left' : 'center'; + } + /** Check if wallpaper files are available on server */ function probeWallpapers(cb) { if (_wallpapersAvailable !== null) { cb(_wallpapersAvailable); return; } @@ -258,6 +266,7 @@ el.style.backgroundImage = DEFAULT_GRADIENT; el.style.backgroundColor = '#0d1117'; el.style.backgroundSize = 'cover'; + el.style.backgroundRepeat = 'no-repeat'; el.style.backgroundPosition = 'center'; } @@ -281,6 +290,7 @@ el.style.backgroundImage = 'none'; el.style.backgroundColor = color; el.style.backgroundSize = ''; + el.style.backgroundRepeat = ''; el.style.backgroundPosition = ''; localStorage.setItem(STORAGE_WALL, url); localStorage.setItem(STORAGE_WALL_FIT, fit); @@ -296,6 +306,7 @@ el.style.backgroundColor = ''; el.style.backgroundImage = 'url("' + url + '")'; el.style.backgroundSize = bgSize; + el.style.backgroundRepeat = 'no-repeat'; el.style.backgroundPosition = bgPos; localStorage.setItem(STORAGE_WALL, url); localStorage.setItem(STORAGE_WALL_FIT, fit); @@ -308,6 +319,7 @@ newLayer.className = 'desktop-wallpaper-new'; newLayer.style.backgroundImage = 'url("' + url + '")'; newLayer.style.backgroundSize = bgSize; + newLayer.style.backgroundRepeat = 'no-repeat'; newLayer.style.backgroundPosition = bgPos; el.appendChild(newLayer); @@ -319,6 +331,7 @@ el.style.backgroundColor = ''; el.style.backgroundImage = 'url("' + url + '")'; el.style.backgroundSize = bgSize; + el.style.backgroundRepeat = 'no-repeat'; el.style.backgroundPosition = bgPos; if (newLayer.parentElement) newLayer.remove(); }, 600); diff --git a/web-nodejs/public/js/settings.js b/web-nodejs/public/js/settings.js index 1f68cf50..97264b81 100644 --- a/web-nodejs/public/js/settings.js +++ b/web-nodejs/public/js/settings.js @@ -1197,6 +1197,7 @@ if (rdclientOverlayVal) rdclientOverlayVal.textContent = data.rdclientBgOverlay || '0'; showBackgroundPanel('rdclient-bg', data.rdclientBgType || 'inherit'); syncExistingBackgroundUploadStatuses(data); + markSelectedBackgroundImage(data.bgImageUrl || ''); // Footer & custom CSS setVal('footer-text', data.footerText || ''); @@ -1262,6 +1263,69 @@ input.click(); }); }); + initBackgroundLibrary(); + } + + async function initBackgroundLibrary() { + const library = document.getElementById('bg-image-library'); + if (!library) return; + library.addEventListener('click', (event) => { + const item = event.target.closest('.background-library-item'); + if (!item) return; + selectBackgroundLibraryImage(item.dataset.url || ''); + }); + await loadBackgroundLibrary(document.getElementById('bg-image-url')?.value || ''); + } + + async function loadBackgroundLibrary(selectedUrl = '') { + const library = document.getElementById('bg-image-library'); + if (!library) return; + library.innerHTML = `
${_('common.loading')}
`; + try { + const resp = await Utils.api('/api/settings/branding/backgrounds'); + const items = Array.isArray(resp.data) ? resp.data : []; + renderBackgroundLibrary(items, selectedUrl || document.getElementById('bg-image-url')?.value || ''); + } catch (err) { + library.innerHTML = `
${Utils.escapeHtml(err.message || _('errors.server_error'))}
`; + } + } + + function renderBackgroundLibrary(items, selectedUrl = '') { + const library = document.getElementById('bg-image-library'); + if (!library) return; + if (!items.length) { + library.innerHTML = `
${_('desktop.wp_unavailable')}
`; + return; + } + library.innerHTML = items.map(item => { + const url = String(item.url || ''); + const name = String(item.name || url.split('/').pop() || ''); + const isActive = url === selectedUrl; + return ` + + `; + }).join(''); + } + + function selectBackgroundLibraryImage(url) { + if (!url) return; + const input = document.getElementById('bg-image-url'); + if (input) input.value = url; + selectBackgroundImageType('bg-image-url'); + setBackgroundUploadStatus(document.getElementById('bg-file-name'), url.split('/').pop() || url, url); + markSelectedBackgroundImage(url); + onBrandingFieldChange(); + } + + function markSelectedBackgroundImage(url) { + const library = document.getElementById('bg-image-library'); + if (!library) return; + library.querySelectorAll('.background-library-item').forEach(item => { + item.classList.toggle('active', item.dataset.url === url); + }); } /** @@ -1359,6 +1423,8 @@ if (uploadedUrl) { clearTimeout(_autosaveDebounce); + await loadBackgroundLibrary(uploadedUrl); + markSelectedBackgroundImage(uploadedUrl); updateBackgroundUploadPanel(statusEl, { state: 'success', icon: 'check_circle', diff --git a/web-nodejs/routes/settings.routes.js b/web-nodejs/routes/settings.routes.js index 9bb5b664..a9c6b351 100644 --- a/web-nodejs/routes/settings.routes.js +++ b/web-nodejs/routes/settings.routes.js @@ -313,6 +313,37 @@ const bgUpload = multer({ } }); +/** + * GET /api/settings/branding/backgrounds - List uploaded background images. + */ +router.get('/api/settings/branding/backgrounds', requireAuth, (req, res) => { + try { + const uploadsRoot = path.resolve(UPLOADS_DIR); + const managedPattern = /^bg-[0-9a-f]{16}\.(png|jpg|jpeg|gif|webp)$/i; + const files = fs.readdirSync(uploadsRoot) + .filter((name) => managedPattern.test(name)) + .map((name) => { + const fullPath = path.resolve(uploadsRoot, name); + if (!fullPath.startsWith(uploadsRoot + path.sep)) return null; + const stat = fs.lstatSync(fullPath); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + return { + name, + url: `/uploads/${name}`, + size: stat.size, + updatedAt: stat.mtime.toISOString() + }; + }) + .filter(Boolean) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + + res.json({ success: true, data: files }); + } catch (err) { + console.error('List branding backgrounds error:', err); + res.status(500).json({ success: false, error: req.t('errors.server_error') }); + } +}); + /** * POST /api/settings/branding/upload-background - Upload a background image. * Used by the console / login / agent-portal wallpaper pickers. Returns the diff --git a/web-nodejs/tests/branding.routes.test.js b/web-nodejs/tests/branding.routes.test.js index bc5bbba5..24163afc 100644 --- a/web-nodejs/tests/branding.routes.test.js +++ b/web-nodejs/tests/branding.routes.test.js @@ -117,6 +117,29 @@ describe('Branding routes', () => { }); describe('POST /api/settings/branding/upload-background', () => { + it('lists uploaded managed background images', async () => { + const uploadsDir = path.join(config.dataDir, 'uploads'); + fs.mkdirSync(uploadsDir, { recursive: true }); + const fileName = 'bg-0123456789abcdef.png'; + const filePath = path.join(uploadsDir, fileName); + fs.writeFileSync(filePath, Buffer.from('managed-background')); + + try { + const res = await request(app).get('/api/settings/branding/backgrounds'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: fileName, + url: `/uploads/${fileName}` + }) + ])); + } finally { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } + }); + it('accepts a multipart background image upload', async () => { const res = await request(app) .post('/api/settings/branding/upload-background') diff --git a/web-nodejs/views/settings.ejs b/web-nodejs/views/settings.ejs index 57419c08..8a495a91 100644 --- a/web-nodejs/views/settings.ejs +++ b/web-nodejs/views/settings.ejs @@ -811,16 +811,20 @@