Instance-wide custom font upload: storage, API, PDF embedding, backup #303

Closed
opened 2026-08-01 06:19:28 +02:00 by opus-5 · 0 comments
Contributor

An operator running a private instance wants to use a font they hold a
licence for, in the browser and in exported PDFs. Today that is
impossible without building a custom image: FONT_CATALOG
(packages/shared/src/fonts.ts) is a compile-time list, and the build
step bakes the WOFF2 files into the image.

This issue covers the server side: storage, model, endpoints, PDF
embedding and backup. The UI follows in its own issue.

ADR 0016 must be amended first

packages/shared/src/fonts.ts states the current position plainly:
"Adding a font is a change here + an image rebuild — there is no runtime
font management (deliberately small surface)." This issue deliberately
reverses that, so the decision record has to move with it.

Write the amendment (nachgetragener ## Decisions entry in ADR 0016, or
a successor ADR that supersedes it) before the implementation, and
record the boundaries that keep the surface small:

  • Site admins only — not pond admins, not ordinary users.
  • Uploads are data, not code: stored bytes are served back with a pinned
    content type and never parsed by the api.
  • The curated catalogue stays the default; custom fonts are additive and
    never replace or shadow a catalogue family.

Where the files live — this one is not free to choose

Custom fonts must not go into FONTS_DIR (packages/shared/src/env.ts:120,
default ./fonts). That is the catalogue directory baked into the image;
anything written there is lost on the next deploy and is not backed up.

The backup archives exactly [UPLOADS_DIR, PLUGINS_DIR]
(apps/backup/src/index.ts:53), and archiveBase
(apps/backup/src/archive.ts:14) requires all data directories to share
one parent. So:

  • New env CUSTOM_FONTS_DIR, default ./data/fonts — a sibling of
    ./data/uploads and ./data/plugins.
  • Add it to the archive list in apps/backup/src/index.ts and to the
    restore path in apps/backup/src/perform-restore.ts:33 (and its
    Pick<BackupEnv, …> type at :19).
  • Mount the directory in the compose stacks. Remember CD does not sync
    stage composes — the change has to be applied on ONE separately.

Without this, a restore silently drops the operator's licensed font.

On-disk layout mirrors the catalogue so the PDF path stays uniform:
<CUSTOM_FONTS_DIR>/<slug>/<slug>-<weight>.woff2 (plus .woff when
supplied).

Model

New table custom_fonts: id, family, slug, category
(sans-serif | serif | monospace), licence label, optional licence URL,
uploader, timestamps. Weights live in a child table or a jsonb array —
one row per available weight, style normal only.

Italic is out of scope: the PDF @font-face builder emits
font-style: normal exclusively today
(apps/api/src/import-export/export.service.ts:392), and browsers
synthesise oblique. Worth a follow-up issue, not this one.

slug reuses fontSlug() from shared. Reject a family whose slug or
name collides with a FONT_CATALOG entry
— otherwise the pond setting
fonts.body.family becomes ambiguous and the PDF path could embed the
wrong file.

Endpoints (site admin only)

  • GET /admin/fonts — list custom fonts with weights and licence data.
  • POST /admin/fonts — create a family; multipart, one or more weight
    files.
  • POST /admin/fonts/:id/weights — add a weight to an existing family.
  • DELETE /admin/fonts/:id — remove family and files.
  • GET /fonts/custom/:slug/:file — serve a font file to any user who may
    see a pond using it. Public-ish by nature (a font is referenced from
    CSS); pin Content-Type: font/woff2, rely on the existing nosniff
    header, and set a long cache lifetime with the slug+weight in the path.

WOFF2 is required per family; WOFF optional. OTF is not accepted — it is
used by neither the browser nor Gotenberg's Chromium, so storing it would
only inflate the backup.

Validation

  • Magic bytes: wOF2 for WOFF2, wOFF for WOFF. Reject on mismatch
    rather than trusting the filename or the client's content type.
  • Per-file size cap (a few MB) and a cap on weights per family.
  • Do not parse the font to extract metadata. Family name, category
    and licence come from the submitted form. Font parsers are a
    well-known memory-safety surface and there is no reason to expose the
    api to one.

Catalogue lookup in shared

fontStack() and fontEntry() (packages/shared/src/fonts.ts) only
know FONT_CATALOG, and fontStack falls back to the sans-serif system
stack for an unknown family. Custom fonts are runtime data, so shared
cannot hold them statically — extend both to accept optional extra
entries, e.g. fontStack(family, extra?: readonly FontCatalogEntry[]),
and pass the instance's custom fonts at the call sites in web and api.

Keeping the fallback behaviour for a genuinely unknown family is what
makes deletion safe (below).

After touching shared: pnpm --filter @dorfteich/shared build — the
package resolves to dist, and skipping this has bitten the project
repeatedly, including for constants that look documentation-only.

PDF export

fontFaceCss() (apps/api/src/import-export/export.service.ts:378)
already reads WOFF2 from disk at request time and inlines it as base64,
so this is the smallest part of the change: look up the family, and read
from CUSTOM_FONTS_DIR instead of FONTS_DIR when it is a custom one.
The existing "file missing → skip the face, fall back to the system
stack" behaviour (:395) stays as is.

Deleting a font that is in use

Deletion is allowed and is not blocked by usage — but it must not be
silent. The delete response/UI names the ponds whose fonts settings
reference the family. Those ponds are not rewritten; fontStack()
already yields the system fallback for an unknown family, so they degrade
to the default look rather than breaking, and re-uploading the family
restores them.

Audit

Upload and deletion of an instance-wide font are site-admin actions and
must be audited. Per the CI gate (audit-catalogue.test.ts), each new
action needs an entry in apps/api/src/audit/audit-actions.ts and in
docs/architecture/audit-events.md, with the catalogue minor version
bumped — currently v1.5, so v1.6.

Also check whether the new endpoints or CUSTOM_FONTS_DIR need a line in
the Härtungsleitfaden plus triage in the catalogue or advisory list of
packages/shared/src/vs-nfd-profile.ts; the gate
vs-nfd-profile-catalogue.test.ts parses §1.1/§1.2 and fails otherwise.

Accessibility

No user-facing surface in this issue — it lands with the UI issue. The
serving route must set correct caching and content type so that fonts do
not silently fail to load, which would leave every affected pond on the
fallback stack.

Acceptance criteria

  • ADR 0016 amended (or superseded) and merged before or with the
    implementation.
  • A site admin can upload a WOFF2 family with one or more weights,
    list it, and delete it.
  • A non-admin gets 403/404 on every management endpoint, consistent
    with the project's 404/403 policy.
  • A file that is not a real WOFF2/WOFF is rejected.
  • A family colliding with a catalogue family is rejected.
  • A pond set to a custom font renders it in the browser and embeds it
    in the exported PDF, verified on a real export — not only in tests.
  • Deleting a font in use leaves the pond usable on the fallback
    stack, and the deletion names the affected ponds beforehand.
  • A backup taken after upload and restored into an empty stack brings
    the font back, and the PDF export still embeds it. Verified in a
    real restore, not asserted from the code.
  • Audit catalogue at v1.6 with docs/architecture/audit-events.md in
    step.
  • pnpm lint, pnpm typecheck, api suite green; shared rebuilt.
An operator running a private instance wants to use a font they hold a licence for, in the browser **and** in exported PDFs. Today that is impossible without building a custom image: `FONT_CATALOG` (`packages/shared/src/fonts.ts`) is a compile-time list, and the build step bakes the WOFF2 files into the image. This issue covers the server side: storage, model, endpoints, PDF embedding and backup. The UI follows in its own issue. ## ADR 0016 must be amended first `packages/shared/src/fonts.ts` states the current position plainly: "Adding a font is a change here + an image rebuild — there is no runtime font management (deliberately small surface)." This issue deliberately reverses that, so the decision record has to move with it. Write the amendment (nachgetragener `## Decisions` entry in ADR 0016, or a successor ADR that supersedes it) **before** the implementation, and record the boundaries that keep the surface small: - Site admins only — not pond admins, not ordinary users. - Uploads are data, not code: stored bytes are served back with a pinned content type and never parsed by the api. - The curated catalogue stays the default; custom fonts are additive and never replace or shadow a catalogue family. ## Where the files live — this one is not free to choose Custom fonts must **not** go into `FONTS_DIR` (`packages/shared/src/env.ts:120`, default `./fonts`). That is the catalogue directory baked into the image; anything written there is lost on the next deploy and is not backed up. The backup archives exactly `[UPLOADS_DIR, PLUGINS_DIR]` (`apps/backup/src/index.ts:53`), and `archiveBase` (`apps/backup/src/archive.ts:14`) requires all data directories to share one parent. So: - New env `CUSTOM_FONTS_DIR`, default `./data/fonts` — a sibling of `./data/uploads` and `./data/plugins`. - Add it to the archive list in `apps/backup/src/index.ts` and to the restore path in `apps/backup/src/perform-restore.ts:33` (and its `Pick<BackupEnv, …>` type at `:19`). - Mount the directory in the compose stacks. Remember CD does not sync stage composes — the change has to be applied on ONE separately. Without this, a restore silently drops the operator's licensed font. On-disk layout mirrors the catalogue so the PDF path stays uniform: `<CUSTOM_FONTS_DIR>/<slug>/<slug>-<weight>.woff2` (plus `.woff` when supplied). ## Model New table `custom_fonts`: id, family, slug, category (`sans-serif | serif | monospace`), licence label, optional licence URL, uploader, timestamps. Weights live in a child table or a jsonb array — one row per available weight, style `normal` only. Italic is out of scope: the PDF `@font-face` builder emits `font-style: normal` exclusively today (`apps/api/src/import-export/export.service.ts:392`), and browsers synthesise oblique. Worth a follow-up issue, not this one. `slug` reuses `fontSlug()` from shared. **Reject a family whose slug or name collides with a `FONT_CATALOG` entry** — otherwise the pond setting `fonts.body.family` becomes ambiguous and the PDF path could embed the wrong file. ## Endpoints (site admin only) - `GET /admin/fonts` — list custom fonts with weights and licence data. - `POST /admin/fonts` — create a family; multipart, one or more weight files. - `POST /admin/fonts/:id/weights` — add a weight to an existing family. - `DELETE /admin/fonts/:id` — remove family and files. - `GET /fonts/custom/:slug/:file` — serve a font file to any user who may see a pond using it. Public-ish by nature (a font is referenced from CSS); pin `Content-Type: font/woff2`, rely on the existing `nosniff` header, and set a long cache lifetime with the slug+weight in the path. WOFF2 is required per family; WOFF optional. OTF is not accepted — it is used by neither the browser nor Gotenberg's Chromium, so storing it would only inflate the backup. ### Validation - Magic bytes: `wOF2` for WOFF2, `wOFF` for WOFF. Reject on mismatch rather than trusting the filename or the client's content type. - Per-file size cap (a few MB) and a cap on weights per family. - Do **not** parse the font to extract metadata. Family name, category and licence come from the submitted form. Font parsers are a well-known memory-safety surface and there is no reason to expose the api to one. ## Catalogue lookup in shared `fontStack()` and `fontEntry()` (`packages/shared/src/fonts.ts`) only know `FONT_CATALOG`, and `fontStack` falls back to the sans-serif system stack for an unknown family. Custom fonts are runtime data, so shared cannot hold them statically — extend both to accept optional extra entries, e.g. `fontStack(family, extra?: readonly FontCatalogEntry[])`, and pass the instance's custom fonts at the call sites in web and api. Keeping the fallback behaviour for a genuinely unknown family is what makes deletion safe (below). After touching shared: `pnpm --filter @dorfteich/shared build` — the package resolves to `dist`, and skipping this has bitten the project repeatedly, including for constants that look documentation-only. ## PDF export `fontFaceCss()` (`apps/api/src/import-export/export.service.ts:378`) already reads WOFF2 from disk at request time and inlines it as base64, so this is the smallest part of the change: look up the family, and read from `CUSTOM_FONTS_DIR` instead of `FONTS_DIR` when it is a custom one. The existing "file missing → skip the face, fall back to the system stack" behaviour (`:395`) stays as is. ## Deleting a font that is in use Deletion is allowed and is not blocked by usage — but it must not be silent. The delete response/UI names the ponds whose `fonts` settings reference the family. Those ponds are **not** rewritten; `fontStack()` already yields the system fallback for an unknown family, so they degrade to the default look rather than breaking, and re-uploading the family restores them. ## Audit Upload and deletion of an instance-wide font are site-admin actions and must be audited. Per the CI gate (`audit-catalogue.test.ts`), each new action needs an entry in `apps/api/src/audit/audit-actions.ts` **and** in `docs/architecture/audit-events.md`, with the catalogue minor version bumped — currently v1.5, so v1.6. Also check whether the new endpoints or `CUSTOM_FONTS_DIR` need a line in the Härtungsleitfaden plus triage in the catalogue or advisory list of `packages/shared/src/vs-nfd-profile.ts`; the gate `vs-nfd-profile-catalogue.test.ts` parses §1.1/§1.2 and fails otherwise. ## Accessibility No user-facing surface in this issue — it lands with the UI issue. The serving route must set correct caching and content type so that fonts do not silently fail to load, which would leave every affected pond on the fallback stack. ## Acceptance criteria - [ ] ADR 0016 amended (or superseded) and merged before or with the implementation. - [ ] A site admin can upload a WOFF2 family with one or more weights, list it, and delete it. - [ ] A non-admin gets 403/404 on every management endpoint, consistent with the project's 404/403 policy. - [ ] A file that is not a real WOFF2/WOFF is rejected. - [ ] A family colliding with a catalogue family is rejected. - [ ] A pond set to a custom font renders it in the browser and embeds it in the exported PDF, verified on a real export — not only in tests. - [ ] Deleting a font in use leaves the pond usable on the fallback stack, and the deletion names the affected ponds beforehand. - [ ] A backup taken after upload and restored into an empty stack brings the font back, and the PDF export still embeds it. Verified in a real restore, not asserted from the code. - [ ] Audit catalogue at v1.6 with `docs/architecture/audit-events.md` in step. - [ ] `pnpm lint`, `pnpm typecheck`, api suite green; shared rebuilt.
opus-5 added this to the M33 — Tweaks & Feinschliff milestone 2026-08-01 06:19:28 +02:00
opus-5 added the
area:ops
effort:L
backend
labels 2026-08-01 06:19:28 +02:00
Sign in to join this conversation.
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: stwaidele/dorfteich#303
No description provided.