From d935b7374dbe4c041972a2ad6b8ccdbb70e266b0 Mon Sep 17 00:00:00 2001 From: Verum Date: Fri, 20 Mar 2026 16:30:45 +0700 Subject: [PATCH] 123 --- .env.example | 3 + .gitignore | 1 + Dockerfile | 3 + docker-compose.yml | 3 + public/assets/app.js | 69 +++++++++++++++++- public/index.html | 67 ++++++++++++++++++ server.js | 79 ++++++++++++++++++++- src/lib/audit.js | 4 +- src/services/backups.js | 126 +++++++++++++++++++++++++++------ src/services/notifications.js | 26 ++++++- src/services/runtime-config.js | 88 +++++++++++++++++++++++ 11 files changed, 438 insertions(+), 31 deletions(-) create mode 100644 src/services/runtime-config.js diff --git a/.env.example b/.env.example index dd23ec7..234a0b4 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ SESSION_SECRET=change_me_to_long_random_secret ENABLE_TELEGRAM_NOTIFICATIONS=false TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= + +# Optional container timezone for scheduler/logs +TZ=UTC diff --git a/.gitignore b/.gitignore index e6dd91c..a798117 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,4 @@ temp/ .cache/ audit.log backups/ +settings.json diff --git a/Dockerfile b/Dockerfile index c597732..2498b5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,9 @@ FROM node:20-alpine # Рабочая директория WORKDIR /app +# pg_dump for database backups +RUN apk add --no-cache postgresql-client + # Копируем package.json COPY package*.json ./ diff --git a/docker-compose.yml b/docker-compose.yml index effaa7c..7a56db4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,9 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro + - ./backups:/app/backups + - ./audit.log:/app/audit.log + - ./settings.json:/app/settings.json depends_on: db: diff --git a/public/assets/app.js b/public/assets/app.js index 1900041..f735b13 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -10,6 +10,7 @@ this.currentContainer = ''; this.logStream = null; this.logsBuffer = []; + this.currentSettings = null; this.currentPage = 1; this.limit = 10; this.editingRecord = null; @@ -152,6 +153,7 @@ 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.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none'; document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none'; @@ -1226,6 +1228,11 @@ document.getElementById('backupsModal').classList.remove('hidden'); } + async showSettingsModal() { + await this.loadSettings(); + document.getElementById('settingsModal').classList.remove('hidden'); + } + async loadBackups() { try { const response = await fetch('/api/backups'); @@ -1239,7 +1246,7 @@
${backup.filename}
-
${backup.createdAt} · ${backup.size} bytes
+
${backup.createdAt} - ${backup.kind} - ${backup.size} bytes
Download
@@ -1267,6 +1274,66 @@ } } + async loadSettings() { + try { + const response = await fetch('/api/settings'); + const settings = await response.json(); + if (!response.ok) { + throw new Error(settings.error || 'Failed to load settings'); + } + + 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 || ''; + } catch (err) { + this.showToast(err.message, 'error'); + } + } + + async saveSettings() { + const [hour, minute] = (document.getElementById('settingsBackupTime').value || '03:00').split(':').map(Number); + const payload = { + backups: { + enabled: document.getElementById('settingsBackupsEnabled').checked, + hour, + minute, + keepLast: Number(document.getElementById('settingsKeepLast').value || 14), + includeAppSnapshot: document.getElementById('settingsIncludeAppSnapshot').checked, + }, + telegram: { + enabled: document.getElementById('settingsTelegramEnabled').checked, + botToken: document.getElementById('settingsTelegramToken').value.trim(), + chatId: document.getElementById('settingsTelegramChatId').value.trim(), + }, + }; + + try { + const response = await fetch('/api/settings', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Request-Source': 'WEB', + }, + body: JSON.stringify(payload), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || 'Failed to save settings'); + } + + this.currentSettings = result.settings; + this.showToast('Settings saved', 'success'); + this.closeModal('settingsModal'); + } catch (err) { + this.showToast(err.message, 'error'); + } + } + async loadAuditLog() { try { const response = await fetch('/api/audit'); diff --git a/public/index.html b/public/index.html index 45dea56..11de856 100644 --- a/public/index.html +++ b/public/index.html @@ -74,6 +74,10 @@ Backups + + +
+
+
+

Automatic backups

+

Daily SQL dump of PostgreSQL plus optional application snapshot.

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+

Telegram notifications

+

Used for backup errors and important service events.

+
+ +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+ + +
diff --git a/server.js b/server.js index 3444c48..a216f4e 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const { Pool } = require('pg'); const session = require('express-session'); const cors = require('cors'); const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); const { ALLOWED_SQL_TYPES, canAccessFolder, @@ -34,10 +35,17 @@ const { createBackup, getBackupPath, listBackups, + pruneBackups, } = require('./src/services/backups'); const { notifyError, + notifyInfo, } = require('./src/services/notifications'); +const { + getSettings, + saveSettings, + validateSettings, +} = require('./src/services/runtime-config'); const app = express(); @@ -103,6 +111,45 @@ function applyRecordMetadata(structure, payload, currentUser, { isCreate = false return data; } +let lastAutoBackupSlot = ''; + +async function runScheduledBackupIfNeeded() { + const settings = getSettings(); + if (!settings.backups.enabled) { + return; + } + + const now = new Date(); + if (now.getHours() !== settings.backups.hour || now.getMinutes() !== settings.backups.minute) { + return; + } + + const slot = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${String(settings.backups.hour).padStart(2, '0')}-${String(settings.backups.minute).padStart(2, '0')}`; + if (lastAutoBackupSlot === slot) { + return; + } + + const backup = await createBackup(pool, 'system', { + includeAppSnapshot: settings.backups.includeAppSnapshot, + keepLast: settings.backups.keepLast, + }); + + lastAutoBackupSlot = slot; + appendAudit('backup.auto_created', 'system', { + files: backup.files, + source: 'WEB', + }); + notifyInfo('Scheduled backup completed', backup.files.map((file) => `${file.kind}: ${file.filename}`)).catch(() => {}); +} + +function startBackupScheduler() { + setInterval(() => { + runScheduledBackupIfNeeded().catch((error) => { + notifyError('Scheduled backup failed', error).catch(() => {}); + }); + }, 60000); +} + // Helper: get primary key column for a table (returns null if none) async function getPrimaryKeyColumn(tableName) { const result = await pool.query(` @@ -353,8 +400,12 @@ app.post('/api/backups', requireAuth, requirePermission( 'Backup access denied' ), async (req, res) => { try { - const backup = await createBackup(pool, req.currentUser.username); - appendAudit('backup.created', req.currentUser.username, { filename: backup.filename, source: getAuditSource(req) }); + const settings = getSettings(); + const backup = await createBackup(pool, req.currentUser.username, { + includeAppSnapshot: settings.backups.includeAppSnapshot, + keepLast: settings.backups.keepLast, + }); + appendAudit('backup.created', req.currentUser.username, { files: backup.files, source: getAuditSource(req) }); res.json({ success: true, backup }); } catch (err) { notifyError('Backup creation failed', err, { actor: req.currentUser.username }).catch(() => {}); @@ -362,6 +413,28 @@ app.post('/api/backups', requireAuth, requirePermission( } }); +app.get('/api/settings', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'Settings access denied' +), (req, res) => { + res.json(getSettings()); +}); + +app.put('/api/settings', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'Settings access denied' +), (req, res) => { + try { + const settings = validateSettings(req.body); + const saved = saveSettings(settings); + pruneBackups(saved.backups.keepLast); + appendAudit('settings.updated', req.currentUser.username, { source: getAuditSource(req) }); + res.json({ success: true, settings: saved }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + app.get('/api/backups/:filename/download', requireAuth, requirePermission( (permissions) => permissions.canManageUsers, 'Backup access denied' @@ -943,4 +1016,4 @@ app.listen(PORT, () => { console.log('📝 Make sure to configure your database in .env file'); }); - +startBackupScheduler(); diff --git a/src/lib/audit.js b/src/lib/audit.js index 362c53b..1a4de11 100644 --- a/src/lib/audit.js +++ b/src/lib/audit.js @@ -11,7 +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 || ''}`.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(), + 'settings.updated': 'updated system settings', 'table.created': `created table ${details.table || ''}`.trim(), 'table.deleted': `deleted table ${details.table || ''}`.trim(), 'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(), diff --git a/src/services/backups.js b/src/services/backups.js index c102528..db420fc 100644 --- a/src/services/backups.js +++ b/src/services/backups.js @@ -1,22 +1,22 @@ const fs = require('fs'); const path = require('path'); +const { spawn } = require('child_process'); const BACKUPS_DIR = path.join(__dirname, '..', '..', 'backups'); 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-'; function ensureBackupsDir() { fs.mkdirSync(BACKUPS_DIR, { recursive: true }); } -function makeBackupFilename() { - const now = new Date().toISOString().replace(/[:.]/g, '-'); - return `backup-${now}.json`; +function makeBackupStamp() { + return new Date().toISOString().replace(/[:.]/g, '-'); } -async function createBackup(pool, actor = 'system') { - ensureBackupsDir(); - +async function collectAppSnapshot(pool, actor = 'system') { const tablesResult = await pool.query(` SELECT table_name FROM information_schema.tables @@ -45,44 +45,123 @@ async function createBackup(pool, actor = 'system') { }); } - const backup = { + return { meta: { createdAt: new Date().toISOString(), createdBy: actor, version: 1, }, 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, audit: fs.existsSync(AUDIT_LOG_FILE) ? fs.readFileSync(AUDIT_LOG_FILE, 'utf8').split(/\r?\n/).filter(Boolean) : [], tables, }; +} - const filename = makeBackupFilename(); - const filePath = path.join(BACKUPS_DIR, filename); - fs.writeFileSync(filePath, JSON.stringify(backup, null, 2), 'utf8'); +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 child = spawn('pg_dump', args, { + env: { + ...process.env, + PGPASSWORD: process.env.DB_PASSWORD || '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + 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 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, - filePath, - size: fs.statSync(filePath).size, - createdAt: backup.meta.createdAt, + size: stats.size, + createdAt: stats.birthtime.toISOString(), + kind, + bundle: match ? match[1] : null, + }; +} + +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'))) + .map((name) => ({ + name, + filePath: path.join(BACKUPS_DIR, name), + mtimeMs: fs.statSync(path.join(BACKUPS_DIR, name)).mtimeMs, + })) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + files.slice(maxFiles).forEach((file) => { + fs.unlinkSync(file.filePath); + }); +} + +async function createBackup(pool, actor = 'system', options = {}) { + ensureBackupsDir(); + const stamp = makeBackupStamp(); + const createdAt = new Date().toISOString(); + const includeAppSnapshot = options.includeAppSnapshot !== false; + const files = []; + + 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)); + + 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.keepLast) { + pruneBackups(options.keepLast); + } + + return { + createdAt, + files, }; } function listBackups() { ensureBackupsDir(); return fs.readdirSync(BACKUPS_DIR) - .filter((name) => name.endsWith('.json')) - .map((name) => { - const filePath = path.join(BACKUPS_DIR, name); - const stats = fs.statSync(filePath); - return { - filename: name, - size: stats.size, - createdAt: stats.birthtime.toISOString(), - }; - }) + .filter((name) => name.startsWith(BACKUP_PREFIX) && (name.endsWith('.json') || name.endsWith('.sql'))) + .map((name) => formatBackupEntry(path.join(BACKUPS_DIR, name), name)) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } @@ -99,4 +178,5 @@ module.exports = { createBackup, getBackupPath, listBackups, + pruneBackups, }; diff --git a/src/services/notifications.js b/src/services/notifications.js index f6ab0ab..dc5595a 100644 --- a/src/services/notifications.js +++ b/src/services/notifications.js @@ -1,7 +1,16 @@ +const { getSettings } = require('./runtime-config'); + +function getTelegramConfig() { + const settings = getSettings(); + return { + enabled: settings.telegram.enabled, + token: settings.telegram.botToken || process.env.TELEGRAM_BOT_TOKEN, + chatId: settings.telegram.chatId || process.env.TELEGRAM_CHAT_ID, + }; +} + async function sendTelegramMessage(text) { - const enabled = process.env.ENABLE_TELEGRAM_NOTIFICATIONS === 'true'; - const token = process.env.TELEGRAM_BOT_TOKEN; - const chatId = process.env.TELEGRAM_CHAT_ID; + const { enabled, token, chatId } = getTelegramConfig(); if (!enabled || !token || !chatId || !text) { return { sent: false, reason: 'disabled' }; @@ -37,7 +46,18 @@ async function notifyError(title, error, context = {}) { return sendTelegramMessage(message); } +async function notifyInfo(title, details = []) { + const message = [ + 'PG Admin', + title, + ...details.filter(Boolean), + ].join('\n'); + + return sendTelegramMessage(message); +} + module.exports = { notifyError, + notifyInfo, sendTelegramMessage, }; diff --git a/src/services/runtime-config.js b/src/services/runtime-config.js new file mode 100644 index 0000000..dda7dea --- /dev/null +++ b/src/services/runtime-config.js @@ -0,0 +1,88 @@ +const fs = require('fs'); +const path = require('path'); + +const SETTINGS_FILE = path.join(__dirname, '..', '..', 'settings.json'); + +const DEFAULT_SETTINGS = { + backups: { + enabled: true, + hour: 3, + minute: 0, + keepLast: 14, + includeAppSnapshot: true, + }, + telegram: { + enabled: process.env.ENABLE_TELEGRAM_NOTIFICATIONS === 'true', + botToken: process.env.TELEGRAM_BOT_TOKEN || '', + chatId: process.env.TELEGRAM_CHAT_ID || '', + }, +}; + +function mergeSettings(base, overrides) { + return { + backups: { + ...base.backups, + ...(overrides?.backups || {}), + }, + telegram: { + ...base.telegram, + ...(overrides?.telegram || {}), + }, + }; +} + +function getSettings() { + if (!fs.existsSync(SETTINGS_FILE)) { + return DEFAULT_SETTINGS; + } + + try { + const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); + return mergeSettings(DEFAULT_SETTINGS, parsed); + } catch (error) { + return DEFAULT_SETTINGS; + } +} + +function saveSettings(nextSettings) { + const merged = mergeSettings(DEFAULT_SETTINGS, nextSettings); + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2), 'utf8'); + return merged; +} + +function validateSettings(payload = {}) { + const hour = Number(payload?.backups?.hour ?? DEFAULT_SETTINGS.backups.hour); + const minute = Number(payload?.backups?.minute ?? DEFAULT_SETTINGS.backups.minute); + const keepLast = Number(payload?.backups?.keepLast ?? DEFAULT_SETTINGS.backups.keepLast); + + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + throw new Error('Backup hour must be between 0 and 23'); + } + if (!Number.isInteger(minute) || minute < 0 || minute > 59) { + throw new Error('Backup minute must be between 0 and 59'); + } + if (!Number.isInteger(keepLast) || keepLast < 1 || keepLast > 90) { + throw new Error('Keep last must be between 1 and 90'); + } + + return { + backups: { + enabled: Boolean(payload?.backups?.enabled), + hour, + minute, + keepLast, + includeAppSnapshot: payload?.backups?.includeAppSnapshot !== false, + }, + telegram: { + enabled: Boolean(payload?.telegram?.enabled), + botToken: String(payload?.telegram?.botToken || '').trim(), + chatId: String(payload?.telegram?.chatId || '').trim(), + }, + }; +} + +module.exports = { + getSettings, + saveSettings, + validateSettings, +};