This commit is contained in:
2026-03-20 16:30:45 +07:00
parent e51f34d7be
commit d935b7374d
11 changed files with 438 additions and 31 deletions

View File

@@ -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(),

View File

@@ -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,
};

View File

@@ -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 = [
'<b>PG Admin</b>',
title,
...details.filter(Boolean),
].join('\n');
return sendTelegramMessage(message);
}
module.exports = {
notifyError,
notifyInfo,
sendTelegramMessage,
};

View File

@@ -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,
};