#306: instance branding — logo and favicon, cropped in the browser
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m40s
CI / Auth e2e pack (pull_request) Failing after 3m12s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 4m5s

An instance had no way to look like itself: the top bar said "Dorfteich"
whatever the operator called their instance, `instance.name` was never
rendered in the running app at all, and there was no favicon anywhere —
`index.html` had no `<link rel="icon">` and `public/` held only fonts and
theme-init.js.

Where the line is drawn, and why:

- **The api never decodes an image.** Cropping, scaling and the conversion
  to PNG happen on a canvas in the browser; the api checks the PNG
  signature, reads the IHDR dimensions at their fixed offsets and enforces
  the caps. An image library would put a decoder in front of
  attacker-supplied bytes AND would have to be carried through the
  `--network none` offline build. Reading two big-endian integers is not
  decoding.
- **SVG is refused**, with its own error message rather than a generic
  "not a PNG": it can carry script, and serving it from our own origin
  would be a cross-site-scripting vector. An operator who tried one should
  learn that it is deliberate.
- **The crop is driven by number inputs, not by dragging.** A drag-only
  cropper excludes keyboard and switch users outright; a number input is
  arrow-key operable and screen-reader readable without any custom aria.
  The resulting pixel size is stated in text, not only drawn as a frame.
- **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has
  already resolved `data-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 logo carries both themes — the operator's own
  asset shown unchanged beats one they did not choose (the rule #307
  extends to ponds). The settings screen warns; it never blocks.
- **The favicon link is static, its resource dynamic.** index.html stays a
  static file and the api answers with the uploaded icon or a shipped
  default — that route must never 404, or the browser keeps its generic
  icon for good. The default is generated by a script from Node's own zlib
  (`gen-default-favicon.mjs`), for the same offline-build reason.
- Both favicon sizes are uploaded together: one source, one crop, so the
  tab icon and the home-screen icon can never disagree.
- Branding is served WITHOUT a session, because the login screen carries it
  and the browser fetches the favicon before anyone signs in. The admin
  screen says so — an operator may not expect their logo to be public.
- The metadata is not writable through the settings endpoint: it describes
  bytes on disk, and hand-writing it would claim an asset that is not
  there.

`./data/branding` follows the three-step rule #303 paid for: env default +
`data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the
`mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and
closes the hole that made #303's variant invisible: the nightly archive
skips a missing directory WORDLESSLY, so the fence now demands that every
`*_DIR` the backup env declares actually travels in the archive. Verified
against the real defect — removing the line fails it by name.

Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start
so #307 is the same event with a different scope, not a second id.

Verified: api suite 103 files green (a lone `public-api` ECONNRESET under
local parallel load, green in isolation — the documented local flake);
branding suite 12 tests against a real directory; crop arithmetic unit
tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the
new section (overflow 0); and the whole flow walked in the browser: upload
→ crop 780×180 → stored as 512×118 → logo in the sidebar linking home with
the instance name as its accessible name → topbar wordmark following
`instance.name` → light logo still shown under `data-theme="dark"`.
This commit is contained in:
Claude Opus 5 2026-08-01 19:15:03 +02:00
parent 485c8fa538
commit e279d26a55
41 changed files with 1898 additions and 27 deletions

View File

@ -29,19 +29,19 @@ ARG APP_VERSION=0.0.0-dev
# Default the data dirs to the writable, node-owned locations created below, so # 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 # 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). # 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 WORKDIR /app
COPY --from=build --chown=node:node /out /app COPY --from=build --chown=node:node /out /app
# Generate the Prisma client for this image's platform. # Generate the Prisma client for this image's platform.
RUN node node_modules/prisma/build/index.js generate RUN node node_modules/prisma/build/index.js generate
# A fresh named volume mounted at /data/uploads, /data/plugins or /data/fonts # A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
# is created # or /data/branding is created
# root-owned; pre-creating them here (Docker copies an image directory's # 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 # 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 # 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 # node-owned keeps the shared `backups` volume writable for the backup
# sidecar even when the api container is the one that initializes it. # 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 USER node
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --retries=3 \

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

View File

@ -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 `<link rel="icon">` 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}`);
}

View File

@ -11,9 +11,17 @@ import {
} from '../settings/instance-settings.service'; } from '../settings/instance-settings.service';
import { SiteAdminGuard } from './site-admin.guard'; import { SiteAdminGuard } from './site-admin.guard';
// Lifecycle markers, not configuration: never editable through this // Lifecycle markers and file-backed metadata, not configuration: never
// endpoint (the setup lock must be irreversible, issue #80). // editable through this endpoint. The setup lock must be irreversible
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']); // (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<InstanceSettingKey> = new Set([
'setup.completedAt',
'instance.logo',
'instance.logoDark',
'instance.favicon',
]);
// Partial update: any subset of the known settings, each validated by // Partial update: any subset of the known settings, each validated by
// its own schema inside the service (double validation is fine — this // its own schema inside the service (double validation is fine — this

View File

@ -6,6 +6,7 @@ import { AdminModule } from './admin/admin.module';
import { AuditModule } from './audit/audit.module'; import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BackupModule } from './backup/backup.module'; import { BackupModule } from './backup/backup.module';
import { BrandingModule } from './branding/branding.module';
import { ApiExceptionFilter } from './common/api-exception.filter'; import { ApiExceptionFilter } from './common/api-exception.filter';
import { maskTokenParam } from './common/mask-token-param'; import { maskTokenParam } from './common/mask-token-param';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware'; import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
@ -82,6 +83,7 @@ import { VersionsModule } from './versions/versions.module';
PublicModule, PublicModule,
PublicApiModule, PublicApiModule,
McpModule, McpModule,
BrandingModule,
FontsModule, FontsModule,
ImportExportModule, ImportExportModule,
PluginsModule, PluginsModule,

View File

@ -45,6 +45,7 @@ export const AUDIT_EVENTS = {
'quota.override_set': { severity: 'notice' }, 'quota.override_set': { severity: 'notice' },
'read_trail.pruned': { severity: 'info' }, 'read_trail.pruned': { severity: 'info' },
'settings.changed': { severity: 'notice' }, 'settings.changed': { severity: 'notice' },
'branding.changed': { severity: 'notice' },
'font.uploaded': { severity: 'notice' }, 'font.uploaded': { severity: 'notice' },
'font.deleted': { severity: 'notice' }, 'font.deleted': { severity: 'notice' },
'setup.admin_created': { severity: 'notice' }, 'setup.admin_created': { severity: 'notice' },

View File

@ -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-<id>-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<void> {
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<Buffer | null> {
try {
return await readFile(this.pathFor(key));
} catch {
return null;
}
}
/** Idempotent: removing what is not there is success. */
async remove(key: string): Promise<void> {
await rm(this.pathFor(key), { force: true });
}
}

View File

@ -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<BrandingView> {
return this.branding.view();
}
@Public()
@Get('logo')
async logo(@Query('variant') variant: string | undefined, @Res() res: Response): Promise<void> {
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<void> {
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 `<link rel="icon">` 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<BrandingView> {
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<BrandingView> {
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<BrandingView> {
// 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<FaviconSize, Buffer>;
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<BrandingView> {
return this.branding.clearFavicon(request.user!);
}
}

View File

@ -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<Buffer> {
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<string> =>
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 `<link rel="icon">` 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('<?xml version="1.0"?><svg xmlns="..."><script/></svg>'), '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');
});
});

View File

@ -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 {}

View File

@ -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<BrandingView> {
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<BrandingView> {
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<BrandingView> {
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<FaviconSize, Buffer>): Promise<BrandingView> {
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<BrandingView> {
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<Buffer | null> {
return this.storage.read(BrandingService.logoKey(variant));
}
/**
* The favicon bytes: the uploaded one, else the shipped default. The
* `<link rel="icon">` 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<unknown> {
// `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 },
});
}
}

View File

@ -1,5 +1,10 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; 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 { Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod'; import { z } from 'zod';
@ -18,6 +23,16 @@ export const INSTANCE_SETTINGS = {
'auth.registrationMode': z.enum(['open', 'closed']).default('open'), 'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'), 'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
'instance.defaultLocale': z.enum(['de', 'en']).default('en'), '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 // Instance-default quotas (ADR 0011); per-user/per-pond overrides live
// in quota_overrides and win over these (QuotaService, issue #22). // in quota_overrides and win over these (QuotaService, issue #22).
'quota.editorsPerPond': z.number().int().min(0).default(5), 'quota.editorsPerPond': z.number().int().min(0).default(5),

View File

@ -20,7 +20,7 @@ ENV NODE_ENV=production APP_VERSION=${APP_VERSION} \
# Baked-in volume paths (self-sufficient without compose env, like the # Baked-in volume paths (self-sufficient without compose env, like the
# api image's PLUGINS_DIR — issue #71's lesson). # api image's PLUGINS_DIR — issue #71's lesson).
BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \ 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 SECRETS_FILE=/data/secrets/secrets.env
# pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the # 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 # volume archives, tzdata so BACKUP_TIME honors a configured TZ, and

View File

@ -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<string, unknown>;
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]);
}
});
});

View File

@ -12,7 +12,7 @@ import type { BackupEnv } from '@dorfteich/shared';
* root from that and throws otherwise. * root from that and throws otherwise.
*/ */
export function dataDirs( export function dataDirs(
env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'>, env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR' | 'BRANDING_DIR'>,
): string[] { ): 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];
} }

View File

@ -19,7 +19,12 @@ import type { RemoteLogger } from './remote.js';
export async function performRestore( export async function performRestore(
env: Pick< env: Pick<
BackupEnv, 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, backupId: string,
log: RemoteLogger, log: RemoteLogger,

View File

@ -99,6 +99,11 @@ for (const scheme of SCHEMES) {
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175). // Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
await page.locator('.user-manager__table .user-row').first().waitFor(); await page.locator('.user-manager__table .user-row').first().waitFor();
// Branding-Abschnitt mitgeladen (issue #306) — sonst liefe der Scan
// auch dann grün, wenn er gar nicht rendert. Der Zuschnitt selbst ist
// erst nach Dateiwahl sichtbar; geprüft wird hier die Dateiauswahl mit
// ihrem sichtbaren Label.
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
await expectClean(page, `/admin (${scheme})`); await expectClean(page, `/admin (${scheme})`);
await context.close(); await context.close();
}); });

View File

@ -10,6 +10,13 @@
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#2f6f4f" /> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#2f6f4f" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#10161d" /> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#10161d" />
<title>Dorfteich</title> <title>Dorfteich</title>
<!-- Static link, dynamic resource (issue #306): the api answers with the
operator's favicon or the shipped default, so this href never has to
change and index.html stays a static file. An attribute like `lang`
cannot be indirected this way — that is #179's problem, not this
one's. -->
<link rel="icon" type="image/png" href="/api/v1/branding/favicon" />
<link rel="apple-touch-icon" href="/api/v1/branding/favicon?size=180" />
<!-- Classic (non-module) script: executes during head parsing, before <!-- Classic (non-module) script: executes during head parsing, before
first paint and before the deferred module bundle. External file first paint and before the deferred module bundle. External file
because the prod CSP forbids inline scripts (issue #180). --> because the prod CSP forbids inline scripts (issue #180). -->

View File

@ -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 (
<Link to="/" className="brand-logo" aria-label={instanceName}>
{logo ? (
<>
<img
className={`brand-logo__img brand-logo__img--light${logoDark ? '' : ' brand-logo__img--both'}`}
src={logoUrl('light', logo.hash)}
width={logo.width}
height={logo.height}
alt=""
/>
{logoDark && (
<img
className="brand-logo__img brand-logo__img--dark"
src={logoUrl('dark', logoDark.hash)}
width={logoDark.width}
height={logoDark.height}
alt=""
/>
)}
</>
) : (
<span className="brand-logo__name">{instanceName}</span>
)}
</Link>
);
}

View File

@ -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<HTMLImageElement | null>(null);
const [crop, setCrop] = useState<CropRect | null>(null);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(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<void> {
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<CropRect>): 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 (
<div className="crop-field">
<Field label={t('crop.file')} hint={t('crop.fileHint')} error={error ?? undefined}>
<input
type="file"
accept={BRANDING_SOURCE_TYPES.join(',')}
onChange={(event) => void choose(event.target.files?.[0])}
/>
</Field>
{image && crop && out && (
<>
<div className="crop-field__controls">
<Field label={t('crop.x')}>
<input
type="number"
id={`${idPrefix}-x`}
min={0}
max={image.width - crop.width}
value={crop.x}
onChange={(event) => update({ x: Number(event.target.value) })}
/>
</Field>
<Field label={t('crop.y')}>
<input
type="number"
id={`${idPrefix}-y`}
min={0}
max={image.height - crop.height}
value={crop.y}
onChange={(event) => update({ y: Number(event.target.value) })}
/>
</Field>
<Field label={square ? t('crop.size') : t('crop.width')}>
<input
type="number"
id={`${idPrefix}-w`}
min={1}
max={square ? Math.min(image.width, image.height) : image.width}
value={crop.width}
onChange={(event) => update({ width: Number(event.target.value) })}
/>
</Field>
{!square && (
<Field label={t('crop.height')}>
<input
type="number"
id={`${idPrefix}-h`}
min={1}
max={image.height}
value={crop.height}
onChange={(event) => update({ height: Number(event.target.value) })}
/>
</Field>
)}
<button
type="button"
className="linklike"
onClick={() => setCrop(initialCrop(image, square))}
>
{t('crop.reset')}
</button>
</div>
<div className="crop-field__preview">
<canvas ref={canvasRef} className="crop-field__canvas" />
{/* The outcome in words: the frame alone would leave a
keyboard-only or screen-reader user guessing. */}
<p className="crop-field__result" role="status">
{t('crop.result', {
width: out.width,
height: out.height,
sourceWidth: image.width,
sourceHeight: image.height,
})}
</p>
</div>
</>
)}
</div>
);
}

View File

@ -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,
});
});
});

View File

@ -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<HTMLImageElement> {
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<File> {
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');
});
}

View File

@ -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<BrandingView>('/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<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
};
}

View File

@ -1,5 +1,6 @@
import deAccess from '@dorfteich/shared/i18n/de/access.json'; import deAccess from '@dorfteich/shared/i18n/de/access.json';
import deAuth from '@dorfteich/shared/i18n/de/auth.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 deComments from '@dorfteich/shared/i18n/de/comments.json';
import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json';
import deEditor from '@dorfteich/shared/i18n/de/editor.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 deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAccess from '@dorfteich/shared/i18n/en/access.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json';
import enAuth from '@dorfteich/shared/i18n/en/auth.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 enComments from '@dorfteich/shared/i18n/en/comments.json';
import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json';
import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json';
@ -79,6 +81,7 @@ void i18n
editor: enEditor, editor: enEditor,
export: enExport, export: enExport,
files: enFiles, files: enFiles,
branding: enBranding,
font: enFont, font: enFont,
graph: enGraph, graph: enGraph,
import: enImport, import: enImport,
@ -109,6 +112,7 @@ void i18n
editor: deEditor, editor: deEditor,
export: deExport, export: deExport,
files: deFiles, files: deFiles,
branding: deBranding,
font: deFont, font: deFont,
graph: deGraph, graph: deGraph,
import: deImport, import: deImport,

View File

@ -29,6 +29,7 @@ import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl'; import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips'; import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels'; import { usePondLabels } from '../labels/use-pond-labels';
import { BrandLogo } from '../branding/BrandLogo';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
import { NewPageForm } from './NewPageForm'; import { NewPageForm } from './NewPageForm';
@ -71,6 +72,11 @@ export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element
aria-label={t('layout.sidebar.label')} aria-label={t('layout.sidebar.label')}
> >
{resizer} {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. */}
<BrandLogo />
{!pond.data ? ( {!pond.data ? (
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p> <p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
) : ( ) : (

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { useBranding } from '../branding/use-branding';
import { IconButton, IconLink } from '../components/IconButton'; import { IconButton, IconLink } from '../components/IconButton';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { isTypingTarget } from '../lib/keyboard'; import { isTypingTarget } from '../lib/keyboard';
@ -67,6 +68,9 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
enabled: Boolean(user && pondSlug), enabled: Boolean(user && pondSlug),
}); });
const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId); 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<void> { async function handleLogout(): Promise<void> {
setMenuOpen(false); setMenuOpen(false);
@ -101,8 +105,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
> >
<Menu aria-hidden /> <Menu aria-hidden />
</IconButton> </IconButton>
{/* 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. */}
<Link to="/" className="topbar__brand"> <Link to="/" className="topbar__brand">
Dorfteich {branding?.instanceName ?? 'Dorfteich'}
</Link> </Link>
{user && <PondSwitcher />} {user && <PondSwitcher />}
{isPondOwner && pondSlug && ( {isPondOwner && pondSlug && (

View File

@ -90,6 +90,27 @@ export async function apiUploadFile<T>(
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
/** Multipart upload of a whole form (issue #304's font upload: several files
* plus metadata in one request). `apiUploadFile` above covers the single-file
* case; this one takes the `FormData` the caller assembled. */
export async function apiPostForm<T>(path: string, form: FormData): Promise<T> {
let response: Response;
try {
response = await fetch(`/api/v1${path}`, { method: 'POST', body: form });
} catch {
throw new ApiError(0, { code: 'network', message: 'network error' });
}
if (!response.ok) {
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
throw new ApiError(
response.status,
parsed ?? { code: `http_${response.status}`, message: response.statusText },
);
}
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export function fetchHealth(): Promise<HealthResponse> { export function fetchHealth(): Promise<HealthResponse> {
return apiGet<HealthResponse>('/healthz'); return apiGet<HealthResponse>('/healthz');
} }

View File

@ -9,6 +9,7 @@ import { Field, FormError, FormSuccess } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout'; import { SettingsLayout } from '../components/SettingsLayout';
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import { BrandingManager } from './BrandingManager';
import { PluginManager } from './PluginManager'; import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager'; import { QuotaManager } from './QuotaManager';
import { UserManager } from './UserManager'; import { UserManager } from './UserManager';
@ -180,6 +181,7 @@ export function AdminSettingsPage(): React.JSX.Element {
<LandingSettingsForm settings={settings.data} /> <LandingSettingsForm settings={settings.data} />
<LegalSettingsForm settings={settings.data} /> <LegalSettingsForm settings={settings.data} />
<BrandingManager />
<PluginManager /> <PluginManager />
<QuotaManager /> <QuotaManager />
<UserManager /> <UserManager />

View File

@ -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<BrandingView>('/branding'),
});
const view = branding.data;
const refresh = async (): Promise<void> => {
await branding.refetch();
await invalidate();
};
return (
<section className="settings-section branding">
<h2>{t('admin.title')}</h2>
<p>{t('admin.intro')}</p>
{/* 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. */}
<p>{t('admin.publicNote')}</p>
<LogoSection variant="light" asset={view?.logo ?? null} onChanged={refresh} />
<LogoSection variant="dark" asset={view?.logoDark ?? null} onChanged={refresh} />
{view?.logo && !view.logoDark && (
// Advisory, never blocking (issue #306): it names the consequence and
// the operator may decide their logo works on both surfaces.
<p className="branding__warning" role="note">
<span aria-hidden="true"> </span>
{t('admin.darkMissing')}
</p>
)}
<FaviconSection present={Boolean(view?.favicon)} onChanged={refresh} />
</section>
);
}
function LogoSection({
variant,
asset,
onChanged,
}: {
variant: LogoVariant;
asset: { hash: string; width: number; height: number } | null;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('branding');
const canvasRef = useRef<HTMLCanvasElement | null>(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<BrandingView>(`/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 (
<div className="branding__slot" data-logo-variant={variant}>
<h3>{t(`admin.logo.${variant}`)}</h3>
<p>{t(`admin.logo.${variant}Hint`)}</p>
<FormError error={upload.error ?? remove.error} />
<FormSuccess message={done ? t('admin.logo.saved') : null} />
{asset ? (
<div className="branding__current">
<img
src={logoUrl(variant, asset.hash)}
alt={t('admin.logo.currentAlt')}
className={`branding__preview branding__preview--${variant}`}
/>
<p>{t('admin.logo.current', { width: asset.width, height: asset.height })}</p>
<button
type="button"
className="button button--outline"
onClick={() => remove.mutate()}
disabled={remove.isPending}
>
{t('admin.logo.remove')}
</button>
</div>
) : (
<p>{t('admin.logo.none')}</p>
)}
<CropField
idPrefix={`logo-${variant}`}
square={false}
maxEdge={MAX_LOGO_EDGE}
onChange={(canvas) => {
canvasRef.current = canvas;
setDone(false);
}}
/>
<button
type="button"
className="button"
onClick={() => upload.mutate()}
disabled={upload.isPending}
>
{t('admin.logo.submit')}
</button>
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
</div>
);
}
function FaviconSection({
present,
onChanged,
}: {
present: boolean;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('branding');
const canvasRef = useRef<HTMLCanvasElement | null>(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<BrandingView>('/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 (
<div className="branding__slot" data-branding-slot="favicon">
<h3>{t('admin.favicon.title')}</h3>
<p>{t('admin.favicon.hint')}</p>
<FormError error={upload.error ?? remove.error} />
<FormSuccess message={done ? t('admin.favicon.saved') : null} />
<p>{present ? t('admin.favicon.present') : t('admin.favicon.default')}</p>
{present && (
<button
type="button"
className="button button--outline"
onClick={() => remove.mutate()}
disabled={remove.isPending}
>
{t('admin.favicon.remove')}
</button>
)}
<CropField
idPrefix="favicon"
square
maxEdge={180}
onChange={(canvas) => {
canvasRef.current = canvas;
setDone(false);
}}
/>
<button
type="button"
className="button"
onClick={() => upload.mutate()}
disabled={upload.isPending}
>
{t('admin.favicon.submit')}
</button>
<p role="status">{upload.isPending ? t('admin.uploading') : ''}</p>
</div>
);
}

View File

@ -4109,3 +4109,107 @@ ul[data-type='task_list'] li p:last-of-type {
border-radius: 8px; border-radius: 8px;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* 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;
}

View File

@ -91,6 +91,9 @@ services:
# uploads/plugins so all three travel in one restore set — NOT inside # uploads/plugins so all three travel in one restore set — NOT inside
# the image-baked font catalog, which a deploy would overwrite. # the image-baked font catalog, which a deploy would overwrite.
CUSTOM_FONTS_DIR: /data/fonts 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 # Read-only view of the backup sidecar's volume — the api only consumes
# its status.json (readyz freshness #85, admin backup card #86). # its status.json (readyz freshness #85, admin backup card #86).
BACKUPS_DIR: /data/backups BACKUPS_DIR: /data/backups
@ -105,6 +108,7 @@ services:
- uploads:/data/uploads - uploads:/data/uploads
- plugins:/data/plugins - plugins:/data/plugins
- customfonts:/data/fonts - customfonts:/data/fonts
- branding:/data/branding
- secrets:/data/secrets - secrets:/data/secrets
- backups:/data/backups:ro - backups:/data/backups:ro
depends_on: depends_on:
@ -194,6 +198,7 @@ services:
- uploads:/data/uploads - uploads:/data/uploads
- plugins:/data/plugins - plugins:/data/plugins
- customfonts:/data/fonts - customfonts:/data/fonts
- branding:/data/branding
- secrets:/data/secrets:ro - secrets:/data/secrets:ro
- backups:/backups - backups:/backups
depends_on: depends_on:
@ -280,6 +285,7 @@ volumes:
uploads: uploads:
plugins: plugins:
customfonts: customfonts:
branding:
secrets: secrets:
backups: backups:
# Only used by the optional `caddy` profile (certificates + state). # Only used by the optional `caddy` profile (certificates + state).

View File

@ -1,6 +1,7 @@
# Audit event catalogue # 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`, `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 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_*`, `read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
@ -88,7 +89,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
### Administration (`user.*`, `quota.*`, `settings.*`, `job.*`) ### Administration (`user.*`, `quota.*`, `settings.*`, `job.*`)
| Id | Trigger | Severity | Actor | Target | Fields | | Id | Trigger | Severity | Actor | Target | Fields |
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | -------------------- | | -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) | | `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.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.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |
@ -97,6 +98,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
| `quota.override_set` | Per-user/per-pond quota override set | notice | the admin | `user` \| `pond` | `quotaKey`, `value` | | `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` | | `quota.override_cleared` | Quota override removed | notice | the admin | `user` \| `pond` | `quotaKey` |
| `settings.changed` | Instance setting written | notice | the admin | `setting` (key) | — | | `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` | | `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` |
### Classification (`page.classification_*`, ADR 0022, issue #205) ### Classification (`page.classification_*`, ADR 0022, issue #205)

View File

@ -139,6 +139,21 @@ or sloppy plugin authors, compromised dependencies.
header; the path is validated against the file's own slug prefix, so it header; the path is validated against the file's own slug prefix, so it
cannot reach another family's directory. Uploads and deletions are cannot reach another family's directory. Uploads and deletions are
audited (`font.uploaded`, `font.deleted`). 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); - App CSP (strict): `default-src 'self'`; `font-src 'self'` (ADR 0016);
no third-party origins at all — the GDPR posture is "zero external no third-party origins at all — the GDPR posture is "zero external
requests". Operator-uploaded fonts are served from the instance itself requests". Operator-uploaded fonts are served from the instance itself

View File

@ -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)."
}
}

View File

@ -114,5 +114,13 @@
"classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert.", "classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert.",
"vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced).", "vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "Dieses Plugin steht nicht auf der Allowlist (Hash-Pinning aktiv) — erst pinnen, dann installieren.", "plugin_not_pinned": "Dieses Plugin steht nicht auf der Allowlist (Hash-Pinning aktiv) — erst pinnen, dann installieren.",
"plugin_hash_mismatch": "Der Bundle-Hash weicht vom gepinnten Hash ab — das Bundle ist nicht das geprüfte (oder eine neue Version braucht ein Re-Pin)." "plugin_hash_mismatch": "Der Bundle-Hash weicht vom gepinnten Hash ab — das Bundle ist nicht das geprüfte (oder eine neue Version braucht ein Re-Pin).",
"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."
} }

View File

@ -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)."
}
}

View File

@ -114,5 +114,13 @@
"classified_upload_blocked": "Uploads to classified pages are blocked on this instance.", "classified_upload_blocked": "Uploads to classified pages are blocked on this instance.",
"vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced).", "vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "This plugin is not on the allowlist (hash pinning active) — pin it first, then install.", "plugin_not_pinned": "This plugin is not on the allowlist (hash pinning active) — pin it first, then install.",
"plugin_hash_mismatch": "The bundle hash deviates from the pinned hash — the bundle is not the reviewed one (or a new version needs a re-pin)." "plugin_hash_mismatch": "The bundle hash deviates from the pinned hash — the bundle is not the reviewed one (or a new version needs a re-pin).",
"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."
} }

View File

@ -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 `<svg` tag near
* the start). Only used to answer a rejected upload with the real reason
* instead of a generic "not a PNG". */
export function looksLikeSvg(bytes: Uint8Array): boolean {
const head = Buffer.from(bytes.subarray(0, 256)).toString('latin1').toLowerCase();
return head.includes('<svg') || (head.includes('<?xml') && head.includes('svg'));
}
/**
* Width and height from a PNG's IHDR, which is at a FIXED offset directly
* after the signature. Reading two big-endian integers is not decoding
* nothing is decompressed and no attacker-controlled length drives a loop.
* Returns null when the bytes are not a PNG with an IHDR first.
*/
export function pngDimensions(bytes: Uint8Array): { width: number; height: number } | null {
if (!hasPngMagic(bytes) || bytes.length < 33) return null;
const buf = Buffer.from(bytes.subarray(0, 33));
if (buf.subarray(12, 16).toString('latin1') !== 'IHDR') return null;
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
if (width === 0 || height === 0) return null;
return { width, height };
}
/** What is stored per asset. The bytes stay on disk; `hash` goes into the
* serving URL so a replaced logo is picked up without fighting caches. */
export const brandingAssetSchema = z.object({
hash: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-f0-9]{16,64}$/),
width: z.number().int().min(1),
height: z.number().int().min(1),
});
export type BrandingAsset = z.infer<typeof brandingAssetSchema>;
/** What the api reports about the branding in force. Every field may be null
* an instance without branding renders its name as text and the shipped
* default favicon. */
export interface BrandingView {
logo: BrandingAsset | null;
logoDark: BrandingAsset | null;
favicon: BrandingAsset | null;
/** The instance name, so the logo link has an accessible name and the
* logo-less case has something to render. Public on purpose: the login
* screen carries the branding. */
instanceName: string;
}

View File

@ -127,6 +127,14 @@ export const apiEnvSchema = z.object({
* Layout mirrors the catalog: `<dir>/<slug>/<slug>-<weight>.woff2`. * Layout mirrors the catalog: `<dir>/<slug>/<slug>-<weight>.woff2`.
*/ */
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'), 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 * Directory holding installed plugin packages (ADR 0008, issue #71). Layout
* `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe * `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe
@ -263,6 +271,7 @@ export const backupEnvSchema = z.object({
UPLOADS_DIR: z.string().min(1).default('./data/uploads'), UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
PLUGINS_DIR: z.string().min(1).default('./data/plugins'), PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'), 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. */ /** Daily run time as HH:MM, interpreted in the container's TZ. */
BACKUP_TIME: z BACKUP_TIME: z
.string() .string()

View File

@ -6,6 +6,7 @@ export * from './auth';
export * from './backup-set'; export * from './backup-set';
export * from './backup-status'; export * from './backup-status';
export * from './backup-target-policy'; export * from './backup-target-policy';
export * from './branding';
export * from './collab-token'; export * from './collab-token';
export * from './comments'; export * from './comments';
export * from './editor-schema'; export * from './editor-schema';