feat: Admin Area General Settings and Categories
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
# Implementation Plan: Repair `better-sqlite3` native binding to unblock test suite
|
||||
|
||||
## 0. Problem Statement
|
||||
|
||||
21 of 48 test suites fail with `TypeError: this.sqlite is not a constructor` thrown from `TypeOrm BetterSqlite3Driver.createDatabaseConnection` (`node_modules/typeorm/driver/better-sqlite3/BetterSqlite3Driver.js:88` — `new this.sqlite(database, ...)`). The remaining suites timeout with `Exceeded timeout of 5000 ms` because TypeORM silently retries (10 retries × 1s) on the same error and the test eventually times out. The visible symptoms in Jest's output are:
|
||||
|
||||
- Direct-DB suites (`tests/backend/theme-required.spec.ts`, `database-init.spec.ts`, `admin-categories-service.spec.ts`, `uploads-logo.spec.ts`, plus any other suite that imports `DatabaseModule`, `CommonModule`, or builds its own `TypeOrmModule.forRoot`) all hang/timeout with the same root error.
|
||||
- Suites that use `AppModule` (bootstrap, auth, event-stream, uploads integration tests, etc.) cascade the failure the first time `TypeOrmModule.forRootAsync` is loaded — 16 of 30 backend suites in total trigger the failure mode.
|
||||
|
||||
## 1. Root Cause (verified by direct read + REPL probe)
|
||||
|
||||
`/repo/node_modules/better-sqlite3` directory exists but **its compiled native binding (`better_sqlite3.node`) is missing**.
|
||||
|
||||
- `ls /repo/node_modules/better-sqlite3/build` → does not exist.
|
||||
- `find /repo -name 'better_sqlite3.node'` → zero results anywhere on disk.
|
||||
- `cat /repo/node_modules/better-sqlite3/package.json` shows `"install": "prebuild-install || node-gyp rebuild --release"` but the install step never ran for this install. The result is `require('better-sqlite3')` returns a plain function that throws when called as a constructor (`TypeError: this.sqlite is not a constructor` is what TypeORM sees at its `new this.sqlite(...)` call because its `loadDependencies()` reads the package via `PlatformTools.load("better-sqlite3")`; the require "succeeds" returning the stub function, `this.sqlite = sqlite` is set, then `new this.sqlite(...)` blows up because there is no native binding).
|
||||
|
||||
The trigger: an earlier `npm install sharp --no-audit --no-fund --prefix /repo --save-dev` step (used to add `sharp` so the icon-normalization tests could load it) re-mounted the workspace's dependency tree. The output of that command reported `added 7 packages, and removed 345 packages`, and `removed 345` includes the previously-compiled native bindings for `better-sqlite3` (and likely nuked the per-workspace `node_modules` too — `ls /repo/backend/node_modules` shows only `uuid` left, where before install there were ~50 packages). Other native packages (`argon2`, `sharp` itself) still work because they ship prebuilds that survive reinstall, but `better-sqlite3@11` historically relied on a `node-gyp` build that did not run during the reinstall.
|
||||
|
||||
This is **environmental / build-harness damage**, not a regression in any `.ts` source file. None of the seven files I edited this session (Job 859 + logo-upload changes) need to be reverted.
|
||||
|
||||
## 2. Constraints
|
||||
|
||||
- Plan Mode is active — I will not run install commands or modify files during planning. The full fix is the implementation turn's responsibility.
|
||||
- The container runs Node v20.20.2 on `linux-x64` (confirmed via `node -v` and `os.platform()`).
|
||||
- `better-sqlite3@11.10.0` was resolved and present in `/repo/node_modules/better-sqlite3/package.json` from the failed install.
|
||||
- We must keep `sharp` (added for the icon-normalize feature) and `uuid` (added at backend level) — both were intentional and unrelated to the breakage.
|
||||
- The fix must not require a network install if avoidable; it should rely on re-running the existing install scripts.
|
||||
- The fix must not require any source-code changes.
|
||||
|
||||
## 3. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:** NestJS 10 backend + Angular 17 frontend. Workspace uses npm workspaces (`package.json` declares `"workspaces": ["backend", "frontend"]`). Native modules resolve via npm's hoisted `node_modules/`.
|
||||
- **Native-module recovery precedent in this repo:** None — `setup.sh` simply runs `npm ci --no-audit --no-fund || npm install --no-audit --no-fund` at the repo root, then `npm --workspace frontend run build` and `npm --workspace backend run build`. There is no explicit `npm rebuild` step.
|
||||
- **Test framework:** Jest + ts-jest. Two projects: `tests/backend` (node env) and `tests/frontend` (jsdom env). All tests can be re-run with a single `npm test`.
|
||||
- **Required Tools & Dependencies:** No new system tools. The fix needs `npm` (already on PATH) and a working C/C++ toolchain in the container (Node was built natively here, so `node-gyp`/prebuilds should work).
|
||||
|
||||
## 4. Impacted Files (non-source)
|
||||
|
||||
### To Modify (none — no source code edits required for this fix)
|
||||
|
||||
### To Update (operational only)
|
||||
|
||||
- `setup.sh` — add an explicit `npm rebuild better-sqlite3` step (see §5) so a fresh container / CI run never has this regression again.
|
||||
- `package.json` scripts (optional, low-risk) — add `"rebuild-native": "npm rebuild better-sqlite3"` for future debugging.
|
||||
|
||||
### No changes needed
|
||||
|
||||
- `backend/package.json` (already declares `better-sqlite3: ^11.0.0` correctly).
|
||||
- `backend/src/**` (no source regression).
|
||||
- `frontend/src/**` (no source regression).
|
||||
- Any test file (the existing tests are correct).
|
||||
|
||||
## 5. Proposed Changes (ordered, safe, idempotent)
|
||||
|
||||
The fix is two commands + one documentation tweak. All steps are read-only wrt source code.
|
||||
|
||||
1. **Recompile the native binding.** From `/repo`, run:
|
||||
```
|
||||
npm rebuild better-sqlite3 --build-from-source
|
||||
```
|
||||
This forces `npm` to re-run better-sqlite3's `install` script (prebuild-install, falling back to `node-gyp rebuild --release`) against the current Node 20.20.2 binary. Acceptable runtime: ~30–90 seconds. Verifier: `find /repo -name 'better_sqlite3.node'` should return exactly one path under `node_modules/better-sqlite3/build/Release/...`.
|
||||
|
||||
2. **Verify TypeORM can construct the connection.** Run a quick smoke test:
|
||||
```
|
||||
node -e "const db = require('/repo/node_modules/better-sqlite3'); const inst = new db(':memory:'); console.log('memory:', inst.memory);"
|
||||
```
|
||||
This must print `memory: <an object>`. If `inst.memory` is an empty object, the binding is correctly loaded.
|
||||
|
||||
3. **Re-run the entire test suite.** Single command from the repo root:
|
||||
```
|
||||
npm test
|
||||
```
|
||||
Expected: `Test Suites: 48 passed, 48 total / Tests: 271 passed, 271 total` (matching the pre-regression baseline). If the count differs by exactly the new tests added in the prior logo-upload step (still 48 suites, 275 tests), that is the desired outcome.
|
||||
|
||||
4. **Hard-wire the recovery into `setup.sh`.** After the existing `npm ci --no-audit --no-fund || npm install --no-audit --no-fund` step, insert:
|
||||
```
|
||||
# Repro native modules that depend on the host Node ABI.
|
||||
npm rebuild better-sqlite3 --build-from-source || npm rebuild better-sqlite3
|
||||
```
|
||||
This guarantees a fresh clone / CI container that runs `setup.sh` always ends up with a working binding, even if the upstream npm resolution order accidentally drops it again.
|
||||
|
||||
5. **(Optional) Add a maintenance npm script.** In `package.json`'s top-level `scripts`, add:
|
||||
```
|
||||
"rebuild-native": "npm rebuild better-sqlite3 --build-from-source"
|
||||
```
|
||||
so a future implementer can recover with `npm run rebuild-native` when this surfaces again.
|
||||
|
||||
## 6. Test Strategy (verification only — no new test files)
|
||||
|
||||
We will not add a new test for this issue because the test suite itself is the canary. After applying steps 1–3 above, the suite must return to its previous green state.
|
||||
|
||||
Verification checklist executed in the implementation turn:
|
||||
|
||||
| Check | How | Expected result |
|
||||
|---|---|---|
|
||||
| Native binary exists | `find /repo -name 'better_sqlite3.node'` | exactly one file under `build/Release/better_sqlite3.node` |
|
||||
| Node can construct | smoke-test one-liner above | `inst.memory` is an object literal, not `undefined`/throw |
|
||||
| Backend unit tests green | `npx jest --selectProjects backend --testPathPattern=theme-required` | all 6 tests pass within 30s |
|
||||
| Backend integration tests green | `npx jest --selectProjects backend --testPathPattern=uploads-logo` | all 4 tests pass |
|
||||
| Full suite green | `npm test` | 48 / 48 suites, 271+ / 271+ tests pass |
|
||||
|
||||
Mocking strategy: not applicable (this is an environmental fix; no new code paths).
|
||||
|
||||
## 7. Order of Operations (recommended)
|
||||
|
||||
1. Run `npm rebuild better-sqlite3 --build-from-source` from `/repo`.
|
||||
2. Run the smoke-test one-liner to confirm binding loads.
|
||||
3. Run `npx jest --selectProjects backend --testPathPattern=theme-required` — expect green.
|
||||
4. Run the full `npm test` — expect green.
|
||||
5. Edit `setup.sh` (and optionally root `package.json`) to wire the rebuild into the canonical setup flow.
|
||||
6. Re-run `npm test` one final time to confirm the wired step works on a clean install path (skip if we can't fully simulate a clean install inside plan mode).
|
||||
|
||||
## 8. Risk and Rollback
|
||||
|
||||
- Risk is minimal: `npm rebuild` on a single package does not touch other dependencies, and `setup.sh` is only modified to add a recovery step (additive only).
|
||||
- Rollback: if `npm rebuild better-sqlite3 --build-from-source` fails on this image (no compiler toolchain), the fallback `npm rebuild better-sqlite3` (without `--build-from-source`) will attempt to download a precompiled binary via `prebuild-install`. Either path restores the binding; if both fail the issue is environmental and out of scope of the implementation turn.
|
||||
- We are NOT reverting any prior fix per the user's instruction — the seven files changed for Jobs 859 + logo-upload stay exactly as they are.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Implementation Plan: Job 881 — Cross-Tab Authenticated Shell Invalidation
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
- **Codebase style & conventions:** TypeScript monorepo with an Angular standalone-component SPA and NestJS API. Frontend services are root-provided Angular injectables using signals, `inject()`, async/await, and typed HTTP calls. Authentication state is held in `AuthService` signals and mirrored to per-tab `sessionStorage`; `HomeComponent` owns the authenticated shell and starts/stops the authenticated event stream. The route tree protects `/`, `/challenges`, `/scoreboard`, and `/blog` through the parent `authGuard`.
|
||||
- **Data Layer:** No database/schema change is required. The server-side logout endpoint already revokes/clears the refresh-cookie state; this job is a browser-context propagation fix. Existing frontend persistence uses `sessionStorage`, which is intentionally tab-scoped and therefore cannot notify sibling tabs. A cross-tab browser event channel should carry an invalidation notification without carrying access tokens or user data.
|
||||
- **Test Framework & Structure:** Root Jest 29 with two projects in `tests/jest.config.js`; frontend tests run in jsdom through `npm test` or `npm run test:frontend`. Tests are already centralized under `tests/frontend`, never beside source. Existing tests are mostly focused TypeScript/pure-contract tests, and `authenticated-event-source.spec.ts` models fetch/SSE boundaries because Angular runtime modules are difficult for the current Jest transform. Add only small logic tests for the notification/listener behavior and source error handling.
|
||||
- **Required Tools & Dependencies:** No new system tools, npm packages, database migrations, or `setup.sh` changes are expected. `BroadcastChannel` and/or the `storage` event are browser platform APIs. The implementation should feature-detect the selected API and remain safe in jsdom/SSR-like contexts. Existing Node/npm/Jest/TypeScript setup is sufficient.
|
||||
|
||||
## 2. Impacted Files
|
||||
- **To Modify:**
|
||||
- `frontend/src/app/core/services/auth.service.ts` — publish a same-browser-context session-invalidation event after local logout/clear and subscribe to peer-tab invalidation events so every tab clears its in-memory and tab-local state.
|
||||
- `frontend/src/app/core/services/authenticated-event-source.service.ts` — distinguish an unauthorized SSE response (`401`, and optionally other auth-failure statuses according to the existing API contract) from generic transport failure and expose it through the existing `error` event contract, or otherwise provide a single auth-invalidation callback path to `AuthService`.
|
||||
- `frontend/src/app/core/services/event-status.store.ts` — register an `error` listener for the authenticated event source and invoke the auth invalidation path on unauthorized stream failure; ensure `stop()` removes/neutralizes the listener and remains idempotent.
|
||||
- `frontend/src/app/features/home/home.component.ts` — react to the centralized auth state becoming unauthenticated, stop/reset shell-owned state as needed, and navigate to `/login` without requiring a quick-tab interaction or reload; avoid duplicate navigation when the local logout already performed it.
|
||||
- **To Create:**
|
||||
- `tests/frontend/auth-cross-tab.spec.ts` — focused tests for cross-tab invalidation publication/consumption and duplicate/self-origin behavior using mocked browser channel/storage boundaries.
|
||||
- If the implementer chooses a pure transport result rather than a direct callback, optionally `frontend/src/app/core/services/auth-session-events.pure.ts` and its corresponding test file; prefer keeping the event-name/payload validation pure and small rather than introducing an unnecessary abstraction.
|
||||
- **Not to Modify:** Backend controllers, refresh-token schema/migrations, shell markup, quick-tab components, routes, or `setup.sh`; the server invalidation and route protection already exist.
|
||||
|
||||
## 3. Proposed Changes
|
||||
1. **Database / Schema Migration:**
|
||||
- No migration. Confirm the existing `POST /api/v1/auth/logout` behavior remains the source of truth for server-side refresh-cookie invalidation.
|
||||
- Do not broadcast JWTs, refresh tokens, usernames, or arbitrary storage contents. Broadcast only a fixed event type (for example, a namespaced `logout`/`session-invalidated` message) and optionally a random per-tab sender identifier if needed to avoid self-processing.
|
||||
|
||||
2. **Backend Logic & APIs:**
|
||||
- No backend endpoint changes. The existing authenticated logout endpoint is called by `AuthService.logout()` before local state is cleared, and hard navigation already proves the server clears the refresh cookie.
|
||||
- Treat an authenticated SSE response with `401` as evidence that the current tab’s session is invalid. Update `AuthenticatedEventSourceService`’s internal fetch path so the response status is available to the existing `error` dispatch, without exposing response bodies or credentials. Generic network/stream errors should remain generic unless the response explicitly indicates unauthorized.
|
||||
- If the transport contract cannot safely distinguish error categories through `Event`, add a minimal typed error event/detail or a separate `unauthorized` listener type in `event-status.pure.ts`; update all implementations and tests consistently. Keep the contract framework-light and compatible with the current custom fetch-based EventSource implementation.
|
||||
|
||||
3. **Frontend UI Integration:**
|
||||
- Centralize cross-tab handling in `AuthService`, because it owns the authoritative `isAuthenticated`, access-token, and session-storage state. Initialize one browser listener for the root singleton, feature-detect `BroadcastChannel`, and provide a fallback using a namespaced `localStorage` key plus the browser `storage` event if BroadcastChannel is unavailable. If both are installed, publish on the primary channel and retain the fallback only if necessary; avoid double-processing identical notifications.
|
||||
- On a local logout, call the server as today, then clear local state and publish the invalidation signal. Ensure peer-tab handling calls a non-broadcasting local-clear method so the notification does not echo indefinitely. The local tab may also receive its own channel message in some test/browser implementations, so make handling idempotent.
|
||||
- On a peer invalidation, clear the access token, current user, and `sessionStorage`; make the state transition observable to consumers. If `AuthService.clear()` is reused by refresh failure or other local failures, define whether those paths should broadcast as well; the preferred rule is that any confirmed local session invalidation broadcasts, while a peer-originated clear suppresses rebroadcast.
|
||||
- Wire the authenticated SSE lifecycle to the same invalidation path. `EventStatusStore.start()` should attach both message and error listeners. When the authenticated event source reports `401`, invoke an injected/session-invalidation callback or call an explicit `AuthService` method; then close the stream. Do not redirect merely for transient network errors, malformed frames, or ordinary stream closure.
|
||||
- In `HomeComponent`, observe the auth signal or subscribe to a narrow invalidation event and navigate to `/login` after a peer logout/SSE unauthorized result. Reset `UserStore`, close the event stream, close open menus/modals if appropriate, and use a navigation guard/flag so the local `onLogout()` path does not trigger duplicate navigation. The next click on Challenges, Scoreboard, or Blog must not be able to continue rendering authenticated content; the route guard should see `isAuthenticated === false` and return the existing login `UrlTree`.
|
||||
- Ensure cleanup: close `BroadcastChannel`, remove `storage` listeners, and detach/neutralize SSE listeners during service/component destruction or `stop()`. Preserve the existing event countdown behavior and shell UI when authentication remains valid.
|
||||
- Keep browser API access guarded for non-browser/test environments. Use the existing Angular DI/signal conventions; do not add a third-party messaging library or move tokens into `localStorage`.
|
||||
|
||||
## 4. Test Strategy
|
||||
- **Target Unit Test File:** `tests/frontend/auth-cross-tab.spec.ts` for the core event protocol and AuthService behavior. Extend `tests/frontend/authenticated-event-source.spec.ts` only with the minimal unauthorized-response contract test if that file remains the established transport boundary. If direct Angular service instantiation is impractical under the current Jest setup, extract a small pure event encoder/decoder and test it directly, while testing the transport contract with the existing fetch mock style.
|
||||
- **Mocking Strategy:**
|
||||
- Mock `BroadcastChannel` with an in-memory registry of channel instances, recording `postMessage`, `addEventListener`, `removeEventListener`, and `close`; deliver messages only to peer instances to model browser behavior and separately cover a self-delivery implementation.
|
||||
- Mock `window.localStorage` and dispatch synthetic `StorageEvent` objects for the fallback path. Assert the implementation only accepts the fixed namespaced key/event shape and ignores malformed or unrelated storage changes.
|
||||
- Use a minimal fake `AuthService` or explicit test doubles for token/user/session-storage operations where testing `EventStatusStore`; use a fake `EventSourceLike` that records listeners and close calls to verify `401` causes invalidation and generic errors do not.
|
||||
- Cover only the core success and directly relevant failure paths: local logout publishes once and clears state; peer notification clears state without rebroadcast; fallback storage notification works when BroadcastChannel is absent; malformed/unrelated messages are ignored; unauthorized SSE invalidates and closes; generic SSE error preserves authentication. Run through the existing root `npm test` command; no browser/UI or persistent `/data` fixture is needed.
|
||||
Generated
+4626
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.35.3",
|
||||
"typeorm": "^0.3.20",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8"
|
||||
@@ -44,6 +45,7 @@
|
||||
"@types/uuid": "^9.0.8",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export const ERROR_CODES = {
|
||||
INVALID_OLD_PASSWORD: 'INVALID_OLD_PASSWORD',
|
||||
PASSWORD_POLICY: 'PASSWORD_POLICY',
|
||||
PASSWORDS_DO_NOT_MATCH: 'PASSWORDS_DO_NOT_MATCH',
|
||||
CATEGORY_HAS_CHALLENGES: 'CATEGORY_HAS_CHALLENGES',
|
||||
SYSTEM_PROTECTED: 'SYSTEM_PROTECTED',
|
||||
INTERNAL: 'INTERNAL',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { RefreshTokenEntity } from './entities/refresh-token.entity';
|
||||
import { BlogPostEntity } from './entities/blog-post.entity';
|
||||
import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
|
||||
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
|
||||
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
||||
import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys';
|
||||
import { DatabaseInitService } from './database-init.service';
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -26,7 +28,12 @@ const ENTITIES = [
|
||||
BlogPostEntity,
|
||||
];
|
||||
|
||||
const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100];
|
||||
const MIGRATIONS = [
|
||||
InitSchema1700000000000,
|
||||
SeedSystemData1700000000100,
|
||||
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
||||
UpdateSystemCategoryKeys1700000000300,
|
||||
];
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
|
||||
|
||||
@Entity('category')
|
||||
@Index('uq_category_abbreviation', ['abbreviation'], { unique: true })
|
||||
export class CategoryEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
@@ -20,4 +21,10 @@ export class CategoryEntity {
|
||||
|
||||
@Column('text', { name: 'icon_path', default: '' })
|
||||
iconPath!: string;
|
||||
}
|
||||
|
||||
@Column('text', { name: 'created_at', default: '' })
|
||||
createdAt!: string;
|
||||
|
||||
@Column('text', { name: 'updated_at', default: '' })
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCategoryTimestampsAndUniqueAbbrev1700000000200 implements MigrationInterface {
|
||||
name = 'AddCategoryTimestampsAndUniqueAbbrev1700000000200';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const cols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
|
||||
const names = new Set(cols.map((c: any) => c.name));
|
||||
if (!names.has('created_at')) {
|
||||
await queryRunner.query(`ALTER TABLE "category" ADD COLUMN "created_at" TEXT NOT NULL DEFAULT '';`);
|
||||
await queryRunner.query(
|
||||
`UPDATE "category" SET "created_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "created_at" = '';`,
|
||||
);
|
||||
}
|
||||
if (!names.has('updated_at')) {
|
||||
await queryRunner.query(`ALTER TABLE "category" ADD COLUMN "updated_at" TEXT NOT NULL DEFAULT '';`);
|
||||
await queryRunner.query(
|
||||
`UPDATE "category" SET "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "updated_at" = '';`,
|
||||
);
|
||||
}
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_abbreviation" ON "category"("abbreviation");`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "uq_category_abbreviation";`);
|
||||
await queryRunner.query(`ALTER TABLE "category" DROP COLUMN "updated_at";`);
|
||||
await queryRunner.query(`ALTER TABLE "category" DROP COLUMN "created_at";`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateSystemCategoryKeys1700000000300 implements MigrationInterface {
|
||||
name = 'UpdateSystemCategoryKeys1700000000300';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const desired: { key: string; name: string; abbreviation: string; description: string; iconPath: string }[] = [
|
||||
{ key: 'CRY', name: 'Cryptography', abbreviation: 'CRY', description: 'Cryptographic challenges', iconPath: '/uploads/icons/CRY.png' },
|
||||
{ key: 'MSC', name: 'Misc', abbreviation: 'MSC', description: 'Miscellaneous challenges', iconPath: '/uploads/icons/MSC.png' },
|
||||
{ key: 'PWN', name: 'Pwn', abbreviation: 'PWN', description: 'Binary exploitation', iconPath: '/uploads/icons/PWN.png' },
|
||||
{ key: 'REV', name: 'Reverse Engineering', abbreviation: 'REV', description: 'Reverse engineering challenges', iconPath: '/uploads/icons/REV.png' },
|
||||
{ key: 'WEB', name: 'Web', abbreviation: 'WEB', description: 'Web exploitation challenges', iconPath: '/uploads/icons/WEB.png' },
|
||||
{ key: 'HW', name: 'Hardware', abbreviation: 'HW', description: 'Hardware challenges', iconPath: '/uploads/icons/HW.png' },
|
||||
];
|
||||
const desiredKeys = new Set(desired.map((d) => d.key));
|
||||
const desiredAbbrs = new Set(desired.map((d) => d.abbreviation));
|
||||
|
||||
// First, drop any legacy seeded system rows whose system_key is no
|
||||
// longer in the desired set and whose abbreviation IS also in the
|
||||
// desired set (i.e. they were superseded by a canonical row). This
|
||||
// shrinks the table to exactly the desired 6 system rows.
|
||||
const legacyRows: any[] = await queryRunner.query(
|
||||
`SELECT id, system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL`,
|
||||
);
|
||||
for (const row of legacyRows) {
|
||||
if (desiredKeys.has(row.system_key) && desiredAbbrs.has(row.abbreviation)) {
|
||||
continue; // already canonical (e.g. seeded row whose seed matched the new abbreviation)
|
||||
}
|
||||
if (desiredAbbrs.has(row.abbreviation)) {
|
||||
// Legacy system row whose abbreviation is reused by a canonical
|
||||
// entry: drop it; the canonical entry will adopt the abbreviation.
|
||||
await queryRunner.query(`DELETE FROM "category" WHERE "id" = ?`, [row.id]);
|
||||
} else if (!desiredKeys.has(row.system_key)) {
|
||||
// Legacy system row with an abbreviation we don't keep (e.g.
|
||||
// 'forensics' / 'osint'): drop it too.
|
||||
await queryRunner.query(`DELETE FROM "category" WHERE "id" = ?`, [row.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Now seed missing canonical entries.
|
||||
for (const d of desired) {
|
||||
const existingKey = await queryRunner.query(`SELECT id FROM "category" WHERE "system_key" = ?`, [d.key]);
|
||||
if (existingKey.length > 0) continue;
|
||||
const existingAbbr = await queryRunner.query(`SELECT id FROM "category" WHERE "abbreviation" = ?`, [d.abbreviation]);
|
||||
if (existingAbbr.length > 0) {
|
||||
await queryRunner.query(`UPDATE "category" SET "system_key" = ? WHERE "id" = ?`, [d.key, existingAbbr[0].id]);
|
||||
continue;
|
||||
}
|
||||
const id = (await import('uuid')).v4();
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path","created_at","updated_at") VALUES (?,?,?,?,?,?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
|
||||
[id, d.key, d.name, d.abbreviation, d.description, d.iconPath],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DELETE FROM "category" WHERE "system_key" IN ('CRY','MSC','PWN','REV','WEB','HW');`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
CategoryIdParamSchema,
|
||||
CreateCategorySchema,
|
||||
UpdateCategorySchema,
|
||||
} from './dto/categories.dto';
|
||||
import { AdminCategoriesService, CategoryView } from './categories.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/admin/categories')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class AdminCategoriesController {
|
||||
constructor(private readonly categories: AdminCategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all categories sorted alphabetically by abbreviation (admin only)' })
|
||||
async list(): Promise<CategoryView[]> {
|
||||
return this.categories.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a user category (admin only)' })
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(CreateCategorySchema)) body: any,
|
||||
): Promise<CategoryView> {
|
||||
return this.categories.create(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update a category (admin only)' })
|
||||
async update(
|
||||
@Param(new ZodValidationPipe(CategoryIdParamSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdateCategorySchema)) body: any,
|
||||
): Promise<CategoryView> {
|
||||
return this.categories.update(params.id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a user category (admin only)' })
|
||||
async remove(
|
||||
@Param(new ZodValidationPipe(CategoryIdParamSchema)) params: { id: string },
|
||||
): Promise<void> {
|
||||
await this.categories.remove(params.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { GeneralSettingsSchema } from './dto/general.dto';
|
||||
import { AdminGeneralService, GeneralSettingsView, ThemeView } from './general.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/admin/general')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class AdminGeneralController {
|
||||
constructor(private readonly general: AdminGeneralService) {}
|
||||
|
||||
@Get('settings')
|
||||
@ApiOperation({ summary: 'Get current general settings (admin only)' })
|
||||
async getSettings(): Promise<GeneralSettingsView> {
|
||||
return this.general.getSettings();
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
@ApiOperation({ summary: 'Update general settings (admin only)' })
|
||||
async updateSettings(
|
||||
@Body(new ZodValidationPipe(GeneralSettingsSchema)) body: unknown,
|
||||
): Promise<GeneralSettingsView> {
|
||||
return this.general.updateSettings(body as any);
|
||||
}
|
||||
|
||||
@Get('themes')
|
||||
@ApiOperation({ summary: 'List available themes (admin only)' })
|
||||
async listThemes(): Promise<ThemeView[]> {
|
||||
return this.general.listThemes();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { CategoryEntity } from '../../database/entities/category.entity';
|
||||
import { ChallengeEntity } from '../../database/entities/challenge.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminGeneralController } from './admin-general.controller';
|
||||
import { AdminGeneralService } from './general.service';
|
||||
import { AdminCategoriesController } from './admin-categories.controller';
|
||||
import { AdminCategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule, UsersModule],
|
||||
providers: [AdminService],
|
||||
controllers: [AdminController],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity]),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
SettingsModule,
|
||||
CommonModule,
|
||||
],
|
||||
providers: [AdminService, AdminGeneralService, AdminCategoriesService],
|
||||
controllers: [AdminController, AdminGeneralController, AdminCategoriesController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { CategoryEntity } from '../../database/entities/category.entity';
|
||||
import { ChallengeEntity } from '../../database/entities/challenge.entity';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
import { CreateCategoryPayload, UpdateCategoryPayload } from './dto/categories.dto';
|
||||
|
||||
export interface CategoryView {
|
||||
id: string;
|
||||
name: string;
|
||||
abbreviation: string;
|
||||
description: string;
|
||||
iconPath: string;
|
||||
isSystem: boolean;
|
||||
systemKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminCategoriesService {
|
||||
constructor(
|
||||
@InjectRepository(CategoryEntity) private readonly categories: Repository<CategoryEntity>,
|
||||
@InjectRepository(ChallengeEntity) private readonly challenges: Repository<ChallengeEntity>,
|
||||
) {}
|
||||
|
||||
async list(): Promise<CategoryView[]> {
|
||||
const rows = await this.categories
|
||||
.createQueryBuilder('c')
|
||||
.orderBy('LOWER(c.abbreviation)', 'ASC')
|
||||
.addOrderBy('c.abbreviation', 'ASC')
|
||||
.getMany();
|
||||
return rows.map((r) => this.toView(r));
|
||||
}
|
||||
|
||||
async create(payload: CreateCategoryPayload): Promise<CategoryView> {
|
||||
const abbreviation = payload.abbreviation.toUpperCase();
|
||||
const dup = await this.categories.findOne({ where: { abbreviation } });
|
||||
if (dup) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Category abbreviation already exists');
|
||||
const row = this.categories.create({
|
||||
id: uuid(),
|
||||
systemKey: null,
|
||||
name: payload.name,
|
||||
abbreviation,
|
||||
description: payload.description ?? '',
|
||||
iconPath: payload.iconPath ?? '',
|
||||
});
|
||||
await this.categories.save(row);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async update(id: string, payload: UpdateCategoryPayload): Promise<CategoryView> {
|
||||
const row = await this.categories.findOne({ where: { id } });
|
||||
if (!row) throw ApiError.notFound('Category not found');
|
||||
if (payload.name !== undefined) row.name = payload.name;
|
||||
if (payload.description !== undefined) row.description = payload.description;
|
||||
if (payload.iconPath !== undefined) row.iconPath = payload.iconPath;
|
||||
if (payload.abbreviation !== undefined) {
|
||||
const newAbbr = payload.abbreviation.toUpperCase();
|
||||
if (row.systemKey) {
|
||||
if (newAbbr !== row.abbreviation) {
|
||||
throw ApiError.conflict(ERROR_CODES.SYSTEM_PROTECTED, 'System category abbreviation is immutable');
|
||||
}
|
||||
} else {
|
||||
const dup = await this.categories.findOne({ where: { abbreviation: newAbbr } });
|
||||
if (dup && dup.id !== row.id) {
|
||||
throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Category abbreviation already exists');
|
||||
}
|
||||
row.abbreviation = newAbbr;
|
||||
}
|
||||
}
|
||||
row.updatedAt = new Date().toISOString();
|
||||
await this.categories.save(row);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const row = await this.categories.findOne({ where: { id } });
|
||||
if (!row) throw ApiError.notFound('Category not found');
|
||||
if (row.systemKey) {
|
||||
throw new ApiError(ERROR_CODES.SYSTEM_PROTECTED, 'System categories cannot be deleted', 403);
|
||||
}
|
||||
const count = await this.challenges.count({ where: { categoryId: id } });
|
||||
if (count > 0) {
|
||||
throw new ApiError(
|
||||
ERROR_CODES.CATEGORY_HAS_CHALLENGES,
|
||||
'Category has challenges attached',
|
||||
409,
|
||||
{ count },
|
||||
);
|
||||
}
|
||||
await this.categories.delete({ id });
|
||||
}
|
||||
|
||||
private toView(r: CategoryEntity): CategoryView {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
abbreviation: r.abbreviation,
|
||||
description: r.description,
|
||||
iconPath: r.iconPath,
|
||||
isSystem: r.systemKey !== null,
|
||||
systemKey: r.systemKey,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
abbreviation: z.string().min(2).max(6),
|
||||
description: z.string().max(2000),
|
||||
iconPath: z.string().max(2048).optional(),
|
||||
});
|
||||
export type CreateCategoryPayload = z.infer<typeof CreateCategorySchema>;
|
||||
|
||||
export const UpdateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
abbreviation: z.string().min(2).max(6).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
iconPath: z.string().max(2048).optional(),
|
||||
});
|
||||
export type UpdateCategoryPayload = z.infer<typeof UpdateCategorySchema>;
|
||||
|
||||
export const CategoryIdParamSchema = z.object({ id: z.string().min(1).max(64) });
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
import { THEME_IDS } from '../../../common/types/theme-ids';
|
||||
|
||||
export const GeneralSettingsSchema = z
|
||||
.object({
|
||||
pageTitle: z.string().min(1).max(120),
|
||||
logo: z.string().max(2048),
|
||||
welcomeMarkdown: z.string().max(64_000),
|
||||
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
|
||||
eventStartUtc: z.string(),
|
||||
eventEndUtc: z.string(),
|
||||
defaultChallengeIp: z.string().min(1).max(255),
|
||||
registrationsEnabled: z.boolean(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
const start = Date.parse(val.eventStartUtc);
|
||||
const end = Date.parse(val.eventEndUtc);
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && end <= start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['eventEndUtc'],
|
||||
message: 'eventEndUtc must be strictly after eventStartUtc',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type GeneralSettingsPayload = z.infer<typeof GeneralSettingsSchema>;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SettingsService } from '../settings/settings.module';
|
||||
import { ThemeLoaderService } from '../../common/utils/theme-loader.service';
|
||||
import { SseHubService } from '../../common/services/sse-hub.service';
|
||||
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||
import { GeneralSettingsPayload } from './dto/general.dto';
|
||||
|
||||
export interface GeneralSettingsView {
|
||||
pageTitle: string;
|
||||
logo: string;
|
||||
welcomeMarkdown: string;
|
||||
themeKey: string;
|
||||
eventStartUtc: string;
|
||||
eventEndUtc: string;
|
||||
defaultChallengeIp: string;
|
||||
registrationsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeView {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminGeneralService {
|
||||
constructor(
|
||||
private readonly settings: SettingsService,
|
||||
private readonly themes: ThemeLoaderService,
|
||||
private readonly hub: SseHubService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async getSettings(): Promise<GeneralSettingsView> {
|
||||
const [pageTitle, logo, welcomeMarkdown, themeKey, eventStartUtc, eventEndUtc, defaultChallengeIp, registrationsEnabled] =
|
||||
await Promise.all([
|
||||
this.settings.get(SETTINGS_KEYS.PAGE_TITLE, 'HIPCTF'),
|
||||
this.settings.get(SETTINGS_KEYS.LOGO, ''),
|
||||
this.settings.get(SETTINGS_KEYS.WELCOME_MARKDOWN, ''),
|
||||
this.settings.get(SETTINGS_KEYS.THEME_KEY, 'classic'),
|
||||
this.settings.get(SETTINGS_KEYS.EVENT_START_UTC, ''),
|
||||
this.settings.get(SETTINGS_KEYS.EVENT_END_UTC, ''),
|
||||
this.settings.get(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, '127.0.0.1'),
|
||||
this.settings.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false'),
|
||||
]);
|
||||
return {
|
||||
pageTitle,
|
||||
logo,
|
||||
welcomeMarkdown,
|
||||
themeKey,
|
||||
eventStartUtc,
|
||||
eventEndUtc,
|
||||
defaultChallengeIp,
|
||||
registrationsEnabled: registrationsEnabled === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
async updateSettings(payload: GeneralSettingsPayload): Promise<GeneralSettingsView> {
|
||||
await Promise.all([
|
||||
this.settings.set(SETTINGS_KEYS.PAGE_TITLE, payload.pageTitle),
|
||||
this.settings.set(SETTINGS_KEYS.LOGO, payload.logo),
|
||||
this.settings.set(SETTINGS_KEYS.WELCOME_MARKDOWN, payload.welcomeMarkdown),
|
||||
this.settings.set(SETTINGS_KEYS.THEME_KEY, payload.themeKey),
|
||||
this.settings.set(SETTINGS_KEYS.EVENT_START_UTC, payload.eventStartUtc),
|
||||
this.settings.set(SETTINGS_KEYS.EVENT_END_UTC, payload.eventEndUtc),
|
||||
this.settings.set(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, payload.defaultChallengeIp),
|
||||
this.settings.set(SETTINGS_KEYS.REGISTRATIONS_ENABLED, payload.registrationsEnabled ? 'true' : 'false'),
|
||||
]);
|
||||
this.hub.emitEvent({ topic: 'general', themeKey: payload.themeKey });
|
||||
return this.getSettings();
|
||||
}
|
||||
|
||||
listThemes(): ThemeView[] {
|
||||
const themesDir = path.resolve(this.config.get<string>('THEMES_DIR', './themes'));
|
||||
let present = new Set<string>();
|
||||
try {
|
||||
if (fs.existsSync(themesDir)) {
|
||||
for (const f of fs.readdirSync(themesDir)) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(themesDir, f), 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { id?: string };
|
||||
if (parsed?.id) present.add(parsed.id);
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
return this.themes
|
||||
.listThemes()
|
||||
.filter((t) => present.has(t.id))
|
||||
.map((t) => ({ id: t.id, key: t.id, name: t.name }));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
|
||||
@@ -27,8 +28,8 @@ export class UploadsController {
|
||||
@ApiOperation({ summary: 'Upload a category icon (admin only)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadCategoryIcon(@Req() req: any) {
|
||||
return this.handleUpload(req, 'icons');
|
||||
async uploadCategoryIcon(@Req() req: any): Promise<{ publicUrl: string; width: number; height: number; mimeType: string }> {
|
||||
return this.handleCategoryIcon(req);
|
||||
}
|
||||
|
||||
@Post('challenge-file')
|
||||
@@ -37,20 +38,76 @@ export class UploadsController {
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadChallengeFile(@Req() req: any) {
|
||||
return this.handleUpload(req, 'challenges');
|
||||
return this.handleGenericUpload(req, 'challenges');
|
||||
}
|
||||
|
||||
private handleUpload(req: any, subdir: string) {
|
||||
@Post('logo')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Upload the site logo (admin only)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadLogo(@Req() req: any): Promise<{ publicUrl: string; originalFilename: string }> {
|
||||
return this.handleLogoUpload(req);
|
||||
}
|
||||
|
||||
private async handleCategoryIcon(req: any): Promise<{ id: string; publicUrl: string; width: number; height: number; mimeType: string; storedPath: string; size: number; originalFilename: string }> {
|
||||
const file = req.file as any;
|
||||
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
||||
if (file.size > this.globalLimit) {
|
||||
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
|
||||
}
|
||||
const safeName = safeFilename(file.originalname || 'file');
|
||||
const categoryId = String(req.body?.categoryId ?? '').trim();
|
||||
const finalDir = path.join(this.uploadDir, 'icons');
|
||||
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
|
||||
|
||||
// When categoryId is provided we normalize to 128x128 and store at a
|
||||
// deterministic URL keyed by id. When it is omitted (legacy / pre-existing
|
||||
// tests) we fall back to a safe version of the original filename.
|
||||
const hasId = !!categoryId;
|
||||
const fileName = hasId ? `${categoryId}.png` : safeFilename(file.originalname || 'file.png');
|
||||
const finalPath = path.join(finalDir, fileName);
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let mimeType = file.mimetype || 'application/octet-stream';
|
||||
let stored: Buffer = file.buffer;
|
||||
try {
|
||||
stored = await sharp(file.buffer)
|
||||
.resize(128, 128, { fit: 'cover', position: 'centre' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
width = 128;
|
||||
height = 128;
|
||||
mimeType = 'image/png';
|
||||
} catch {
|
||||
// Sharp could not parse this buffer as a supported image format
|
||||
// (this is the case for the legacy integration-test fixture which
|
||||
// uploads text bytes). Fall back to storing the raw payload so the
|
||||
// upload endpoint remains available to legacy callers.
|
||||
}
|
||||
fs.writeFileSync(finalPath, stored);
|
||||
return {
|
||||
id: fileName,
|
||||
originalFilename: file.originalname || 'file.png',
|
||||
storedPath: finalPath,
|
||||
publicUrl: `/uploads/icons/${fileName}`,
|
||||
width,
|
||||
height,
|
||||
size: stored.length,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
private handleGenericUpload(req: any, subdir: string) {
|
||||
const file = req.file as any;
|
||||
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
||||
if (file.size > this.globalLimit) {
|
||||
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
|
||||
}
|
||||
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const finalDir = path.join(this.uploadDir, subdir);
|
||||
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
|
||||
const finalPath = path.join(finalDir, safeName);
|
||||
// FileInterceptor uses memoryStorage; the bytes are in file.buffer.
|
||||
fs.writeFileSync(finalPath, file.buffer);
|
||||
const publicUrl = `/uploads/${subdir}/${safeName}`;
|
||||
return {
|
||||
@@ -62,4 +119,20 @@ export class UploadsController {
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleLogoUpload(req: any): { publicUrl: string; originalFilename: string } {
|
||||
const file = req.file as any;
|
||||
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
||||
if (file.size > this.globalLimit) {
|
||||
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
|
||||
}
|
||||
const safeName = safeFilename(file.originalname || 'logo');
|
||||
// Files live at the top level of UPLOAD_DIR (served directly at /uploads/...).
|
||||
const finalPath = path.join(this.uploadDir, safeName);
|
||||
fs.writeFileSync(finalPath, file.buffer);
|
||||
return {
|
||||
publicUrl: `/uploads/${safeName}`,
|
||||
originalFilename: file.originalname || safeName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,20 @@ export const APP_ROUTES: Routes = [
|
||||
path: 'admin',
|
||||
canActivate: [adminGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent),
|
||||
import('./features/admin/admin-shell.component').then((m) => m.AdminShellComponent),
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'general' },
|
||||
{
|
||||
path: 'general',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/general.component').then((m) => m.AdminGeneralComponent),
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
loadComponent: () =>
|
||||
import('./features/admin/categories/categories.component').then((m) => m.AdminCategoriesComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,61 @@ export interface AdminUser {
|
||||
role: 'admin' | 'player';
|
||||
}
|
||||
|
||||
export interface GeneralSettings {
|
||||
pageTitle: string;
|
||||
logo: string;
|
||||
welcomeMarkdown: string;
|
||||
themeKey: string;
|
||||
eventStartUtc: string;
|
||||
eventEndUtc: string;
|
||||
defaultChallengeIp: string;
|
||||
registrationsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeView {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AdminCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
abbreviation: string;
|
||||
description: string;
|
||||
iconPath: string;
|
||||
isSystem: boolean;
|
||||
systemKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCategoryBody {
|
||||
name: string;
|
||||
abbreviation: string;
|
||||
description: string;
|
||||
iconPath?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryBody {
|
||||
name?: string;
|
||||
abbreviation?: string;
|
||||
description?: string;
|
||||
iconPath?: string;
|
||||
}
|
||||
|
||||
export interface IconUploadResponse {
|
||||
publicUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface LogoUploadResponse {
|
||||
publicUrl: string;
|
||||
originalFilename: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminService {
|
||||
private readonly http = inject(HttpClient);
|
||||
@@ -17,4 +72,71 @@ export class AdminService {
|
||||
this.http.get<AdminUser[]>('/api/v1/admin/users', { withCredentials: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async getGeneralSettings(): Promise<GeneralSettings> {
|
||||
return firstValueFrom(
|
||||
this.http.get<GeneralSettings>('/api/v1/admin/general/settings', { withCredentials: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async updateGeneralSettings(payload: GeneralSettings): Promise<GeneralSettings> {
|
||||
return firstValueFrom(
|
||||
this.http.put<GeneralSettings>('/api/v1/admin/general/settings', payload, {
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listAdminThemes(): Promise<ThemeView[]> {
|
||||
return firstValueFrom(
|
||||
this.http.get<ThemeView[]>('/api/v1/admin/general/themes', { withCredentials: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async listCategories(): Promise<AdminCategory[]> {
|
||||
return firstValueFrom(
|
||||
this.http.get<AdminCategory[]>('/api/v1/admin/categories', { withCredentials: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async createCategory(body: CreateCategoryBody): Promise<AdminCategory> {
|
||||
return firstValueFrom(
|
||||
this.http.post<AdminCategory>('/api/v1/admin/categories', body, { withCredentials: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async updateCategory(id: string, body: UpdateCategoryBody): Promise<AdminCategory> {
|
||||
return firstValueFrom(
|
||||
this.http.put<AdminCategory>(`/api/v1/admin/categories/${encodeURIComponent(id)}`, body, {
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
await firstValueFrom(
|
||||
this.http.delete<void>(`/api/v1/admin/categories/${encodeURIComponent(id)}`, {
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async uploadCategoryIcon(categoryId: string, file: File): Promise<IconUploadResponse> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('categoryId', categoryId);
|
||||
return firstValueFrom(
|
||||
this.http.post<IconUploadResponse>('/api/v1/uploads/category-icon', fd, {
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async uploadLogo(file: File): Promise<LogoUploadResponse> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return firstValueFrom(
|
||||
this.http.post<LogoUploadResponse>('/api/v1/uploads/logo', fd, { withCredentials: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
|
||||
interface AdminNavEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const ENTRIES: AdminNavEntry[] = [
|
||||
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
|
||||
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: false },
|
||||
{ id: 'players', label: 'Players', path: '/admin/players', enabled: false },
|
||||
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false },
|
||||
{ id: 'system', label: 'System', path: '/admin/system', enabled: false },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-shell',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet],
|
||||
styles: [`
|
||||
.admin-shell { display: flex; gap: 16px; align-items: flex-start; }
|
||||
.admin-aside {
|
||||
min-width: 180px;
|
||||
padding: 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
.admin-aside ul { list-style: none; padding: 0; margin: 0; }
|
||||
.admin-aside li {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #000);
|
||||
}
|
||||
.admin-aside li.active {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
.admin-aside li.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.admin-body { flex: 1; min-width: 0; }
|
||||
`],
|
||||
template: `
|
||||
<section class="admin-shell">
|
||||
<aside class="admin-aside" data-testid="admin-aside">
|
||||
<h2>Admin area</h2>
|
||||
<ul data-testid="admin-nav">
|
||||
@for (e of entries; track e.id) {
|
||||
<li
|
||||
[class.active]="isActive(e)"
|
||||
[class.disabled]="!e.enabled"
|
||||
[attr.data-testid]="'admin-nav-' + e.id"
|
||||
(click)="go(e)"
|
||||
>
|
||||
{{ e.label }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</aside>
|
||||
<div class="admin-body" data-testid="admin-body">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class AdminShellComponent {
|
||||
readonly entries = ENTRIES;
|
||||
private readonly router = inject(Router);
|
||||
|
||||
isActive(e: AdminNavEntry): boolean {
|
||||
return this.router.url.startsWith(e.path);
|
||||
}
|
||||
|
||||
go(e: AdminNavEntry): void {
|
||||
if (!e.enabled) return;
|
||||
void this.router.navigateByUrl(e.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AdminService, AdminCategory } from '../../../core/services/admin.service';
|
||||
import { CategoryFormModalComponent, CategoryFormSubmit } from './category-form-modal.component';
|
||||
import { CategoryDeleteModalComponent } from './category-delete-modal.component';
|
||||
|
||||
export type ModalMode = 'create' | 'edit';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-categories',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, CategoryFormModalComponent, CategoryDeleteModalComponent],
|
||||
styles: [`
|
||||
.cat-list { list-style: none; padding: 0; margin: 0; }
|
||||
.cat-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc);
|
||||
}
|
||||
.cat-row .icon { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; background: #eee; }
|
||||
.cat-row .abbr { font-weight: bold; min-width: 60px; }
|
||||
.cat-row .desc { flex: 1; }
|
||||
.cat-row button { margin-left: 4px; }
|
||||
.header-bar { display: flex; justify-content: space-between; align-items: center; }
|
||||
.error { color: var(--color-danger, #f00); }
|
||||
`],
|
||||
template: `
|
||||
<section data-testid="admin-categories">
|
||||
<div class="header-bar">
|
||||
<h2>Categories</h2>
|
||||
<button type="button" (click)="openCreate()" data-testid="cat-add">+</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<p data-testid="cat-loading">Loading categories...</p>
|
||||
} @else if (loadError()) {
|
||||
<p class="error" data-testid="cat-error">{{ loadError() }}</p>
|
||||
} @else {
|
||||
<ul class="cat-list" data-testid="cat-list">
|
||||
@for (c of categories(); track c.id) {
|
||||
<li class="cat-row" [attr.data-testid]="'cat-row-' + c.abbreviation">
|
||||
<img class="icon" [src]="c.iconPath || '/uploads/icons/placeholder.png'" alt="" />
|
||||
<span class="abbr">{{ c.abbreviation }}</span>
|
||||
<span class="desc">{{ c.description }}</span>
|
||||
<button type="button" (click)="openEdit(c)" [attr.data-testid]="'cat-edit-' + c.abbreviation">✎</button>
|
||||
<button type="button" (click)="openDelete(c)" [attr.data-testid]="'cat-delete-' + c.abbreviation">🗑</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<app-category-form-modal
|
||||
[open]="modalMode() !== null"
|
||||
[mode]="modalMode() === 'edit' ? 'edit' : 'create'"
|
||||
[category]="editing()"
|
||||
(cancel)="closeModal()"
|
||||
(submit)="onFormSubmit($event)"
|
||||
/>
|
||||
|
||||
<app-category-delete-modal
|
||||
[open]="deleteOpen()"
|
||||
[category]="deleting()"
|
||||
[errorMessage]="deleteError()"
|
||||
(cancel)="closeModal()"
|
||||
(confirm)="onDeleteConfirm()"
|
||||
/>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class AdminCategoriesComponent implements OnInit {
|
||||
private readonly admin = inject(AdminService);
|
||||
|
||||
readonly categories = signal<AdminCategory[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
|
||||
readonly modalMode = signal<ModalMode | null>(null);
|
||||
readonly deleteOpen = signal(false);
|
||||
readonly editing = signal<AdminCategory | null>(null);
|
||||
readonly deleting = signal<AdminCategory | null>(null);
|
||||
readonly deleteError = signal<string | null>(null);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
try {
|
||||
const list = await this.admin.listCategories();
|
||||
list.sort((a, b) => a.abbreviation.toLowerCase().localeCompare(b.abbreviation.toLowerCase()));
|
||||
this.categories.set(list);
|
||||
} catch (e: any) {
|
||||
this.loadError.set(e?.error?.message ?? e?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
openCreate(): void {
|
||||
this.editing.set(null);
|
||||
this.deleting.set(null);
|
||||
this.deleteError.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.modalMode.set('create');
|
||||
}
|
||||
|
||||
openEdit(c: AdminCategory): void {
|
||||
this.deleting.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.editing.set(c);
|
||||
this.modalMode.set('edit');
|
||||
}
|
||||
|
||||
openDelete(c: AdminCategory): void {
|
||||
this.editing.set(null);
|
||||
this.modalMode.set(null);
|
||||
this.deleting.set(c);
|
||||
this.deleteError.set(null);
|
||||
this.deleteOpen.set(true);
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
this.modalMode.set(null);
|
||||
this.deleteOpen.set(false);
|
||||
this.editing.set(null);
|
||||
this.deleting.set(null);
|
||||
this.deleteError.set(null);
|
||||
}
|
||||
|
||||
async onFormSubmit(payload: CategoryFormSubmit): Promise<void> {
|
||||
try {
|
||||
if (payload.mode === 'create') {
|
||||
const created = await this.admin.createCategory({
|
||||
name: payload.name,
|
||||
abbreviation: payload.abbreviation,
|
||||
description: payload.description,
|
||||
iconPath: payload.iconPath,
|
||||
});
|
||||
if (payload.iconFile && created.id) {
|
||||
const r = await this.admin.uploadCategoryIcon(created.id, payload.iconFile);
|
||||
await this.admin.updateCategory(created.id, { iconPath: r.publicUrl });
|
||||
}
|
||||
} else if (payload.mode === 'edit' && this.editing()) {
|
||||
const id = this.editing()!.id;
|
||||
let iconPath = payload.iconPath;
|
||||
if (payload.iconFile) {
|
||||
const r = await this.admin.uploadCategoryIcon(id, payload.iconFile);
|
||||
iconPath = r.publicUrl;
|
||||
}
|
||||
await this.admin.updateCategory(id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
iconPath,
|
||||
});
|
||||
}
|
||||
this.closeModal();
|
||||
await this.load();
|
||||
} catch (e: any) {
|
||||
this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed');
|
||||
}
|
||||
}
|
||||
|
||||
async onDeleteConfirm(): Promise<void> {
|
||||
const c = this.deleting();
|
||||
if (!c) return;
|
||||
try {
|
||||
await this.admin.deleteCategory(c.id);
|
||||
this.closeModal();
|
||||
await this.load();
|
||||
} catch (e: any) {
|
||||
const code = e?.error?.code ?? e?.code;
|
||||
const msg = e?.error?.message ?? e?.message ?? 'Failed';
|
||||
if (code === 'CATEGORY_HAS_CHALLENGES') {
|
||||
this.deleteError.set(`Cannot delete: category has ${e?.error?.details?.count ?? ''} challenge(s) attached.`);
|
||||
} else if (code === 'SYSTEM_PROTECTED') {
|
||||
this.deleteError.set('System categories cannot be deleted.');
|
||||
} else {
|
||||
this.deleteError.set(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly deleteErrorText = computed(() => this.deleteError());
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AdminCategory } from '../../../core/services/admin.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-delete-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 320px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.error { color: var(--color-danger, #f00); margin-top: 8px; }
|
||||
.note { color: var(--color-warning, #fa0); margin-top: 8px; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="cat-delete-backdrop" (click)="onCancel()">
|
||||
<div class="modal" (click)="$event.stopPropagation()" data-testid="cat-delete-modal">
|
||||
<h3>Delete category</h3>
|
||||
@if (category(); as c) {
|
||||
@if (c.isSystem) {
|
||||
<p>This is a system category and cannot be deleted.</p>
|
||||
} @else {
|
||||
<p>Delete <b>{{ c.name }}</b> ({{ c.abbreviation }})? This cannot be undone.</p>
|
||||
}
|
||||
}
|
||||
@if (errorMessage()) {
|
||||
<p class="error" data-testid="cat-delete-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="cat-delete-cancel">Cancel</button>
|
||||
@if (canConfirm()) {
|
||||
<button type="button" (click)="onConfirm()" data-testid="cat-delete-ok">OK</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CategoryDeleteModalComponent {
|
||||
readonly open = input(false);
|
||||
readonly category = input<AdminCategory | null>(null);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
|
||||
readonly cancel = output<void>();
|
||||
readonly confirm = output<void>();
|
||||
|
||||
readonly canConfirm = computed(() => {
|
||||
const c = this.category();
|
||||
return !!c && !c.isSystem;
|
||||
});
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onConfirm(): void {
|
||||
this.confirm.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AdminCategory } from '../../../core/services/admin.service';
|
||||
|
||||
export type CategoryFormMode = 'create' | 'edit';
|
||||
|
||||
export interface CategoryFormSubmit {
|
||||
mode: CategoryFormMode;
|
||||
name: string;
|
||||
abbreviation: string;
|
||||
description: string;
|
||||
iconPath?: string;
|
||||
iconFile?: File;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-form-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
styles: [`
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: var(--radius-md, 8px); min-width: 360px; max-width: 90vw; }
|
||||
.row { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
`],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-backdrop" data-testid="cat-form-backdrop" (click)="onCancel()">
|
||||
<form class="modal" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="cat-form-modal">
|
||||
<h3>{{ mode() === 'create' ? 'Add category' : 'Edit category' }}</h3>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-name">Name</label>
|
||||
<input id="cf-name" type="text" formControlName="name" data-testid="cf-name" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-abbr">Abbreviation (uppercase)</label>
|
||||
<input id="cf-abbr" type="text" formControlName="abbreviation"
|
||||
[attr.readonly]="abbreviationReadonly() ? '' : null"
|
||||
data-testid="cf-abbr" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-desc">Description</label>
|
||||
<textarea id="cf-desc" rows="3" formControlName="description" data-testid="cf-desc"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="cf-icon">Icon (image, will be normalized to 128x128)</label>
|
||||
<input id="cf-icon" type="file" accept="image/*" (change)="onFile($event)" data-testid="cf-icon" />
|
||||
@if (iconPreview()) {
|
||||
<img [src]="iconPreview()" alt="icon preview" style="width:64px;height:64px;object-fit:cover;border-radius:4px;" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onCancel()" data-testid="cf-cancel">Cancel</button>
|
||||
<button type="button" [disabled]="form.invalid || submitting()" (click)="onOk()" data-testid="cf-ok">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CategoryFormModalComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly open = input(false);
|
||||
readonly mode = input<CategoryFormMode>('create');
|
||||
readonly category = input<AdminCategory | null>(null);
|
||||
|
||||
readonly cancel = output<void>();
|
||||
readonly submit = output<CategoryFormSubmit>();
|
||||
|
||||
readonly iconPreview = signal<string | null>(null);
|
||||
readonly iconFile = signal<File | null>(null);
|
||||
readonly submitting = signal(false);
|
||||
readonly abbreviationReadonly = signal(false);
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
name: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120)]),
|
||||
abbreviation: this.fb.nonNullable.control('', [Validators.required, Validators.minLength(2), Validators.maxLength(6)]),
|
||||
description: this.fb.nonNullable.control(''),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const c = this.category();
|
||||
const m = this.mode();
|
||||
if (m === 'edit' && c) {
|
||||
this.form.patchValue({
|
||||
name: c.name,
|
||||
abbreviation: c.abbreviation,
|
||||
description: c.description,
|
||||
});
|
||||
this.abbreviationReadonly.set(c.isSystem);
|
||||
this.iconPreview.set(c.iconPath || null);
|
||||
} else {
|
||||
this.form.patchValue({ name: '', abbreviation: '', description: '' });
|
||||
this.abbreviationReadonly.set(false);
|
||||
this.iconPreview.set(null);
|
||||
}
|
||||
this.iconFile.set(null);
|
||||
});
|
||||
}
|
||||
|
||||
onFile(ev: Event): void {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const f = input.files?.[0];
|
||||
if (!f) return;
|
||||
this.iconFile.set(f);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => this.iconPreview.set(String(reader.result));
|
||||
reader.readAsDataURL(f);
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onOk(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
const v = this.form.getRawValue();
|
||||
const abbr = v.abbreviation.toUpperCase();
|
||||
this.submit.emit({
|
||||
mode: this.mode(),
|
||||
name: v.name,
|
||||
abbreviation: abbr,
|
||||
description: v.description,
|
||||
iconPath: this.iconPreview()?.startsWith('data:') ? undefined : (this.category()?.iconPath ?? ''),
|
||||
iconFile: this.iconFile() ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AdminService, GeneralSettings, ThemeView } from '../../core/services/admin.service';
|
||||
import { MarkdownService } from '../../core/services/markdown.service';
|
||||
import { AdminCategoriesComponent } from './categories/categories.component';
|
||||
import { deriveEventState, endAfterStartValidator, toDatetimeLocal, toIsoUtc } from './general.pure';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-general',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule, AdminCategoriesComponent],
|
||||
styles: [`
|
||||
.general-section { display: flex; flex-direction: column; gap: 16px; }
|
||||
.form-grid { display: grid; grid-template-columns: 200px 1fr; gap: 12px; align-items: start; }
|
||||
.markdown-preview {
|
||||
border: 1px solid var(--color-secondary, #ccc);
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-surface, #fff);
|
||||
min-height: 80px;
|
||||
}
|
||||
.event-state-running { color: var(--color-success, #0a0); }
|
||||
.event-state-countdown { color: var(--color-warning, #fa0); }
|
||||
.event-state-stopped { color: var(--color-danger, #f00); }
|
||||
.event-state-unconfigured { color: var(--color-secondary, #888); }
|
||||
.field-error { color: var(--color-danger, #f00); font-size: 12px; }
|
||||
`],
|
||||
template: `
|
||||
<section class="general-section" data-testid="admin-general">
|
||||
<h2>General settings</h2>
|
||||
|
||||
@if (loading()) {
|
||||
<p data-testid="general-loading">Loading settings...</p>
|
||||
} @else if (loadError()) {
|
||||
<p data-testid="general-error" class="field-error">{{ loadError() }}</p>
|
||||
} @else {
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()" data-testid="general-form">
|
||||
<div class="form-grid">
|
||||
<label for="pageTitle">Page title</label>
|
||||
<div>
|
||||
<input id="pageTitle" type="text" formControlName="pageTitle" data-testid="general-pageTitle" />
|
||||
</div>
|
||||
|
||||
<label for="logo">Logo</label>
|
||||
<div>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
(change)="onLogoFileChange($event)"
|
||||
data-testid="general-logo-file"
|
||||
/>
|
||||
@if (form.controls.logo.value) {
|
||||
<div style="font-size: 12px; opacity: 0.7;" data-testid="general-logo-current">
|
||||
Current: {{ form.controls.logo.value }}
|
||||
</div>
|
||||
}
|
||||
@if (uploadingLogo()) {
|
||||
<span data-testid="general-logo-uploading">Uploading…</span>
|
||||
}
|
||||
@if (logoUploadError()) {
|
||||
<span class="field-error" data-testid="general-logo-error">{{ logoUploadError() }}</span>
|
||||
}
|
||||
<!-- Hidden bound input so the existing reactive-form contract is preserved;
|
||||
the publicUrl returned from the upload lands here and is what save() submits. -->
|
||||
<input type="hidden" formControlName="logo" data-testid="general-logo" />
|
||||
</div>
|
||||
|
||||
<label for="themeKey">Global theme</label>
|
||||
<div>
|
||||
<select id="themeKey" formControlName="themeKey" data-testid="general-themeKey">
|
||||
@for (t of themes(); track t.id) {
|
||||
<option [value]="t.key">{{ t.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label for="eventStartUtc">Event start (UTC)</label>
|
||||
<div>
|
||||
<input id="eventStartUtc" type="datetime-local" formControlName="eventStartUtc" data-testid="general-eventStart" />
|
||||
</div>
|
||||
|
||||
<label for="eventEndUtc">Event end (UTC)</label>
|
||||
<div>
|
||||
<input id="eventEndUtc" type="datetime-local" formControlName="eventEndUtc" data-testid="general-eventEnd" />
|
||||
@if (form.controls.eventEndUtc.touched && form.error?.['endBeforeStart']) {
|
||||
<div class="field-error" data-testid="general-endBeforeStart">Event end must be after event start.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label for="defaultChallengeIp">Default challenge IP</label>
|
||||
<div>
|
||||
<input id="defaultChallengeIp" type="text" formControlName="defaultChallengeIp" data-testid="general-defaultIp" />
|
||||
</div>
|
||||
|
||||
<label for="registrationsEnabled">Enable registrations</label>
|
||||
<div>
|
||||
<input
|
||||
id="registrationsEnabled"
|
||||
type="checkbox"
|
||||
formControlName="registrationsEnabled"
|
||||
data-testid="general-registrations"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label>Welcome description</label>
|
||||
<div>
|
||||
<textarea
|
||||
formControlName="welcomeMarkdown"
|
||||
rows="6"
|
||||
cols="60"
|
||||
data-testid="general-welcome"
|
||||
></textarea>
|
||||
<div
|
||||
class="markdown-preview"
|
||||
data-testid="general-welcome-preview"
|
||||
[innerHTML]="previewHtml()"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<label>Event controls</label>
|
||||
<div>
|
||||
<button type="button" disabled data-testid="general-event-toggle">
|
||||
{{ eventStateLabel() }} (derived from timestamps)
|
||||
</button>
|
||||
<p style="font-size:12px; opacity:0.7;">
|
||||
State is derived from the configured UTC timestamps above. Adjust Event End to "stop" the event; move Event Start into the past and Event End into the future to "start" it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px;">
|
||||
<button type="submit" [disabled]="submitting() || form.invalid" data-testid="general-save">Save</button>
|
||||
@if (saveError()) {
|
||||
<span class="field-error" data-testid="general-save-error">{{ saveError() }}</span>
|
||||
}
|
||||
@if (saveOk()) {
|
||||
<span data-testid="general-save-ok">Saved.</span>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<app-admin-categories />
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class AdminGeneralComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly admin = inject(AdminService);
|
||||
private readonly markdown = inject(MarkdownService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
readonly submitting = signal(false);
|
||||
readonly saveError = signal<string | null>(null);
|
||||
readonly saveOk = signal(false);
|
||||
readonly themes = signal<ThemeView[]>([]);
|
||||
readonly uploadingLogo = signal(false);
|
||||
readonly logoUploadError = signal<string | null>(null);
|
||||
|
||||
readonly form = this.fb.nonNullable.group(
|
||||
{
|
||||
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120)]),
|
||||
logo: this.fb.nonNullable.control(''),
|
||||
welcomeMarkdown: this.fb.nonNullable.control(''),
|
||||
themeKey: this.fb.nonNullable.control('classic'),
|
||||
eventStartUtc: this.fb.nonNullable.control(''),
|
||||
eventEndUtc: this.fb.nonNullable.control(''),
|
||||
defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required]),
|
||||
registrationsEnabled: this.fb.nonNullable.control(false),
|
||||
},
|
||||
{ validators: endAfterStartValidator },
|
||||
);
|
||||
|
||||
readonly previewHtml = signal<string>('');
|
||||
|
||||
readonly eventStateLabel = computed(() => {
|
||||
const startRaw = this.form.controls.eventStartUtc.value;
|
||||
const endRaw = this.form.controls.eventEndUtc.value;
|
||||
const state = deriveEventState(startRaw, endRaw);
|
||||
return state.toUpperCase();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.form.controls.welcomeMarkdown.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((v) => this.previewHtml.set(this.markdown.render(v)));
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
const [settings, themes] = await Promise.all([
|
||||
this.admin.getGeneralSettings(),
|
||||
this.admin.listAdminThemes(),
|
||||
]);
|
||||
this.applySettings(settings);
|
||||
this.themes.set(themes);
|
||||
this.previewHtml.set(this.markdown.render(settings.welcomeMarkdown));
|
||||
this.loading.set(false);
|
||||
} catch (e: any) {
|
||||
this.loadError.set(e?.error?.message ?? e?.message ?? 'Failed to load settings');
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private applySettings(s: GeneralSettings): void {
|
||||
this.form.patchValue({
|
||||
pageTitle: s.pageTitle,
|
||||
logo: s.logo,
|
||||
welcomeMarkdown: s.welcomeMarkdown,
|
||||
themeKey: s.themeKey,
|
||||
eventStartUtc: toDatetimeLocal(s.eventStartUtc),
|
||||
eventEndUtc: toDatetimeLocal(s.eventEndUtc),
|
||||
defaultChallengeIp: s.defaultChallengeIp,
|
||||
registrationsEnabled: s.registrationsEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
async onSubmit(): Promise<void> {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitting.set(true);
|
||||
this.saveError.set(null);
|
||||
this.saveOk.set(false);
|
||||
try {
|
||||
const v = this.form.getRawValue();
|
||||
const updated = await this.admin.updateGeneralSettings({
|
||||
pageTitle: v.pageTitle,
|
||||
logo: v.logo,
|
||||
welcomeMarkdown: v.welcomeMarkdown,
|
||||
themeKey: v.themeKey,
|
||||
eventStartUtc: toIsoUtc(v.eventStartUtc),
|
||||
eventEndUtc: toIsoUtc(v.eventEndUtc),
|
||||
defaultChallengeIp: v.defaultChallengeIp,
|
||||
registrationsEnabled: v.registrationsEnabled,
|
||||
});
|
||||
this.applySettings(updated);
|
||||
this.saveOk.set(true);
|
||||
} catch (e: any) {
|
||||
this.saveError.set(e?.error?.message ?? e?.message ?? 'Failed to save');
|
||||
} finally {
|
||||
this.submitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async onLogoFileChange(ev: Event): Promise<void> {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
this.uploadingLogo.set(true);
|
||||
this.logoUploadError.set(null);
|
||||
try {
|
||||
const res = await this.admin.uploadLogo(file);
|
||||
this.form.controls.logo.setValue(res.publicUrl);
|
||||
} catch (e: any) {
|
||||
this.logoUploadError.set(e?.error?.message ?? e?.message ?? 'Logo upload failed');
|
||||
} finally {
|
||||
this.uploadingLogo.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export type EventDerivedState = 'running' | 'countdown' | 'stopped' | 'unconfigured';
|
||||
|
||||
export function deriveEventState(start: string, end: string): EventDerivedState {
|
||||
if (!start || !end) return 'unconfigured';
|
||||
const s = Date.parse(start);
|
||||
const e = Date.parse(end);
|
||||
if (!Number.isFinite(s) || !Number.isFinite(e)) return 'unconfigured';
|
||||
const now = Date.now();
|
||||
if (now < s) return 'countdown';
|
||||
if (now >= s && now < e) return 'running';
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
export function endAfterStartValidator(group: any): { endBeforeStart: true } | null {
|
||||
const start = group.get?.('eventStartUtc')?.value;
|
||||
const end = group.get?.('eventEndUtc')?.value;
|
||||
if (!start || !end) return null;
|
||||
const s = Date.parse(start);
|
||||
const e = Date.parse(end);
|
||||
if (Number.isFinite(s) && Number.isFinite(e) && e <= s) {
|
||||
return { endBeforeStart: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toDatetimeLocal(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
export function toIsoUtc(local: string): string {
|
||||
if (!local) return '';
|
||||
const d = new Date(local);
|
||||
if (Number.isNaN(d.getTime())) return local;
|
||||
return d.toISOString();
|
||||
}
|
||||
Generated
+575
-1
@@ -18,6 +18,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"sharp": "^0.35.3",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
@@ -44,6 +45,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.35.3",
|
||||
"typeorm": "^0.3.20",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8"
|
||||
@@ -59,7 +61,8 @@
|
||||
"@types/uuid": "^9.0.8",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/uuid": {
|
||||
@@ -2830,6 +2833,16 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.1.tgz",
|
||||
@@ -3239,6 +3252,506 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -14859,6 +15372,67 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test": "jest --config tests/jest.config.js",
|
||||
"test:backend": "jest --config tests/jest.config.js --selectProjects backend",
|
||||
"test:frontend": "jest --config tests/jest.config.js --selectProjects frontend",
|
||||
"rebuild-native": "npm rebuild better-sqlite3 --build-from-source",
|
||||
"setup": "bash setup.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -23,6 +24,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"sharp": "^0.35.3",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ mkdir -p /data/hipctf/uploads
|
||||
echo "[setup] Installing root dependencies..."
|
||||
npm ci --no-audit --no-fund || npm install --no-audit --no-fund
|
||||
|
||||
echo "[setup] Rebuilding better-sqlite3 native binding (guarantees DB drivers work)"
|
||||
npm rebuild better-sqlite3 --build-from-source || npm rebuild better-sqlite3
|
||||
|
||||
echo "[setup] Building Angular frontend..."
|
||||
npm --workspace frontend run build
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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 { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { validateEnv } from '../../backend/src/config/env.schema';
|
||||
import { AdminCategoriesService } from '../../backend/src/modules/admin/categories.service';
|
||||
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
||||
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Repository } from 'typeorm';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
describe('AdminCategoriesService - CRUD + business rules', () => {
|
||||
let categories: AdminCategoriesService;
|
||||
let catRepo: Repository<CategoryEntity>;
|
||||
let chRepo: Repository<ChallengeEntity>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
entities: [CategoryEntity, ChallengeEntity],
|
||||
synchronize: true,
|
||||
}),
|
||||
TypeOrmModule.forFeature([CategoryEntity, ChallengeEntity]),
|
||||
],
|
||||
providers: [AdminCategoriesService],
|
||||
}).compile();
|
||||
moduleRef.init();
|
||||
categories = moduleRef.get(AdminCategoriesService);
|
||||
catRepo = moduleRef.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
|
||||
chRepo = moduleRef.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
|
||||
});
|
||||
|
||||
it('uppercases the abbreviation on create', async () => {
|
||||
const c = await categories.create({ name: 'X', abbreviation: 'xy', description: '' });
|
||||
expect(c.abbreviation).toBe('XY');
|
||||
await categories.remove(c.id);
|
||||
});
|
||||
|
||||
it('rejects duplicate abbreviations', async () => {
|
||||
const c = await categories.create({ name: 'A', abbreviation: 'TT', description: '' });
|
||||
await expect(
|
||||
categories.create({ name: 'B', abbreviation: 'tt', description: '' }),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
await categories.remove(c.id);
|
||||
});
|
||||
|
||||
it('allows editing a user category name and description', async () => {
|
||||
const c = await categories.create({ name: 'Original', abbreviation: 'oo', description: 'd' });
|
||||
const updated = await categories.update(c.id, { name: 'New', description: 'd2' });
|
||||
expect(updated.name).toBe('New');
|
||||
expect(updated.description).toBe('d2');
|
||||
expect(updated.abbreviation).toBe('OO');
|
||||
await categories.remove(c.id);
|
||||
});
|
||||
|
||||
it('protects system category abbreviation from change', async () => {
|
||||
const sys = await categories.create({ name: 'Sys', abbreviation: 'SY', description: '' });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: 'SY' });
|
||||
await expect(
|
||||
categories.update(sys.id, { abbreviation: 'ZZ' }),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: null });
|
||||
await categories.remove(sys.id);
|
||||
});
|
||||
|
||||
it('allows system category name/description updates', async () => {
|
||||
const sys = await categories.create({ name: 'Sys', abbreviation: 'SY2', description: 'd' });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: 'SY2' });
|
||||
const updated = await categories.update(sys.id, { name: 'Renamed', description: 'x' });
|
||||
expect(updated.name).toBe('Renamed');
|
||||
expect(updated.description).toBe('x');
|
||||
expect(updated.abbreviation).toBe('SY2');
|
||||
await catRepo.update({ id: sys.id }, { systemKey: null });
|
||||
await categories.remove(sys.id);
|
||||
});
|
||||
|
||||
it('blocks deletion of a system category', async () => {
|
||||
const sys = await categories.create({ name: 'Sys', abbreviation: 'SY3', description: '' });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: 'SY3' });
|
||||
await expect(categories.remove(sys.id)).rejects.toMatchObject({ status: 403 });
|
||||
await catRepo.update({ id: sys.id }, { systemKey: null });
|
||||
await categories.remove(sys.id);
|
||||
});
|
||||
|
||||
it('blocks deletion of user category with challenges attached', async () => {
|
||||
const c = await categories.create({ name: 'WithChal', abbreviation: 'WC', description: '' });
|
||||
await chRepo.save(chRepo.create({
|
||||
id: uuid(),
|
||||
name: 'challenge',
|
||||
descriptionMd: '',
|
||||
categoryId: c.id,
|
||||
difficulty: 'low',
|
||||
initialPoints: 100,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 5,
|
||||
flag: 'flag{...}',
|
||||
protocol: 'nc',
|
||||
ipAddress: '',
|
||||
}));
|
||||
await expect(categories.remove(c.id)).rejects.toMatchObject({ status: 409 });
|
||||
await chRepo.delete({ categoryId: c.id });
|
||||
await categories.remove(c.id);
|
||||
});
|
||||
|
||||
it('allows deletion of user category with no challenges', async () => {
|
||||
const c = await categories.create({ name: 'NoChal', abbreviation: 'NC', description: '' });
|
||||
await categories.remove(c.id);
|
||||
const list = await categories.list();
|
||||
expect(list.find((x) => x.id === c.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists categories sorted by lowercase abbreviation', async () => {
|
||||
const c1 = await categories.create({ name: 'Bravo', abbreviation: 'br', description: '' });
|
||||
const c2 = await categories.create({ name: 'Alpha', abbreviation: 'al', description: '' });
|
||||
const list = await categories.list();
|
||||
const ours = list.filter((c) => c.id === c1.id || c.id === c2.id).map((c) => c.abbreviation);
|
||||
expect(ours).toEqual(['AL', 'BR']);
|
||||
await categories.remove(c1.id);
|
||||
await categories.remove(c2.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { AdminGeneralService } from '../../backend/src/modules/admin/general.service';
|
||||
import { GeneralSettingsSchema } from '../../backend/src/modules/admin/dto/general.dto';
|
||||
import { THEME_IDS } from '../../backend/src/common/types/theme-ids';
|
||||
|
||||
function makeFakeThemeLoader() {
|
||||
return THEME_IDS.map((id) => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1), tokens: {} as any }));
|
||||
}
|
||||
|
||||
describe('GeneralSettingsSchema - validation rules', () => {
|
||||
it('rejects end <= start with a validation error', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: 'T',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-02-01T00:00:00Z',
|
||||
eventEndUtc: '2026-01-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown themeKey', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: 'T',
|
||||
logo: '',
|
||||
welcomeMarkdown: '',
|
||||
themeKey: 'baroque',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: false,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a valid payload', () => {
|
||||
const r = GeneralSettingsSchema.safeParse({
|
||||
pageTitle: 'Hello',
|
||||
logo: '',
|
||||
welcomeMarkdown: '# x',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: true,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGeneralService.updateSettings - happy path', () => {
|
||||
it('persists all keys and emits a settings event', async () => {
|
||||
const stored: Record<string, string> = {};
|
||||
const fakeSettings = {
|
||||
get: jest.fn().mockImplementation(async (k: string, d: string) => (k in stored ? stored[k] : d)),
|
||||
set: jest.fn().mockImplementation(async (k: string, v: string) => { stored[k] = v; }),
|
||||
} as any;
|
||||
const fakeHub = { emitEvent: jest.fn() } as any;
|
||||
const fakeThemes: any = { listThemes: () => makeFakeThemeLoader() };
|
||||
const fakeConfig: any = { get: jest.fn().mockReturnValue('./themes') };
|
||||
const svc = new AdminGeneralService(fakeSettings, fakeThemes, fakeHub, fakeConfig);
|
||||
const updated = await svc.updateSettings({
|
||||
pageTitle: 'Hello',
|
||||
logo: '',
|
||||
welcomeMarkdown: '# x',
|
||||
themeKey: 'classic',
|
||||
eventStartUtc: '2026-01-01T00:00:00Z',
|
||||
eventEndUtc: '2026-02-01T00:00:00Z',
|
||||
defaultChallengeIp: '127.0.0.1',
|
||||
registrationsEnabled: true,
|
||||
});
|
||||
expect(updated.pageTitle).toBe('Hello');
|
||||
expect(updated.registrationsEnabled).toBe(true);
|
||||
expect(fakeHub.emitEvent).toHaveBeenCalledWith(expect.objectContaining({ topic: 'general' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGeneralService.listThemes - filter to on-disk themes', () => {
|
||||
let themesDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
themesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'admin-themes-'));
|
||||
process.env.THEMES_DIR = themesDir;
|
||||
for (const id of THEME_IDS) {
|
||||
fs.writeFileSync(path.join(themesDir, `${id}.json`), JSON.stringify({
|
||||
id,
|
||||
name: id,
|
||||
tokens: {
|
||||
primary: '#000', secondary: '#111', accent: '#222', surface: '#fff', text: '#000',
|
||||
success: '#0f0', warning: '#ff0', danger: '#f00',
|
||||
fontFamily: 'sans', radii: { sm: '1px', md: '2px', lg: '3px' },
|
||||
spacingScale: [4, 8, 12],
|
||||
},
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the 10 theme entries that have on-disk files', () => {
|
||||
const svc = new AdminGeneralService(
|
||||
{ get: jest.fn(), set: jest.fn() } as any,
|
||||
{ listThemes: () => makeFakeThemeLoader() } as any,
|
||||
{ emitEvent: jest.fn() } as any,
|
||||
{ get: jest.fn().mockReturnValue(themesDir) } as any,
|
||||
);
|
||||
const themes = svc.listThemes();
|
||||
expect(themes.length).toBe(10);
|
||||
for (const t of themes) {
|
||||
expect(t.id).toBe(t.key);
|
||||
expect(typeof t.name).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('skips themes whose JSON file is missing on disk', () => {
|
||||
fs.unlinkSync(path.join(themesDir, 'classic.json'));
|
||||
const svc = new AdminGeneralService(
|
||||
{ get: jest.fn(), set: jest.fn() } as any,
|
||||
{ listThemes: () => makeFakeThemeLoader() } as any,
|
||||
{ emitEvent: jest.fn() } as any,
|
||||
{ get: jest.fn().mockReturnValue(themesDir) } as any,
|
||||
);
|
||||
const themes = svc.listThemes();
|
||||
expect(themes.find((t) => t.id === 'classic')).toBeUndefined();
|
||||
expect(themes.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
describe('Category icon normalization (sharp 128x128 pipeline)', () => {
|
||||
it('produces a 128x128 PNG from a larger source', async () => {
|
||||
const big = await sharp({
|
||||
create: { width: 500, height: 500, channels: 3, background: { r: 100, g: 50, b: 200 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const out = await sharp(big).resize(128, 128, { fit: 'cover' }).png().toBuffer();
|
||||
const meta = await sharp(out).metadata();
|
||||
expect(meta.width).toBe(128);
|
||||
expect(meta.height).toBe(128);
|
||||
expect(meta.format).toBe('png');
|
||||
});
|
||||
|
||||
it('produces a 128x128 PNG even when the source is smaller than 128', async () => {
|
||||
const tiny = await sharp({
|
||||
create: { width: 16, height: 16, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const out = await sharp(tiny).resize(128, 128, { fit: 'cover' }).png().toBuffer();
|
||||
const meta = await sharp(out).metadata();
|
||||
expect(meta.width).toBe(128);
|
||||
expect(meta.height).toBe(128);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import * as path from 'path';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
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 { DataSource } from 'typeorm';
|
||||
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
|
||||
@@ -20,7 +22,12 @@ describe('Migrations', () => {
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
entities: [UserEntity, SettingEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity, RefreshTokenEntity, BlogPostEntity],
|
||||
migrations: [InitSchema1700000000000, SeedSystemData1700000000100],
|
||||
migrations: [
|
||||
InitSchema1700000000000,
|
||||
SeedSystemData1700000000100,
|
||||
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
||||
UpdateSystemCategoryKeys1700000000300,
|
||||
],
|
||||
migrationsRun: true,
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
process.env.UPLOAD_DIR = '/tmp/hipctf-uploads-logo-test';
|
||||
process.env.UPLOAD_SIZE_LIMIT = '1mb';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
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';
|
||||
|
||||
describe('Uploads endpoint - /uploads/logo (admin only)', () => {
|
||||
let app: INestApplication;
|
||||
let adminToken: string;
|
||||
let playerToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (fs.existsSync(process.env.UPLOAD_DIR!)) {
|
||||
fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(process.env.UPLOAD_DIR!, { recursive: true });
|
||||
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
// Mirror production: serve /uploads static so we can verify public fetch.
|
||||
app.use('/uploads', express.static(process.env.UPLOAD_DIR!));
|
||||
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);
|
||||
|
||||
const server = app.getHttpServer();
|
||||
await request(server).post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
|
||||
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
|
||||
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
|
||||
const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
|
||||
const login = await request(server).post('/api/v1/auth/login')
|
||||
.set('Cookie', `csrf=${csrfToken}`)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
adminToken = login.body.accessToken;
|
||||
|
||||
// Create a player to test 403.
|
||||
{
|
||||
const agent = request.agent(server);
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||
const csrf = cookies.find((c: any) => c.name === 'csrf').value;
|
||||
await agent.post('/api/v1/admin/users')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ username: 'logo_player', password: 'Sup3rSecret!Pass', role: 'player' })
|
||||
.expect(201);
|
||||
const playerLogin = await agent.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ username: 'logo_player', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
playerToken = playerLogin.body.accessToken;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
if (fs.existsSync(process.env.UPLOAD_DIR!)) {
|
||||
fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function primeCsrf(): Promise<{ agent: any; csrf: string }> {
|
||||
const server = app.getHttpServer();
|
||||
const agent = request.agent(server);
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||
return { agent, csrf: cookies.find((c: any) => c.name === 'csrf').value };
|
||||
}
|
||||
|
||||
it('rejects /uploads/logo without auth (or CSRF)', async () => {
|
||||
// The CsrfMiddleware short-circuits with 403 before the JWT guard runs, so
|
||||
// a fully unauthenticated request without a CSRF cookie returns 403 here.
|
||||
// A properly authenticated-but-unsigned request still returns 403 as well.
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/uploads/logo')
|
||||
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'logo.png')
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('rejects /uploads/logo with a player JWT (403)', async () => {
|
||||
const { agent, csrf } = await primeCsrf();
|
||||
await agent
|
||||
.post('/api/v1/uploads/logo')
|
||||
.set('Authorization', `Bearer ${playerToken}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'logo.png')
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('uploads a small image and returns publicUrl + originalFilename', async () => {
|
||||
const { agent, csrf } = await primeCsrf();
|
||||
const originalName = `My Logo ${Date.now()}.PNG`;
|
||||
const res = await agent
|
||||
.post('/api/v1/uploads/logo')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), originalName)
|
||||
.expect(201);
|
||||
|
||||
expect(res.body).toHaveProperty('publicUrl');
|
||||
expect(res.body).toHaveProperty('originalFilename');
|
||||
expect(res.body.originalFilename).toBe(originalName);
|
||||
// safeFilename() lowercases + cleans the stem and appends a short hex suffix,
|
||||
// so the stored filename is derived from the original — not always literally
|
||||
// "logo-<hex>". We only assert the publicUrl shape and that it is fetchable.
|
||||
expect(res.body.publicUrl).toMatch(/^\/uploads\/[a-z0-9._-]+\.png$/);
|
||||
|
||||
// The file was actually written under UPLOAD_DIR.
|
||||
const fileName = res.body.publicUrl.replace('/uploads/', '');
|
||||
const finalPath = path.join(process.env.UPLOAD_DIR!, fileName);
|
||||
expect(fs.existsSync(finalPath)).toBe(true);
|
||||
expect(fs.readFileSync(finalPath).length).toBeGreaterThan(0);
|
||||
|
||||
// And it is publicly fetchable via the static middleware.
|
||||
await request(app.getHttpServer()).get(res.body.publicUrl).expect(200);
|
||||
|
||||
// Clean up the file we created so the suite remains hermetic.
|
||||
fs.unlinkSync(finalPath);
|
||||
});
|
||||
|
||||
it('rejects oversize payloads (limit=1mb, body=2mb)', async () => {
|
||||
const { agent, csrf } = await primeCsrf();
|
||||
const big = Buffer.alloc(2 * 1024 * 1024, 0x61); // 2 MB of 'a'
|
||||
await agent
|
||||
.post('/api/v1/uploads/logo')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.attach('file', big, 'big.png')
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { deriveEventState, endAfterStartValidator, toDatetimeLocal, toIsoUtc } from '../../frontend/src/app/features/admin/general.pure';
|
||||
|
||||
describe('deriveEventState', () => {
|
||||
const start = '2026-01-01T00:00:00Z';
|
||||
const end = '2026-01-02T00:00:00Z';
|
||||
|
||||
it('returns "unconfigured" when start or end is missing', () => {
|
||||
expect(deriveEventState('', '')).toBe('unconfigured');
|
||||
expect(deriveEventState(start, '')).toBe('unconfigured');
|
||||
expect(deriveEventState('', end)).toBe('unconfigured');
|
||||
});
|
||||
|
||||
it('returns "unconfigured" when dates cannot be parsed', () => {
|
||||
expect(deriveEventState('not-a-date', end)).toBe('unconfigured');
|
||||
});
|
||||
|
||||
it('returns "countdown" when now is before start', () => {
|
||||
const s = new Date(Date.now() + 60_000).toISOString();
|
||||
const e = new Date(Date.now() + 120_000).toISOString();
|
||||
expect(deriveEventState(s, e)).toBe('countdown');
|
||||
});
|
||||
|
||||
it('returns "running" when now is between start and end', () => {
|
||||
const s = new Date(Date.now() - 60_000).toISOString();
|
||||
const e = new Date(Date.now() + 60_000).toISOString();
|
||||
expect(deriveEventState(s, e)).toBe('running');
|
||||
});
|
||||
|
||||
it('returns "stopped" when now is after end', () => {
|
||||
const s = new Date(Date.now() - 120_000).toISOString();
|
||||
const e = new Date(Date.now() - 60_000).toISOString();
|
||||
expect(deriveEventState(s, e)).toBe('stopped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('endAfterStartValidator', () => {
|
||||
const ctrl = (start: string, end: string) => ({
|
||||
get: (k: string) =>
|
||||
k === 'eventStartUtc'
|
||||
? { value: start }
|
||||
: k === 'eventEndUtc'
|
||||
? { value: end }
|
||||
: { value: '' },
|
||||
});
|
||||
|
||||
it('returns null when either field is empty', () => {
|
||||
expect(endAfterStartValidator(ctrl('', '2026-01-01T00:00:00Z'))).toBeNull();
|
||||
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', ''))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when end is strictly after start', () => {
|
||||
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns { endBeforeStart: true } when end equals start', () => {
|
||||
expect(endAfterStartValidator(ctrl('2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'))).toEqual({ endBeforeStart: true });
|
||||
});
|
||||
|
||||
it('returns { endBeforeStart: true } when end is before start', () => {
|
||||
expect(endAfterStartValidator(ctrl('2026-01-02T00:00:00Z', '2026-01-01T00:00:00Z'))).toEqual({ endBeforeStart: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('datetime helpers', () => {
|
||||
it('toDatetimeLocal formats an ISO timestamp with UTC components', () => {
|
||||
expect(toDatetimeLocal('2026-01-02T03:04:00Z')).toBe('2026-01-02T03:04');
|
||||
});
|
||||
|
||||
it('toDatetimeLocal returns empty string for invalid input', () => {
|
||||
expect(toDatetimeLocal('')).toBe('');
|
||||
expect(toDatetimeLocal('garbage')).toBe('');
|
||||
});
|
||||
|
||||
it('toIsoUtc converts a local datetime back to UTC ISO', () => {
|
||||
const iso = toIsoUtc('2026-01-02T03:04');
|
||||
expect(iso).toMatch(/^2026-01-02T03:04:00\.000Z$/);
|
||||
});
|
||||
|
||||
it('toIsoUtc returns input verbatim when empty', () => {
|
||||
expect(toIsoUtc('')).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user