From b96997501ae4fcb6b54a28acf5349e1f78d35f55 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 14:49:13 +0200 Subject: [PATCH] =?UTF-8?q?#303:=20operator-uploaded=20fonts=20=E2=80=94?= =?UTF-8?q?=20storage,=20API,=20PDF=20embedding,=20backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/Dockerfile | 2 +- .../20260801120000_custom_fonts/migration.sql | 45 ++++ apps/api/prisma/schema.prisma | 158 +++++++---- apps/api/src/app.module.ts | 2 + apps/api/src/audit/audit-actions.ts | 2 + .../src/fonts/custom-font-storage.service.ts | 55 ++++ apps/api/src/fonts/custom-fonts.controller.ts | 146 ++++++++++ .../api/src/fonts/custom-fonts.e2e.db.test.ts | 220 ++++++++++++++++ apps/api/src/fonts/custom-fonts.service.ts | 249 ++++++++++++++++++ apps/api/src/fonts/fonts.module.ts | 14 + apps/api/src/import-export/export.service.ts | 24 +- .../src/import-export/import-export.module.ts | 2 + apps/backup/Dockerfile | 1 + apps/backup/src/data-dirs.ts | 18 ++ apps/backup/src/index.ts | 3 +- apps/backup/src/perform-restore.ts | 12 +- deploy/compose/docker-compose.yml | 9 +- .../adr/0016-self-hosted-fonts.md | 37 +++ docs/architecture/audit-events.md | 19 +- docs/architecture/security.md | 14 +- packages/shared/src/custom-fonts.test.ts | 50 ++++ packages/shared/src/env.ts | 13 +- packages/shared/src/fonts.ts | 126 ++++++++- 23 files changed, 1132 insertions(+), 89 deletions(-) create mode 100644 apps/api/prisma/migrations/20260801120000_custom_fonts/migration.sql create mode 100644 apps/api/src/fonts/custom-font-storage.service.ts create mode 100644 apps/api/src/fonts/custom-fonts.controller.ts create mode 100644 apps/api/src/fonts/custom-fonts.e2e.db.test.ts create mode 100644 apps/api/src/fonts/custom-fonts.service.ts create mode 100644 apps/api/src/fonts/fonts.module.ts create mode 100644 apps/backup/src/data-dirs.ts create mode 100644 packages/shared/src/custom-fonts.test.ts diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 5631b8c..880538e 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -29,7 +29,7 @@ ARG APP_VERSION=0.0.0-dev # Default the data dirs to the writable, node-owned locations created below, so # the image works out of the box even where compose does not set them; compose # still mounts named volumes here for persistence (UPLOADS_DIR/PLUGINS_DIR). -ENV NODE_ENV=production APP_VERSION=${APP_VERSION} UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins 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 COPY --from=build --chown=node:node /out /app # Generate the Prisma client for this image's platform. diff --git a/apps/api/prisma/migrations/20260801120000_custom_fonts/migration.sql b/apps/api/prisma/migrations/20260801120000_custom_fonts/migration.sql new file mode 100644 index 0000000..84c0869 --- /dev/null +++ b/apps/api/prisma/migrations/20260801120000_custom_fonts/migration.sql @@ -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..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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c6377bd..498672b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -31,42 +31,43 @@ enum UserStatus { /// Account profile. Login methods live in UserIdentity (OIDC-ready, /// ADR 0007); Site Admin is a user flag, all other roles are grants. model User { - id String @id @default(uuid()) - username String @unique - email String @unique - displayName String @map("display_name") - locale String @default("en") - isSiteAdmin Boolean @default(false) @map("is_site_admin") + id String @id @default(uuid()) + username String @unique + email String @unique + displayName String @map("display_name") + locale String @default("en") + isSiteAdmin Boolean @default(false) @map("is_site_admin") /// True when the flag was last SET by the IdP claim mapping (issue #217): /// only then may the mapping revoke it again on a later login. A manual /// admin toggle clears the marker, so hand-granted admins are never /// demoted by a missing claim. - isSiteAdminManaged Boolean @default(false) @map("is_site_admin_managed") + isSiteAdminManaged Boolean @default(false) @map("is_site_admin_managed") /// Auto-watch preferences (issue #93): watch pages I create / comment on. - autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages") - autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment") + autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages") + autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment") /// E-mail digest cadence (issue #95): hourly | daily | off. - digestFrequency String @default("hourly") @map("digest_frequency") - status UserStatus @default(PENDING_VERIFICATION) - emailVerifiedAt DateTime? @map("email_verified_at") - createdAt DateTime @default(now()) @map("created_at") - lastLoginAt DateTime? @map("last_login_at") + digestFrequency String @default("hourly") @map("digest_frequency") + status UserStatus @default(PENDING_VERIFICATION) + emailVerifiedAt DateTime? @map("email_verified_at") + createdAt DateTime @default(now()) @map("created_at") + lastLoginAt DateTime? @map("last_login_at") - identities UserIdentity[] - sessions Session[] - authTokens AuthToken[] - apiTokens ApiToken[] - feedTokens FeedToken[] - mentionRows PageMention[] - ponds Pond[] - pages Page[] - attachments Attachment[] - conversionJobs ConversionJob[] - auditEntries AuditEntry[] - comments Comment[] - watches Watch[] - notifications Notification[] - favorites PageFavorite[] + identities UserIdentity[] + sessions Session[] + authTokens AuthToken[] + apiTokens ApiToken[] + feedTokens FeedToken[] + mentionRows PageMention[] + ponds Pond[] + pages Page[] + attachments Attachment[] + conversionJobs ConversionJob[] + auditEntries AuditEntry[] + comments Comment[] + watches Watch[] + notifications Notification[] + favorites PageFavorite[] + customFonts CustomFont[] @@map("users") } @@ -107,32 +108,32 @@ model AuditEntry { /// lives per partition there (a partitioned parent cannot carry it without /// the partition key); `db push` test databases get it on the plain table. model ReadEvent { - id String @default(uuid()) - occurredAt DateTime @default(now()) @map("occurred_at") + id String @default(uuid()) + occurredAt DateTime @default(now()) @map("occurred_at") /// Null = anonymous reader (public grant); `sessionKey` still names the /// browsing session, so the anonymous marker is explicit, not an accident. - actorId String? @map("actor_id") + actorId String? @map("actor_id") /// `session:` for cookie sessions, `token:` for PATs, `job:` /// for background builds (account data export), `anon` for anonymous /// visitors — the dedup-window key basis (#223). - sessionKey String @map("session_key") - pageId String? @map("page_id") - pondId String @map("pond_id") + sessionKey String @map("session_key") + pageId String? @map("page_id") + pondId String @map("pond_id") /// Which read surface fired: `page_view` | `no_js_shell` | `public_api` | /// `attachment` | `export` | `collab_join` (READ_CHANNELS union in code). - channel String + channel String /// Classification at read time — a later reclassification must not /// rewrite history (ADR 0023). classification String - details Json? + details Json? /// Dedup window (issue #223): `::` plus the /// aligned bucket `floor(epoch / windowSeconds)`. The unique pair makes /// concurrent duplicate reads collapse race-free (insert or P2002-skip). - dedupKey String @map("dedup_key") - windowBucket BigInt @map("window_bucket") + dedupKey String @map("dedup_key") + windowBucket BigInt @map("window_bucket") /// Window length the event was recorded under — the row itself states it /// represents up to this many seconds, so the evidence is not overread. - windowSeconds Int @map("window_seconds") + windowSeconds Int @map("window_seconds") @@id([id, occurredAt]) @@unique([dedupKey, windowBucket]) @@ -237,14 +238,14 @@ model Pond { deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") - owner User @relation(fields: [ownerId], references: [id]) - usage PondUsage? - pages Page[] - attachments Attachment[] - labels Label[] - grants RoleGrant[] + owner User @relation(fields: [ownerId], references: [id]) + usage PondUsage? + pages Page[] + attachments Attachment[] + labels Label[] + grants RoleGrant[] conversionJobs ConversionJob[] - pondPlugins PondPlugin[] + pondPlugins PondPlugin[] @@index([ownerId]) @@map("ponds") @@ -460,12 +461,12 @@ model CollabOpenSession { /// built from the Yjs state via the shared editor schema. `outline` is the /// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb. model PageContentCache { - pageId String @id @map("page_id") - plainText String @map("plain_text") - markdown String - html String - outline Json - updatedAt DateTime @updatedAt @map("updated_at") + pageId String @id @map("page_id") + plainText String @map("plain_text") + markdown String + html String + outline Json + updatedAt DateTime @updatedAt @map("updated_at") /// Weighted full-text search vector (title A, labels B, body C; issue #49, /// ADR 0010). Maintained by the SearchProvider and the collab persistence /// hook (both write it with the same weighting). The GIN index is added in @@ -854,9 +855,9 @@ model ConversionJob { sourceName String? @map("source_name") resultPageId String? @map("result_page_id") - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade) - page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade) + page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull) @@index([status, createdAt]) @@map("conversion_jobs") @@ -913,3 +914,48 @@ model PondPlugin { @@id([pondId, pluginId]) @@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..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") +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5072a4d..bcd0852 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,6 +17,7 @@ import { FilesModule } from './files/files.module'; import { GrantsModule } from './grants/grants.module'; import { HealthModule } from './health/health.module'; import { HomeModule } from './home/home.module'; +import { FontsModule } from './fonts/fonts.module'; import { ImportExportModule } from './import-export/import-export.module'; import { LabelsModule } from './labels/labels.module'; import { LegalModule } from './legal/legal.module'; @@ -81,6 +82,7 @@ import { VersionsModule } from './versions/versions.module'; PublicModule, PublicApiModule, McpModule, + FontsModule, ImportExportModule, PluginsModule, AuthModule, diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index 5ed0f89..01fa883 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -45,6 +45,8 @@ export const AUDIT_EVENTS = { 'quota.override_set': { severity: 'notice' }, 'read_trail.pruned': { severity: 'info' }, 'settings.changed': { severity: 'notice' }, + 'font.uploaded': { severity: 'notice' }, + 'font.deleted': { severity: 'notice' }, 'setup.admin_created': { severity: 'notice' }, 'setup.completed': { severity: 'info' }, 'setup.preseeded': { severity: 'info' }, diff --git a/apps/api/src/fonts/custom-font-storage.service.ts b/apps/api/src/fonts/custom-font-storage.service.ts new file mode 100644 index 0000000..e53dba3 --- /dev/null +++ b/apps/api/src/fonts/custom-font-storage.service.ts @@ -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 — `/-.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 { + await mkdir(this.dirFor(slug), { recursive: true }); + await writeFile(this.pathFor(slug, weight, format), bytes); + } + + read(slug: string, weight: number, format: FontUploadFormat): Promise { + 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 { + await rm(this.dirFor(slug), { recursive: true, force: true }); + } + + async deleteWeight(slug: string, weight: number, format: FontUploadFormat): Promise { + await rm(this.pathFor(slug, weight, format), { force: true }); + } +} diff --git a/apps/api/src/fonts/custom-fonts.controller.ts b/apps/api/src/fonts/custom-fonts.controller.ts new file mode 100644 index 0000000..972fce3 --- /dev/null +++ b/apps/api/src/fonts/custom-fonts.controller.ts @@ -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-` and the optional `woff-`. */ +const FILE_FIELD = /^(woff2|woff)-(\d{3})$/; + +function parseUploads(files: Express.Multer.File[] | undefined): WeightUpload[] { + const byWeight = new Map(); + 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 { + 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 { + // 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/api/src/fonts/custom-fonts.e2e.db.test.ts b/apps/api/src/fonts/custom-fonts.e2e.db.test.ts new file mode 100644 index 0000000..67429d9 --- /dev/null +++ b/apps/api/src/fonts/custom-fonts.e2e.db.test.ts @@ -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 => + 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 }); + }); +}); diff --git a/apps/api/src/fonts/custom-fonts.service.ts b/apps/api/src/fonts/custom-fonts.service.ts new file mode 100644 index 0000000..1cd6ae3 --- /dev/null +++ b/apps/api/src/fonts/custom-fonts.service.ts @@ -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..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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/apps/api/src/fonts/fonts.module.ts b/apps/api/src/fonts/fonts.module.ts new file mode 100644 index 0000000..bc0e2e3 --- /dev/null +++ b/apps/api/src/fonts/fonts.module.ts @@ -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 {} diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index 2cd0ca5..b5e3cb0 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -20,6 +20,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { FileStorageService } from '../files/file-storage.service'; +import { CustomFontsService } from '../fonts/custom-fonts.service'; import { PermissionService } from '../permissions/permission.service'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginsService } from '../plugins/plugins.service'; @@ -53,6 +54,7 @@ export class ExportService { private readonly plugins: PluginsService, private readonly fallbacks: PluginFallbackRenderer, private readonly config: AppConfig, + private readonly customFonts: CustomFontsService, private readonly readTrail: ReadTrailService, private readonly logger: PinoLogger, ) { @@ -371,12 +373,18 @@ export class ExportService { }); } - /** Base64 `@font-face` rules for the pond's three fonts, read from the - * catalog baked into the image (ADR 0016). A font file that is absent (a - * native dev run without `FONTS_DIR` populated) is skipped — the render falls - * back to the system stack rather than failing. */ + /** Base64 `@font-face` rules for the pond's three fonts. Catalog families + * come from the directory baked into the image (ADR 0016); operator-uploaded + * ones from `CUSTOM_FONTS_DIR` (issue #303) — same on-disk layout, so only + * 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 { 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. const seen = new Set(); const faces: string[] = []; @@ -384,8 +392,10 @@ export class ExportService { const key = `${slot.family}:${slot.weight}`; if (seen.has(key)) continue; seen.add(key); - const slug = fontSlug(slot.family); - const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`); + const customSlug = customSlugs.get(slot.family); + 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 { const bytes = await readFile(file); faces.push( @@ -393,7 +403,7 @@ export class ExportService { ` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`, ); } 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'); diff --git a/apps/api/src/import-export/import-export.module.ts b/apps/api/src/import-export/import-export.module.ts index 5fcfbc2..b865c6e 100644 --- a/apps/api/src/import-export/import-export.module.ts +++ b/apps/api/src/import-export/import-export.module.ts @@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { CommonModule } from '../common/common.module'; import { FilesModule } from '../files/files.module'; +import { FontsModule } from '../fonts/fonts.module'; import { LabelsModule } from '../labels/labels.module'; import { PagesModule } from '../pages/pages.module'; import { PluginsModule } from '../plugins/plugins.module'; @@ -40,6 +41,7 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60; imports: [ CommonModule, FilesModule, + FontsModule, LabelsModule, PagesModule, PluginsModule, diff --git a/apps/backup/Dockerfile b/apps/backup/Dockerfile index b2a5aa4..7b9f638 100644 --- a/apps/backup/Dockerfile +++ b/apps/backup/Dockerfile @@ -20,6 +20,7 @@ ENV NODE_ENV=production APP_VERSION=${APP_VERSION} \ # Baked-in volume paths (self-sufficient without compose env, like the # api image's PLUGINS_DIR — issue #71's lesson). BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \ + CUSTOM_FONTS_DIR=/data/fonts \ SECRETS_FILE=/data/secrets/secrets.env # pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the # volume archives, tzdata so BACKUP_TIME honors a configured TZ, and diff --git a/apps/backup/src/data-dirs.ts b/apps/backup/src/data-dirs.ts new file mode 100644 index 0000000..0e6a258 --- /dev/null +++ b/apps/backup/src/data-dirs.ts @@ -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, +): string[] { + return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR]; +} diff --git a/apps/backup/src/index.ts b/apps/backup/src/index.ts index 69af953..87f505f 100644 --- a/apps/backup/src/index.ts +++ b/apps/backup/src/index.ts @@ -6,6 +6,7 @@ import { pino } from 'pino'; import { createArchive } from './archive.js'; import { createCommandListener } from './commands.js'; +import { dataDirs } from './data-dirs.js'; import { loadBackupEnv } from './config.js'; import { sendFailureMail } from './mail.js'; import { mirrorSets, resolveMirrorConfig } from './mirror.js'; @@ -50,7 +51,7 @@ async function buildRunnerDeps(trigger: 'scheduled' | 'manual'): Promise new Date(), 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) => { const sent = await sendFailureMail(env, run); if (!sent) diff --git a/apps/backup/src/perform-restore.ts b/apps/backup/src/perform-restore.ts index fbf261e..2108218 100644 --- a/apps/backup/src/perform-restore.ts +++ b/apps/backup/src/perform-restore.ts @@ -5,18 +5,22 @@ import type { BackupEnv } from '@dorfteich/shared'; import { extractArchive } from './archive.js'; import { archiveFileName, dumpFileName } from './backup-set.js'; +import { dataDirs } from './data-dirs.js'; import { pgRestore } from './pg.js'; import type { RemoteLogger } from './remote.js'; /** * Restores one local set into the live database and data volumes: * `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) — * one restore path, exercised by drills and the app alike. */ export async function performRestore( - env: Pick, + env: Pick< + BackupEnv, + 'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR' + >, backupId: string, log: RemoteLogger, ): Promise { @@ -29,6 +33,6 @@ export async function performRestore( } log.info({ backupId }, 'restoring database dump'); await pgRestore(env.DATABASE_URL, dumpFile); - log.info({ backupId }, 'restoring uploads/plugins archive'); - await extractArchive(archiveFile, [env.UPLOADS_DIR, env.PLUGINS_DIR]); + log.info({ backupId }, 'restoring the data-directory archive'); + await extractArchive(archiveFile, dataDirs(env)); } diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 39f6395..27547a4 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -87,6 +87,10 @@ services: # Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site # Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them. 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 # its status.json (readyz freshness #85, admin backup card #86). BACKUPS_DIR: /data/backups @@ -100,6 +104,7 @@ services: volumes: - uploads:/data/uploads - plugins:/data/plugins + - customfonts:/data/fonts - secrets:/data/secrets - backups:/data/backups:ro depends_on: @@ -184,10 +189,11 @@ services: SMTP_FROM: ${SMTP_FROM:-} networks: [internal] 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. - uploads:/data/uploads - plugins:/data/plugins + - customfonts:/data/fonts - secrets:/data/secrets:ro - backups:/backups depends_on: @@ -273,6 +279,7 @@ volumes: db-data: uploads: plugins: + customfonts: secrets: backups: # Only used by the optional `caddy` profile (certificates + state). diff --git a/docs/architecture/adr/0016-self-hosted-fonts.md b/docs/architecture/adr/0016-self-hosted-fonts.md index 41785c0..98ec547 100644 --- a/docs/architecture/adr/0016-self-hosted-fonts.md +++ b/docs/architecture/adr/0016-self-hosted-fonts.md @@ -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 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 - **Runtime font download by the server on admin selection**: flexible but diff --git a/docs/architecture/audit-events.md b/docs/architecture/audit-events.md index 6491e20..3565b5c 100644 --- a/docs/architecture/audit-events.md +++ b/docs/architecture/audit-events.md @@ -1,6 +1,7 @@ # 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 `read_trail.pruned`, issue #224; 1.1 added `page.classification_*`, issue #205).** @@ -124,13 +125,15 @@ failure), `warning` = feeds detection (suspicious or destructive), ### Plugins (`plugin.*`) -| Id | Trigger | Severity | Actor | Target | Fields | -| --------------------- | ------------------------------------------------------------ | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `plugin.installed` | Plugin package installed or updated | notice | admin, `null` for dropzone drop | `plugin` | `version`, `update` (bool) | -| `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.uninstalled` | Plugin removed | notice | the admin | `plugin` | — | -| `plugin.pond_toggled` | Optional plugin toggled for one pond | info | the pond admin | `pond` | `plugin`, `enabled` | +| Id | Trigger | Severity | Actor | Target | Fields | +| --------------------- | ----------------------------------------------------------------- | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `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.mode_set` | Instance mode changed (disabled/optional/required) | notice | the admin | `plugin` | `mode` | +| `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — | +| `plugin.pond_toggled` | Optional plugin toggled for one pond | info | the pond admin | `pond` | `plugin`, `enabled` | ### Backup & restore (`backup.*`) diff --git a/docs/architecture/security.md b/docs/architecture/security.md index ff2033d..db71f61 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -128,9 +128,21 @@ or sloppy plugin authors, compromised dependencies. for non-image types, no user content served same-origin as executable (`X-Content-Type-Options: nosniff`; uploads path never serves `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//` 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); 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 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 diff --git a/packages/shared/src/custom-fonts.test.ts b/packages/shared/src/custom-fonts.test.ts new file mode 100644 index 0000000..cf2ccbe --- /dev/null +++ b/packages/shared/src/custom-fonts.test.ts @@ -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'); + }); +}); diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index ec2ffd7..79a6cc9 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -118,6 +118,15 @@ export const apiEnvSchema = z.object({ * dev/test runs point this at the web app's built `public/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: `//-.woff2`. + */ + CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'), /** * Directory holding installed plugin packages (ADR 0008, issue #71). Layout * `///…` for unpacked bundles the sandbox iframe @@ -249,9 +258,11 @@ export const backupEnvSchema = z.object({ DATABASE_URL: databaseUrl, /** Where restore sets and `status.json` are written (the `backups` volume). */ 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'), 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. */ BACKUP_TIME: z .string() diff --git a/packages/shared/src/fonts.ts b/packages/shared/src/fonts.ts index 9cbf82e..43b7014 100644 --- a/packages/shared/src/fonts.ts +++ b/packages/shared/src/fonts.ts @@ -2,14 +2,19 @@ * The curated self-hosted font catalog (ADR 0016). One maintained list drives * everything: the build step downloads these families' WOFF2 subsets into the * 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 - * — there is no runtime font management (deliberately small surface). + * page lists their licenses. Adding a family HERE is a change plus an image + * 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:` — * the `data:` part covers fonts embedded inline in saved plugin SVGs, e.g. * Excalidraw sketches); a visitor's browser makes zero third-party requests * (the GDPR guarantee, security.md). */ +import { z } from 'zod'; + export type FontCategory = 'sans-serif' | 'serif' | 'monospace'; export type FontLicense = 'OFL-1.1' | 'Apache-2.0'; @@ -173,17 +178,120 @@ export function fontSlug(family: string): string { .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 catalog) ahead of the category's system fallback, so text stays readable - * before the WOFF2 loads or if the catalog font is unknown. + * The `font-family` stack for a chosen family: the family itself (when it is + * known) ahead of the category's system fallback, so text stays readable + * 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 { - const entry = fontEntry(family); +export function fontStack(family: string, extra: readonly FontCatalogEntry[] = []): string { + const entry = fontEntry(family, extra); const fallback = FONT_FALLBACKS[entry?.category ?? 'sans-serif']; 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> = { + 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; + +/** 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;