123
This commit is contained in:
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -166,3 +166,4 @@ temp/
|
||||
.cache/
|
||||
audit.log
|
||||
backups/
|
||||
settings.json
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 @@
|
||||
<div class="border border-slate-200 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="font-medium text-slate-800">${backup.filename}</div>
|
||||
<div class="text-sm text-slate-500">${backup.createdAt} · ${backup.size} bytes</div>
|
||||
<div class="text-sm text-slate-500">${backup.createdAt} - ${backup.kind} - ${backup.size} bytes</div>
|
||||
</div>
|
||||
<a href="/api/backups/${encodeURIComponent(backup.filename)}/download" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Download</a>
|
||||
</div>
|
||||
@@ -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');
|
||||
|
||||
@@ -74,6 +74,10 @@
|
||||
<i data-lucide="archive" class="w-4 h-4"></i>
|
||||
Backups
|
||||
</button>
|
||||
<button id="settingsButton" onclick="app.showSettingsModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||
<i data-lucide="sliders-horizontal" class="w-4 h-4"></i>
|
||||
Settings
|
||||
</button>
|
||||
<button id="auditButton" onclick="app.showAuditModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||
<i data-lucide="history" class="w-4 h-4"></i>
|
||||
Audit
|
||||
@@ -593,6 +597,69 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settingsModal" class="hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col fade-in">
|
||||
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold text-slate-800">System settings</h3>
|
||||
<button onclick="app.closeModal('settingsModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
|
||||
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-auto space-y-6">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-slate-800">Automatic backups</h4>
|
||||
<p class="text-sm text-slate-500">Daily SQL dump of PostgreSQL plus optional application snapshot.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" id="settingsBackupsEnabled" class="w-4 h-4">
|
||||
Enable automatic backups
|
||||
</label>
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Time</label>
|
||||
<input type="time" id="settingsBackupTime" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Keep last days</label>
|
||||
<input type="number" id="settingsKeepLast" min="1" max="90" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" id="settingsIncludeAppSnapshot" class="w-4 h-4">
|
||||
Include site settings and audit snapshot
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-slate-800">Telegram notifications</h4>
|
||||
<p class="text-sm text-slate-500">Used for backup errors and important service events.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" id="settingsTelegramEnabled" class="w-4 h-4">
|
||||
Enable Telegram notifications
|
||||
</label>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Bot token</label>
|
||||
<input type="text" id="settingsTelegramToken" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="123456:ABC...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Chat ID</label>
|
||||
<input type="text" id="settingsTelegramChatId" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="-1001234567890">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
|
||||
<button onclick="app.closeModal('settingsModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors">Cancel</button>
|
||||
<button onclick="app.saveSettings()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">Save settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
|
||||
79
server.js
79
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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
88
src/services/runtime-config.js
Normal file
88
src/services/runtime-config.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user