Files
open-isp/services/backupService.js
T

423 lines
11 KiB
JavaScript

/**
* Service: Backup & Recovery System
* Melakukan backup otomatis database dan settings
*/
const fs = require('fs');
const Database = require('better-sqlite3');
const path = require('path');
const { logger } = require('../config/logger');
const { getSetting, getCurrentDateInTimezone, getNowLocalISO } = require('../config/settingsManager');
const projectRoot = path.join(__dirname, '..');
const backupDir = path.join(projectRoot, 'backups');
const dbPath = path.join(projectRoot, 'database', 'billing.db');
const { settingsPath } = require('../config/paths');
// Pastikan direktori backup ada
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
logger.info('[Backup] Created backup directory');
}
/**
* Generate timestamp untuk nama file backup
*/
function getBackupTimestamp() {
const now = getCurrentDateInTimezone();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
}
/**
* Backup database SQLite
*/
function backupDatabase() {
try {
const timestamp = getBackupTimestamp();
const backupFileName = `billing_db_${timestamp}.db`;
const backupFilePath = path.join(backupDir, backupFileName);
// VACUUM INTO: snapshot konsisten & aman di mode WAL
// (menyalin file mentah bisa kehilangan transaksi terbaru yang masih di -wal)
if (fs.existsSync(backupFilePath)) fs.unlinkSync(backupFilePath);
const srcDb = new Database(dbPath, { readonly: true });
try {
srcDb.prepare('VACUUM INTO ?').run(backupFilePath);
} finally {
srcDb.close();
}
// Compress backup (optional - bisa ditambahkan nanti)
const stats = fs.statSync(backupFilePath);
const sizeKB = Math.round(stats.size / 1024);
logger.info(`[Backup] Database backup created: ${backupFileName} (${sizeKB} KB)`);
return {
success: true,
fileName: backupFileName,
size: stats.size,
timestamp: getNowLocalISO()
};
} catch (e) {
logger.error(`[Backup] Failed to backup database: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Backup settings.json
*/
function backupSettings() {
try {
const timestamp = getBackupTimestamp();
const backupFileName = `settings_${timestamp}.json`;
const backupFilePath = path.join(backupDir, backupFileName);
// Copy settings file
fs.copyFileSync(settingsPath, backupFilePath);
const stats = fs.statSync(backupFilePath);
const sizeKB = Math.round(stats.size / 1024);
logger.info(`[Backup] Settings backup created: ${backupFileName} (${sizeKB} KB)`);
return {
success: true,
fileName: backupFileName,
size: stats.size,
timestamp: getNowLocalISO()
};
} catch (e) {
logger.error(`[Backup] Failed to backup settings: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Backup semua (database + settings)
*/
function backupAll() {
const dbResult = backupDatabase();
const settingsResult = backupSettings();
return {
database: dbResult,
settings: settingsResult,
timestamp: getNowLocalISO()
};
}
/**
* Restore database dari backup
*/
function restoreDatabase(backupFileName) {
try {
const backupFilePath = path.join(backupDir, backupFileName);
// Cek apakah file backup ada
if (!fs.existsSync(backupFilePath)) {
return {
success: false,
error: `Backup file not found: ${backupFileName}`
};
}
// Backup database saat ini sebelum restore
const preRestoreBackup = backupDatabase();
if (!preRestoreBackup.success) {
logger.warn('[Backup] Failed to create pre-restore backup');
}
// Tunda restore ke saat startup: menimpa DB di bawah koneksi hidup bisa merusak (WAL bercampur).
// Penanda dibaca config/database.js saat boot, sebelum koneksi dibuka.
const pendingPath = path.join(projectRoot, 'database', '.restore-pending');
fs.writeFileSync(pendingPath, backupFileName, 'utf8');
const size = fs.statSync(backupFilePath).size;
logger.info(`[Backup] Restore dijadwalkan (diterapkan saat restart): ${backupFileName}`);
return {
success: true,
fileName: backupFileName,
size,
pendingRestart: true,
timestamp: getNowLocalISO(),
preRestoreBackup: preRestoreBackup.fileName
};
} catch (e) {
logger.error(`[Backup] Failed to restore database: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Restore settings dari backup
*/
function restoreSettings(backupFileName) {
try {
const backupFilePath = path.join(backupDir, backupFileName);
// Cek apakah file backup ada
if (!fs.existsSync(backupFilePath)) {
return {
success: false,
error: `Backup file not found: ${backupFileName}`
};
}
// Backup settings saat ini sebelum restore
const preRestoreBackup = backupSettings();
if (!preRestoreBackup.success) {
logger.warn('[Backup] Failed to create pre-restore backup');
}
// Restore settings
fs.copyFileSync(backupFilePath, settingsPath);
const stats = fs.statSync(settingsPath);
const sizeKB = Math.round(stats.size / 1024);
logger.info(`[Backup] Settings restored from: ${backupFileName} (${sizeKB} KB)`);
return {
success: true,
fileName: backupFileName,
size: stats.size,
timestamp: getNowLocalISO(),
preRestoreBackup: preRestoreBackup.fileName
};
} catch (e) {
logger.error(`[Backup] Failed to restore settings: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Daftar semua backup yang tersedia
*/
function listBackups() {
try {
const files = fs.readdirSync(backupDir);
const backups = [];
for (const file of files) {
const filePath = path.join(backupDir, file);
const stats = fs.statSync(filePath);
// Parse filename untuk mendapatkan tanggal
let backupDate = null;
let backupType = null;
if (file.startsWith('billing_db_') && file.endsWith('.db')) {
backupType = 'database';
const timestamp = file.replace('billing_db_', '').replace('.db', '');
backupDate = parseBackupTimestamp(timestamp);
} else if (file.startsWith('settings_') && file.endsWith('.json')) {
backupType = 'settings';
const timestamp = file.replace('settings_', '').replace('.json', '');
backupDate = parseBackupTimestamp(timestamp);
}
backups.push({
fileName: file,
type: backupType,
size: stats.size,
sizeKB: Math.round(stats.size / 1024),
created: stats.birthtime,
createdDate: backupDate,
modified: stats.mtime
});
}
// Sort by created date (terbaru dulu)
backups.sort((a, b) => b.created - a.created);
return {
success: true,
backups: backups,
total: backups.length
};
} catch (e) {
logger.error(`[Backup] Failed to list backups: ${e.message}`);
return {
success: false,
error: e.message,
backups: []
};
}
}
/**
* Parse timestamp dari nama file backup
*/
function parseBackupTimestamp(timestamp) {
try {
// Format: YYYYMMDD_HHMMSS
const [datePart, timePart] = timestamp.split('_');
const year = datePart.substring(0, 4);
const month = datePart.substring(4, 6);
const day = datePart.substring(6, 8);
const hours = timePart.substring(0, 2);
const minutes = timePart.substring(2, 4);
const seconds = timePart.substring(4, 6);
return new Date(year, month - 1, day, hours, minutes, seconds);
} catch (e) {
return null;
}
}
/**
* Hapus backup lama berdasarkan retention policy
*/
function cleanupOldBackups(retentionDays = 30) {
try {
const result = listBackups();
if (!result.success) {
return result;
}
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
let deletedCount = 0;
const deletedFiles = [];
for (const backup of result.backups) {
if (backup.created < cutoffDate) {
const filePath = path.join(backupDir, backup.fileName);
fs.unlinkSync(filePath);
deletedCount++;
deletedFiles.push(backup.fileName);
logger.info(`[Backup] Deleted old backup: ${backup.fileName}`);
}
}
return {
success: true,
deletedCount,
deletedFiles,
retentionDays
};
} catch (e) {
logger.error(`[Backup] Failed to cleanup old backups: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Cek kapasitas backup dan hapus jika perlu
*/
function checkBackupCapacity(maxSizeMB = 500) {
try {
const result = listBackups();
if (!result.success) {
return result;
}
const totalSize = result.backups.reduce((sum, backup) => sum + backup.size, 0);
const totalSizeMB = totalSize / (1024 * 1024);
if (totalSizeMB > maxSizeMB) {
logger.warn(`[Backup] Backup size (${totalSizeMB.toFixed(2)} MB) exceeds limit (${maxSizeMB} MB)`);
// Hapus backup paling lama sampai kapasitas aman
const sortedBackups = [...result.backups].sort((a, b) => a.created - b.created);
let deletedCount = 0;
for (const backup of sortedBackups) {
if (totalSizeMB <= maxSizeMB * 0.8) { // Hapus sampai 80% dari limit
break;
}
const filePath = path.join(backupDir, backup.fileName);
fs.unlinkSync(filePath);
totalSizeMB -= backup.size / (1024 * 1024);
deletedCount++;
logger.info(`[Backup] Deleted backup for capacity: ${backup.fileName}`);
}
return {
success: true,
action: 'cleanup',
deletedCount,
totalSizeMB: totalSizeMB.toFixed(2),
maxSizeMB
};
}
return {
success: true,
action: 'none',
totalSizeMB: totalSizeMB.toFixed(2),
maxSizeMB
};
} catch (e) {
logger.error(`[Backup] Failed to check backup capacity: ${e.message}`);
return {
success: false,
error: e.message
};
}
}
/**
* Jadwal backup otomatis
*/
function scheduleAutoBackup() {
const nodeCron = require('node-cron');
const enabled = getSetting('auto_backup_enabled', true);
const schedule = getSetting('auto_backup_schedule', '0 2 * * *'); // Default jam 2 pagi setiap hari
if (!enabled) {
logger.info('[Backup] Auto backup disabled');
return;
}
nodeCron.schedule(schedule, () => {
logger.info('[Backup] Starting scheduled backup...');
const result = backupAll();
if (result.database.success && result.settings.success) {
logger.info('[Backup] Scheduled backup completed successfully');
} else {
logger.error('[Backup] Scheduled backup failed');
}
});
logger.info(`[Backup] Auto backup scheduled: ${schedule}`);
}
module.exports = {
backupDatabase,
backupSettings,
backupAll,
restoreDatabase,
restoreSettings,
listBackups,
cleanupOldBackups,
checkBackupCapacity,
scheduleAutoBackup
};