All checks were successful
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"`.
128 lines
4.3 KiB
JavaScript
128 lines
4.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Generates the shipped default favicons (issue #306):
|
||
* `apps/api/assets/default-favicon-32.png` and `-180.png`.
|
||
*
|
||
* The api serves these whenever an operator has not uploaded one, so an
|
||
* instance always has a tab icon — the `<link rel="icon">` in index.html is
|
||
* static and its resource must never 404.
|
||
*
|
||
* Drawn here rather than pulled in as a binary: the whole toolchain must
|
||
* survive the `--network none` offline build (96-offline-build-protokoll.md),
|
||
* and adding an image library for one 32×32 icon would be the tail wagging
|
||
* the dog. Node's own zlib is enough to write a PNG.
|
||
*
|
||
* Motif: a pond seen from above — the accent-green disc with two ripples.
|
||
*
|
||
* Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit
|
||
* script and binaries together.
|
||
*/
|
||
import { deflateSync } from 'node:zlib';
|
||
import { writeFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
/** Brand green — the same value as index.html's light `theme-color`. */
|
||
const GREEN = [0x2f, 0x6f, 0x4f];
|
||
const LIGHT = [0xe8, 0xf2, 0xec];
|
||
|
||
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
||
let c = n;
|
||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||
return c >>> 0;
|
||
});
|
||
|
||
function crc32(buf) {
|
||
let c = 0xffffffff;
|
||
for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||
return (c ^ 0xffffffff) >>> 0;
|
||
}
|
||
|
||
function chunk(type, data) {
|
||
const length = Buffer.alloc(4);
|
||
length.writeUInt32BE(data.length);
|
||
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||
const crc = Buffer.alloc(4);
|
||
crc.writeUInt32BE(crc32(body));
|
||
return Buffer.concat([length, body, crc]);
|
||
}
|
||
|
||
/** Minimal RGBA PNG writer — no filtering, one IDAT. */
|
||
function encodePng(size, rgba) {
|
||
const ihdr = Buffer.alloc(13);
|
||
ihdr.writeUInt32BE(size, 0);
|
||
ihdr.writeUInt32BE(size, 4);
|
||
ihdr[8] = 8; // bit depth
|
||
ihdr[9] = 6; // colour type RGBA
|
||
const raw = Buffer.alloc(size * (size * 4 + 1));
|
||
for (let y = 0; y < size; y += 1) {
|
||
raw[y * (size * 4 + 1)] = 0; // filter: none
|
||
rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
||
}
|
||
return Buffer.concat([
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||
chunk('IHDR', ihdr),
|
||
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||
chunk('IEND', Buffer.alloc(0)),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Colour at one point of the unit square, in continuous coordinates — the
|
||
* caller supersamples it, which is where the anti-aliasing comes from.
|
||
*/
|
||
function sample(x, y) {
|
||
const dx = x - 0.5;
|
||
const dy = y - 0.5;
|
||
const r = Math.hypot(dx, dy);
|
||
if (r > 0.48) return null; // outside the disc: transparent
|
||
// Two ripples spreading from a point struck slightly above centre — rings
|
||
// rather than a bullseye, which is why the centre stays green and the
|
||
// spacing widens outward the way real ripples do.
|
||
const rr = Math.hypot(dx, dy + 0.06);
|
||
const onRing = (radius, width) => Math.abs(rr - radius) < width;
|
||
if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT;
|
||
return GREEN;
|
||
}
|
||
|
||
function render(size) {
|
||
const SS = 4; // supersampling factor
|
||
const out = Buffer.alloc(size * size * 4);
|
||
for (let y = 0; y < size; y += 1) {
|
||
for (let x = 0; x < size; x += 1) {
|
||
let r = 0;
|
||
let g = 0;
|
||
let b = 0;
|
||
let a = 0;
|
||
for (let sy = 0; sy < SS; sy += 1) {
|
||
for (let sx = 0; sx < SS; sx += 1) {
|
||
const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size);
|
||
if (c) {
|
||
r += c[0];
|
||
g += c[1];
|
||
b += c[2];
|
||
a += 255;
|
||
}
|
||
}
|
||
}
|
||
const n = SS * SS;
|
||
const covered = a / 255;
|
||
const i = (y * size + x) * 4;
|
||
// Premultiplied average of the covered samples only, so the edge fades
|
||
// in alpha rather than towards black.
|
||
out[i] = covered ? Math.round(r / covered) : 0;
|
||
out[i + 1] = covered ? Math.round(g / covered) : 0;
|
||
out[i + 2] = covered ? Math.round(b / covered) : 0;
|
||
out[i + 3] = Math.round(a / n);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets');
|
||
for (const size of [32, 180]) {
|
||
const file = join(assets, `default-favicon-${size}.png`);
|
||
writeFileSync(file, encodePng(size, render(size)));
|
||
console.log(`wrote ${file}`);
|
||
}
|