Compare commits

...

4 Commits

Author SHA1 Message Date
76a5e92f2e An instance had no way to look like itself: the top bar said "Dorfteich"
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m10s
CI / Build container images (pull_request) Successful in 4m12s
CI / Auth e2e pack (pull_request) Successful in 9m33s
CI / Import/export fidelity gate (pull_request) Successful in 1m16s
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:28:11 +02:00
942f7b13d3 #304: scope the legal spec's status locator to its own form
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m31s
CI / Build container images (pull_request) Successful in 1m15s
CI / Auth e2e pack (pull_request) Successful in 8m57s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CD / Build and push images (push) Successful in 36s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m24s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Failing after 7m12s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
The font manager's upload live regions made `getByRole('status')` ambiguous
on /admin, and legal.spec.ts — which asserts the legal form's success message
— started failing in the e2e pack. That is the documented trap in CLAUDE.md:
a new label or region makes an existing page-wide locator ambiguous, and the
fix is to scope the SPEC, not to drop the region a screen reader needs.

The section gets a named class for exactly that purpose.

Verified locally against the running stack: legal, fonts, admin-users,
admin-quotas and the a11y pack all pass.
2026-08-01 19:05:18 +02:00
ee6a11f9b0 #304: declare the font-list route's access rule explicitly
Some checks failed
CI / Build container images (pull_request) Successful in 3m51s
CI / Lint, typecheck, test (pull_request) Successful in 6m35s
CI / Auth e2e pack (pull_request) Failing after 3m6s
CI / Import/export fidelity gate (pull_request) Has been skipped
The route-permission fence (#52) failed in CI, not locally: I had run the
fonts and import-export suites, not the full api suite, and that fence needs
a database. `@AuthenticatedOnly()` is the rule the route always meant — a
session, no further permission.

Re-verified with the FULL api suite against a fresh database: 103 files /
575 tests passed.
2026-08-01 18:46:47 +02:00
f8c241b11a #304: custom fonts in the pickers, an admin screen, and the licence page
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 6m26s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
The backend from #303 could store an operator's font but nothing could
choose one: no list endpoint outside the Site-Admin routes, no @font-face
rules for a family that only exists at runtime, and no management UI.

Found while wiring it up — a real defect in #303, invisible to its tests:
`fontStack` cannot tell an uploaded family from a deleted one, so the PDF
exporter embedded the face and then never named it. Every export of a pond
using an operator font rendered in the system font while the job reported
success. Both `fontStack` call sites now take the uploaded families
(`buildPdfHtml`, `pondFontVariables`); `pdf-html.test.ts` pins the
regression from both sides. Verified against a real Gotenberg: with the
families the PDF embeds PlayfairDisplay-Bold, without them NotoSans-Bold —
that was the whole bug, in one diff of two PDFs.

- `GET /fonts/custom` is readable by any signed-in user, not Site Admins
  only: the pickers, the licence page and the injected `@font-face` rules
  all need it, and gating it would have forced a second, admin-only UI.
- Bundled and uploaded families are told apart by their `<optgroup>`, not
  by a badge — the grouping is then part of the control's semantics, so a
  screen reader announces it and the native mobile select keeps it. Within
  each source the catalog's category grouping is preserved.
- The delete confirmation names how many ponds use the family and what
  happens to them; focus moves to it and back on cancel. Deletion stays
  unblocked (the api's decision, #303) — the ponds degrade, they do not
  break.
- The licence page grew a second table. That is what makes an attribution
  obligation satisfiable: a commercial licence that requires naming the
  foundry needs a page to name it on.

Verified in the browser end to end (upload two weights → listed and
rendered in its own font → chosen in a pond → page renders in it → deleted
→ pond falls back): api suite for fonts/export 77 passed, a11y pack 11/11
locally in both schemes, lint/typecheck/i18n:check green.
2026-08-01 18:32:46 +02:00
56 changed files with 2874 additions and 70 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

@ -23,6 +23,7 @@ import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard'; import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard'; import { AuthedRequest, Public } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { CustomFontStorageService } from './custom-font-storage.service'; import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsService, WeightUpload } from './custom-fonts.service'; import { CustomFontsService, WeightUpload } from './custom-fonts.service';
@ -106,7 +107,15 @@ export class CustomFontsAdminController {
} }
/** /**
* Serving route. Unauthenticated on purpose: a font is referenced from CSS, * Reading side of the uploaded fonts: the family list every signed-in user
* needs, and the bytes themselves.
*
* The listing is NOT site-admin-gated (issue #304): every signed-in user picks
* fonts in their pond's Appearance settings, reads the licence page, and needs
* the `@font-face` rules injected the admin list at `/admin/fonts` carries
* the same data, so gating this one would only force a second, admin-only UI.
*
* The file route is unauthenticated on purpose: a font is referenced from CSS,
* and the login screen carries the pond-independent chrome an authenticated * and the login screen carries the pond-independent chrome an authenticated
* font URL would simply not load. The bytes are branding, not content. * font URL would simply not load. The bytes are branding, not content.
*/ */
@ -117,6 +126,15 @@ export class CustomFontsFileController {
private readonly fonts: CustomFontsService, private readonly fonts: CustomFontsService,
) {} ) {}
// Explicit access declaration, as every route needs (issue #52's fence
// `route-permissions.e2e.db.test.ts`): a session, no further permission —
// the list says which families exist, which is what the pickers offer.
@AuthenticatedOnly()
@Get()
list(): Promise<CustomFontView[]> {
return this.fonts.list();
}
@Public() @Public()
@Get(':slug/:file') @Get(':slug/:file')
async serve( async serve(

View File

@ -155,6 +155,30 @@ describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => {
expect(res.body.code).toBe('font_woff2_missing'); expect(res.body.code).toBe('font_woff2_missing');
}); });
/**
* Issue #304: an ordinary member picks fonts in their pond's Appearance
* settings and reads the licence page, so the family list cannot be
* Site-Admin-only only the management routes are.
*/
it('lets any signed-in user read the family list, but nobody anonymous', async () => {
await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Leseschrift ${suffix}`)
.field('category', 'monospace')
.field('licence', 'Read me')
.attach('woff2-500', woff2(), 'x.woff2')
.expect(201);
const listed = await api().get('/api/v1/fonts/custom').set('Cookie', plainCookie).expect(200);
const seen = (listed.body as { family: string; weights: number[] }[]).find(
(font) => font.family === `Leseschrift ${suffix}`,
);
expect(seen?.weights).toEqual([500]);
await api().get('/api/v1/fonts/custom').expect(401);
});
it('keeps every management route away from a non-admin', async () => { it('keeps every management route away from a non-admin', async () => {
await api().get('/api/v1/admin/fonts').set('Cookie', plainCookie).expect(403); await api().get('/api/v1/admin/fonts').set('Cookie', plainCookie).expect(403);
await api() await api()

View File

@ -6,6 +6,7 @@ import {
ConversionJobView, ConversionJobView,
ExportFormat, ExportFormat,
PondFonts, PondFonts,
customFontEntries,
fontSlug, fontSlug,
PageClassification, PageClassification,
classificationMarking, classificationMarking,
@ -335,6 +336,9 @@ export class ExportService {
pondName: page.pond.name, pondName: page.pond.name,
bodyHtml, bodyHtml,
fonts, fonts,
// Both the rules and the stack need the uploaded families: embedding a
// face the stack never names would render the system font (issue #304).
customFonts: customFontEntries(await this.customFonts.list()),
fontFaceCss: await this.fontFaceCss(fonts), fontFaceCss: await this.fontFaceCss(fonts),
// Styled sections keep their look in the PDF (#75); a pond without // Styled sections keep their look in the PDF (#75); a pond without
// active style plugins contributes an empty string. // active style plugins contributes an empty string.

View File

@ -0,0 +1,51 @@
import { DEFAULT_FONTS, customFontEntries } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest';
import { buildPdfHtml } from './pdf-html';
const CUSTOM = customFontEntries([
{
id: 'f1',
family: 'Corporate Grotesk',
slug: 'corporate-grotesk',
category: 'sans-serif',
licence: 'Bought from Foundry X',
licenceUrl: null,
weights: [400, 700],
createdAt: '2026-08-01T00:00:00.000Z',
},
]);
function base(family: string): Parameters<typeof buildPdfHtml>[0] {
return {
title: 'T',
pondName: 'P',
bodyHtml: '<p>x</p>',
fonts: { ...DEFAULT_FONTS, body: { family, weight: 400 } },
fontFaceCss: `@font-face { font-family: '${family}'; src: url('data:font/woff2;base64,AA'); }`,
};
}
describe('buildPdfHtml font stacks (issues #303/#304)', () => {
it('names an operator-uploaded family in the CSS stack when it is known', () => {
const html = buildPdfHtml({ ...base('Corporate Grotesk'), customFonts: CUSTOM });
expect(html).toContain("--font-body: 'Corporate Grotesk',");
});
/**
* The regression this pins: the `@font-face` rule for a custom family was
* embedded, but `fontStack` not knowing the family produced the bare
* system fallback, so the rule was never referenced and the PDF rendered in
* the system font while everything reported success.
*/
it('would fall back to the system stack without the uploaded families', () => {
const html = buildPdfHtml(base('Corporate Grotesk'));
expect(html).not.toContain("'Corporate Grotesk',");
expect(html).toContain('--font-body: system-ui');
});
it('leaves catalog families working without any uploaded ones', () => {
const html = buildPdfHtml(base('Lora'));
expect(html).toContain("--font-body: 'Lora', Georgia");
});
});

View File

@ -1,4 +1,4 @@
import { PondFonts, fontStack } from '@dorfteich/shared'; import { FontCatalogEntry, PondFonts, fontStack } from '@dorfteich/shared';
export interface PdfHtmlParams { export interface PdfHtmlParams {
title: string; title: string;
@ -8,6 +8,12 @@ export interface PdfHtmlParams {
fonts: PondFonts; fonts: PondFonts;
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */ /** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
fontFaceCss: string; fontFaceCss: string;
/** The instance's operator-uploaded families (issue #303), so a pond set to
* one gets it NAMED in the `font-family` stack. Without them `fontStack`
* cannot tell a custom family from a typo and yields the bare system
* fallback the `@font-face` rule would then be embedded but never
* referenced, and the PDF would silently render in the system font. */
customFonts?: readonly FontCatalogEntry[];
/** The pond's active section-style plugin CSS (issue #75), already validated /** The pond's active section-style plugin CSS (issue #75), already validated
* at install time (scoped selectors, no external fetches, no `</style>`). * at install time (scoped selectors, no external fetches, no `</style>`).
* Sections of a disabled plugin render neutrally their class matches * Sections of a disabled plugin render neutrally their class matches
@ -35,6 +41,7 @@ function escapeHtml(value: string): string {
*/ */
export function buildPdfHtml(params: PdfHtmlParams): string { export function buildPdfHtml(params: PdfHtmlParams): string {
const { fonts } = params; const { fonts } = params;
const extra = params.customFonts ?? [];
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@ -44,9 +51,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
${params.fontFaceCss} ${params.fontFaceCss}
@page { size: A4; } @page { size: A4; }
:root { :root {
--font-heading: ${fontStack(fonts.heading.family)}; --font-heading: ${fontStack(fonts.heading.family, extra)};
--font-body: ${fontStack(fonts.body.family)}; --font-body: ${fontStack(fonts.body.family, extra)};
--font-mono: ${fontStack(fonts.mono.family)}; --font-mono: ${fontStack(fonts.mono.family, extra)};
} }
html { font-size: 11pt; } html { font-size: 11pt; }
body { body {

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

@ -88,6 +88,14 @@ for (const scheme of SCHEMES) {
await page.goto('/settings'); await page.goto('/settings');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await expectClean(page, `/settings (${scheme})`); await expectClean(page, `/settings (${scheme})`);
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
// eigenen Schriften zwei Tabellen samt Scroll-Regionen). Bewusst
// KEIN eigener Test — jeder zusätzliche Login im Pack bringt die
// CI zwei Packs später ans Rate-Limit (Lehre aus #301).
await page.goto('/fonts');
await page.waitForLoadState('networkidle');
await expectClean(page, `/fonts (${scheme})`);
await context.close(); await context.close();
}); });
@ -99,6 +107,12 @@ 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();
// 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 expectClean(page, `/admin (${scheme})`);
await context.close(); await context.close();
}); });

View File

@ -63,7 +63,11 @@ test('the admin form previews and publishes the privacy policy', async ({ browse
await expect(editor.locator('.legal-editor__preview strong')).toHaveText('only what is needed'); await expect(editor.locator('.legal-editor__preview strong')).toHaveText('only what is needed');
await page.getByRole('button', { name: /save legal pages|rechtsseiten speichern/i }).click(); await page.getByRole('button', { name: /save legal pages|rechtsseiten speichern/i }).click();
await expect(page.getByRole('status')).toBeVisible(); // Auf den Abschnitt gescopet: seit der Schriftverwaltung (#304) hat /admin
// weitere Live-Regionen (Upload-Fortschritt), und ein seitenweites
// getByRole('status') wäre mehrdeutig. Gemeint war immer die
// Erfolgsmeldung DIESES Formulars.
await expect(page.locator('.legal-settings').getByRole('status')).toBeVisible();
await admin.close(); await admin.close();
const anonymous = await browser.newContext({ baseURL: BASE_URL }); const anonymous = await browser.newContext({ baseURL: BASE_URL });

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

@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { apiPatch } from '../lib/api'; import { apiPatch } from '../lib/api';
import { useCustomFontEntries } from './use-custom-fonts';
const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono']; const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono'];
const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace']; const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace'];
@ -29,9 +30,12 @@ export function AppearanceManager({
const [draft, setDraft] = useState<PondFonts>(fonts); const [draft, setDraft] = useState<PondFonts>(fonts);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
// The operator's own families (issue #304) — offered next to the catalog,
// in their own labelled group, and resolvable by `fontEntry`/`fontStack`.
const custom = useCustomFontEntries();
function chooseFamily(slot: keyof PondFonts, family: string): void { function chooseFamily(slot: keyof PondFonts, family: string): void {
const weights = fontEntry(family)?.weights ?? []; const weights = fontEntry(family, custom)?.weights ?? [];
// Keep the current weight if the new family offers it, else its first. // Keep the current weight if the new family offers it, else its first.
const weight = weights.includes(draft[slot].weight) ? draft[slot].weight : (weights[0] ?? 400); const weight = weights.includes(draft[slot].weight) ? draft[slot].weight : (weights[0] ?? 400);
setDraft((prev) => ({ ...prev, [slot]: { family, weight } })); setDraft((prev) => ({ ...prev, [slot]: { family, weight } }));
@ -62,7 +66,7 @@ export function AppearanceManager({
<FormError error={error} /> <FormError error={error} />
{SLOTS.map((slot) => { {SLOTS.map((slot) => {
const value = draft[slot]; const value = draft[slot];
const weights = fontEntry(value.family)?.weights ?? [value.weight]; const weights = fontEntry(value.family, custom)?.weights ?? [value.weight];
return ( return (
<div className={`appearance__slot appearance__slot--${slot}`} key={slot}> <div className={`appearance__slot appearance__slot--${slot}`} key={slot}>
<span className="appearance__slot-label">{t(`slots.${slot}`)}</span> <span className="appearance__slot-label">{t(`slots.${slot}`)}</span>
@ -72,8 +76,18 @@ export function AppearanceManager({
value={value.family} value={value.family}
onChange={(event) => chooseFamily(slot, event.target.value)} onChange={(event) => chooseFamily(slot, event.target.value)}
> >
{/* Bundled and uploaded families are told apart by the group
they sit in, not by a badge (issue #304): the grouping is
then part of the control's semantics a screen reader
announces it on entering, and the native mobile select
keeps it. Within each source the category grouping of the
catalog is preserved, so a custom family appears under its
own category exactly like a bundled one. */}
{CATEGORIES.map((category) => ( {CATEGORIES.map((category) => (
<optgroup key={category} label={t(`category.${category}`)}> <optgroup
key={category}
label={t('group.bundled', { category: t(`category.${category}`) })}
>
{FONT_CATALOG.filter((font) => font.category === category).map((font) => ( {FONT_CATALOG.filter((font) => font.category === category).map((font) => (
<option key={font.family} value={font.family}> <option key={font.family} value={font.family}>
{font.family} {font.family}
@ -81,6 +95,22 @@ export function AppearanceManager({
))} ))}
</optgroup> </optgroup>
))} ))}
{CATEGORIES.filter((category) =>
custom.some((font) => font.category === category),
).map((category) => (
<optgroup
key={`custom-${category}`}
label={t('group.custom', { category: t(`category.${category}`) })}
>
{custom
.filter((font) => font.category === category)
.map((font) => (
<option key={font.family} value={font.family}>
{font.family}
</option>
))}
</optgroup>
))}
</select> </select>
</label> </label>
<label className="appearance__field"> <label className="appearance__field">
@ -98,7 +128,7 @@ export function AppearanceManager({
</label> </label>
<p <p
className="appearance__preview" className="appearance__preview"
style={{ fontFamily: fontStack(value.family), fontWeight: value.weight }} style={{ fontFamily: fontStack(value.family, custom), fontWeight: value.weight }}
> >
{t('preview')} {t('preview')}
</p> </p>

View File

@ -0,0 +1,40 @@
import { useCustomFonts } from './use-custom-fonts';
/**
* A family name is free text the operator typed. It ends up inside a CSS
* string, so quote and backslash are escaped and everything that could end
* the declaration, the rule or the `<style>` element is dropped. Site Admins
* are trusted with far more than this, but a rule that silently breaks the
* whole stylesheet on an apostrophe would be a bug either way.
*/
function cssFamily(family: string): string {
return family.replace(/[\\'<>{};\r\n]/g, '');
}
/**
* `@font-face` rules for the operator-uploaded families (issue #304).
*
* Catalog families are declared in the generated `public/fonts/catalog.css`,
* which the build writes and `index.html` links. Uploaded ones only exist at
* runtime, so their rules are injected here same shape, same `swap`
* behaviour, bytes from the api's public font route.
*
* Without this the pickers would offer families the browser cannot resolve:
* `fontStack` names them, nothing declares them, and the text renders in the
* system fallback.
*/
export function CustomFontFaces(): React.JSX.Element | null {
const fonts = useCustomFonts();
if (fonts.length === 0) return null;
const css = fonts
.flatMap((font) =>
font.weights.map(
(weight) =>
`@font-face { font-family: '${cssFamily(font.family)}'; font-style: normal;` +
` font-weight: ${weight}; font-display: swap;` +
` src: url('/api/v1/fonts/custom/${font.slug}/${font.slug}-${weight}.woff2') format('woff2'); }`,
),
)
.join('\n');
return <style data-custom-fonts="">{css}</style>;
}

View File

@ -1,19 +1,28 @@
import { PondSettings, fontStack } from '@dorfteich/shared'; import { FontCatalogEntry, PondSettings, fontStack } from '@dorfteich/shared';
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { useCustomFontEntries } from './use-custom-fonts';
/** The CSS custom properties a pond's font choice sets on its content root /** The CSS custom properties a pond's font choice sets on its content root
* (ADR 0016). The content CSS reads these; a family that fails to load falls * (ADR 0016). The content CSS reads these; a family that fails to load falls
* back to the category's system stack (`fontStack`). */ * back to the category's system stack (`fontStack`).
export function pondFontVariables(fonts: PondSettings['fonts']): CSSProperties { *
* `custom` carries the operator-uploaded families (issue #304): `fontStack`
* cannot tell an uploaded family from a deleted one, so without them a pond
* set to its operator's own font would render in the system fallback. */
export function pondFontVariables(
fonts: PondSettings['fonts'],
custom: readonly FontCatalogEntry[] = [],
): CSSProperties {
// Overrides the same custom properties the app-wide CSS already reads // Overrides the same custom properties the app-wide CSS already reads
// (tokens.css), so headings, body, and code inside the scope re-resolve to // (tokens.css), so headings, body, and code inside the scope re-resolve to
// the pond's fonts without any per-element rules. // the pond's fonts without any per-element rules.
return { return {
'--font-heading': fontStack(fonts.heading.family), '--font-heading': fontStack(fonts.heading.family, custom),
'--font-weight-heading': String(fonts.heading.weight), '--font-weight-heading': String(fonts.heading.weight),
'--font-body': fontStack(fonts.body.family), '--font-body': fontStack(fonts.body.family, custom),
'--font-weight-body': String(fonts.body.weight), '--font-weight-body': String(fonts.body.weight),
'--font-mono': fontStack(fonts.mono.family), '--font-mono': fontStack(fonts.mono.family, custom),
'--font-weight-mono': String(fonts.mono.weight), '--font-weight-mono': String(fonts.mono.weight),
} as CSSProperties; } as CSSProperties;
} }
@ -32,8 +41,9 @@ export function PondFontScope({
fonts: PondSettings['fonts']; fonts: PondSettings['fonts'];
children: ReactNode; children: ReactNode;
}): React.JSX.Element { }): React.JSX.Element {
const custom = useCustomFontEntries();
return ( return (
<div className="pond-font-scope" style={pondFontVariables(fonts)}> <div className="pond-font-scope" style={pondFontVariables(fonts, custom)}>
{children} {children}
</div> </div>
); );

View File

@ -0,0 +1,39 @@
import { CustomFontView, FontCatalogEntry, customFontEntries } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api';
export const CUSTOM_FONTS_KEY = ['fonts', 'custom'];
/**
* The instance's operator-uploaded font families (issues #303/#304).
*
* Every font-aware surface needs them: the pickers offer them, the licence
* page attributes them, `fontStack` needs them to NAME the family instead of
* falling through to the system stack, and `CustomFontFaces` turns them into
* `@font-face` rules. One query key, so they are fetched once per session and
* shared.
*
* Only fetched while signed in the endpoint requires a session, and asking
* on the login screen would produce a 401 for nothing.
*/
export function useCustomFonts(): CustomFontView[] {
const { user } = useAuth();
const query = useQuery({
queryKey: CUSTOM_FONTS_KEY,
queryFn: () => apiGet<CustomFontView[]>('/fonts/custom'),
enabled: Boolean(user),
// Uploading a font is a rare Site-Admin act; the manager invalidates the
// key itself, so a long life here costs nothing.
staleTime: 5 * 60 * 1000,
});
return query.data ?? [];
}
/** The same families in the shape `fontEntry`/`fontStack` accept. */
export function useCustomFontEntries(): FontCatalogEntry[] {
const fonts = useCustomFonts();
return useMemo(() => customFontEntries(fonts), [fonts]);
}

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

@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { CustomFontFaces } from '../fonts/CustomFontFaces';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
import { Footer } from './Footer'; import { Footer } from './Footer';
import { PageActionsSlotContext } from './page-actions'; import { PageActionsSlotContext } from './page-actions';
@ -55,6 +56,9 @@ export function AppLayout(): React.JSX.Element {
<SidebarChromeContext.Provider value={setForcedHidden}> <SidebarChromeContext.Provider value={setForcedHidden}>
<PageActionsSlotContext.Provider value={actionsSlot}> <PageActionsSlotContext.Provider value={actionsSlot}>
<div className="app"> <div className="app">
{/* Declares the operator-uploaded families (#304) for every screen
below pickers, previews, editor and read view alike. */}
<CustomFontFaces />
{/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */} {/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */}
<a className="skip-link" href="#main"> <a className="skip-link" href="#main">
{t('layout.skipToContent')} {t('layout.skipToContent')}

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,8 @@ 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 { CustomFontManager } from './CustomFontManager';
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 +182,8 @@ export function AdminSettingsPage(): React.JSX.Element {
<LandingSettingsForm settings={settings.data} /> <LandingSettingsForm settings={settings.data} />
<LegalSettingsForm settings={settings.data} /> <LegalSettingsForm settings={settings.data} />
<BrandingManager />
<CustomFontManager />
<PluginManager /> <PluginManager />
<QuotaManager /> <QuotaManager />
<UserManager /> <UserManager />
@ -457,7 +461,10 @@ function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.
} }
return ( return (
<section className="settings-section"> // Named class so the e2e can scope its success-message assertion to this
// form: /admin has more than one live region since #304 (upload progress),
// and a page-wide getByRole('status') became ambiguous.
<section className="settings-section legal-settings">
<h2>{t('admin.title')}</h2> <h2>{t('admin.title')}</h2>
<p className="field__hint">{t('admin.hint')}</p> <p className="field__hint">{t('admin.hint')}</p>
<form onSubmit={(event) => void onSubmit(event)} noValidate> <form onSubmit={(event) => void onSubmit(event)} noValidate>

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

@ -0,0 +1,425 @@
import {
CustomFontView,
FONT_CATEGORIES,
FONT_WEIGHTS,
FontCategory,
MAX_FONT_FILE_BYTES,
fontStack,
customFontEntries,
} from '@dorfteich/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Field, FormError, FormSuccess } from '../components/forms';
import { CUSTOM_FONTS_KEY } from '../fonts/use-custom-fonts';
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
/** One weight the operator is about to upload. `woff` is optional the api
* rejects a WOFF without its WOFF2 because the PDF path reads WOFF2 only. */
interface WeightDraft {
weight: number;
woff2: File | null;
woff: File | null;
}
const MAX_MIB = Math.round(MAX_FONT_FILE_BYTES / (1024 * 1024));
/** Preselects the weight the operator most likely wants next: Regular for the
* first row, then upwards from the heaviest one already chosen (Regular
* Bold is the usual second file), and only after that whatever is left. The
* selection stays free this is a starting point, not a rule. */
function emptyDraft(used: number[]): WeightDraft {
const free = FONT_WEIGHTS.filter((weight) => !used.includes(weight));
const heaviest = Math.max(0, ...used);
const next = free.includes(400) ? 400 : (free.find((w) => w > heaviest) ?? free[0] ?? 400);
return { weight: next, woff2: null, woff: null };
}
function appendWeight(form: FormData, draft: WeightDraft): void {
if (draft.woff2) form.append(`woff2-${draft.weight}`, draft.woff2);
if (draft.woff) form.append(`woff-${draft.weight}`, draft.woff);
}
/**
* Site-Admin management of operator-uploaded font families (issue #304,
* backend #303, ADR 0016 §#303).
*
* The whole cycle lives here: upload a family with its licence and one file
* per weight, see what is installed, add a weight later, and delete a family
* after being told how many ponds still use it.
*
* Deleting is never blocked (the api's decision): an unknown family falls back
* to the system stack, so the affected ponds change appearance rather than
* break. The confirmation therefore names the consequence in text instead of
* refusing.
*/
export function CustomFontManager(): React.JSX.Element {
const { t } = useTranslation('font');
const queryClient = useQueryClient();
const fonts = useQuery({
queryKey: ['admin', 'fonts'],
queryFn: () => apiGet<CustomFontView[]>('/admin/fonts'),
});
const installed = fonts.data ?? [];
const entries = customFontEntries(installed);
const invalidate = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'fonts'] });
// The pickers, the licence page and the injected `@font-face` rules read
// the non-admin list — without this they keep the pre-upload state.
await queryClient.invalidateQueries({ queryKey: CUSTOM_FONTS_KEY });
};
return (
<section className="settings-section custom-fonts">
<h2>{t('admin.title')}</h2>
<p>{t('admin.intro')}</p>
<UploadForm onUploaded={invalidate} />
<h3>{t('admin.installed')}</h3>
{installed.length === 0 ? (
<p>{t('admin.empty')}</p>
) : (
<ul className="custom-fonts__list">
{installed.map((font) => (
<FontRow key={font.id} font={font} entries={entries} onChanged={invalidate} />
))}
</ul>
)}
</section>
);
}
function UploadForm({ onUploaded }: { onUploaded: () => Promise<void> }): React.JSX.Element {
const { t } = useTranslation('font');
const [family, setFamily] = useState('');
const [category, setCategory] = useState<FontCategory>('sans-serif');
const [licence, setLicence] = useState('');
const [licenceUrl, setLicenceUrl] = useState('');
const [weights, setWeights] = useState<WeightDraft[]>([emptyDraft([])]);
const [done, setDone] = useState(false);
const upload = useMutation({
mutationFn: async () => {
const form = new FormData();
form.append('family', family.trim());
form.append('category', category);
form.append('licence', licence.trim());
if (licenceUrl.trim()) form.append('licenceUrl', licenceUrl.trim());
for (const draft of weights) appendWeight(form, draft);
return apiPostForm<CustomFontView>('/admin/fonts', form);
},
onSuccess: async () => {
setFamily('');
setLicence('');
setLicenceUrl('');
setWeights([emptyDraft([])]);
setDone(true);
await onUploaded();
},
});
const ready = family.trim() !== '' && licence.trim() !== '' && weights.some((w) => w.woff2);
return (
<form
className="custom-fonts__upload"
noValidate
onSubmit={(event) => {
event.preventDefault();
setDone(false);
upload.mutate();
}}
>
<h3>{t('admin.upload.title')}</h3>
<FormError error={upload.error} />
{/* role="status", so completion reaches assistive technology instead of
being a colour change in the corner. */}
<FormSuccess message={done ? t('admin.upload.done') : null} />
<Field label={t('admin.upload.family')} hint={t('admin.upload.familyHint')}>
<input
type="text"
value={family}
maxLength={80}
autoComplete="off"
onChange={(event) => setFamily(event.target.value)}
/>
</Field>
<Field label={t('admin.upload.category')}>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FontCategory)}
>
{FONT_CATEGORIES.map((value) => (
<option key={value} value={value}>
{t(`category.${value}`)}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.licence')} hint={t('admin.upload.licenceHint')}>
<input
type="text"
value={licence}
maxLength={200}
onChange={(event) => setLicence(event.target.value)}
/>
</Field>
<Field label={t('admin.upload.licenceUrl')}>
<input
type="url"
value={licenceUrl}
maxLength={500}
placeholder="https://"
onChange={(event) => setLicenceUrl(event.target.value)}
/>
</Field>
<fieldset className="custom-fonts__weights">
<legend>{t('admin.upload.weights')}</legend>
{/* The accepted formats are stated up front, not only when a rejected
upload comes back an operator should not have to fail to learn
the requirement. */}
<p>{t('admin.upload.formatHint', { max: MAX_MIB })}</p>
{weights.map((draft, index) => (
<div className="custom-fonts__weight" key={index}>
<Field label={t('admin.upload.weight')}>
<select
value={draft.weight}
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, weight: Number(event.target.value) } : entry,
),
)
}
>
{FONT_WEIGHTS.map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.woff2', { weight: draft.weight })}>
<input
type="file"
accept=".woff2,font/woff2"
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, woff2: event.target.files?.[0] ?? null } : entry,
),
)
}
/>
</Field>
<Field label={t('admin.upload.woff', { weight: draft.weight })}>
<input
type="file"
accept=".woff,font/woff"
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, woff: event.target.files?.[0] ?? null } : entry,
),
)
}
/>
</Field>
{weights.length > 1 && (
<button
type="button"
className="linklike"
onClick={() => setWeights((prev) => prev.filter((_, i) => i !== index))}
>
{t('admin.upload.removeWeight', { weight: draft.weight })}
</button>
)}
</div>
))}
<button
type="button"
className="button button--outline"
onClick={() =>
setWeights((prev) => [...prev, emptyDraft(prev.map((entry) => entry.weight))])
}
>
{t('admin.upload.addWeight')}
</button>
</fieldset>
<button type="submit" className="button" disabled={!ready || upload.isPending}>
{t('admin.upload.submit')}
</button>
{/* Announced, not just spun: an 8 MiB face over a slow link takes long
enough that silence reads as failure. */}
<p role="status" className="custom-fonts__status">
{upload.isPending ? t('admin.upload.uploading') : ''}
</p>
</form>
);
}
function FontRow({
font,
entries,
onChanged,
}: {
font: CustomFontView;
entries: ReturnType<typeof customFontEntries>;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('font');
const [confirming, setConfirming] = useState(false);
const [adding, setAdding] = useState<WeightDraft | null>(null);
const confirmRef = useRef<HTMLButtonElement | null>(null);
const deleteRef = useRef<HTMLButtonElement | null>(null);
const usage = useQuery({
queryKey: ['admin', 'fonts', font.id, 'usage'],
queryFn: () => apiGet<{ pondsAffected: number }>(`/admin/fonts/${font.id}/usage`),
enabled: confirming,
});
// The confirmation appears below the button that opened it; without moving
// focus a keyboard user would have to hunt for it, and a screen reader would
// never learn it exists.
useEffect(() => {
if (confirming) confirmRef.current?.focus();
}, [confirming, usage.data]);
const addWeight = useMutation({
mutationFn: async (draft: WeightDraft) => {
const form = new FormData();
appendWeight(form, draft);
return apiPostForm<CustomFontView>(`/admin/fonts/${font.id}/weights`, form);
},
onSuccess: async () => {
setAdding(null);
await onChanged();
},
});
const remove = useMutation({
mutationFn: () => apiDelete(`/admin/fonts/${font.id}`),
onSuccess: async () => {
setConfirming(false);
await onChanged();
},
});
return (
<li className="custom-fonts__item" data-font-slug={font.slug}>
<p className="custom-fonts__name" style={{ fontFamily: fontStack(font.family, entries) }}>
{font.family}
</p>
<p className="custom-fonts__meta">
{t(`category.${font.category}`)} · {t('catalog.weights')}: {font.weights.join(', ')} ·{' '}
{font.licenceUrl ? (
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
{font.licence}
</a>
) : (
font.licence
)}
</p>
<FormError error={remove.error ?? addWeight.error} />
<div className="custom-fonts__actions">
<button
type="button"
className="linklike"
onClick={() => setAdding((prev) => (prev ? null : emptyDraft(font.weights)))}
aria-expanded={adding !== null}
>
{t('admin.addWeight.toggle')}
</button>
<button
type="button"
className="linklike"
ref={deleteRef}
onClick={() => setConfirming(true)}
aria-expanded={confirming}
>
{t('admin.delete.start', { family: font.family })}
</button>
</div>
{adding && (
<form
className="custom-fonts__add-weight"
noValidate
onSubmit={(event) => {
event.preventDefault();
addWeight.mutate(adding);
}}
>
<Field label={t('admin.upload.weight')}>
<select
value={adding.weight}
onChange={(event) => setAdding({ ...adding, weight: Number(event.target.value) })}
>
{FONT_WEIGHTS.filter((weight) => !font.weights.includes(weight)).map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.woff2', { weight: adding.weight })}>
<input
type="file"
accept=".woff2,font/woff2"
onChange={(event) => setAdding({ ...adding, woff2: event.target.files?.[0] ?? null })}
/>
</Field>
<Field label={t('admin.upload.woff', { weight: adding.weight })}>
<input
type="file"
accept=".woff,font/woff"
onChange={(event) => setAdding({ ...adding, woff: event.target.files?.[0] ?? null })}
/>
</Field>
<button type="submit" className="button" disabled={!adding.woff2 || addWeight.isPending}>
{t('admin.addWeight.submit')}
</button>
<p role="status">{addWeight.isPending ? t('admin.upload.uploading') : ''}</p>
</form>
)}
{confirming && (
<div className="custom-fonts__confirm">
{/* The count comes from the api; until it arrives the consequence is
still stated, so the text never reads as "nothing will happen". */}
<p>
{usage.data
? t('admin.delete.usage', { count: usage.data.pondsAffected })
: t('admin.delete.usageLoading')}
</p>
<p>{t('admin.delete.consequence')}</p>
<button
type="button"
className="button button--danger"
ref={confirmRef}
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('admin.delete.confirm')}
</button>
<button
type="button"
className="button button--outline"
onClick={() => {
setConfirming(false);
deleteRef.current?.focus();
}}
>
{t('admin.delete.cancel')}
</button>
</div>
)}
</li>
);
}

View File

@ -1,19 +1,29 @@
import { FONT_CATALOG, fontStack } from '@dorfteich/shared'; import { CustomFontView, FONT_CATALOG, FontCatalogEntry, fontStack } from '@dorfteich/shared';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCustomFontEntries, useCustomFonts } from '../fonts/use-custom-fonts';
import { useDocumentTitle } from '../lib/use-document-title'; import { useDocumentTitle } from '../lib/use-document-title';
/**
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted /** One licence table. Both sources carry the same four columns; only where the
* family with its license, rendered in the font itself. The self-hosting is the * licence text comes from differs a catalog entry names a licence id we
* GDPR guarantee this page is the human-readable attribution surface. * ship, an uploaded family whatever the operator typed. */
*/ function LicenceTable({
export function FontCatalogPage(): React.JSX.Element { label,
rows,
}: {
label: string;
rows: {
key: string;
family: string;
category: string;
weights: number[];
licence: React.ReactNode;
stack: string;
}[];
}): React.JSX.Element {
const { t } = useTranslation('font'); const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
return ( return (
<div className="font-catalog"> <div className="table-scroll" tabIndex={0} role="region" aria-label={label}>
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<table className="font-catalog__table"> <table className="font-catalog__table">
<thead> <thead>
<tr> <tr>
@ -24,16 +34,12 @@ export function FontCatalogPage(): React.JSX.Element {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{FONT_CATALOG.map((font) => ( {rows.map((row) => (
<tr key={font.family}> <tr key={row.key}>
<td style={{ fontFamily: fontStack(font.family) }}>{font.family}</td> <td style={{ fontFamily: row.stack }}>{row.family}</td>
<td>{t(`category.${font.category}`)}</td> <td>{row.category}</td>
<td>{font.weights.join(', ')}</td> <td>{row.weights.join(', ')}</td>
<td> <td>{row.licence}</td>
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
{font.license}
</a>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -41,3 +47,72 @@ export function FontCatalogPage(): React.JSX.Element {
</div> </div>
); );
} }
/**
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
* family with its license, rendered in the font itself. The self-hosting is the
* GDPR guarantee this page is the human-readable attribution surface.
*
* Since issue #304 the operator's own uploaded families are listed too, with
* the licence label and link recorded at upload. That is what makes an
* attribution obligation satisfiable: many commercial font licences require
* naming the foundry or the licence, and an operator who cannot point at such
* a page cannot comply.
*/
export function FontCatalogPage(): React.JSX.Element {
const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
const customFonts: CustomFontView[] = useCustomFonts();
const customEntries: FontCatalogEntry[] = useCustomFontEntries();
return (
<div className="font-catalog">
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<h2>{t('catalog.bundledHeading')}</h2>
<p>{t('catalog.bundledIntro')}</p>
<LicenceTable
label={t('catalog.bundledHeading')}
rows={FONT_CATALOG.map((font) => ({
key: font.family,
family: font.family,
category: t(`category.${font.category}`),
weights: font.weights,
stack: fontStack(font.family),
licence: (
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
{font.license}
</a>
),
}))}
/>
<h2>{t('catalog.customHeading')}</h2>
<p>{t('catalog.customIntro')}</p>
{customFonts.length === 0 ? (
<p>{t('catalog.customEmpty')}</p>
) : (
<LicenceTable
label={t('catalog.customHeading')}
rows={customFonts.map((font) => ({
key: font.id,
family: font.family,
category: t(`category.${font.category}`),
weights: font.weights,
stack: fontStack(font.family, customEntries),
// A licence URL is optional — without one the label stands alone
// rather than becoming a link to nowhere.
licence: font.licenceUrl ? (
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
{font.licence}
</a>
) : (
font.licence
),
}))}
/>
)}
</div>
);
}

View File

@ -4109,3 +4109,175 @@ 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);
} }
/* Site-Admin font management (issue #304). The layout stays a plain column so
the section reflows at 320px without its own rules (#301). */
.custom-fonts__list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.custom-fonts__item {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-3);
}
.custom-fonts__name {
font-size: 1.25rem;
margin: 0;
}
.custom-fonts__meta {
color: var(--color-text-muted);
margin: var(--space-1) 0 var(--space-2);
}
.custom-fonts__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.custom-fonts__weights {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-3);
margin: var(--space-3) 0;
min-width: 0;
}
.custom-fonts__weight {
border-top: 1px solid var(--color-border);
padding-top: var(--space-2);
margin-top: var(--space-2);
}
.custom-fonts__weight:first-of-type {
border-top: none;
padding-top: 0;
margin-top: 0;
}
.custom-fonts__confirm,
.custom-fonts__add-weight {
border-top: 1px solid var(--color-border);
margin-top: var(--space-3);
padding-top: var(--space-3);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
/* A file input is as wide as its filename; without this it pushes the page
at 320px (issue #301's lesson, applied ahead of the fact). */
.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 # 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_*`,
@ -87,17 +88,18 @@ 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` | — |
| `user.pseudonymized` | GDPR pseudonymization of authorship completed | notice | `null` (system) | `user` | — | | `user.pseudonymized` | GDPR pseudonymization of authorship completed | notice | `null` (system) | `user` | — |
| `user.verification_resent` | Site Admin re-sends the verification mail | info | the admin | `user` | — | | `user.verification_resent` | Site Admin re-sends the verification mail | info | the admin | `user` | — |
| `quota.override_set` | Per-user/per-pond quota override set | notice | the admin | `user` \| `pond` | `quotaKey`, `value` | | `quota.override_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) | — |
| `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` | | `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) ### 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,26 @@
"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).",
"font_family_reserved": "Dieser Schriftname gehört zum mitgelieferten Katalog und kann nicht überschrieben werden.",
"font_family_exists": "Eine eigene Schrift mit diesem Namen existiert bereits.",
"font_family_unusable": "Aus diesem Schriftnamen lässt sich kein Adressname bilden — bitte lateinische Buchstaben oder Ziffern verwenden.",
"font_file_empty": "Die Schriftdatei ist leer.",
"font_file_too_large": "Die Schriftdatei ist zu groß.",
"font_file_not_a_font": "Diese Datei ist keine WOFF2-/WOFF-Schriftdatei.",
"font_woff2_missing": "Zu jedem Schnitt gehört eine WOFF2-Datei; eine WOFF allein genügt nicht.",
"font_no_weights": "Es wurde keine Schriftdatei ausgewählt.",
"font_too_many_weights": "Diese Schrift hat bereits die höchstmögliche Zahl an Schnitten.",
"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.",
"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

@ -12,19 +12,66 @@
"save": "Schriften speichern", "save": "Schriften speichern",
"saved": "Gespeichert", "saved": "Gespeichert",
"catalogLink": "Schrift-Lizenzen", "catalogLink": "Schrift-Lizenzen",
"group": {
"bundled": "Mitgelieferte Schriften · {{category}}",
"custom": "Eigene Schriften · {{category}}"
},
"catalog": { "catalog": {
"title": "Schriftkatalog", "title": "Schriftkatalog",
"intro": "Jede Schrift wird selbst gehostet — beim Anzeigen eines Teichs wird keine Anfrage an Dritte gestellt (DSGVO). Alle Schriften sind frei unter den angegebenen Lizenzen.", "intro": "Jede Schrift wird selbst gehostet — beim Anzeigen eines Teichs wird keine Anfrage an Dritte gestellt (DSGVO).",
"family": "Schrift", "family": "Schrift",
"category": "Stil", "category": "Stil",
"weights": "Schnitte", "weights": "Schnitte",
"license": "Lizenz" "license": "Lizenz",
"bundledHeading": "Mitgelieferte Schriften",
"bundledIntro": "Diese Schriften sind frei unter den angegebenen Lizenzen.",
"customHeading": "Eigene Schriften",
"customIntro": "Diese Schriften hat der Betreiber dieser Instanz hochgeladen; die Lizenzangabe stammt von ihm.",
"customEmpty": "Es wurden keine eigenen Schriften hochgeladen."
}, },
"category": { "category": {
"sans-serif": "Serifenlos", "sans-serif": "Serifenlos",
"serif": "Serif", "serif": "Serif",
"monospace": "Dicktengleich" "monospace": "Dicktengleich"
}, },
"admin": {
"title": "Eigene Schriften",
"intro": "Lade eigene, lizenzierte Schriften hoch. Sie stehen zusätzlich zu den mitgelieferten in allen Teichen zur Auswahl und erscheinen mit ihrer Lizenz auf der Seite „Schrift-Lizenzen“. Die Dateien werden gespeichert, aber nicht geöffnet oder ausgewertet.",
"installed": "Hochgeladene Schriften",
"empty": "Es wurden noch keine eigenen Schriften hochgeladen.",
"upload": {
"title": "Schrift hinzufügen",
"family": "Schriftname",
"familyHint": "Genau der Name, unter dem die Schrift ausgewählt werden soll. Ein Name aus dem mitgelieferten Katalog wird abgelehnt.",
"category": "Stil",
"licence": "Lizenz",
"licenceHint": "Freitext, z. B. „Desktop-Lizenz Foundry X, Rechnung 4711“. Erscheint auf der Lizenzseite.",
"licenceUrl": "Link zur Lizenz (optional)",
"weights": "Schnitte",
"formatHint": "Pro Schnitt eine WOFF2-Datei (Pflicht), optional zusätzlich WOFF. Andere Formate werden abgelehnt; höchstens {{max}} MiB je Datei.",
"weight": "Schnitt",
"woff2": "WOFF2-Datei für Schnitt {{weight}}",
"woff": "WOFF-Datei für Schnitt {{weight}} (optional)",
"removeWeight": "Schnitt {{weight}} wieder entfernen",
"addWeight": "Weiteren Schnitt hinzufügen",
"submit": "Schrift hochladen",
"uploading": "Die Schrift wird hochgeladen …",
"done": "Die Schrift wurde hochgeladen und steht jetzt zur Auswahl."
},
"addWeight": {
"toggle": "Schnitt ergänzen",
"submit": "Schnitt hochladen"
},
"delete": {
"start": "Schrift „{{family}}“ löschen",
"usage_one": "Ein Teich benutzt diese Schrift derzeit.",
"usage_other": "{{count}} Teiche benutzen diese Schrift derzeit.",
"usageLoading": "Es wird geprüft, wie viele Teiche diese Schrift benutzen …",
"consequence": "Nach dem Löschen fallen diese Teiche auf die Standard-Schrift zurück. Ihre Einstellung bleibt gespeichert: Wird dieselbe Schrift erneut hochgeladen, sehen sie wieder aus wie vorher.",
"confirm": "Endgültig löschen",
"cancel": "Abbrechen"
}
},
"pondTheme": { "pondTheme": {
"legend": "Akzentfarbe des Teichs", "legend": "Akzentfarbe des Teichs",
"inherit": "Eigene Einstellung der Betrachtenden (Standard)", "inherit": "Eigene Einstellung der Betrachtenden (Standard)",

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,26 @@
"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).",
"font_family_reserved": "This font name belongs to the bundled catalog and cannot be overridden.",
"font_family_exists": "A custom font with this name already exists.",
"font_family_unusable": "No address name can be derived from this font name — please use Latin letters or digits.",
"font_file_empty": "The font file is empty.",
"font_file_too_large": "The font file is too large.",
"font_file_not_a_font": "This file is not a WOFF2/WOFF font file.",
"font_woff2_missing": "Every weight needs a WOFF2 file; a WOFF alone is not enough.",
"font_no_weights": "No font file was selected.",
"font_too_many_weights": "This font already has the maximum number of weights.",
"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.",
"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

@ -12,19 +12,66 @@
"save": "Save fonts", "save": "Save fonts",
"saved": "Saved", "saved": "Saved",
"catalogLink": "Font licenses", "catalogLink": "Font licenses",
"group": {
"bundled": "Bundled fonts · {{category}}",
"custom": "Custom fonts · {{category}}"
},
"catalog": { "catalog": {
"title": "Font catalog", "title": "Font catalog",
"intro": "Every font below is self-hosted — rendering a pond makes no request to any third party (GDPR). Fonts are free/libre under the licenses shown.", "intro": "Every font below is self-hosted — rendering a pond makes no request to any third party (GDPR).",
"family": "Font", "family": "Font",
"category": "Style", "category": "Style",
"weights": "Weights", "weights": "Weights",
"license": "License" "license": "License",
"bundledHeading": "Bundled fonts",
"bundledIntro": "These fonts are free/libre under the licenses shown.",
"customHeading": "Custom fonts",
"customIntro": "These fonts were uploaded by this instance's operator; the licence details are theirs.",
"customEmpty": "No custom fonts have been uploaded."
}, },
"category": { "category": {
"sans-serif": "Sans-serif", "sans-serif": "Sans-serif",
"serif": "Serif", "serif": "Serif",
"monospace": "Monospace" "monospace": "Monospace"
}, },
"admin": {
"title": "Custom fonts",
"intro": "Upload your own licensed font families. They become selectable in every pond alongside the bundled ones and are listed with their licence on the “Font licenses” page. The files are stored, never opened or parsed.",
"installed": "Uploaded fonts",
"empty": "No custom fonts have been uploaded yet.",
"upload": {
"title": "Add a font",
"family": "Font name",
"familyHint": "Exactly the name the font should be selectable under. A name from the bundled catalog is rejected.",
"category": "Style",
"licence": "Licence",
"licenceHint": "Free text, e.g. “Desktop licence, Foundry X, invoice 4711”. Shown on the licence page.",
"licenceUrl": "Link to the licence (optional)",
"weights": "Weights",
"formatHint": "One WOFF2 file per weight (required), optionally a WOFF as well. Other formats are rejected; at most {{max}} MiB per file.",
"weight": "Weight",
"woff2": "WOFF2 file for weight {{weight}}",
"woff": "WOFF file for weight {{weight}} (optional)",
"removeWeight": "Remove weight {{weight}} again",
"addWeight": "Add another weight",
"submit": "Upload font",
"uploading": "Uploading the font …",
"done": "The font was uploaded and can now be selected."
},
"addWeight": {
"toggle": "Add a weight",
"submit": "Upload weight"
},
"delete": {
"start": "Delete font “{{family}}”",
"usage_one": "One pond currently uses this font.",
"usage_other": "{{count}} ponds currently use this font.",
"usageLoading": "Checking how many ponds use this font …",
"consequence": "After deletion those ponds fall back to the default look. Their setting is kept: uploading the same font again restores their appearance.",
"confirm": "Delete permanently",
"cancel": "Cancel"
}
},
"pondTheme": { "pondTheme": {
"legend": "Pond accent color", "legend": "Pond accent color",
"inherit": "Each viewer's own setting (default)", "inherit": "Each viewer's own setting (default)",

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';