192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
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 };
|
|
}
|
|
}
|