dorfteich/packages/shared/src/branding.ts
Claude Opus 5 6377faf332
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m28s
CI / Build container images (pull_request) Successful in 2m7s
CI / Auth e2e pack (pull_request) Successful in 9m37s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Build and push images (push) Successful in 23s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m47s
CD / Promote to Int (push) Successful in 16s
CI / Lint, typecheck, test (push) Successful in 7m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m41s
CI / Import/export fidelity gate (push) Successful in 1m12s
#306: instance branding — logo and favicon, cropped in the browser
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"`.
2026-08-01 19:30:52 +02:00

99 lines
4.4 KiB
TypeScript

/**
* Instance and pond branding assets — logo and favicon (issues #306/#307).
*
* The bytes live on disk under `BRANDING_DIR`; only metadata (present/absent,
* dimensions, a content hash for cache busting) goes into settings. Cropping,
* scaling and the conversion to PNG happen in the BROWSER on a canvas: adding
* a native image library to the api would put a decoder in front of
* attacker-supplied bytes and would have to be carried through the
* `--network none` offline build (96-offline-build-protokoll.md).
*
* The api therefore never decodes an image. It checks the PNG signature, reads
* the fixed-offset IHDR fields for the dimensions, and enforces the caps —
* which is exactly as far as one can go without a decoder.
*/
import { z } from 'zod';
/** Logo variants. A set belongs to one level and is never mixed across levels
* (#307): a pond with only a light logo shows THAT logo in dark mode rather
* than silently borrowing the instance's dark one. */
export const LOGO_VARIANTS = ['light', 'dark'] as const;
export type LogoVariant = (typeof LOGO_VARIANTS)[number];
/** Favicon sizes emitted by the browser-side crop: the tab icon and the
* home-screen icon. No `.ico` — every current browser accepts PNG. */
export const FAVICON_SIZES = [32, 180] as const;
export type FaviconSize = (typeof FAVICON_SIZES)[number];
/** Longest edge of an uploaded logo. Beyond this the browser downscales
* before uploading; the api rejects anything larger as a backstop. */
export const MAX_LOGO_EDGE = 512;
/** Per-file cap. A 512px PNG is tens of KB; 2 MiB leaves room for a
* needlessly lossless export without inviting abuse. */
export const MAX_BRANDING_BYTES = 2 * 1024 * 1024;
/** Formats a source image may have in the browser. SVG is deliberately absent:
* it can carry script, and serving it from our own origin would be a
* cross-site-scripting vector (security.md §Uploads). What leaves the canvas
* is PNG regardless. */
export const BRANDING_SOURCE_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
/** True when `bytes` starts with the PNG signature. */
export function hasPngMagic(bytes: Uint8Array): boolean {
if (bytes.length < PNG_MAGIC.length) return false;
return PNG_MAGIC.every((byte, index) => bytes[index] === byte);
}
/** True when the bytes look like SVG (XML declaration or an `<svg` tag near
* the start). Only used to answer a rejected upload with the real reason
* instead of a generic "not a PNG". */
export function looksLikeSvg(bytes: Uint8Array): boolean {
const head = Buffer.from(bytes.subarray(0, 256)).toString('latin1').toLowerCase();
return head.includes('<svg') || (head.includes('<?xml') && head.includes('svg'));
}
/**
* Width and height from a PNG's IHDR, which is at a FIXED offset directly
* after the signature. Reading two big-endian integers is not decoding —
* nothing is decompressed and no attacker-controlled length drives a loop.
* Returns null when the bytes are not a PNG with an IHDR first.
*/
export function pngDimensions(bytes: Uint8Array): { width: number; height: number } | null {
if (!hasPngMagic(bytes) || bytes.length < 33) return null;
const buf = Buffer.from(bytes.subarray(0, 33));
if (buf.subarray(12, 16).toString('latin1') !== 'IHDR') return null;
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
if (width === 0 || height === 0) return null;
return { width, height };
}
/** What is stored per asset. The bytes stay on disk; `hash` goes into the
* serving URL so a replaced logo is picked up without fighting caches. */
export const brandingAssetSchema = z.object({
hash: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-f0-9]{16,64}$/),
width: z.number().int().min(1),
height: z.number().int().min(1),
});
export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
/** What the api reports about the branding in force. Every field may be null —
* an instance without branding renders its name as text and the shipped
* default favicon. */
export interface BrandingView {
logo: BrandingAsset | null;
logoDark: BrandingAsset | null;
favicon: BrandingAsset | null;
/** The instance name, so the logo link has an accessible name and the
* logo-less case has something to render. Public on purpose: the login
* screen carries the branding. */
instanceName: string;
}