#306: instance branding - logo and favicon, cropped in the browser #314

Merged
opus-5 merged 1 commits from issue-306-instance-branding into main 2026-08-01 19:55:28 +02:00
Contributor

Implements #306. Touches the same files as #313 (#304) — whichever merges second gets rebased.

What it adds

  • BRANDING_DIR (./data/branding) with the full three-step registration #303 paid for: env default + data-dirs.ts + compose volume + the mkdir/chown line in the api Dockerfile.
  • GET /branding (metadata), GET /branding/logo?variant=, GET /branding/favicon?size= - all unauthenticated; POST/DELETE /admin/branding/... for management.
  • Site-Admin screen with an in-browser cropper; the instance logo at the top of the sidebar linking home; the top-bar wordmark from instance.name; a static <link rel="icon"> whose resource is dynamic.
  • Audit catalogue v1.7 (branding.changed).

The decisions worth reviewing

Decision Why
The api never decodes an image An image library would put a decoder in front of attacker-supplied bytes and would have to be carried through the --network none offline build. It validates the PNG signature, the IHDR dimensions (fixed offsets) and the caps - as far as one can go without a decoder.
SVG refused, with its own message It can carry script; serving it from our origin is an XSS vector. An operator who tries one should learn it is deliberate.
Crop by number inputs, not dragging A drag-only cropper excludes keyboard and switch users. The result is also stated in text, not only drawn as a frame.
Variant chosen in CSS theme-init.js resolves data-theme before first paint, so the right logo is the one PAINTED, not the one that appears after a flash.
No dark variant to fall back to The light logo carries both themes: the operator's own asset unchanged beats a substitute they did not choose (the rule #307 extends to ponds). The screen warns; nothing is blocked.
Metadata not writable via PATCH /admin/settings It describes bytes on disk; hand-writing it would claim an asset that is not there.
Both favicon sizes uploaded together The api cannot resize, and one source must never become two different icons.

A hole closed on the way

createArchive tolerates a missing directory wordlessly - which is exactly why #303's omission stayed invisible until a restore would have come up short. The new data-dirs.test.ts demands that every *_DIR the backup env declares actually travels in the archive. Checked against the real defect: removing the BRANDING_DIR line fails it by name.

Verification

  • api suite: 103 files. One public-api ECONNRESET under local parallel load, green in isolation - the documented local flake.
  • Branding suite: 12 tests against a real temp directory (bytes written and read back, SVG/format/size/square rejections, 404 vs. shipped default, audit fields, non-admin 403 but reading allowed).
  • Crop arithmetic: unit tests for the bounds a number input could otherwise break.
  • a11y pack 11/11 in both colour schemes; /admin measured at 320px with the new section - overflow 0.
  • Walked in the browser: upload -> crop 780x180 -> stored as 512x118 -> logo at the top of the sidebar, linking home, accessible name = instance name -> top-bar wordmark follows instance.name -> the light logo stays put under data-theme="dark".

Deploy note (like #303)

The stage composes on ONE need the branding volume before the next CD deploy - CD does not sync them. Without it, uploaded branding lands in the container filesystem and is lost on the next redeploy. Same merker for prod at release time.

Not done: a full backup-and-restore drill against a live stack. What is proven instead is the registration (the new fence plus the existing archive round-trip); the drill belongs to the next restore-drill run.

Closes #306

Implements #306. Touches the same files as #313 (#304) — whichever merges second gets rebased. ## What it adds - `BRANDING_DIR` (`./data/branding`) with the full three-step registration #303 paid for: env default + `data-dirs.ts` + compose volume + the `mkdir`/`chown` line in the api Dockerfile. - `GET /branding` (metadata), `GET /branding/logo?variant=`, `GET /branding/favicon?size=` - all unauthenticated; `POST`/`DELETE /admin/branding/...` for management. - Site-Admin screen with an in-browser cropper; the instance logo at the top of the sidebar linking home; the top-bar wordmark from `instance.name`; a static `<link rel="icon">` whose resource is dynamic. - Audit catalogue **v1.7** (`branding.changed`). ## The decisions worth reviewing | Decision | Why | |---|---| | The api never decodes an image | An image library would put a decoder in front of attacker-supplied bytes and would have to be carried through the `--network none` offline build. It validates the PNG signature, the IHDR dimensions (fixed offsets) and the caps - as far as one can go without a decoder. | | SVG refused, with its own message | It can carry script; serving it from our origin is an XSS vector. An operator who tries one should learn it is deliberate. | | Crop by number inputs, not dragging | A drag-only cropper excludes keyboard and switch users. The result is also stated in text, not only drawn as a frame. | | Variant chosen in CSS | `theme-init.js` resolves `data-theme` before first paint, so the right logo is the one PAINTED, not the one that appears after a flash. | | No dark variant to fall back to | The light logo carries both themes: the operator's own asset unchanged beats a substitute they did not choose (the rule #307 extends to ponds). The screen warns; nothing is blocked. | | Metadata not writable via `PATCH /admin/settings` | It describes bytes on disk; hand-writing it would claim an asset that is not there. | | Both favicon sizes uploaded together | The api cannot resize, and one source must never become two different icons. | ## A hole closed on the way `createArchive` tolerates a missing directory **wordlessly** - which is exactly why #303's omission stayed invisible until a restore would have come up short. The new `data-dirs.test.ts` demands that every `*_DIR` the backup env declares actually travels in the archive. Checked against the real defect: removing the `BRANDING_DIR` line fails it by name. ## Verification - api suite: 103 files. One `public-api` ECONNRESET under local parallel load, green in isolation - the documented local flake. - Branding suite: 12 tests against a real temp directory (bytes written and read back, SVG/format/size/square rejections, 404 vs. shipped default, audit fields, non-admin 403 but reading allowed). - Crop arithmetic: unit tests for the bounds a number input could otherwise break. - a11y pack 11/11 in both colour schemes; `/admin` measured at 320px with the new section - overflow 0. - Walked in the browser: upload -> crop 780x180 -> stored as 512x118 -> logo at the top of the sidebar, linking home, accessible name = instance name -> top-bar wordmark follows `instance.name` -> the light logo stays put under `data-theme="dark"`. ## Deploy note (like #303) The stage composes on ONE need the `branding` volume **before** the next CD deploy - CD does not sync them. Without it, uploaded branding lands in the container filesystem and is lost on the next redeploy. Same merker for prod at release time. Not done: a full backup-and-restore drill against a live stack. What is proven instead is the registration (the new fence plus the existing archive round-trip); the drill belongs to the next restore-drill run. Closes #306
opus-5 added 1 commit 2026-08-01 19:16:14 +02:00
#306: instance branding — logo and favicon, cropped in the browser
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m40s
CI / Auth e2e pack (pull_request) Failing after 3m12s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 4m5s
e279d26a55
An instance had no way to look like itself: the top bar said "Dorfteich"
whatever the operator called their instance, `instance.name` was never
rendered in the running app at all, and there was no favicon anywhere —
`index.html` had no `<link rel="icon">` and `public/` held only fonts and
theme-init.js.

Where the line is drawn, and why:

- **The api never decodes an image.** Cropping, scaling and the conversion
  to PNG happen on a canvas in the browser; the api checks the PNG
  signature, reads the IHDR dimensions at their fixed offsets and enforces
  the caps. An image library would put a decoder in front of
  attacker-supplied bytes AND would have to be carried through the
  `--network none` offline build. Reading two big-endian integers is not
  decoding.
- **SVG is refused**, with its own error message rather than a generic
  "not a PNG": it can carry script, and serving it from our own origin
  would be a cross-site-scripting vector. An operator who tried one should
  learn that it is deliberate.
- **The crop is driven by number inputs, not by dragging.** A drag-only
  cropper excludes keyboard and switch users outright; a number input is
  arrow-key operable and screen-reader readable without any custom aria.
  The resulting pixel size is stated in text, not only drawn as a frame.
- **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has
  already resolved `data-theme` before first paint, so the correct logo is
  the one painted rather than the one that appears after a flash. Without a
  dark variant the LIGHT logo carries both themes — the operator's own
  asset shown unchanged beats one they did not choose (the rule #307
  extends to ponds). The settings screen warns; it never blocks.
- **The favicon link is static, its resource dynamic.** index.html stays a
  static file and the api answers with the uploaded icon or a shipped
  default — that route must never 404, or the browser keeps its generic
  icon for good. The default is generated by a script from Node's own zlib
  (`gen-default-favicon.mjs`), for the same offline-build reason.
- Both favicon sizes are uploaded together: one source, one crop, so the
  tab icon and the home-screen icon can never disagree.
- Branding is served WITHOUT a session, because the login screen carries it
  and the browser fetches the favicon before anyone signs in. The admin
  screen says so — an operator may not expect their logo to be public.
- The metadata is not writable through the settings endpoint: it describes
  bytes on disk, and hand-writing it would claim an asset that is not
  there.

`./data/branding` follows the three-step rule #303 paid for: env default +
`data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the
`mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and
closes the hole that made #303's variant invisible: the nightly archive
skips a missing directory WORDLESSLY, so the fence now demands that every
`*_DIR` the backup env declares actually travels in the archive. Verified
against the real defect — removing the line fails it by name.

Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start
so #307 is the same event with a different scope, not a second id.

Verified: api suite 103 files green (a lone `public-api` ECONNRESET under
local parallel load, green in isolation — the documented local flake);
branding suite 12 tests against a real directory; crop arithmetic unit
tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the
new section (overflow 0); and the whole flow walked in the browser: upload
→ crop 780×180 → stored as 512×118 → logo in the sidebar linking home with
the instance name as its accessible name → topbar wordmark following
`instance.name` → light logo still shown under `data-theme="dark"`.
fable-5 force-pushed issue-306-instance-branding from e279d26a55 to 76a5e92f2e 2026-08-01 19:30:40 +02:00 Compare
fable-5 force-pushed issue-306-instance-branding from 76a5e92f2e to 6377faf332 2026-08-01 19:30:56 +02:00 Compare
opus-5 merged commit 6377faf332 into main 2026-08-01 19:55:28 +02:00
Sign in to join this conversation.
No description provided.