AI Implementation feature(871): Admin Area System: Database Backup/Restore and Danger Zone #61

Merged
m0rph3us1987 merged 2 commits from feature-871-1784805463846 into dev 2026-07-23 12:10:41 +00:00
42 changed files with 3996 additions and 55 deletions
+30
View File
@@ -0,0 +1,30 @@
# Implementation Plan: Job 871 — follow-up tweaks
## 1. Architectural Reconnaissance
- The previous implementation introduced a `RestoreService` that takes a `ConfigService` constructor parameter but does not retain it on `this`. Internally `stageArchive()` calls `resolveSystemStagingDir(this.requireConfig())`, where `requireConfig()` returns the parameter via a cast `(this as any).configService`. This works but is a hack — the parameter should simply be retained as a class field.
- The previous `DangerZoneService.wipeChallenges()` snapshots `<UPLOAD_DIR>/challenges` to a side directory, transactionally deletes the DB rows, and then removes the snapshot. It currently never actually deletes the live `<UPLOAD_DIR>/challenges` directory on success, so the filesystem files persist even though the DB rows are gone. The user wants the live directory physically removed after the DB commit succeeds, with the snapshot retained as the rollback source until the disk delete is complete so that a disk-delete failure can be restored from it.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/admin/system/restore.service.ts` — retain `ConfigService` on the instance and stop using the `requireConfig()` shim; use it directly in `stageArchive()`.
- `backend/src/modules/admin/system/danger-zone.service.ts` — in `wipeChallenges()`, after the DB transaction commits, physically remove the live `<UPLOAD_DIR>/challenges` directory, and only after that succeeds remove the snapshot; on any failure after the snapshot was created, copy the snapshot back into place so a disk-delete failure can be reversed.
## 3. Proposed Changes
1. **`restore.service.ts` cleanup:**
- Convert the constructor's `config: ConfigService` parameter into `private readonly configService: ConfigService` so it is stored on the instance.
- At the top of the constructor body, capture each derived value (`uploadDir`, `databasePath`, `stageTtlMs`, `restoreUploadLimit`) from `configService` once.
- Remove the private `requireConfig()` shim method entirely.
- In `stageArchive()`, change the call to `resolveSystemStagingDir(this.configService)`.
2. **`danger-zone.service.ts` physical delete:**
- In `wipeChallenges()`, after the `dataSource.transaction(...)` block returns successfully (we already have the row counts in `counts`), add: `FilesystemTransactionService.rmSafe(challengesDir)` to physically remove the live `<UPLOAD_DIR>/challenges` directory and its contents.
- If that rm succeeds, then `rmSafe` the `stagingBackup` snapshot.
- If the rm of `challengesDir` itself throws, retain the snapshot so the catch branch can restore from it (keep `staged = true`), rethrow the error to enter the catch branch, and from the existing catch branch copy `stagingBackup` back into `challengesDir` exactly as before. Update the in-method narrative so that order of operations matches: stage → db commit → live rm → snapshot rm.
- Do not change the DB ordering: the transaction must commit before any filesystem delete so a DB failure does not leave a partial filesystem state.
## 4. Test Strategy
- **Target Unit Test File:**
- Existing `tests/backend/admin-system-danger.spec.ts` already verifies that after `wipe-challenges` the `challenge` table is empty. Extend it with one assertion: after a successful wipe, the live `<UPLOAD_DIR>/challenges` directory is also physically empty/absent.
- Existing `tests/backend/admin-system-backup.spec.ts` and `tests/backend/admin-system-restore-validation.spec.ts` already validate `BackupService` and `RestoreService.stageArchive()` behavior; no behavioral changes are expected from the constructor cleanup since `stageArchive()` still resolves the same staging directory. Re-run those tests after the edit to confirm no regressions.
- No new frontend tests are required — these are strictly backend concerns.
- **Mocking Strategy:** Reuse the existing per-suite isolated temp upload directories (`mkdtempSync(path.join(os.tmpdir(), 'hipctf-…'))`) and assert against the live path with `fs.existsSync`/`fs.readdirSync`. No new mocking infrastructure is needed; the danger-zone logic now relies on the same primitives (fs + SQLite transaction) the existing tests already exercise.
- **Verification:** Run `npm test` to confirm both `admin-system-danger.spec.ts` and the related system suites stay green; also run `npx --workspace backend tsc -p tsconfig.build.json --noEmit` to confirm the `requireConfig()` removal does not break the build.
-48
View File
@@ -1,48 +0,0 @@
# Implementation Plan: Job 915 — Enable Admin Blog Post Deletion Confirmation
## Status
The requested job is **not fully implemented**. The backend DELETE endpoint, persistence service, Angular API client, parent delete workflow, and confirmation modal already exist, but the parent binds the modal's disabled state to the selected post rather than the request-in-flight state. The UI therefore cannot complete an otherwise wired deletion flow.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js npm-workspace monorepo using TypeScript, NestJS 10 on the backend, and Angular 17 standalone components on the frontend. Angular admin pages use smart/container components with `signal()` state, signal inputs/outputs, `ChangeDetectionStrategy.OnPush`, and Angular control-flow syntax. The admin blog parent owns service calls and modal lifecycle; `BlogDeleteModalComponent` is presentational and only receives inputs/emits cancel/confirm events.
- **Data Layer:** TypeORM `BlogPostEntity` backed by SQLite via `better-sqlite3`. Blog deletion is already implemented in `backend/src/modules/blog/admin-blog.service.ts:65-69` with a repository lookup, not-found error, and `delete({ id })`; no schema or migration change is required.
- **Test Framework & Structure:** Root Jest multi-project configuration in `tests/jest.config.js`, with frontend tests under the dedicated `tests/frontend/` folder and backend tests under `tests/backend/`. The root `npm test` command runs all projects; focused frontend execution is available through `npm run test:frontend`. Existing blog tests are mostly pure-logic/source-contract tests, while the API suite already covers DELETE persistence and 404 behavior.
- **Required Tools & Dependencies:** No new package or system dependency is required. Existing Node/npm, Angular, Jest, jsdom, ts-jest, and repository dependencies are sufficient. `setup.sh` needs no change; it already installs dependencies, rebuilds `better-sqlite3`, and builds both workspaces. No `/data` fixture or persistent test asset is needed because this is a frontend state-binding fix and existing backend tests use in-memory SQLite.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/admin/blog/blog.component.ts` — bind the delete modal's `deleting` input to `actionInFlight()` rather than `deleting() !== null`, preserving the selected post while enabling confirmation before the request starts and disabling it during the request.
- `tests/frontend/blog-admin-delete.spec.ts` — add a minimal focused regression test in the dedicated frontend test folder covering the modal binding and deletion state contract.
- **To Create:**
- `tests/frontend/blog-admin-delete.spec.ts` — new focused frontend regression test file, if no existing delete-modal/admin-page test is suitable for extension.
No backend controller, service, entity, DTO, route, database migration, API client, delete modal component, documentation, dependency manifest, or setup script needs modification.
## 3. Proposed Changes
1. **Database / Schema Migration:**
- No change. `AdminBlogService.remove()` already deletes the requested `blog_post` row and returns not-found for a missing ID.
- Do not add migration or seed data. The expected persistence behavior is already provided by the existing DELETE API and is covered by `tests/backend/blog-admin.spec.ts:220-240`.
2. **Backend Logic & APIs:**
- No change. `AdminBlogController.remove()` already exposes `DELETE /api/v1/admin/blog/posts/:id`, applies the admin guard/role protection, and returns HTTP 204.
- `BlogApiService.deletePost()` already calls the endpoint with the encoded ID and credentials.
- The parent `AdminBlogComponent.onDeleteConfirm()` already sets `actionInFlight` to true before awaiting the DELETE request, closes the modal after success, reloads the complete admin list, reports `Post deleted.`, and resets the in-flight signal in `finally`. Its error path keeps the modal open and exposes the server message.
3. **Frontend UI Integration:**
- In `frontend/src/app/features/admin/blog/blog.component.ts:111-118`, change only the `[deleting]` binding from `[deleting]="deleting() !== null"` to `[deleting]="actionInFlight()"`.
- Keep `[post]="deleting()"` unchanged so the modal continues displaying the selected title and the parent retains the target ID until success or cancel.
- Keep the existing `actionInFlight` signal shared by create, edit, and delete operations. When `openDelete()` sets the selected post and opens the modal, `actionInFlight()` remains false, so `post-delete-ok` is enabled. When `onDeleteConfirm()` begins, it becomes true and the modal disables Delete while awaiting the request. On success, `closeModal()` clears the selection and `load()` refreshes the table; on failure, the selection/modal remain visible and the button is re-enabled after `finally` so retry is possible.
- Do not change `BlogDeleteModalComponent`: its `[disabled]="deleting()"` behavior is already correct when supplied the proper request-state signal.
## 4. Test Strategy
- **Target Unit Test File:** Add `tests/frontend/blog-admin-delete.spec.ts` under the existing dedicated Jest frontend test root. The test should be minimal and focused on the regression contract, not a browser/UI visual test.
- **Test cases:**
1. Read/verify the admin component template contract that the delete modal receives `actionInFlight()` for its `deleting` input, and that it no longer derives the disabled state from the selected `deleting()` post. This matches the repository's existing lightweight source-contract testing style and directly catches reintroduction of the bug.
2. Optionally instantiate the standalone `BlogDeleteModalComponent` with signal inputs using the existing Jest/jsdom Angular setup, set `deleting` false and true via `fixture.componentRef.setInput`, run `detectChanges()`, and assert the confirm button is enabled before the request and disabled during it. Keep this only if the current test environment supports standalone component compilation without introducing infrastructure; otherwise the source-contract test is sufficient for this one-line wiring regression.
- **Mocking Strategy:** Do not involve HTTP, the backend, SQLite, authentication, or real navigation in the focused frontend test. If component instantiation is used, provide only the modal's standalone imports and use signal input APIs; no service mock is needed because the modal emits outputs and contains no service dependency. The existing backend DELETE integration test remains the persistence/error boundary test.
- **Verification:** Run the single root command `npm test` after implementation so both existing backend API coverage and the new frontend regression test execute. The implementer should also run the repository's available frontend-focused Jest project if needed during iteration; no new command or setup change is required.
+17
View File
@@ -30,6 +30,23 @@ export const ERROR_CODES = {
EVENT_NOT_RUNNING: 'EVENT_NOT_RUNNING', EVENT_NOT_RUNNING: 'EVENT_NOT_RUNNING',
FLAG_INCORRECT: 'FLAG_INCORRECT', FLAG_INCORRECT: 'FLAG_INCORRECT',
CHALLENGE_DISABLED: 'CHALLENGE_DISABLED', CHALLENGE_DISABLED: 'CHALLENGE_DISABLED',
SYSTEM_BACKUP_FAILED: 'SYSTEM_BACKUP_FAILED',
SYSTEM_RESTORE_VALIDATION_FAILED: 'SYSTEM_RESTORE_VALIDATION_FAILED',
SYSTEM_RESTORE_PAYLOAD_TOO_LARGE: 'SYSTEM_RESTORE_PAYLOAD_TOO_LARGE',
SYSTEM_RESTORE_STAGE_EXPIRED: 'SYSTEM_RESTORE_STAGE_EXPIRED',
SYSTEM_RESTORE_FAILED: 'SYSTEM_RESTORE_FAILED',
SYSTEM_RESTORE_ROLLED_BACK: 'SYSTEM_RESTORE_ROLLED_BACK',
SYSTEM_OPERATION_IN_PROGRESS: 'SYSTEM_OPERATION_IN_PROGRESS',
SYSTEM_REAUTH_REQUIRED: 'SYSTEM_REAUTH_REQUIRED',
SYSTEM_INVALID_CREDENTIALS: 'SYSTEM_INVALID_CREDENTIALS',
SYSTEM_TOKEN_INVALID: 'SYSTEM_TOKEN_INVALID',
SYSTEM_TOKEN_EXPIRED: 'SYSTEM_TOKEN_EXPIRED',
SYSTEM_TOKEN_REUSED: 'SYSTEM_TOKEN_REUSED',
SYSTEM_TOKEN_MISMATCH: 'SYSTEM_TOKEN_MISMATCH',
SYSTEM_DANGER_FAILED: 'SYSTEM_DANGER_FAILED',
SYSTEM_DANGER_ROLLED_BACK: 'SYSTEM_DANGER_ROLLED_BACK',
SYSTEM_FILE_READ_FAILED: 'SYSTEM_FILE_READ_FAILED',
SYSTEM_PAYLOAD_INVALID: 'SYSTEM_PAYLOAD_INVALID',
INTERNAL: 'INTERNAL', INTERNAL: 'INTERNAL',
} as const; } as const;
+35
View File
@@ -73,3 +73,38 @@ export function buildMulter(config: ConfigService, opts: MulterOptions = {}): mu
limits, limits,
}); });
} }
/**
* Parse a CONFIG-specified request size limit (defaults to the configured
* BODY_SIZE_LIMIT, falling back to '1mb'). Centralized here so the
* system operation endpoints can enforce the same ceiling as
* `main.ts`'s body parser.
*/
export function getRequestSizeLimit(config: ConfigService): number {
const raw = config.get<string>('BODY_SIZE_LIMIT', '1mb');
return parseUploadSizeLimit(raw);
}
/**
* Resolve and ensure a private staging directory dedicated to system
* operations (backup/restore staging). Defaults to `<DATA_DIR>/.system-staging`
* and never returns a directory inside UPLOAD_DIR.
*/
export function resolveSystemStagingDir(config: ConfigService): string {
const explicit = config.get<string>('SYSTEM_OP_STAGING_DIR');
const uploadDir = path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
let dir: string;
if (explicit && explicit.trim().length > 0) {
dir = path.resolve(explicit);
} else {
const databasePath = config.get<string>('DATABASE_PATH', './data/db.sqlite');
const parent = path.dirname(path.resolve(databasePath));
dir = path.join(parent, '.system-staging');
}
if (dir.startsWith(uploadDir + path.sep) || dir === uploadDir) {
// Never let staging nest under the public upload tree.
dir = path.join(uploadDir, '..', '.system-staging');
}
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
+4
View File
@@ -28,6 +28,10 @@ export const envSchema = z.object({
UPLOAD_SIZE_LIMIT: z.string().default('50mb'), UPLOAD_SIZE_LIMIT: z.string().default('50mb'),
TLS_ENABLED: z.coerce.boolean().default(false), TLS_ENABLED: z.coerce.boolean().default(false),
SYSTEM_OP_STAGING_DIR: z.string().min(1).optional(),
SYSTEM_OP_RESTORE_STAGE_TTL_MS: z.coerce.number().int().positive().default(15 * 60_000),
SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: z.coerce.number().int().positive().default(5 * 60_000),
}); });
export type AppEnv = z.infer<typeof envSchema>; export type AppEnv = z.infer<typeof envSchema>;
+4
View File
@@ -11,6 +11,7 @@ import { ChallengeFileEntity } from './entities/challenge-file.entity';
import { SolveEntity } from './entities/solve.entity'; import { SolveEntity } from './entities/solve.entity';
import { RefreshTokenEntity } from './entities/refresh-token.entity'; import { RefreshTokenEntity } from './entities/refresh-token.entity';
import { BlogPostEntity } from './entities/blog-post.entity'; import { BlogPostEntity } from './entities/blog-post.entity';
import { AdminOperationTokenEntity } from './entities/admin-operation-token.entity';
import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema'; import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData'; import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev'; import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
@@ -20,6 +21,7 @@ import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/170000000
import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges'; import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges';
import { AddUserDeleteCascades1700000000700 } from './migrations/1700000000700-AddUserDeleteCascades'; import { AddUserDeleteCascades1700000000700 } from './migrations/1700000000700-AddUserDeleteCascades';
import { AddUserLastAdminTriggers1700000000800 } from './migrations/1700000000800-AddUserLastAdminTriggers'; import { AddUserLastAdminTriggers1700000000800 } from './migrations/1700000000800-AddUserLastAdminTriggers';
import { AddAdminOperationTokens1700000000900 } from './migrations/1700000000900-AddAdminOperationTokens';
import { DatabaseInitService } from './database-init.service'; import { DatabaseInitService } from './database-init.service';
const ENTITIES = [ const ENTITIES = [
@@ -31,6 +33,7 @@ const ENTITIES = [
SolveEntity, SolveEntity,
RefreshTokenEntity, RefreshTokenEntity,
BlogPostEntity, BlogPostEntity,
AdminOperationTokenEntity,
]; ];
const MIGRATIONS = [ const MIGRATIONS = [
@@ -43,6 +46,7 @@ const MIGRATIONS = [
SeedSampleChallenges1700000000600, SeedSampleChallenges1700000000600,
AddUserDeleteCascades1700000000700, AddUserDeleteCascades1700000000700,
AddUserLastAdminTriggers1700000000800, AddUserLastAdminTriggers1700000000800,
AddAdminOperationTokens1700000000900,
]; ];
@Global() @Global()
@@ -0,0 +1,41 @@
import { Entity, PrimaryColumn, Column, Index, ManyToOne, JoinColumn } from 'typeorm';
import { UserEntity } from './user.entity';
export const ADMIN_OPERATION_KINDS = [
'restore-backup',
'reset-scores',
'wipe-challenges',
] as const;
export type AdminOperationKind = (typeof ADMIN_OPERATION_KINDS)[number];
@Entity('admin_operation_token')
export class AdminOperationTokenEntity {
@PrimaryColumn('text')
id!: string;
@Index()
@Column('text', { name: 'user_id' })
userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: UserEntity;
@Column('text')
operation!: AdminOperationKind;
@Index({ unique: true })
@Column('text', { name: 'token_hash' })
tokenHash!: string;
@Column('text', { name: 'issued_at' })
issuedAt!: string;
@Index()
@Column('text', { name: 'expires_at' })
expiresAt!: string;
@Column('text', { name: 'consumed_at', nullable: true })
consumedAt!: string | null;
}
@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAdminOperationTokens1700000000900 implements MigrationInterface {
name = 'AddAdminOperationTokens1700000000900';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "admin_operation_token" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"issued_at" TEXT NOT NULL,
"expires_at" TEXT NOT NULL,
"consumed_at" TEXT,
CONSTRAINT "fk_admin_op_token_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_admin_op_token_hash" ON "admin_operation_token"("token_hash");`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "idx_admin_op_token_user_op" ON "admin_operation_token"("user_id","operation");`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "idx_admin_op_token_expiry" ON "admin_operation_token"("expires_at");`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "idx_admin_op_token_expiry";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_admin_op_token_user_op";`);
await queryRunner.query(`DROP INDEX IF EXISTS "uq_admin_op_token_hash";`);
await queryRunner.query(`DROP TABLE IF EXISTS "admin_operation_token";`);
}
}
@@ -18,6 +18,7 @@ import { AdminCategoriesService } from './categories.service';
import { AdminChallengesController } from './admin-challenges.controller'; import { AdminChallengesController } from './admin-challenges.controller';
import { AdminChallengesService } from './challenges.service'; import { AdminChallengesService } from './challenges.service';
import { ChallengeFilesService } from './challenge-files.service'; import { ChallengeFilesService } from './challenge-files.service';
import { AdminSystemModule } from './system/admin-system.module';
@Module({ @Module({
imports: [ imports: [
@@ -26,6 +27,7 @@ import { ChallengeFilesService } from './challenge-files.service';
UsersModule, UsersModule,
SettingsModule, SettingsModule,
CommonModule, CommonModule,
AdminSystemModule,
], ],
providers: [ providers: [
AdminService, AdminService,
@@ -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');
}
+40
View File
@@ -225,6 +225,46 @@ export class AuthService implements OnModuleInit {
}); });
} }
/**
* Reauthenticate the supplied administrator by re-checking their password
* with Argon2 and verifying that their status is still 'enabled' and
* their role is still 'admin'. Returns the verified user entity or
* throws an `ApiError` with a stable error code consumed by the system
* operation endpoints.
*/
async reauthenticateAdmin(userId: string, password: string): Promise<UserEntity> {
if (!password || typeof password !== 'string') {
throw new ApiError(ERROR_CODES.SYSTEM_INVALID_CREDENTIALS, 'Password is required', 401);
}
const user = await this.users.findOne({ where: { id: userId } });
if (!user || user.status !== 'enabled' || user.role !== 'admin') {
throw new ApiError(ERROR_CODES.SYSTEM_REAUTH_REQUIRED, 'Administrator re-authentication required', 401);
}
const ok = await argon2.verify(user.passwordHash, password);
if (!ok) {
throw new ApiError(ERROR_CODES.SYSTEM_INVALID_CREDENTIALS, 'Current password is incorrect', 401);
}
return user;
}
/**
* Revoke every refresh-token row for the supplied user, marking them as
* consumed. Used after a successful restore so subsequent refresh attempts
* are rejected server-side.
*/
async revokeAllRefreshSessions(userId: string, manager?: EntityManager): Promise<number> {
const exec = (m: EntityManager) =>
m
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId })
.andWhere('revokedAt IS NULL')
.execute();
const result = manager ? await exec(manager) : await exec(this.dataSource.manager);
return result.affected ?? 0;
}
/** /**
* Mint a fresh access JWT and a new refresh token row inside the supplied * Mint a fresh access JWT and a new refresh token row inside the supplied
* transaction. The refresh token (raw) is returned alongside the access * transaction. The refresh token (raw) is returned alongside the access
+291
View File
@@ -0,0 +1,291 @@
---
type: api
title: Admin System Endpoints
description: Admin-only destructive System operations: backup download, restore validate/commit, re-authenticated confirmation tokens, reset-scores, and wipe-challenges.
resource: backend/src/modules/admin/system/admin-system.controller.ts
tags: [api, admin, system, backup, restore, danger-zone, confirmation-tokens]
timestamp: 2026-07-23T12:04:50Z
---
# Overview
All handlers below are mounted under
`/api/v1/admin/system` and require a JWT-protected admin session
(`AdminGuard` + `@Roles('admin')`). Every destructive operation is
gated by a single-use confirmation token issued by the re-authentication
endpoint:
```
client ──reauth──▶ POST /confirmations ──token──▶ client
client ──commit──▶ POST /restore/commit | /scores/reset | /challenges/wipe
```
The token table is documented at
[/database/admin-operation-tokens.md](/database/admin-operation-tokens.md).
# Endpoints
| Method | Path | Source |
|--------|--------------------------------------------|-----------------------------------------------------------|
| `GET` | `/api/v1/admin/system/backup` | `admin-system.controller.ts``BackupService.build()` |
| `POST` | `/api/v1/admin/system/restore/validate` | `admin-system.controller.ts``RestoreService.stageArchive()` |
| `POST` | `/api/v1/admin/system/restore/commit` | `admin-system.controller.ts``RestoreService.commitRestore()` + `AuthService.revokeAllRefreshSessions()` |
| `POST` | `/api/v1/admin/system/confirmations` | `admin-system.controller.ts``AuthService.reauthenticateAdmin()` + `ConfirmationTokenService.issue()` |
| `POST` | `/api/v1/admin/system/scores/reset` | `admin-system.controller.ts``DangerZoneService.resetScores()` |
| `POST` | `/api/v1/admin/system/challenges/wipe` | `admin-system.controller.ts``DangerZoneService.wipeChallenges()` |
## GET /backup
Streams a complete platform snapshot as a JSON attachment. The body has
the shape:
```jsonc
{
"format": "hipctf-system-backup",
"version": 1,
"exportedAt": "2026-07-23T12:00:00.000Z",
"tables": { "user": [...], "challenge": [...], /* every application table */ },
"tableCounts": { "user": 12, "challenge": 4 },
"uploads": [
{ "path": "challenges/foo.bin",
"originalFilename": "foo.bin",
"sizeBytes": 1024,
"sha256": "...", "dataBase64": "..." }
]
}
```
Internal tables (`admin_operation_token`, `migrations`, all
`sqlite_*`) are excluded. The upload root is walked recursively and the
`.staging` directory is skipped. Files are base64-encoded inline so the
artifact is a single self-contained JSON file.
The response uses
`Content-Disposition: attachment; filename="hipctf-backup-<ISO>.json"`.
## POST /restore/validate
Body:
```jsonc
{ "text": "<raw backup JSON string>" } // or
{ "archive": { /* already-parsed object */ } }
```
The body is parsed with a strict zod schema (`RestoreArchiveSchema`).
Validation enforces:
* `format === 'hipctf-system-backup'`, `version === 1`
* every upload entry has a safe, non-`..` path, decodes from
base64 to exactly `sizeBytes`, and (when provided) matches `sha256`
* total restored bytes ≤ `BODY_SIZE_LIMIT`
Successful validation returns a `stagingId` plus a summary (sorted
table list, per-table counts, file count, total bytes, ISO expiresAt):
```jsonc
{
"stagingId": "f0c9...",
"expiresAt": "2026-07-23T12:15:00.000Z",
"summary": {
"tables": ["category", "challenge", "user", /* ... */],
"tableCounts": { "user": 12, "challenge": 4 },
"files": 3,
"totalBytes": 8192
}
}
```
The stage lives in-memory for `SYSTEM_OP_RESTORE_STAGE_TTL_MS` (default
15 min) and inside `<DATA_DIR>/.system-staging/restore-<id>/`.
Errors:
* `SYSTEM_RESTORE_VALIDATION_FAILED` (400) — JSON parse, schema, safe
path, duplicate path, base64, size mismatch, or checksum mismatch.
* `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE` (400) — total bytes over
`BODY_SIZE_LIMIT`.
## POST /restore/commit
Body:
```jsonc
{
"operation": "restore-backup",
"token": "<confirmation token>",
"stagingId": "<stage returned from validate>"
}
```
Flow:
1. Verifies the admin role and that `operation === 'restore-backup'`.
2. `ConfirmationTokenService.consume()` verifies the token belongs to
this admin, is for `restore-backup`, is unexpired, and is unused.
3. `RestoreService.commitRestore(stagingId)` builds a new SQLite file
by cloning the live DB, then clearing every application table and
inserting the archived rows in FK-safe order. Uploads are copied
aside as `<UPLOAD_DIR>.restore-stage-<stamp>` and the live
database + uploads are swapped atomically via
`FilesystemTransactionService.stageSwap()` (with a `restore-backup`
rollback handle).
4. On any failure the swap is rolled back, the staged tree is removed,
and the error is mapped to `SYSTEM_RESTORE_ROLLED_BACK` (500).
5. On success, every refresh token belonging to the current admin is
revoked via `AuthService.revokeAllRefreshSessions()` so the SPA is
forced back to `/login`.
Returns `{ ok: true, operation: 'restore-backup' }`.
## POST /confirmations
Body:
```jsonc
{
"operation": "restore-backup" | "reset-scores" | "wipe-challenges",
"password": "<current admin password>",
"stagingId": "<optional, only when restoring>"
}
```
Flow:
1. `AuthService.reauthenticateAdmin(userId, password)` verifies the
user is still enabled + admin and the Argon2id password matches.
2. `ConfirmationTokenService.issue()` stores a SHA-256 hash of the
generated token and returns the raw token plus its `expiresAt`.
Returns:
```jsonc
{ "operation": "restore-backup", "token": "<raw>", "expiresAt": "2026-07-23T12:05:00.000Z" }
```
Errors:
* `SYSTEM_INVALID_CREDENTIALS` (401) — wrong password.
* `SYSTEM_REAUTH_REQUIRED` (401) — user is no longer admin/disabled.
## POST /scores/reset
Body:
```jsonc
{ "operation": "reset-scores", "token": "<confirmation token>" }
```
Consumes a `reset-scores` token, then `DangerZoneService.resetScores()`
runs a single `DELETE FROM solve` inside a transaction. Returns
`{ ok: true, solvesRemoved: N }`. Failure maps to `SYSTEM_DANGER_FAILED`
(500).
## POST /challenges/wipe
Body:
```jsonc
{ "operation": "wipe-challenges", "token": "<confirmation token>" }
```
Consumes a `wipe-challenges` token, then
`DangerZoneService.wipeChallenges()`:
1. Snapshots `<UPLOAD_DIR>/challenges` to `<challengesDir>.wipe-stage-<ts>`.
2. Runs a SQLite transaction that deletes every `challenge` row
(cascading via FK into `challenge_file` and `solve`).
3. After the DB commits, physically removes the live
`<UPLOAD_DIR>/challenges` directory. If the rm fails, the snapshot
is copied back into place.
4. Removes the snapshot only once the live tree is gone.
Returns
`{ ok: true, challengesRemoved: N, challengeFilesRemoved: M, solvesRemoved: K }`.
Failure maps to `SYSTEM_DANGER_ROLLED_BACK` (500).
# Error codes
| Code | HTTP | Source |
|-----------------------------------|------|-------------------------------------------------|
| `SYSTEM_BACKUP_FAILED` | 500 | `BackupService` table/file read failure |
| `SYSTEM_FILE_READ_FAILED` | 500 | `BackupService` upload walk failure |
| `SYSTEM_RESTORE_VALIDATION_FAILED`| 400 | `RestoreService` archive schema/size/sha mismatch |
| `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE`| 400 | Restored bytes exceed `BODY_SIZE_LIMIT` |
| `SYSTEM_RESTORE_STAGE_EXPIRED` | 400 | `stagingId` missing or expired |
| `SYSTEM_RESTORE_FAILED` | 500 | Live DB not readable after swap |
| `SYSTEM_RESTORE_ROLLED_BACK` | 500 | Restore aborted; original state restored |
| `SYSTEM_DANGER_FAILED` | 500 | Reset-scores internal error |
| `SYSTEM_DANGER_ROLLED_BACK` | 500 | Reset/wipe failed; data unchanged |
| `SYSTEM_PAYLOAD_INVALID` | 400 | `restore/validate` got neither `text` nor `archive` |
| `SYSTEM_REAUTH_REQUIRED` | 401 | `reauthenticateAdmin` user no longer admin/enabled |
| `SYSTEM_INVALID_CREDENTIALS` | 401 | Wrong password |
| `SYSTEM_TOKEN_INVALID` | 400 | Unknown token hash |
| `SYSTEM_TOKEN_EXPIRED` | 400 | Token `expiresAt` < now |
| `SYSTEM_TOKEN_REUSED` | 400 | Token already consumed |
| `SYSTEM_TOKEN_MISMATCH` | 400 | Token issued for a different operation/user |
# Examples
## Backup → restore on a fresh install
```bash
# 1. Download a backup
curl -sS -b cookies.txt -c cookies.txt \
-X GET 'http://localhost:3000/api/v1/admin/system/backup' \
-o hipctf-backup.json
# 2. Validate (and stage) it
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/restore/validate' \
-d "{\"text\": \"$(cat hipctf-backup.json | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')\"}"
# => {"stagingId":"<id>", "expiresAt":"...", "summary":{...}}
# 3. Re-authenticate to get a confirmation token
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/confirmations' \
-d '{"operation":"restore-backup","password":"<currentPassword>","stagingId":"<id>"}'
# => {"operation":"restore-backup","token":"<raw>","expiresAt":"..."}
# 4. Commit
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/restore/commit' \
-d '{"operation":"restore-backup","token":"<raw>","stagingId":"<id>"}'
```
## Reset scores
```bash
# 1. Re-auth
curl -sS ... -d '{"operation":"reset-scores","password":"..."}' # → token
# 2. Commit
curl -sS ... -d '{"operation":"reset-scores","token":"<raw>"}'
# → {"ok":true,"solvesRemoved":42}
```
# Wiring
| Concern | File |
|------------------------------|---------------------------------------------------------------------------------|
| Controller | `backend/src/modules/admin/system/admin-system.controller.ts` |
| Module | `backend/src/modules/admin/system/admin-system.module.ts` |
| Backup | `backend/src/modules/admin/system/backup.service.ts` |
| Restore | `backend/src/modules/admin/system/restore.service.ts` |
| Confirmation tokens | `backend/src/modules/admin/system/confirmation-token.service.ts` |
| Danger zone | `backend/src/modules/admin/system/danger-zone.service.ts` |
| Filesystem swap | `backend/src/modules/admin/system/filesystem-transaction.service.ts` |
| Staging dir helper | `backend/src/common/utils/upload.ts` (`resolveSystemStagingDir`) |
| Password re-auth / revoke | `backend/src/modules/auth/auth.service.ts` (`reauthenticateAdmin`, `revokeAllRefreshSessions`) |
| Error codes | `backend/src/common/errors/error-codes.ts` |
| Front-end page | `frontend/src/app/features/admin/system/system.component.ts` |
| Front-end modal | `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` |
| Front-end service | `frontend/src/app/features/admin/system/system.service.ts` |
| Front-end pure helpers | `frontend/src/app/features/admin/system/system.pure.ts` |
| Front-end nav entry | `frontend/src/app/features/admin/admin-shell.component.ts` |
| Front-end route | `frontend/src/app/app.routes.ts` (admin child `system`) |
| Cross-store invalidation | `frontend/src/app/core/services/system-data-change.service.ts` |
| Forced logout helper | `frontend/src/app/core/services/auth.service.ts` (`forceServerInvalidation`) |
| Tests | `tests/backend/admin-system-authorization.spec.ts`, `admin-system-backup.spec.ts`, `admin-system-confirmation-token.spec.ts`, `admin-system-danger.spec.ts`, `admin-system-restore-validation.spec.ts`; `tests/frontend/admin-system.pure.spec.ts` |
+4 -1
View File
@@ -27,7 +27,8 @@ also registers two global providers:
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). | | `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). |
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. | | `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. |
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. | | `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Four controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`, `AdminChallengesController` for `/api/v1/admin/challenges*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). | | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Five controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`, `AdminChallengesController` for `/api/v1/admin/challenges*`, `AdminSystemController` for `/api/v1/admin/system/*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`, `BackupService`, `RestoreService`, `DangerZoneService`, `FilesystemTransactionService`, `ConfirmationTokenService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, `CommonModule` (for `SseHubService`, `ThemeLoaderService`), and `AdminSystemModule` (which adds `AdminOperationTokenEntity` + `AuthModule`). |
| `AdminSystemModule` | `backend/src/modules/admin/system/admin-system.module.ts` | Hosts the destructive System operations: `AdminSystemController` + `BackupService` + `RestoreService` + `DangerZoneService` + `FilesystemTransactionService` + `ConfirmationTokenService`. Imports `TypeOrmModule.forFeature([AdminOperationTokenEntity])` and `AuthModule` (for `reauthenticateAdmin`/`revokeAllRefreshSessions`). |
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController`/`BlogService` expose published posts at `GET /api/v1/blog/posts`; `AdminBlogController`/`AdminBlogService` provide guarded CRUD at `/api/v1/admin/blog/posts` using `BlogPostEntity`. | | `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController`/`BlogService` expose published posts at `GET /api/v1/blog/posts`; `AdminBlogController`/`AdminBlogService` provide guarded CRUD at `/api/v1/admin/blog/posts` using `BlogPostEntity`. |
| `ChallengesModule` | `backend/src/modules/challenges/challenges.module.ts` | `ChallengesController` (`/api/v1/challenges/{board,status,:id,:id/solves}`) + `ChallengesEventsController` (authenticated SSE `/api/v1/events`) + `ChallengesService` (board, detail, submit, scoring util). | | `ChallengesModule` | `backend/src/modules/challenges/challenges.module.ts` | `ChallengesController` (`/api/v1/challenges/{board,status,:id,:id/solves}`) + `ChallengesEventsController` (authenticated SSE `/api/v1/events`) + `ChallengesService` (board, detail, submit, scoring util). |
@@ -44,6 +45,7 @@ also registers two global providers:
| `AdminGeneralController` | `/api/v1/admin/general` | Admin only | `backend/src/modules/admin/admin-general.controller.ts` | | `AdminGeneralController` | `/api/v1/admin/general` | Admin only | `backend/src/modules/admin/admin-general.controller.ts` |
| `AdminCategoriesController` | `/api/v1/admin/categories` | Admin only | `backend/src/modules/admin/admin-categories.controller.ts` | | `AdminCategoriesController` | `/api/v1/admin/categories` | Admin only | `backend/src/modules/admin/admin-categories.controller.ts` |
| `AdminChallengesController` | `/api/v1/admin/challenges` | Admin only | `backend/src/modules/admin/admin-challenges.controller.ts` | | `AdminChallengesController` | `/api/v1/admin/challenges` | Admin only | `backend/src/modules/admin/admin-challenges.controller.ts` |
| `AdminSystemController` | `/api/v1/admin/system` | Admin only (plus confirmation tokens for destructive ops) | `backend/src/modules/admin/system/admin-system.controller.ts` |
| `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` | | `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` |
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` | | `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
@@ -91,3 +93,4 @@ also registers two global providers:
- [Key Files Index](/architecture/key-files.md) - [Key Files Index](/architecture/key-files.md)
- [REST API Overview](/api/rest-overview.md) - [REST API Overview](/api/rest-overview.md)
- [Blog API](/api/blog.md) - [Blog API](/api/blog.md)
- [Admin System API](/api/admin-system.md)
+33
View File
@@ -123,6 +123,36 @@ timestamp: 2026-07-23T10:12:24Z
| `tests/backend/scoreboard.service.spec.ts` | Service-level tests covering `computeAwardedPoints` parity with `submitFlag`, color-index stability, matrix ordering, and graph state handling. | | `tests/backend/scoreboard.service.spec.ts` | Service-level tests covering `computeAwardedPoints` parity with `submitFlag`, color-index stability, matrix ordering, and graph state handling. |
| `tests/frontend/scoreboard.pure.spec.ts` | Pure helpers: `applyRankingSort` (competition-rank numbering), `parseSolveEventIntoRanking` (add-or-increment + sort), `dedupEventLogBySolveId`, `applySolveToGraph` (anchors + plateau + top-10 trim), `mutateMatrixFromSolve`, `stablePlayerColorIndex`, `formatSolveDateTime`. | | `tests/frontend/scoreboard.pure.spec.ts` | Pure helpers: `applyRankingSort` (competition-rank numbering), `parseSolveEventIntoRanking` (add-or-increment + sort), `dedupEventLogBySolveId`, `applySolveToGraph` (anchors + plateau + top-10 trim), `mutateMatrixFromSolve`, `stablePlayerColorIndex`, `formatSolveDateTime`. |
| `tests/frontend/scoreboard.store.spec.ts` | Store lifecycle + live merge: `loadAll` aggregation + idempotency, SSE `solve` frame applying to all four tabs, `stop()` teardown, exponential reconnect. | | `tests/frontend/scoreboard.store.spec.ts` | Store lifecycle + live merge: `loadAll` aggregation + idempotency, SSE `solve` frame applying to all four tabs, `stop()` teardown, exponential reconnect. |
| `backend/src/modules/admin/system/admin-system.controller.ts` | Mounts `/api/v1/admin/system/*` (backup download, restore validate/commit, confirmations, scores/reset, challenges/wipe); all handlers require `AdminGuard` + `@Roles('admin')` and the destructive ones also consume a confirmation token. |
| `backend/src/modules/admin/system/admin-system.module.ts` | Wires `AdminSystemController`, `BackupService`, `RestoreService`, `DangerZoneService`, `FilesystemTransactionService`, `ConfirmationTokenService`; imports `TypeOrmModule.forFeature([AdminOperationTokenEntity])` and `AuthModule`. |
| `backend/src/modules/admin/system/backup.service.ts` | Builds the full backup JSON: every application table (`sqlite_master` discovery, excluding `admin_operation_token`/`migrations`/`sqlite_*`), with base64-encoded uploads walked recursively from `UPLOAD_DIR` (skipping `.staging`). Exposes `discoverTables()`, `getUploadDir()`, and `BackupService.stringify(doc)`. |
| `backend/src/modules/admin/system/restore.service.ts` | Two-phase restore: `stageArchive(rawText, userId)` validates the archive (zod), decodes uploads into `<DATA_DIR>/.system-staging/restore-<id>/`, and returns a summary; `commitRestore(stagingId)` clones the live DB, clears every application table, re-inserts archived rows in FK-safe order, then atomically swaps the live DB + uploads via `FilesystemTransactionService.stageSwap({ name: 'restore-backup', ... })`. On any failure the swap is rolled back and `SYSTEM_RESTORE_ROLLED_BACK` is returned. |
| `backend/src/modules/admin/system/danger-zone.service.ts` | `resetScores()` deletes every `solve` row transactionally; `wipeChallenges()` snapshots `<UPLOAD_DIR>/challenges` to a side directory, deletes every challenge inside a transaction, physically removes the live `-challenges` directory post-commit, and either deletes the snapshot or restores from it on failure. |
| `backend/src/modules/admin/system/filesystem-transaction.service.ts` | Generic file/directory swap primitive: `stageSwap({ pairs, hooks })` renames each live path to a rollback location, then renames the staged path into place (with copy+unlink cross-device fallback); `commit(handle)` removes rollback artifacts; `rollback(handle)` restores the original live paths. Provides static helpers `rmSafe`, `copyDirSync`, `ensureDir`. |
| `backend/src/modules/admin/system/confirmation-token.service.ts` | Issues SHA-256-hashed single-use tokens with `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS` TTL; `consume()` runs in a transaction with conditional `WHERE consumedAt IS NULL` updates so only the first concurrent caller succeeds. `purgeExpired()` deletes expired and >24h-old consumed rows. |
| `backend/src/modules/admin/system/dto/admin-system.dto.ts` | Re-exports `ADMIN_OPERATION_KINDS` and zod contracts for the re-auth body, danger confirm body, and restore-commit body; declares the backup format identifier + version constants. |
| `backend/src/database/entities/admin-operation-token.entity.ts` | TypeORM entity and the canonical `ADMIN_OPERATION_KINDS` literal union (`restore-backup`, `reset-scores`, `wipe-challenges`). |
| `backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts` | Forward-only migration that creates the `admin_operation_token` table and its three indexes (`uq_admin_op_token_hash`, `idx_admin_op_token_user_op`, `idx_admin_op_token_expiry`). |
| `backend/src/common/utils/upload.ts` | Adds `resolveSystemStagingDir(config)` (defaults to `<DATA_DIR>/.system-staging`, never nested under `UPLOAD_DIR`) and `getRequestSizeLimit(config)` used by the system endpoints. |
| `backend/src/config/env.schema.ts` | Adds `SYSTEM_OP_STAGING_DIR`, `SYSTEM_OP_RESTORE_STAGE_TTL_MS` (default 15 min), `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS` (default 5 min). |
| `backend/src/common/errors/error-codes.ts` | Adds the `SYSTEM_*` error codes used by the system endpoints (`SYSTEM_BACKUP_FAILED`, `SYSTEM_RESTORE_VALIDATION_FAILED`, `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE`, `SYSTEM_RESTORE_STAGE_EXPIRED`, `SYSTEM_RESTORE_FAILED`, `SYSTEM_RESTORE_ROLLED_BACK`, `SYSTEM_OPERATION_IN_PROGRESS`, `SYSTEM_REAUTH_REQUIRED`, `SYSTEM_INVALID_CREDENTIALS`, `SYSTEM_TOKEN_INVALID`, `SYSTEM_TOKEN_EXPIRED`, `SYSTEM_TOKEN_REUSED`, `SYSTEM_TOKEN_MISMATCH`, `SYSTEM_DANGER_FAILED`, `SYSTEM_DANGER_ROLLED_BACK`, `SYSTEM_FILE_READ_FAILED`, `SYSTEM_PAYLOAD_INVALID`). |
| `backend/src/modules/auth/auth.service.ts` | Adds `reauthenticateAdmin(userId, password)` (Argon2id verify + admin/enabled status check) and `revokeAllRefreshSessions(userId, manager?)` (used after a successful restore to force the SPA back to `/login`). |
| `tests/backend/admin-system-authorization.spec.ts` | Non-admin JWTs and missing tokens are rejected on every `/api/v1/admin/system/*` endpoint. |
| `tests/backend/admin-system-backup.spec.ts` | Backup envelope shape, table discovery exclusion, and base64 file inclusion. |
| `tests/backend/admin-system-confirmation-token.spec.ts` | Issue/consume/expire/reuse/mismatch flow and TTL config. |
| `tests/backend/admin-system-danger.spec.ts` | Reset-scores and wipe-challenges happy path + DB/file rollback. |
| `tests/backend/admin-system-restore-validation.spec.ts` | Archive schema validation, base64 size mismatch, sha256 mismatch, safe-path rejection, and duplicate-path rejection. |
| `frontend/src/app/features/admin/system/system.component.ts` | `/admin/system` smart page: two panels (Database + Danger Zone), backup download, restore pick + validate, confirm modal trigger, and per-operation success/error surfacing + cross-store invalidation. |
| `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` | Re-authentication + confirmation-phrase modal with operation-specific copy from `system.pure.ts`. |
| `frontend/src/app/features/admin/system/system.service.ts` | HTTP client for the six `/api/v1/admin/system/*` endpoints. |
| `frontend/src/app/features/admin/system/system.pure.ts` | Pure helpers: error → friendly message map, `destructiveCopy(op)` per-operation modal copy, `pickRestoreFile()` (jsdom-testable file picker), `deriveBackupFilename()`. |
| `frontend/src/app/core/services/system-data-change.service.ts` | Root-provided signal bus (`'scores-reset'`, `'challenges-wiped'`, `'restore-completed'`) consumed by `ChallengesStore` and `ScoreboardStore` to invalidate their caches after a destructive operation. |
| `frontend/src/app/core/services/auth.service.ts` | Adds `forceServerInvalidation()` so the restore flow can drop the local session and broadcast the cross-tab invalidation without invoking the backend logout. |
| `frontend/src/app/features/challenges/challenges.store.ts` | Now subscribes to `SystemDataChangeService` and calls `reset()` on every destructive operation kind. |
| `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Now subscribes to `SystemDataChangeService` and clears ranking/matrix/event-log/graph on every destructive operation kind. |
| `frontend/src/app/features/admin/admin-shell.component.ts` | Side-nav entry for `system` is now `enabled: true`. |
| `frontend/src/app/app.routes.ts` | Adds the lazy `system` child route under the admin shell. |
| `tests/frontend/admin-system.pure.spec.ts` | Pure-helper contracts for the friendly error map, destructive copy, and file picker. |
# See also # See also
@@ -135,3 +165,6 @@ timestamp: 2026-07-23T10:12:24Z
- [Notifications](/guides/notifications.md) - [Notifications](/guides/notifications.md)
- [Blog Publishing and Reading](/guides/blog.md) - [Blog Publishing and Reading](/guides/blog.md)
- [Blog API](/api/blog.md) - [Blog API](/api/blog.md)
- [Admin System API](/api/admin-system.md)
- [Admin System Page Guide](/guides/admin-system-page.md)
- [Admin Operation Tokens Table](/database/admin-operation-tokens.md)
@@ -0,0 +1,38 @@
---
type: database
title: Admin Operation Tokens Migration
description: Forward-only migration that adds the `admin_operation_token` table and its indexes for destructive System-operation confirmation tokens.
resource: backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts
tags: [database, migration, admin, system, tokens]
timestamp: 2026-07-23T12:04:50Z
---
# Purpose
Adds the [admin_operation_token](/database/admin-operation-tokens.md)
table that backs the destructive operations under
`/api/v1/admin/system/*` (restore-backup, reset-scores,
wipe-challenges). Always fresh on a new database; on existing instances
the migration is a no-op if the table already exists.
# Up
1. `CREATE TABLE IF NOT EXISTS admin_operation_token` with the columns
listed in the [schema doc](/database/admin-operation-tokens.md#schema).
2. `CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_op_token_hash` on
`token_hash` to make one-shot use enforceable.
3. `CREATE INDEX IF NOT EXISTS idx_admin_op_token_user_op` on
`(user_id, operation)` for fast per-admin lookup.
4. `CREATE INDEX IF NOT EXISTS idx_admin_op_token_expiry` on
`expires_at` so the periodic purge can use an index scan.
# Down
Drops the indexes in reverse order and drops the table.
# Wiring
The migration is registered in
`backend/src/database/database.module.ts` after the existing user
triggers migration. The entity is registered in the same `ENTITIES`
array so `forFeature` lets `AdminSystemModule` inject the repository.
+55
View File
@@ -0,0 +1,55 @@
---
type: database
title: Admin Operation Tokens
description: Single-use confirmation tokens backing the destructive admin System operations (restore-backup, reset-scores, wipe-challenges).
resource: backend/src/database/entities/admin-operation-token.entity.ts
tags: [database, admin, system, backup, restore, danger-zone, tokens]
timestamp: 2026-07-23T12:04:50Z
---
# Schema
| Column | Type | Description |
|---------------|-----------|----------------------------------------------------------------------------------------------------------|
| `id` | TEXT PK | UUIDv4 generated client-side; the primary key. |
| `user_id` | TEXT FK | `user.id` of the administrator who issued the token. FK `ON DELETE CASCADE` to `user`. |
| `operation` | TEXT | One of `restore-backup`, `reset-scores`, `wipe-challenges` (mirrors `ADMIN_OPERATION_KINDS`). |
| `token_hash` | TEXT | Hex-encoded SHA-256 of the raw token. The raw token is returned once and never logged or stored. Unique. |
| `issued_at` | TEXT | ISO-8601 timestamp at issuance. |
| `expires_at` | TEXT | ISO-8601 timestamp after which the token is invalid (default TTL: `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS`, 5 min). |
| `consumed_at` | TEXT null | ISO-8601 timestamp when the token was consumed; null while still valid. |
# Indexes
| Index | Purpose |
|--------------------------------|--------------------------------------------------------|
| `uq_admin_op_token_hash` | `UNIQUE` on `token_hash` — guarantees one-shot use. |
| `idx_admin_op_token_user_op` | `(user_id, operation)` — fast per-admin/per-op lookup. |
| `idx_admin_op_token_expiry` | `expires_at` — enables the periodic expiry sweep. |
# Lifecycle
1. Admin re-authenticates with their current password via
`POST /api/v1/admin/system/confirmations`. The backend verifies the
password with Argon2id, then `ConfirmationTokenService.issue()`
generates a 32-byte base64url token, persists only its SHA-256 hash,
and returns the raw token plus its `expiresAt`.
2. The client passes the raw token (and the `stagingId` for restores)
to the matching destructive endpoint (`/restore/commit`,
`/scores/reset`, `/challenges/wipe`).
3. `ConfirmationTokenService.consume()` runs the consumption inside a
transaction: it looks the token up by hash, validates
`user_id` + `operation` (+ `stagingId` for restores), checks
`expires_at > now`, and finally performs a conditional update
`WHERE consumedAt IS NULL` so only the first concurrent caller
succeeds. A second attempt returns `SYSTEM_TOKEN_REUSED`.
4. `purgeExpired()` deletes expired rows and consumed rows older than
24h.
# Related
* Migration: [Admin Operation Tokens Migration](/database/admin-operation-tokens-migration.md)
* Service: `backend/src/modules/admin/system/confirmation-token.service.ts`
* Error codes: `SYSTEM_TOKEN_INVALID`, `SYSTEM_TOKEN_EXPIRED`,
`SYSTEM_TOKEN_REUSED`, `SYSTEM_TOKEN_MISMATCH`
* [Admin System API](/api/admin-system.md)
+3
View File
@@ -42,6 +42,7 @@ re-asserts the unique indexes on `system_key` and `abbreviation`.
| `solve` | User-solved challenges (scoring ledger). | | `solve` | User-solved challenges (scoring ledger). |
| `refresh_token` | Rotating refresh tokens (hash + revocation). | | `refresh_token` | Rotating refresh tokens (hash + revocation). |
| `blog_post` | Markdown blog posts in draft/published states. | | `blog_post` | Markdown blog posts in draft/published states. |
| `admin_operation_token` | Single-use confirmation tokens for destructive System operations. |
# Relationships # Relationships
@@ -68,6 +69,7 @@ user ───< refresh_token
| `solve` | `uq_solve_challenge_user` (unique), `idx_solve_user`, `idx_solve_challenge` | | `solve` | `uq_solve_challenge_user` (unique), `idx_solve_user`, `idx_solve_challenge` |
| `refresh_token` | `idx_refresh_token_user`, unique on `token_hash` | | `refresh_token` | `idx_refresh_token_user`, unique on `token_hash` |
| `blog_post` | `idx_blog_status_published` (`status`, `published_at`) | | `blog_post` | `idx_blog_status_published` (`status`, `published_at`) |
| `admin_operation_token` | `uq_admin_op_token_hash` (unique on `token_hash`), `idx_admin_op_token_user_op` (`user_id`, `operation`), `idx_admin_op_token_expiry` (`expires_at`) |
# PRAGMAs # PRAGMAs
@@ -91,3 +93,4 @@ On every process start, `DatabaseInitService.init()`:
- [Challenge Tables](/database/challenges.md) - [Challenge Tables](/database/challenges.md)
- [Auth and Settings Tables](/database/auth-settings.md) - [Auth and Settings Tables](/database/auth-settings.md)
- [Blog Post Table](/database/blog-posts.md) - [Blog Post Table](/database/blog-posts.md)
- [Admin Operation Tokens Table](/database/admin-operation-tokens.md)
+180
View File
@@ -0,0 +1,180 @@
---
type: guide
title: Admin — System (Backup, Restore, Danger Zone)
description: How an admin navigates to /admin/system, creates a backup, stages and commits a restore, resets all scores, and wipes challenges — including the re-authentication confirmation, progress, and side effects.
tags: [guide, admin, system, backup, restore, danger-zone, confirmation]
timestamp: 2026-07-23T12:04:50Z
---
# Navigation
1. Sign in as an admin and open the admin side-nav (see the
[Admin Shell guide](/guides/admin-shell.md)).
2. Click **System** in the side-nav. The route resolves to
`/admin/system` and lazily loads the
`AdminSystemComponent` standalone component.
3. The page renders two panels side by side: **Database** (left) and
**Danger Zone** (right). On narrow screens they stack vertically.
# Database panel
## Create a backup
1. Click **Create backup** (data-testid `admin-system-create-backup`).
A spinner appears inside the button while the request is in flight.
2. The browser receives a JSON file via `Content-Disposition:
attachment; filename="hipctf-backup-YYYY-MM-DD.json"` and the
browser download UI saves it to disk.
3. On success the panel shows a green **Backup ready.** status line
(data-testid `admin-system-backup-success`).
4. On failure the panel shows a red error message under the buttons
(data-testid `admin-system-backup-error`). The friendly message
comes from `system.pure.ts` and resolves `SYSTEM_BACKUP_FAILED`,
`SYSTEM_FILE_READ_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, etc.
## Restore a backup
1. Click **Restore backup** (data-testid `admin-system-restore-backup`).
A hidden file picker opens restricted to `application/json`/`.json`.
2. Pick a backup JSON file. The progress line shows
**Reading selected backup…**, then **Validating backup
server-side…** (data-testid `admin-system-restore-progress`).
3. The server validates the JSON, decodes uploads, and verifies
sizes/checksums. On success a summary card appears
(data-testid `admin-system-restore-summary`) showing:
* Tables detected
* Uploaded files
* Total bytes
* Stage expiry (ISO timestamp)
4. From the summary card, the admin has two options:
* **Discard staged backup** (data-testid
`admin-system-discard-restore`) — drops the staged state and
resumes from a clean panel.
* **Continue to destructive restore…** (data-testid
`admin-system-confirm-restore`) — opens the confirmation modal.
### Confirmation modal (restore-backup)
* The modal title is **Restore database?**.
* The detail paragraph explains that the entire database and uploads
will be replaced (the modal's `preserved` line says **Nothing is
preserved**).
* The stage banner explicitly warns: *You will be logged out after
restore completes.*
* The admin must enter their current password and type
`RESTORE BACKUP` exactly into the confirmation phrase field.
* Click **Confirm**. The modal swaps the input fields for a
*Working…* spinner.
* The frontend calls `POST /confirmations` with `{ operation:
'restore-backup', password, stagingId }` to receive a single-use
token, then immediately calls `POST /restore/commit` with that
token and the staging id.
* On success the SPA calls `AuthService.forceServerInvalidation()`,
resets the `UserStore`, and navigates to `/login`. The
`ChallengesStore` and `ScoreboardStore` invalidate themselves via
the `SystemDataChangeService` `'restore-completed'` event.
* On failure the modal stays open and shows the resolved error
(e.g. `SYSTEM_TOKEN_MISMATCH`, `SYSTEM_RESTORE_ROLLED_BACK`). The
database panel also reflects the error inline.
# Danger Zone panel
The danger zone panel is outlined by a dashed red border and contains
two red buttons:
| Button | Operation |
|--------------------------------------------|---------------------|
| `admin-system-reset-scores` | `reset-scores` |
| `admin-system-wipe-challenges` | `wipe-challenges` |
Each button opens the same confirmation modal with operation-specific
copy:
* **Reset all scores?** — detail lists what is removed
(every solve and award), preserved (users, sessions, roles,
challenges, categories, etc.). Confirmation phrase: `RESET SCORES`.
* **Wipe all challenges?** — detail explains that every challenge, its
attached files, every solve, and every award are removed, and that
every uploaded challenge file on disk is deleted. Preserved:
users, sessions, roles, categories, settings, blog posts, and
root-level uploads (e.g. the site logo). Confirmation phrase:
`WIPE CHALLENGES`.
After the admin enters their password and types the confirmation
phrase, the SPA re-authenticates to issue a token and then commits the
operation. The panel shows a green summary
(`N solve record(s) removed.` or `N challenge(s), M file(s) wiped.`)
and a toast notification. The corresponding stores
(`ChallengesStore`, `ScoreboardStore`) automatically reset themselves
via the `SystemDataChangeService` `scores-reset` and `challenges-wiped`
events so every open tab sees fresh data.
# Cross-tab invalidation
After any of the destructive operations, the
`SystemDataChangeService` notifies all subscribers in the same tab via
an Angular signal. The injected effects in `ChallengesStore` and
`ScoreboardStore` reset their cached board/matrix/event-log/graph
data. For a restore, the SPA additionally forces a cross-tab
invalidation by calling `AuthService.forceServerInvalidation()`,
which clears the access token and notifies peer tabs via
`BroadcastChannel` + the `storage`-event fallback so they redirect to
`/login` as well.
# Error mapping
The pure helper `friendlySystemErrorMessage(op, err)` in
`system.pure.ts` converts the backend error codes into user-facing
messages. Notable mappings:
| Backend code | UI message |
|---------------------------------------|----------------------------------------------------------------------------|
| `SYSTEM_REAUTH_REQUIRED` | "Your session is no longer valid. Please log in again." |
| `SYSTEM_INVALID_CREDENTIALS` | "The current password is incorrect." |
| `SYSTEM_TOKEN_INVALID` / `_EXPIRED` | "Confirmation token is invalid / expired. Re-authenticate and try again." |
| `SYSTEM_TOKEN_REUSED` | "Confirmation token was already used. Re-authenticate to get a fresh token." |
| `SYSTEM_TOKEN_MISMATCH` | "Confirmation token does not match this operation." |
| `SYSTEM_RESTORE_VALIDATION_FAILED` | "Backup validation failed. No data was changed." |
| `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE` | "The backup is larger than the configured upload limit." |
| `SYSTEM_RESTORE_STAGE_EXPIRED` | "The backup staging window expired. Re-upload and validate again." |
| `SYSTEM_RESTORE_ROLLED_BACK` | "Restore failed and was rolled back. Your data is unchanged." |
| `SYSTEM_DANGER_ROLLED_BACK` | "Operation failed and was rolled back. Nothing was changed." |
| `SYSTEM_BACKUP_FAILED` | "Backup could not be generated. No data was changed." |
| `SYSTEM_DANGER_FAILED` | "Operation could not complete. No data was changed." |
| `SYSTEM_OPERATION_IN_PROGRESS` | "Another destructive operation is currently running." |
# Examples
## Manual restore happy path
1. Download the backup from a known-good environment.
2. Sign in as the admin on the target environment.
3. Navigate to `/admin/system`, click **Restore backup**, select the
downloaded file.
4. Verify the summary matches the expected table counts and file
counts.
5. Click **Continue to destructive restore…**, enter the admin
password, type `RESTORE BACKUP`, click **Confirm**.
6. The modal closes, the page shows a brief *Restore complete.
Logging out…* toast, and the SPA navigates to `/login`.
7. Sign in again with the same admin credentials. The data on the
target environment now matches the source backup.
## Manual reset happy path
1. Navigate to `/admin/system`.
2. Click **Reset all scores**, enter the password, type
`RESET SCORES`, click **Confirm**.
3. The danger zone panel shows the number of solve records removed
and a toast notifies the operation completed. Refresh the
Scoreboard page — every player is back at zero.
## Manual wipe happy path
1. Navigate to `/admin/system`.
2. Click **Wipe challenges**, enter the password, type
`WIPE CHALLENGES`, click **Confirm**.
3. The danger zone panel shows counts of challenges, files, and
solves removed. The Challenges page becomes empty and the
`/admin/challenges` list is empty. The `challenges/` folder on
disk is also gone.
+14 -1
View File
@@ -12,7 +12,7 @@ controls, administrator-managed Markdown blog publishing, and public and
signed-in blog views. signed-in blog views.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-23T10:36:45Z. they need. Last regenerated 2026-07-23T12:04:50Z.
# Architecture # Architecture
@@ -43,6 +43,11 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
* [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token` * [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token`
and `setting` tables. and `setting` tables.
* [Blog Post Table](/database/blog-posts.md) - `blog_post` table. * [Blog Post Table](/database/blog-posts.md) - `blog_post` table.
* [Admin Operation Tokens Table](/database/admin-operation-tokens.md) -
Single-use confirmation tokens for destructive System operations.
* [Admin Operation Tokens Migration](/database/admin-operation-tokens-migration.md) -
Forward-only migration that adds the `admin_operation_token` table
and its indexes.
# API # API
@@ -63,6 +68,9 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
`/events/status` SSE stream. `/events/status` SSE stream.
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos. * [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos.
* [Blog Endpoints](/api/blog.md) - Public published-post listing and admin-only post CRUD, validation, and publication behavior. * [Blog Endpoints](/api/blog.md) - Public published-post listing and admin-only post CRUD, validation, and publication behavior.
* [Admin System Endpoints](/api/admin-system.md) - Admin-only backup
download, restore validate/commit, re-authenticated confirmation
tokens, reset-scores, and wipe-challenges.
# Guides # Guides
@@ -87,6 +95,11 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
* [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) - * [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) -
Transactional full-replace semantics, side effects, and the auto-close Transactional full-replace semantics, side effects, and the auto-close
contract of the confirmed challenges import on `/admin/challenges`. contract of the confirmed challenges import on `/admin/challenges`.
* [Admin — System (Backup, Restore, Danger Zone)](/guides/admin-system-page.md) -
How an admin navigates to `/admin/system`, creates a backup, stages
and commits a restore, resets all scores, and wipes challenges,
including the re-authentication confirmation flow and cross-tab
invalidation.
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED * [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame + countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
every signed-in page. every signed-in page.
+5
View File
@@ -69,6 +69,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/admin/blog/blog.component').then((m) => m.AdminBlogComponent), import('./features/admin/blog/blog.component').then((m) => m.AdminBlogComponent),
}, },
{
path: 'system',
loadComponent: () =>
import('./features/admin/system/system.component').then((m) => m.AdminSystemComponent),
},
], ],
}, },
], ],
@@ -240,6 +240,17 @@ export class AuthService {
this.publishInvalidation(message); this.publishInvalidation(message);
} }
/**
* Forcibly invalidate the local session (and broadcast peer
* invalidation) — used after a destructive server-initiated action such
* as a successful restore that revokes access. Same as a normal logout
* from the client's perspective.
*/
forceServerInvalidation(): void {
this.suppressBroadcast = false;
this.clearAndBroadcast();
}
handleSseUnauthorized(): void { handleSseUnauthorized(): void {
this.clearAndBroadcast(); this.clearAndBroadcast();
this.notifyPeerInvalidation('sse-unauthorized'); this.notifyPeerInvalidation('sse-unauthorized');
@@ -0,0 +1,14 @@
import { Injectable, signal } from '@angular/core';
export type SystemDataChangeKind = 'scores-reset' | 'challenges-wiped' | 'restore-completed';
@Injectable({ providedIn: 'root' })
export class SystemDataChangeService {
private readonly _events = signal<{ kind: SystemDataChangeKind; ts: number }[]>([]);
readonly events = this._events.asReadonly();
notify(kind: SystemDataChangeKind): void {
this._events.update((cur) => [...cur, { kind, ts: Date.now() }]);
}
}
@@ -14,7 +14,7 @@ const ENTRIES: AdminNavEntry[] = [
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true }, { id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: true }, { id: 'players', label: 'Players', path: '/admin/players', enabled: true },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true }, { id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true },
{ id: 'system', label: 'System', path: '/admin/system', enabled: false }, { id: 'system', label: 'System', path: '/admin/system', enabled: true },
]; ];
@Component({ @Component({
@@ -0,0 +1,162 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AdminSystemOperation } from './system.service';
import { destructiveCopy } from './system.pure';
@Component({
selector: 'app-system-confirm-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 80; }
.modal { background: var(--color-surface, #fff); padding: 16px 20px; border-radius: 10px; width: min(520px, 92vw); max-height: 90vh; overflow: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.2); }
.modal h3 { margin: 0 0 8px; color: var(--color-danger, #991b1b); }
.stage { padding: 8px 12px; border-radius: 6px; background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-text, #92400e); margin: 12px 0; }
.row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.row label { font-weight: 600; font-size: 14px; }
.row input { padding: 8px; border-radius: 6px; border: 1px solid var(--color-secondary, #ccc); }
.confirm-box { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.actions button.danger { background: var(--color-danger, #991b1b); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
.actions button.cancel { background: transparent; border: 1px solid var(--color-secondary, #ccc); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
.error { color: var(--color-danger, #b91c1c); margin-top: 8px; }
.progress { color: var(--color-warning, #92400e); margin-top: 8px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.preserved { color: var(--color-success, #065f46); font-size: 13px; }
.detail { font-size: 14px; line-height: 1.45; }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="system-confirm-overlay" (click)="onCancel()">
<div
class="modal"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="'system-confirm-title'"
data-testid="system-confirm-modal"
(click)="$event.stopPropagation()"
>
<h3 id="system-confirm-title">{{ copy().title }}</h3>
<p class="detail" data-testid="system-confirm-detail">{{ copy().detail }}</p>
<p class="preserved" data-testid="system-confirm-preserved">{{ copy().preserved }}</p>
<div class="stage" data-testid="system-confirm-stage">{{ stageMessage() }}</div>
<div class="row">
<label for="system-confirm-password">Current administrator password</label>
<input
id="system-confirm-password"
type="password"
autocomplete="current-password"
[value]="password()"
(input)="onPasswordInput($event)"
[disabled]="submitting()"
data-testid="system-confirm-password"
/>
</div>
<div class="confirm-box">
<label for="system-confirm-phrase">Type <strong>{{ copy().confirmation }}</strong> to confirm</label>
<input
id="system-confirm-phrase"
type="text"
[value]="confirmation()"
(input)="onConfirmInput($event)"
[disabled]="submitting()"
data-testid="system-confirm-phrase"
/>
</div>
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="system-confirm-error">{{ msg }}</p>
}
@if (submitting()) {
<p class="progress" data-testid="system-confirm-progress">
<span class="spinner" aria-hidden="true"></span>Working…
</p>
}
<div class="actions">
<button
type="button"
class="cancel"
(click)="onCancel()"
[disabled]="submitting()"
data-testid="system-confirm-cancel"
>Cancel</button>
<button
type="button"
class="danger"
(click)="onConfirm()"
[disabled]="!canConfirm()"
data-testid="system-confirm-confirm"
>
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
{{ confirmLabel() }}
</button>
</div>
</div>
</div>
}
`,
})
export class SystemConfirmModalComponent {
readonly open = input(false);
readonly operation = input<AdminSystemOperation>('reset-scores');
readonly stageMessage = input<string>('Re-authenticate then confirm the action.');
readonly submitting = input(false);
readonly errorMessage = input<string | null>(null);
readonly cancel = output<void>();
readonly confirm = output<{ password: string; confirmation: string }>();
readonly password = signal('');
readonly confirmation = signal('');
readonly copy = computed(() => destructiveCopy(this.operation()));
readonly canConfirm = computed(() => {
if (this.submitting()) return false;
const phrase = this.copy().confirmation;
return this.password().length > 0 && this.confirmation().trim() === phrase;
});
readonly confirmLabel = computed(() => {
if (this.submitting()) return 'Working…';
return 'Confirm';
});
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting()) this.onCancel();
}
onPasswordInput(ev: Event): void {
this.password.set((ev.target as HTMLInputElement).value);
}
onConfirmInput(ev: Event): void {
this.confirmation.set((ev.target as HTMLInputElement).value);
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (!this.canConfirm()) return;
this.confirm.emit({ password: this.password(), confirmation: this.confirmation() });
}
}
@@ -0,0 +1,426 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { NotificationService } from '../../../core/services/notification.service';
import { AuthService } from '../../../core/services/auth.service';
import { UserStore } from '../../../core/services/user.store';
import { SystemDataChangeService } from '../../../core/services/system-data-change.service';
import { AdminSystemComponent as _Alias } from './system.component';
import {
AdminSystemOperation,
AdminSystemRestoreSummary,
SystemAdminService,
} from './system.service';
import {
coerceSystemError,
destructiveCopy,
deriveBackupFilename,
friendlySystemErrorMessage,
pickRestoreFile,
} from './system.pure';
import { SystemConfirmModalComponent } from './system-confirm-modal.component';
// Preserve namespacing exports for tooling that scans barrel imports.
void _Alias;
interface RestoreState {
open: boolean;
phase: 'idle' | 'uploading' | 'validating' | 'staged' | 'committed' | 'failed';
staged: AdminSystemRestoreSummary | null;
errorMessage: string | null;
}
@Component({
selector: 'app-admin-system',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, SystemConfirmModalComponent],
styles: [`
:host { display: block; padding: 16px; }
.panels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
@media (max-width: 900px) { .panels { grid-template-columns: 1fr; } }
.panel { background: var(--color-surface, #fff); padding: 16px; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.panel h2 { margin: 0 0 6px; font-size: 18px; }
.panel .subtitle { color: var(--color-secondary, #6b7280); margin: 0 0 12px; font-size: 13px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
button.primary { background: var(--color-primary, #2563eb); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button.danger { background: var(--color-danger, #991b1b); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button.cancel { background: transparent; border: 1px solid var(--color-secondary, #ccc); border-radius: 6px; padding: 8px 14px; cursor: pointer; }
button[disabled] { opacity: 0.5; cursor: not-allowed; }
.status { margin-top: 8px; font-size: 13px; }
.status.success { color: var(--color-success, #065f46); }
.status.error { color: var(--color-danger, #b91c1c); }
.status.progress { color: var(--color-warning, #92400e); }
.danger-zone { border: 1px dashed var(--color-danger, #991b1b); }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.summary-grid { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; font-size: 13px; margin: 8px 0 0; }
`],
template: `
<div data-testid="admin-system-page">
<h1>System</h1>
<p class="subtitle">Two clearly separated panels: Database and Danger Zone.</p>
<div class="panels">
<section class="panel" data-testid="admin-system-database-panel">
<h2>Database</h2>
<p class="subtitle">Create or restore the full platform snapshot.</p>
<div class="actions">
<button
type="button"
class="primary"
(click)="onCreateBackup()"
[disabled]="anyInFlight()"
data-testid="admin-system-create-backup"
>
<span *ngIf="backupInFlight()" class="spinner" aria-hidden="true"></span>
Create backup
</button>
<button
type="button"
class="primary"
(click)="onPickRestore()"
[disabled]="anyInFlight()"
data-testid="admin-system-restore-backup"
>
Restore backup
</button>
</div>
@if (backupStatus(); as status) {
<p class="status success" role="status" data-testid="admin-system-backup-success">{{ status }}</p>
}
@if (backupError(); as msg) {
<p class="status error" role="alert" data-testid="admin-system-backup-error">{{ msg }}</p>
}
@if (restoreInFlight()) {
<p class="status progress" role="status" data-testid="admin-system-restore-progress">
<span class="spinner" aria-hidden="true"></span>{{ restoreProgressLabel() }}
</p>
}
@if (restoreSummary(); as summary) {
<div data-testid="admin-system-restore-summary">
<p class="status success">Backup validated and staged.</p>
<div class="summary-grid">
<span>Tables detected</span><strong>{{ summary.summary.tables.length }}</strong>
<span>Uploaded files</span><strong>{{ summary.summary.files }}</strong>
<span>Total bytes</span><strong>{{ summary.summary.totalBytes }}</strong>
<span>Stage expires</span><strong>{{ summary.expiresAt }}</strong>
</div>
<div class="actions" style="margin-top: 12px;">
<button
type="button"
class="danger"
(click)="onOpenConfirm('restore-backup')"
[disabled]="anyInFlight()"
data-testid="admin-system-confirm-restore"
>Continue to destructive restore…</button>
<button
type="button"
class="cancel"
(click)="discardRestore()"
[disabled]="anyInFlight()"
data-testid="admin-system-discard-restore"
>Discard staged backup</button>
</div>
</div>
}
@if (restoreError(); as msg) {
<p class="status error" role="alert" data-testid="admin-system-restore-error">{{ msg }}</p>
}
</section>
<section class="panel danger-zone" data-testid="admin-system-danger-panel">
<h2>Danger Zone</h2>
<p class="subtitle">Visually destructive actions. Re-authentication is required for every operation.</p>
<div class="actions">
<button
type="button"
class="danger"
(click)="onOpenConfirm('reset-scores')"
[disabled]="anyInFlight()"
data-testid="admin-system-reset-scores"
>
Reset all scores
</button>
<button
type="button"
class="danger"
(click)="onOpenConfirm('wipe-challenges')"
[disabled]="anyInFlight()"
data-testid="admin-system-wipe-challenges"
>
Wipe challenges
</button>
</div>
@if (dangerStatus(); as status) {
<p class="status success" role="status" data-testid="admin-system-danger-success">{{ status }}</p>
}
@if (dangerError(); as msg) {
<p class="status error" role="alert" data-testid="admin-system-danger-error">{{ msg }}</p>
}
</section>
</div>
<app-system-confirm-modal
[open]="confirmOpen()"
[operation]="confirmOperation()"
[stageMessage]="confirmStageMessage()"
[submitting]="confirmSubmitting()"
[errorMessage]="confirmError()"
(cancel)="closeConfirm()"
(confirm)="onConfirmAction($event)"
/>
</div>
`,
})
export class AdminSystemComponent {
private readonly system = inject(SystemAdminService);
private readonly auth = inject(AuthService);
private readonly userStore = inject(UserStore);
private readonly router = inject(Router);
private readonly notifications = inject(NotificationService);
private readonly destroyRef = inject(DestroyRef);
private readonly dataChanges = inject(SystemDataChangeService);
readonly backupInFlight = signal(false);
readonly backupStatus = signal<string | null>(null);
readonly backupError = signal<string | null>(null);
readonly restoreState = signal<RestoreState>({
open: false,
phase: 'idle',
staged: null,
errorMessage: null,
});
readonly restoreInFlight = computed(() => {
const phase = this.restoreState().phase;
return phase === 'uploading' || phase === 'validating' || phase === 'committed';
});
readonly restoreProgressLabel = computed(() => {
switch (this.restoreState().phase) {
case 'uploading':
return 'Reading selected backup…';
case 'validating':
return 'Validating backup server-side…';
case 'committed':
return 'Committing restore and finalizing…';
default:
return '';
}
});
readonly restoreSummary = computed(() => this.restoreState().staged);
readonly restoreError = computed(() => this.restoreState().errorMessage);
readonly dangerStatus = signal<string | null>(null);
readonly dangerError = signal<string | null>(null);
readonly confirmOpen = signal(false);
readonly confirmOperation = signal<AdminSystemOperation>('reset-scores');
readonly confirmSubmitting = signal(false);
readonly confirmError = signal<string | null>(null);
readonly anyInFlight = computed(
() =>
this.backupInFlight() ||
this.restoreInFlight() ||
this.confirmSubmitting() ||
!!this.confirmOpen(),
);
readonly confirmStageMessage = computed(() => {
switch (this.confirmOperation()) {
case 'restore-backup':
return 'You will be logged out after restore completes.';
case 'reset-scores':
return 'Solves and awards will be permanently removed.';
case 'wipe-challenges':
return 'Challenges, their files, and related solves/awards will be permanently removed.';
}
});
async onCreateBackup(): Promise<void> {
if (this.anyInFlight()) return;
this.backupStatus.set(null);
this.backupError.set(null);
this.backupInFlight.set(true);
try {
const blob = await this.system.downloadBackup();
const url = URL.createObjectURL(blob);
try {
const a = document.createElement('a');
a.href = url;
a.download = deriveBackupFilename();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
this.backupStatus.set('Backup ready.');
} catch (e) {
const err = coerceSystemError(e, 'Failed to generate backup');
this.backupError.set(friendlySystemErrorMessage('restore-backup', err));
} finally {
this.backupInFlight.set(false);
}
}
async onPickRestore(): Promise<void> {
if (this.anyInFlight()) return;
this.restoreState.set({ open: true, phase: 'uploading', staged: null, errorMessage: null });
const file = await pickRestoreFile();
if (!file) {
this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null });
return;
}
this.restoreState.update((s) => ({ ...s, phase: 'validating' }));
try {
const staged = await this.system.validateRestore({ text: file.text });
this.restoreState.set({ open: true, phase: 'staged', staged, errorMessage: null });
} catch (e) {
const err = coerceSystemError(e, 'Restore validation failed');
const state = { ...this.restoreState() };
state.errorMessage = friendlySystemErrorMessage('restore-backup', err);
state.phase = 'failed';
this.restoreState.set(state);
}
}
discardRestore(): void {
if (this.anyInFlight()) return;
this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null });
}
onOpenConfirm(op: AdminSystemOperation): void {
if (this.anyInFlight()) return;
if (op === 'restore-backup') {
const staged = this.restoreState().staged;
if (!staged) {
this.restoreState.update((s) => ({ ...s, errorMessage: 'Stage a backup before continuing.' }));
return;
}
}
this.confirmError.set(null);
this.confirmOperation.set(op);
this.confirmOpen.set(true);
}
closeConfirm(): void {
if (this.confirmSubmitting()) return;
this.confirmOpen.set(false);
this.confirmError.set(null);
}
async onConfirmAction(payload: { password: string; confirmation: string }): Promise<void> {
if (this.confirmSubmitting()) return;
const operation = this.confirmOperation();
this.confirmSubmitting.set(true);
this.confirmError.set(null);
try {
const issued = await this.system.issueConfirmation({
operation,
password: payload.password,
...(operation === 'restore-backup' && this.restoreState().staged
? { stagingId: this.restoreState().staged!.stagingId }
: {}),
});
if (operation === 'restore-backup') {
this.confirmSubmitting.set(false);
this.confirmOpen.set(false);
await this.runRestoreCommit(issued.token);
} else if (operation === 'reset-scores') {
await this.runResetScores(issued.token);
} else if (operation === 'wipe-challenges') {
await this.runWipeChallenges(issued.token);
}
} catch (e) {
const err = coerceSystemError(e, friendlySystemFallback(operation));
this.confirmError.set(friendlySystemErrorMessage(operation, err));
} finally {
this.confirmSubmitting.set(false);
}
}
private async runRestoreCommit(token: string): Promise<void> {
const staged = this.restoreState().staged;
if (!staged) {
this.confirmError.set('No staged backup available.');
return;
}
this.restoreState.update((s) => ({ ...s, phase: 'committed' }));
try {
await this.system.commitRestore({
operation: 'restore-backup',
token,
stagingId: staged.stagingId,
});
this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null });
this.confirmOpen.set(false);
this.dataChanges.notify('restore-completed');
this.notifications.info('Restore complete. Logging out…');
// Force client-side credential clear.
this.auth.forceServerInvalidation();
this.userStore.reset();
await this.router.navigateByUrl('/login');
} catch (e) {
const err = coerceSystemError(e, 'Restore failed.');
this.confirmError.set(friendlySystemErrorMessage('restore-backup', err));
this.restoreState.update((s) => ({ ...s, phase: 'failed', errorMessage: friendlySystemErrorMessage('restore-backup', err) }));
}
}
private async runResetScores(token: string): Promise<void> {
try {
const res = await this.system.resetScores({ operation: 'reset-scores', token });
this.dangerError.set(null);
this.dangerStatus.set(`${res.solvesRemoved} solve record(s) removed.`);
this.dataChanges.notify('scores-reset');
this.notifications.info(`Reset complete: ${res.solvesRemoved} solve(s) removed.`);
this.confirmOpen.set(false);
} catch (e) {
const err = coerceSystemError(e, 'Reset scores failed.');
this.confirmError.set(friendlySystemErrorMessage('reset-scores', err));
this.dangerError.set(friendlySystemErrorMessage('reset-scores', err));
}
}
private async runWipeChallenges(token: string): Promise<void> {
try {
const res = await this.system.wipeChallenges({ operation: 'wipe-challenges', token });
this.dangerError.set(null);
this.dangerStatus.set(
`${res.challengesRemoved} challenge(s), ${res.challengeFilesRemoved} file(s) wiped.`,
);
this.dataChanges.notify('challenges-wiped');
this.dataChanges.notify('scores-reset');
this.notifications.info(`Wipe complete: ${res.challengesRemoved} challenge(s) removed.`);
this.confirmOpen.set(false);
} catch (e) {
const err = coerceSystemError(e, 'Wipe challenges failed.');
this.confirmError.set(friendlySystemErrorMessage('wipe-challenges', err));
this.dangerError.set(friendlySystemErrorMessage('wipe-challenges', err));
}
}
}
function friendlySystemFallback(op: AdminSystemOperation): string {
switch (op) {
case 'restore-backup':
return 'Restore could not be confirmed.';
case 'reset-scores':
return 'Reset could not be confirmed.';
case 'wipe-challenges':
return 'Wipe could not be confirmed.';
}
}
@@ -0,0 +1,155 @@
import { AdminSystemOperation } from './system.service';
export interface SystemError {
status: number;
code: string;
message: string;
details?: unknown;
}
export function coerceSystemError(err: unknown, fallbackMessage: string): SystemError {
if (err && typeof err === 'object' && 'error' in (err as any)) {
const httpErr = err as { status?: number; error?: { code?: string; message?: string; details?: unknown } };
const body = httpErr.error;
return {
status: httpErr.status ?? 0,
code: body?.code ?? 'INTERNAL',
message: body?.message ?? fallbackMessage,
details: body?.details,
};
}
return {
status: 0,
code: 'INTERNAL',
message: (err as Error)?.message ?? fallbackMessage,
};
}
export function friendlySystemErrorMessage(op: AdminSystemOperation, err: SystemError): string {
const code = err.code;
switch (code) {
case 'SYSTEM_REAUTH_REQUIRED':
return 'Your session is no longer valid. Please log in again.';
case 'SYSTEM_INVALID_CREDENTIALS':
return 'The current password is incorrect.';
case 'SYSTEM_TOKEN_INVALID':
return 'Confirmation token is invalid. Re-authenticate and try again.';
case 'SYSTEM_TOKEN_EXPIRED':
return 'Confirmation token expired. Re-authenticate and try again.';
case 'SYSTEM_TOKEN_REUSED':
return 'Confirmation token was already used. Re-authenticate to get a fresh token.';
case 'SYSTEM_TOKEN_MISMATCH':
return 'Confirmation token does not match this operation.';
case 'SYSTEM_RESTORE_VALIDATION_FAILED':
return 'Backup validation failed. No data was changed.';
case 'SYSTEM_RESTORE_PAYLOAD_TOO_LARGE':
return 'The backup is larger than the configured upload limit.';
case 'SYSTEM_RESTORE_STAGE_EXPIRED':
return 'The backup staging window expired. Re-upload and validate again.';
case 'SYSTEM_RESTORE_ROLLED_BACK':
return 'Restore failed and was rolled back. Your data is unchanged.';
case 'SYSTEM_DANGER_ROLLED_BACK':
return 'Operation failed and was rolled back. Nothing was changed.';
case 'SYSTEM_BACKUP_FAILED':
case 'SYSTEM_FILE_READ_FAILED':
return 'Backup could not be generated. No data was changed.';
case 'SYSTEM_DANGER_FAILED':
return 'Operation could not complete. No data was changed.';
case 'SYSTEM_OPERATION_IN_PROGRESS':
return 'Another destructive operation is currently running.';
case 'CONFLICT':
return 'Server reported a conflict. Try again.';
case 'FORBIDDEN':
return 'You are not allowed to perform this action.';
case 'UNAUTHORIZED':
return 'Your session is no longer valid. Please log in again.';
case 'VALIDATION_FAILED':
return err.message && err.message.trim().length > 0 ? err.message : 'Validation failed.';
default:
return err.message && err.message.trim().length > 0 ? err.message : fallbackMessageForOperation(op);
}
}
function fallbackMessageForOperation(op: AdminSystemOperation): string {
switch (op) {
case 'restore-backup':
return 'Restore failed.';
case 'reset-scores':
return 'Reset scores failed.';
case 'wipe-challenges':
return 'Wipe challenges failed.';
}
}
export interface DestructiveCopy {
title: string;
confirmation: string;
detail: string;
preserved: string;
}
export function destructiveCopy(op: AdminSystemOperation): DestructiveCopy {
switch (op) {
case 'reset-scores':
return {
title: 'Reset all scores?',
confirmation: 'RESET SCORES',
detail:
'Every solve and award record will be permanently deleted. Every player will return to zero points and no solve history will remain.',
preserved:
'Users, sessions, roles, challenges, challenge files, categories and images, event settings, and blog posts are preserved.',
};
case 'wipe-challenges':
return {
title: 'Wipe all challenges?',
confirmation: 'WIPE CHALLENGES',
detail:
'Every challenge, its attached files, all of its solves, and all of its award records will be permanently removed. Every uploaded challenge file on disk will be deleted.',
preserved:
'Users, sessions, roles, categories and images, event settings, blog posts, and root-level uploads (such as the site logo) are preserved.',
};
case 'restore-backup':
return {
title: 'Restore database?',
confirmation: 'RESTORE BACKUP',
detail:
'The entire database and all uploaded files will be replaced with the contents of the selected backup. Current state will be overwritten.',
preserved:
'Nothing is preserved: every table, every challenge, every upload, and every refresh session is replaced.',
};
}
}
export interface PickRestoreFileResult {
text: string;
filename: string;
size: number;
}
/**
* Open a file picker restricted to JSON, read the file as text, and
* validate it parses. Pure DOM logic so it is testable in jsdom.
*/
export function pickRestoreFile(): Promise<PickRestoreFileResult | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) {
resolve(null);
return;
}
const text = await file.text();
resolve({ text, filename: file.name, size: file.size });
});
input.addEventListener('cancel', () => resolve(null));
input.click();
});
}
export function deriveBackupFilename(): string {
const date = new Date().toISOString().slice(0, 10);
return `hipctf-backup-${date}.json`;
}
@@ -0,0 +1,91 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export type AdminSystemOperation = 'restore-backup' | 'reset-scores' | 'wipe-challenges';
export interface AdminSystemRestoreSummary {
stagingId: string;
expiresAt: string;
summary: {
tables: string[];
tableCounts: Record<string, number>;
files: number;
totalBytes: number;
};
}
@Injectable({ providedIn: 'root' })
export class SystemAdminService {
private readonly http = inject(HttpClient);
async downloadBackup(): Promise<Blob> {
return firstValueFrom(
this.http.get('/api/v1/admin/system/backup', {
withCredentials: true,
responseType: 'blob',
}),
);
}
async validateRestore(payload: { text: string } | { archive: unknown }): Promise<AdminSystemRestoreSummary> {
const body = 'text' in payload ? { text: payload.text } : { archive: payload.archive };
return firstValueFrom(
this.http.post<AdminSystemRestoreSummary>(
'/api/v1/admin/system/restore/validate',
body,
{ withCredentials: true },
),
);
}
async commitRestore(payload: { operation: 'restore-backup'; token: string; stagingId: string }): Promise<{ ok: true; operation: 'restore-backup' }> {
return firstValueFrom(
this.http.post<{ ok: true; operation: 'restore-backup' }>(
'/api/v1/admin/system/restore/commit',
payload,
{ withCredentials: true },
),
);
}
async issueConfirmation(payload: {
operation: AdminSystemOperation;
password: string;
stagingId?: string;
}): Promise<{ operation: AdminSystemOperation; token: string; expiresAt: string }> {
return firstValueFrom(
this.http.post<{ operation: AdminSystemOperation; token: string; expiresAt: string }>(
'/api/v1/admin/system/confirmations',
payload,
{ withCredentials: true },
),
);
}
async resetScores(payload: { operation: 'reset-scores'; token: string }): Promise<{ ok: true; solvesRemoved: number }> {
return firstValueFrom(
this.http.post<{ ok: true; solvesRemoved: number }>(
'/api/v1/admin/system/scores/reset',
payload,
{ withCredentials: true },
),
);
}
async wipeChallenges(payload: { operation: 'wipe-challenges'; token: string }): Promise<{
ok: true;
challengesRemoved: number;
challengeFilesRemoved: number;
solvesRemoved: number;
}> {
return firstValueFrom(
this.http.post<{
ok: true;
challengesRemoved: number;
challengeFilesRemoved: number;
solvesRemoved: number;
}>('/api/v1/admin/system/challenges/wipe', payload, { withCredentials: true }),
);
}
}
@@ -1,4 +1,4 @@
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core'; import { DestroyRef, Injectable, computed, effect, inject, signal } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
BoardCard, BoardCard,
@@ -17,11 +17,13 @@ import {
} from './challenges.pure'; } from './challenges.pure';
import { ChallengesApiService } from './challenges.service'; import { ChallengesApiService } from './challenges.service';
import { EventSourceLike } from '../../core/services/event-status.pure'; import { EventSourceLike } from '../../core/services/event-status.pure';
import { SystemDataChangeService } from '../../core/services/system-data-change.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChallengesStore { export class ChallengesStore {
private readonly api: ChallengesApiService; private readonly api: ChallengesApiService;
private readonly destroyRef: DestroyRef; private readonly destroyRef: DestroyRef;
private readonly dataChanges: SystemDataChangeService | null;
private readonly _board = signal<BoardResponse | null>(null); private readonly _board = signal<BoardResponse | null>(null);
private readonly _eventState = signal<EventStatePayloadLike['state']>('unconfigured'); private readonly _eventState = signal<EventStatePayloadLike['state']>('unconfigured');
@@ -35,10 +37,35 @@ export class ChallengesStore {
private readonly solveListeners = new Map<string, Set<(p: SolveEventLivePayload) => void>>(); private readonly solveListeners = new Map<string, Set<(p: SolveEventLivePayload) => void>>();
constructor(api?: ChallengesApiService, destroyRef?: DestroyRef) { constructor(api?: ChallengesApiService, destroyRef?: DestroyRef, dataChanges?: SystemDataChangeService) {
this.api = api ?? inject(ChallengesApiService); this.api = api ?? inject(ChallengesApiService);
this.destroyRef = destroyRef ?? inject(DestroyRef); this.destroyRef = destroyRef ?? inject(DestroyRef);
this.dataChanges = dataChanges ?? null;
this.destroyRef.onDestroy(() => this.stop()); this.destroyRef.onDestroy(() => this.stop());
this.installDataChangeListeners();
}
private installDataChangeListeners(): void {
if (!this.dataChanges) return;
const lastSeen = { ts: 0 };
effect(() => {
const evts = this.dataChanges!.events();
const latest = evts[evts.length - 1];
if (!latest || latest.ts === lastSeen.ts) return;
lastSeen.ts = latest.ts;
if (latest.kind === 'challenges-wiped' || latest.kind === 'restore-completed' || latest.kind === 'scores-reset') {
this.reset();
}
});
}
/**
* Exposed for callers that want to clear the cached board/selected detail
* after a destructive operation (e.g. challenge wipe). The signal
* subscription above re-evaluates when `events()` mutates.
*/
invalidateForSystemChange(_kind: 'scores-reset' | 'challenges-wiped' | 'restore-completed'): void {
this.reset();
} }
readonly board = this._board.asReadonly(); readonly board = this._board.asReadonly();
@@ -1,4 +1,4 @@
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core'; import { DestroyRef, Injectable, computed, effect, inject, signal } from '@angular/core';
import { ScoreboardApiService } from './scoreboard.service'; import { ScoreboardApiService } from './scoreboard.service';
import { import {
EventLogRow, EventLogRow,
@@ -15,11 +15,13 @@ import {
parseSolveEventIntoRanking, parseSolveEventIntoRanking,
} from './scoreboard.pure'; } from './scoreboard.pure';
import { EventSourceLike } from '../../core/services/event-status.pure'; import { EventSourceLike } from '../../core/services/event-status.pure';
import { SystemDataChangeService } from '../../core/services/system-data-change.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ScoreboardStore { export class ScoreboardStore {
private readonly api: ScoreboardApiService; private readonly api: ScoreboardApiService;
private readonly destroyRef: DestroyRef; private readonly destroyRef: DestroyRef;
private readonly dataChanges: SystemDataChangeService | null;
private readonly _ranking = signal<RankingRow[] | null>(null); private readonly _ranking = signal<RankingRow[] | null>(null);
private readonly _matrix = signal<MatrixView | null>(null); private readonly _matrix = signal<MatrixView | null>(null);
@@ -35,10 +37,26 @@ export class ScoreboardStore {
private createSource: (() => EventSourceLike) | null = null; private createSource: (() => EventSourceLike) | null = null;
private loadAllInFlight: Promise<void> | null = null; private loadAllInFlight: Promise<void> | null = null;
constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef) { constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef, dataChanges?: SystemDataChangeService) {
this.api = api ?? inject(ScoreboardApiService); this.api = api ?? inject(ScoreboardApiService);
this.destroyRef = destroyRef ?? inject(DestroyRef); this.destroyRef = destroyRef ?? inject(DestroyRef);
this.dataChanges = dataChanges ?? null;
this.destroyRef.onDestroy(() => this.stop()); this.destroyRef.onDestroy(() => this.stop());
if (!this.dataChanges) return;
const lastSeen = { ts: 0 };
const dc = this.dataChanges;
effect(() => {
const evts = dc.events();
const latest = evts[evts.length - 1];
if (!latest || latest.ts === lastSeen.ts) return;
lastSeen.ts = latest.ts;
if (latest.kind === 'scores-reset' || latest.kind === 'restore-completed' || latest.kind === 'challenges-wiped') {
this._ranking.set(null);
this._matrix.set(null);
this._eventLog.set(null);
this._graph.set(null);
}
});
} }
readonly ranking = this._ranking.asReadonly(); readonly ranking = this._ranking.asReadonly();
@@ -0,0 +1,131 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import * as express from 'express';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
import { ConfigService } from '@nestjs/config';
import { initDb } from './db-helper';
async function buildAgent(
app: INestApplication,
username: string,
password: string,
): Promise<{ agent: any; token: string }> {
const server = app.getHttpServer();
const agent = request.agent(server);
await agent.get('/api/v1/auth/csrf');
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
const res = await agent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrf.value)
.send({ username, password })
.expect(201);
(agent as any).accessToken = res.body.accessToken as string;
return { agent, token: res.body.accessToken as string };
}
async function csrfHeaderFor(agent: any): Promise<string> {
const cookies = agent.jar.getCookies(CookieAccessInfo.All);
return (cookies as any[]).find((c: any) => c.name === 'csrf').value as string;
}
describe('Job 871: System endpoint authorization', () => {
let app: INestApplication;
let adminAgent: any;
let adminToken: string;
let playerAgent: any;
let playerToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '10mb' }));
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await initDb(app);
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
adminAgent = request.agent(app.getHttpServer());
await adminAgent.get('/api/v1/auth/csrf');
const csrfAdmin = (adminAgent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
const adminLogin = await adminAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrfAdmin.value)
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
adminToken = adminLogin.body.accessToken;
(adminAgent as any).accessToken = adminToken;
await adminAgent
.post('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrfAdmin.value)
.send({ username: 'player1', password: 'Sup3rSecret!Pass', role: 'player' })
.expect(201);
const playerLogin = await adminAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrfAdmin.value)
.send({ username: 'player1', password: 'Sup3rSecret!Pass' })
.expect(201);
playerToken = playerLogin.body.accessToken;
playerAgent = request.agent(app.getHttpServer());
await playerAgent.get('/api/v1/auth/csrf');
const csrfPlayer = (playerAgent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
await playerAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrfPlayer.value)
.send({ username: 'player1', password: 'Sup3rSecret!Pass' })
.expect(201);
(playerAgent as any).accessToken = playerToken;
});
afterAll(async () => {
await app.close();
});
it('GET /admin/system/backup without token returns 401', async () => {
await request(app.getHttpServer()).get('/api/v1/admin/system/backup').expect(401);
});
it('GET /admin/system/backup with player JWT returns 403', async () => {
await request(app.getHttpServer())
.get('/api/v1/admin/system/backup')
.set('Authorization', `Bearer ${playerToken}`)
.expect(403);
});
it('GET /admin/system/backup with admin JWT returns 200 application/json', async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/admin/system/backup')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expect(typeof res.text).toBe('string');
const parsed = JSON.parse(res.text);
expect(parsed.format).toBe('hipctf-system-backup');
expect(parsed.tables).toBeDefined();
expect(Array.isArray(parsed.uploads)).toBe(true);
});
});
+107
View File
@@ -0,0 +1,107 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { validateEnv } from '../../backend/src/config/env.schema';
import { BackupService } from '../../backend/src/modules/admin/system/backup.service';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../backend/src/database/entities/challenge-file.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity';
import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity';
import { AdminOperationTokenEntity } from '../../backend/src/database/entities/admin-operation-token.entity';
import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1700000000000-InitSchema';
import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData';
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema';
import { AddAdminOperationTokens1700000000900 } from '../../backend/src/database/migrations/1700000000900-AddAdminOperationTokens';
describe('Job 871: BackupService captures + filters', () => {
let moduleRef: any;
let backup: BackupService;
let uploadDir: string;
beforeAll(async () => {
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-sys-backup-'));
process.env.UPLOAD_DIR = uploadDir;
fs.writeFileSync(path.join(uploadDir, 'logo.bin'), Buffer.from('LOGO-BYTES'));
fs.mkdirSync(path.join(uploadDir, 'icons'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'icons', 'icon.png'), Buffer.from('ICON'));
moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
entities: [
UserEntity, SettingEntity, CategoryEntity, ChallengeEntity,
ChallengeFileEntity, SolveEntity, RefreshTokenEntity, BlogPostEntity,
AdminOperationTokenEntity,
],
migrations: [
InitSchema1700000000000,
SeedSystemData1700000000100,
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
AddAdminOperationTokens1700000000900,
],
migrationsRun: true,
synchronize: false,
}),
],
providers: [BackupService],
}).compile();
backup = moduleRef.get(BackupService);
const dataSource = moduleRef.get(getDataSourceToken());
await dataSource.query(
`INSERT INTO user (id,username,password_hash,role,status,created_at) VALUES (?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
['u-1', 'alice', 'HASH', 'player', 'enabled'],
);
await dataSource.query(`INSERT INTO setting (key,value) VALUES (?,?)`, ['testCustomKey', 'HIPCTF']);
});
afterAll(async () => {
await moduleRef?.close();
if (uploadDir && fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
it('captures every application table and base64-encoded uploads', async () => {
const obj = await backup.build();
expect(obj.format).toBe('hipctf-system-backup');
expect(obj.tables.user.length).toBeGreaterThanOrEqual(1);
expect(obj.tables.setting.length).toBeGreaterThanOrEqual(1);
expect(obj.uploads.length).toBeGreaterThanOrEqual(1);
const logo = obj.uploads.find((u) => u.path.includes('logo.bin'));
expect(logo).toBeDefined();
expect(Buffer.from(logo!.dataBase64, 'base64').toString()).toBe('LOGO-BYTES');
});
it('never includes operational or sqlite-internal tables', async () => {
const obj = await backup.build();
expect(Object.keys(obj.tables)).not.toContain('admin_operation_token');
expect(Object.keys(obj.tables)).not.toContain('migrations');
expect(Object.keys(obj.tables)).not.toContain('sqlite_master');
expect(obj.tableCounts['admin_operation_token']).toBeUndefined();
});
});
@@ -0,0 +1,146 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import * as express from 'express';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
import { ConfigService } from '@nestjs/config';
import { initDb } from './db-helper';
async function csrfHeader(agent: any): Promise<string> {
const cookies = agent.jar.getCookies(CookieAccessInfo.All);
return (cookies as any[]).find((c: any) => c.name === 'csrf').value as string;
}
async function loginAgent(app: INestApplication, username: string, password: string): Promise<{ agent: any; token: string }> {
const agent = request.agent(app.getHttpServer());
await agent.get('/api/v1/auth/csrf');
const csrf = await csrfHeader(agent);
const res = await agent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrf)
.send({ username, password })
.expect(201);
(agent as any).accessToken = res.body.accessToken;
return { agent, token: res.body.accessToken };
}
describe('Job 871: Confirmation-token binding + replay rejection', () => {
let app: INestApplication;
let admin: { agent: any; token: string };
let csrf: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '10mb' }));
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await initDb(app);
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
admin = await loginAgent(app, 'admin', 'Sup3rSecret!Pass');
csrf = await csrfHeader(admin.agent);
});
afterAll(async () => {
await app.close();
});
it('issues a confirmation token for reset-scores', async () => {
const res = await admin.agent
.post('/api/v1/admin/system/confirmations')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' })
.expect(200);
expect(res.body.token).toEqual(expect.any(String));
expect(res.body.operation).toBe('reset-scores');
expect(res.body.expiresAt).toEqual(expect.any(String));
});
it('rejects confirmation requests with the wrong password', async () => {
const res = await admin.agent
.post('/api/v1/admin/system/confirmations')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', password: 'WrongPassword!1' });
expect(res.status).toBe(401);
expect(res.body.code).toBe('SYSTEM_INVALID_CREDENTIALS');
});
it('rejects token requests without authentication', async () => {
const res = await request(app.getHttpServer())
.post('/api/v1/admin/system/confirmations')
.send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' });
expect([401, 403]).toContain(res.status);
});
it('rejects token reuse on the reset-scores endpoint', async () => {
const issued = await admin.agent
.post('/api/v1/admin/system/confirmations')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' })
.expect(200);
const first = await admin.agent
.post('/api/v1/admin/system/scores/reset')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', token: issued.body.token })
.expect(200);
expect(first.body.ok).toBe(true);
const second = await admin.agent
.post('/api/v1/admin/system/scores/reset')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', token: issued.body.token });
expect([400, 409]).toContain(second.status);
expect(['SYSTEM_TOKEN_REUSED', 'SYSTEM_TOKEN_INVALID']).toContain(second.body.code);
});
it('rejects tokens issued for a different operation', async () => {
const issued = await admin.agent
.post('/api/v1/admin/system/confirmations')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' })
.expect(200);
const res = await admin.agent
.post('/api/v1/admin/system/challenges/wipe')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'wipe-challenges', token: issued.body.token });
expect(res.status).toBe(400);
expect(['SYSTEM_TOKEN_MISMATCH', 'SYSTEM_TOKEN_INVALID']).toContain(res.body.code);
});
it('rejects tokens with a fabricated value', async () => {
const res = await admin.agent
.post('/api/v1/admin/system/scores/reset')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', token: 'not-a-real-token' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('SYSTEM_TOKEN_INVALID');
});
});
+215
View File
@@ -0,0 +1,215 @@
// Resolve a stable per-suite upload directory BEFORE AppModule is imported so
// the @nestjs/config validator picks it up at module-decoration time. We
// don't care about the exact path for the test runner — the absolute path
// just needs to be writable and consistent for the lifetime of this worker.
const TEST_UPLOAD_DIR = `/tmp/hipctf-danger-zone-${process.pid}-${Date.now()}`;
try {
fs.mkdirSync(TEST_UPLOAD_DIR, { recursive: true });
} catch {
// ignore
}
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.UPLOAD_DIR = TEST_UPLOAD_DIR;
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { getDataSourceToken } from '@nestjs/typeorm';
import cookieParser from 'cookie-parser';
import * as express from 'express';
import * as fs from 'fs';
import * as path from 'path';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
import { ConfigService } from '@nestjs/config';
import { DangerZoneService } from '../../backend/src/modules/admin/system/danger-zone.service';
import { initDb } from './db-helper';
async function csrfHeader(agent: any): Promise<string> {
const cookies = agent.jar.getCookies(CookieAccessInfo.All);
return (cookies as any[]).find((c: any) => c.name === 'csrf').value as string;
}
async function loginAgent(
app: INestApplication,
username: string,
password: string,
): Promise<{ agent: any; token: string }> {
const agent = request.agent(app.getHttpServer());
await agent.get('/api/v1/auth/csrf');
const csrf = await csrfHeader(agent);
const res = await agent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrf)
.send({ username, password })
.expect(201);
return { agent, token: res.body.accessToken };
}
describe('Job 871: Danger zone preservation rules', () => {
let app: INestApplication;
let admin: { agent: any; token: string };
let csrf: string;
const uploadDir = TEST_UPLOAD_DIR;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '10mb' }));
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await initDb(app);
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
admin = await loginAgent(app, 'admin', 'Sup3rSecret!Pass');
csrf = await csrfHeader(admin.agent);
// Add a player + a solve by inserting a row directly (so we don't depend on challenges being provisioned).
const directSql = async (sql: string) => {
const ds = app.get('DatabaseModule') ;
const dataSource = app.get('DataSource' as any);
void ds;
await dataSource.query(sql);
};
const dataSource = app.get(getDataSourceToken());
const playerId = 'p-1';
const catId = 'cat-1';
const chId = 'ch-1';
await dataSource.query(
`INSERT INTO user (id,username,password_hash,role,status,created_at) VALUES (?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[playerId, 'player1', 'X', 'player', 'enabled'],
);
await dataSource.query(
`INSERT INTO category (id,system_key,name,abbreviation,description,icon_path,created_at,updated_at) VALUES (?,NULL,'Custom','CUS','','',strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[catId],
);
await dataSource.query(
`INSERT INTO challenge (id,name,description_md,category_id,difficulty,initial_points,minimum_points,decay_solves,flag,protocol,port,ip_address,enabled,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[chId, 'Test', '', catId, 'LOW', 100, 50, 10, 'flag{}', 'WEB', null, '', 1],
);
await dataSource.query(
`INSERT INTO solve (id,challenge_id,user_id,solved_at,points_awarded,base_points,rank_bonus,is_first,is_second,is_third) VALUES (?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),?,?,?,?,?,?)`,
['s-1', chId, playerId, 100, 100, 0, 1, 0, 0],
);
void directSql;
});
afterAll(async () => {
await app.close();
if (uploadDir && fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
it('reset-scores deletes every solve while preserving users + categories + challenges', async () => {
const dataSource = app.get(getDataSourceToken());
const before = await dataSource.query('SELECT COUNT(*) AS c FROM solve');
expect(Number((before as any[])[0].c)).toBeGreaterThanOrEqual(1);
const issued = await admin.agent
.post('/api/v1/admin/system/confirmations')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' })
.expect(200);
const res = await admin.agent
.post('/api/v1/admin/system/scores/reset')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'reset-scores', token: issued.body.token })
.expect(200);
expect(res.body.ok).toBe(true);
expect(res.body.solvesRemoved).toBeGreaterThanOrEqual(1);
const afterSolves = await dataSource.query('SELECT COUNT(*) AS c FROM solve');
expect(Number((afterSolves as any[])[0].c)).toBe(0);
const users = await dataSource.query('SELECT COUNT(*) AS c FROM user');
expect(Number((users as any[])[0].c)).toBeGreaterThanOrEqual(2);
const cats = await dataSource.query('SELECT COUNT(*) AS c FROM category');
expect(Number((cats as any[])[0].c)).toBeGreaterThanOrEqual(1);
const chs = await dataSource.query('SELECT COUNT(*) AS c FROM challenge');
expect(Number((chs as any[])[0].c)).toBeGreaterThanOrEqual(1);
});
it('wipe-challenges removes every challenge + solve + file but keeps users + categories', async () => {
const dataSource = app.get(getDataSourceToken());
// seed a challenge file row so we can confirm preserves rule
await dataSource.query(
`INSERT INTO challenge_file (id,challenge_id,original_filename,stored_filename,mime_type,size_bytes,created_at) VALUES (?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
['cf-1', 'ch-1', 'readme.txt', 'cf-1.bin', 'text/plain', 0],
);
// Pre-create a real challenge file on disk so the physical-delete
// step actually has something to remove.
const challengesDir = path.join(uploadDir, 'challenges');
fs.mkdirSync(challengesDir, { recursive: true });
fs.writeFileSync(path.join(challengesDir, 'cf-1.bin'), Buffer.from('LIVE-CHALLENGE-BYTES'));
// Sanity: confirm the DangerZoneService resolved to our temp dir.
const danger = app.get(DangerZoneService);
expect((danger as any).uploadDir).toBe(uploadDir);
void danger;
const issued = await admin.agent
.post('/api/v1/admin/system/confirmations')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'wipe-challenges', password: 'Sup3rSecret!Pass' })
.expect(200);
const res = await admin.agent
.post('/api/v1/admin/system/challenges/wipe')
.set('Authorization', `Bearer ${admin.token}`)
.set('X-CSRF-Token', csrf)
.send({ operation: 'wipe-challenges', token: issued.body.token })
.expect(200);
expect(res.body.ok).toBe(true);
expect(res.body.challengesRemoved).toBeGreaterThanOrEqual(1);
const challenges = await dataSource.query('SELECT COUNT(*) AS c FROM challenge');
expect(Number((challenges as any[])[0].c)).toBe(0);
const files = await dataSource.query('SELECT COUNT(*) AS c FROM challenge_file');
expect(Number((files as any[])[0].c)).toBe(0);
const solves = await dataSource.query('SELECT COUNT(*) AS c FROM solve');
expect(Number((solves as any[])[0].c)).toBe(0);
const users = await dataSource.query('SELECT COUNT(*) AS c FROM user');
expect(Number((users as any[])[0].c)).toBeGreaterThanOrEqual(2);
const cats = await dataSource.query('SELECT COUNT(*) AS c FROM category');
expect(Number((cats as any[])[0].c)).toBeGreaterThanOrEqual(1);
// Live challenge-files directory must be physically empty/absent.
if (fs.existsSync(challengesDir)) {
const remaining = fs.readdirSync(challengesDir);
expect(remaining).toEqual([]);
}
// Staging snapshot must have been cleaned up after the disk delete.
const snapshotLeftover = fs
.readdirSync(uploadDir)
.some((entry) => entry.startsWith('challenges.wipe-stage-'));
expect(snapshotLeftover).toBe(false);
});
});
@@ -0,0 +1,129 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { getDataSourceToken } from '@nestjs/typeorm';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { validateEnv } from '../../backend/src/config/env.schema';
import { RestoreService } from '../../backend/src/modules/admin/system/restore.service';
import { FilesystemTransactionService } from '../../backend/src/modules/admin/system/filesystem-transaction.service';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../backend/src/database/entities/challenge-file.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity';
import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity';
import { AdminOperationTokenEntity } from '../../backend/src/database/entities/admin-operation-token.entity';
describe('Job 871: RestoreService validation rejects malformed archives before mutation', () => {
let moduleRef: any;
let restore: RestoreService;
let dataSource: any;
beforeAll(async () => {
process.env.UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-restore-uploads-'));
moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
entities: [
UserEntity, SettingEntity, CategoryEntity, ChallengeEntity,
ChallengeFileEntity, SolveEntity, RefreshTokenEntity, BlogPostEntity,
AdminOperationTokenEntity,
],
synchronize: true,
}),
],
providers: [RestoreService, FilesystemTransactionService],
}).compile();
moduleRef.init();
dataSource = moduleRef.get(getDataSourceToken());
restore = moduleRef.get(RestoreService);
});
afterAll(async () => {
const dir = process.env.UPLOAD_DIR!;
if (fs.existsSync(dir)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
await moduleRef?.close();
});
it('rejects empty JSON', async () => {
await expect(restore.stageArchive('', 'admin-1')).rejects.toMatchObject({
code: 'SYSTEM_RESTORE_VALIDATION_FAILED',
});
});
it('rejects wrong format', async () => {
const bad = JSON.stringify({ format: 'wrong', version: 1, exportedAt: 'x', tables: {}, uploads: [] });
await expect(restore.stageArchive(bad, 'admin-1')).rejects.toMatchObject({
code: 'SYSTEM_RESTORE_VALIDATION_FAILED',
});
});
it('rejects base64 size mismatch', async () => {
const bad = {
format: 'hipctf-system-backup',
version: 1,
exportedAt: 'x',
tables: {},
uploads: [
{
path: 'icons/mismatch.png',
originalFilename: 'm.png',
mimeType: 'image/png',
sizeBytes: 100,
dataBase64: Buffer.from('short').toString('base64'),
},
],
};
await expect(restore.stageArchive(JSON.stringify(bad), 'admin-1')).rejects.toMatchObject({
code: 'SYSTEM_RESTORE_VALIDATION_FAILED',
});
});
it('rejects path traversal', async () => {
const data = Buffer.alloc(0).toString('base64');
const bad = {
format: 'hipctf-system-backup',
version: 1,
exportedAt: 'x',
tables: {},
uploads: [
{
path: '../../../etc/passwd',
originalFilename: 'x',
mimeType: 'application/octet-stream',
sizeBytes: 0,
dataBase64: data,
},
],
};
await expect(restore.stageArchive(JSON.stringify(bad), 'admin-1')).rejects.toMatchObject({
code: 'SYSTEM_RESTORE_VALIDATION_FAILED',
});
});
it('does not mutate live data after rejected validation', async () => {
const users = await dataSource.query('SELECT COUNT(*) AS c FROM user');
expect(Number((users as any[])[0]?.c ?? 0)).toBeGreaterThanOrEqual(0);
const stagedCount = (restore as any).stages?.size ?? 0;
expect(stagedCount).toBe(0);
});
});
+76
View File
@@ -0,0 +1,76 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
import {
coerceSystemError,
destructiveCopy,
deriveBackupFilename,
friendlySystemErrorMessage,
} from '../../frontend/src/app/features/admin/system/system.pure';
describe('Job 871: friendlySystemErrorMessage mapping', () => {
it('returns the friendly token-expired message for SYSTEM_TOKEN_EXPIRED', () => {
const err = { status: 400, code: 'SYSTEM_TOKEN_EXPIRED', message: 'expired' };
expect(friendlySystemErrorMessage('restore-backup', err)).toContain('expired');
expect(friendlySystemErrorMessage('restore-backup', err)).toMatch(/Re-authenticate/i);
});
it('returns a specific message for SYSTEM_RESTORE_ROLLED_BACK', () => {
expect(friendlySystemErrorMessage('restore-backup', { status: 500, code: 'SYSTEM_RESTORE_ROLLED_BACK', message: 'rb' }))
.toContain('rolled back');
});
it('falls back to operation message when code is unknown', () => {
expect(friendlySystemErrorMessage('wipe-challenges', { status: 500, code: 'WEIRD', message: 'broken' }))
.toBe('broken');
});
it('falls back to the operation default when message is empty', () => {
expect(friendlySystemErrorMessage('reset-scores', { status: 500, code: 'WEIRD', message: '' }))
.toContain('Reset scores failed');
});
});
describe('Job 871: destructiveCopy', () => {
it('reset-scores confirms with RESET SCORES', () => {
expect(destructiveCopy('reset-scores').confirmation).toBe('RESET SCORES');
});
it('wipe-challenges confirms with WIPE CHALLENGES', () => {
expect(destructiveCopy('wipe-challenges').confirmation).toBe('WIPE CHALLENGES');
});
it('restore-backup confirms with RESTORE BACKUP', () => {
expect(destructiveCopy('restore-backup').confirmation).toBe('RESTORE BACKUP');
});
});
describe('Job 871: deriveBackupFilename', () => {
it('contains today date and .json', () => {
const name = deriveBackupFilename();
expect(name).toMatch(/^hipctf-backup-\d{4}-\d{2}-\d{2}\.json$/);
});
});
describe('Job 871: coerceSystemError', () => {
it('reads body from HttpErrorResponse shape', () => {
const out = coerceSystemError(
{ status: 400, error: { code: 'X', message: 'Y' } },
'fallback',
);
expect(out.code).toBe('X');
expect(out.message).toBe('Y');
expect(out.status).toBe(400);
});
it('falls back when no envelope is present', () => {
const out = coerceSystemError(new Error('boom'), 'fallback');
expect(out.code).toBe('INTERNAL');
expect(out.message).toBe('boom');
});
it('falls back for null', () => {
const out = coerceSystemError(null, 'fallback');
expect(out.code).toBe('INTERNAL');
expect(out.message).toBe('fallback');
});
});