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,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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user