feat: Admin Area System: Database Backup/Restore and Danger Zone
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -30,6 +30,23 @@ export const ERROR_CODES = {
|
||||
EVENT_NOT_RUNNING: 'EVENT_NOT_RUNNING',
|
||||
FLAG_INCORRECT: 'FLAG_INCORRECT',
|
||||
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',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -72,4 +72,39 @@ export function buildMulter(config: ConfigService, opts: MulterOptions = {}): mu
|
||||
}),
|
||||
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;
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export const envSchema = z.object({
|
||||
UPLOAD_SIZE_LIMIT: z.string().default('50mb'),
|
||||
|
||||
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>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ChallengeFileEntity } from './entities/challenge-file.entity';
|
||||
import { SolveEntity } from './entities/solve.entity';
|
||||
import { RefreshTokenEntity } from './entities/refresh-token.entity';
|
||||
import { BlogPostEntity } from './entities/blog-post.entity';
|
||||
import { AdminOperationTokenEntity } from './entities/admin-operation-token.entity';
|
||||
import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
|
||||
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
|
||||
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
||||
@@ -20,6 +21,7 @@ import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/170000000
|
||||
import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges';
|
||||
import { AddUserDeleteCascades1700000000700 } from './migrations/1700000000700-AddUserDeleteCascades';
|
||||
import { AddUserLastAdminTriggers1700000000800 } from './migrations/1700000000800-AddUserLastAdminTriggers';
|
||||
import { AddAdminOperationTokens1700000000900 } from './migrations/1700000000900-AddAdminOperationTokens';
|
||||
import { DatabaseInitService } from './database-init.service';
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -31,6 +33,7 @@ const ENTITIES = [
|
||||
SolveEntity,
|
||||
RefreshTokenEntity,
|
||||
BlogPostEntity,
|
||||
AdminOperationTokenEntity,
|
||||
];
|
||||
|
||||
const MIGRATIONS = [
|
||||
@@ -43,6 +46,7 @@ const MIGRATIONS = [
|
||||
SeedSampleChallenges1700000000600,
|
||||
AddUserDeleteCascades1700000000700,
|
||||
AddUserLastAdminTriggers1700000000800,
|
||||
AddAdminOperationTokens1700000000900,
|
||||
];
|
||||
|
||||
@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 { AdminChallengesService } from './challenges.service';
|
||||
import { ChallengeFilesService } from './challenge-files.service';
|
||||
import { AdminSystemModule } from './system/admin-system.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { ChallengeFilesService } from './challenge-files.service';
|
||||
UsersModule,
|
||||
SettingsModule,
|
||||
CommonModule,
|
||||
AdminSystemModule,
|
||||
],
|
||||
providers: [
|
||||
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');
|
||||
}
|
||||
@@ -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
|
||||
* transaction. The refresh token (raw) is returned alongside the access
|
||||
|
||||
@@ -69,6 +69,11 @@ export const APP_ROUTES: Routes = [
|
||||
loadComponent: () =>
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.clearAndBroadcast();
|
||||
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: 'players', label: 'Players', path: '/admin/players', 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({
|
||||
|
||||
@@ -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 {
|
||||
BoardCard,
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
} from './challenges.pure';
|
||||
import { ChallengesApiService } from './challenges.service';
|
||||
import { EventSourceLike } from '../../core/services/event-status.pure';
|
||||
import { SystemDataChangeService } from '../../core/services/system-data-change.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChallengesStore {
|
||||
private readonly api: ChallengesApiService;
|
||||
private readonly destroyRef: DestroyRef;
|
||||
private readonly dataChanges: SystemDataChangeService | null;
|
||||
|
||||
private readonly _board = signal<BoardResponse | null>(null);
|
||||
private readonly _eventState = signal<EventStatePayloadLike['state']>('unconfigured');
|
||||
@@ -35,10 +37,35 @@ export class ChallengesStore {
|
||||
|
||||
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.destroyRef = destroyRef ?? inject(DestroyRef);
|
||||
this.dataChanges = dataChanges ?? null;
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
EventLogRow,
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
parseSolveEventIntoRanking,
|
||||
} from './scoreboard.pure';
|
||||
import { EventSourceLike } from '../../core/services/event-status.pure';
|
||||
import { SystemDataChangeService } from '../../core/services/system-data-change.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScoreboardStore {
|
||||
private readonly api: ScoreboardApiService;
|
||||
private readonly destroyRef: DestroyRef;
|
||||
private readonly dataChanges: SystemDataChangeService | null;
|
||||
|
||||
private readonly _ranking = signal<RankingRow[] | null>(null);
|
||||
private readonly _matrix = signal<MatrixView | null>(null);
|
||||
@@ -35,10 +37,26 @@ export class ScoreboardStore {
|
||||
private createSource: (() => EventSourceLike) | 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.destroyRef = destroyRef ?? inject(DestroyRef);
|
||||
this.dataChanges = dataChanges ?? null;
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user