175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import Database from 'better-sqlite3';
|
|
import { db } from '../db/connection.js';
|
|
import { contexts } from '../db/schema.js';
|
|
import { buildBackupZip } from './ai-export-service.js';
|
|
|
|
const BACKUP_DIR = process.env.BACKUP_DIR ?? '/data/backups';
|
|
const CONFIG_FILE = path.join(BACKUP_DIR, 'backup-config.json');
|
|
const DEFAULT_RETENTION_COUNT = Number(process.env.BACKUP_RETENTION_COUNT ?? 14);
|
|
|
|
const STATE_FILE = path.join(BACKUP_DIR, 'backup-state.json');
|
|
|
|
interface BackupConfig {
|
|
retentionCount: number;
|
|
}
|
|
|
|
interface BackupState {
|
|
lastRunAt: string | null;
|
|
}
|
|
|
|
async function readState(): Promise<BackupState> {
|
|
try {
|
|
const raw = await fs.readFile(STATE_FILE, 'utf-8');
|
|
return JSON.parse(raw) as BackupState;
|
|
} catch {
|
|
return { lastRunAt: null };
|
|
}
|
|
}
|
|
|
|
async function writeState(state: BackupState): Promise<void> {
|
|
await ensureBackupDir();
|
|
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
|
|
}
|
|
|
|
async function readConfig(): Promise<BackupConfig> {
|
|
try {
|
|
const raw = await fs.readFile(CONFIG_FILE, 'utf-8');
|
|
return JSON.parse(raw) as BackupConfig;
|
|
} catch {
|
|
return { retentionCount: DEFAULT_RETENTION_COUNT };
|
|
}
|
|
}
|
|
|
|
async function writeConfig(config: BackupConfig): Promise<void> {
|
|
await ensureBackupDir();
|
|
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
}
|
|
|
|
export async function getRetentionCount(): Promise<number> {
|
|
return (await readConfig()).retentionCount;
|
|
}
|
|
|
|
export async function setRetentionCount(count: number): Promise<void> {
|
|
await writeConfig({ retentionCount: count });
|
|
}
|
|
|
|
export interface BackupEntry {
|
|
filename: string;
|
|
size: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
function timestamp(): string {
|
|
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
}
|
|
|
|
async function ensureBackupDir(): Promise<void> {
|
|
await fs.mkdir(BACKUP_DIR, { recursive: true });
|
|
}
|
|
|
|
async function pruneOldBackups(): Promise<void> {
|
|
const retentionCount = await getRetentionCount();
|
|
const all = await listBackups();
|
|
// listBackups returns newest-first; delete everything beyond retentionCount
|
|
const toDelete = all.slice(retentionCount);
|
|
for (const entry of toDelete) {
|
|
await fs.unlink(path.join(BACKUP_DIR, entry.filename));
|
|
}
|
|
}
|
|
|
|
async function getDistinctUserIds(): Promise<string[]> {
|
|
const rows = await db.selectDistinct({ userId: contexts.userId }).from(contexts);
|
|
return rows.map((r) => r.userId);
|
|
}
|
|
|
|
export function checkIntegrity(): { ok: boolean; result: string } {
|
|
const dbPath = process.env.DATABASE_PATH || './ka-note.db';
|
|
const sqlite = new Database(dbPath, { readonly: true });
|
|
try {
|
|
const row = sqlite.pragma('integrity_check', { simple: true }) as string;
|
|
const ok = row === 'ok';
|
|
if (!ok) console.error('[db] integrity_check failed:', row);
|
|
else console.log('[db] integrity_check ok');
|
|
return { ok, result: row };
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
}
|
|
|
|
export async function runScheduledBackup(): Promise<void> {
|
|
const { ok, result } = checkIntegrity();
|
|
if (!ok) {
|
|
console.error('[backup] aborting — integrity_check failed:', result);
|
|
return;
|
|
}
|
|
await ensureBackupDir();
|
|
const userIds = await getDistinctUserIds();
|
|
const ts = timestamp();
|
|
for (const userId of userIds) {
|
|
const zip = await buildBackupZip(userId);
|
|
const filename = `backup-${userId}-${ts}.zip`;
|
|
await fs.writeFile(path.join(BACKUP_DIR, filename), zip);
|
|
}
|
|
await pruneOldBackups();
|
|
await writeState({ lastRunAt: new Date().toISOString() });
|
|
}
|
|
|
|
/** Run backup immediately if it has never run or last run was >24h ago. */
|
|
export async function runIfMissed(): Promise<void> {
|
|
const { lastRunAt } = await readState();
|
|
const missedThreshold = 24 * 60 * 60 * 1000;
|
|
if (!lastRunAt || Date.now() - new Date(lastRunAt).getTime() > missedThreshold) {
|
|
console.log('[backup] missed run detected, executing now');
|
|
await runScheduledBackup();
|
|
}
|
|
}
|
|
|
|
function parseCreatedAt(filename: string): string {
|
|
// filename: backup-{userId}-YYYY-MM-DDTHH-MM-SS.zip
|
|
const base = filename.slice(0, -4); // strip .zip
|
|
const ts = base.slice(base.lastIndexOf('-', base.length - 16) + 1);
|
|
// ts: "2026-02-27T14-30-45" → restore colons in time part
|
|
const iso = ts.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3') + 'Z';
|
|
const d = new Date(iso);
|
|
return isNaN(d.getTime()) ? new Date(0).toISOString() : d.toISOString();
|
|
}
|
|
|
|
export async function listBackups(): Promise<BackupEntry[]> {
|
|
await ensureBackupDir();
|
|
const entries = await fs.readdir(BACKUP_DIR);
|
|
const result: BackupEntry[] = [];
|
|
for (const name of entries) {
|
|
if (!name.endsWith('.zip')) continue;
|
|
const stat = await fs.stat(path.join(BACKUP_DIR, name));
|
|
result.push({ filename: name, size: stat.size, createdAt: parseCreatedAt(name) });
|
|
}
|
|
return result.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
}
|
|
|
|
export async function deleteBackup(filename: string): Promise<void> {
|
|
if (path.basename(filename) !== filename || filename.includes('/') || filename.includes('\\')) {
|
|
throw new Error('Invalid filename');
|
|
}
|
|
await fs.unlink(path.join(BACKUP_DIR, filename));
|
|
}
|
|
|
|
export function backupFilePath(filename: string): string {
|
|
if (path.basename(filename) !== filename || filename.includes('/') || filename.includes('\\')) {
|
|
throw new Error('Invalid filename');
|
|
}
|
|
return path.join(BACKUP_DIR, filename);
|
|
}
|
|
|
|
export async function getBackupStatus(): Promise<{ enabled: boolean; retentionCount: number; backupDir: string; cronHour: number; lastRunAt: string | null }> {
|
|
const { lastRunAt } = await readState();
|
|
return {
|
|
enabled: process.env.BACKUP_ENABLED === 'true',
|
|
retentionCount: await getRetentionCount(),
|
|
backupDir: BACKUP_DIR,
|
|
cronHour: Number(process.env.BACKUP_CRON_HOUR ?? 3),
|
|
lastRunAt,
|
|
};
|
|
}
|