From 7f0a86b84cfd8e545602cfde8a0cc0fcff074c99 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 18:46:15 +0200 Subject: [PATCH] #304: declare the font-list route's access rule explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route-permission fence (#52) failed in CI, not locally: I had run the fonts and import-export suites, not the full api suite, and the fence needs a database. `@AuthenticatedOnly()` is the rule the route always meant — a session, no further permission. --- apps/api/assets/default-favicon-180.png | Bin 0 -> 3796 bytes apps/api/assets/default-favicon-32.png | Bin 0 -> 683 bytes apps/api/scripts/gen-default-favicon.mjs | 127 ++++++++++++ .../src/branding/branding-storage.service.ts | 50 +++++ apps/api/src/branding/branding.controller.ts | 135 +++++++++++++ apps/api/src/branding/branding.module.ts | 15 ++ apps/api/src/branding/branding.service.ts | 182 ++++++++++++++++++ apps/api/src/fonts/custom-fonts.controller.ts | 5 + packages/shared/src/branding.ts | 98 ++++++++++ 9 files changed, 612 insertions(+) create mode 100644 apps/api/assets/default-favicon-180.png create mode 100644 apps/api/assets/default-favicon-32.png create mode 100644 apps/api/scripts/gen-default-favicon.mjs create mode 100644 apps/api/src/branding/branding-storage.service.ts create mode 100644 apps/api/src/branding/branding.controller.ts create mode 100644 apps/api/src/branding/branding.module.ts create mode 100644 apps/api/src/branding/branding.service.ts create mode 100644 packages/shared/src/branding.ts diff --git a/apps/api/assets/default-favicon-180.png b/apps/api/assets/default-favicon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..355d58f4585200fe9e5e1c406e7886845c0fe5de GIT binary patch literal 3796 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 literal 0 HcmV?d00001 diff --git a/apps/api/scripts/gen-default-favicon.mjs b/apps/api/scripts/gen-default-favicon.mjs new file mode 100644 index 0000000..8aeea26 --- /dev/null +++ b/apps/api/scripts/gen-default-favicon.mjs @@ -0,0 +1,127 @@ +#!/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 `` 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}`); +} diff --git a/apps/api/src/branding/branding-storage.service.ts b/apps/api/src/branding/branding-storage.service.ts new file mode 100644 index 0000000..1647a6b --- /dev/null +++ b/apps/api/src/branding/branding-storage.service.ts @@ -0,0 +1,50 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { Injectable } from '@nestjs/common'; + +import { AppConfig } from '../config/app-config.service'; + +/** + * Filesystem binding for branding assets (issue #306; pond overrides #307). + * + * One flat directory of PNGs named by a caller-supplied key + * (`instance-logo-light`, later `pond--favicon-32`). Flat because there + * are a handful of files per instance and the backup archives the directory + * as a whole — a tree would buy nothing and cost a traversal question. + * + * The key is constrained here rather than trusted from the route: it is the + * only thing between a request parameter and a path. + */ +@Injectable() +export class BrandingStorageService { + constructor(private readonly config: AppConfig) {} + + /** Lowercase, digits and dashes only — no dot, so no `..`, and no slash, + * so the file cannot leave the directory whatever a caller sends. */ + private pathFor(key: string): string { + if (!/^[a-z0-9-]{1,120}$/.test(key)) throw new Error(`invalid branding key: ${key}`); + return join(this.config.env.BRANDING_DIR, `${key}.png`); + } + + async save(key: string, bytes: Buffer): Promise { + await mkdir(this.config.env.BRANDING_DIR, { recursive: true }); + await writeFile(this.pathFor(key), bytes); + } + + /** The bytes, or null when the file is absent — a missing asset is a normal + * state here (nothing uploaded, or metadata and disk drifted after a + * partial restore), and every caller has a fallback. */ + async read(key: string): Promise { + try { + return await readFile(this.pathFor(key)); + } catch { + return null; + } + } + + /** Idempotent: removing what is not there is success. */ + async remove(key: string): Promise { + await rm(this.pathFor(key), { force: true }); + } +} diff --git a/apps/api/src/branding/branding.controller.ts b/apps/api/src/branding/branding.controller.ts new file mode 100644 index 0000000..83a1b71 --- /dev/null +++ b/apps/api/src/branding/branding.controller.ts @@ -0,0 +1,135 @@ +import { + BadRequestException, + Controller, + Delete, + Get, + Post, + Query, + Req, + Res, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { + BrandingView, + FAVICON_SIZES, + FaviconSize, + LOGO_VARIANTS, + LogoVariant, + MAX_BRANDING_BYTES, +} from '@dorfteich/shared'; +import type { Response } from 'express'; + +import { SiteAdminGuard } from '../admin/site-admin.guard'; +import { AuthedRequest, Public } from '../auth/auth.guard'; +import { BrandingService } from './branding.service'; + +function parseVariant(value: unknown): LogoVariant { + if (!LOGO_VARIANTS.includes(value as LogoVariant)) { + throw new BadRequestException({ code: 'bad_request' }); + } + return value as LogoVariant; +} + +/** + * Public branding surface (issue #306). + * + * Unauthenticated by design and worth stating plainly in the admin UI: the + * login screen carries the branding and the browser fetches the favicon before + * anyone signs in, so an operator's logo IS visible to anonymous visitors. + */ +@Controller('branding') +export class BrandingController { + constructor(private readonly branding: BrandingService) {} + + @Public() + @Get() + view(): Promise { + return this.branding.view(); + } + + @Public() + @Get('logo') + async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise { + const bytes = await this.branding.logoBytes(parseVariant(variant ?? 'light')); + // No shipped default: without a logo the app renders the instance NAME as + // text, so an empty answer here is the honest one. + if (!bytes) { + res.status(404).json({ code: 'not_found', message: 'no logo' }); + return; + } + res.setHeader('Content-Type', 'image/png'); + // The caller puts the content hash in the query string, so a given URL + // never changes what it points at. + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + res.send(bytes); + } + + @Public() + @Get('favicon') + async favicon(@Query('size') size: string | undefined, @Res() res: Response): Promise { + const wanted = Number(size ?? 32); + if (!(FAVICON_SIZES as readonly number[]).includes(wanted)) { + throw new BadRequestException({ code: 'bad_request' }); + } + const { bytes, uploaded } = await this.branding.faviconBytes(wanted as FaviconSize); + res.setHeader('Content-Type', 'image/png'); + // The `` href is a constant in index.html, so this URL + // cannot carry a hash — revalidation is the only way a replaced favicon + // ever reaches a browser that already has one. + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('ETag', `"${uploaded ? 'custom' : 'default'}-${bytes.length}"`); + res.send(bytes); + } +} + +/** Site-Admin management of the instance branding (issue #306). */ +@Controller('admin/branding') +@UseGuards(SiteAdminGuard) +export class BrandingAdminController { + constructor(private readonly branding: BrandingService) {} + + @Post('logo') + @UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } })) + async setLogo( + @Query('variant') variant: string | undefined, + @Req() request: AuthedRequest, + @UploadedFiles() files: Express.Multer.File[] | undefined, + ): Promise { + const file = files?.find((entry) => entry.fieldname === 'file'); + if (!file) throw new BadRequestException({ code: 'branding_file_missing' }); + return this.branding.setLogo(request.user!, parseVariant(variant ?? 'light'), file.buffer); + } + + @Delete('logo') + clearLogo( + @Query('variant') variant: string | undefined, + @Req() request: AuthedRequest, + ): Promise { + return this.branding.clearLogo(request.user!, parseVariant(variant ?? 'light')); + } + + @Post('favicon') + @UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_BRANDING_BYTES } })) + async setFavicon( + @Req() request: AuthedRequest, + @UploadedFiles() files: Express.Multer.File[] | undefined, + ): Promise { + // Field names are the pixel sizes the browser rendered: `png-32`, `png-180`. + const byField = new Map((files ?? []).map((file) => [file.fieldname, file.buffer])); + const collected = {} as Record; + for (const size of FAVICON_SIZES) { + const bytes = byField.get(`png-${size}`); + if (!bytes) throw new BadRequestException({ code: 'branding_file_missing' }); + collected[size] = bytes; + } + return this.branding.setFavicon(request.user!, collected); + } + + @Delete('favicon') + clearFavicon(@Req() request: AuthedRequest): Promise { + return this.branding.clearFavicon(request.user!); + } +} diff --git a/apps/api/src/branding/branding.module.ts b/apps/api/src/branding/branding.module.ts new file mode 100644 index 0000000..f4845cf --- /dev/null +++ b/apps/api/src/branding/branding.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { BrandingAdminController, BrandingController } from './branding.controller'; +import { BrandingStorageService } from './branding-storage.service'; +import { BrandingService } from './branding.service'; + +/** Instance branding — logo and favicon (issue #306). Exports the services so + * the pond-level override (#307) can build on the same storage and the same + * resolution path instead of a parallel one. */ +@Module({ + controllers: [BrandingController, BrandingAdminController], + providers: [BrandingService, BrandingStorageService], + exports: [BrandingService, BrandingStorageService], +}) +export class BrandingModule {} diff --git a/apps/api/src/branding/branding.service.ts b/apps/api/src/branding/branding.service.ts new file mode 100644 index 0000000..08fb9c9 --- /dev/null +++ b/apps/api/src/branding/branding.service.ts @@ -0,0 +1,182 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BrandingAsset, + BrandingView, + FaviconSize, + LogoVariant, + MAX_BRANDING_BYTES, + MAX_LOGO_EDGE, + hasPngMagic, + looksLikeSvg, + pngDimensions, +} from '@dorfteich/shared'; +import { User } from '@prisma/client'; + +import { AuditService } from '../audit/audit.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { BrandingStorageService } from './branding-storage.service'; + +/** The settings key each instance asset's metadata lives under. */ +const INSTANCE_KEYS = { + logoLight: 'instance.logo', + logoDark: 'instance.logoDark', + favicon: 'instance.favicon', +} as const; + +/** + * Instance branding (issue #306): the logo shown at the top of the sidebar and + * the favicon served to the browser. + * + * The api stores and serves bytes; it never decodes them. Validation is the + * PNG signature, the IHDR dimensions and the size cap — see + * `packages/shared/src/branding.ts` for why that line is drawn there. + */ +@Injectable() +export class BrandingService { + constructor( + private readonly settings: InstanceSettingsService, + private readonly storage: BrandingStorageService, + private readonly audit: AuditService, + ) {} + + static logoKey(variant: LogoVariant): string { + return `instance-logo-${variant}`; + } + + static faviconKey(size: FaviconSize): string { + return `instance-favicon-${size}`; + } + + /** + * Rejects anything that is not a PNG within the caps, before a byte is + * written. SVG gets its own message: an operator who tried one deserves to + * learn that it is refused on purpose, not that "the file is broken". + */ + private assertUsablePng(bytes: Buffer, maxEdge: number): { width: number; height: number } { + if (bytes.length === 0) throw new BadRequestException({ code: 'branding_file_empty' }); + if (bytes.length > MAX_BRANDING_BYTES) { + throw new BadRequestException({ code: 'branding_file_too_large' }); + } + if (looksLikeSvg(bytes)) throw new BadRequestException({ code: 'branding_svg_rejected' }); + if (!hasPngMagic(bytes)) throw new BadRequestException({ code: 'branding_not_a_png' }); + const size = pngDimensions(bytes); + if (!size) throw new BadRequestException({ code: 'branding_not_a_png' }); + if (size.width > maxEdge || size.height > maxEdge) { + throw new BadRequestException({ code: 'branding_image_too_large' }); + } + return size; + } + + private assetOf(bytes: Buffer, size: { width: number; height: number }): BrandingAsset { + return { + // Short digest: it only has to change when the bytes change, and it + // travels in every logo URL. + hash: createHash('sha256').update(bytes).digest('hex').slice(0, 16), + ...size, + }; + } + + async view(): Promise { + const [logo, logoDark, favicon, instanceName] = await Promise.all([ + this.settings.get(INSTANCE_KEYS.logoLight), + this.settings.get(INSTANCE_KEYS.logoDark), + this.settings.get(INSTANCE_KEYS.favicon), + this.settings.get('instance.name'), + ]); + return { logo, logoDark, favicon, instanceName }; + } + + async setLogo(admin: User, variant: LogoVariant, bytes: Buffer): Promise { + const size = this.assertUsablePng(bytes, MAX_LOGO_EDGE); + await this.storage.save(BrandingService.logoKey(variant), bytes); + await this.settings.set( + variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight, + this.assetOf(bytes, size), + admin.id, + ); + await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'set'); + return this.view(); + } + + async clearLogo(admin: User, variant: LogoVariant): Promise { + await this.storage.remove(BrandingService.logoKey(variant)); + await this.settings.set( + variant === 'dark' ? INSTANCE_KEYS.logoDark : INSTANCE_KEYS.logoLight, + null, + admin.id, + ); + await this.record(admin, variant === 'dark' ? 'logoDark' : 'logo', 'cleared'); + return this.view(); + } + + /** + * Both favicon sizes arrive together: the browser produced them from one + * source on the same canvas, and the api cannot resize. Storing them as a + * pair keeps the tab icon and the home-screen icon from ever showing two + * different images. + */ + async setFavicon(admin: User, files: Record): Promise { + const sizes = Object.entries(files).map(([declared, bytes]) => { + const size = this.assertUsablePng(bytes, 512); + const expected = Number(declared); + if (size.width !== expected || size.height !== expected) { + throw new BadRequestException({ code: 'branding_favicon_not_square' }); + } + return { expected: expected as FaviconSize, bytes, size }; + }); + for (const entry of sizes) { + await this.storage.save(BrandingService.faviconKey(entry.expected), entry.bytes); + } + // The 32px variant identifies the pair — it is what the tab shows. + const small = sizes.find((entry) => entry.expected === 32)!; + await this.settings.set(INSTANCE_KEYS.favicon, this.assetOf(small.bytes, small.size), admin.id); + await this.record(admin, 'favicon', 'set'); + return this.view(); + } + + async clearFavicon(admin: User): Promise { + await this.storage.remove(BrandingService.faviconKey(32)); + await this.storage.remove(BrandingService.faviconKey(180)); + await this.settings.set(INSTANCE_KEYS.favicon, null, admin.id); + await this.record(admin, 'favicon', 'cleared'); + return this.view(); + } + + /** The bytes to serve for a logo variant, or null when none is stored. */ + logoBytes(variant: LogoVariant): Promise { + return this.storage.read(BrandingService.logoKey(variant)); + } + + /** + * The favicon bytes: the uploaded one, else the shipped default. The + * `` in index.html is static, so this route must always + * answer with an image — a 404 there would leave the browser's generic + * icon for good. + */ + async faviconBytes(size: FaviconSize): Promise<{ bytes: Buffer; uploaded: boolean }> { + const stored = await this.storage.read(BrandingService.faviconKey(size)); + if (stored) return { bytes: stored, uploaded: true }; + const bytes = await readFile(join(__dirname, '../../assets', `default-favicon-${size}.png`)); + return { bytes, uploaded: false }; + } + + private record( + admin: User, + asset: 'logo' | 'logoDark' | 'favicon', + action: 'set' | 'cleared', + ): Promise { + // `scope` is here from the start so the pond-level change (#307) is the + // same event with a different scope, not a second id in the catalogue. + return this.audit.record({ + action: 'branding.changed', + actorId: admin.id, + targetType: 'setting', + targetId: `instance.${asset}`, + details: { scope: 'instance', asset, change: action }, + }); + } +} diff --git a/apps/api/src/fonts/custom-fonts.controller.ts b/apps/api/src/fonts/custom-fonts.controller.ts index dbda4de..a4ea9ff 100644 --- a/apps/api/src/fonts/custom-fonts.controller.ts +++ b/apps/api/src/fonts/custom-fonts.controller.ts @@ -23,6 +23,7 @@ import type { Response } from 'express'; import { SiteAdminGuard } from '../admin/site-admin.guard'; import { AuthedRequest, Public } from '../auth/auth.guard'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { CustomFontStorageService } from './custom-font-storage.service'; import { CustomFontsService, WeightUpload } from './custom-fonts.service'; @@ -125,6 +126,10 @@ export class CustomFontsFileController { private readonly fonts: CustomFontsService, ) {} + // Explicit access declaration, as every route needs (issue #52's fence + // `route-permissions.e2e.db.test.ts`): a session, no further permission — + // the list says which families exist, which is what the pickers offer. + @AuthenticatedOnly() @Get() list(): Promise { return this.fonts.list(); diff --git a/packages/shared/src/branding.ts b/packages/shared/src/branding.ts new file mode 100644 index 0000000..0b0d2a0 --- /dev/null +++ b/packages/shared/src/branding.ts @@ -0,0 +1,98 @@ +/** + * 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; +}