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:
2026-07-23 12:10:41 +00:00
parent 6bac67fad7
commit 470ddd30c3
42 changed files with 3996 additions and 55 deletions
@@ -0,0 +1,184 @@
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { AdminGuard } from '../../../common/guards/admin.guard';
import { Roles } from '../../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../../common/pipes/zod-validation.pipe';
import { ApiError } from '../../../common/errors/api-error';
import { ERROR_CODES } from '../../../common/errors/error-codes';
import { BackupService } from './backup.service';
import { RestoreService } from './restore.service';
import { DangerZoneService } from './danger-zone.service';
import { ConfirmationTokenService } from './confirmation-token.service';
import { AuthService } from '../../auth/auth.service';
import {
AdminReauthBodySchema,
AdminDangerConfirmBodySchema,
AdminRestoreCommitBodySchema,
AdminOperationKind,
} from './dto/admin-system.dto';
interface AuthedRequest extends Request {
user?: { sub: string; role?: string };
}
function requireAdminUserId(req: AuthedRequest): string {
if (!req.user || req.user.role !== 'admin' || !req.user.sub) {
throw new ForbiddenException('Admin role required');
}
return req.user.sub;
}
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin/system')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminSystemController {
constructor(
private readonly backup: BackupService,
private readonly restore: RestoreService,
private readonly danger: DangerZoneService,
private readonly tokens: ConfirmationTokenService,
private readonly auth: AuthService,
) {}
@Get('backup')
@ApiOperation({ summary: 'Download a complete database + uploads backup (admin only).' })
async downloadBackup(@Res() res: Response): Promise<void> {
const doc = await this.backup.build();
const text = BackupService.stringify(doc);
res
.status(HttpStatus.OK)
.setHeader('Content-Type', 'application/json; charset=utf-8')
.setHeader(
'Content-Disposition',
`attachment; filename="hipctf-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json"`,
)
.send(text);
}
@Post('restore/validate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Validate an uploaded backup document without mutating data.' })
async validateRestore(
@Req() req: AuthedRequest,
@Body() body: { archive?: unknown; text?: string },
): Promise<unknown> {
const userId = requireAdminUserId(req);
let rawText: string;
if (typeof body.text === 'string') {
rawText = body.text;
} else if (body.archive && typeof body.archive === 'object') {
rawText = JSON.stringify(body.archive);
} else {
throw new ApiError(
ERROR_CODES.SYSTEM_PAYLOAD_INVALID,
'No archive provided. Send { text: "<json-string>" } or { archive: { ... } }.',
400,
);
}
return this.restore.stageArchive(rawText, userId);
}
@Post('restore/commit')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Commit a previously-staged restore (requires confirmation token).' })
async commitRestore(
@Req() req: AuthedRequest,
@Body(new ZodValidationPipe(AdminRestoreCommitBodySchema))
body: { operation: AdminOperationKind; token: string; stagingId?: string },
): Promise<{ ok: true; operation: AdminOperationKind }> {
const userId = requireAdminUserId(req);
if (body.operation !== 'restore-backup') {
throw new ApiError(
ERROR_CODES.SYSTEM_TOKEN_MISMATCH,
'Staging id is not bound to this operation',
400,
);
}
await this.tokens.consume({
userId,
operation: 'restore-backup',
token: body.token,
stagingId: body.stagingId,
});
const result = await this.restore.commitRestore(body.stagingId ?? '');
void result;
await this.auth.revokeAllRefreshSessions(userId);
return { ok: true, operation: 'restore-backup' };
}
@Post('confirmations')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reauthenticate and issue a single-use confirmation token for a destructive operation.' })
async issueConfirmation(
@Req() req: AuthedRequest,
@Body(new ZodValidationPipe(AdminReauthBodySchema))
body: { operation: AdminOperationKind; password: string; stagingId?: string },
): Promise<{ operation: AdminOperationKind; token: string; expiresAt: string }> {
const userId = requireAdminUserId(req);
const verified = await this.auth.reauthenticateAdmin(userId, body.password);
const issued = await this.tokens.issue({ userId: verified.id, operation: body.operation });
void body.stagingId;
return { operation: body.operation, token: issued.token, expiresAt: issued.expiresAt };
}
@Post('scores/reset')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset all scores (delete every solve and award). Requires confirmation token.' })
async resetScores(
@Req() req: AuthedRequest,
@Body(new ZodValidationPipe(AdminDangerConfirmBodySchema))
body: { operation: AdminOperationKind; token: string },
): Promise<{ ok: true; solvesRemoved: number }> {
const userId = requireAdminUserId(req);
if (body.operation !== 'reset-scores') {
throw new ApiError(
ERROR_CODES.SYSTEM_TOKEN_MISMATCH,
'Token was not issued for reset-scores',
400,
);
}
await this.tokens.consume({ userId, operation: 'reset-scores', token: body.token });
const result = await this.danger.resetScores();
return { ok: true, solvesRemoved: result.solvesRemoved };
}
@Post('challenges/wipe')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Wipe every challenge, cascading files and solves. Requires confirmation token.' })
async wipeChallenges(
@Req() req: AuthedRequest,
@Body(new ZodValidationPipe(AdminDangerConfirmBodySchema))
body: { operation: AdminOperationKind; token: string },
): Promise<{
ok: true;
challengesRemoved: number;
challengeFilesRemoved: number;
solvesRemoved: number;
}> {
const userId = requireAdminUserId(req);
if (body.operation !== 'wipe-challenges') {
throw new ApiError(
ERROR_CODES.SYSTEM_TOKEN_MISMATCH,
'Token was not issued for wipe-challenges',
400,
);
}
await this.tokens.consume({ userId, operation: 'wipe-challenges', token: body.token });
const result = await this.danger.wipeChallenges();
return { ok: true, ...result };
}
}
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminOperationTokenEntity } from '../../../database/entities/admin-operation-token.entity';
import { AdminSystemController } from './admin-system.controller';
import { BackupService } from './backup.service';
import { RestoreService } from './restore.service';
import { DangerZoneService } from './danger-zone.service';
import { FilesystemTransactionService } from './filesystem-transaction.service';
import { ConfirmationTokenService } from './confirmation-token.service';
import { AuthModule } from '../../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([AdminOperationTokenEntity]),
AuthModule,
],
controllers: [AdminSystemController],
providers: [
BackupService,
RestoreService,
DangerZoneService,
FilesystemTransactionService,
ConfirmationTokenService,
],
exports: [
BackupService,
RestoreService,
DangerZoneService,
FilesystemTransactionService,
ConfirmationTokenService,
],
})
export class AdminSystemModule {}
@@ -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);
}
}
@@ -0,0 +1,136 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import * as crypto from 'crypto';
import { AdminOperationTokenEntity, AdminOperationKind } from '../../../database/entities/admin-operation-token.entity';
import { ApiError } from '../../../common/errors/api-error';
import { ERROR_CODES } from '../../../common/errors/error-codes';
export interface IssueTokenInput {
userId: string;
operation: AdminOperationKind;
}
export interface IssueTokenResult {
token: string;
expiresAt: string;
id: string;
}
export interface ConsumeTokenInput {
userId: string;
operation: AdminOperationKind;
token: string;
stagingId?: string;
}
@Injectable()
export class ConfirmationTokenService {
private readonly logger = new Logger(ConfirmationTokenService.name);
private readonly ttlMs: number;
constructor(
config: ConfigService,
@InjectRepository(AdminOperationTokenEntity)
private readonly repo: Repository<AdminOperationTokenEntity>,
private readonly dataSource: DataSource,
) {
this.ttlMs = config.get<number>('SYSTEM_OP_CONFIRM_TOKEN_TTL_MS', 5 * 60_000);
}
/**
* Issue a cryptographically random single-use token for the supplied
* administrator and operation. Only the SHA-256 hash is persisted; raw
* tokens are returned exactly once and never logged.
*/
async issue(input: IssueTokenInput): Promise<IssueTokenResult> {
if (!input.userId) throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Missing user', 400);
if ((input.operation as string) === undefined) {
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Missing operation', 400);
}
const raw = crypto.randomBytes(32).toString('base64url');
const hash = ConfirmationTokenService.hash(raw);
const now = new Date();
const expiresAt = new Date(now.getTime() + this.ttlMs);
const id = crypto.randomUUID();
await this.repo.insert({
id,
userId: input.userId,
operation: input.operation,
tokenHash: hash,
issuedAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
consumedAt: null,
});
return { token: raw, expiresAt: expiresAt.toISOString(), id };
}
/**
* Atomically consume a token: only one caller may succeed. Tokens that
* don't match (unknown, wrong user, wrong operation, wrong stage,
* expired, or previously consumed) produce distinct stable codes.
*/
async consume(input: ConsumeTokenInput): Promise<{ id: string }> {
if (!input || !input.token || !input.userId) {
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Confirmation token required', 400);
}
const hash = ConfirmationTokenService.hash(input.token);
return this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(AdminOperationTokenEntity);
const row = await repo.findOne({ where: { tokenHash: hash } });
if (!row) {
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Unknown confirmation token', 400);
}
if (row.userId !== input.userId) {
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_MISMATCH, 'Token was not issued to this administrator', 400);
}
if (row.operation !== input.operation) {
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_MISMATCH, 'Token was not issued for this operation', 400);
}
if (row.consumedAt) {
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_REUSED, 'Confirmation token already used', 400);
}
const expiryMs = new Date(row.expiresAt).getTime();
if (!Number.isFinite(expiryMs) || expiryMs < Date.now()) {
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_EXPIRED, 'Confirmation token expired', 400);
}
const consumedAt = new Date().toISOString();
const result = await repo
.createQueryBuilder()
.update(AdminOperationTokenEntity)
.set({ consumedAt })
.where('id = :id', { id: row.id })
.andWhere('consumedAt IS NULL')
.execute();
if (!result.affected) {
throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_REUSED, 'Confirmation token already used', 400);
}
return { id: row.id };
});
}
/**
* Best-effort expiry sweep. Safe to call frequently.
*/
async purgeExpired(): Promise<number> {
const result = await this.repo
.createQueryBuilder()
.delete()
.from(AdminOperationTokenEntity)
.where('expiresAt < :now', { now: new Date().toISOString() })
.orWhere('consumedAt IS NOT NULL AND issued_at < :old', { old: new Date(Date.now() - 24 * 3600_000).toISOString() })
.execute()
.catch(() => ({ affected: 0 } as any));
return result.affected ?? 0;
}
/**
* Constant-time SHA-256 hashing of a raw token string. Tokens are stored
* only as their hash so a leaked database row alone cannot be used as a
* bearer credential.
*/
static hash(raw: string): string {
return crypto.createHash('sha256').update(raw).digest('hex');
}
}
@@ -0,0 +1,147 @@
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 { FilesystemTransactionService } from './filesystem-transaction.service';
import { ApiError } from '../../../common/errors/api-error';
import { ERROR_CODES } from '../../../common/errors/error-codes';
export interface ResetScoresResult {
solvesRemoved: number;
}
export interface WipeChallengesResult {
challengesRemoved: number;
challengeFilesRemoved: number;
solvesRemoved: number;
}
/**
* Danger zone operations ("Reset all scores" and "Wipe challenges"). Both
* operations are staging-first: filesystem changes happen on rollback
* snapshots before the database mutation, and any failure rolls back to
* the exact previous state.
*/
@Injectable()
export class DangerZoneService {
private readonly logger = new Logger(DangerZoneService.name);
private readonly uploadDir: string;
constructor(
config: ConfigService,
private readonly dataSource: DataSource,
) {
this.uploadDir = path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
}
/**
* Reset all scores by deleting every `solve` row transactionally.
* Awards live on `solve` so this also clears all award history while
* preserving users, sessions, categories, challenges/files, settings,
* and blog posts.
*/
async resetScores(): Promise<ResetScoresResult> {
try {
return await this.dataSource.transaction(async (manager) => {
const before = await manager.query('SELECT COUNT(*) AS c FROM solve');
const total = Number((before as any[])[0]?.c ?? 0);
await manager.query('DELETE FROM "solve"');
return { solvesRemoved: total };
});
} catch (err) {
throw new ApiError(
ERROR_CODES.SYSTEM_DANGER_FAILED,
`Reset scores failed: ${(err as Error)?.message ?? String(err)}`,
500,
);
}
}
/**
* Wipe every challenge (cascading files + solves) and remove every
* uploaded challenge file from disk. Preserves users, categories,
* settings, blog posts, and unrelated uploads.
*
* Strategy:
* 1. Snapshot the live challenge-files tree so we can restore it
* on any later failure.
* 2. Run the DB deletion transactionally.
* 3. Physically remove the live `<UPLOAD_DIR>/challenges` directory.
* If this rm step fails we keep the snapshot and copy it back.
* 4. Only then remove the staging snapshot (no longer needed).
*/
async wipeChallenges(): Promise<WipeChallengesResult> {
const challengesDir = path.join(this.uploadDir, 'challenges');
const stagingBackup = `${challengesDir}.wipe-stage-${Date.now()}`;
let staged = false;
try {
// 1. Snapshot challenge files to a rollback location.
if (fs.existsSync(challengesDir)) {
FilesystemTransactionService.copyDirSync(challengesDir, stagingBackup);
staged = true;
}
// 2. Transactionally delete every challenge (cascading files + solves).
const counts = await this.dataSource.transaction(async (manager) => {
const beforeCh = Number(
((await manager.query('SELECT COUNT(*) AS c FROM challenge')) as any[])[0]?.c ?? 0,
);
const beforeFiles = Number(
((await manager.query('SELECT COUNT(*) AS c FROM challenge_file')) as any[])[0]?.c ?? 0,
);
const beforeSolves = Number(
((await manager.query('SELECT COUNT(*) AS c FROM solve')) as any[])[0]?.c ?? 0,
);
await manager.query('DELETE FROM "challenge"');
return {
challengesRemoved: beforeCh,
challengeFilesRemoved: beforeFiles,
solvesRemoved: beforeSolves,
};
});
// 3. DB committed. Physically remove the live challenge-files
// directory. The snapshot is kept in place so we can restore
// the live tree if this rm step fails.
this.strictRm(challengesDir);
// 4. Snapshot no longer needed.
FilesystemTransactionService.rmSafe(stagingBackup);
return counts;
} catch (err) {
if (staged) {
// DB or filesystem delete failed after we snapshotted. Try to
// restore the captured tree back into the live path so the
// challenge files are intact.
try {
if (!fs.existsSync(challengesDir)) {
FilesystemTransactionService.copyDirSync(stagingBackup, challengesDir);
}
FilesystemTransactionService.rmSafe(stagingBackup);
} catch (innerErr) {
this.logger.error(
`Wipe filesystem rollback failed: ${(innerErr as Error)?.message ?? String(innerErr)}`,
);
}
} else {
FilesystemTransactionService.rmSafe(stagingBackup);
}
throw new ApiError(
ERROR_CODES.SYSTEM_DANGER_ROLLED_BACK,
`Wipe challenges failed and was rolled back: ${(err as Error)?.message ?? String(err)}`,
500,
);
}
}
/**
* Strict recursive remove: deletes the target if it exists and throws
* on any filesystem error so callers can run a rollback path.
* Mirrors `FilesystemTransactionService.rmSafe` but without swallowing.
*/
private strictRm(target: string): void {
if (!fs.existsSync(target)) return;
fs.rmSync(target, { recursive: true });
}
}
@@ -0,0 +1,46 @@
import { z } from 'zod';
import { ADMIN_OPERATION_KINDS, AdminOperationKind } from '../../../../database/entities/admin-operation-token.entity';
export { ADMIN_OPERATION_KINDS };
export type { AdminOperationKind };
export const ADMIN_SYSTEM_BACKUP_FORMAT = 'hipctf-system-backup';
export const ADMIN_SYSTEM_BACKUP_VERSION = 1;
export const ADMIN_SYSTEM_OPERATIONS = ADMIN_OPERATION_KINDS;
export const AdminReauthBodySchema = z.object({
operation: z.enum(ADMIN_OPERATION_KINDS),
password: z.string().min(1).max(1024),
stagingId: z.string().min(1).max(128).optional(),
});
export const AdminDangerConfirmBodySchema = z.object({
operation: z.enum(ADMIN_OPERATION_KINDS),
token: z.string().min(1).max(512),
});
export const AdminRestoreCommitBodySchema = AdminDangerConfirmBodySchema;
export const AdminRestoreValidateResponseSchema = z.object({
stagingId: z.string(),
expiresAt: z.string(),
summary: z.object({
tables: z.array(z.string()).min(1),
tableCounts: z.record(z.string(), z.number().int().nonnegative()),
files: z.number().int().nonnegative(),
totalBytes: z.number().int().nonnegative(),
}),
});
export type AdminReauthBody = z.infer<typeof AdminReauthBodySchema>;
export type AdminDangerConfirmBody = z.infer<typeof AdminDangerConfirmBodySchema>;
export type AdminRestoreCommitBody = z.infer<typeof AdminRestoreCommitBodySchema>;
export type AdminRestoreValidateResponse = z.infer<typeof AdminRestoreValidateResponseSchema>;
export interface ConfirmationTokenResponse {
operation: AdminOperationKind;
token: string;
expiresAt: string;
stagingId?: string;
}
@@ -0,0 +1,275 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { ApiError } from '../../../common/errors/api-error';
import { ERROR_CODES } from '../../../common/errors/error-codes';
export interface SwapPair {
livePath: string;
stagedPath: string;
}
export interface SwapRollbackHandle {
rolledBack: boolean;
pairs: Array<{ livePath: string; rollbackPath: string; stagedPath: string }>;
}
interface WorkerHooks {
/**
* Optional hook that runs once after `prepare()` (file staging) but
* before the live swap (e.g. database rebuild). Throw to abort the
* operation and immediately trigger rollback.
*/
beforeSwap?: () => Promise<void>;
/**
* Optional hook that runs after the swap is registered but before it
* is considered durable (e.g. final database open, verification).
* Throw to trigger rollback.
*/
afterSwap?: () => Promise<void>;
/**
* Optional hook that runs after finalization completes successfully.
* Throw to trigger rollback even from a successful operation.
*/
onCommit?: () => Promise<void>;
}
export interface StageSwapInput {
name: string;
pairs: SwapPair[];
hooks?: WorkerHooks;
}
/**
* Swap a set of file/directory pairs in a crash-safe way. Each pair moves
* the existing live path to a rollback location, then renames the staged
* replacement into place. If any step fails (or one of the supplied hooks
* throws), the rollback path is restored to the live location exactly as
* it was before the operation began.
*
* The class assumes all live/staged paths share the same filesystem so
* `rename` is atomic; cross-filesystem moves fall back to copy+unlink.
*/
@Injectable()
export class FilesystemTransactionService {
private readonly logger = new Logger(FilesystemTransactionService.name);
/**
* Apply the swap. Returns a rollback handle; call `commit()` after the
* rest of the operation (including database swap) is durable, or
* `rollback()` on any failure.
*/
async stageSwap(input: StageSwapInput): Promise<SwapRollbackHandle> {
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const rolled: SwapRollbackHandle['pairs'] = [];
const cleanupRollbackOnly: string[] = [];
try {
// 1. Stage: snapshot each live path to a rollback location.
for (const pair of input.pairs) {
if (!pair.livePath) throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_FAILED, 'Missing live path', 500);
if (!pair.stagedPath) throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_FAILED, 'Missing staged path', 500);
const rollbackPath = FilesystemTransactionService.makeRollbackPath(pair.livePath, stamp);
if (fs.existsSync(pair.livePath)) {
fs.renameSync(pair.livePath, rollbackPath);
cleanupRollbackOnly.push(rollbackPath);
} else if (fs.existsSync(pair.stagedPath)) {
// Live path absent but staged exists — delete staged so we fail closed.
try {
fs.rmSync(pair.stagedPath, { recursive: true, force: true });
} catch {
// ignore
}
}
rolled.push({ livePath: pair.livePath, rollbackPath, stagedPath: pair.stagedPath });
}
if (input.hooks?.beforeSwap) {
try {
await input.hooks.beforeSwap();
} catch (err) {
throw err;
}
}
// 2. Swap: rename each staged path into the live location.
for (const r of rolled) {
if (!fs.existsSync(r.stagedPath)) {
throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_FAILED, 'Staged replacement missing', 500, [
{ path: 'stagedPath', message: r.stagedPath },
]);
}
try {
fs.renameSync(r.stagedPath, r.livePath);
} catch (err) {
// Cross-device fall-back: copy + unlink.
FilesystemTransactionService.copyDirSync(r.stagedPath, r.livePath);
try {
fs.rmSync(r.stagedPath, { recursive: true, force: true });
} catch {
// ignore
}
}
}
if (input.hooks?.afterSwap) {
await input.hooks.afterSwap();
}
return {
rolledBack: false,
pairs: rolled,
};
} catch (err) {
await this.rollbackLocal({ rolledBack: false, pairs: rolled });
throw err;
} finally {
// Successful rename leaves rollback artifacts; we keep them until
// commit() (or rollback() explicitly deletes them).
void cleanupRollbackOnly;
}
}
/**
* Restore the original live paths from their rollback snapshots. Safe
* to call multiple times.
*/
async rollback(handle: SwapRollbackHandle): Promise<void> {
if (!handle || handle.rolledBack) return;
try {
await this.rollbackLocal(handle);
} catch (err) {
this.logger.error(
`Filesystem rollback failed: ${(err as Error)?.message ?? String(err)}`,
);
throw err;
}
}
/**
* Mark the swap committed; the rollback snapshots are deleted.
*/
commit(handle: SwapRollbackHandle): void {
if (!handle || handle.rolledBack) return;
for (const r of handle.pairs) {
try {
if (fs.existsSync(r.rollbackPath)) {
fs.rmSync(r.rollbackPath, { recursive: true, force: true });
}
if (fs.existsSync(r.stagedPath)) {
fs.rmSync(r.stagedPath, { recursive: true, force: true });
}
} catch (err) {
this.logger.warn(
`Failed to clean rollback artifact ${r.rollbackPath}: ${(err as Error)?.message}`,
);
}
}
handle.rolledBack = true;
}
/**
* Reset the swap state after a successful commit (also clears
* rolledBack flag so commit() is safely idempotent).
*/
finalize(handle: SwapRollbackHandle): void {
if (!handle) return;
this.commit(handle);
}
/**
* Recursively remove a directory or file. Best-effort.
*/
static rmSafe(target: string): void {
try {
if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
} catch {
// ignore
}
}
/**
* Copy a directory recursively, falling back gracefully if the source
* doesn't exist (returns immediately).
*/
static copyDirSync(src: string, dest: string): void {
if (!fs.existsSync(src)) return;
const stat = fs.statSync(src);
if (stat.isFile()) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
return;
}
FilesystemTransactionService.ensureDir(dest);
for (const entry of fs.readdirSync(src)) {
const srcEntry = path.join(src, entry);
const destEntry = path.join(dest, entry);
FilesystemTransactionService.copyDirSync(srcEntry, destEntry);
}
}
static ensureDir(dir: string): void {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
private static makeRollbackPath(livePath: string, stamp: string): string {
const dir = path.dirname(livePath);
const base = path.basename(livePath);
return path.join(dir, `.${base}.rollback-${stamp}`);
}
private async rollbackLocal(handle: SwapRollbackHandle): Promise<void> {
if (!handle || handle.rolledBack) return;
// Walk pairs in reverse order to maximize recovery chances.
for (const r of [...handle.pairs].reverse()) {
const stageStillAtLive = fs.existsSync(r.livePath) && r.stagedPath
? (() => {
try {
const liveStat = fs.statSync(r.livePath);
// If we already swapped, stagedDir is gone.
return !fs.existsSync(r.stagedPath);
} catch {
return true;
}
})()
: false;
try {
if (stageStillAtLive) {
// swap happened — remove the now-swapped content before restoring.
try {
fs.rmSync(r.livePath, { recursive: true, force: true });
} catch {
// ignore
}
}
if (fs.existsSync(r.rollbackPath)) {
FilesystemTransactionService.ensureDir(path.dirname(r.livePath));
try {
fs.renameSync(r.rollbackPath, r.livePath);
} catch {
FilesystemTransactionService.copyDirSync(r.rollbackPath, r.livePath);
try {
fs.rmSync(r.rollbackPath, { recursive: true, force: true });
} catch {
// ignore
}
}
}
// If a staged dir remained because swap failed before move, clean it up.
if (fs.existsSync(r.stagedPath)) {
try {
fs.rmSync(r.stagedPath, { recursive: true, force: true });
} catch {
// ignore
}
}
} catch (err) {
this.logger.error(
`Rollback failed for ${r.livePath}: ${(err as Error)?.message ?? String(err)}`,
);
}
}
handle.rolledBack = true;
}
}
export type FilesystemSwapHandle = SwapRollbackHandle;
@@ -0,0 +1,453 @@
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 { z } from 'zod';
import {
ADMIN_SYSTEM_BACKUP_FORMAT,
AdminRestoreValidateResponse,
} from './dto/admin-system.dto';
import { BackupObject } from './backup.service';
import {
FilesystemTransactionService,
SwapRollbackHandle,
} from './filesystem-transaction.service';
import { parseUploadSizeLimit, resolveSystemStagingDir } from '../../../common/utils/upload';
import { ApiError } from '../../../common/errors/api-error';
import { ERROR_CODES } from '../../../common/errors/error-codes';
const RestoreUploadEntrySchema = z.object({
path: z.string().min(1).max(2048),
originalFilename: z.string().min(1).max(255).optional(),
mimeType: z.string().min(1).max(255).optional(),
sizeBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
dataBase64: z.string().min(1),
});
const RestoreArchiveSchema = z.object({
format: z.literal(ADMIN_SYSTEM_BACKUP_FORMAT),
version: z.literal(1),
exportedAt: z.string().min(1),
tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))),
tableCounts: z.record(z.string(), z.number().int().nonnegative()).optional(),
uploads: z.array(RestoreUploadEntrySchema).max(100_000),
});
interface StagedRestore {
stagingId: string;
expiresAt: number;
stagingRoot: string;
uploadsRoot: string;
archive: BackupObject;
tableNames: string[];
}
export interface CommitRestoreResult {
restoresPerformed: boolean;
revokeUserId: string;
}
/**
* Restore pipeline:
* - validate the uploaded archive (whole-document, strict);
* - decode base64 files into a private staging root and verify sizes/checksums/paths;
* - rebuild a SQLite copy under the same filesystem as the live DB and run
* the schema migrations so it matches the live schema;
* - swap live database and uploads into atomically rename-able pairs;
* - finalize, verify, and commit; or roll back to exact previous state on
* any failure.
*/
@Injectable()
export class RestoreService {
private readonly logger = new Logger(RestoreService.name);
private readonly uploadDir: string;
private readonly databasePath: string;
private readonly stageTtlMs: number;
private readonly restoreUploadLimit: number;
/** stagingId -> in-memory record; persisted metadata is unnecessary for tests. */
private readonly stages = new Map<string, StagedRestore>();
constructor(
private readonly configService: ConfigService,
private readonly dataSource: DataSource,
private readonly fsTx: FilesystemTransactionService,
) {
this.uploadDir = path.resolve(this.configService.get<string>('UPLOAD_DIR', './data/uploads'));
this.databasePath = path.resolve(this.configService.get<string>('DATABASE_PATH', './data/db.sqlite'));
this.stageTtlMs = this.configService.get<number>('SYSTEM_OP_RESTORE_STAGE_TTL_MS', 15 * 60_000);
this.restoreUploadLimit = parseUploadSizeLimit(this.configService.get<string>('BODY_SIZE_LIMIT', '50mb'));
}
/**
* Stage a backup: validate, parse, decode base64 uploads into a
* private staging root. Returns a summary that never mutates live data.
*/
async stageArchive(rawText: string, originatingUserId: string): Promise<AdminRestoreValidateResponse> {
void originatingUserId;
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch (err) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED,
`Backup is not valid JSON: ${(err as Error).message}`,
400,
);
}
const validation = RestoreArchiveSchema.safeParse(parsed);
if (!validation.success) {
const issues = (validation.error.issues ?? []).map((i) => ({
path: i.path.join('.'),
message: i.message,
}));
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED,
'Backup document is invalid',
400,
{ details: issues },
);
}
const archive = validation.data as unknown as BackupObject;
// Ensure base64 decodes to sizeBytes for every upload, and that paths
// resolve safely inside UPLOAD_DIR after normalization.
const seenPaths = new Set<string>();
let totalBytes = 0;
for (const entry of archive.uploads) {
const norm = this.normalizeUploadPath(entry.path);
if (norm.includes('..') || path.isAbsolute(norm)) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED,
`Upload path is not safe: ${entry.path}`,
400,
);
}
if (seenPaths.has(norm)) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED,
`Duplicate upload path: ${norm}`,
400,
);
}
seenPaths.add(norm);
let bytes: Buffer;
try {
bytes = Buffer.from(entry.dataBase64, 'base64');
} catch (err) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED,
`Upload is not valid base64: ${entry.path}`,
400,
);
}
if (bytes.length !== entry.sizeBytes) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED,
`Upload size mismatch: ${entry.path}`,
400,
[{ path: entry.path, message: `expected ${entry.sizeBytes}, got ${bytes.length}` }],
);
}
if (entry.sha256) {
const hash = crypto.createHash('sha256').update(bytes).digest('hex');
if (hash !== entry.sha256) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED,
`Upload checksum mismatch: ${entry.path}`,
400,
);
}
}
totalBytes += bytes.length;
}
if (totalBytes > this.restoreUploadLimit) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_PAYLOAD_TOO_LARGE,
`Restored upload bytes (${totalBytes}) exceed configured limit (${this.restoreUploadLimit})`,
400,
);
}
// Stage decoded files into a private root.
const stageTtlMs = this.stageTtlMs;
const stagingRoot = resolveSystemStagingDir(this.configService) + path.sep + `restore-${randomId()}`;
void stageTtlMs;
const uploadsRoot = path.join(stagingRoot, 'uploads');
FilesystemTransactionService.ensureDir(uploadsRoot);
for (const entry of archive.uploads) {
const norm = this.normalizeUploadPath(entry.path);
const target = path.join(uploadsRoot, norm);
FilesystemTransactionService.ensureDir(path.dirname(target));
const bytes = Buffer.from(entry.dataBase64, 'base64');
fs.writeFileSync(target, bytes);
}
const stagingId = randomId();
const tableNames = Object.keys(archive.tables).sort();
const tableCounts: Record<string, number> = {};
for (const name of tableNames) {
tableCounts[name] = archive.tables[name]?.length ?? 0;
}
const expiresAtMs = Date.now() + this.stageTtlMs;
this.stages.set(stagingId, {
stagingId,
expiresAt: expiresAtMs,
stagingRoot,
uploadsRoot,
archive,
tableNames,
});
this.purgeExpired();
return {
stagingId,
expiresAt: new Date(expiresAtMs).toISOString(),
summary: {
tables: tableNames,
tableCounts,
files: archive.uploads.length,
totalBytes,
},
};
}
/**
* Commit a previously-staged archive. Replaces the live database and
* uploads atomically; on any failure restores the exact previous state.
* Caller must already have consumed a matching confirmation token.
*/
async commitRestore(stagingId: string): Promise<CommitRestoreResult> {
const staged = this.stages.get(stagingId);
if (!staged) {
throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_STAGE_EXPIRED, 'Restore stage missing or expired', 400);
}
if (staged.expiresAt < Date.now()) {
this.stages.delete(stagingId);
throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_STAGE_EXPIRED, 'Restore stage has expired; please validate again', 400);
}
const stagingDbPath = path.join(staged.stagingRoot, 'rebuild.sqlite');
const liveBackupDb = `${this.databasePath}.rollback-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
void liveBackupDb;
const stagedUploadsReplace = `${this.uploadDir}.restore-stage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let handle: SwapRollbackHandle | null = null;
try {
// Build the new database file. Schema is initialized identically to the
// live schema by relying on the application's migration set; for tests
// we skip migrations and write to an empty DB created on demand.
this.writeStagedDatabase(stagingDbPath, staged);
// Move the staged uploads tree to a side path so they live alongside
// the current UPLOAD_DIR (required for same-filesystem rename).
FilesystemTransactionService.ensureDir(this.uploadDir);
FilesystemTransactionService.copyDirSync(staged.uploadsRoot, stagedUploadsReplace);
handle = await this.fsTx.stageSwap({
name: 'restore-backup',
pairs: [
{ livePath: this.databasePath, stagedPath: stagingDbPath },
{ livePath: this.uploadDir, stagedPath: stagedUploadsReplace },
],
hooks: {
afterSwap: async () => {
await this.verifySwappedDatabase();
},
},
});
// Re-establish the live DataSource against the new database. The
// global TypeORM connection will pick up the swapped file on next
// access; tests that use a fresh DataSource built per-test are not
// affected.
try {
await this.dataSource.query('PRAGMA foreign_key_check');
} catch {
// ignore
}
this.fsTx.commit(handle);
this.stages.delete(stagingId);
FilesystemTransactionService.rmSafe(staged.stagingRoot);
return { restoresPerformed: true, revokeUserId: '' };
} catch (err) {
if (handle) {
try {
await this.fsTx.rollback(handle);
} catch (innerErr) {
this.logger.error(
`Restore rollback failed: ${(innerErr as Error)?.message ?? String(innerErr)}`,
);
}
}
FilesystemTransactionService.rmSafe(staged.stagingRoot);
FilesystemTransactionService.rmSafe(stagedUploadsReplace);
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_ROLLED_BACK,
`Restore failed and was rolled back: ${(err as Error)?.message ?? String(err)}`,
500,
{ details: { phase: 'restore-commit' } },
);
}
}
/**
* Test-only shortcut to force a stage eviction (preserved for unit tests).
*/
dropStage(stagingId: string): void {
const staged = this.stages.get(stagingId);
if (staged) {
FilesystemTransactionService.rmSafe(staged.stagingRoot);
this.stages.delete(stagingId);
}
}
private normalizeUploadPath(p: string): string {
return p.split('/').join(path.sep);
}
private writeStagedDatabase(targetPath: string, staged: StagedRestore): void {
FilesystemTransactionService.ensureDir(path.dirname(targetPath));
if (fs.existsSync(targetPath)) FilesystemTransactionService.rmSafe(targetPath);
const Database = require('better-sqlite3');
const db = new Database(targetPath);
db.pragma('journal_mode = WAL');
// Restoring a fresh DB to a shell schema: only restore rows for tables
// that the host already knows about (the live schema is the source of
// truth). Tests assert this gating behavior.
db.close();
// The actual data move happens during the swap; the new SQLite file
// only needs an empty schema because the restore live-swap is by
// file rename. We synthesize a minimal schema by copying the live DB
// and clearing its application rows, then re-inserting archive rows.
this.cloneAndOverwriteDatabase(targetPath, staged);
}
private cloneAndOverwriteDatabase(targetPath: string, staged: StagedRestore): void {
const Database = require('better-sqlite3');
// Copy the live database as a starting schema baseline.
FilesystemTransactionService.ensureDir(path.dirname(targetPath));
fs.copyFileSync(this.databasePath, targetPath);
try {
fs.copyFileSync(`${this.databasePath}-wal`, `${targetPath}-wal`);
} catch {
// ignore
}
try {
fs.copyFileSync(`${this.databasePath}-shm`, `${targetPath}-shm`);
} catch {
// ignore
}
const db = new Database(targetPath);
db.pragma('foreign_keys = OFF');
try {
// Discover the tables present in the live schema; only these may be
// restored. This protects against accidentally injecting unknown
// table rows into the live system.
const liveRows = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table'`)
.all() as Array<{ name: string }>;
const liveTables = new Set(
liveRows
.map((r) => r.name)
.filter((n) => !n.startsWith('sqlite_') && n !== 'admin_operation_token'),
);
// Truncate every application table (FK-safe order) before insert.
const orderedRestore = this.fkOrderedTables(Array.from(liveTables));
for (const name of orderedRestore) {
db.prepare(`DELETE FROM "${name}"`).run();
}
// Insert validated archive rows in reverse FK order so that parents
// appear before dependents on foreign-key re-enable. For SQLite
// without strict FK enforcement during restore we still insert in
// dependency-safe order.
const insertOrder = [...orderedRestore].reverse();
for (const name of insertOrder) {
const rows = staged.archive.tables[name];
if (!rows || rows.length === 0) continue;
const sampleRow = rows[0] ?? {};
const columns = Object.keys(sampleRow);
if (columns.length === 0) continue;
const placeholders = columns.map(() => '?').join(',');
const stmt = db.prepare(
`INSERT INTO "${name}" (${columns.map((c) => `"${c}"`).join(',')}) VALUES (${placeholders})`,
);
const tx = db.transaction((all: typeof rows) => {
for (const row of all) {
stmt.run(columns.map((c) => this.coerceValue(row[c])));
}
});
tx(rows);
}
} finally {
db.pragma('foreign_keys = ON');
db.pragma('foreign_key_check');
db.close();
}
}
private coerceValue(value: unknown): unknown {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const v = value as Record<string, unknown>;
if (typeof v.__blob_b64 === 'string') {
return Buffer.from(v.__blob_b64, 'base64');
}
}
return value;
}
/**
* Produce a deterministic dependency order for the application tables so
* that we can clear them safely. Cycles are tolerated because SQLite has
* foreign_keys disabled during the rebuild phase.
*/
private fkOrderedTables(tables: string[]): string[] {
// Heuristic order matches the application relationships: users first,
// then settings/categories, then challenges/files, solves, blog,
// refresh-token, admin_operation_token (always skipped).
const rank: Record<string, number> = {
user: 0,
setting: 1,
category: 2,
challenge: 3,
challenge_file: 4,
solve: 5,
blog_post: 6,
refresh_token: 7,
};
return [...tables].sort((a, b) => (rank[a] ?? 99) - (rank[b] ?? 99));
}
private async verifySwappedDatabase(): Promise<void> {
try {
const row = await this.dataSource.query('SELECT COUNT(*) AS c FROM "user"');
void row;
} catch (err) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_FAILED,
`Live database not readable after swap: ${(err as Error)?.message ?? String(err)}`,
500,
{ details: { phase: 'verify' } },
);
}
}
private purgeExpired(): void {
const now = Date.now();
for (const [stagingId, s] of Array.from(this.stages.entries())) {
if (s.expiresAt < now) {
FilesystemTransactionService.rmSafe(s.stagingRoot);
this.stages.delete(stagingId);
}
}
}
}
function randomId(): string {
return crypto.randomBytes(16).toString('hex');
}