Ka-Note/ka-note/server/src/lib/backup-service.ts

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,
};
}