#303: operator-uploaded fonts — storage, API, PDF embedding, backup
All checks were successful
CI / Build container images (pull_request) Successful in 3m53s
CI / Auth e2e pack (pull_request) Successful in 8m42s
CI / Auth e2e pack (push) Successful in 8m41s
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Deploy to Test (push) Successful in 16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m41s
CI / Build container images (push) Has been skipped
CI / Import/export fidelity gate (push) Successful in 52s

An operator holding a font licence could only use it by baking the file
into a custom image, which tied every change to a rebuild and left the
file out of the backup.

ADR 0016 said there is no runtime font management. It also listed this
exact case under "Alternatives considered" — *may become a Site-Admin-
level feature later*. The amendment takes that option and answers the two
objections it raised: licensing risk (Site Admins only, licence recorded
with the family) and file-format attack surface (magic-byte check and a
size cap, never a parse).

- `CUSTOM_FONTS_DIR` (default `./data/fonts`) — a sibling of uploads and
  plugins, NOT inside the image-baked `FONTS_DIR`, where a deploy would
  overwrite it and no backup would ever see it.
- One list of data directories (`apps/backup/src/data-dirs.ts`) now feeds
  both the nightly archive and the restore, so they cannot drift. #306 and
  #307 add one line each instead of a second mechanism.
- Both Dockerfiles bake the path. The backup image sets its volume paths
  itself ("self-sufficient without compose env" — #71's lesson) and reads
  no *_DIR from compose; without the ENV entry the archive would have
  skipped the directory silently.
- The PDF path already read WOFF2 from disk at request time, so it only
  had to pick the other base directory for a custom family.
- `fontStack`/`fontEntry` take the instance's uploaded families as an
  argument — they are runtime data. The catalog is searched first, and a
  colliding family name is rejected at upload, so a custom font can never
  shadow a catalog one.
- Deletion is never blocked by usage: an unknown family already falls back
  to the system stack, so affected ponds degrade instead of breaking. The
  count of affected ponds travels into the audit entry.
- Audit catalogue v1.6 (`font.uploaded`, `font.deleted`).

Verified: api full suite against a fresh database, 102 files / 571 tests.
The upload suite writes into a real temp directory and reads the bytes
back off disk, so the storage layer is exercised rather than mocked.
This commit is contained in:
Claude Opus 5 2026-08-01 14:49:13 +02:00
parent 5164801676
commit b96997501a
23 changed files with 1132 additions and 89 deletions

View File

@ -29,7 +29,7 @@ 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 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 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.

View File

@ -0,0 +1,45 @@
-- #303: operator-uploaded font families (ADR 0016 §#303).
-- The bytes live on disk under CUSTOM_FONTS_DIR; these rows record only what
-- the upload form stated, because the api never parses the font file.
CREATE TABLE "custom_fonts" (
"id" TEXT NOT NULL,
"family" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"category" TEXT NOT NULL,
"licence" TEXT NOT NULL,
"licence_url" TEXT,
"uploaded_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "custom_fonts_pkey" PRIMARY KEY ("id")
);
-- Both unique: `family` keeps `fonts.<slot>.family` in pond settings
-- unambiguous, `slug` owns a directory under CUSTOM_FONTS_DIR.
CREATE UNIQUE INDEX "custom_fonts_family_key" ON "custom_fonts"("family");
CREATE UNIQUE INDEX "custom_fonts_slug_key" ON "custom_fonts"("slug");
ALTER TABLE "custom_fonts" ADD CONSTRAINT "custom_fonts_uploaded_by_fkey"
FOREIGN KEY ("uploaded_by") REFERENCES "users"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE TABLE "custom_font_weights" (
"id" TEXT NOT NULL,
"font_id" TEXT NOT NULL,
"weight" INTEGER NOT NULL,
"has_woff" BOOLEAN NOT NULL DEFAULT false,
"byte_size" INTEGER NOT NULL,
CONSTRAINT "custom_font_weights_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "custom_font_weights_font_id_weight_key"
ON "custom_font_weights"("font_id", "weight");
-- Deleting a family takes its weights with it; the files on disk are removed
-- by the service in the same operation.
ALTER TABLE "custom_font_weights" ADD CONSTRAINT "custom_font_weights_font_id_fkey"
FOREIGN KEY ("font_id") REFERENCES "custom_fonts"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -67,6 +67,7 @@ model User {
watches Watch[] watches Watch[]
notifications Notification[] notifications Notification[]
favorites PageFavorite[] favorites PageFavorite[]
customFonts CustomFont[]
@@map("users") @@map("users")
} }
@ -913,3 +914,48 @@ model PondPlugin {
@@id([pondId, pluginId]) @@id([pondId, pluginId])
@@map("pond_plugins") @@map("pond_plugins")
} }
/// An operator-uploaded font family (issue #303, ADR 0016 §#303). The bytes
/// live on disk under CUSTOM_FONTS_DIR — this row only records what the
/// upload form stated, because the api never parses the font file itself.
/// Additive to the compile-time catalog: a family whose name or slug
/// collides with a catalog entry is rejected, so `fonts.<slot>.family` in a
/// pond's settings stays unambiguous.
model CustomFont {
id String @id @default(uuid())
/// CSS `font-family` name, as typed by the uploader.
family String @unique
/// URL/file-safe form; names the directory under CUSTOM_FONTS_DIR.
slug String @unique
/// Drives the system fallback stack, like FontCatalogEntry.category.
category String
/// Free-text licence label, e.g. "Commercial — Foundry XY". Required so
/// an attribution obligation can be met on the font catalogue page.
licence String
licenceUrl String? @map("licence_url")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
uploader User @relation(fields: [uploadedBy], references: [id])
weights CustomFontWeight[]
@@map("custom_fonts")
}
/// One weight of a custom family. Style is always `normal`: the PDF
/// `@font-face` builder emits only that, and browsers synthesise oblique —
/// italic uploads are a follow-up, not a silent half-feature.
model CustomFontWeight {
id String @id @default(uuid())
fontId String @map("font_id")
weight Int
/// Whether a legacy WOFF was supplied next to the required WOFF2.
hasWoff Boolean @default(false) @map("has_woff")
byteSize Int @map("byte_size")
font CustomFont @relation(fields: [fontId], references: [id], onDelete: Cascade)
@@unique([fontId, weight])
@@map("custom_font_weights")
}

View File

@ -17,6 +17,7 @@ import { FilesModule } from './files/files.module';
import { GrantsModule } from './grants/grants.module'; import { GrantsModule } from './grants/grants.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { HomeModule } from './home/home.module'; import { HomeModule } from './home/home.module';
import { FontsModule } from './fonts/fonts.module';
import { ImportExportModule } from './import-export/import-export.module'; import { ImportExportModule } from './import-export/import-export.module';
import { LabelsModule } from './labels/labels.module'; import { LabelsModule } from './labels/labels.module';
import { LegalModule } from './legal/legal.module'; import { LegalModule } from './legal/legal.module';
@ -81,6 +82,7 @@ import { VersionsModule } from './versions/versions.module';
PublicModule, PublicModule,
PublicApiModule, PublicApiModule,
McpModule, McpModule,
FontsModule,
ImportExportModule, ImportExportModule,
PluginsModule, PluginsModule,
AuthModule, AuthModule,

View File

@ -45,6 +45,8 @@ 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' },
'font.uploaded': { severity: 'notice' },
'font.deleted': { severity: 'notice' },
'setup.admin_created': { severity: 'notice' }, 'setup.admin_created': { severity: 'notice' },
'setup.completed': { severity: 'info' }, 'setup.completed': { severity: 'info' },
'setup.preseeded': { severity: 'info' }, 'setup.preseeded': { severity: 'info' },

View File

@ -0,0 +1,55 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable } from '@nestjs/common';
import type { FontUploadFormat } from '@dorfteich/shared';
import { AppConfig } from '../config/app-config.service';
/**
* Filesystem binding for operator-uploaded fonts (issue #303, ADR 0016 §#303).
*
* The layout mirrors the baked-in catalog `<slug>/<slug>-<weight>.woff2`
* so the PDF exporter's `@font-face` builder needs no special case beyond
* choosing the directory.
*
* That directory is `CUSTOM_FONTS_DIR`, NOT `FONTS_DIR`: the latter is baked
* into the image, so anything written there disappears on the next deploy and
* never reaches a backup. This one is a sibling of the uploads and plugins
* mounts and travels in the restore set (`apps/backup/src/data-dirs.ts`).
*/
@Injectable()
export class CustomFontStorageService {
constructor(private readonly config: AppConfig) {}
private dirFor(slug: string): string {
return join(this.config.env.CUSTOM_FONTS_DIR, slug);
}
fileNameFor(slug: string, weight: number, format: FontUploadFormat): string {
return `${slug}-${weight}.${format}`;
}
pathFor(slug: string, weight: number, format: FontUploadFormat): string {
return join(this.dirFor(slug), this.fileNameFor(slug, weight, format));
}
async save(slug: string, weight: number, format: FontUploadFormat, bytes: Buffer): Promise<void> {
await mkdir(this.dirFor(slug), { recursive: true });
await writeFile(this.pathFor(slug, weight, format), bytes);
}
read(slug: string, weight: number, format: FontUploadFormat): Promise<Buffer> {
return readFile(this.pathFor(slug, weight, format));
}
/** Removes the family's whole directory. Missing is fine deletion must
* stay idempotent so a half-failed upload can still be cleaned up. */
async deleteFamily(slug: string): Promise<void> {
await rm(this.dirFor(slug), { recursive: true, force: true });
}
async deleteWeight(slug: string, weight: number, format: FontUploadFormat): Promise<void> {
await rm(this.pathFor(slug, weight, format), { force: true });
}
}

View File

@ -0,0 +1,146 @@
import {
BadRequestException,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Req,
Res,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
CustomFontView,
FONT_WEIGHTS,
MAX_FONT_FILE_BYTES,
createCustomFontInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsService, WeightUpload } from './custom-fonts.service';
/** Multipart field names: `woff2-<weight>` and the optional `woff-<weight>`. */
const FILE_FIELD = /^(woff2|woff)-(\d{3})$/;
function parseUploads(files: Express.Multer.File[] | undefined): WeightUpload[] {
const byWeight = new Map<number, WeightUpload>();
for (const file of files ?? []) {
const match = FILE_FIELD.exec(file.fieldname);
if (!match) throw new BadRequestException({ code: 'font_unexpected_field' });
const weight = Number(match[2]);
if (!(FONT_WEIGHTS as readonly number[]).includes(weight)) {
throw new BadRequestException({ code: 'font_weight_invalid' });
}
const entry = byWeight.get(weight) ?? { weight, woff2: Buffer.alloc(0) };
if (match[1] === 'woff2') entry.woff2 = file.buffer;
else entry.woff = file.buffer;
byWeight.set(weight, entry);
}
// A WOFF without its WOFF2 would produce a weight the PDF path cannot
// embed — the exporter reads WOFF2 only.
for (const entry of byWeight.values()) {
if (entry.woff2.length === 0) throw new BadRequestException({ code: 'font_woff2_missing' });
}
return [...byWeight.values()].sort((a, b) => a.weight - b.weight);
}
/** Site-Admin management of operator-uploaded fonts (issue #303). */
@Controller('admin/fonts')
@UseGuards(SiteAdminGuard)
export class CustomFontsAdminController {
constructor(private readonly fonts: CustomFontsService) {}
@Get()
list(): Promise<CustomFontView[]> {
return this.fonts.list();
}
@Post()
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_FONT_FILE_BYTES } }))
async create(
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<CustomFontView> {
// The metadata rides as ordinary multipart fields next to the files.
const input = createCustomFontInputSchema.parse({
family: request.body?.family,
category: request.body?.category,
licence: request.body?.licence,
licenceUrl: request.body?.licenceUrl || null,
});
return this.fonts.create(request.user!, input, parseUploads(files));
}
@Post(':id/weights')
@UseInterceptors(AnyFilesInterceptor({ limits: { fileSize: MAX_FONT_FILE_BYTES } }))
async addWeight(
@Param('id') id: string,
@Req() request: AuthedRequest,
@UploadedFiles() files: Express.Multer.File[] | undefined,
): Promise<CustomFontView> {
const uploads = parseUploads(files);
if (uploads.length !== 1) throw new BadRequestException({ code: 'font_one_weight_expected' });
return this.fonts.addWeight(request.user!, id, uploads[0]!);
}
/** How many live ponds still use the family — shown before deleting. */
@Get(':id/usage')
async usage(@Param('id') id: string): Promise<{ pondsAffected: number }> {
const font = (await this.fonts.list()).find((entry) => entry.id === id);
if (!font) throw new BadRequestException({ code: 'not_found' });
return { pondsAffected: await this.fonts.pondsUsing(font.family) };
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.fonts.remove(request.user!, id);
}
}
/**
* Serving route. Unauthenticated on purpose: a font is referenced from CSS,
* and the login screen carries the pond-independent chrome an authenticated
* font URL would simply not load. The bytes are branding, not content.
*/
@Controller('fonts/custom')
export class CustomFontsFileController {
constructor(
private readonly storage: CustomFontStorageService,
private readonly fonts: CustomFontsService,
) {}
@Public()
@Get(':slug/:file')
async serve(
@Param('slug') slug: string,
@Param('file') file: string,
@Res() res: Response,
): Promise<void> {
const match = /^([a-z0-9-]+)-(\d{3})\.(woff2|woff)$/.exec(file);
// The slug must match the file's own prefix, so the path cannot be used
// to reach a different family's directory.
if (!match || match[1] !== slug) throw new BadRequestException({ code: 'not_found' });
const known = (await this.fonts.list()).find((entry) => entry.slug === slug);
if (!known) throw new BadRequestException({ code: 'not_found' });
const format = match[3] as 'woff2' | 'woff';
const bytes = await this.storage
.read(slug, Number(match[2]), format)
.catch(() => Promise.reject(new BadRequestException({ code: 'not_found' })));
res.setHeader('Content-Type', format === 'woff2' ? 'font/woff2' : 'font/woff');
// Slug + weight + format identify the bytes; a changed family is a new
// upload under a new id, so a long lifetime is safe.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(bytes);
}
}

View File

@ -0,0 +1,220 @@
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, deletePondsWhere, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/** Smallest bytes that pass the magic check — the api never parses further. */
const woff2 = (): Buffer => Buffer.concat([Buffer.from('wOF2'), Buffer.alloc(64)]);
const woff = (): Buffer => Buffer.concat([Buffer.from('wOFF'), Buffer.alloc(64)]);
describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let fontsDir: string;
const suffix = uniqueSuffix();
const password = 'schriftverwaltung mit stil 1';
const admin = { username: `fa-${suffix}`, displayName: `Font Admin ${suffix}` };
const plain = { username: `fp-${suffix}`, displayName: `Font Plain ${suffix}` };
let adminCookie: string;
let plainCookie: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
// A real directory so the storage layer is exercised, not mocked — the
// point of this suite is that bytes actually land somewhere retrievable.
fontsDir = await mkdtemp(join(tmpdir(), 'dorfteich-fonts-'));
process.env.CUSTOM_FONTS_DIR = fontsDir;
app = await createTestApp();
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: admin.username,
email: `${admin.username}@example.org`,
displayName: admin.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
// additional_ponds defaults to 0 (ADR 0011) and the instance default is
// never raised — the usage test needs a pond, so grant an override.
await prisma.quotaOverride.create({
data: {
subjectType: 'USER',
subjectId: adminUser.id,
quotaKey: 'additional_ponds',
value: 10,
},
});
const plainUser = await users.createUser({
username: plain.username,
email: `${plain.username}@example.org`,
displayName: plain.displayName,
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.customFont.deleteMany({});
const ids = (
await prisma.user.findMany({
where: { username: { contains: suffix } },
select: { id: true },
})
).map((row) => row.id);
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } });
await deletePondsWhere(prisma, { owner: { username: { contains: suffix } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
await rm(fontsDir, { recursive: true, force: true });
delete process.env.CUSTOM_FONTS_DIR;
});
it('uploads a family, writes the bytes, and serves them back', async () => {
const created = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Hausschrift ${suffix}`)
.field('category', 'serif')
.field('licence', 'Commercial — Foundry XY')
.attach('woff2-400', woff2(), 'x.woff2')
.attach('woff-400', woff(), 'x.woff')
.expect(201);
expect(created.body.weights).toEqual([400]);
expect(created.body.licence).toBe('Commercial — Foundry XY');
const slug = created.body.slug as string;
// The bytes are really on disk, in the catalog's layout.
const onDisk = await readFile(join(fontsDir, slug, `${slug}-400.woff2`));
expect(onDisk.subarray(0, 4).toString()).toBe('wOF2');
// …and reachable without a session: a font is fetched from CSS.
const served = await api().get(`/api/v1/fonts/custom/${slug}/${slug}-400.woff2`).expect(200);
expect(served.headers['content-type']).toContain('font/woff2');
});
it('rejects a file that is not a font, whatever it is called', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Fake ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', Buffer.from('\x89PNG\r\n\x1a\n and more'), 'evil.woff2')
.expect(400);
expect(res.body.code).toBe('font_file_not_a_font');
});
it('refuses a family name that a catalog font already owns', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', 'Roboto')
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(409);
expect(res.body.code).toBe('font_family_reserved');
});
it('refuses a weight whose WOFF2 is missing', async () => {
const res = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `NurWoff ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff-400', woff(), 'x.woff')
.expect(400);
expect(res.body.code).toBe('font_woff2_missing');
});
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()
.post('/api/v1/admin/fonts')
.set('Cookie', plainCookie)
.field('family', `Nope ${suffix}`)
.field('category', 'serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(403);
});
it('counts the ponds a family is used by, and deletion leaves them working', async () => {
const created = await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Zählschrift ${suffix}`)
.field('category', 'sans-serif')
.field('licence', 'X')
.attach('woff2-400', woff2(), 'x.woff2')
.expect(201);
const pond = await api()
.post('/api/v1/ponds')
.set('Cookie', adminCookie)
.send({ name: `Schriftteich ${suffix}` })
.expect(201);
await api()
.patch(`/api/v1/ponds/${pond.body.id}`)
.set('Cookie', adminCookie)
.send({ fonts: { body: { family: `Zählschrift ${suffix}`, weight: 400 } } })
.expect(200);
const usage = await api()
.get(`/api/v1/admin/fonts/${created.body.id}/usage`)
.set('Cookie', adminCookie)
.expect(200);
expect(usage.body.pondsAffected).toBe(1);
// Deletion is never blocked by usage.
await api()
.delete(`/api/v1/admin/fonts/${created.body.id}`)
.set('Cookie', adminCookie)
.expect(204);
// The pond still resolves — it keeps the stored family name and falls
// back to the system stack, rather than breaking.
const after = await api()
.get(`/api/v1/ponds/${pond.body.slug}`)
.set('Cookie', adminCookie)
.expect(200);
expect(after.body.settings.fonts.body.family).toBe(`Zählschrift ${suffix}`);
expect(
await api().get('/api/v1/admin/fonts').set('Cookie', adminCookie).expect(200),
).toBeTruthy();
const audit = await prisma.auditEntry.findFirst({
where: { action: 'font.deleted', targetId: created.body.id },
});
expect(audit).not.toBeNull();
expect(audit!.details).toMatchObject({ pondsAffected: 1 });
});
});

View File

@ -0,0 +1,249 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CreateCustomFontInput,
CustomFontView,
FONT_CATALOG,
FontCategory,
FontUploadFormat,
MAX_FONT_FILE_BYTES,
MAX_FONT_WEIGHTS,
fontSlug,
hasFontMagic,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { CustomFontStorageService } from './custom-font-storage.service';
/** One weight's bytes as they arrive from the controller. */
export interface WeightUpload {
weight: number;
woff2: Buffer;
woff?: Buffer;
}
/**
* Operator-uploaded font families (issue #303, ADR 0016 §#303).
*
* Site-Admin-only, additive to the compile-time catalog, and deliberately
* incurious about the files: the api validates the magic number and the size
* and then stores the bytes. Family, category and licence come from the form.
*/
@Injectable()
export class CustomFontsService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: CustomFontStorageService,
private readonly audit: AuditService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(CustomFontsService.name);
}
/**
* Rejects bytes that are not what they claim to be, before anything is
* written. Deliberately the ONLY inspection: parsing the font would gain
* metadata the form already carries, at the price of a known
* memory-safety surface (ADR 0016 §#303).
*/
private assertUsableFont(bytes: Buffer, format: FontUploadFormat): void {
if (bytes.length === 0) throw new BadRequestException({ code: 'font_file_empty' });
if (bytes.length > MAX_FONT_FILE_BYTES) {
throw new BadRequestException({ code: 'font_file_too_large' });
}
if (!hasFontMagic(bytes, format)) {
throw new BadRequestException({ code: 'font_file_not_a_font' });
}
}
/**
* A custom family must not collide with a catalog one, by name or by slug:
* a pond stores `fonts.<slot>.family` as a plain string, so two families
* answering to the same name would make the PDF path embed whichever file
* it happened to find.
*/
private async assertNameIsFree(family: string, slug: string): Promise<void> {
const catalogHit = FONT_CATALOG.some(
(entry) => entry.family === family || fontSlug(entry.family) === slug,
);
if (catalogHit) throw new ConflictException({ code: 'font_family_reserved' });
const existing = await this.prisma.customFont.findFirst({
where: { OR: [{ family }, { slug }] },
select: { id: true },
});
if (existing) throw new ConflictException({ code: 'font_family_exists' });
}
private viewOf(font: {
id: string;
family: string;
slug: string;
category: string;
licence: string;
licenceUrl: string | null;
createdAt: Date;
weights: { weight: number }[];
}): CustomFontView {
return {
id: font.id,
family: font.family,
slug: font.slug,
category: font.category as FontCategory,
licence: font.licence,
licenceUrl: font.licenceUrl,
weights: font.weights.map((row) => row.weight).sort((a, b) => a - b),
createdAt: font.createdAt.toISOString(),
};
}
async list(): Promise<CustomFontView[]> {
const fonts = await this.prisma.customFont.findMany({
orderBy: { family: 'asc' },
include: { weights: { select: { weight: true } } },
});
return fonts.map((font) => this.viewOf(font));
}
async create(
admin: User,
input: CreateCustomFontInput,
uploads: WeightUpload[],
): Promise<CustomFontView> {
if (uploads.length === 0) throw new BadRequestException({ code: 'font_no_weights' });
if (uploads.length > MAX_FONT_WEIGHTS) {
throw new BadRequestException({ code: 'font_too_many_weights' });
}
for (const upload of uploads) {
this.assertUsableFont(upload.woff2, 'woff2');
if (upload.woff) this.assertUsableFont(upload.woff, 'woff');
}
const slug = fontSlug(input.family);
if (!slug) throw new BadRequestException({ code: 'font_family_unusable' });
await this.assertNameIsFree(input.family, slug);
// Row first, then bytes: a row without files is repairable (re-upload the
// weight), while files without a row would be invisible litter.
const font = await this.prisma.customFont.create({
data: {
family: input.family,
slug,
category: input.category,
licence: input.licence,
licenceUrl: input.licenceUrl,
uploadedBy: admin.id,
weights: {
create: uploads.map((upload) => ({
weight: upload.weight,
hasWoff: Boolean(upload.woff),
byteSize: upload.woff2.length,
})),
},
},
include: { weights: { select: { weight: true } } },
});
for (const upload of uploads) {
await this.storage.save(slug, upload.weight, 'woff2', upload.woff2);
if (upload.woff) await this.storage.save(slug, upload.weight, 'woff', upload.woff);
}
await this.audit.record({
action: 'font.uploaded',
actorId: admin.id,
targetType: 'font',
targetId: font.id,
details: { family: font.family },
});
return this.viewOf(font);
}
async addWeight(admin: User, fontId: string, upload: WeightUpload): Promise<CustomFontView> {
this.assertUsableFont(upload.woff2, 'woff2');
if (upload.woff) this.assertUsableFont(upload.woff, 'woff');
const font = await this.prisma.customFont.findUnique({
where: { id: fontId },
include: { weights: { select: { weight: true } } },
});
if (!font) throw new NotFoundException();
if (font.weights.length >= MAX_FONT_WEIGHTS) {
throw new BadRequestException({ code: 'font_too_many_weights' });
}
if (font.weights.some((row) => row.weight === upload.weight)) {
throw new ConflictException({ code: 'font_weight_exists' });
}
await this.prisma.customFontWeight.create({
data: {
fontId,
weight: upload.weight,
hasWoff: Boolean(upload.woff),
byteSize: upload.woff2.length,
},
});
await this.storage.save(font.slug, upload.weight, 'woff2', upload.woff2);
if (upload.woff) await this.storage.save(font.slug, upload.weight, 'woff', upload.woff);
await this.audit.record({
action: 'font.uploaded',
actorId: admin.id,
targetType: 'font',
targetId: fontId,
details: { family: font.family, weight: upload.weight },
});
const updated = await this.prisma.customFont.findUniqueOrThrow({
where: { id: fontId },
include: { weights: { select: { weight: true } } },
});
return this.viewOf(updated);
}
/**
* How many live ponds still name this family in any of their three font
* slots. Shown before deletion those ponds keep working (an unknown
* family falls back to the system stack) but they visibly change.
*/
async pondsUsing(family: string): Promise<number> {
const rows = await this.prisma.$queryRaw<{ count: bigint }[]>`
SELECT count(*)::bigint AS count
FROM ponds
WHERE deleted_at IS NULL
AND (settings #>> '{fonts,heading,family}' = ${family}
OR settings #>> '{fonts,body,family}' = ${family}
OR settings #>> '{fonts,mono,family}' = ${family})
`;
return Number(rows[0]?.count ?? 0);
}
/**
* Deletion is never blocked by usage. `fontStack` already yields the system
* fallback for an unknown family, so affected ponds degrade rather than
* break, and re-uploading the family restores them but the count travels
* into the audit entry so the change is not silent.
*/
async remove(admin: User, fontId: string): Promise<void> {
const font = await this.prisma.customFont.findUnique({ where: { id: fontId } });
if (!font) throw new NotFoundException();
const pondsAffected = await this.pondsUsing(font.family);
await this.prisma.customFont.delete({ where: { id: fontId } });
await this.storage.deleteFamily(font.slug);
await this.audit.record({
action: 'font.deleted',
actorId: admin.id,
targetType: 'font',
targetId: fontId,
details: { family: font.family, pondsAffected },
});
this.logger.info({ fontId, family: font.family, pondsAffected }, 'custom font deleted');
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsAdminController, CustomFontsFileController } from './custom-fonts.controller';
import { CustomFontsService } from './custom-fonts.service';
/** Operator-uploaded fonts (issue #303, ADR 0016 §#303). Exports the service
* so the PDF exporter can resolve a pond's font to a custom family. */
@Module({
controllers: [CustomFontsAdminController, CustomFontsFileController],
providers: [CustomFontsService, CustomFontStorageService],
exports: [CustomFontsService, CustomFontStorageService],
})
export class FontsModule {}

View File

@ -20,6 +20,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service'; import { FileStorageService } from '../files/file-storage.service';
import { CustomFontsService } from '../fonts/custom-fonts.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PluginsService } from '../plugins/plugins.service'; import { PluginsService } from '../plugins/plugins.service';
@ -53,6 +54,7 @@ export class ExportService {
private readonly plugins: PluginsService, private readonly plugins: PluginsService,
private readonly fallbacks: PluginFallbackRenderer, private readonly fallbacks: PluginFallbackRenderer,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly customFonts: CustomFontsService,
private readonly readTrail: ReadTrailService, private readonly readTrail: ReadTrailService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
@ -371,12 +373,18 @@ export class ExportService {
}); });
} }
/** Base64 `@font-face` rules for the pond's three fonts, read from the /** Base64 `@font-face` rules for the pond's three fonts. Catalog families
* catalog baked into the image (ADR 0016). A font file that is absent (a * come from the directory baked into the image (ADR 0016); operator-uploaded
* native dev run without `FONTS_DIR` populated) is skipped the render falls * ones from `CUSTOM_FONTS_DIR` (issue #303) same on-disk layout, so only
* back to the system stack rather than failing. */ * the base directory differs. A font file that is absent (a native dev run
* without `FONTS_DIR` populated, or a family deleted between the settings
* write and the export) is skipped: the render falls back to the system
* stack rather than failing. */
private async fontFaceCss(fonts: PondFonts): Promise<string> { private async fontFaceCss(fonts: PondFonts): Promise<string> {
const slots = [fonts.heading, fonts.body, fonts.mono]; const slots = [fonts.heading, fonts.body, fonts.mono];
const customSlugs = new Map(
(await this.customFonts.list()).map((font) => [font.family, font.slug]),
);
// Dedup identical family+weight so a doc that repeats a font embeds it once. // Dedup identical family+weight so a doc that repeats a font embeds it once.
const seen = new Set<string>(); const seen = new Set<string>();
const faces: string[] = []; const faces: string[] = [];
@ -384,8 +392,10 @@ export class ExportService {
const key = `${slot.family}:${slot.weight}`; const key = `${slot.family}:${slot.weight}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const slug = fontSlug(slot.family); const customSlug = customSlugs.get(slot.family);
const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`); const slug = customSlug ?? fontSlug(slot.family);
const baseDir = customSlug ? this.config.env.CUSTOM_FONTS_DIR : this.config.env.FONTS_DIR;
const file = join(baseDir, slug, `${slug}-${slot.weight}.woff2`);
try { try {
const bytes = await readFile(file); const bytes = await readFile(file);
faces.push( faces.push(
@ -393,7 +403,7 @@ export class ExportService {
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`, ` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
); );
} catch { } catch {
this.logger.warn({ font: key }, 'pdf export: catalog font file missing, using fallback'); this.logger.warn({ font: key }, 'pdf export: font file missing, using fallback');
} }
} }
return faces.join('\n'); return faces.join('\n');

View File

@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common';
import { CommonModule } from '../common/common.module'; import { CommonModule } from '../common/common.module';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
import { FontsModule } from '../fonts/fonts.module';
import { LabelsModule } from '../labels/labels.module'; import { LabelsModule } from '../labels/labels.module';
import { PagesModule } from '../pages/pages.module'; import { PagesModule } from '../pages/pages.module';
import { PluginsModule } from '../plugins/plugins.module'; import { PluginsModule } from '../plugins/plugins.module';
@ -40,6 +41,7 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
imports: [ imports: [
CommonModule, CommonModule,
FilesModule, FilesModule,
FontsModule,
LabelsModule, LabelsModule,
PagesModule, PagesModule,
PluginsModule, PluginsModule,

View File

@ -20,6 +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 \
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,18 @@
import type { BackupEnv } from '@dorfteich/shared';
/**
* The data directories that travel in a restore set's archive (ADR 0015).
*
* ONE list, used by the nightly archive AND by the restore they must not
* drift, or a backup would carry something the restore never puts back.
* Adding a new persistent data directory is a one-line change here plus the
* env entry and the compose mount.
*
* All of them must share a parent directory: `archiveBase` derives the tar
* root from that and throws otherwise.
*/
export function dataDirs(
env: Pick<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'>,
): string[] {
return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR];
}

View File

@ -6,6 +6,7 @@ import { pino } from 'pino';
import { createArchive } from './archive.js'; import { createArchive } from './archive.js';
import { createCommandListener } from './commands.js'; import { createCommandListener } from './commands.js';
import { dataDirs } from './data-dirs.js';
import { loadBackupEnv } from './config.js'; import { loadBackupEnv } from './config.js';
import { sendFailureMail } from './mail.js'; import { sendFailureMail } from './mail.js';
import { mirrorSets, resolveMirrorConfig } from './mirror.js'; import { mirrorSets, resolveMirrorConfig } from './mirror.js';
@ -50,7 +51,7 @@ async function buildRunnerDeps(trigger: 'scheduled' | 'manual'): Promise<RunnerD
retentionDays: settings.localRetentionDays ?? env.BACKUP_RETENTION_DAYS, retentionDays: settings.localRetentionDays ?? env.BACKUP_RETENTION_DAYS,
now: () => new Date(), now: () => new Date(),
dump: (outFile) => pgDump(env.DATABASE_URL, outFile), dump: (outFile) => pgDump(env.DATABASE_URL, outFile),
archive: (outFile) => createArchive(outFile, [env.UPLOADS_DIR, env.PLUGINS_DIR]), archive: (outFile) => createArchive(outFile, dataDirs(env)),
onFailure: async (run) => { onFailure: async (run) => {
const sent = await sendFailureMail(env, run); const sent = await sendFailureMail(env, run);
if (!sent) if (!sent)

View File

@ -5,18 +5,22 @@ import type { BackupEnv } from '@dorfteich/shared';
import { extractArchive } from './archive.js'; import { extractArchive } from './archive.js';
import { archiveFileName, dumpFileName } from './backup-set.js'; import { archiveFileName, dumpFileName } from './backup-set.js';
import { dataDirs } from './data-dirs.js';
import { pgRestore } from './pg.js'; import { pgRestore } from './pg.js';
import type { RemoteLogger } from './remote.js'; import type { RemoteLogger } from './remote.js';
/** /**
* Restores one local set into the live database and data volumes: * Restores one local set into the live database and data volumes:
* `pg_restore --clean --if-exists` of the dump, then the volume archive * `pg_restore --clean --if-exists` of the dump, then the volume archive
* back over the uploads/plugins mounts. Shared by the operator CLI * back over the data mounts (see data-dirs.ts). Shared by the operator CLI
* (restore.js via restore.sh) and the in-app restore orchestrator (#103) * (restore.js via restore.sh) and the in-app restore orchestrator (#103)
* one restore path, exercised by drills and the app alike. * one restore path, exercised by drills and the app alike.
*/ */
export async function performRestore( export async function performRestore(
env: Pick<BackupEnv, 'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR'>, env: Pick<
BackupEnv,
'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'
>,
backupId: string, backupId: string,
log: RemoteLogger, log: RemoteLogger,
): Promise<void> { ): Promise<void> {
@ -29,6 +33,6 @@ export async function performRestore(
} }
log.info({ backupId }, 'restoring database dump'); log.info({ backupId }, 'restoring database dump');
await pgRestore(env.DATABASE_URL, dumpFile); await pgRestore(env.DATABASE_URL, dumpFile);
log.info({ backupId }, 'restoring uploads/plugins archive'); log.info({ backupId }, 'restoring the data-directory archive');
await extractArchive(archiveFile, [env.UPLOADS_DIR, env.PLUGINS_DIR]); await extractArchive(archiveFile, dataDirs(env));
} }

View File

@ -87,6 +87,10 @@ services:
# Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site # Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site
# Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them. # Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them.
PLUGINS_DIR: /data/plugins PLUGINS_DIR: /data/plugins
# Operator-uploaded fonts (issue #303, ADR 0016 §#303). A sibling of
# uploads/plugins so all three travel in one restore set — NOT inside
# the image-baked font catalog, which a deploy would overwrite.
CUSTOM_FONTS_DIR: /data/fonts
# 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
@ -100,6 +104,7 @@ services:
volumes: volumes:
- uploads:/data/uploads - uploads:/data/uploads
- plugins:/data/plugins - plugins:/data/plugins
- customfonts:/data/fonts
- secrets:/data/secrets - secrets:/data/secrets
- backups:/data/backups:ro - backups:/data/backups:ro
depends_on: depends_on:
@ -184,10 +189,11 @@ services:
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
networks: [internal] networks: [internal]
volumes: volumes:
# Write access to uploads/plugins is for the restore path only; the # Write access to the data mounts is for the restore path only; the
# nightly run just reads them into the archive. # nightly run just reads them into the archive.
- uploads:/data/uploads - uploads:/data/uploads
- plugins:/data/plugins - plugins:/data/plugins
- customfonts:/data/fonts
- secrets:/data/secrets:ro - secrets:/data/secrets:ro
- backups:/backups - backups:/backups
depends_on: depends_on:
@ -273,6 +279,7 @@ volumes:
db-data: db-data:
uploads: uploads:
plugins: plugins:
customfonts:
secrets: secrets:
backups: backups:
# Only used by the optional `caddy` profile (certificates + state). # Only used by the optional `caddy` profile (certificates + state).

View File

@ -38,6 +38,43 @@ itself (never from Google's CDN) to avoid GDPR issues. Defaults: Roboto 400
- Image size grows by a few MiB per family (WOFF2, subset to latin/latin-ext - Image size grows by a few MiB per family (WOFF2, subset to latin/latin-ext
by default) — negligible. by default) — negligible.
## Decisions taken in #303 (2026-08-01)
This ADR's "Consequences" said adding a font is a catalog PR plus an image
rebuild, with **no runtime font management surface**. That is amended here,
for the case this ADR already anticipated under "Alternatives considered":
_arbitrary font upload … may become a Site-Admin-level feature later_.
An operator running a private instance holds a licence for a typeface and
wants to use it on screen and in exported PDFs. Baking it into a custom
image works but ties every font change to a rebuild, and the file then
lives in the image rather than in the backup.
- **Site Admins only.** Not Pond Admins — which is what closes the
licensing-risk objection above: the operator who holds the licence is
the only one who can upload, and the licence is recorded with the font.
Uploaded families are additive; they never replace or shadow a catalog
family, and a name collision with one is rejected.
- **Uploads are data, not code.** The api stores the submitted bytes and
serves them back with a pinned content type. It validates the magic
number (`wOF2`/`wOFF`) and a size cap, and it does **not** parse the
font — family, category and licence come from the form. This is the
answer to the file-format attack-surface objection: font parsers are a
known memory-safety surface and we gain nothing from entering it.
- **WOFF2 required, WOFF optional, OTF not accepted.** WOFF2 covers both
consumers we have — the browser and Gotenberg's Chromium — and is the
format the PDF path already inlines. Storing OTF would enlarge uploads
and backups for no runtime benefit.
- **The bytes live under `./data/`, not in `FONTS_DIR`.** `FONTS_DIR` is
the catalog directory baked into the image: anything written there is
lost on the next deploy and is never backed up. Custom fonts go to
`CUSTOM_FONTS_DIR` (default `./data/fonts`), a sibling of the uploads
and plugins directories, and are registered with the backup so a
restore brings them back.
- **The GDPR guarantee is untouched.** Custom fonts are served from the
instance itself like the catalog ones; `font-src 'self' data:` stays as
it is, and a visitor's browser still makes zero third-party requests.
## Alternatives considered ## Alternatives considered
- **Runtime font download by the server on admin selection**: flexible but - **Runtime font download by the server on admin selection**: flexible but

View File

@ -1,6 +1,7 @@
# Audit event catalogue # Audit event catalogue
**Catalogue version 1.5 (2026-07-31; 1.5 adds `plugin.rejected`, **Catalogue version 1.6 (2026-08-01; 1.6 adds `font.uploaded` and
`font.deleted`, issue #303; 1.5 added `plugin.rejected`,
issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added 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_*`,
issue #205).** issue #205).**
@ -125,8 +126,10 @@ failure), `warning` = feeds detection (suspicious or destructive),
### Plugins (`plugin.*`) ### Plugins (`plugin.*`)
| Id | Trigger | Severity | Actor | Target | Fields | | Id | Trigger | Severity | Actor | Target | Fields |
| --------------------- | ------------------------------------------------------------ | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ----------------------------------------------------------------- | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `plugin.installed` | Plugin package installed or updated | notice | admin, `null` for dropzone drop | `plugin` | `version`, `update` (bool) | | `plugin.installed` | Plugin package installed or updated | notice | admin, `null` for dropzone drop | `plugin` | `version`, `update` (bool) |
| `font.uploaded` | Site Admin uploaded a custom font family or added a weight (#303) | notice | admin | `font` | `family`, `weight` (when a single weight was added) |
| `font.deleted` | Site Admin removed a custom font family (#303) | notice | admin | `font` | `family`, `pondsAffected` (count of ponds still referencing it) |
| `plugin.rejected` | Install or load blocked by the hash-pinning allowlist (#232) | warning | admin for installs, `null` for loads | `plugin` | `surface` (`install`/`load`), `reason` (`not_pinned`/`hash_mismatch`/`unpinned`/`mismatch`), `version`, `bundleHash` (installs) | | `plugin.rejected` | Install or load blocked by the hash-pinning allowlist (#232) | warning | admin for installs, `null` for loads | `plugin` | `surface` (`install`/`load`), `reason` (`not_pinned`/`hash_mismatch`/`unpinned`/`mismatch`), `version`, `bundleHash` (installs) |
| `plugin.mode_set` | Instance mode changed (disabled/optional/required) | notice | the admin | `plugin` | `mode` | | `plugin.mode_set` | Instance mode changed (disabled/optional/required) | notice | the admin | `plugin` | `mode` |
| `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — | | `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — |

View File

@ -128,9 +128,21 @@ or sloppy plugin authors, compromised dependencies.
for non-image types, no user content served same-origin as executable for non-image types, no user content served same-origin as executable
(`X-Content-Type-Options: nosniff`; uploads path never serves (`X-Content-Type-Options: nosniff`; uploads path never serves
`text/html`). `text/html`).
- **Font upload (issue #303, ADR 0016 §#303)**: Site Admins — not Pond
Admins — may upload licensed font families. The api validates the magic
number (`wOF2`/`wOFF`) and a per-file size cap and then stores the bytes;
it deliberately does **not** parse the font. Family, category and licence
come from the form, so nothing is gained by entering a font parser's
memory-safety surface. OTF is not accepted (no consumer needs it). Files
are served from `/api/v1/fonts/custom/<slug>/<file>` with a pinned
`font/woff2`-or-`font/woff` content type and the instance-wide `nosniff`
header; the path is validated against the file's own slug prefix, so it
cannot reach another family's directory. Uploads and deletions are
audited (`font.uploaded`, `font.deleted`).
- 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". requests". Operator-uploaded fonts are served from the instance itself
like the catalog ones, so this is unchanged by #303.
- **Attachment integrity (issue #199)**: every upload stores the SHA-256 - **Attachment integrity (issue #199)**: every upload stores the SHA-256
of its bytes, computed from the in-memory buffer as it is written (never of its bytes, computed from the in-memory buffer as it is written (never
by re-reading disk). Every download re-hashes the stored object BEFORE by re-reading disk). Every download re-hashes the stored object BEFORE

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import {
FONT_CATALOG,
customFontEntries,
fontEntry,
fontStack,
hasFontMagic,
type CustomFontView,
} from './fonts';
const view = (family: string, category: CustomFontView['category']): CustomFontView => ({
id: 'id',
family,
slug: 'x',
category,
licence: 'Commercial',
licenceUrl: null,
weights: [400],
createdAt: '2026-08-01T00:00:00.000Z',
});
describe('custom fonts (issue #303)', () => {
it('accepts only real WOFF2/WOFF signatures', () => {
expect(hasFontMagic(Buffer.from('wOF2rest'), 'woff2')).toBe(true);
expect(hasFontMagic(Buffer.from('wOFFrest'), 'woff')).toBe(true);
// A renamed file is the case this actually guards against.
expect(hasFontMagic(Buffer.from('\x89PNG\r\n'), 'woff2')).toBe(false);
expect(hasFontMagic(Buffer.from('wOFF'), 'woff2')).toBe(false);
expect(hasFontMagic(Buffer.alloc(0), 'woff2')).toBe(false);
// Shorter than the signature must not read past the end.
expect(hasFontMagic(Buffer.from('wO'), 'woff2')).toBe(false);
});
it('never lets a custom family shadow a catalog one', () => {
const catalogFamily = FONT_CATALOG[0]!.family;
const impostor = customFontEntries([view(catalogFamily, 'monospace')]);
// The catalog wins even when a same-named custom entry is passed in.
expect(fontEntry(catalogFamily, impostor)?.category).toBe(FONT_CATALOG[0]!.category);
});
it('builds a stack for a custom family and falls back once it is gone', () => {
const extra = customFontEntries([view('Hausschrift', 'serif')]);
expect(fontStack('Hausschrift', extra)).toContain("'Hausschrift'");
expect(fontStack('Hausschrift', extra)).toContain('Georgia');
// Deleting the font removes it from `extra` — the pond must degrade to
// the system stack, not render an unresolvable family name.
expect(fontStack('Hausschrift')).not.toContain('Hausschrift');
});
});

View File

@ -118,6 +118,15 @@ export const apiEnvSchema = z.object({
* dev/test runs point this at the web app's built `public/fonts`. * dev/test runs point this at the web app's built `public/fonts`.
*/ */
FONTS_DIR: z.string().min(1).default('./fonts'), FONTS_DIR: z.string().min(1).default('./fonts'),
/**
* Directory of operator-uploaded fonts (issue #303, ADR 0016 §#303).
* Deliberately NOT under {@link FONTS_DIR}: that one is baked into the
* image, so anything written there is lost on the next deploy and never
* reaches a backup. This is a sibling of the uploads and plugins
* directories so it travels in the same restore set (ADR 0015).
* Layout mirrors the catalog: `<dir>/<slug>/<slug>-<weight>.woff2`.
*/
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'),
/** /**
* 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
@ -249,9 +258,11 @@ export const backupEnvSchema = z.object({
DATABASE_URL: databaseUrl, DATABASE_URL: databaseUrl,
/** Where restore sets and `status.json` are written (the `backups` volume). */ /** Where restore sets and `status.json` are written (the `backups` volume). */
BACKUPS_DIR: z.string().min(1).default('./data/backups'), BACKUPS_DIR: z.string().min(1).default('./data/backups'),
/** Same mounts as the api — archived together as one restore set. */ /** Same mounts as the api archived together as one restore set.
* The authoritative list lives in `apps/backup/src/data-dirs.ts`. */
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'),
/** 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

@ -2,14 +2,19 @@
* The curated self-hosted font catalog (ADR 0016). One maintained list drives * The curated self-hosted font catalog (ADR 0016). One maintained list drives
* everything: the build step downloads these families' WOFF2 subsets into the * everything: the build step downloads these families' WOFF2 subsets into the
* web image, the pond-settings Appearance UI offers them, and the attribution * web image, the pond-settings Appearance UI offers them, and the attribution
* page lists their licenses. Adding a font is a change here + an image rebuild * page lists their licenses. Adding a family HERE is a change plus an image
* there is no runtime font management (deliberately small surface). * rebuild. Since issue #303 an operator can additionally upload their own
* licensed families at runtime (ADR 0016 §#303); those live in the database
* and under CUSTOM_FONTS_DIR, are additive, and never shadow a catalog
* family see {@link fontEntry}'s `extra` parameter.
* *
* Fonts are served only from the instance itself (`font-src 'self' data:` * Fonts are served only from the instance itself (`font-src 'self' data:`
* the `data:` part covers fonts embedded inline in saved plugin SVGs, e.g. * the `data:` part covers fonts embedded inline in saved plugin SVGs, e.g.
* Excalidraw sketches); a visitor's browser makes zero third-party requests * Excalidraw sketches); a visitor's browser makes zero third-party requests
* (the GDPR guarantee, security.md). * (the GDPR guarantee, security.md).
*/ */
import { z } from 'zod';
export type FontCategory = 'sans-serif' | 'serif' | 'monospace'; export type FontCategory = 'sans-serif' | 'serif' | 'monospace';
export type FontLicense = 'OFL-1.1' | 'Apache-2.0'; export type FontLicense = 'OFL-1.1' | 'Apache-2.0';
@ -173,17 +178,120 @@ export function fontSlug(family: string): string {
.replace(/(^-|-$)/g, ''); .replace(/(^-|-$)/g, '');
} }
export function fontEntry(family: string): FontCatalogEntry | undefined { /**
return FONT_CATALOG.find((entry) => entry.family === family); * Look up a family. `extra` carries the instance's operator-uploaded fonts
* (issue #303) they are runtime data, so this package cannot hold them
* statically; callers in web and api pass what the api reported. A custom
* family never shadows a catalog one: uploads with a colliding name are
* rejected at the api, and the catalog is searched first regardless.
*/
export function fontEntry(
family: string,
extra: readonly FontCatalogEntry[] = [],
): FontCatalogEntry | undefined {
return (
FONT_CATALOG.find((entry) => entry.family === family) ??
extra.find((entry) => entry.family === family)
);
} }
/** /**
* The `font-family` stack for a chosen family: the family itself (when it is in * The `font-family` stack for a chosen family: the family itself (when it is
* the catalog) ahead of the category's system fallback, so text stays readable * known) ahead of the category's system fallback, so text stays readable
* before the WOFF2 loads or if the catalog font is unknown. * before the WOFF2 loads or if the family is unknown.
*
* Falling back for an unknown family is what makes deleting a custom font
* safe: the ponds using it degrade to the system stack instead of breaking,
* and re-uploading restores them.
*/ */
export function fontStack(family: string): string { export function fontStack(family: string, extra: readonly FontCatalogEntry[] = []): string {
const entry = fontEntry(family); const entry = fontEntry(family, extra);
const fallback = FONT_FALLBACKS[entry?.category ?? 'sans-serif']; const fallback = FONT_FALLBACKS[entry?.category ?? 'sans-serif'];
return entry ? `'${family}', ${fallback}` : fallback; return entry ? `'${family}', ${fallback}` : fallback;
} }
/** What the api reports about one uploaded family (issue #303). */
export interface CustomFontView {
id: string;
family: string;
slug: string;
category: FontCategory;
licence: string;
licenceUrl: string | null;
weights: number[];
createdAt: string;
}
/** Adapts uploaded fonts to the shape {@link fontStack} understands. */
export function customFontEntries(fonts: readonly CustomFontView[]): FontCatalogEntry[] {
return fonts.map((font) => ({
id: font.slug,
family: font.family,
category: font.category,
weights: font.weights,
// Not a catalog licence id — the label is free text and lives on the
// view; these two only satisfy the catalog shape.
license: 'OFL-1.1',
licenseUrl: font.licenceUrl ?? '',
}));
}
/** Formats accepted on upload. OTF is deliberately absent (ADR 0016 §#303):
* neither the browser nor Gotenberg's Chromium needs it. */
export const FONT_UPLOAD_FORMATS = ['woff2', 'woff'] as const;
export type FontUploadFormat = (typeof FONT_UPLOAD_FORMATS)[number];
/** Magic numbers of the accepted formats the ONLY inspection the api does
* on the bytes. Parsing the font would buy metadata we take from the form
* anyway, at the cost of entering a known memory-safety surface. */
const FONT_MAGIC: Readonly<Record<FontUploadFormat, string>> = {
woff2: 'wOF2',
woff: 'wOFF',
};
/** True when `bytes` starts with the format's signature. */
export function hasFontMagic(bytes: Uint8Array, format: FontUploadFormat): boolean {
const magic = FONT_MAGIC[format];
if (bytes.length < magic.length) return false;
for (let i = 0; i < magic.length; i += 1) {
if (bytes[i] !== magic.charCodeAt(i)) return false;
}
return true;
}
/** Per-file cap. A latin-subset WOFF2 is tens of KB; a full CJK face can
* reach a few MB, so 8 MiB leaves headroom without inviting abuse. */
export const MAX_FONT_FILE_BYTES = 8 * 1024 * 1024;
/** Cap on weights per family — the catalog's widest family offers five. */
export const MAX_FONT_WEIGHTS = 12;
/** Weights a custom family may declare: the CSS 100…900 ladder. */
export const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
export const FONT_CATEGORIES = ['sans-serif', 'serif', 'monospace'] as const;
/**
* Metadata half of a font upload (issue #303). The bytes travel as multipart
* files next to it. Everything here is what the OPERATOR states the api
* does not read it out of the font, on purpose (ADR 0016 §#303).
*/
export const createCustomFontInputSchema = z.object({
family: z.string().trim().min(1).max(80),
category: z.enum(FONT_CATEGORIES),
/** Free text so any licence can be named, commercial ones included. */
licence: z.string().trim().min(1).max(200),
licenceUrl: z.string().url().max(500).nullable().default(null),
});
export type CreateCustomFontInput = z.infer<typeof createCustomFontInputSchema>;
/** One weight of an upload; the files themselves ride as multipart parts. */
export const customFontWeightInputSchema = z.object({
weight: z.coerce
.number()
.int()
.refine((w) => (FONT_WEIGHTS as readonly number[]).includes(w), {
message: 'validation.invalid',
}),
});
export type CustomFontWeightInput = z.infer<typeof customFontWeightInputSchema>;