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 @@