/** * 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 `; /** 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; }