diff --git a/modules/backup/index.js b/modules/backup/index.js new file mode 100644 index 0000000..190b10f --- /dev/null +++ b/modules/backup/index.js @@ -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 +}; \ No newline at end of file diff --git a/package.json b/package.json index 97dfb29..7871c63 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", + "node-cron": "^4.2.1", "prom-client": "^15.1.3", "sharp": "^0.34.5", "sqlite3": "^6.0.1" diff --git a/public/admin.html b/public/admin.html index ec1a589..1575e8a 100644 --- a/public/admin.html +++ b/public/admin.html @@ -340,6 +340,62 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #

Настройки

+

Резервное копирование

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

История бекапов

+
+ + + + + + + + + + + + + +
ДатаРазмерТипВосстановленДействия
Загрузка...
+
+
+
+
+
+ +

Кодовое слово для отзывов

@@ -368,17 +424,6 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #

- -
-

Мой профиль

-
-
-
-
-
-

-

-
@@ -474,6 +519,34 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
+ +