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
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"`.
99 lines
4.4 KiB
TypeScript
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;
|
|
}
|