AI Implementation feature(871): Admin Area System: Database Backup/Restore and Danger Zone (#61)
This commit was merged in pull request #61.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user