Compare commits

...

2 Commits

Author SHA1 Message Date
8752cf0c5a #179: negotiate the SPA shell's lang attribute in nginx
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m13s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 9m25s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m40s
CD / Promote to Int (push) Successful in 15s
CI / Lint, typecheck, test (push) Successful in 7m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m45s
CI / Import/export fidelity gate (push) Successful in 55s
`apps/web/index.html` carries a hard `lang="en"`. The app corrects it at
runtime (#163), but nginx answers every SPA route with that same file, so a
crawler or a no-JS visit — `/public/...` on prod is exactly that — saw `en`
for German content, permanently. WCAG 3.1.1 is about the delivered document,
not the one JavaScript later fixes.

nginx-only, no backend involved: a `map` on `Accept-Language` and a
`sub_filter` in the index.html path. Only the FIRST tag decides, which is
what "the browser's preferred language" means and mirrors #163 — `de-CH`
counts as German, `en-US,de` does not.

`Vary: Accept-Language` is new. The response now genuinely depends on a
request header, and without it a shared cache could hand one language's copy
to the other. Everything else in the location is untouched: same CSP, same
`Cache-Control: no-cache`, same `nosniff`.

The known limit is documented in the config rather than worked around: nginx
cannot read `instance.defaultLocale`, so an unlisted or absent
Accept-Language yields `en` even on a German instance. For public content
that is not the authoritative rendering anyway — the api's server shell
(`/api/v1/public/...`) already renders those with the instance locale.

Verified against a real nginx 1.27 (the image the stage runs) with the
config mounted as-is: `de-DE,de;q=0.9,en;q=0.8` and `de` yield `lang="de"`;
`en-US,en;q=0.9`, `en-US,en;q=0.9,de;q=0.8`, `fr-FR,fr` and a request with no
header yield `lang="en"`; an SPA route (`/public/teich/seite`) negotiates the
same way; the gzipped response is rewritten too, and a JavaScript asset comes
through byte-identical.
2026-08-01 20:01:53 +02:00
6377faf332 #306: instance branding — logo and favicon, cropped in the browser
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m28s
CI / Build container images (pull_request) Successful in 2m7s
CI / Auth e2e pack (pull_request) Successful in 9m37s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Build and push images (push) Successful in 23s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m47s
CD / Promote to Int (push) Successful in 16s
CI / Lint, typecheck, test (push) Successful in 7m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m41s
CI / Import/export fidelity gate (push) Successful in 1m12s
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"`.
2026-08-01 19:30:52 +02:00
41 changed files with 1905 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
# the image works out of the box even where compose does not set them; compose
# still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR).
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding SECRETS_FILE=/data/secrets/secrets.env BACKUPS_DIR=/data/backups
WORKDIR /app
COPY --from=build --chown=node:node /out /app
# Generate the Prisma client for this image's platform.
RUN node node_modules/prisma/build/index.js generate
# A fresh named volume mounted at /data/uploads, /data/plugins or /data/fonts
# is created
# A fresh named volume mounted at /data/uploads, /data/plugins, /data/fonts
# or /data/branding is created
# root-owned; pre-creating them here (Docker copies an image directory's
# ownership into a new volume on first mount) lets the non-root `node` user
# write to them. /data/backups is mounted read-only here, but pre-creating it
# node-owned keeps the shared `backups` volume writable for the backup
# sidecar even when the api container is the one that initializes it.
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/secrets /data/backups
RUN mkdir -p /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups && chown -R node:node /data/uploads /data/plugins /data/fonts /data/branding /data/secrets /data/backups
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \

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';
import { SiteAdminGuard } from './site-admin.guard';
// Lifecycle markers, not configuration: never editable through this
// endpoint (the setup lock must be irreversible, issue #80).
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set(['setup.completedAt']);
// Lifecycle markers and file-backed metadata, not configuration: never
// editable through this endpoint. The setup lock must be irreversible
// (issue #80), and the branding entries only describe bytes on disk
// (issue #306) — writing one by hand would claim an asset that is not
// there. Both have their own write paths.
const INTERNAL_KEYS: ReadonlySet<InstanceSettingKey> = new Set([
'setup.completedAt',
'instance.logo',
'instance.logoDark',
'instance.favicon',
]);
// Partial update: any subset of the known settings, each validated by
// its own schema inside the service (double validation is fine — this

View File

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

View File

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

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 { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared';
import {
DEFAULT_ATTACHMENT_EXTENSIONS,
VS_NFD_PROFILE,
brandingAssetSchema,
isVsNfdCompliant,
} from '@dorfteich/shared';
import { Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod';
@ -18,6 +23,16 @@ export const INSTANCE_SETTINGS = {
'auth.registrationMode': z.enum(['open', 'closed']).default('open'),
'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'),
'instance.defaultLocale': z.enum(['de', 'en']).default('en'),
// Branding assets (issue #306). Metadata only — the PNG bytes live under
// BRANDING_DIR and travel in the restore set; `hash` goes into the serving
// URL so a replaced asset is picked up without cache trouble. Null = not
// uploaded: the instance name renders as text, the favicon falls back to
// the shipped default. `logoDark` is optional by design — without it the
// LIGHT logo is used in both themes, because showing the operator's own
// asset unchanged beats substituting one they did not choose (#307).
'instance.logo': brandingAssetSchema.nullable().default(null),
'instance.logoDark': brandingAssetSchema.nullable().default(null),
'instance.favicon': brandingAssetSchema.nullable().default(null),
// Instance-default quotas (ADR 0011); per-user/per-pond overrides live
// in quota_overrides and win over these (QuotaService, issue #22).
'quota.editorsPerPond': z.number().int().min(0).default(5),

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
# api image's PLUGINS_DIR — issue #71's lesson).
BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \
CUSTOM_FONTS_DIR=/data/fonts \
CUSTOM_FONTS_DIR=/data/fonts BRANDING_DIR=/data/branding \
SECRETS_FILE=/data/secrets/secrets.env
# pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the
# volume archives, tzdata so BACKUP_TIME honors a configured TZ, and

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.
*/
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[] {
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(
env: Pick<
BackupEnv,
'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'
| 'BACKUPS_DIR'
| 'DATABASE_URL'
| 'UPLOADS_DIR'
| 'PLUGINS_DIR'
| 'CUSTOM_FONTS_DIR'
| 'BRANDING_DIR'
>,
backupId: string,
log: RemoteLogger,

View File

@ -110,6 +110,9 @@ for (const scheme of SCHEMES) {
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
// Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
await expectClean(page, `/admin (${scheme})`);
await context.close();
});

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: dark)" content="#10161d" />
<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
first paint and before the deferred module bundle. External file
because the prod CSP forbids inline scripts (issue #180). -->

View File

@ -1,3 +1,23 @@
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
# SPA route which nginx answers with index.html would see `en` forever,
# even for German content.
#
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
# German; `en-US,de` does not, because that visitor asked for English first.
#
# Known limit, documented rather than worked around: nginx does not know
# `instance.defaultLocale` from the database, so a visitor with no (or an
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
# content that is not the authoritative rendering anyway the api's server
# shell (`/api/v1/public/...`) renders those with the instance locale.
map $http_accept_language $spa_lang {
default en;
~*^de de;
}
# SPA serving: static assets with long-lived caching, everything else
# falls back to index.html (client-side routing).
server {
@ -34,6 +54,17 @@ server {
# zero-third-party-request guarantee (security.md) is unaffected.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
# The shell's language (issue #179). Only the html document is
# rewritten, and only its first match `<html lang="en">` is the
# first and only occurrence in index.html. Everything else this
# location serves passes through untouched.
sub_filter_types text/html;
sub_filter_once on;
sub_filter 'lang="en"' 'lang="$spa_lang"';
# The response now depends on a request header, so shared caches must
# not serve one language's copy to the other. This location is
# `no-cache` anyway; the header states the dependency correctly.
add_header Vary "Accept-Language" always;
try_files $uri /index.html;
}
}

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

View File

@ -29,6 +29,7 @@ import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels';
import { BrandLogo } from '../branding/BrandLogo';
import { apiGet, apiPatch } from '../lib/api';
import { usePersistentState } from '../lib/use-persistent-state';
import { NewPageForm } from './NewPageForm';
@ -71,6 +72,11 @@ export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element
aria-label={t('layout.sidebar.label')}
>
{resizer}
{/* The instance identity sits ABOVE the pond section, not inside it:
the sidebar has no pond header outside a pond, and the logo is the
link home it must not disappear on /admin or the start page
(issue #306). The pond name below stays the heading. */}
<BrandLogo />
{!pond.data ? (
<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 { useAuth } from '../auth/auth-context';
import { useBranding } from '../branding/use-branding';
import { IconButton, IconLink } from '../components/IconButton';
import { apiGet } from '../lib/api';
import { isTypingTarget } from '../lib/keyboard';
@ -67,6 +68,9 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
enabled: Boolean(user && pondSlug),
});
const isPondOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
// Instance identity (issue #306) — shared query, also read by the sidebar
// logo and reachable without a session (the login screen carries it).
const branding = useBranding();
async function handleLogout(): Promise<void> {
setMenuOpen(false);
@ -101,8 +105,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
>
<Menu aria-hidden />
</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">
Dorfteich
{branding?.instanceName ?? 'Dorfteich'}
</Link>
{user && <PondSwitcher />}
{isPondOwner && pondSlug && (

View File

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

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

@ -4178,3 +4178,106 @@ ul[data-type='task_list'] li p:last-of-type {
.custom-fonts input[type='file'] {
max-width: 100%;
}
/* Instance branding (issue #306). The logo sits above the pond name in the
sidebar; the two variants are both rendered and one is hidden here rather
than in JavaScript, so the right one is the one PAINTED theme-init.js has
already resolved data-theme when this applies. */
.brand-logo {
display: block;
padding: var(--space-2) 0;
color: inherit;
text-decoration: none;
}
.brand-logo__img {
display: block;
max-width: 100%;
height: auto;
/* A tall logo must not push the pond name out of view. */
max-height: 3rem;
}
.brand-logo__name {
font-weight: var(--font-weight-heading);
}
/* Without a dark variant the light logo carries both themes (#306/#307:
variants are never mixed across levels). */
.brand-logo__img--dark {
display: none;
}
:root[data-theme='dark'] .brand-logo__img--light:not(.brand-logo__img--both) {
display: none;
}
:root[data-theme='dark'] .brand-logo__img--dark {
display: block;
}
.branding__slot {
border-top: 1px solid var(--color-border);
margin-top: var(--space-4);
padding-top: var(--space-3);
min-width: 0;
}
.branding__preview {
max-width: 100%;
max-height: 6rem;
height: auto;
}
/* The dark logo is meant for a dark surface previewing it on the light
admin background would misrepresent it. */
.branding__preview--dark {
background: #10161d;
padding: var(--space-2);
border-radius: 6px;
}
.branding__warning {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-2) var(--space-3);
}
.crop-field {
min-width: 0;
}
.crop-field input[type='file'] {
max-width: 100%;
}
.crop-field__controls {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: flex-end;
}
.crop-field__controls .field {
max-width: 12rem;
}
.crop-field__canvas {
max-width: 100%;
height: auto;
border: 1px solid var(--color-border);
border-radius: 6px;
/* A transparent PNG on a transparent page shows nothing the checkerboard
is how an operator sees that the background really is transparent. */
background-color: var(--color-surface);
background-image:
linear-gradient(45deg, var(--color-surface-muted) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-surface-muted) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-surface-muted) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-surface-muted) 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}

View File

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

View File

@ -1,6 +1,7 @@
# Audit event catalogue
**Catalogue version 1.6 (2026-08-01; 1.6 adds `font.uploaded` and
**Catalogue version 1.7 (2026-08-01; 1.7 adds `branding.changed`,
issue #306; 1.6 added `font.uploaded` and
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
@ -88,7 +89,7 @@ failure), `warning` = feeds detection (suspicious or destructive),
### Administration (`user.*`, `quota.*`, `settings.*`, `job.*`)
| Id | Trigger | Severity | Actor | Target | Fields |
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | -------------------- |
| -------------------------- | ------------------------------------------------------ | -------- | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| `user.disabled_set` | Site Admin disables/enables an account | notice | the admin | `user` | `disabled` (bool) |
| `user.site_admin_set` | Site-Admin privilege granted/revoked | notice | the admin | `user` | `isSiteAdmin` (bool) |
| `user.deleted` | Account deleted by a Site Admin | notice | the admin | `user` | — |
@ -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_cleared` | Quota override removed | notice | the admin | `user` \| `pond` | `quotaKey` |
| `settings.changed` | Instance setting written | notice | the admin | `setting` (key) | — |
| `branding.changed` | Logo or favicon uploaded or removed (#306/#307) | notice | the admin | `setting` (key) | `scope` (`instance`/`pond`), `asset` (`logo`/`logoDark`/`favicon`), `change` (`set`/`cleared`), `pondId` (pond scope) |
| `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` |
### Classification (`page.classification_*`, ADR 0022, issue #205)

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
cannot reach another family's directory. Uploads and deletions are
audited (`font.uploaded`, `font.deleted`).
- **Branding upload (issue #306)**: Site Admins upload an instance logo and
favicon; the pond-level override (#307) puts the same surface in the hands
of ordinary Pond Admins, so the rules are identical at both levels. **SVG is
refused** — it can carry script, and serving it from our own origin would be
a cross-site-scripting vector. Cropping, scaling and the conversion to PNG
happen in the BROWSER on a canvas; the api validates the PNG signature, the
IHDR dimensions (fixed offsets — no decoding) and a size cap, then stores
the bytes. No image library runs in the api: it would put a decoder in front
of attacker-supplied bytes and would have to be carried through the
`--network none` offline build. Assets are served from
`/api/v1/branding/…` with a pinned `image/png` content type under the
instance-wide `nosniff` header. The serving routes are **unauthenticated by
design** — the login screen carries the branding and the browser fetches the
favicon before anyone signs in; the admin UI states this. Changes are
audited (`branding.changed`).
- App CSP (strict): `default-src 'self'`; `font-src 'self'` (ADR 0016);
no third-party origins at all — the GDPR posture is "zero external
requests". Operator-uploaded fonts are served from the instance itself

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

@ -127,5 +127,13 @@
"font_weight_exists": "Dieser Schnitt existiert für diese Schrift bereits.",
"font_weight_invalid": "Dieser Schnitt ist nicht zulässig.",
"font_one_weight_expected": "Es lässt sich nur ein Schnitt auf einmal ergänzen.",
"font_unexpected_field": "Die Anfrage enthält ein unerwartetes Feld."
"font_unexpected_field": "Die Anfrage enthält ein unerwartetes Feld.",
"branding_file_empty": "Die Bilddatei ist leer.",
"branding_file_too_large": "Die Bilddatei ist zu groß.",
"branding_file_missing": "Es wurde keine Bilddatei übermittelt.",
"branding_not_a_png": "Es konnte kein PNG erzeugt werden — bitte eine andere Bilddatei wählen.",
"branding_not_an_image": "Diese Datei ist kein unterstütztes Bild (PNG, JPEG oder WebP).",
"branding_svg_rejected": "SVG wird nicht angenommen: eine SVG-Datei kann Skripte enthalten. Bitte PNG, JPEG oder WebP verwenden.",
"branding_image_too_large": "Das Bild ist zu groß — bitte den Ausschnitt verkleinern.",
"branding_favicon_not_square": "Das Favicon muss quadratisch sein."
}

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

@ -127,5 +127,13 @@
"font_weight_exists": "This weight already exists for this font.",
"font_weight_invalid": "This weight is not allowed.",
"font_one_weight_expected": "Only one weight can be added at a time.",
"font_unexpected_field": "The request contains an unexpected field."
"font_unexpected_field": "The request contains an unexpected field.",
"branding_file_empty": "The image file is empty.",
"branding_file_too_large": "The image file is too large.",
"branding_file_missing": "No image file was submitted.",
"branding_not_a_png": "No PNG could be produced — please choose a different image file.",
"branding_not_an_image": "This file is not a supported image (PNG, JPEG or WebP).",
"branding_svg_rejected": "SVG is not accepted: an SVG file can carry script. Please use PNG, JPEG or WebP.",
"branding_image_too_large": "The image is too large — please reduce the crop.",
"branding_favicon_not_square": "The favicon must be square."
}

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`.
*/
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
* `<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'),
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'),
BRANDING_DIR: z.string().min(1).default('./data/branding'),
/** Daily run time as HH:MM, interpreted in the container's TZ. */
BACKUP_TIME: z
.string()

View File

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