backu
This commit is contained in:
453
modules/backup/index.js
Normal file
453
modules/backup/index.js
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const cron = require('node-cron');
|
||||||
|
|
||||||
|
let db;
|
||||||
|
let backupDir;
|
||||||
|
let dbPath;
|
||||||
|
let cronJob = null;
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
backup_auto_enabled: 'true',
|
||||||
|
backup_auto_time: '03:00',
|
||||||
|
backup_retention_days: '30'
|
||||||
|
};
|
||||||
|
|
||||||
|
function init(database, mainDbPath) {
|
||||||
|
db = database;
|
||||||
|
dbPath = mainDbPath;
|
||||||
|
backupDir = path.join(path.dirname(mainDbPath), 'backups');
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupDir)) {
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
initTable();
|
||||||
|
initDefaultSettings();
|
||||||
|
startScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTable() {
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS backups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
type TEXT DEFAULT 'manual',
|
||||||
|
restored_at DATETIME
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDefaultSettings() {
|
||||||
|
Object.entries(DEFAULT_SETTINGS).forEach(([key, value]) => {
|
||||||
|
db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, [key, value], (err) => {
|
||||||
|
if (err) console.error('Backup settings init error:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackupFilename() {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const h = String(now.getHours()).padStart(2, '0');
|
||||||
|
const min = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
return `hotel_${y}-${m}-${d}_${h}-${min}-${s}.db`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackup(type = 'manual') {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const filename = createBackupFilename();
|
||||||
|
const backupPath = path.join(backupDir, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
|
const stats = fs.statSync(backupPath);
|
||||||
|
const size = stats.size;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO backups (filename, size, type) VALUES (?, ?, ?)`,
|
||||||
|
[filename, size, type],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('[BACKUP] Database insert error:', err);
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BACKUP] Created backup: ${filename} (${formatBytes(size)}) [${type}]`);
|
||||||
|
|
||||||
|
cleanupOldBackups((cleanupErr) => {
|
||||||
|
if (cleanupErr) {
|
||||||
|
console.warn('[BACKUP] Cleanup warning:', cleanupErr.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
id: this.lastID,
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
type,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BACKUP] Create backup error:', err);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBackups(callback) {
|
||||||
|
db.all(`SELECT * FROM backups ORDER BY created_at DESC`, [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[BACKUP] List backups error:', err);
|
||||||
|
return callback(err);
|
||||||
|
}
|
||||||
|
callback(null, rows);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupById(id, callback) {
|
||||||
|
db.get(`SELECT * FROM backups WHERE id = ?`, [id], (err, row) => {
|
||||||
|
if (err) return callback(err);
|
||||||
|
if (!row) return callback(new Error('Backup not found'));
|
||||||
|
callback(null, row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBackup(id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
getBackupById(id, (err, backup) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
const backupPath = path.join(backupDir, backup.filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
fs.unlinkSync(backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`DELETE FROM backups WHERE id = ?`, [id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[BACKUP] Delete from DB error:', err);
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
console.log(`[BACKUP] Deleted backup: ${backup.filename}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} catch (fsErr) {
|
||||||
|
console.error('[BACKUP] Delete file error:', fsErr);
|
||||||
|
reject(fsErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreBackup(id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
getBackupById(id, (err, backup) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
const backupPath = path.join(backupDir, backup.filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
return reject(new Error('Backup file not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BACKUP] Starting restore from: ${backup.filename}`);
|
||||||
|
|
||||||
|
createBackup('emergency_before_restore')
|
||||||
|
.then((emergencyBackup) => {
|
||||||
|
console.log(`[BACKUP] Emergency backup created: ${emergencyBackup.filename}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tempPath = dbPath + '.tmp';
|
||||||
|
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
fs.copyFileSync(dbPath, tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(backupPath, dbPath);
|
||||||
|
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE backups SET restored_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
[id],
|
||||||
|
(updateErr) => {
|
||||||
|
if (updateErr) {
|
||||||
|
console.warn('[BACKUP] Update restored_at error:', updateErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[BACKUP] Restore completed successfully`);
|
||||||
|
resolve({
|
||||||
|
message: 'Backup restored successfully',
|
||||||
|
restored_backup: backup.filename,
|
||||||
|
emergency_backup: emergencyBackup.filename
|
||||||
|
});
|
||||||
|
} catch (copyErr) {
|
||||||
|
console.error('[BACKUP] Restore copy error:', copyErr);
|
||||||
|
|
||||||
|
const tempPath = dbPath + '.tmp';
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(tempPath, dbPath);
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
} catch (restoreErr) {
|
||||||
|
console.error('[BACKUP] Rollback failed:', restoreErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(copyErr);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((emergencyErr) => {
|
||||||
|
console.error('[BACKUP] Emergency backup failed, aborting restore:', emergencyErr);
|
||||||
|
reject(new Error('Failed to create emergency backup, restore aborted'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupOldBackups(callback) {
|
||||||
|
db.get(`SELECT value FROM settings WHERE key = 'backup_retention_days'`, [], (err, row) => {
|
||||||
|
if (err || !row) {
|
||||||
|
return callback(err || new Error('Retention days not set'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const retentionDays = parseInt(row.value, 10) || 30;
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||||
|
const cutoffStr = cutoffDate.toISOString();
|
||||||
|
|
||||||
|
db.all(`SELECT * FROM backups WHERE created_at < ?`, [cutoffStr], (err, oldBackups) => {
|
||||||
|
if (err) return callback(err);
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
if (oldBackups.length === 0) return callback(null, 0);
|
||||||
|
|
||||||
|
oldBackups.forEach((backup) => {
|
||||||
|
const backupPath = path.join(backupDir, backup.filename);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
fs.unlinkSync(backupPath);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[BACKUP] Could not delete ${backup.filename}:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`DELETE FROM backups WHERE id = ?`, [backup.id], (delErr) => {
|
||||||
|
if (delErr) console.warn(`[BACKUP] DB delete error for ${backup.id}:`, delErr);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleted > 0) {
|
||||||
|
console.log(`[BACKUP] Cleaned up ${deleted} old backup(s)`);
|
||||||
|
}
|
||||||
|
callback(null, deleted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startScheduler() {
|
||||||
|
db.get(`SELECT value FROM settings WHERE key = 'backup_auto_enabled'`, [], (err, row) => {
|
||||||
|
const enabled = !err && row && row.value === 'true';
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
console.log('[BACKUP] Auto backup is disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleBackup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBackup() {
|
||||||
|
if (cronJob) {
|
||||||
|
cronJob.stop();
|
||||||
|
cronJob = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`SELECT value FROM settings WHERE key = 'backup_auto_time'`, [], (err, row) => {
|
||||||
|
const time = (!err && row) ? row.value : '03:00';
|
||||||
|
const [hours, minutes] = time.split(':');
|
||||||
|
|
||||||
|
const cronExpression = `${minutes} ${hours} * * *`;
|
||||||
|
|
||||||
|
cronJob = cron.schedule(cronExpression, async () => {
|
||||||
|
console.log('[BACKUP] Running scheduled backup...');
|
||||||
|
try {
|
||||||
|
const result = await createBackup('auto');
|
||||||
|
console.log(`[BACKUP] Scheduled backup completed: ${result.filename}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BACKUP] Scheduled backup failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[BACKUP] Scheduler started: daily at ${time}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSchedulerSettings() {
|
||||||
|
db.get(`SELECT value FROM settings WHERE key = 'backup_auto_enabled'`, [], (err, row) => {
|
||||||
|
const enabled = !err && row && row.value === 'true';
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
if (cronJob) {
|
||||||
|
cronJob.stop();
|
||||||
|
cronJob = null;
|
||||||
|
console.log('[BACKUP] Scheduler stopped (disabled)');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleBackup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettings(callback) {
|
||||||
|
db.all(`SELECT key, value FROM settings WHERE key LIKE 'backup_%'`, [], (err, rows) => {
|
||||||
|
if (err) return callback(err);
|
||||||
|
const settings = {};
|
||||||
|
rows.forEach(row => { settings[row.key] = row.value; });
|
||||||
|
callback(null, settings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSettings(settings, callback) {
|
||||||
|
const entries = Object.entries(settings);
|
||||||
|
let completed = 0;
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
entries.forEach(([key, value]) => {
|
||||||
|
if (hasError) return;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[key, value, value],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[BACKUP] Settings update error:', err);
|
||||||
|
hasError = true;
|
||||||
|
callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
completed++;
|
||||||
|
if (completed === entries.length) {
|
||||||
|
updateSchedulerSettings();
|
||||||
|
callback(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRoutes(app, authenticateToken, requireAdmin) {
|
||||||
|
app.post('/api/admin/backup', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const backup = await createBackup('manual');
|
||||||
|
res.json({ success: true, backup });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BACKUP] API create backup error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create backup' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/backups', authenticateToken, requireAdmin, (req, res) => {
|
||||||
|
listBackups((err, backups) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[BACKUP] API list backups error:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to list backups' });
|
||||||
|
}
|
||||||
|
res.json({ backups });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/backups/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await deleteBackup(parseInt(req.params.id, 10));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BACKUP] API delete backup error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to delete backup' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/backups/:id/restore', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await restoreBackup(parseInt(req.params.id, 10));
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BACKUP] API restore backup error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to restore backup' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/backups/:id/download', authenticateToken, requireAdmin, (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
getBackupById(id, (err, backup) => {
|
||||||
|
if (err) return res.status(404).json({ error: 'Backup not found' });
|
||||||
|
|
||||||
|
const backupPath = path.join(backupDir, backup.filename);
|
||||||
|
if (!fs.existsSync(backupPath)) {
|
||||||
|
return res.status(404).json({ error: 'Backup file not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.download(backupPath, backup.filename);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/backup/settings', authenticateToken, requireAdmin, (req, res) => {
|
||||||
|
getSettings((err, settings) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to get settings' });
|
||||||
|
res.json(settings);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/backup/settings', authenticateToken, requireAdmin, (req, res) => {
|
||||||
|
const { backup_auto_enabled, backup_auto_time, backup_retention_days } = req.body;
|
||||||
|
|
||||||
|
const settings = {};
|
||||||
|
if (backup_auto_enabled !== undefined) settings.backup_auto_enabled = String(backup_auto_enabled);
|
||||||
|
if (backup_auto_time !== undefined) settings.backup_auto_time = backup_auto_time;
|
||||||
|
if (backup_retention_days !== undefined) settings.backup_retention_days = String(backup_retention_days);
|
||||||
|
|
||||||
|
if (Object.keys(settings).length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No settings provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(settings, (err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to update settings' });
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
createBackup,
|
||||||
|
listBackups,
|
||||||
|
deleteBackup,
|
||||||
|
restoreBackup,
|
||||||
|
getSettings,
|
||||||
|
updateSettings,
|
||||||
|
setupRoutes
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sqlite3": "^6.0.1"
|
"sqlite3": "^6.0.1"
|
||||||
|
|||||||
@@ -340,6 +340,62 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
|||||||
<h1>Настройки</h1>
|
<h1>Настройки</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div class="card-header-custom"><h3>Резервное копирование</h3></div>
|
||||||
|
<div class="card-body-custom">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="backupAutoEnabled" style="width: 50px; height: 24px; cursor: pointer;">
|
||||||
|
<label class="form-check-label fw-500 ms-2" for="backupAutoEnabled">Автоматическое резервное копирование</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Время запуска</label>
|
||||||
|
<input type="time" class="form-control" id="backupAutoTime" value="03:00" style="width: 130px;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Хранить (дней)</label>
|
||||||
|
<input type="number" class="form-control" id="backupRetentionDays" value="30" min="1" max="365" style="width: 100px;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-flex align-items-end">
|
||||||
|
<button class="btn-gold btn-sm" onclick="saveBackupSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-center justify-content-end">
|
||||||
|
<button class="btn-primary-custom" onclick="createManualBackup()" id="btnCreateBackup">
|
||||||
|
<i class="fas fa-download me-2"></i>Создать бекап сейчас
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3" style="background: #f8fafc; border: 1px solid #e2e8f0;">
|
||||||
|
<div class="card-body-custom">
|
||||||
|
<h4 class="mb-3" style="font-size: 1rem;"><i class="fas fa-history me-2"></i>История бекапов</h4>
|
||||||
|
<div style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Размер</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Восстановлен</th>
|
||||||
|
<th class="text-end">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="backupsTableBody">
|
||||||
|
<tr><td colspan="5" class="text-center text-muted">Загрузка...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
<div class="card-header-custom"><h3>Кодовое слово для отзывов</h3></div>
|
<div class="card-header-custom"><h3>Кодовое слово для отзывов</h3></div>
|
||||||
<div class="card-body-custom">
|
<div class="card-body-custom">
|
||||||
<p style="color: #64748b; margin-bottom: 20px;">
|
<p style="color: #64748b; margin-bottom: 20px;">
|
||||||
@@ -368,17 +424,6 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-profile" class="tab-content">
|
|
||||||
<div class="top-bar"><h1>Мой профиль</h1></div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body-custom">
|
|
||||||
<div class="profile-section">
|
|
||||||
<div class="profile-avatar" id="profileAvatar">—</div>
|
|
||||||
<div class="profile-info">
|
|
||||||
<h4 id="profileName">—</h4>
|
|
||||||
<p id="profileLogin">—</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<form id="profileForm">
|
<form id="profileForm">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
@@ -474,6 +519,34 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop-custom" id="restoreModal">
|
||||||
|
<div class="modal-custom">
|
||||||
|
<div class="modal-header-custom">
|
||||||
|
<h3><i class="fas fa-exclamation-triangle text-warning me-2"></i>Восстановление из бекапа</h3>
|
||||||
|
<button class="modal-close" onclick="hideRestoreModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body-custom">
|
||||||
|
<div class="alert alert-warning" style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 10px;">
|
||||||
|
<p class="mb-2"><strong>Внимание!</strong></p>
|
||||||
|
<p class="mb-2">Текущие данные будут заменены данными из бекапа.</p>
|
||||||
|
<p class="mb-0"><strong>Перед восстановлением будет создан аварийный бекап текущего состояния.</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="mb-1"><strong>Бэкап:</strong> <span id="restoreBackupFilename">—</span></p>
|
||||||
|
<p class="mb-1"><strong>Дата создания:</strong> <span id="restoreBackupDate">—</span></p>
|
||||||
|
<p class="mb-0"><strong>Размер:</strong> <span id="restoreBackupSize">—</span></p>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="restoreBackupId">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer-custom">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="hideRestoreModal()">Отмена</button>
|
||||||
|
<button type="button" class="btn-danger-custom" onclick="confirmRestore()" id="btnConfirmRestore">
|
||||||
|
<i class="fas fa-undo me-1"></i>Восстановить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-backdrop-custom" id="promocodeModal">
|
<div class="modal-backdrop-custom" id="promocodeModal">
|
||||||
<div class="modal-custom">
|
<div class="modal-custom">
|
||||||
<div class="modal-header-custom">
|
<div class="modal-header-custom">
|
||||||
@@ -1170,6 +1243,13 @@ async function loadSettings() {
|
|||||||
const display = document.getElementById('currentCodeDisplay');
|
const display = document.getElementById('currentCodeDisplay');
|
||||||
if (display) display.value = 'Ошибка загрузки';
|
if (display) display.value = 'Ошибка загрузки';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadBackupSettings();
|
||||||
|
await loadBackupsList();
|
||||||
|
} catch(err) {
|
||||||
|
console.error('Failed to load backup module:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('settingsForm').addEventListener('submit', async e => {
|
document.getElementById('settingsForm').addEventListener('submit', async e => {
|
||||||
@@ -1199,6 +1279,197 @@ function toggleCodeShow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup Functions
|
||||||
|
async function loadBackupSettings() {
|
||||||
|
try {
|
||||||
|
const settings = await api('/api/admin/backup/settings');
|
||||||
|
document.getElementById('backupAutoEnabled').checked = settings.backup_auto_enabled === 'true';
|
||||||
|
document.getElementById('backupAutoTime').value = settings.backup_auto_time || '03:00';
|
||||||
|
document.getElementById('backupRetentionDays').value = settings.backup_retention_days || '30';
|
||||||
|
} catch(err) {
|
||||||
|
console.error('Failed to load backup settings:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBackupSettings() {
|
||||||
|
const enabled = document.getElementById('backupAutoEnabled').checked;
|
||||||
|
const time = document.getElementById('backupAutoTime').value;
|
||||||
|
const retention = document.getElementById('backupRetentionDays').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/admin/backup/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
backup_auto_enabled: enabled,
|
||||||
|
backup_auto_time: time,
|
||||||
|
backup_retention_days: retention
|
||||||
|
})
|
||||||
|
});
|
||||||
|
showToast('Настройки резервного копирования сохранены');
|
||||||
|
} catch(err) {
|
||||||
|
showToast('Ошибка сохранения настроек: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManualBackup() {
|
||||||
|
const btn = document.getElementById('btnCreateBackup');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="loading-spinner"></span> Создание...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('/api/admin/backup', { method: 'POST' });
|
||||||
|
showToast('Бекап создан: ' + result.backup.filename);
|
||||||
|
loadBackupsList();
|
||||||
|
} catch(err) {
|
||||||
|
showToast('Ошибка создания бекапа: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackupsList() {
|
||||||
|
try {
|
||||||
|
const result = await api('/api/admin/backups');
|
||||||
|
const tbody = document.getElementById('backupsTableBody');
|
||||||
|
|
||||||
|
if (!result.backups || result.backups.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Нет бекапов</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = result.backups.map(b => {
|
||||||
|
const date = new Date(b.created_at);
|
||||||
|
const dateStr = date.toLocaleDateString('ru-RU') + ' ' + date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const sizeStr = formatBackupSize(b.size);
|
||||||
|
const typeLabel = b.type === 'auto' ? '<span class="badge" style="background: #dbeafe; color: #1e40af;">авто</span>' :
|
||||||
|
b.type === 'emergency_before_restore' ? '<span class="badge" style="background: #fef3c7; color: #92400e;">аварийный</span>' :
|
||||||
|
'<span class="badge" style="background: #dcfce7; color: #15803d;">ручной</span>';
|
||||||
|
const restoredStr = b.restored_at ? new Date(b.restored_at).toLocaleDateString('ru-RU') : '—';
|
||||||
|
|
||||||
|
return '<tr>' +
|
||||||
|
'<td style="white-space: nowrap;">' + esc(dateStr) + '</td>' +
|
||||||
|
'<td>' + sizeStr + '</td>' +
|
||||||
|
'<td>' + typeLabel + '</td>' +
|
||||||
|
'<td>' + esc(restoredStr) + '</td>' +
|
||||||
|
'<td class="text-end">' +
|
||||||
|
'<button class="btn btn-outline-secondary btn-sm me-1" onclick="downloadBackup(' + b.id + ')" title="Скачать"><i class="fas fa-download"></i></button>' +
|
||||||
|
'<button class="btn btn-outline-warning btn-sm me-1" onclick="showRestoreModal(' + b.id + ')" title="Восстановить"><i class="fas fa-undo"></i></button>' +
|
||||||
|
'<button class="btn-danger-custom btn-sm" onclick="deleteBackupConfirm(' + b.id + ')" title="Удалить"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
} catch(err) {
|
||||||
|
const tbody = document.getElementById('backupsTableBody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-danger">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBackupSize(bytes) {
|
||||||
|
if (!bytes) return '—';
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
let backupToDelete = null;
|
||||||
|
|
||||||
|
function deleteBackupConfirm(id) {
|
||||||
|
backupToDelete = id;
|
||||||
|
if (confirm('Удалить этот бекап?')) {
|
||||||
|
doDeleteBackup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDeleteBackup() {
|
||||||
|
if (!backupToDelete) return;
|
||||||
|
try {
|
||||||
|
await api('/api/admin/backups/' + backupToDelete, { method: 'DELETE' });
|
||||||
|
showToast('Бекап удалён');
|
||||||
|
loadBackupsList();
|
||||||
|
} catch(err) {
|
||||||
|
showToast('Ошибка удаления: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
backupToDelete = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBackup(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/backups/' + id + '/download', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Download failed');
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename="?(.+)"?/);
|
||||||
|
if (match) a.download = match[1];
|
||||||
|
}
|
||||||
|
if (!a.download) a.download = 'backup.db';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch(err) {
|
||||||
|
showToast('Ошибка скачивания: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let backupToRestore = null;
|
||||||
|
|
||||||
|
async function showRestoreModal(id) {
|
||||||
|
try {
|
||||||
|
const result = await api('/api/admin/backups');
|
||||||
|
const backup = result.backups.find(b => b.id === id);
|
||||||
|
if (!backup) {
|
||||||
|
showToast('Бекап не найден', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
backupToRestore = backup;
|
||||||
|
document.getElementById('restoreBackupFilename').textContent = backup.filename;
|
||||||
|
document.getElementById('restoreBackupDate').textContent = new Date(backup.created_at).toLocaleString('ru-RU');
|
||||||
|
document.getElementById('restoreBackupSize').textContent = formatBackupSize(backup.size);
|
||||||
|
document.getElementById('restoreModal').classList.add('show');
|
||||||
|
} catch(err) {
|
||||||
|
showToast('Ошибка загрузки данных бекапа', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideRestoreModal() {
|
||||||
|
document.getElementById('restoreModal').classList.remove('show');
|
||||||
|
backupToRestore = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRestore() {
|
||||||
|
if (!backupToRestore) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('btnConfirmRestore');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="loading-spinner"></span> Восстановление...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('/api/admin/backups/' + backupToRestore.id + '/restore', { method: 'POST' });
|
||||||
|
hideRestoreModal();
|
||||||
|
showToast('Восстановление завершено! Перезагрузите страницу.');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (confirm('Данные были восстановлены. Перезагрузить страницу?')) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} catch(err) {
|
||||||
|
showToast('Ошибка восстановления: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-undo me-1"></i>Восстановить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== ROOMS MODULE =====
|
// ===== ROOMS MODULE =====
|
||||||
|
|
||||||
const ROOM_TYPES_LIST = ['2x-местный', '3х-местный', 'Семейный', 'Люкс'];
|
const ROOM_TYPES_LIST = ['2x-местный', '3х-местный', 'Семейный', 'Люкс'];
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ const usersModule = require('./modules/users');
|
|||||||
const settingsModule = require('./modules/settings');
|
const settingsModule = require('./modules/settings');
|
||||||
const reviewsModule = require('./modules/reviews');
|
const reviewsModule = require('./modules/reviews');
|
||||||
const translationsModule = require('./modules/translations');
|
const translationsModule = require('./modules/translations');
|
||||||
|
const backupModule = require('./modules/backup');
|
||||||
const { runStartupTests } = require('./tests/runStartupTests');
|
const { runStartupTests } = require('./tests/runStartupTests');
|
||||||
|
|
||||||
modules.auth = authModule;
|
modules.auth = authModule;
|
||||||
@@ -452,6 +453,7 @@ modules.adminBookings = adminBookingsModule;
|
|||||||
modules.settings = settingsModule;
|
modules.settings = settingsModule;
|
||||||
modules.reviews = reviewsModule;
|
modules.reviews = reviewsModule;
|
||||||
modules.translations = translationsModule;
|
modules.translations = translationsModule;
|
||||||
|
modules.backup = backupModule;
|
||||||
|
|
||||||
authModule.init(db, JWT_SECRET);
|
authModule.init(db, JWT_SECRET);
|
||||||
bookingsModule.init(db);
|
bookingsModule.init(db);
|
||||||
@@ -461,6 +463,7 @@ roomsModule.init(db);
|
|||||||
usersModule.init(db, bcrypt);
|
usersModule.init(db, bcrypt);
|
||||||
settingsModule.init(db);
|
settingsModule.init(db);
|
||||||
reviewsModule.init(db, settingsModule);
|
reviewsModule.init(db, settingsModule);
|
||||||
|
backupModule.init(db, dbPath);
|
||||||
|
|
||||||
function initDefaultRooms() {
|
function initDefaultRooms() {
|
||||||
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
|
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
|
||||||
@@ -528,6 +531,7 @@ roomsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdm
|
|||||||
usersModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
usersModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||||
settingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
settingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||||
reviewsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
reviewsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||||
|
backupModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||||
|
|
||||||
app.get('/api/translations/:lang', (req, res) => {
|
app.get('/api/translations/:lang', (req, res) => {
|
||||||
const lang = req.params.lang;
|
const lang = req.params.lang;
|
||||||
|
|||||||
Reference in New Issue
Block a user