/** * brandAssets.mjs — the brand mount, resolved (PLAN.md §7) * * Two directories. `brand-default/` is baked into the image and always complete. * `brand/` is the bind mount and may be empty, partial or full. Every file resolves * against the mount first and the defaults second, PER FILE, so overriding only * `theme.css` leaves every logo stock and an empty mount produces exactly the stock site. * * --------------------------------------------------------------------------------------- * WHY THIS IS A RUNTIME MODULE AND NOT AN ASSET IMPORT * --------------------------------------------------------------------------------------- * Nothing here goes through Vite. Vite would fingerprint the filename into the build — * `logo.a1b2c3.png` — and a mounted file could then never replace it, because no page * would ever ask for the name the operator wrote. Stable, unhashed URLs are the mechanism; * the ETag below is what buys back the caching that fingerprinting would have given. * * --------------------------------------------------------------------------------------- * ONE FILE IS THE WHOLE REBRAND * --------------------------------------------------------------------------------------- * §7's promise is that swapping a logo is "a file copy". The site asks for about fifteen * images — header at three pixel ratios, hero, two PWA icons, an apple-touch icon, three * favicon sizes and an .ico. If those were fifteen files in `brand-default/`, keeping the * promise would mean an operator producing fifteen files, and the realistic outcome is a * deployment with a new header mark and the old favicon. * * So the defaults hold exactly one raster — `logo.png` — and everything else is derived * from whichever `logo.png` is in force, cached in memory after the first request. Drop in * one file, restart, and the header, the browser tab, the installed icon and the hero all * change together. * * Derivation is limited to an allowlist of sizes. That is not tidiness: an open size * parameter is an invitation to make the container resize an image ten thousand times. */ import { createHash } from 'node:crypto'; import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import sharp from 'sharp'; /** * Both directories are resolved from the working directory, which is `/app` in the * container and the repository root in development — so the defaults are correct in both * places and the env vars exist for the third case nobody has hit yet. */ const MOUNT_DIR = process.env.BRAND_DIR || path.join(process.cwd(), 'brand'); const DEFAULT_DIR = process.env.BRAND_DEFAULT_DIR || path.join(process.cwd(), 'brand-default'); const CONTENT_TYPES = { '.png': 'image/png', '.webp': 'image/webp', '.avif': 'image/avif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.webmanifest': 'application/manifest+json; charset=utf-8', }; /** * The files that may be served verbatim from either directory. * * An allowlist rather than "whatever is in the directory", because the mount is operator * data: without this, dropping a stray file into `brand/` would publish it, and a mount * pointed at the wrong directory by a typo in a compose file would publish that instead. * Serving only names the site actually asks for keeps the blast radius of a mistake to a * missing logo. */ const STATIC_FILES = new Set([ 'brand.json', 'theme.css', 'logo.png', 'logo.svg', 'wordmark.svg', 'og-image.png', ]); /** Sizes the site actually uses, at 1x, 2x and 3x where it uses them. */ const DERIVABLE_SIZES = new Set([ 16, 32, 40, 48, 64, 80, 96, 120, 128, 160, 180, 192, 240, 256, 320, 384, 512, ]); const NAMED_DERIVATIVES = { // Derivable, not static, even though §7's table lists it as a file: a mounted // `favicon.ico` still wins, because `locate` runs before derivation for every name. The // distinction that matters is what happens when NOBODY supplies one, and the answer has // to be "derive it from the logo" rather than "404" — browsers request `/favicon.ico` // whether or not a page links it. 'favicon.ico': { size: 48, format: 'ico' }, 'icon-192.png': { size: 192, format: 'png' }, 'icon-512.png': { size: 512, format: 'png' }, 'apple-touch-icon.png': { size: 180, format: 'png' }, 'favicon-16.png': { size: 16, format: 'png' }, 'favicon-32.png': { size: 32, format: 'png' }, 'favicon-48.png': { size: 48, format: 'png' }, }; /** `logo-.` — the header and hero variants. */ const SIZED = /^logo-(\d{1,4})\.(webp|avif|png)$/; /** * Describes what a requested name means, or returns null if it means nothing. * Names are flat by construction: a `/` or a `..` never reaches here (see `parseName`). */ export function classify(name) { if (STATIC_FILES.has(name)) return { kind: 'static', name }; if (NAMED_DERIVATIVES[name]) return { kind: 'derived', name, ...NAMED_DERIVATIVES[name] }; const sized = SIZED.exec(name); if (sized) { const size = Number(sized[1]); if (DERIVABLE_SIZES.has(size)) return { kind: 'derived', name, size, format: sized[2] }; } return null; } /** * Rejects anything that is not a single flat filename. * * Path traversal is the obvious reason, and it is not the only one: this route reads from * a directory an operator controls but does not audit, so "one segment, lowercase, from * the allowlist" is a much smaller thing to be sure of than "no `..` anywhere". */ export function parseName(rest) { const name = (rest || '').replace(/^\/+/, ''); if (!name || !/^[a-z0-9][a-z0-9._-]*$/.test(name) || name.includes('..')) return null; return name; } async function statOrNull(file) { try { const info = await stat(file); return info.isFile() ? info : null; } catch { return null; } } /** * Where a given file comes from, mount first. Returns null when neither directory has it — * which for a derivable name is not an error, it just means "derive it". */ async function locate(name) { const mounted = path.join(MOUNT_DIR, name); const info = await statOrNull(mounted); if (info) return { file: mounted, info, source: 'mount' }; const fallback = path.join(DEFAULT_DIR, name); const defaultInfo = await statOrNull(fallback); if (defaultInfo) return { file: fallback, info: defaultInfo, source: 'default' }; return null; } /** * The raster every derivative descends from. * * `logo.svg` is second rather than first because it is the rarer case and the PNG is what * §7's table calls the emblem; an operator who mounts both means the PNG. An operator who * mounts only the SVG gets it rasterised, which is better than getting the stock mark. */ async function locateSource() { for (const candidate of ['logo.png', 'logo.svg']) { const mounted = path.join(MOUNT_DIR, candidate); const info = await statOrNull(mounted); if (info) return { file: mounted, info, source: 'mount' }; } const fallback = path.join(DEFAULT_DIR, 'logo.png'); const info = await statOrNull(fallback); return info ? { file: fallback, info, source: 'default' } : null; } /** * A cache key that changes when the file behind it changes. * * §7 says a rebrand is a file copy and a restart, and a restart empties this map — so * strictly the mtime is redundant. It is here because the failure it prevents is the * confusing one: an operator who copies a new logo in without restarting should see either * the old mark or the new one, never a header showing the new mark beside a favicon still * derived from the old. */ function signature({ file, info }) { return `${file}:${info.mtimeMs}:${info.size}`; } /** name -> { bytes, etag, type, source } */ const cache = new Map(); const etagOf = (bytes) => `"${createHash('sha256').update(bytes).digest('base64url').slice(0, 24)}"`; /** * Minimal ICO container. * * `favicon.ico` is in §7's table and sharp cannot write the format, but an .ico is barely a * format: a six-byte header, a sixteen-byte directory entry per image, and — since Vista — * ordinary PNG payloads. Writing those forty bytes is cheaper than a dependency, and it is * what lets `/favicon.ico`, which browsers request whether or not a page links it, answer * with the operator's mark rather than a 404. */ function encodeIco(images) { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); // reserved header.writeUInt16LE(1, 2); // 1 = icon header.writeUInt16LE(images.length, 4); const directory = Buffer.alloc(16 * images.length); let offset = header.length + directory.length; images.forEach(({ size, bytes }, i) => { const at = i * 16; directory.writeUInt8(size >= 256 ? 0 : size, at); // 0 means 256 directory.writeUInt8(size >= 256 ? 0 : size, at + 1); directory.writeUInt8(0, at + 2); // palette size, 0 for truecolour directory.writeUInt8(0, at + 3); // reserved directory.writeUInt16LE(1, at + 4); // colour planes directory.writeUInt16LE(32, at + 6); // bits per pixel directory.writeUInt32LE(bytes.length, at + 8); directory.writeUInt32LE(offset, at + 12); offset += bytes.length; }); return Buffer.concat([header, directory, ...images.map((image) => image.bytes)]); } /** * Rasterise at the target size. An SVG source needs the density raised to match, otherwise * librsvg renders it at its nominal size and sharp scales the result up. */ async function rasterise(source, size) { const isSvg = source.file.endsWith('.svg'); const bytes = await readFile(source.file); if (!isSvg) return sharp(bytes).resize(size, size, { fit: 'contain', background: TRANSPARENT }); const nominal = (await sharp(bytes).metadata()).width || size; return sharp(bytes, { density: Math.min(2400, Math.max(72, (72 * size) / nominal)) }) .resize(size, size, { fit: 'contain', background: TRANSPARENT }); } const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 }; async function derive(spec, source) { if (spec.name === 'favicon.ico') { const images = await Promise.all( [16, 32, 48].map(async (size) => ({ size, bytes: await (await rasterise(source, size)).png({ compressionLevel: 9 }).toBuffer(), })) ); return encodeIco(images); } const pipeline = await rasterise(source, spec.size); if (spec.format === 'webp') return pipeline.webp({ quality: 90, effort: 5 }).toBuffer(); if (spec.format === 'avif') return pipeline.avif({ quality: 62, effort: 4 }).toBuffer(); return pipeline.png({ compressionLevel: 9 }).toBuffer(); } /** * Resolve a brand file to bytes: mount, then defaults, then derivation. * * Returns null for a name that is not a brand file at all, so the caller answers 404 * rather than leaking which of the three steps failed. */ export async function resolveBrandFile(name) { const spec = classify(name); if (!spec) return null; const found = await locate(name); // A mounted or stock file always wins over a derivation. An operator who has produced a // hand-tuned 32px favicon should get theirs, not one this code resized. if (found) { const key = `file:${signature(found)}`; const hit = cache.get(name); if (hit?.key === key) return hit; const bytes = await readFile(found.file); const entry = { key, bytes, etag: etagOf(bytes), type: CONTENT_TYPES[path.extname(name)] || 'application/octet-stream', source: found.source, }; cache.set(name, entry); return entry; } if (spec.kind !== 'derived') return null; const source = await locateSource(); if (!source) return null; const key = `derive:${signature(source)}`; const hit = cache.get(name); if (hit?.key === key) return hit; const bytes = await derive(spec, source); const entry = { key, bytes, etag: etagOf(bytes), type: CONTENT_TYPES[path.extname(name)] || 'application/octet-stream', source: `derived:${source.source}`, }; cache.set(name, entry); return entry; } /** * The variants every page requests, derived ahead of the first visitor. * * Called when the route module is first loaded, not at process start — Astro loads a route * lazily — so in practice the first request to anything under `/brand/` pays for its own * file and warms the rest in the background. That is the difference between one slow * request and eight. */ export function warmCache() { const names = [ 'logo-40.webp', 'logo-80.webp', 'logo-120.webp', 'favicon-32.png', 'favicon.ico', 'apple-touch-icon.png', ]; return Promise.allSettled(names.map((name) => resolveBrandFile(name))); } /** For the checks and the smoke test, which assert on what a request WOULD produce. */ export const brandDirs = { mount: MOUNT_DIR, defaults: DEFAULT_DIR };