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
+
`).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.
+
+
+
+
+
+
+
System settings
+
Telegram, automatic backups and retention are configured here.
+
+
+
+
Automatic backups
+
The panel creates a database archive and, optionally, a site snapshot every day.
+
+
+
+
+
+
+
Telegram notifications
+
Used for backup errors, restore events and scheduled notifications.
+
+
+
+
+
+ Save settings
+
+
+
+
+
+
Backups
+
Archives contain the SQL dump and, if enabled, the application snapshot.
+
+
Create archive
+
+
+
+
+
+
+
+
+
Users
+
Create accounts and tune access to folders, tables and destructive actions.
+
+
+
+
+ | Username |
+ Role |
+ Status |
+ Actions |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Choose folders
+
+
+
+
+
+
+ Choose tables
+
+
+
+
+
+
+ Choose folders
+
+
+
+
+
+ Reset
+ Save user
+
+
+
+
+
+
+
+
Audit log
+
Human-readable actions with time, source and actor.
+
+
Refresh
+
+
+
+
+
+
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,
};