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 };
}
}