All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m8s
CI / Build container images (pull_request) Successful in 4m3s
CI / Auth e2e pack (pull_request) Successful in 9m1s
CI / Import/export fidelity gate (pull_request) Successful in 1m3s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m49s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m42s
CI / Import/export fidelity gate (push) Successful in 58s
Built on #306's storage, serving and crop control — a layer, not a parallel implementation. `resolveBranding` in shared is the ONE place that answers "which asset applies here?", and both the sidebar logo and the favicon swap read it. The decision most likely to be "fixed" by accident, so it is pinned by name in `branding.test.ts`: **a logo set belongs to one level and variants are never mixed across levels.** A pond that uploaded only a light logo shows THAT logo in dark mode; it does not borrow 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. Only a pond with no logo at all inherits the instance's set, again as a set. The settings screen warns about a missing dark variant; it never blocks. Consequences that fall out of that rule and are easy to get wrong: - The serving route does NOT fall back when given a pond scope. The caller already decided which level applies; a "helpful" fallback in the route would mix variants across levels behind the resolver's back. - The logo link's accessible name follows the LEVEL: a pond logo is named by the pond, an instance logo by the instance. It is the link home, and a link's name has to say where it goes. - **Charged to the pond's storage quota**, before the write, like attachments. Without it branding would be a way around the quota, and replacing a logo repeatedly would consume disk with no ceiling. The replaced asset's bytes are released FIRST, so re-uploading the same logo costs nothing — and a refused upload puts the released reservation back, so a rejection cannot leave the pond with more room than it had. - **Purge removes the branding files.** The purge standard is absolute: after it nothing referencing the pond survives, rows or files. Asserted against the real purge path, not the new code alone. - Security unchanged from #306 and not relaxed because the uploader is now an ordinary Pond Admin: SVG refused, magic bytes and IHDR checked server-side, size caps, content type pinned, no image parsing. - The favicon swap is driven by the RESOLVED pond, never the raw route parameter — an unreadable or unknown slug must not leave a stale icon in the tab. That it happens after first paint is accepted and stated in the code and the UI: avoiding it would mean server-rendering index.html, which is #179's territory. Same audit id as #306 (`branding.changed`) with `scope: 'pond'` — the catalogue already carries the field, so no version bump. Verified: api suite 105 files / 592 tests green; 5 pond-branding e2e tests (pond scope serves the pond's bytes while the instance level still 404s, the quota is charged and released exactly, SVG refused at pond level, a reader may read but not change, purge deletes the files); 9 shared unit tests on the resolution order including both mixing directions.
154 lines
6.8 KiB
TypeScript
154 lines
6.8 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),
|
|
/** Stored bytes. Needed so a pond's quota can be released exactly when the
|
|
* asset is replaced or removed (issue #307) — optional because instance
|
|
* assets predating it are not charged to anything. */
|
|
byteSize: z.number().int().min(0).optional(),
|
|
});
|
|
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;
|
|
}
|
|
|
|
/** 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<typeof pondBrandingSchema>;
|
|
|
|
/** 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<BrandingView, 'logo' | 'logoDark' | 'favicon'>,
|
|
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,
|
|
};
|
|
}
|