From 430c7f456ec474e179fade8d97d9bf7b99dabdf0 Mon Sep 17 00:00:00 2001 From: Verum Date: Fri, 20 Mar 2026 16:48:45 +0700 Subject: [PATCH] =?UTF-8?q?=D1=87=D1=82=D0=BE=20=D1=82=D0=BE=20=D0=B0?= =?UTF-8?q?=D0=BC=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/assets/app.js | 213 +++++++++++++++++++++++++------------- public/index.html | 190 +++++++++++++++++++++++++++++++--- server.js | 30 +++++- src/lib/audit.js | 5 +- src/services/backups.js | 222 +++++++++++++++++++++++++++++----------- 5 files changed, 505 insertions(+), 155 deletions(-) diff --git a/public/assets/app.js b/public/assets/app.js index f735b13..9c66407 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -151,16 +151,27 @@ `${dbInfo.host}:${dbInfo.port}/${dbInfo.database}`; document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer'; document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs); - document.getElementById('usersButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); - document.getElementById('backupsButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); - document.getElementById('settingsButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); - document.getElementById('auditButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); + document.getElementById('managementButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none'; document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none'; this.loadTables(); } + hideWorkspacePanels() { + ['emptyState', 'dataGrid', 'sqlPanel', 'logsPanel', 'managementPanel'].forEach((id) => { + const node = document.getElementById(id); + if (node) { + node.classList.add('hidden'); + } + }); + } + + setToolbarMode(mode) { + document.getElementById('tableActions').classList.toggle('hidden', mode !== 'table'); + document.getElementById('recordControls').classList.toggle('hidden', mode !== 'table'); + } + getPermissions() { if (this.currentUser?.role === 'superadmin') { return { @@ -293,10 +304,8 @@ this.closeSidebar(); document.getElementById('currentTableTitle').textContent = tableName; - document.getElementById('tableActions').classList.remove('hidden'); - document.getElementById('emptyState').classList.add('hidden'); - document.getElementById('sqlPanel').classList.add('hidden'); - document.getElementById('logsPanel').classList.add('hidden'); + this.setToolbarMode('table'); + this.hideWorkspacePanels(); document.getElementById('dataGrid').classList.remove('hidden'); // Update action buttons based on permissions @@ -841,10 +850,10 @@ this.showToast('SQL доступ разрешен только администраторам', 'error'); return; } - document.getElementById('emptyState').classList.add('hidden'); - document.getElementById('dataGrid').classList.add('hidden'); - document.getElementById('logsPanel').classList.add('hidden'); + this.setToolbarMode('workspace'); + this.hideWorkspacePanels(); document.getElementById('sqlPanel').classList.remove('hidden'); + document.getElementById('currentTableTitle').textContent = 'SQL Query'; } executeSQL() { @@ -936,10 +945,10 @@ return; } - document.getElementById('emptyState').classList.add('hidden'); - document.getElementById('dataGrid').classList.add('hidden'); - document.getElementById('sqlPanel').classList.add('hidden'); + this.setToolbarMode('workspace'); + this.hideWorkspacePanels(); document.getElementById('logsPanel').classList.remove('hidden'); + document.getElementById('currentTableTitle').textContent = 'Container logs'; await this.loadContainers(); } @@ -1070,13 +1079,41 @@ return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`)).map(input => input.value); } + async showManagementPanel(section = 'settings') { + if (!this.getPermissions().canManageUsers) { + this.showToast('Management is available only for admins', 'error'); + return; + } + + this.setToolbarMode('workspace'); + this.hideWorkspacePanels(); + document.getElementById('managementPanel').classList.remove('hidden'); + document.getElementById('currentTableTitle').textContent = 'Management'; + + ['settings', 'backups', 'users', 'audit'].forEach((item) => { + document.getElementById(`managementSection-${item}`).classList.toggle('hidden', item !== section); + document.getElementById(`managementTab-${item}`).className = item === section + ? 'w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left bg-slate-100 text-slate-900' + : 'w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100'; + }); + + if (section === 'settings') { + await this.loadSettings(); + } else if (section === 'backups') { + await this.loadBackups(); + } else if (section === 'users') { + this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); + this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); + this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name)); + await this.loadUsers(); + this.resetUserForm(); + } else if (section === 'audit') { + await this.loadAuditLog(); + } + } + async showUsersModal() { - this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); - this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); - this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name)); - await this.loadUsers(); - this.resetUserForm(); - document.getElementById('usersModal').classList.remove('hidden'); + await this.showManagementPanel('users'); } async loadUsers() { @@ -1087,7 +1124,7 @@ throw new Error(users.error || 'Failed to load users'); } - document.getElementById('usersTableBody').innerHTML = users.map(user => ` + document.getElementById('managementUsersTableBody').innerHTML = users.map(user => ` ${user.username} ${user.role} @@ -1105,43 +1142,43 @@ editUser(serializedUser) { const user = JSON.parse(serializedUser); - document.getElementById('userEditMode').value = user.username; - document.getElementById('userUsername').value = user.username; - document.getElementById('userUsername').disabled = true; - document.getElementById('userPassword').value = ''; - document.getElementById('userRole').value = user.role; - this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []); - this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []); - this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []); - document.getElementById('userDisabled').checked = Boolean(user.disabled); + document.getElementById('managementUserEditMode').value = user.username; + document.getElementById('managementUserUsername').value = user.username; + document.getElementById('managementUserUsername').disabled = true; + document.getElementById('managementUserPassword').value = ''; + document.getElementById('managementUserRole').value = user.role; + this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []); + this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []); + this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []); + document.getElementById('managementUserDisabled').checked = Boolean(user.disabled); } resetUserForm() { - document.getElementById('userEditMode').value = ''; - document.getElementById('userUsername').value = ''; - document.getElementById('userUsername').disabled = false; - document.getElementById('userPassword').value = ''; - document.getElementById('userRole').value = 'viewer'; - this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); - this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); - this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name)); - document.getElementById('userDisabled').checked = false; + document.getElementById('managementUserEditMode').value = ''; + document.getElementById('managementUserUsername').value = ''; + document.getElementById('managementUserUsername').disabled = false; + document.getElementById('managementUserPassword').value = ''; + document.getElementById('managementUserRole').value = 'viewer'; + this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); + this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); + this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name)); + document.getElementById('managementUserDisabled').checked = false; } async saveUser() { - const username = document.getElementById('userUsername').value.trim(); - const editMode = document.getElementById('userEditMode').value; - const password = document.getElementById('userPassword').value; + const username = document.getElementById('managementUserUsername').value.trim(); + const editMode = document.getElementById('managementUserEditMode').value; + const password = document.getElementById('managementUserPassword').value; const payload = { username, password, - role: document.getElementById('userRole').value, - disabled: document.getElementById('userDisabled').checked, + role: document.getElementById('managementUserRole').value, + disabled: document.getElementById('managementUserDisabled').checked, access: { - view: { folders: this.getCheckedValues('userViewFoldersList'), tables: [] }, - create: { folders: this.getCheckedValues('userViewFoldersList'), tables: [] }, - edit: { folders: [], tables: this.getCheckedValues('userEditTablesList') }, - delete: { folders: this.getCheckedValues('userDeleteFoldersList'), tables: [] }, + view: { folders: this.getCheckedValues('managementUserViewFoldersList'), tables: [] }, + create: { folders: this.getCheckedValues('managementUserViewFoldersList'), tables: [] }, + edit: { folders: [], tables: this.getCheckedValues('managementUserEditTablesList') }, + delete: { folders: this.getCheckedValues('managementUserDeleteFoldersList'), tables: [] }, }, }; @@ -1219,18 +1256,15 @@ } async showAuditModal() { - await this.loadAuditLog(); - document.getElementById('auditModal').classList.remove('hidden'); + await this.showManagementPanel('audit'); } async showBackupsModal() { - await this.loadBackups(); - document.getElementById('backupsModal').classList.remove('hidden'); + await this.showManagementPanel('backups'); } async showSettingsModal() { - await this.loadSettings(); - document.getElementById('settingsModal').classList.remove('hidden'); + await this.showManagementPanel('settings'); } async loadBackups() { @@ -1241,14 +1275,17 @@ throw new Error(backups.error || 'Failed to load backups'); } - document.getElementById('backupsList').innerHTML = backups.length + document.getElementById('managementBackupsList').innerHTML = backups.length ? backups.map(backup => `
${backup.filename}
${backup.createdAt} - ${backup.kind} - ${backup.size} bytes
- Download +
+ + Download +
`).join('') : '
No backups yet.
'; @@ -1267,13 +1304,44 @@ if (!response.ok) { throw new Error(result.error || 'Failed to create backup'); } - this.showToast('Backup created', 'success'); + this.showToast('Archive created', 'success'); this.loadBackups(); } catch (err) { this.showToast(err.message, 'error'); } } + async restoreBackup(filename) { + if (!confirm(`Restore backup ${filename}? The current database will be replaced.`)) { + return; + } + + try { + const response = await fetch(`/api/backups/${encodeURIComponent(filename)}/restore`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Request-Source': 'WEB', + }, + body: JSON.stringify({ + restoreAppSnapshot: document.getElementById('managementRestoreAppSnapshot').checked, + }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || 'Failed to restore backup'); + } + + this.showToast('Backup restored', 'success'); + await this.loadTables(); + if (this.currentTable) { + await this.selectTable(this.currentTable); + } + } catch (err) { + this.showToast(err.message, 'error'); + } + } + async loadSettings() { try { const response = await fetch('/api/settings'); @@ -1283,32 +1351,32 @@ } this.currentSettings = settings; - document.getElementById('settingsBackupsEnabled').checked = Boolean(settings.backups?.enabled); - document.getElementById('settingsBackupTime').value = `${String(settings.backups?.hour ?? 3).padStart(2, '0')}:${String(settings.backups?.minute ?? 0).padStart(2, '0')}`; - document.getElementById('settingsKeepLast').value = settings.backups?.keepLast ?? 14; - document.getElementById('settingsIncludeAppSnapshot').checked = settings.backups?.includeAppSnapshot !== false; - document.getElementById('settingsTelegramEnabled').checked = Boolean(settings.telegram?.enabled); - document.getElementById('settingsTelegramToken').value = settings.telegram?.botToken || ''; - document.getElementById('settingsTelegramChatId').value = settings.telegram?.chatId || ''; + document.getElementById('managementSettingsBackupsEnabled').checked = Boolean(settings.backups?.enabled); + document.getElementById('managementSettingsBackupTime').value = `${String(settings.backups?.hour ?? 3).padStart(2, '0')}:${String(settings.backups?.minute ?? 0).padStart(2, '0')}`; + document.getElementById('managementSettingsKeepLast').value = settings.backups?.keepLast ?? 14; + document.getElementById('managementSettingsIncludeAppSnapshot').checked = settings.backups?.includeAppSnapshot !== false; + document.getElementById('managementSettingsTelegramEnabled').checked = Boolean(settings.telegram?.enabled); + document.getElementById('managementSettingsTelegramToken').value = settings.telegram?.botToken || ''; + document.getElementById('managementSettingsTelegramChatId').value = settings.telegram?.chatId || ''; } catch (err) { this.showToast(err.message, 'error'); } } async saveSettings() { - const [hour, minute] = (document.getElementById('settingsBackupTime').value || '03:00').split(':').map(Number); + const [hour, minute] = (document.getElementById('managementSettingsBackupTime').value || '03:00').split(':').map(Number); const payload = { backups: { - enabled: document.getElementById('settingsBackupsEnabled').checked, + enabled: document.getElementById('managementSettingsBackupsEnabled').checked, hour, minute, - keepLast: Number(document.getElementById('settingsKeepLast').value || 14), - includeAppSnapshot: document.getElementById('settingsIncludeAppSnapshot').checked, + keepLast: Number(document.getElementById('managementSettingsKeepLast').value || 14), + includeAppSnapshot: document.getElementById('managementSettingsIncludeAppSnapshot').checked, }, telegram: { - enabled: document.getElementById('settingsTelegramEnabled').checked, - botToken: document.getElementById('settingsTelegramToken').value.trim(), - chatId: document.getElementById('settingsTelegramChatId').value.trim(), + enabled: document.getElementById('managementSettingsTelegramEnabled').checked, + botToken: document.getElementById('managementSettingsTelegramToken').value.trim(), + chatId: document.getElementById('managementSettingsTelegramChatId').value.trim(), }, }; @@ -1328,7 +1396,6 @@ this.currentSettings = result.settings; this.showToast('Settings saved', 'success'); - this.closeModal('settingsModal'); } catch (err) { this.showToast(err.message, 'error'); } @@ -1342,7 +1409,7 @@ throw new Error(entries.error || 'Failed to load audit log'); } - document.getElementById('auditList').innerHTML = entries.length + document.getElementById('managementAuditList').innerHTML = entries.length ? entries.map(entry => `
diff --git a/public/index.html b/public/index.html index 11de856..e41e80e 100644 --- a/public/index.html +++ b/public/index.html @@ -66,21 +66,9 @@ - - - -
Select a container to load recent logs.
+ diff --git a/server.js b/server.js index a216f4e..621d933 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,7 @@ const { getBackupPath, listBackups, pruneBackups, + restoreBackup, } = require('./src/services/backups'); const { notifyError, @@ -136,10 +137,10 @@ async function runScheduledBackupIfNeeded() { lastAutoBackupSlot = slot; appendAudit('backup.auto_created', 'system', { - files: backup.files, + filename: backup.filename, source: 'WEB', }); - notifyInfo('Scheduled backup completed', backup.files.map((file) => `${file.kind}: ${file.filename}`)).catch(() => {}); + notifyInfo('Scheduled backup completed', [backup.filename]).catch(() => {}); } function startBackupScheduler() { @@ -405,7 +406,7 @@ app.post('/api/backups', requireAuth, requirePermission( includeAppSnapshot: settings.backups.includeAppSnapshot, keepLast: settings.backups.keepLast, }); - appendAudit('backup.created', req.currentUser.username, { files: backup.files, source: getAuditSource(req) }); + appendAudit('backup.created', req.currentUser.username, { filename: backup.filename, source: getAuditSource(req) }); res.json({ success: true, backup }); } catch (err) { notifyError('Backup creation failed', err, { actor: req.currentUser.username }).catch(() => {}); @@ -413,6 +414,29 @@ app.post('/api/backups', requireAuth, requirePermission( } }); +app.post('/api/backups/:filename/restore', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'Backup access denied' +), async (req, res) => { + try { + const result = await restoreBackup(req.params.filename, { + restoreAppSnapshot: req.body?.restoreAppSnapshot !== false, + }); + appendAudit('backup.restored', req.currentUser.username, { + filename: req.params.filename, + source: getAuditSource(req), + }); + notifyInfo('Backup restored', [req.params.filename, `actor: ${req.currentUser.username}`]).catch(() => {}); + res.json({ success: true, result }); + } catch (err) { + notifyError('Backup restore failed', err, { + actor: req.currentUser.username, + filename: req.params.filename, + }).catch(() => {}); + res.status(500).json({ success: false, error: err.message }); + } +}); + app.get('/api/settings', requireAuth, requirePermission( (permissions) => permissions.canManageUsers, 'Settings access denied' diff --git a/src/lib/audit.js b/src/lib/audit.js index 1a4de11..5dbc29a 100644 --- a/src/lib/audit.js +++ b/src/lib/audit.js @@ -11,8 +11,9 @@ function formatAuditSummary(event, details = {}) { 'user.created': `created user ${details.username || ''}`.trim(), 'user.updated': `updated user ${details.username || ''}`.trim(), 'user.deleted': `deleted user ${details.username || ''}`.trim(), - 'backup.created': `created backup ${details.filename || details.files?.map((file) => file.filename).join(', ') || ''}`.trim(), - 'backup.auto_created': `created scheduled backup ${details.files?.map((file) => file.filename).join(', ') || ''}`.trim(), + 'backup.created': `created backup ${details.filename || ''}`.trim(), + 'backup.auto_created': `created scheduled backup ${details.filename || ''}`.trim(), + 'backup.restored': `restored backup ${details.filename || ''}`.trim(), 'settings.updated': 'updated system settings', 'table.created': `created table ${details.table || ''}`.trim(), 'table.deleted': `deleted table ${details.table || ''}`.trim(), diff --git a/src/services/backups.js b/src/services/backups.js index db420fc..26ac18a 100644 --- a/src/services/backups.js +++ b/src/services/backups.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); @@ -7,6 +8,7 @@ const USERS_FILE = path.join(__dirname, '..', '..', 'users.json'); const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log'); const SETTINGS_FILE = path.join(__dirname, '..', '..', 'settings.json'); const BACKUP_PREFIX = 'backup-'; +const BACKUP_EXTENSION = '.tar.gz'; function ensureBackupsDir() { fs.mkdirSync(BACKUPS_DIR, { recursive: true }); @@ -16,6 +18,42 @@ function makeBackupStamp() { return new Date().toISOString().replace(/[:.]/g, '-'); } +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'pg-admin-backup-')); +} + +function cleanupDir(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options); + const stdout = []; + const stderr = []; + + if (child.stdout) { + child.stdout.on('data', (chunk) => stdout.push(chunk)); + } + if (child.stderr) { + child.stderr.on('data', (chunk) => stderr.push(chunk)); + } + + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(Buffer.concat(stderr).toString('utf8') || `${command} exited with code ${code}`)); + return; + } + + resolve({ + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }); + }); + }); +} + async function collectAppSnapshot(pool, actor = 'system') { const tablesResult = await pool.query(` SELECT table_name @@ -49,7 +87,7 @@ async function collectAppSnapshot(pool, actor = 'system') { meta: { createdAt: new Date().toISOString(), createdBy: actor, - version: 1, + version: 2, }, users: fs.existsSync(USERS_FILE) ? JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')) : { users: [] }, settings: fs.existsSync(SETTINGS_FILE) ? JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) : null, @@ -60,60 +98,72 @@ async function collectAppSnapshot(pool, actor = 'system') { }; } +function createArchive(tempDir, archivePath, fileNames) { + return runCommand('tar', ['-czf', archivePath, '-C', tempDir, ...fileNames], { + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function extractArchive(archivePath, tempDir) { + return runCommand('tar', ['-xzf', archivePath, '-C', tempDir], { + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + function runPgDump() { - return new Promise((resolve, reject) => { - const args = [ - '-h', process.env.DB_HOST || 'db', - '-p', String(process.env.DB_PORT || '5432'), - '-U', process.env.DB_USER || 'postgres', - '-d', process.env.DB_NAME || 'postgres', - '--clean', - '--if-exists', - '--no-owner', - '--no-privileges', - ]; + const args = [ + '-h', process.env.DB_HOST || 'db', + '-p', String(process.env.DB_PORT || '5432'), + '-U', process.env.DB_USER || 'postgres', + '-d', process.env.DB_NAME || 'postgres', + '--clean', + '--if-exists', + '--no-owner', + '--no-privileges', + ]; - const child = spawn('pg_dump', args, { - env: { - ...process.env, - PGPASSWORD: process.env.DB_PASSWORD || '', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); + return runCommand('pg_dump', args, { + env: { + ...process.env, + PGPASSWORD: process.env.DB_PASSWORD || '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }).then((result) => result.stdout); +} - const stdout = []; - const stderr = []; - child.stdout.on('data', (chunk) => stdout.push(chunk)); - child.stderr.on('data', (chunk) => stderr.push(chunk)); - child.on('error', reject); - child.on('close', (code) => { - if (code !== 0) { - reject(new Error(Buffer.concat(stderr).toString('utf8') || `pg_dump exited with code ${code}`)); - return; - } - resolve(Buffer.concat(stdout).toString('utf8')); - }); +function runPsqlFile(filePath) { + const args = [ + '-h', process.env.DB_HOST || 'db', + '-p', String(process.env.DB_PORT || '5432'), + '-U', process.env.DB_USER || 'postgres', + '-d', process.env.DB_NAME || 'postgres', + '-v', 'ON_ERROR_STOP=1', + '-f', filePath, + ]; + + return runCommand('psql', args, { + env: { + ...process.env, + PGPASSWORD: process.env.DB_PASSWORD || '', + }, + stdio: ['ignore', 'pipe', 'pipe'], }); } function formatBackupEntry(filePath, filename) { const stats = fs.statSync(filePath); - const kind = filename.endsWith('.sql') ? 'database' : 'application'; - const match = filename.match(/^backup-(.+?)-(db|app)\.(sql|json)$/); return { filename, size: stats.size, createdAt: stats.birthtime.toISOString(), - kind, - bundle: match ? match[1] : null, + kind: 'archive', }; } function pruneBackups(keepLast = 14) { ensureBackupsDir(); - const maxFiles = Math.max(1, keepLast) * 2; const files = fs.readdirSync(BACKUPS_DIR) - .filter((name) => name.startsWith(BACKUP_PREFIX) && (name.endsWith('.json') || name.endsWith('.sql'))) + .filter((name) => name.startsWith(BACKUP_PREFIX) && name.endsWith(BACKUP_EXTENSION)) .map((name) => ({ name, filePath: path.join(BACKUPS_DIR, name), @@ -121,46 +171,49 @@ function pruneBackups(keepLast = 14) { })) .sort((a, b) => b.mtimeMs - a.mtimeMs); - files.slice(maxFiles).forEach((file) => { + files.slice(Math.max(1, keepLast)).forEach((file) => { fs.unlinkSync(file.filePath); }); } async function createBackup(pool, actor = 'system', options = {}) { ensureBackupsDir(); + + const tempDir = makeTempDir(); const stamp = makeBackupStamp(); - const createdAt = new Date().toISOString(); - const includeAppSnapshot = options.includeAppSnapshot !== false; - const files = []; + const archiveFilename = `${BACKUP_PREFIX}${stamp}${BACKUP_EXTENSION}`; + const archivePath = path.join(BACKUPS_DIR, archiveFilename); + const fileNames = []; - const sqlDump = await runPgDump(); - const sqlFilename = `${BACKUP_PREFIX}${stamp}-db.sql`; - const sqlPath = path.join(BACKUPS_DIR, sqlFilename); - fs.writeFileSync(sqlPath, sqlDump, 'utf8'); - files.push(formatBackupEntry(sqlPath, sqlFilename)); + try { + const sqlDump = await runPgDump(); + const sqlFilename = 'database.sql'; + fs.writeFileSync(path.join(tempDir, sqlFilename), sqlDump, 'utf8'); + fileNames.push(sqlFilename); - if (includeAppSnapshot) { - const snapshot = await collectAppSnapshot(pool, actor); - const jsonFilename = `${BACKUP_PREFIX}${stamp}-app.json`; - const jsonPath = path.join(BACKUPS_DIR, jsonFilename); - fs.writeFileSync(jsonPath, JSON.stringify(snapshot, null, 2), 'utf8'); - files.push(formatBackupEntry(jsonPath, jsonFilename)); + if (options.includeAppSnapshot !== false) { + const snapshot = await collectAppSnapshot(pool, actor); + const jsonFilename = 'application.json'; + fs.writeFileSync(path.join(tempDir, jsonFilename), JSON.stringify(snapshot, null, 2), 'utf8'); + fileNames.push(jsonFilename); + } + + await createArchive(tempDir, archivePath, fileNames); + + if (options.keepLast) { + pruneBackups(options.keepLast); + } + + return formatBackupEntry(archivePath, archiveFilename); + } finally { + cleanupDir(tempDir); } - - if (options.keepLast) { - pruneBackups(options.keepLast); - } - - return { - createdAt, - files, - }; } function listBackups() { ensureBackupsDir(); return fs.readdirSync(BACKUPS_DIR) - .filter((name) => name.startsWith(BACKUP_PREFIX) && (name.endsWith('.json') || name.endsWith('.sql'))) + .filter((name) => name.startsWith(BACKUP_PREFIX) && name.endsWith(BACKUP_EXTENSION)) .map((name) => formatBackupEntry(path.join(BACKUPS_DIR, name), name)) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } @@ -173,10 +226,55 @@ function getBackupPath(filename) { return filePath; } +async function restoreBackup(filename, options = {}) { + ensureBackupsDir(); + const archivePath = getBackupPath(filename); + const tempDir = makeTempDir(); + + try { + await extractArchive(archivePath, tempDir); + + const sqlPath = path.join(tempDir, 'database.sql'); + const jsonPath = path.join(tempDir, 'application.json'); + + if (!fs.existsSync(sqlPath)) { + throw new Error('Archive does not contain database.sql'); + } + + await runPsqlFile(sqlPath); + + let restoredAppSnapshot = false; + if (options.restoreAppSnapshot !== false && fs.existsSync(jsonPath)) { + const snapshot = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + + if (snapshot.users) { + fs.writeFileSync(USERS_FILE, JSON.stringify(snapshot.users, null, 2), 'utf8'); + } + if (snapshot.settings) { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(snapshot.settings, null, 2), 'utf8'); + } + if (Array.isArray(snapshot.audit)) { + fs.writeFileSync(AUDIT_LOG_FILE, `${snapshot.audit.join('\n')}${snapshot.audit.length ? '\n' : ''}`, 'utf8'); + } + + restoredAppSnapshot = true; + } + + return { + filename, + restoredDatabase: true, + restoredAppSnapshot, + }; + } finally { + cleanupDir(tempDir); + } +} + module.exports = { BACKUPS_DIR, createBackup, getBackupPath, listBackups, pruneBackups, + restoreBackup, };