From c7f03bbe6f68a4b8bc538aa6768dbef729d8d46e Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:52:52 +0200 Subject: [PATCH] settings: backup retention + delete + size display (#116) - updateService: getDirectorySize, deleteBackup, pruneBackups; auto-prune in createPreUpdateBackup using DB setting backup_retention_count or env BACKUP_RETENTION_COUNT - settings.routes: DELETE /api/settings/updates/backups/:name, POST /api/settings/updates/backups/prune, GET/PUT /api/settings/backup/retention - settings.ejs: retention input + 'Apply now' button + total size summary - settings.js: render size per backup, delete button, retention controls - pages.css: backup-retention-row + update-backup-actions styles - lang/en + pl: 11 new keys (delete, retention, prune, total_size) --- web-nodejs/lang/en.json | 17 ++++ web-nodejs/lang/pl.json | 17 ++++ web-nodejs/public/css/pages.css | 54 ++++++++++++ web-nodejs/public/js/settings.js | 119 +++++++++++++++++++++++++-- web-nodejs/routes/settings.routes.js | 88 ++++++++++++++++++++ web-nodejs/services/updateService.js | 111 ++++++++++++++++++++++++- web-nodejs/views/settings.ejs | 17 ++++ 7 files changed, 415 insertions(+), 8 deletions(-) diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index f5211db9..2357cda6 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -427,6 +427,7 @@ "connect_desktop": "Connect via Desktop Client", "remote_viewer": "Remote Viewer", "web_remote": "Web Remote", + "cdap_viewer": "CDAP Snapshot Viewer", "details": "Details", "edit": "Edit", "delete": "Delete", @@ -593,6 +594,8 @@ "password_placeholder": "Device password", "connect": "Connect", "reconnect": "Reconnect", + "use_cdap_fallback": "Use CDAP viewer", + "cdap_fallback_hint": "Open the lightweight JPEG-polling viewer that uses bd-signal/CDAP instead of the RustDesk relay. Useful when the peer is offline on the relay but reachable via the agent management channel.", "disconnect": "Disconnect", "back_to_devices": "Back to Devices", "fullscreen": "Fullscreen", @@ -2832,6 +2835,20 @@ "restore": "Restore", "restore_confirm": "Are you sure you want to restore from this backup? Changed files will be overwritten.", "restore_success": "Backup restored successfully. Console is restarting…", + "delete": "Delete", + "delete_confirm": "Permanently delete backup {name}? This cannot be undone.", + "delete_success": "Backup deleted", + "delete_failed": "Failed to delete backup", + "retention_label": "Keep N most recent backups", + "retention_hint": "Older backups are deleted automatically after each update. Use 0 to keep all backups.", + "retention_saved": "Retention setting saved", + "retention_invalid": "Retention must be an integer between 0 and 1000", + "retention_required_for_prune": "Set a retention value greater than 0 first", + "prune_now": "Apply now", + "prune_confirm": "Keep only the {n} most recent backups and delete the rest?", + "prune_done": "Pruned {n} backup(s)", + "total_size": "Total size", + "backups_count": "backup(s)", "baseline_set": "Baseline set", "commits_behind": "commits behind", "total_files": "Total files", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 65742511..9aee9aee 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -426,6 +426,7 @@ "connect": "Połącz", "connect_desktop": "Połącz przez klienta desktop", "remote_viewer": "Podgląd zdalny", + "cdap_viewer": "Podgląd CDAP (zrzuty)", "web_remote": "Pulpit zdalny", "details": "Szczegóły", "edit": "Edytuj", @@ -593,6 +594,8 @@ "password_placeholder": "Hasło urządzenia", "connect": "Połącz", "reconnect": "Połącz ponownie", + "use_cdap_fallback": "Użyj podglądu CDAP", + "cdap_fallback_hint": "Otwiera lekki podgląd JPEG przez bd-signal/CDAP zamiast przekaźnika RustDesk. Przydatny, gdy urządzenie jest niedostępne przez relay, ale podłączone do kanału zarządzania.", "disconnect": "Rozłącz", "back_to_devices": "Powrót do Urządzeń", "fullscreen": "Pełny ekran", @@ -2832,6 +2835,20 @@ "restore": "Przywróć", "restore_confirm": "Czy na pewno chcesz przywrócić z tej kopii zapasowej? Zmienione pliki zostaną nadpisane.", "restore_success": "Kopia zapasowa przywrócona pomyślnie. Konsola jest restartowana…", + "delete": "Usuń", + "delete_confirm": "Trwale usunąć kopię zapasową {name}? Tej operacji nie można cofnąć.", + "delete_success": "Kopia zapasowa usunięta", + "delete_failed": "Nie udało się usunąć kopii zapasowej", + "retention_label": "Zachowaj N najnowszych kopii", + "retention_hint": "Starsze kopie są usuwane automatycznie po każdej aktualizacji. Wpisz 0, aby zachować wszystkie.", + "retention_saved": "Ustawienie retencji zapisane", + "retention_invalid": "Retencja musi być liczbą całkowitą między 0 a 1000", + "retention_required_for_prune": "Najpierw ustaw wartość retencji większą od 0", + "prune_now": "Zastosuj teraz", + "prune_confirm": "Zachować tylko {n} najnowszych kopii i usunąć resztę?", + "prune_done": "Usunięto {n} kopii", + "total_size": "Całkowity rozmiar", + "backups_count": "kopii", "baseline_set": "Punkt odniesienia ustawiony", "commits_behind": "commitów do tyłu", "total_files": "Łączna liczba plików", diff --git a/web-nodejs/public/css/pages.css b/web-nodejs/public/css/pages.css index 10401791..8d82f8b0 100644 --- a/web-nodejs/public/css/pages.css +++ b/web-nodejs/public/css/pages.css @@ -1529,6 +1529,60 @@ font-size: var(--font-size-xs, 0.75rem); } +.update-backup-actions { + display: flex; + gap: var(--space-xs, 4px); + flex-wrap: wrap; + justify-content: flex-end; +} + +.backup-retention-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + background: var(--bg-tertiary, rgba(255,255,255,0.03)); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + margin-bottom: var(--space-md); + flex-wrap: wrap; +} + +.backup-retention-label { + display: flex; + flex-direction: column; + gap: 4px; + font-weight: 500; + color: var(--text-primary); +} + +.backup-retention-label small { + font-weight: normal; + font-size: var(--font-size-xs, 0.75rem); +} + +.backup-retention-controls { + display: flex; + gap: var(--space-sm); + align-items: center; + flex-wrap: wrap; +} + +.backup-retention-controls input[type="number"] { + width: 110px; + padding: 6px 10px; + background: var(--bg-secondary, rgba(0,0,0,0.2)); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm, 4px); + color: var(--text-primary); +} + +.backup-summary { + margin-bottom: var(--space-sm); + font-size: var(--font-size-sm); +} + /* commit list */ .update-commits { display: flex; diff --git a/web-nodejs/public/js/settings.js b/web-nodejs/public/js/settings.js index b05a7bb6..906e5556 100644 --- a/web-nodejs/public/js/settings.js +++ b/web-nodejs/public/js/settings.js @@ -1249,6 +1249,7 @@ installBtn?.addEventListener('click', installUpdate); loadUpdateBackups(); + loadBackupRetention(); } async function checkForUpdates() { @@ -1612,9 +1613,86 @@ }, 2000); } + function formatBytes(n) { + if (!Number.isFinite(n) || n <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let v = n; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`; + } + + async function loadBackupRetention() { + const input = document.getElementById('backup-retention-input'); + if (!input) return; + try { + const data = await Utils.api('/api/settings/backup/retention'); + const count = (data && typeof data.count === 'number') ? data.count : 0; + input.value = String(count); + } catch { + input.value = '0'; + } + } + + async function saveBackupRetention() { + const input = document.getElementById('backup-retention-input'); + if (!input) return; + const count = parseInt(input.value, 10); + if (!Number.isFinite(count) || count < 0 || count > 1000) { + Notifications.error(_('updates.retention_invalid')); + return; + } + try { + await Utils.api('/api/settings/backup/retention', { + method: 'PUT', + body: { count } + }); + Notifications.success(_('updates.retention_saved')); + } catch (err) { + Notifications.error(err.message || _('errors.server_error')); + } + } + + async function pruneBackupsNow() { + const input = document.getElementById('backup-retention-input'); + const count = parseInt(input?.value, 10); + if (!Number.isFinite(count) || count <= 0) { + Notifications.error(_('updates.retention_required_for_prune')); + return; + } + if (!confirm(_('updates.prune_confirm').replace('{n}', String(count)))) return; + try { + const data = await Utils.api('/api/settings/updates/backups/prune', { + method: 'POST', + body: { keep: count } + }); + const deleted = (data && Array.isArray(data.deleted)) ? data.deleted.length : 0; + Notifications.success(_('updates.prune_done').replace('{n}', String(deleted))); + await loadUpdateBackups(); + } catch (err) { + Notifications.error(err.message || _('errors.server_error')); + } + } + + async function deleteBackup(name, btn) { + if (!confirm(_('updates.delete_confirm').replace('{name}', name))) return; + if (btn) btn.disabled = true; + try { + await Utils.api(`/api/settings/updates/backups/${encodeURIComponent(name)}`, { + method: 'DELETE' + }); + Notifications.success(_('updates.delete_success')); + await loadUpdateBackups(); + } catch (err) { + Notifications.error(err.message || _('errors.server_error')); + if (btn) btn.disabled = false; + } + } + async function loadUpdateBackups() { const listEl = document.getElementById('update-backups-list'); if (!listEl) return; + const summaryEl = document.getElementById('backup-summary'); try { const data = await Utils.api('/api/settings/updates/backups'); @@ -1622,30 +1700,42 @@ if (!backups.length) { listEl.innerHTML = `

${_('updates.no_backups')}

`; + if (summaryEl) summaryEl.textContent = ''; return; } + + const totalBytes = backups.reduce((acc, b) => acc + (b.sizeBytes || 0), 0); + if (summaryEl) { + summaryEl.textContent = `${_('updates.total_size')}: ${formatBytes(totalBytes)} · ${backups.length} ${_('updates.backups_count')}`; + } let html = '
'; for (const b of backups) { const date = b.timestamp ? new Date(b.timestamp).toLocaleString() : ''; const sha = b.sha ? ` · ${Utils.escapeHtml(b.sha)}` : ''; + const size = formatBytes(b.sizeBytes || 0); html += `
${Utils.escapeHtml(b.name)} - ${date}${sha} · ${b.fileCount || b.filesBackedUp || 0} ${_('updates.files')} + ${date}${sha} · ${b.fileCount || b.filesBackedUp || 0} ${_('updates.files')} · ${size} +
+
+ +
-
`; } html += '
'; listEl.innerHTML = html; // Attach restore handlers - listEl.querySelectorAll('[data-backup]').forEach(btn => { + listEl.querySelectorAll('[data-backup-restore]').forEach(btn => { btn.addEventListener('click', async () => { - const name = btn.dataset.backup; + const name = btn.dataset.backupRestore; if (!confirm(_('updates.restore_confirm'))) return; btn.disabled = true; @@ -1662,9 +1752,26 @@ } }); }); + + // Attach delete handlers + listEl.querySelectorAll('[data-backup-delete]').forEach(btn => { + btn.addEventListener('click', () => deleteBackup(btn.dataset.backupDelete, btn)); + }); } catch { listEl.innerHTML = `

${_('updates.no_backups')}

`; + if (summaryEl) summaryEl.textContent = ''; } } + + // Wire retention controls (idempotent — handles tab re-mount) + document.addEventListener('click', (ev) => { + const target = ev.target.closest('#backup-retention-save, #backup-prune-now'); + if (!target) return; + if (target.id === 'backup-retention-save') saveBackupRetention(); + else if (target.id === 'backup-prune-now') pruneBackupsNow(); + }); + + // Expose retention loader so tab activation can call it + window.loadBackupRetention = loadBackupRetention; })(); diff --git a/web-nodejs/routes/settings.routes.js b/web-nodejs/routes/settings.routes.js index 0b853cb7..be092141 100644 --- a/web-nodejs/routes/settings.routes.js +++ b/web-nodejs/routes/settings.routes.js @@ -679,4 +679,92 @@ router.post('/api/settings/updates/restore', requireAuth, requirePermission('ser } }); +/** + * DELETE /api/settings/updates/backups/:name - Delete a single pre-update backup + */ +router.delete('/api/settings/updates/backups/:name', requireAuth, requirePermission('server.config'), async (req, res) => { + try { + const name = req.params.name; + const result = updateService.deleteBackup(name); + await db.logAction(req.session?.userId, 'backup_deleted', `Deleted update backup: ${name}`, req.ip); + res.json({ success: true, data: result }); + } catch (err) { + console.error('Delete backup error:', err); + // 400 for validation errors, 500 for unexpected ones + const status = /invalid|not found|outside/i.test(err.message) ? 400 : 500; + res.status(status).json({ success: false, error: err.message }); + } +}); + +/** + * POST /api/settings/updates/backups/prune - Apply retention now + * Body: { keep: number } — keep N newest, delete the rest + */ +router.post('/api/settings/updates/backups/prune', requireAuth, requirePermission('server.config'), async (req, res) => { + try { + const keep = parseInt(req.body?.keep, 10); + if (!Number.isFinite(keep) || keep <= 0) { + return res.status(400).json({ success: false, error: 'keep must be a positive integer' }); + } + const result = updateService.pruneBackups(keep); + if (result.deleted.length) { + await db.logAction( + req.session?.userId, 'backup_pruned', + `Manual prune: kept ${keep}, deleted ${result.deleted.length} backup(s)`, + req.ip + ); + } + res.json({ success: true, data: result }); + } catch (err) { + console.error('Prune backups error:', err); + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * GET /api/settings/backup/retention - Read current retention count + * Resolution: DB setting → env var BACKUP_RETENTION_COUNT → 0 (unlimited) + */ +router.get('/api/settings/backup/retention', requireAuth, requirePermission('server.config'), async (req, res) => { + try { + const dbVal = await db.getSetting('backup_retention_count'); + let count = 0; + let source = 'unset'; + if (dbVal !== null && dbVal !== undefined && dbVal !== '') { + const parsed = parseInt(dbVal, 10); + if (Number.isFinite(parsed)) { count = parsed; source = 'database'; } + } else if (process.env.BACKUP_RETENTION_COUNT) { + const parsed = parseInt(process.env.BACKUP_RETENTION_COUNT, 10); + if (Number.isFinite(parsed)) { count = parsed; source = 'environment'; } + } + res.json({ success: true, data: { count, source } }); + } catch (err) { + console.error('Get retention error:', err); + res.status(500).json({ success: false, error: req.t('errors.server_error') }); + } +}); + +/** + * PUT /api/settings/backup/retention - Update retention count + * Body: { count: number } — 0 means "keep all" + */ +router.put('/api/settings/backup/retention', requireAuth, requirePermission('server.config'), async (req, res) => { + try { + const count = parseInt(req.body?.count, 10); + if (!Number.isFinite(count) || count < 0 || count > 1000) { + return res.status(400).json({ success: false, error: 'count must be an integer between 0 and 1000' }); + } + await db.setSetting('backup_retention_count', String(count)); + await db.logAction( + req.session?.userId, 'backup_retention_changed', + `Backup retention set to ${count === 0 ? 'unlimited' : count}`, + req.ip + ); + res.json({ success: true, data: { count } }); + } catch (err) { + console.error('Set retention error:', err); + res.status(500).json({ success: false, error: req.t('errors.server_error') }); + } +}); + module.exports = router; diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js index af2c2c67..3226922d 100644 --- a/web-nodejs/services/updateService.js +++ b/web-nodejs/services/updateService.js @@ -768,6 +768,31 @@ async function createPreUpdateBackup(allFiles) { files: allFiles.filter(f => f.component === 'console' && f.localPath).map(f => f.localPath) }, null, 2)); + // Auto-prune old backups based on retention setting. + // Resolution order: DB setting `backup_retention_count` → env var + // BACKUP_RETENTION_COUNT → 0 (keep all). Operator-controlled. + try { + let retention = 0; + try { + const db = require('./database'); + const dbVal = await db.getSetting('backup_retention_count'); + if (dbVal !== null && dbVal !== undefined && dbVal !== '') { + retention = parseInt(dbVal, 10); + } + } catch (_e) { /* DB unavailable — fall through to env */ } + if (!Number.isFinite(retention) || retention <= 0) { + retention = parseInt(process.env.BACKUP_RETENTION_COUNT, 10); + } + if (Number.isFinite(retention) && retention > 0) { + const pruneResult = pruneBackups(retention); + if (pruneResult.deleted.length) { + console.log(`[UPDATE] Pruned ${pruneResult.deleted.length} old backup(s) (retention=${retention})`); + } + } + } catch (err) { + console.warn(`[UPDATE] Auto-prune failed: ${err.message}`); + } + return { backupPath, backedUp }; } @@ -1033,6 +1058,28 @@ function restartService(serviceName) { } } +/** + * Recursively compute total size in bytes of a directory. + * Returns 0 on error so the UI can still render. + */ +function getDirectorySize(dirPath) { + let total = 0; + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dirPath, entry.name); + try { + if (entry.isDirectory()) { + total += getDirectorySize(full); + } else if (entry.isFile()) { + total += fs.statSync(full).size; + } + } catch (_e) { /* skip unreadable entry */ } + } + } catch (_e) { /* skip unreadable dir */ } + return total; +} + /** * List pre-update backups (newest first). */ @@ -1054,17 +1101,75 @@ function listBackups() { sha: (m.sha || '').slice(0, 7), timestamp: m.timestamp || '', filesBackedUp: m.filesBackedUp || 0, - fileCount: m.filesBackedUp || 0 + fileCount: m.filesBackedUp || 0, + sizeBytes: getDirectorySize(dir) }; }) .sort((a, b) => b.timestamp.localeCompare(a.timestamp)); } +/** + * Validate a backup directory name to prevent path traversal. + * Only allows the canonical `pre-update-{ISO-timestamp}` format. + */ +function isValidBackupName(name) { + return typeof name === 'string' && /^pre-update-[\d\-T]+$/.test(name); +} + +/** + * Recursively delete a directory. Refuses to delete anything outside + * BACKUP_DIR to defend against path-traversal bugs upstream. + */ +function deleteBackup(name) { + if (!isValidBackupName(name)) { + throw new Error('Invalid backup name'); + } + const target = path.resolve(BACKUP_DIR, name); + const root = path.resolve(BACKUP_DIR); + if (!target.startsWith(root + path.sep) && target !== root) { + throw new Error('Backup path is outside the backup directory'); + } + if (target === root) { + throw new Error('Refusing to delete the backup directory itself'); + } + if (!fs.existsSync(target)) { + throw new Error('Backup not found'); + } + fs.rmSync(target, { recursive: true, force: true }); + return { deleted: name }; +} + +/** + * Apply retention: keep the `keep` newest backups, delete older ones. + * keep <= 0 means "keep everything" (no-op). + */ +function pruneBackups(keep) { + const n = parseInt(keep, 10); + if (!Number.isFinite(n) || n <= 0) { + return { kept: -1, deleted: [] }; + } + const all = listBackups(); + if (all.length <= n) { + return { kept: n, deleted: [] }; + } + const toDelete = all.slice(n); + const deleted = []; + for (const b of toDelete) { + try { + deleteBackup(b.name); + deleted.push(b.name); + } catch (err) { + console.error(`[UPDATE] Failed to prune backup ${b.name}: ${err.message}`); + } + } + return { kept: n, deleted }; +} + /** * Restore console files from a pre-update backup and revert the SHA. */ function restoreFromBackup(backupName) { - if (!/^pre-update-[\d\-T]+$/.test(backupName)) throw new Error('Invalid backup name'); + if (!isValidBackupName(backupName)) throw new Error('Invalid backup name'); const backupPath = path.join(BACKUP_DIR, backupName); if (!fs.existsSync(backupPath)) throw new Error('Backup not found'); @@ -1096,6 +1201,8 @@ module.exports = { applyUpdate, restartService, listBackups, + deleteBackup, + pruneBackups, restoreFromBackup, getLocalVersion, getLocalSHA, diff --git a/web-nodejs/views/settings.ejs b/web-nodejs/views/settings.ejs index b262bd4a..cae15d48 100644 --- a/web-nodejs/views/settings.ejs +++ b/web-nodejs/views/settings.ejs @@ -753,6 +753,23 @@

${_('updates.backups_desc')}

+
+ +
+ + + +
+
+

${_('common.loading')}