178 lines
5.7 KiB
TypeScript
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);
|
|
}
|
|
}
|