import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { ADMIN_SYSTEM_BACKUP_FORMAT, ADMIN_SYSTEM_BACKUP_VERSION, } from './dto/admin-system.dto'; import { ApiError } from '../../../common/errors/api-error'; import { ERROR_CODES } from '../../../common/errors/error-codes'; const INTERNAL_TABLES = new Set([ 'admin_operation_token', 'migrations', 'sqlite_sequence', 'sqlite_master', 'sqlite_temp_master', 'sqlite_temp_schema', ]); export interface BackupFileEntry { path: string; originalFilename?: string; mimeType?: string; sizeBytes: number; sha256: string; dataBase64: string; } export interface BackupObject { format: typeof ADMIN_SYSTEM_BACKUP_FORMAT; version: number; exportedAt: string; tables: Record>>; tableCounts: Record; uploads: BackupFileEntry[]; } /** * Generates a complete database + uploads backup as a single in-memory * JSON object. The resulting payload is intentionally unversioned at the * application level beyond the project's backup format identifier — new * tables are captured automatically because they are discovered from * `sqlite_master` at generation time rather than being hard-coded. */ @Injectable() export class BackupService { private readonly logger = new Logger(BackupService.name); private readonly uploadDir: string; constructor(config: ConfigService, private readonly dataSource: DataSource) { this.uploadDir = path.resolve(config.get('UPLOAD_DIR', './data/uploads')); } /** * Build the complete backup. Any failure during table snapshot or file * reading throws without partial results — the caller is expected to * translate this into an `SYSTEM_BACKUP_FAILED` envelope. */ async build(): Promise { const exportedAt = new Date().toISOString(); const tables: Record>> = {}; const tableCounts: Record = {}; const tablesList = await this.discoverTables(); for (const tableName of tablesList) { const rows = await this.safeRead(tableName); tables[tableName] = rows.map((row) => ({ ...row })); tableCounts[tableName] = rows.length; } const uploads = await this.collectUploads(); return { format: ADMIN_SYSTEM_BACKUP_FORMAT, version: ADMIN_SYSTEM_BACKUP_VERSION, exportedAt, tables, tableCounts, uploads, }; } /** * Returns every application table — dynamic discovery from the live * SQLite schema. Internal metadata tables and operational tables that * must not be restored are excluded. */ async discoverTables(): Promise { const rows = await this.safeQuery<{ name: string }>( `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name ASC`, ); return rows.map((r) => r.name).filter((n) => !INTERNAL_TABLES.has(n)); } /** * Convenience for tests: report the upload root resolved at construction time. */ getUploadDir(): string { return this.uploadDir; } private async safeRead(table: string): Promise[]> { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) { throw new ApiError(ERROR_CODES.SYSTEM_BACKUP_FAILED, `Invalid table name: ${table}`, 500); } const rows = await this.safeQuery>(`SELECT * FROM "${table}"`); return rows.map((row) => { const out: Record = {}; for (const [k, v] of Object.entries(row)) { if (v instanceof Buffer) { // better-sqlite3 returns BLOB columns as Buffers; encode so JSON // round-trips without losing data. out[k] = { __blob_b64: v.toString('base64') }; } else { out[k] = v; } } return out; }); } private async safeQuery(sql: string): Promise { try { return (await this.dataSource.query(sql)) as T[]; } catch (err) { throw new ApiError( ERROR_CODES.SYSTEM_BACKUP_FAILED, `Backup query failed: ${(err as Error)?.message ?? String(err)}`, 500, [{ path: 'database', message: sql }], ); } } private async collectUploads(): Promise { const out: BackupFileEntry[] = []; if (!fs.existsSync(this.uploadDir)) return out; const visit = (dir: string) => { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name === '.staging') continue; const target = path.join(dir, entry.name); if (entry.isDirectory()) { visit(target); } else if (entry.isFile()) { const stat = fs.statSync(target); const buf = fs.readFileSync(target); out.push({ path: path.relative(this.uploadDir, target).split(path.sep).join('/'), originalFilename: entry.name, sizeBytes: stat.size, sha256: crypto.createHash('sha256').update(buf).digest('hex'), dataBase64: buf.toString('base64'), }); } } }; try { visit(this.uploadDir); } catch (err) { throw new ApiError( ERROR_CODES.SYSTEM_FILE_READ_FAILED, `Failed to read uploads: ${(err as Error)?.message ?? String(err)}`, 500, ); } return out; } /** * Best-effort JSON stringifier used by the controller when streaming the * download. Centralized so tests can build deterministic snapshots. */ static stringify(obj: BackupObject): string { return JSON.stringify(obj); } }