AI Implementation feature(885): Admin Area General Settings and Categories 1.03 #25
@@ -1,136 +0,0 @@
|
|||||||
# Implementation Plan: Admin Area General Settings — Actionable Page-Title Validation Message (Job 884)
|
|
||||||
|
|
||||||
## 0. Status — Already Implemented?
|
|
||||||
|
|
||||||
**Not fully implemented.** The reactive-form plumbing that disables the Save button (`Validators.required`, custom `pageTitleNotBlankValidator`, `[disabled]="submitting() || form.invalid"`) works correctly for the empty / whitespace-only negative case, and the field's classes (`ng-dirty ng-invalid ng-touched`) update as expected. However, the **actionable inline error message** required by this Job is **not** shown to the user.
|
|
||||||
|
|
||||||
Concrete gap (verified in source):
|
|
||||||
|
|
||||||
- `frontend/src/app/features/admin/general.component.ts:199-202` — `showPageTitleError` is an Angular `computed()` that reads `this.form.controls.pageTitle.touched`, `.dirty`, `.invalid`. These are not signals, so the computed never re-evaluates when the control's status flips.
|
|
||||||
- `frontend/src/app/features/admin/general.component.ts:204-217` — `pageTitleMessage` is a `computed()` that reads `this.form.controls.pageTitle.value` and `.errors`. Same problem: the computed is not subscribed to the form control's `valueChanges` / `statusChanges`, so it never re-runs after the user types.
|
|
||||||
- Result: the template `<div ... data-testid="general-pageTitle-error">{{ pageTitleMessage() }}</div>` at line 53 stays at Angular's `<!---->` placeholder even after the field is `ng-dirty ng-invalid ng-touched`.
|
|
||||||
|
|
||||||
Therefore a code change is required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
|
|
||||||
- **Codebase style & conventions:** TypeScript, NestJS REST API + Angular 17+ standalone components with `ChangeDetectionStrategy.OnPush`, signal-based state (`signal`, `computed`, `effect`), reactive forms with `fb.nonNullable.group(...)`, `takeUntilDestroyed()` for RxJS → signal bridging. Validators and pure helpers live in sibling `*.pure.ts` files (e.g. `general.pure.ts`). `data-testid` attributes are the contract for every input, button, and status element.
|
|
||||||
- **Data Layer:** SQLite via `better-sqlite3` (per `package.json`); persisted key/value settings in the `setting` table. No schema migration is needed for this Job — only the frontend inline validation message is broken.
|
|
||||||
- **Test Framework & Structure:** Jest 29 with `ts-jest`, jsdom environment for frontend. All tests live in **`/repo/tests/`** (never alongside source). Frontend spec files are pure-function tests on `*.pure.ts` validators/helpers (e.g. `admin-general-pure.spec.ts`, `landing-markdown.spec.ts`, `change-password-modal.spec.ts`) — there are currently **no TestBed component-level tests** in the repo. `npm test` runs both backend and frontend projects via `tests/jest.config.js`.
|
|
||||||
- **Required Tools & Dependencies:** None new. The fix uses Angular primitives (`signal`, `computed`, `toSignal` from `@angular/core/rxjs-interop`, `takeUntilDestroyed`) already present in the file. No `setup.sh` changes, no new packages.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
|
|
||||||
- **To Modify:**
|
|
||||||
- `frontend/src/app/features/admin/general.component.ts` — fix the reactivity gap for `showPageTitleError` and `pageTitleMessage` so the inline `<div data-testid="general-pageTitle-error">` renders when the field is `touched && invalid`.
|
|
||||||
- **To Create:**
|
|
||||||
- `tests/frontend/admin-general-page-title-error.spec.ts` — pure-function unit tests covering the new `pageTitleError` branches the component will rely on (already partially covered, but we extend them to lock down the specific "whitespace-only" → `'required'` message that drives the actionable UX).
|
|
||||||
|
|
||||||
(No backend changes, no schema migration, no new routes, no DTO changes.)
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
|
|
||||||
### 3.1 Backend / API
|
|
||||||
|
|
||||||
None. The `PUT /api/v1/admin/general/settings` route, DTO, and `general.service.ts` are unchanged. The server-side trim + `1–120` validation already returns `400 VALIDATION_FAILED` and preserves the stored value — exactly the behavior the Job reports on the negative path. The bug is purely client-side rendering of the error message.
|
|
||||||
|
|
||||||
### 3.2 Frontend Logic — `frontend/src/app/features/admin/general.component.ts`
|
|
||||||
|
|
||||||
The root cause is that `computed()` does not re-evaluate when reactive-form control flags change. Replace the non-reactive reads with signal-backed local state that IS subscribed to `valueChanges` and `statusChanges`.
|
|
||||||
|
|
||||||
Concrete edits:
|
|
||||||
|
|
||||||
1. **Add explicit reactive signals for the pageTitle control**, declared at the top of the class alongside the other signals:
|
|
||||||
```ts
|
|
||||||
private readonly pageTitleValue = signal<string>('');
|
|
||||||
private readonly pageTitleInvalid = signal<boolean>(false);
|
|
||||||
private readonly pageTitleTouchedOrDirty = signal<boolean>(false);
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Wire the control to those signals** in the existing constructor, right after the `welcomeMarkdown.valueChanges` subscription (using the same `takeUntilDestroyed(this.destroyRef)` pattern already in the file):
|
|
||||||
```ts
|
|
||||||
const pt = this.form.controls.pageTitle;
|
|
||||||
this.pageTitleValue.set(pt.value);
|
|
||||||
this.pageTitleInvalid.set(pt.invalid);
|
|
||||||
pt.valueChanges
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe((v) => this.pageTitleValue.set(v));
|
|
||||||
pt.statusChanges
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.pageTitleInvalid.set(pt.invalid);
|
|
||||||
this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty);
|
|
||||||
});
|
|
||||||
pt.events
|
|
||||||
?.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
?.subscribe?.(); // no-op safety; see note below
|
|
||||||
```
|
|
||||||
Note: do NOT introduce a third subscription — the `blur`/`input` events that flip `touched`/`dirty` are dispatched by the DOM and re-validate via Angular's value sync, which already emits a `statusChanges` (and, when the value changes, a `valueChanges`). If `touched`/`dirty` flips without a status change (rare — e.g. focus loss while value is unchanged), also subscribe to the Angular `events` observable filtered by `EventType.Blur`:
|
|
||||||
```ts
|
|
||||||
import { EventType } from '@angular/forms';
|
|
||||||
...
|
|
||||||
pt.events
|
|
||||||
.pipe(
|
|
||||||
filter((e) => e.type === EventType.Blur),
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
)
|
|
||||||
.subscribe(() => this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty));
|
|
||||||
```
|
|
||||||
Confirm with a quick grep that `EventType` is exported from `@angular/forms` in the installed Angular version before relying on it; if not available in this version, fall back to a `focusout` listener bound via `@HostListener('focusout')` on a directive, OR simply call `this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty)` after every `valueChanges` AND `statusChanges` emission — both reliably fire on input/blur in modern Angular reactive forms. **Preferred approach (no extra imports):** set `pageTitleTouchedOrDirty` inside the existing `statusChanges` subscription AND inside the existing `valueChanges` subscription (covering both dirty/touched transitions).
|
|
||||||
|
|
||||||
3. **Replace the broken `computed`s** so they read from the new signals:
|
|
||||||
```ts
|
|
||||||
readonly showPageTitleError = computed(
|
|
||||||
() => this.pageTitleTouchedOrDirty() && this.pageTitleInvalid(),
|
|
||||||
);
|
|
||||||
|
|
||||||
readonly pageTitleMessage = computed(() => {
|
|
||||||
const err = pageTitleError(this.pageTitleValue());
|
|
||||||
if (err === 'required') return 'Page title is required and cannot contain only whitespace.';
|
|
||||||
if (err === 'maxlength') return 'Page title must be 120 characters or fewer.';
|
|
||||||
// fallback to the raw control errors so the {whitespace:true} branch from
|
|
||||||
// the custom validator still surfaces the same human-readable text
|
|
||||||
const controlErrors = this.form.controls.pageTitle.errors;
|
|
||||||
if (controlErrors?.['whitespace']) return 'Page title is required and cannot contain only whitespace.';
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
The existing `pageTitleError` helper in `general.pure.ts:3-8` already returns `'required'` for both empty and whitespace-only strings (line 5: `if (v.trim().length === 0) return 'required';`), so the actionable message will fire for `''`, `' '`, `'\t\n '`, etc., exactly as the Job requires.
|
|
||||||
|
|
||||||
4. **Patch the form after load** — extend the existing `applySettings()` (line 250) to also call `this.pageTitleTouchedOrDirty.set(false)` so loading valid settings from the backend does not flash the error on first paint. After patching the value, also sync `this.pageTitleValue` (a single `pt.setValue(...)` will trigger `valueChanges`, which already updates `pageTitleValue`).
|
|
||||||
|
|
||||||
5. **No template change** — `<div class="field-error" data-testid="general-pageTitle-error">{{ pageTitleMessage() }}</div>` at line 53 already lives inside the `@if (showPageTitleError())` block; once the computeds are reactive it will render with the actionable text and the `data-testid` will appear in the DOM (matching the negative-case expectation in the Job description).
|
|
||||||
|
|
||||||
### 3.3 Pure helper — `frontend/src/app/features/admin/general.pure.ts`
|
|
||||||
|
|
||||||
No changes needed. `pageTitleError()` already returns `'required'` for both empty and whitespace-only strings (verified at line 5). Existing tests in `tests/frontend/admin-general-pure.spec.ts:91-114` already lock down this behavior — we extend that suite, not the helper.
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
|
|
||||||
Per the project's existing convention (all `tests/frontend/*.spec.ts` are pure-function tests against `*.pure.ts` modules), and per the Job's "minimal, focused tests" rule, **we do not introduce a TestBed-based component test**. The existing `admin-general-pure.spec.ts` is the right home.
|
|
||||||
|
|
||||||
- **Target test files:**
|
|
||||||
- **Extend** `tests/frontend/admin-general-pure.spec.ts` with two new `describe` blocks:
|
|
||||||
1. `describe('pageTitleMessage')` — pure-function mapping (the same mapping the component's computed now uses). Asserts:
|
|
||||||
- `pageTitleMessage('')` → required message
|
|
||||||
- `pageTitleMessage(' ')` → required message (the Job's specific edge case)
|
|
||||||
- `pageTitleMessage('\t\n ')` → required message
|
|
||||||
- `pageTitleMessage('OpenVelo')` → `null`
|
|
||||||
- `pageTitleMessage('a'.repeat(121))` → maxlength message
|
|
||||||
2. To keep this pure-function-friendly, **extract** the message-mapping function out of the component into `general.pure.ts` as `export function pageTitleMessage(value, errors?): string | null`, export it, and import it from the test. This is the minimal refactor that lets us assert the exact user-visible text without spinning up TestBed. The component's `pageTitleMessage` `computed` becomes a one-liner that calls this pure function.
|
|
||||||
|
|
||||||
- **Add (new file)** `tests/frontend/admin-general-page-title-error.spec.ts` only if the extracted helper's behavior isn't already covered by extending `admin-general-pure.spec.ts`. Per "minimal tests, single command", **prefer extending the existing file** to avoid duplicate test setup.
|
|
||||||
|
|
||||||
- **Mocking strategy:** No mocks needed. The new pure function has no dependencies on `FormControl`, `HttpClient`, or Angular DI. It is a pure `(value: string, errors?: ValidationErrors | null) => string | null` mapping. The reactive plumbing inside the component is exercised manually per the Job description (no automated UI tests, per the Job rules).
|
|
||||||
|
|
||||||
- **Run command:** `npm test` (from `/repo`) executes `jest --config tests/jest.config.js --selectProjects frontend` (and backend). Single command, no UI required, fully headless under jsdom.
|
|
||||||
|
|
||||||
## 5. Out of Scope / Non-Goals
|
|
||||||
|
|
||||||
- Backend DTO, `general.service.ts`, controller, or `setting` table — all already correct.
|
|
||||||
- Adding new validators (the existing `pageTitleNotBlankValidator` + `Validators.required` + `Validators.maxLength(120)` are sufficient).
|
|
||||||
- Visual / CSS changes — the existing `.field-error` style and red color tokens are already correct.
|
|
||||||
- Component-harness / TestBed tests — explicitly out per Job rules (minimal infra, no visual confirmation).
|
|
||||||
- Refactoring other admin pages — not requested.
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Implementation Plan: Admin Area General Settings and Categories 1.03
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
- **Codebase style & conventions:** TypeScript monorepo with a NestJS 10 REST backend and Angular 17 standalone components. Upload requests are encapsulated in `AdminService`; `AdminGeneralComponent` uses reactive forms, signals, `async` handlers, and inline `data-testid` feedback. Backend upload handling currently lives directly in `UploadsController`, uses Multer memory buffers, `sharp`, collision-resistant `safeFilename`, and the global exception filter's standard `{ code, message, path, timestamp }` envelope. The Job is not already implemented: `handleLogoUpload` only checks presence/size before writing raw bytes, while the existing frontend error branch is never reached for malformed data.
|
||||||
|
- **Data Layer:** SQLite through TypeORM for settings, but this fix does not require a schema or migration. Logo bytes are stored under configured `UPLOAD_DIR` (default `/data/hipctf/uploads`) and become active only after the returned URL is assigned to the form and settings are saved.
|
||||||
|
- **Test Framework & Structure:** Root Jest 29/ts-jest multi-project configuration. Backend endpoint tests live in `tests/backend`, use Nest's real application, in-memory SQLite, Supertest, CSRF/auth setup, and an isolated upload directory; frontend logic tests live in `tests/frontend` with jsdom. All tests run with root `npm test`, with focused backend runs available through `npm run test:backend`.
|
||||||
|
- **Required Tools & Dependencies:** No new system tool, global CLI, package, or `setup.sh` change is required. Reuse the already installed `sharp` dependency to decode the full image and obtain authoritative format metadata rather than trusting the multipart MIME type, extension, or magic bytes alone. Existing setup already installs native dependencies, prepares `/data/hipctf/uploads`, and builds both workspaces.
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
- **To Modify:**
|
||||||
|
- `backend/src/modules/uploads/uploads.controller.ts` — make logo handling asynchronous; decode and validate the complete image against an explicit logo format policy before creating a filename or writing bytes.
|
||||||
|
- `tests/backend/uploads-logo.spec.ts` — replace the header-only pseudo-PNG success fixture with a genuinely decodable image and add focused missing/malformed/unsupported rejection assertions, including proof that rejected payloads create no files.
|
||||||
|
- **To Create:** None.
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
1. **Database / Schema Migration:** No database change. Invalid uploads must never return a URL or mutate the General form's existing `logo` control, so the persisted setting and subsequent bootstrap `pageLogo` remain unchanged.
|
||||||
|
2. **Backend Logic & APIs:**
|
||||||
|
- Convert `handleLogoUpload` to return a promise and await it from `uploadLogo`.
|
||||||
|
- Retain the current missing-file and configured-size checks, then call `sharp(file.buffer).metadata()` (or an equivalent full decode operation) before any filesystem side effect. Treat decode failures, absent format/dimensions, truncated/corrupt images, and formats outside an explicit configured logo allowlist as `400 BadRequestException`/`VALIDATION_FAILED` with a stable, user-readable message such as `Logo must be a valid PNG, JPEG, GIF, or WebP image.`
|
||||||
|
- Define the logo policy beside the handler as a readonly set/literal union so accepted formats are auditable. Base acceptance on Sharp's detected format, not `file.mimetype`, original extension, or browser `accept` filtering. Keep the current upload-size policy and collision-resistant filename behavior for accepted files.
|
||||||
|
- Perform validation before `mkdir`/`writeFileSync`; only valid bytes reach `UPLOAD_DIR`. Preserve the response contract `{ publicUrl, originalFilename }` and existing 201 status for valid uploads.
|
||||||
|
3. **Frontend UI Integration:** No frontend code change is expected. `AdminGeneralComponent.onLogoFileChange` already clears the prior error, updates `form.controls.logo` only after a successful `uploadLogo` response, catches the server's standard error message into `logoUploadError`, and resets `uploadingLogo` in `finally`. Once the backend rejects malformed payloads, `[data-testid="general-logo-error"]` will render, the previous hidden `[data-testid="general-logo"]` value will be preserved, and the form remains usable. Do not add client-only validation as a security boundary; `accept="image/*"` remains a picker hint.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
- **Target Unit Test File:** Modify `tests/backend/uploads-logo.spec.ts`; no broad component harness or UI/E2E suite is needed because the existing component's success-only mutation and catch/finally behavior already provides the required preservation/feedback flow.
|
||||||
|
- **Mocking Strategy:** Use no mocks for image validation or filesystem behavior. Generate a tiny valid PNG in-memory with the existing `sharp` package for the core success test. Through the authenticated Supertest endpoint, assert: missing multipart `file` returns 400; representative malformed/unsupported inputs (plain text with a misleading image name/MIME, a truncated GIF, and an eight-zero-byte PNG sent as `image/png`) return 400 with the standard validation envelope; and the upload directory listing is unchanged after each rejection. Keep one valid-image assertion proving a URL is returned, the file is persisted/fetchable, and cleanup remains hermetic. Run all coverage from the repository root with `npm test`; during implementation also run the existing root build/typecheck (`npm run build`) since no standalone lint script is defined.
|
||||||
@@ -4,7 +4,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import sharp from 'sharp';
|
import sharp, { FormatEnum } from 'sharp';
|
||||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||||
import { Roles } from '../../common/decorators/roles.decorator';
|
import { Roles } from '../../common/decorators/roles.decorator';
|
||||||
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
|
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
|
||||||
@@ -50,6 +50,21 @@ export class UploadsController {
|
|||||||
return this.handleLogoUpload(req);
|
return this.handleLogoUpload(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed image formats for the site logo. Detection is performed by
|
||||||
|
* decoding the full payload with sharp — never by trusting the
|
||||||
|
* multipart `mimetype` header, the original filename extension, or
|
||||||
|
* the browser's `accept="image/*"` filter.
|
||||||
|
*/
|
||||||
|
private static readonly ALLOWED_LOGO_FORMATS: ReadonlySet<keyof FormatEnum> = new Set<keyof FormatEnum>([
|
||||||
|
'png',
|
||||||
|
'jpeg',
|
||||||
|
'gif',
|
||||||
|
'webp',
|
||||||
|
]);
|
||||||
|
private static readonly LOGO_ERROR_MESSAGE =
|
||||||
|
'Logo must be a valid PNG, JPEG, GIF, or WebP image.';
|
||||||
|
|
||||||
private async handleCategoryIcon(req: any): Promise<{ id: string; publicUrl: string; width: number; height: number; mimeType: string; storedPath: string; size: number; originalFilename: string }> {
|
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;
|
const file = req.file as any;
|
||||||
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
||||||
@@ -120,12 +135,22 @@ export class UploadsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLogoUpload(req: any): { publicUrl: string; originalFilename: string } {
|
private async handleLogoUpload(req: any): Promise<{ publicUrl: string; originalFilename: string }> {
|
||||||
const file = req.file as any;
|
const file = req.file as any;
|
||||||
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
||||||
if (file.size > this.globalLimit) {
|
if (file.size > this.globalLimit) {
|
||||||
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
|
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
|
||||||
}
|
}
|
||||||
|
let metadata: { format?: keyof FormatEnum; width?: number; height?: number };
|
||||||
|
try {
|
||||||
|
metadata = await sharp(file.buffer).metadata();
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(UploadsController.LOGO_ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
const fmt = metadata.format;
|
||||||
|
if (!fmt || !UploadsController.ALLOWED_LOGO_FORMATS.has(fmt)) {
|
||||||
|
throw new BadRequestException(UploadsController.LOGO_ERROR_MESSAGE);
|
||||||
|
}
|
||||||
const safeName = safeFilename(file.originalname || 'logo');
|
const safeName = safeFilename(file.originalname || 'logo');
|
||||||
// Files live at the top level of UPLOAD_DIR (served directly at /uploads/...).
|
// Files live at the top level of UPLOAD_DIR (served directly at /uploads/...).
|
||||||
const finalPath = path.join(this.uploadDir, safeName);
|
const finalPath = path.join(this.uploadDir, safeName);
|
||||||
|
|||||||
+48
-11
@@ -1,33 +1,70 @@
|
|||||||
---
|
---
|
||||||
type: api
|
type: api
|
||||||
title: Uploads Endpoints
|
title: Uploads Endpoints
|
||||||
description: Admin-only multipart upload endpoints for category icons and challenge files.
|
description: Admin-only multipart upload endpoints for site logos, category icons, and challenge files.
|
||||||
tags: [api, uploads, multipart, admin]
|
tags: [api, uploads, multipart, admin]
|
||||||
timestamp: 2026-07-21T18:28:00Z
|
timestamp: 2026-07-22T13:05:30Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Endpoints
|
# Endpoints
|
||||||
|
|
||||||
| Method | Path | Auth | Source |
|
| Method | Path | Auth | Source |
|
||||||
|--------|---------------------------------------|-------|---------------------------------------------------------|
|
|--------|---------------------------------------|-------|---------------------------------------------------------|
|
||||||
| `POST` | `/api/v1/uploads/category-icon` | Admin | `backend/src/modules/uploads/uploads.controller.ts` |
|
| `POST` | `/api/v1/uploads/logo` | Admin | `backend/src/modules/uploads/uploads.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/uploads/category-icon` | Admin | Same. |
|
||||||
| `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. |
|
| `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. |
|
||||||
|
|
||||||
# Guard chain
|
# Guard chain
|
||||||
|
|
||||||
Both handlers are gated by `JwtAuthGuard` + `AdminGuard`. CSRF is enforced
|
All handlers are gated by the controller-level `AdminGuard` and `admin` role.
|
||||||
(standard cookie + header pattern; nothing is added to the skip list).
|
The global authentication and CSRF protections also apply; clients must send a
|
||||||
|
valid admin session and the standard CSRF cookie/header pair.
|
||||||
|
|
||||||
# Behavior
|
# Behavior
|
||||||
|
|
||||||
* Each endpoint uses `FileInterceptor('file')` and parses a single
|
* Each endpoint uses `FileInterceptor('file')` and parses a single multipart
|
||||||
multipart part named `file`.
|
part named `file`.
|
||||||
* File size is validated by `parseUploadSizeLimit()` in
|
* File size is validated against `UPLOAD_SIZE_LIMIT` through
|
||||||
`backend/src/common/utils/upload.ts`.
|
`parseUploadSizeLimit()` in `backend/src/common/utils/upload.ts`.
|
||||||
* Filenames are sanitized by the same helper before being persisted
|
* Category icons are written under `UPLOAD_DIR/icons`; when `categoryId` is
|
||||||
under `UPLOAD_DIR`.
|
supplied, the image is cropped to a 128-by-128 PNG and exposed at
|
||||||
|
`/uploads/icons/{categoryId}.png`.
|
||||||
|
* Challenge attachments are written under `UPLOAD_DIR/challenges` and exposed
|
||||||
|
at `/uploads/challenges/{safeFilename}`.
|
||||||
|
* Site logos are decoded with Sharp and accepted only when their actual format
|
||||||
|
is PNG, JPEG, GIF, or WebP. The multipart MIME type and filename extension
|
||||||
|
are not trusted for validation.
|
||||||
|
* An accepted logo keeps its sanitized filename, is written directly under
|
||||||
|
`UPLOAD_DIR`, and returns `{ publicUrl, originalFilename }`, with `publicUrl`
|
||||||
|
in the form `/uploads/{safeFilename}`.
|
||||||
|
* A missing `file` part, an oversized upload, a corrupt image, or a decoded logo
|
||||||
|
in another format returns `400 Bad Request`. Invalid logo payloads use the
|
||||||
|
message `Logo must be a valid PNG, JPEG, GIF, or WebP image.` and are not
|
||||||
|
persisted.
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
|
||||||
|
1. Sign in as an administrator and navigate to `/admin/general`.
|
||||||
|
2. In the site-logo setting, choose a PNG, JPEG, GIF, or WebP image and save the
|
||||||
|
general settings.
|
||||||
|
3. The client uploads the selected file to `POST /api/v1/uploads/logo` before
|
||||||
|
saving the returned `/uploads/...` URL as the configured logo.
|
||||||
|
4. After a successful save, the uploaded image is available from its returned
|
||||||
|
public URL and is used wherever the configured site logo is rendered.
|
||||||
|
5. Choosing a corrupt file, or a valid image in an unsupported format, leaves
|
||||||
|
the file unstored and surfaces the upload validation failure to the user.
|
||||||
|
|
||||||
|
# Key files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `backend/src/modules/uploads/uploads.controller.ts` | Registers upload routes, validates payloads, and persists files. |
|
||||||
|
| `backend/src/common/utils/upload.ts` | Parses size limits and sanitizes upload filenames. |
|
||||||
|
| `frontend/src/app/features/admin/general/admin-general.component.ts` | Provides the administrator-facing logo selection and settings workflow. |
|
||||||
|
| `tests/backend/uploads-logo.spec.ts` | Verifies accepted logo formats, spoofed/corrupt payload rejection, size limits, and persisted responses. |
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
|
- [Admin — General Settings](/guides/admin-general-settings.md)
|
||||||
- [REST API Overview](/api/rest-overview.md)
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
- [Backend Module Map](/architecture/backend-modules.md)
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
type: architecture
|
type: architecture
|
||||||
title: Key Files Index
|
title: Key Files Index
|
||||||
description: One-line responsibility for important source files, including authenticated event streaming.
|
description: One-line responsibility for important source files, including authenticated event streaming and validated site-logo uploads.
|
||||||
tags: [architecture, index, key-files]
|
tags: [architecture, index, key-files]
|
||||||
timestamp: 2026-07-22T10:32:00Z
|
timestamp: 2026-07-22T10:32:00Z
|
||||||
---
|
---
|
||||||
@@ -22,6 +22,7 @@ timestamp: 2026-07-22T10:32:00Z
|
|||||||
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload. |
|
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload. |
|
||||||
| `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
|
| `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
|
||||||
| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
|
| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
|
||||||
|
| `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. |
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ they need. Last regenerated 2026-07-22T12:44:45Z.
|
|||||||
* [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE
|
* [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE
|
||||||
streams, public event-window settings, and the authenticated
|
streams, public event-window settings, and the authenticated
|
||||||
`/events/status` SSE stream.
|
`/events/status` SSE stream.
|
||||||
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads.
|
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos.
|
||||||
|
|
||||||
# Guides
|
# Guides
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ process.env.UPLOAD_SIZE_LIMIT = '1mb';
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
@@ -117,11 +118,18 @@ describe('Uploads endpoint - /uploads/logo (admin only)', () => {
|
|||||||
it('uploads a small image and returns publicUrl + originalFilename', async () => {
|
it('uploads a small image and returns publicUrl + originalFilename', async () => {
|
||||||
const { agent, csrf } = await primeCsrf();
|
const { agent, csrf } = await primeCsrf();
|
||||||
const originalName = `My Logo ${Date.now()}.PNG`;
|
const originalName = `My Logo ${Date.now()}.PNG`;
|
||||||
|
// Generate a real, decodable PNG with sharp so the upload path
|
||||||
|
// exercises its full image-validation policy.
|
||||||
|
const png = await sharp({
|
||||||
|
create: { width: 32, height: 32, channels: 3, background: { r: 10, g: 20, b: 30 } },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
const res = await agent
|
const res = await agent
|
||||||
.post('/api/v1/uploads/logo')
|
.post('/api/v1/uploads/logo')
|
||||||
.set('Authorization', `Bearer ${adminToken}`)
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
.set('X-CSRF-Token', csrf)
|
.set('X-CSRF-Token', csrf)
|
||||||
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), originalName)
|
.attach('file', png, originalName)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
expect(res.body).toHaveProperty('publicUrl');
|
expect(res.body).toHaveProperty('publicUrl');
|
||||||
@@ -155,4 +163,48 @@ describe('Uploads endpoint - /uploads/logo (admin only)', () => {
|
|||||||
.attach('file', big, 'big.png')
|
.attach('file', big, 'big.png')
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a plain-text payload labeled as an image', async () => {
|
||||||
|
const { agent, csrf } = await primeCsrf();
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/uploads/logo')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.attach('file', Buffer.from('not an image'), { filename: 'logo.png', contentType: 'image/png' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
|
||||||
|
|
||||||
|
const after = fs.existsSync(process.env.UPLOAD_DIR!)
|
||||||
|
? fs.readdirSync(process.env.UPLOAD_DIR!)
|
||||||
|
: [];
|
||||||
|
expect(after.some((f) => f.endsWith('.png') && f.includes('logo'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a corrupt GIF payload', async () => {
|
||||||
|
const { agent, csrf } = await primeCsrf();
|
||||||
|
// GIF89a header followed by a truncated/invalid body.
|
||||||
|
const corruptGif = Buffer.concat([
|
||||||
|
Buffer.from('GIF89a', 'ascii'),
|
||||||
|
Buffer.alloc(8, 0x00),
|
||||||
|
Buffer.from([0xff, 0xff, 0xff]),
|
||||||
|
]);
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/uploads/logo')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.attach('file', corruptGif, { filename: 'malformed.gif', contentType: 'image/gif' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an 8-byte corrupt PNG payload', async () => {
|
||||||
|
const { agent, csrf } = await primeCsrf();
|
||||||
|
const res = await agent
|
||||||
|
.post('/api/v1/uploads/logo')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.attach('file', Buffer.alloc(8, 0x00), { filename: 'bad.png', contentType: 'image/png' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user