136 lines
7.0 KiB
Markdown
136 lines
7.0 KiB
Markdown
---
|
||
type: architecture
|
||
title: System Category Icon Seed
|
||
description: Idempotent startup hook that writes deterministic 128×128 PNG icons for the canonical six system categories (CRY/HW/MSC/PWN/REV/WEB) into UPLOAD_DIR/icons, so freshly bootstrapped instances never serve 404 icons.
|
||
tags: [architecture, startup, categories, icons, seed, sharp, migrations, tester]
|
||
timestamp: 2026-07-23T16:10:00Z
|
||
---
|
||
|
||
# Purpose
|
||
|
||
The canonical six system categories are seeded by the
|
||
`UpdateSystemCategoryKeys1700000000300` and
|
||
`RepairCategorySchemaAndSystemCategories1700000000400` migrations, which
|
||
insert database rows whose `icon_path` column references
|
||
`/uploads/icons/{CRY,HW,MSC,PWN,REV,WEB}.png`. The migrations create the
|
||
DB rows but never write the PNG assets to disk, and `setup.sh` only
|
||
`mkdir -p`s the upload directory — it does not populate it.
|
||
|
||
As a result, `GET /uploads/icons/CRY.png` returns `404` on a freshly
|
||
cloned instance, and the challenges board and admin categories page
|
||
show broken images for every system row until an admin re-uploads each
|
||
icon.
|
||
|
||
This module closes that gap by generating a deterministic 128×128 PNG
|
||
for each canonical key on every backend startup, idempotently. Admin
|
||
uploads written through `UploadsController` are never overwritten.
|
||
|
||
# Files
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `backend/src/database/system-category-icons.ts` | Exports `CANONICAL_SYSTEM_ICON_KEYS` (`['CRY','HW','MSC','PWN','REV','WEB']`), `SYSTEM_ICON_SIZE` (`128`), and the async `seedSystemCategoryIcons(uploadDir)` helper. Creates `<uploadDir>/icons/` recursively, generates a sharp 128×128 PNG (palette-coloured background + white 2–6-letter abbreviation overlay) for every key whose `<KEY>.png` does not yet exist or is zero-bytes, and returns `{ written, skipped }`. |
|
||
| `backend/src/database/database-init.service.ts` | Calls `seedSystemCategoryIcons(path.resolve(config.get('UPLOAD_DIR', './data/uploads')))` from `init()` after migrations and `verifySeed()`. Skips when `UPLOAD_DIR` is unset or contains `:memory:`. Failures log a `WARN` and never abort startup. |
|
||
| `tests/backend/system-category-icons.spec.ts` | Pure helper contract: writes six 128×128 PNGs in a fresh temp dir; second run reports all six as `skipped` and leaves `mtimeMs` of an existing admin-uploaded icon unchanged; creates the `icons/` subdirectory when the parent is empty. |
|
||
|
||
# How the helper works
|
||
|
||
`seedSystemCategoryIcons(uploadDir)`:
|
||
|
||
1. Resolves `iconsDir = path.join(uploadDir, 'icons')` and creates it
|
||
recursively when missing — covers the fresh-clone case where the
|
||
directory does not yet exist.
|
||
2. For each `key` in `CANONICAL_SYSTEM_ICON_KEYS`:
|
||
* `target = path.join(iconsDir, `${key}.png`)`.
|
||
* If the file exists **and** its size is `> 0`, push `target` onto
|
||
`skipped` and continue. This is the contract that protects
|
||
admin-uploaded icons: a later restart must never overwrite them.
|
||
* Otherwise build a sharp PNG with:
|
||
* 128×128 RGBA buffer,
|
||
* background colour from `PALETTE[hash(key) % PALETTE.length]`
|
||
(8-entry palette, deterministic hash so every icon is visually
|
||
distinct but reproducible across machines/containers),
|
||
* white abbreviation overlay drawn from
|
||
`renderLabel(key)` (one white pixel per letter, centred).
|
||
* Write the buffer with `fs.writeFileSync(target, buf)` and push
|
||
`target` onto `written`.
|
||
3. Return `{ written, skipped }`.
|
||
|
||
The palette and overlay are intentionally simple — these are
|
||
fallback/placeholder icons. An admin who wants branded artwork uploads
|
||
a real image through `POST /api/v1/uploads/category-icon`, which writes
|
||
to the same `<uploadDir>/icons/<KEY>.png` path and is then preserved on
|
||
subsequent restarts.
|
||
|
||
# Startup wiring
|
||
|
||
Inside `DatabaseInitService.init()` the new step runs after the
|
||
existing `runMigrations` block and `verifySeed()`:
|
||
|
||
```
|
||
verifySeed() // best-effort WARN on failure
|
||
ensureSystemCategoryIcons() // best-effort WARN on failure
|
||
```
|
||
|
||
`ensureSystemCategoryIcons()` short-circuits with a `debug` log when
|
||
`UPLOAD_DIR` is unset or contains `:memory:` (matches how the module
|
||
already guards in-memory test databases). Otherwise it logs:
|
||
|
||
```
|
||
System category icons: written=<n> skipped=<n>
|
||
```
|
||
|
||
A failure logs `ensureSystemCategoryIcons skipped: <message>` as a
|
||
`WARN` and **does not abort startup** — mirroring the
|
||
`verifySeed` "best-effort" pattern. Missing icons are recoverable (they
|
||
only affect image rendering on a few pages) and never block HTTP
|
||
traffic.
|
||
|
||
# Source-of-truth coupling
|
||
|
||
`CANONICAL_SYSTEM_ICON_KEYS` is duplicated from
|
||
`CANONICAL_SYSTEM_CATEGORIES` in
|
||
`backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts:13`
|
||
to avoid a migration → runtime import cycle (the migration is
|
||
registered after `database.module.ts` is built, and re-importing from
|
||
the migration would be circular). The duplication is **intentional**;
|
||
the helper file carries a top-of-file comment pointing at the
|
||
canonical migration as the source of truth, and the helper, the
|
||
migration, and the hard-coded `iconPath` URLs in
|
||
`1700000000300-UpdateSystemCategoryKeys.ts` and
|
||
`1700000000400-RepairCategorySchemaAndSystemCategories.ts` must be kept
|
||
in sync whenever a system category is added or renamed.
|
||
|
||
# What to look at next
|
||
|
||
- The `UPLOAD_DIR` value is read from `ConfigService` and defaults to
|
||
`./data/uploads` (see
|
||
[`env.schema.ts`](/architecture/key-files.md) for the canonical
|
||
defaults).
|
||
- The icons directory is served by `app.use('/uploads', express.static(uploadDir))`
|
||
in `backend/src/main.ts:79`, so the on-disk files are the same
|
||
bytes returned by `GET /uploads/icons/<KEY>.png` to the SPA.
|
||
- Admin overrides go through
|
||
[`POST /api/v1/uploads/category-icon`](/api/uploads.md), which
|
||
normalizes uploaded images to the same 128×128 PNG size and writes
|
||
to the same `UPLOAD_DIR/icons/<KEY>.png` path.
|
||
- The challenges board and admin categories page consume
|
||
`<img src="/uploads/icons/<KEY>.png">` from each `category.icon_path`
|
||
column — see [Challenges Board](/guides/challenges-board.md) and
|
||
[Admin — Categories](/guides/admin-categories.md).
|
||
|
||
# Tests
|
||
|
||
| File | What it asserts |
|
||
|---|---|
|
||
| `tests/backend/system-category-icons.spec.ts` | Real `sharp` + `os.tmpdir()`: writes six PNGs of `format === 'png'` and `width === height === 128` on a fresh dir; second invocation reports all six as `skipped` and does not change `mtimeMs` of an existing `CRY.png` (admin-upload contract); creates the `icons/` subdirectory when the parent is empty. No mocks. |
|
||
|
||
# See also
|
||
|
||
- [Category Repair Migration](/database/category-repair-migration.md) — canonical six system categories, DB-side.
|
||
- [Uploads Endpoints](/api/uploads.md) — admin override path for the same on-disk files.
|
||
- [Challenges Board](/guides/challenges-board.md) — tester-visible consumption of the icons.
|
||
- [Admin — Categories](/guides/admin-categories.md) — admin override UI.
|
||
- [Backend Module Map](/architecture/backend-modules.md) — startup chain.
|
||
- [Key Files Index](/architecture/key-files.md) — file-by-file responsibilities.
|