Files
HIPCTF2/backend/src/modules/admin/system/backup.service.ts
T

178 lines
5.7 KiB
TypeScript

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<string>([
'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<string, Array<Record<string, unknown>>>;
tableCounts: Record<string, number>;
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<string>('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<BackupObject> {
const exportedAt = new Date().toISOString();
const tables: Record<string, Array<Record<string, unknown>>> = {};
const tableCounts: Record<string, number> = {};
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<string[]> {
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<Record<string, unknown>[]> {
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<Record<string, unknown>>(`SELECT * FROM "${table}"`);
return rows.map((row) => {
const out: Record<string, unknown> = {};
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<T>(sql: string): Promise<T[]> {
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<BackupFileEntry[]> {
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);
}
}