222 lines
7.4 KiB
JavaScript
222 lines
7.4 KiB
JavaScript
'use strict';
|
|
|
|
const os = require('os');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { getDb, getSetting, setSetting } = require('../db/database');
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function parseBool(key, defaultTrue = true) {
|
|
const v = getSetting(key);
|
|
if (v === null) return defaultTrue;
|
|
return v !== 'false';
|
|
}
|
|
|
|
function parseIntSetting(key, fallback) {
|
|
const v = parseInt(getSetting(key) || '', 10);
|
|
return isNaN(v) ? fallback : v;
|
|
}
|
|
|
|
// ─── Individual cleanup tasks ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Delete rows from import_sessions where expires_at has passed.
|
|
* These are created by the spreadsheet import preview endpoint (24h TTL).
|
|
*/
|
|
function pruneExpiredImportSessions() {
|
|
const result = getDb()
|
|
.prepare("DELETE FROM import_sessions WHERE expires_at <= datetime('now')")
|
|
.run();
|
|
return result.changes;
|
|
}
|
|
|
|
/**
|
|
* Remove stale SQLite export temp files from the OS temp directory.
|
|
* The user-db export writes bill-tracker-user-{id}-{ts}.sqlite to os.tmpdir()
|
|
* and deletes it in the download callback, but a server crash can leave orphans.
|
|
*/
|
|
function pruneStaleExportFiles(maxAgeHours) {
|
|
const tmpDir = os.tmpdir();
|
|
const cutoff = maxAgeHours * 60 * 60 * 1000;
|
|
const now = Date.now();
|
|
let removed = 0;
|
|
let errors = 0;
|
|
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(tmpDir);
|
|
} catch (err) {
|
|
console.warn('[cleanup] Cannot read tmpdir:', err.message);
|
|
return { removed, errors: 1 };
|
|
}
|
|
|
|
for (const name of entries) {
|
|
if (!name.startsWith('bill-tracker-user-') || !name.endsWith('.sqlite')) continue;
|
|
const full = path.join(tmpDir, name);
|
|
try {
|
|
const stat = fs.statSync(full);
|
|
if (now - stat.mtimeMs > cutoff) {
|
|
fs.unlinkSync(full);
|
|
removed++;
|
|
}
|
|
} catch {
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
return { removed, errors };
|
|
}
|
|
|
|
/**
|
|
* Remove orphaned .partial and .upload backup files from the backup directory.
|
|
* These are left behind only if the server crashes mid-backup or mid-import.
|
|
* Uses a 2-hour cutoff so an in-progress operation is never interrupted.
|
|
*/
|
|
function pruneOrphanedBackupPartials(backupDir) {
|
|
const cutoff = 2 * 60 * 60 * 1000;
|
|
const now = Date.now();
|
|
let removed = 0;
|
|
let errors = 0;
|
|
|
|
if (!backupDir || !fs.existsSync(backupDir)) return { removed, errors };
|
|
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(backupDir);
|
|
} catch (err) {
|
|
console.warn('[cleanup] Cannot read backup dir:', err.message);
|
|
return { removed, errors: 1 };
|
|
}
|
|
|
|
for (const name of entries) {
|
|
if (!name.endsWith('.partial') && !name.endsWith('.upload')) continue;
|
|
const full = path.join(backupDir, name);
|
|
try {
|
|
const stat = fs.statSync(full);
|
|
if (now - stat.mtimeMs > cutoff) {
|
|
fs.unlinkSync(full);
|
|
removed++;
|
|
}
|
|
} catch {
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
return { removed, errors };
|
|
}
|
|
|
|
/**
|
|
* Trim import_history rows older than maxAgeDays.
|
|
* Rows are per-user audit records. This task is disabled by default.
|
|
*/
|
|
function pruneImportHistory(maxAgeDays) {
|
|
if (!maxAgeDays || maxAgeDays <= 0) return 0;
|
|
const result = getDb()
|
|
.prepare("DELETE FROM import_history WHERE imported_at < datetime('now', ?)")
|
|
.run(`-${maxAgeDays} days`);
|
|
return result.changes;
|
|
}
|
|
|
|
// ─── Settings ─────────────────────────────────────────────────────────────────
|
|
|
|
function readSettings() {
|
|
return {
|
|
import_sessions_enabled: parseBool('cleanup_import_sessions_enabled', true),
|
|
temp_exports_enabled: parseBool('cleanup_temp_exports_enabled', true),
|
|
temp_export_max_age_hours: parseIntSetting('cleanup_temp_export_max_age_hours', 2),
|
|
backup_partials_enabled: parseBool('cleanup_backup_partials_enabled', true),
|
|
import_history_enabled: parseBool('cleanup_import_history_enabled', false),
|
|
import_history_max_age_days: parseIntSetting('cleanup_import_history_max_age_days', 365),
|
|
};
|
|
}
|
|
|
|
function validateAndApplySettings(input = {}) {
|
|
const toSave = {};
|
|
|
|
const bool = (key, settingKey) => {
|
|
if (input[key] !== undefined) toSave[settingKey] = input[key] ? 'true' : 'false';
|
|
};
|
|
const rangedInt = (key, settingKey, min, max, label) => {
|
|
if (input[key] === undefined) return;
|
|
const v = parseInt(input[key], 10);
|
|
if (isNaN(v) || v < min || v > max) {
|
|
const err = new Error(`${label} must be between ${min} and ${max}`);
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
toSave[settingKey] = String(v);
|
|
};
|
|
|
|
bool('import_sessions_enabled', 'cleanup_import_sessions_enabled');
|
|
bool('temp_exports_enabled', 'cleanup_temp_exports_enabled');
|
|
bool('backup_partials_enabled', 'cleanup_backup_partials_enabled');
|
|
bool('import_history_enabled', 'cleanup_import_history_enabled');
|
|
|
|
rangedInt('temp_export_max_age_hours', 'cleanup_temp_export_max_age_hours', 1, 72, 'temp_export_max_age_hours');
|
|
rangedInt('import_history_max_age_days', 'cleanup_import_history_max_age_days', 30, 3650, 'import_history_max_age_days');
|
|
|
|
for (const [k, v] of Object.entries(toSave)) setSetting(k, v);
|
|
return readSettings();
|
|
}
|
|
|
|
// ─── Main runner ──────────────────────────────────────────────────────────────
|
|
|
|
async function runAllCleanup() {
|
|
const settings = readSettings();
|
|
const tasks = {};
|
|
|
|
if (settings.import_sessions_enabled) {
|
|
const pruned = pruneExpiredImportSessions();
|
|
tasks.import_sessions = { pruned };
|
|
}
|
|
|
|
if (settings.temp_exports_enabled) {
|
|
tasks.temp_export_files = pruneStaleExportFiles(settings.temp_export_max_age_hours);
|
|
}
|
|
|
|
if (settings.backup_partials_enabled) {
|
|
const backupDir = getSetting('backup_path') || path.join(__dirname, '..', 'backups');
|
|
tasks.backup_partials = pruneOrphanedBackupPartials(backupDir);
|
|
}
|
|
|
|
if (settings.import_history_enabled) {
|
|
const pruned = pruneImportHistory(settings.import_history_max_age_days);
|
|
tasks.import_history = { pruned };
|
|
}
|
|
|
|
const ran_at = new Date().toISOString();
|
|
setSetting('cleanup_last_run_at', ran_at);
|
|
setSetting('cleanup_last_result', JSON.stringify(tasks));
|
|
|
|
console.log(`[cleanup] Ran at ${ran_at}:`, JSON.stringify(tasks));
|
|
return { ran_at, tasks };
|
|
}
|
|
|
|
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
|
|
function getCleanupStatus() {
|
|
const raw = getSetting('cleanup_last_result');
|
|
let last_result = null;
|
|
try { if (raw) last_result = JSON.parse(raw); } catch {}
|
|
|
|
return {
|
|
settings: readSettings(),
|
|
last_run_at: getSetting('cleanup_last_run_at') || null,
|
|
last_result,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
runAllCleanup,
|
|
getCleanupStatus,
|
|
readSettings,
|
|
validateAndApplySettings,
|
|
// Exported individually so the daily worker can call them by name in logs
|
|
pruneExpiredImportSessions,
|
|
pruneStaleExportFiles,
|
|
pruneOrphanedBackupPartials,
|
|
pruneImportHistory,
|
|
};
|