diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index a04e0c6..ea15e05 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -29,19 +29,19 @@ ARG APP_VERSION=0.0.0-dev # Default the data dirs to the writable, node-owned locations created below, so # the image works out of the box even where compose does not set them; compose # still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR). -ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups +ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups WORKDIR /app COPY --from=build --chown=node:node /out /app # Generate the Prisma client for this image's platform. RUN node node_modules/prisma/build/index.js generate -# A fresh named volume mounted at /data/uploads, /data/plugins or /data/fonts -# is created +# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts +# or /data/branding is created # root-owned; pre-creating them here (Docker copies an image directory's # ownership into a new volume on first mount) lets the non-root `node` user # write to them. /data/backups is mounted read-only here, but pre-creating it # node-owned keeps the shared `backups` volume writable for the backup # sidecar even when the api container is the one that initializes it. -RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/secrets /data/backups +RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ 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/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts index 2a76b4d..0617fac 100644 --- a/apps/api/src/admin/admin.controller.ts +++ b/apps/api/src/admin/admin.controller.ts @@ -11,9 +11,17 @@ import { } from '../settings/instance-settings.service'; import { SiteAdminGuard } from './site-admin.guard'; -// Lifecycle markers, not configuration: never editable through this -// endpoint (the setup lock must be irreversible, issue #80). -const INTERNAL_KEYS: ReadonlySet = new Set(['setup.completedAt']); +// Lifecycle markers and file-backed metadata, not configuration: never +// editable through this endpoint. The setup lock must be irreversible +// (issue #80), and the branding entries only describe bytes on disk +// (issue #306) — writing one by hand would claim an asset that is not +// there. Both have their own write paths. +const INTERNAL_KEYS: ReadonlySet = new Set([ + 'setup.completedAt', + 'instance.logo', + 'instance.logoDark', + 'instance.favicon', +]); // Partial update: any subset of the known settings, each validated by // its own schema inside the service (double validation is fine — this diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index bcd0852..fa939dc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { AdminModule } from './admin/admin.module'; import { AuditModule } from './audit/audit.module'; import { AuthModule } from './auth/auth.module'; import { BackupModule } from './backup/backup.module'; +import { BrandingModule } from './branding/branding.module'; import { ApiExceptionFilter } from './common/api-exception.filter'; import { maskTokenParam } from './common/mask-token-param'; import { SecurityHeadersMiddleware } from './common/security-headers.middleware'; @@ -82,6 +83,7 @@ import { VersionsModule } from './versions/versions.module'; PublicModule, PublicApiModule, McpModule, + BrandingModule, FontsModule, ImportExportModule, PluginsModule, diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index 01fa883..fdd7ebf 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -45,6 +45,7 @@ export const AUDIT_EVENTS = { 'quota.override_set': { severity: 'notice' }, 'read_trail.pruned': { severity: 'info' }, 'settings.changed': { severity: 'notice' }, + 'branding.changed': { severity: 'notice' }, 'font.uploaded': { severity: 'notice' }, 'font.deleted': { severity: 'notice' }, 'setup.admin_created': { severity: 'notice' }, 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.e2e.db.test.ts b/apps/api/src/branding/branding.e2e.db.test.ts new file mode 100644 index 0000000..75d716c --- /dev/null +++ b/apps/api/src/branding/branding.e2e.db.test.ts @@ -0,0 +1,250 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * A real PNG of `size`×`size`, built the same way the shipped default is — + * the api reads the IHDR, so the header has to be genuine. + */ +async function png(size: number): Promise { + const { deflateSync } = await import('node:zlib'); + 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; + }); + const crc32 = (buf: Buffer): number => { + let c = 0xffffffff; + for (const byte of buf) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + }; + const chunk = (type: string, data: Buffer): Buffer => { + 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]); + }; + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; + ihdr[9] = 6; + const raw = Buffer.alloc(size * (size * 4 + 1)); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +describe.skipIf(!hasTestDb)('instance branding (e2e, issue #306)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let brandingDir: string; + const suffix = uniqueSuffix(); + const password = 'markenzeichen mit teich 1'; + const admin = { username: `ba-${suffix}` }; + const plain = { username: `bp-${suffix}` }; + let adminCookie: string; + let plainCookie: string; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + // A real directory: the point is that bytes land somewhere and come back. + brandingDir = await mkdtemp(join(tmpdir(), 'dorfteich-branding-')); + process.env.BRANDING_DIR = brandingDir; + app = await createTestApp(); + const users = app.get(UsersService); + + const adminUser = await users.createUser({ + username: admin.username, + email: `${admin.username}@example.org`, + displayName: `Branding Admin ${suffix}`, + password, + locale: 'en', + }); + await users.markEmailVerified(adminUser.id); + await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } }); + + const plainUser = await users.createUser({ + username: plain.username, + email: `${plain.username}@example.org`, + displayName: `Branding Plain ${suffix}`, + password, + locale: 'en', + }); + await users.markEmailVerified(plainUser.id); + + const login = async (username: string): Promise => + sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + adminCookie = await login(admin.username); + plainCookie = await login(plain.username); + }); + + afterAll(async () => { + await prisma.instanceSetting.deleteMany({ + where: { key: { in: ['instance.logo', 'instance.logoDark', 'instance.favicon'] } }, + }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + await rm(brandingDir, { recursive: true, force: true }); + delete process.env.BRANDING_DIR; + }); + + it('serves the shipped default favicon before anything is uploaded', async () => { + // The `` in index.html is a constant — this route must + // never 404, or the browser keeps its generic icon for good. + const res = await api().get('/api/v1/branding/favicon').expect(200); + expect(res.headers['content-type']).toContain('image/png'); + expect(res.body.subarray(0, 8).toString('latin1')).toContain('PNG'); + }); + + it('stores a logo, reports it, and serves the bytes without a session', async () => { + const bytes = await png(64); + const view = await api() + .post('/api/v1/admin/branding/logo?variant=light') + .set('Cookie', adminCookie) + .attach('file', bytes, 'logo.png') + .expect(201); + expect(view.body.logo).toMatchObject({ width: 64, height: 64 }); + expect(view.body.logoDark).toBeNull(); + + // On disk, under the key the pond override (#307) will extend. + const onDisk = await readFile(join(brandingDir, 'instance-logo-light.png')); + expect(onDisk.length).toBe(bytes.length); + + // Anonymous: the login screen carries the branding. + const served = await api().get('/api/v1/branding/logo?variant=light').expect(200); + expect(served.headers['content-type']).toContain('image/png'); + const anon = await api().get('/api/v1/branding').expect(200); + expect(anon.body.logo.hash).toBe(view.body.logo.hash); + expect(anon.body.instanceName).toBeTruthy(); + }); + + it('answers 404 for a logo variant that was never uploaded', async () => { + // No shipped default for the logo: without one the app renders the + // instance NAME, so an empty answer is the honest one. + await api().get('/api/v1/branding/logo?variant=dark').expect(404); + }); + + it('rejects an SVG with its own message, not a generic one', async () => { + const res = await api() + .post('/api/v1/admin/branding/logo?variant=light') + .set('Cookie', adminCookie) + .attach('file', Buffer.from(''), 'x.png') + .expect(400); + expect(res.body.code).toBe('branding_svg_rejected'); + }); + + it('rejects bytes that are not a PNG at all', async () => { + const res = await api() + .post('/api/v1/admin/branding/logo?variant=light') + .set('Cookie', adminCookie) + .attach('file', Buffer.from('GIF89a and then some'), 'x.png') + .expect(400); + expect(res.body.code).toBe('branding_not_a_png'); + }); + + it('rejects a logo larger than the maximum edge', async () => { + const res = await api() + .post('/api/v1/admin/branding/logo?variant=light') + .set('Cookie', adminCookie) + .attach('file', await png(600), 'x.png') + .expect(400); + expect(res.body.code).toBe('branding_image_too_large'); + }); + + it('takes both favicon sizes together and serves each back', async () => { + await api() + .post('/api/v1/admin/branding/favicon') + .set('Cookie', adminCookie) + .attach('png-32', await png(32), 'f32.png') + .attach('png-180', await png(180), 'f180.png') + .expect(201); + + for (const size of [32, 180]) { + const res = await api().get(`/api/v1/branding/favicon?size=${size}`).expect(200); + expect(res.body.length).toBe((await png(size)).length); + } + }); + + it('refuses a favicon whose bytes do not match the size they claim', async () => { + const res = await api() + .post('/api/v1/admin/branding/favicon') + .set('Cookie', adminCookie) + .attach('png-32', await png(64), 'f32.png') + .attach('png-180', await png(180), 'f180.png') + .expect(400); + expect(res.body.code).toBe('branding_favicon_not_square'); + }); + + it('clears an asset and falls back again', async () => { + await api().delete('/api/v1/admin/branding/favicon').set('Cookie', adminCookie).expect(200); + const view = await api().get('/api/v1/branding').expect(200); + expect(view.body.favicon).toBeNull(); + // Back to the shipped default rather than a 404. + await api().get('/api/v1/branding/favicon').expect(200); + + await api() + .delete('/api/v1/admin/branding/logo?variant=light') + .set('Cookie', adminCookie) + .expect(200); + await api().get('/api/v1/branding/logo?variant=light').expect(404); + }); + + it('keeps management away from a non-admin, but not reading', async () => { + await api() + .post('/api/v1/admin/branding/logo?variant=light') + .set('Cookie', plainCookie) + .attach('file', await png(32), 'x.png') + .expect(403); + await api().delete('/api/v1/admin/branding/favicon').set('Cookie', plainCookie).expect(403); + await api().get('/api/v1/branding').set('Cookie', plainCookie).expect(200); + }); + + it('audits every branding change with scope, asset and direction', async () => { + await api() + .post('/api/v1/admin/branding/logo?variant=dark') + .set('Cookie', adminCookie) + .attach('file', await png(48), 'logo.png') + .expect(201); + const entry = await prisma.auditEntry.findFirst({ + where: { action: 'branding.changed', targetId: 'instance.logoDark' }, + orderBy: { at: 'desc' }, + }); + expect(entry).not.toBeNull(); + expect(entry!.details).toMatchObject({ scope: 'instance', asset: 'logoDark', change: 'set' }); + }); + + it('refuses to write branding metadata through the settings endpoint', async () => { + // The metadata describes bytes on disk; hand-writing it would claim an + // asset that is not there, so the settings PATCH does not accept it. + const res = await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'instance.logo': { hash: 'deadbeefdeadbeef', width: 10, height: 10 } }) + .expect(400); + expect(res.body.code).toBe('bad_request'); + }); +}); 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/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index edf01f0..33ed550 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -1,5 +1,10 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; -import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared'; +import { + DEFAULT_ATTACHMENT_EXTENSIONS, + VS_NFD_PROFILE, + brandingAssetSchema, + isVsNfdCompliant, +} from '@dorfteich/shared'; import { Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { z } from 'zod'; @@ -18,6 +23,16 @@ export const INSTANCE_SETTINGS = { 'auth.registrationMode': z.enum(['open', 'closed']).default('open'), 'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'), 'instance.defaultLocale': z.enum(['de', 'en']).default('en'), + // Branding assets (issue #306). Metadata only — the PNG bytes live under + // BRANDING_DIR and travel in the restore set; `hash` goes into the serving + // URL so a replaced asset is picked up without cache trouble. Null = not + // uploaded: the instance name renders as text, the favicon falls back to + // the shipped default. `logoDark` is optional by design — without it the + // LIGHT logo is used in both themes, because showing the operator's own + // asset unchanged beats substituting one they did not choose (#307). + 'instance.logo': brandingAssetSchema.nullable().default(null), + 'instance.logoDark': brandingAssetSchema.nullable().default(null), + 'instance.favicon': brandingAssetSchema.nullable().default(null), // Instance-default quotas (ADR 0011); per-user/per-pond overrides live // in quota_overrides and win over these (QuotaService, issue #22). 'quota.editorsPerPond': z.number().int().min(0).default(5), diff --git a/apps/backup/Dockerfile b/apps/backup/Dockerfile index 7b9f638..bbaa995 100644 --- a/apps/backup/Dockerfile +++ b/apps/backup/Dockerfile @@ -20,7 +20,7 @@ ENV NODE_ENV=production APP_VERSION=${APP_VERSION} \ # Baked-in volume paths (self-sufficient without compose env, like the # api image's PLUGINS_DIR — issue #71's lesson). BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \ - CUSTOM_FONTS_DIR=/data/fonts \ + CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding \ SECRETS_FILE=/data/secrets/secrets.env # pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the # volume archives, tzdata so BACKUP_TIME honors a configured TZ, and diff --git a/apps/backup/src/data-dirs.test.ts b/apps/backup/src/data-dirs.test.ts new file mode 100644 index 0000000..18a2efa --- /dev/null +++ b/apps/backup/src/data-dirs.test.ts @@ -0,0 +1,30 @@ +import { backupEnvSchema } from '@dorfteich/shared'; +import { describe, expect, it } from 'vitest'; + +import { dataDirs } from './data-dirs.js'; + +/** + * The fence against the failure #303 hit and #306 could repeat: a new data + * directory gets its env entry but not its line here, and the nightly archive + * skips it WORDLESSLY (`createArchive` tolerates missing directories on + * purpose). Nobody notices until a restore comes up short. + * + * Every `*_DIR` the backup sidecar knows must therefore travel in the archive. + * `BACKUPS_DIR` is the exception by definition — it is where the archive is + * written, not something archived into it. + */ +const NOT_DATA = new Set(['BACKUPS_DIR']); + +describe('data directories (issues #303/#306)', () => { + it('archives every *_DIR the backup env declares', () => { + const env = backupEnvSchema.parse({ DATABASE_URL: 'postgresql://x/y' }); + const values = env as unknown as Record; + const declared = Object.keys(env).filter((key) => key.endsWith('_DIR') && !NOT_DATA.has(key)); + const archived = dataDirs(env); + + expect(declared.length).toBeGreaterThan(0); + for (const key of declared) { + expect(archived, `${key} is missing from dataDirs()`).toContain(values[key]); + } + }); +}); diff --git a/apps/backup/src/data-dirs.ts b/apps/backup/src/data-dirs.ts index 0e6a258..3d0f30a 100644 --- a/apps/backup/src/data-dirs.ts +++ b/apps/backup/src/data-dirs.ts @@ -12,7 +12,7 @@ import type { BackupEnv } from '@dorfteich/shared'; * root from that and throws otherwise. */ export function dataDirs( - env: Pick, + env: Pick, ): string[] { - return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR]; + return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR, env.BRANDING_DIR]; } diff --git a/apps/backup/src/perform-restore.ts b/apps/backup/src/perform-restore.ts index 2108218..59f8dfe 100644 --- a/apps/backup/src/perform-restore.ts +++ b/apps/backup/src/perform-restore.ts @@ -19,7 +19,12 @@ import type { RemoteLogger } from './remote.js'; export async function performRestore( env: Pick< BackupEnv, - 'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR' + | 'BACKUPS_DIR' + | 'DATABASE_URL' + | 'UPLOADS_DIR' + | 'PLUGINS_DIR' + | 'CUSTOM_FONTS_DIR' + | 'BRANDING_DIR' >, backupId: string, log: RemoteLogger, diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 2073085..af43106 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -110,6 +110,9 @@ for (const scheme of SCHEMES) { // Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung // liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert. await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor(); + // Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist + // erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl. + await page.locator('.branding .crop-field input[type="file"]').first().waitFor(); await expectClean(page, `/admin (${scheme})`); await context.close(); }); diff --git a/apps/web/index.html b/apps/web/index.html index 284207c..ee6e7d0 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -10,6 +10,13 @@ Dorfteich + + + diff --git a/apps/web/src/branding/BrandLogo.tsx b/apps/web/src/branding/BrandLogo.tsx new file mode 100644 index 0000000..45b04f8 --- /dev/null +++ b/apps/web/src/branding/BrandLogo.tsx @@ -0,0 +1,51 @@ +import { Link } from 'react-router-dom'; + +import { logoUrl, useBranding } from './use-branding'; + +/** + * The instance identity at the top of the sidebar (issue #306): the uploaded + * logo as a link home, or the instance name as text when nothing is uploaded. + * + * Its accessible name is the INSTANCE NAME, never "logo": for a screen reader + * this is the link home, and a link's name has to say where it goes. The + * images are therefore `alt=""` — the link is already named. + * + * Both variants are rendered and one is hidden by CSS (`:root[data-theme]`), + * not by JavaScript: `theme-init.js` resolves the theme before first paint, so + * the correct logo is the one painted rather than the one that appears after a + * flash. Without a dark variant the light one carries both themes — the + * operator's own asset, shown unchanged, rather than a substitute they did + * not choose (the rule #307 extends to ponds). + */ +export function BrandLogo(): React.JSX.Element | null { + const branding = useBranding(); + if (!branding) return null; + const { logo, logoDark, instanceName } = branding; + + return ( + + {logo ? ( + <> + + {logoDark && ( + + )} + + ) : ( + {instanceName} + )} + + ); +} diff --git a/apps/web/src/branding/CropField.tsx b/apps/web/src/branding/CropField.tsx new file mode 100644 index 0000000..85adfc2 --- /dev/null +++ b/apps/web/src/branding/CropField.tsx @@ -0,0 +1,173 @@ +import { BRANDING_SOURCE_TYPES } from '@dorfteich/shared'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Field } from '../components/forms'; +import { CropRect, clampCrop, drawCrop, initialCrop, loadImage, outputSize } from './crop'; + +/** + * Pick an image, crop it, see the result (issue #306). + * + * The crop is driven by NUMBER INPUTS, not by dragging. A drag-only cropper + * excludes keyboard and switch users outright, and a number input is + * arrow-key operable, screen-reader readable and announces its value without + * any custom aria plumbing — the accessible option is also the simpler one. + * The preview canvas is a picture of the result, never the control. + * + * The resulting pixel dimensions are stated in TEXT next to it, so the outcome + * does not depend on seeing the frame. + */ +export function CropField({ + idPrefix, + square, + maxEdge, + onChange, +}: { + idPrefix: string; + /** Favicons are square by construction; a logo keeps its own proportions. */ + square: boolean; + maxEdge: number; + /** Called with the rendering canvas whenever the crop changes, so the + * parent can encode PNGs from it on submit. Null = nothing selected. */ + onChange: (canvas: HTMLCanvasElement | null) => void; +}): React.JSX.Element { + const { t } = useTranslation('branding'); + const [image, setImage] = useState(null); + const [crop, setCrop] = useState(null); + const [error, setError] = useState(null); + const canvasRef = useRef(null); + + const out = image && crop ? outputSize(crop, maxEdge) : null; + + // Held in a ref so the redraw depends on the crop alone: callers pass an + // inline arrow, whose identity changes every render and would otherwise + // repaint the canvas on every keystroke in the surrounding form. + const notifyRef = useRef(onChange); + notifyRef.current = onChange; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !image || !crop) { + notifyRef.current(null); + return; + } + // Derived inside the effect: `outputSize` returns a fresh object every + // render, so as a dependency it would never compare equal. + drawCrop(image, crop, outputSize(crop, maxEdge), canvas); + notifyRef.current(canvas); + }, [image, crop, maxEdge]); + + async function choose(file: File | undefined): Promise { + setError(null); + if (!file) { + setImage(null); + setCrop(null); + return; + } + if (!(BRANDING_SOURCE_TYPES as readonly string[]).includes(file.type)) { + setImage(null); + setCrop(null); + // SVG is the one an operator is most likely to try, and it is refused + // on purpose (it can carry script) — say which types work instead. + setError(file.type === 'image/svg+xml' ? 'branding_svg_rejected' : 'branding_not_an_image'); + return; + } + try { + const loaded = await loadImage(file); + setImage(loaded); + setCrop(initialCrop(loaded, square)); + } catch { + setError('branding_not_an_image'); + } + } + + function update(patch: Partial): void { + if (!image || !crop) return; + const next = { ...crop, ...patch }; + // A square crop has one size, so width and height move together. + if (square && patch.width !== undefined) next.height = patch.width; + setCrop(clampCrop(next, image)); + } + + return ( +
+ + void choose(event.target.files?.[0])} + /> + + + {image && crop && out && ( + <> +
+ + update({ x: Number(event.target.value) })} + /> + + + update({ y: Number(event.target.value) })} + /> + + + update({ width: Number(event.target.value) })} + /> + + {!square && ( + + update({ height: Number(event.target.value) })} + /> + + )} + +
+ +
+ + {/* The outcome in words: the frame alone would leave a + keyboard-only or screen-reader user guessing. */} +

+ {t('crop.result', { + width: out.width, + height: out.height, + sourceWidth: image.width, + sourceHeight: image.height, + })} +

+
+ + )} +
+ ); +} diff --git a/apps/web/src/branding/crop.test.ts b/apps/web/src/branding/crop.test.ts new file mode 100644 index 0000000..32434b2 --- /dev/null +++ b/apps/web/src/branding/crop.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { clampCrop, initialCrop, outputSize } from './crop'; + +/** + * The crop arithmetic (issue #306). Pure functions on purpose: the canvas + * work is a thin shell around these, and getting the bounds wrong is what + * would let a number input produce a rectangle outside the image. + */ +describe('initialCrop', () => { + it('takes the whole image when the aspect is free', () => { + expect(initialCrop({ width: 900, height: 300 }, false)).toEqual({ + x: 0, + y: 0, + width: 900, + height: 300, + }); + }); + + it('centres the largest square that fits', () => { + expect(initialCrop({ width: 900, height: 300 }, true)).toEqual({ + x: 300, + y: 0, + width: 300, + height: 300, + }); + }); +}); + +describe('outputSize', () => { + it('scales the long edge down to the bound and keeps the ratio', () => { + expect(outputSize({ x: 0, y: 0, width: 900, height: 300 }, 512)).toEqual({ + width: 512, + height: 171, + }); + }); + + it('never scales UP — enlarging would only invent pixels', () => { + expect(outputSize({ x: 0, y: 0, width: 120, height: 40 }, 512)).toEqual({ + width: 120, + height: 40, + }); + }); +}); + +describe('clampCrop', () => { + const source = { width: 200, height: 100 }; + + it('keeps the rectangle inside the image', () => { + expect(clampCrop({ x: 190, y: 90, width: 50, height: 50 }, source)).toEqual({ + x: 150, + y: 50, + width: 50, + height: 50, + }); + }); + + it('never lets a size fall below one pixel or exceed the source', () => { + expect(clampCrop({ x: 0, y: 0, width: 0, height: 999 }, source)).toEqual({ + x: 0, + y: 0, + width: 1, + height: 100, + }); + }); + + it('accepts a negative offset by pulling it back to the edge', () => { + expect(clampCrop({ x: -30, y: -5, width: 20, height: 20 }, source)).toMatchObject({ + x: 0, + y: 0, + }); + }); +}); diff --git a/apps/web/src/branding/crop.ts b/apps/web/src/branding/crop.ts new file mode 100644 index 0000000..17221ff --- /dev/null +++ b/apps/web/src/branding/crop.ts @@ -0,0 +1,106 @@ +/** + * Client-side image preparation for branding uploads (issue #306). + * + * Cropping, scaling and the conversion to PNG happen here on a canvas; the + * api receives finished bytes and never decodes an image. That keeps a + * decoder away from attacker-supplied bytes and keeps `sharp` (and its + * platform binaries) out of the `--network none` offline build. + */ + +export interface CropRect { + x: number; + y: number; + width: number; + height: number; +} + +/** Reads a file into an `HTMLImageElement`, rejecting what the browser cannot + * decode — the first line of defence, before anything reaches the api. */ +export function loadImage(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('image_undecodable')); + }; + image.src = url; + }); +} + +/** The crop the editor starts with: the largest centred rectangle of the + * wanted aspect that fits the source. */ +export function initialCrop(source: { width: number; height: number }, square: boolean): CropRect { + if (!square) return { x: 0, y: 0, width: source.width, height: source.height }; + const size = Math.min(source.width, source.height); + return { + x: Math.round((source.width - size) / 2), + y: Math.round((source.height - size) / 2), + width: size, + height: size, + }; +} + +/** Output size for a crop: scaled down so the longest edge fits `maxEdge`, + * never scaled UP — enlarging would only invent pixels. */ +export function outputSize(crop: CropRect, maxEdge: number): { width: number; height: number } { + const longest = Math.max(crop.width, crop.height); + const factor = longest > maxEdge ? maxEdge / longest : 1; + return { + width: Math.max(1, Math.round(crop.width * factor)), + height: Math.max(1, Math.round(crop.height * factor)), + }; +} + +/** Keeps a crop inside the source and above 1px, so number inputs cannot + * produce a rectangle the canvas would refuse. */ +export function clampCrop(crop: CropRect, source: { width: number; height: number }): CropRect { + const width = Math.min(Math.max(1, Math.round(crop.width)), source.width); + const height = Math.min(Math.max(1, Math.round(crop.height)), source.height); + return { + width, + height, + x: Math.min(Math.max(0, Math.round(crop.x)), source.width - width), + y: Math.min(Math.max(0, Math.round(crop.y)), source.height - height), + }; +} + +/** Renders the crop into a canvas at the given output size. */ +export function drawCrop( + image: CanvasImageSource, + crop: CropRect, + out: { width: number; height: number }, + canvas: HTMLCanvasElement, +): void { + canvas.width = out.width; + canvas.height = out.height; + const context = canvas.getContext('2d'); + if (!context) return; + context.clearRect(0, 0, out.width, out.height); + context.imageSmoothingQuality = 'high'; + context.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, out.width, out.height); +} + +/** + * The canvas contents as PNG bytes. + * + * PNG regardless of the source format — which is why the form states that a + * JPEG source cannot gain transparency: the alpha channel exists in the + * output, but every pixel of a JPEG is opaque, so the background stays. + * Conversion cannot invent what was never in the file. + */ +export function canvasToPngFile(canvas: HTMLCanvasElement, name: string): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('canvas_encode_failed')); + return; + } + resolve(new File([blob], name, { type: 'image/png' })); + }, 'image/png'); + }); +} diff --git a/apps/web/src/branding/use-branding.ts b/apps/web/src/branding/use-branding.ts new file mode 100644 index 0000000..4d0ceb8 --- /dev/null +++ b/apps/web/src/branding/use-branding.ts @@ -0,0 +1,34 @@ +import { BrandingView } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiGet } from '../lib/api'; + +export const BRANDING_KEY = ['branding']; + +/** + * The instance branding in force (issue #306). + * + * Public, so the login screen carries it too — an operator's logo IS visible + * to anonymous visitors, which the admin screen says out loud. + */ +export function useBranding(): BrandingView | undefined { + return useQuery({ + queryKey: BRANDING_KEY, + queryFn: () => apiGet('/branding'), + // Branding changes are rare and the manager invalidates the key itself. + staleTime: 5 * 60 * 1000, + }).data; +} + +/** URL of a logo variant, with the content hash so a replaced logo is never + * served from cache. */ +export function logoUrl(variant: 'light' | 'dark', hash: string): string { + return `/api/v1/branding/logo?variant=${variant}&v=${hash}`; +} + +export function useInvalidateBranding(): () => Promise { + const queryClient = useQueryClient(); + return async () => { + await queryClient.invalidateQueries({ queryKey: BRANDING_KEY }); + }; +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index c35905a..5e1449f 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -1,5 +1,6 @@ import deAccess from '@dorfteich/shared/i18n/de/access.json'; import deAuth from '@dorfteich/shared/i18n/de/auth.json'; +import deBranding from '@dorfteich/shared/i18n/de/branding.json'; import deComments from '@dorfteich/shared/i18n/de/comments.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.json'; @@ -28,6 +29,7 @@ import deWatches from '@dorfteich/shared/i18n/de/watches.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json'; +import enBranding from '@dorfteich/shared/i18n/en/branding.json'; import enComments from '@dorfteich/shared/i18n/en/comments.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json'; @@ -79,6 +81,7 @@ void i18n editor: enEditor, export: enExport, files: enFiles, + branding: enBranding, font: enFont, graph: enGraph, import: enImport, @@ -109,6 +112,7 @@ void i18n editor: deEditor, export: deExport, files: deFiles, + branding: deBranding, font: deFont, graph: deGraph, import: deImport, diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index 9034662..76ecb4a 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -29,6 +29,7 @@ import { usePageFavorites } from '../favorites/use-favorites'; import { ImportControl } from '../import/ImportControl'; import { LabelChips } from '../labels/LabelChips'; import { usePondLabels } from '../labels/use-pond-labels'; +import { BrandLogo } from '../branding/BrandLogo'; import { apiGet, apiPatch } from '../lib/api'; import { usePersistentState } from '../lib/use-persistent-state'; import { NewPageForm } from './NewPageForm'; @@ -71,6 +72,11 @@ export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element aria-label={t('layout.sidebar.label')} > {resizer} + {/* The instance identity sits ABOVE the pond section, not inside it: + the sidebar has no pond header outside a pond, and the logo is the + link home — it must not disappear on /admin or the start page + (issue #306). The pond name below stays the heading. */} + {!pond.data ? (

{t('layout.sidebar.placeholder')}

) : ( diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index 558103d..214805b 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; +import { useBranding } from '../branding/use-branding'; import { IconButton, IconLink } from '../components/IconButton'; import { apiGet } from '../lib/api'; import { isTypingTarget } from '../lib/keyboard'; @@ -67,6 +68,9 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac enabled: Boolean(user && pondSlug), }); const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId); + // Instance identity (issue #306) — shared query, also read by the sidebar + // logo and reachable without a session (the login screen carries it). + const branding = useBranding(); async function handleLogout(): Promise { setMenuOpen(false); @@ -101,8 +105,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac > + {/* The operator's instance name, not the product name (issue #306): + an operator who uploaded their own logo does not expect "Dorfteich" + to stay in the chrome. `instance.name` defaults to "Dorfteich", so + an untouched instance looks exactly as before. */} - Dorfteich + {branding?.instanceName ?? 'Dorfteich'} {user && } {isPondOwner && pondSlug && ( diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 206992f..cf4091c 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -9,6 +9,7 @@ import { Field, FormError, FormSuccess } from '../components/forms'; import { SettingsLayout } from '../components/SettingsLayout'; import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; import { apiGet, apiPatch } from '../lib/api'; +import { BrandingManager } from './BrandingManager'; import { CustomFontManager } from './CustomFontManager'; import { PluginManager } from './PluginManager'; import { QuotaManager } from './QuotaManager'; @@ -181,6 +182,7 @@ export function AdminSettingsPage(): React.JSX.Element { + diff --git a/apps/web/src/pages/BrandingManager.tsx b/apps/web/src/pages/BrandingManager.tsx new file mode 100644 index 0000000..bf0ec17 --- /dev/null +++ b/apps/web/src/pages/BrandingManager.tsx @@ -0,0 +1,228 @@ +import { BrandingView, LogoVariant, MAX_LOGO_EDGE } from '@dorfteich/shared'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CropField } from '../branding/CropField'; +import { canvasToPngFile, drawCrop } from '../branding/crop'; +import { logoUrl, useInvalidateBranding } from '../branding/use-branding'; +import { FormError, FormSuccess } from '../components/forms'; +import { apiDelete, apiGet, apiPostForm } from '../lib/api'; + +/** The favicon is uploaded as the pair the browser rendered — see the api's + * reasoning: it cannot resize, and one source must not become two icons. */ +const FAVICON_SIZES = [32, 180] as const; + +/** + * Site-Admin branding management (issue #306): the instance logo (light and + * an optional dark variant) and the favicon. + * + * The images are prepared in the browser — see `branding/crop.ts` for why the + * api never decodes one. + */ +export function BrandingManager(): React.JSX.Element { + const { t } = useTranslation('branding'); + const invalidate = useInvalidateBranding(); + + const branding = useQuery({ + queryKey: ['admin', 'branding'], + queryFn: () => apiGet('/branding'), + }); + const view = branding.data; + + const refresh = async (): Promise => { + await branding.refetch(); + await invalidate(); + }; + + return ( +
+

{t('admin.title')}

+

{t('admin.intro')}

+ {/* An operator may not expect their logo to be readable by anyone who + opens the login page — so say it, rather than let them find out. */} +

{t('admin.publicNote')}

+ + + + {view?.logo && !view.logoDark && ( + // Advisory, never blocking (issue #306): it names the consequence and + // the operator may decide their logo works on both surfaces. +

+ + {t('admin.darkMissing')} +

+ )} + + +
+ ); +} + +function LogoSection({ + variant, + asset, + onChanged, +}: { + variant: LogoVariant; + asset: { hash: string; width: number; height: number } | null; + onChanged: () => Promise; +}): React.JSX.Element { + const { t } = useTranslation('branding'); + const canvasRef = useRef(null); + const [done, setDone] = useState(false); + + const upload = useMutation({ + mutationFn: async () => { + const canvas = canvasRef.current; + if (!canvas) throw new Error('no image'); + const form = new FormData(); + form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`)); + return apiPostForm(`/admin/branding/logo?variant=${variant}`, form); + }, + onSuccess: async () => { + setDone(true); + await onChanged(); + }, + }); + + const remove = useMutation({ + mutationFn: () => apiDelete(`/admin/branding/logo?variant=${variant}`), + onSuccess: async () => { + setDone(false); + await onChanged(); + }, + }); + + return ( +
+

{t(`admin.logo.${variant}`)}

+

{t(`admin.logo.${variant}Hint`)}

+ + + + {asset ? ( +
+ {t('admin.logo.currentAlt')} +

{t('admin.logo.current', { width: asset.width, height: asset.height })}

+ +
+ ) : ( +

{t('admin.logo.none')}

+ )} + + { + canvasRef.current = canvas; + setDone(false); + }} + /> + +

{upload.isPending ? t('admin.uploading') : ''}

+
+ ); +} + +function FaviconSection({ + present, + onChanged, +}: { + present: boolean; + onChanged: () => Promise; +}): React.JSX.Element { + const { t } = useTranslation('branding'); + const canvasRef = useRef(null); + const [done, setDone] = useState(false); + + const upload = useMutation({ + mutationFn: async () => { + const source = canvasRef.current; + if (!source) throw new Error('no image'); + const form = new FormData(); + // One source, both sizes, rendered from the same crop — the tab icon + // and the home-screen icon can then never disagree. + for (const size of FAVICON_SIZES) { + const scratch = document.createElement('canvas'); + drawCrop( + source, + { x: 0, y: 0, width: source.width, height: source.height }, + { width: size, height: size }, + scratch, + ); + form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`)); + } + return apiPostForm('/admin/branding/favicon', form); + }, + onSuccess: async () => { + setDone(true); + await onChanged(); + }, + }); + + const remove = useMutation({ + mutationFn: () => apiDelete('/admin/branding/favicon'), + onSuccess: async () => { + setDone(false); + await onChanged(); + }, + }); + + return ( +
+

{t('admin.favicon.title')}

+

{t('admin.favicon.hint')}

+ + +

{present ? t('admin.favicon.present') : t('admin.favicon.default')}

+ {present && ( + + )} + { + canvasRef.current = canvas; + setDone(false); + }} + /> + +

{upload.isPending ? t('admin.uploading') : ''}

+
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 8a53003..d181f32 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -4178,3 +4178,106 @@ ul[data-type='task_list'] li p:last-of-type { .custom-fonts input[type='file'] { max-width: 100%; } +/* Instance branding (issue #306). The logo sits above the pond name in the + sidebar; the two variants are both rendered and one is hidden here rather + than in JavaScript, so the right one is the one PAINTED — theme-init.js has + already resolved data-theme when this applies. */ +.brand-logo { + display: block; + padding: var(--space-2) 0; + color: inherit; + text-decoration: none; +} + +.brand-logo__img { + display: block; + max-width: 100%; + height: auto; + /* A tall logo must not push the pond name out of view. */ + max-height: 3rem; +} + +.brand-logo__name { + font-weight: var(--font-weight-heading); +} + +/* Without a dark variant the light logo carries both themes (#306/#307: + variants are never mixed across levels). */ +.brand-logo__img--dark { + display: none; +} + +:root[data-theme='dark'] .brand-logo__img--light:not(.brand-logo__img--both) { + display: none; +} + +:root[data-theme='dark'] .brand-logo__img--dark { + display: block; +} + +.branding__slot { + border-top: 1px solid var(--color-border); + margin-top: var(--space-4); + padding-top: var(--space-3); + min-width: 0; +} + +.branding__preview { + max-width: 100%; + max-height: 6rem; + height: auto; +} + +/* The dark logo is meant for a dark surface — previewing it on the light + admin background would misrepresent it. */ +.branding__preview--dark { + background: #10161d; + padding: var(--space-2); + border-radius: 6px; +} + +.branding__warning { + border: 1px solid var(--color-border); + border-radius: 6px; + padding: var(--space-2) var(--space-3); +} + +.crop-field { + min-width: 0; +} + +.crop-field input[type='file'] { + max-width: 100%; +} + +.crop-field__controls { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: flex-end; +} + +.crop-field__controls .field { + max-width: 12rem; +} + +.crop-field__canvas { + max-width: 100%; + height: auto; + border: 1px solid var(--color-border); + border-radius: 6px; + /* A transparent PNG on a transparent page shows nothing — the checkerboard + is how an operator sees that the background really is transparent. */ + background-color: var(--color-surface); + background-image: + linear-gradient(45deg, var(--color-surface-muted) 25%, transparent 25%), + linear-gradient(-45deg, var(--color-surface-muted) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--color-surface-muted) 75%), + linear-gradient(-45deg, transparent 75%, var(--color-surface-muted) 75%); + background-size: 16px 16px; + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0; +} diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 27547a4..76fb44a 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -91,6 +91,9 @@ services: # uploads/plugins so all three travel in one restore set — NOT inside # the image-baked font catalog, which a deploy would overwrite. CUSTOM_FONTS_DIR: /data/fonts + # Instance and pond branding assets (issues #306/#307) — same reasoning + # as the fonts above: operator data, so its own volume in the restore set. + BRANDING_DIR: /data/branding # Read-only view of the backup sidecar's volume — the api only consumes # its status.json (readyz freshness #85, admin backup card #86). BACKUPS_DIR: /data/backups @@ -105,6 +108,7 @@ services: - uploads:/data/uploads - plugins:/data/plugins - customfonts:/data/fonts + - branding:/data/branding - secrets:/data/secrets - backups:/data/backups:ro depends_on: @@ -194,6 +198,7 @@ services: - uploads:/data/uploads - plugins:/data/plugins - customfonts:/data/fonts + - branding:/data/branding - secrets:/data/secrets:ro - backups:/backups depends_on: @@ -280,6 +285,7 @@ volumes: uploads: plugins: customfonts: + branding: secrets: backups: # Only used by the optional `caddy` profile (certificates + state). diff --git a/docs/architecture/audit-events.md b/docs/architecture/audit-events.md index 3565b5c..622e670 100644 --- a/docs/architecture/audit-events.md +++ b/docs/architecture/audit-events.md @@ -1,6 +1,7 @@ # Audit event catalogue -**Catalogue version 1.6 (2026-08-01; 1.6 adds `font.uploaded` and +**Catalogue version 1.7 (2026-08-01; 1.7 adds `branding.changed`, +issue #306; 1.6 added `font.uploaded` and `font.deleted`, issue #303; 1.5 added `plugin.rejected`, issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added `read_trail.pruned`, issue #224; 1.1 added `page.classification_*`, @@ -87,17 +88,18 @@ failure), `warning` = feeds detection (suspicious or destructive), ### Administration (`user.*`, `quota.*`, `settings.*`, `job.*`) -| Id | Trigger | Severity | Actor | Target | Fields | -| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | -------------------- | -| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) | -| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) | -| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — | -| `user.pseudonymized` | GDPR pseudonymization of authorship completed | notice | `null` (system) | `user` | — | -| `user.verification_resent` | Site Admin re-sends the verification mail | info | the admin | `user` | — | -| `quota.override_set` | Per-user/per-pond quota override set | notice | the admin | `user` \| `pond` | `quotaKey`, `value` | -| `quota.override_cleared` | Quota override removed | notice | the admin | `user` \| `pond` | `quotaKey` | -| `settings.changed` | Instance setting written | notice | the admin | `setting` (key) | — | -| `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` | +| Id | Trigger | Severity | Actor | Target | Fields | +| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- | +| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) | +| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) | +| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — | +| `user.pseudonymized` | GDPR pseudonymization of authorship completed | notice | `null` (system) | `user` | — | +| `user.verification_resent` | Site Admin re-sends the verification mail | info | the admin | `user` | — | +| `quota.override_set` | Per-user/per-pond quota override set | notice | the admin | `user` \| `pond` | `quotaKey`, `value` | +| `quota.override_cleared` | Quota override removed | notice | the admin | `user` \| `pond` | `quotaKey` | +| `settings.changed` | Instance setting written | notice | the admin | `setting` (key) | — | +| `branding.changed` | Logo or favicon uploaded or removed (#306/#307) | notice | the admin | `setting` (key) | `scope` (`instance`/`pond`), `asset` (`logo`/`logoDark`/`favicon`), `change` (`set`/`cleared`), `pondId` (pond scope) | +| `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` | ### Classification (`page.classification_*`, ADR 0022, issue #205) diff --git a/docs/architecture/security.md b/docs/architecture/security.md index db71f61..a10e770 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -139,6 +139,21 @@ or sloppy plugin authors, compromised dependencies. header; the path is validated against the file's own slug prefix, so it cannot reach another family's directory. Uploads and deletions are audited (`font.uploaded`, `font.deleted`). +- **Branding upload (issue #306)**: Site Admins upload an instance logo and + favicon; the pond-level override (#307) puts the same surface in the hands + of ordinary Pond Admins, so the rules are identical at both levels. **SVG is + refused** — it can carry script, and serving it from our own origin would be + a cross-site-scripting vector. Cropping, scaling and the conversion to PNG + happen in the BROWSER on a canvas; the api validates the PNG signature, the + IHDR dimensions (fixed offsets — no decoding) and a size cap, then stores + the bytes. No image library runs in the api: it would put a decoder in front + of attacker-supplied bytes and would have to be carried through the + `--network none` offline build. Assets are served from + `/api/v1/branding/…` with a pinned `image/png` content type under the + instance-wide `nosniff` header. The serving routes are **unauthenticated by + design** — the login screen carries the branding and the browser fetches the + favicon before anyone signs in; the admin UI states this. Changes are + audited (`branding.changed`). - App CSP (strict): `default-src 'self'`; `font-src 'self'` (ADR 0016); no third-party origins at all — the GDPR posture is "zero external requests". Operator-uploaded fonts are served from the instance itself diff --git a/packages/shared/i18n/de/branding.json b/packages/shared/i18n/de/branding.json new file mode 100644 index 0000000..f285c88 --- /dev/null +++ b/packages/shared/i18n/de/branding.json @@ -0,0 +1,41 @@ +{ + "admin": { + "title": "Erscheinungsbild der Instanz", + "intro": "Lade ein Logo und ein Favicon hoch. Das Logo steht in der Seitenleiste oben und verlinkt auf die Startseite; das Favicon zeigt der Browser im Tab. Ohne Logo erscheint dort der Name der Instanz als Text.", + "publicNote": "Beides ist ohne Anmeldung sichtbar: der Anmeldebildschirm trägt das Logo, und das Favicon lädt der Browser, bevor sich jemand anmeldet.", + "darkMissing": "Für den Dunkelmodus ist kein eigenes Logo hinterlegt. Dann wird dort das helle Logo verwendet — auf dunklem Grund kann das schlecht aussehen. Das ist ein Hinweis, keine Sperre.", + "uploading": "Das Bild wird hochgeladen …", + "logo": { + "light": "Logo (Hellmodus)", + "lightHint": "Empfohlen: PNG mit transparentem Hintergrund. Wird auch im Dunkelmodus verwendet, solange dort kein eigenes Logo hinterlegt ist.", + "dark": "Logo (Dunkelmodus, optional)", + "darkHint": "Nur nötig, wenn das helle Logo auf dunklem Grund nicht funktioniert.", + "current": "Hinterlegt: {{width}} × {{height}} px.", + "currentAlt": "Vorschau des hinterlegten Logos", + "none": "Es ist kein Logo hinterlegt.", + "remove": "Logo entfernen", + "submit": "Logo speichern", + "saved": "Das Logo wurde gespeichert." + }, + "favicon": { + "title": "Favicon", + "hint": "Ein quadratischer Ausschnitt; daraus entstehen die Größen 32 × 32 px (Browser-Tab) und 180 × 180 px (Startbildschirm).", + "present": "Ein eigenes Favicon ist hinterlegt.", + "default": "Es ist kein eigenes Favicon hinterlegt — ausgeliefert wird das mitgelieferte Standard-Favicon.", + "remove": "Favicon entfernen", + "submit": "Favicon speichern", + "saved": "Das Favicon wurde gespeichert." + } + }, + "crop": { + "file": "Bilddatei", + "fileHint": "PNG, JPEG oder WebP. SVG wird nicht angenommen, weil darin Skripte stecken können. Ausgeliefert wird immer PNG — Achtung: ein JPEG hat keinen Transparenzkanal, aus einem JPEG entsteht also ein PNG mit deckendem Hintergrund. Zuschneiden und Umwandeln geht, Transparenz lässt sich nicht nachträglich erzeugen.", + "x": "Ausschnitt von links (px)", + "y": "Ausschnitt von oben (px)", + "width": "Breite des Ausschnitts (px)", + "height": "Höhe des Ausschnitts (px)", + "size": "Kantenlänge des Ausschnitts (px)", + "reset": "Ausschnitt zurücksetzen", + "result": "Ergebnis: {{width}} × {{height}} px (Ausgangsbild {{sourceWidth}} × {{sourceHeight}} px)." + } +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 8b0f8b0..d367833 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -127,5 +127,13 @@ "font_weight_exists": "Dieser Schnitt existiert für diese Schrift bereits.", "font_weight_invalid": "Dieser Schnitt ist nicht zulässig.", "font_one_weight_expected": "Es lässt sich nur ein Schnitt auf einmal ergänzen.", - "font_unexpected_field": "Die Anfrage enthält ein unerwartetes Feld." + "font_unexpected_field": "Die Anfrage enthält ein unerwartetes Feld.", + "branding_file_empty": "Die Bilddatei ist leer.", + "branding_file_too_large": "Die Bilddatei ist zu groß.", + "branding_file_missing": "Es wurde keine Bilddatei übermittelt.", + "branding_not_a_png": "Es konnte kein PNG erzeugt werden — bitte eine andere Bilddatei wählen.", + "branding_not_an_image": "Diese Datei ist kein unterstütztes Bild (PNG, JPEG oder WebP).", + "branding_svg_rejected": "SVG wird nicht angenommen: eine SVG-Datei kann Skripte enthalten. Bitte PNG, JPEG oder WebP verwenden.", + "branding_image_too_large": "Das Bild ist zu groß — bitte den Ausschnitt verkleinern.", + "branding_favicon_not_square": "Das Favicon muss quadratisch sein." } diff --git a/packages/shared/i18n/en/branding.json b/packages/shared/i18n/en/branding.json new file mode 100644 index 0000000..c90d385 --- /dev/null +++ b/packages/shared/i18n/en/branding.json @@ -0,0 +1,41 @@ +{ + "admin": { + "title": "Instance appearance", + "intro": "Upload a logo and a favicon. The logo sits at the top of the sidebar and links to the start page; the favicon is what the browser shows in the tab. Without a logo the instance name is rendered there as text.", + "publicNote": "Both are visible without signing in: the login screen carries the logo, and the browser fetches the favicon before anyone signs in.", + "darkMissing": "No separate dark-mode logo is set. The light logo is then used there too — which can look wrong on a dark surface. This is advice, not a block.", + "uploading": "Uploading the image …", + "logo": { + "light": "Logo (light mode)", + "lightHint": "Recommended: PNG with a transparent background. Also used in dark mode as long as no separate logo is set there.", + "dark": "Logo (dark mode, optional)", + "darkHint": "Only needed when the light logo does not work on a dark surface.", + "current": "Stored: {{width}} × {{height}} px.", + "currentAlt": "Preview of the stored logo", + "none": "No logo is stored.", + "remove": "Remove the logo", + "submit": "Save the logo", + "saved": "The logo was saved." + }, + "favicon": { + "title": "Favicon", + "hint": "A square crop; it produces the 32 × 32 px (browser tab) and 180 × 180 px (home screen) sizes.", + "present": "A custom favicon is stored.", + "default": "No custom favicon is stored — the shipped default is served.", + "remove": "Remove the favicon", + "submit": "Save the favicon", + "saved": "The favicon was saved." + } + }, + "crop": { + "file": "Image file", + "fileHint": "PNG, JPEG or WebP. SVG is not accepted because it can carry script. The result is always PNG — note that a JPEG has no alpha channel, so converting one produces a PNG with an opaque background. Cropping and conversion are offered; transparency cannot be invented.", + "x": "Crop from the left (px)", + "y": "Crop from the top (px)", + "width": "Crop width (px)", + "height": "Crop height (px)", + "size": "Crop edge length (px)", + "reset": "Reset the crop", + "result": "Result: {{width}} × {{height}} px (source image {{sourceWidth}} × {{sourceHeight}} px)." + } +} diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index fb67833..0c900a3 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -127,5 +127,13 @@ "font_weight_exists": "This weight already exists for this font.", "font_weight_invalid": "This weight is not allowed.", "font_one_weight_expected": "Only one weight can be added at a time.", - "font_unexpected_field": "The request contains an unexpected field." + "font_unexpected_field": "The request contains an unexpected field.", + "branding_file_empty": "The image file is empty.", + "branding_file_too_large": "The image file is too large.", + "branding_file_missing": "No image file was submitted.", + "branding_not_a_png": "No PNG could be produced — please choose a different image file.", + "branding_not_an_image": "This file is not a supported image (PNG, JPEG or WebP).", + "branding_svg_rejected": "SVG is not accepted: an SVG file can carry script. Please use PNG, JPEG or WebP.", + "branding_image_too_large": "The image is too large — please reduce the crop.", + "branding_favicon_not_square": "The favicon must be square." } 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; +} diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 79a6cc9..50c5bb7 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -127,6 +127,14 @@ export const apiEnvSchema = z.object({ * Layout mirrors the catalog: `//-.woff2`. */ CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'), + /** + * Directory of the operator's branding assets — instance logo and favicon + * (issue #306), pond overrides (issue #307). Like the uploaded fonts these + * are data, not image content: a sibling of the uploads and plugins + * directories, registered in `apps/backup/src/data-dirs.ts` so a restore + * puts the operator's identity back with everything else. + */ + BRANDING_DIR: z.string().min(1).default('./data/branding'), /** * Directory holding installed plugin packages (ADR 0008, issue #71). Layout * `///…` for unpacked bundles the sandbox iframe @@ -263,6 +271,7 @@ export const backupEnvSchema = z.object({ UPLOADS_DIR: z.string().min(1).default('./data/uploads'), PLUGINS_DIR: z.string().min(1).default('./data/plugins'), CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'), + BRANDING_DIR: z.string().min(1).default('./data/branding'), /** Daily run time as HH:MM, interpreted in the container's TZ. */ BACKUP_TIME: z .string() diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6bc9efa..8f5be12 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,7 @@ export * from './auth'; export * from './backup-set'; export * from './backup-status'; export * from './backup-target-policy'; +export * from './branding'; export * from './collab-token'; export * from './comments'; export * from './editor-schema';