/** * 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; } /** A pond's own branding (issue #307), stored in its settings. Null per slot * means "not set at this level". */ export const pondBrandingSchema = z.object({ logo: brandingAssetSchema.nullable().default(null), logoDark: brandingAssetSchema.nullable().default(null), favicon: brandingAssetSchema.nullable().default(null), }); export type PondBranding = z.infer; /** What a pond page should actually show. */ export interface ResolvedBranding { logo: BrandingAsset | null; logoDark: BrandingAsset | null; favicon: BrandingAsset | null; /** Which level the LOGO came from — the link's accessible name follows it: * a pond logo is named by the pond, an instance logo by the instance. */ logoLevel: 'pond' | 'instance' | 'none'; faviconLevel: 'pond' | 'instance' | 'default'; } /** * The single place that decides which asset applies (issues #306/#307): * the pond's own, else the instance's, else the shipped default (favicon) or * the instance name as text (logo). * * **A logo set belongs to one level — variants are NEVER mixed across * levels.** A pond that uploaded only a light logo shows THAT logo in dark * mode; it does not fall back to the instance's dark variant. Decided * 2026-08-01: a logo silently swapping to a different image when the viewer * switches theme is a change nobody ordered, and a design that looks wrong is * more honest than one that is quietly substituted — the pond admin can see * it and fix it. Only a pond with NO logo at all inherits the instance's set, * again as a set. */ export function resolveBranding( instance: Pick, pond?: PondBranding | null, ): ResolvedBranding { const pondHasLogo = Boolean(pond && (pond.logo || pond.logoDark)); const logoLevel = pondHasLogo ? 'pond' : instance.logo || instance.logoDark ? 'instance' : 'none'; const source = pondHasLogo ? pond! : instance; const faviconLevel = pond?.favicon ? 'pond' : instance.favicon ? 'instance' : 'default'; return { logo: logoLevel === 'none' ? null : source.logo, logoDark: logoLevel === 'none' ? null : source.logoDark, favicon: pond?.favicon ?? instance.favicon ?? null, logoLevel, faviconLevel, }; }