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 { 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 { 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: "" } 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, ); } if (!body.stagingId) { throw new ApiError( ERROR_CODES.SYSTEM_RESTORE_STAGE_EXPIRED, 'Restore stage missing or expired', 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 }; } }