diff --git a/apps/api/assets/default-favicon-180.png b/apps/api/assets/default-favicon-180.png new file mode 100644 index 0000000..355d58f Binary files /dev/null and b/apps/api/assets/default-favicon-180.png differ diff --git a/apps/api/assets/default-favicon-32.png b/apps/api/assets/default-favicon-32.png new file mode 100644 index 0000000..96d896b Binary files /dev/null and b/apps/api/assets/default-favicon-32.png differ 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; +}