dorfteich/docs/architecture/adr/0016-self-hosted-fonts.md
Claude Opus 5 b96997501a
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
#303: operator-uploaded fonts — storage, API, PDF embedding, backup
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.
2026-08-01 14:49:13 +02:00

4.3 KiB
Raw Blame History

ADR 0016: Self-hosted Google Fonts, per-pond font configuration

  • Status: accepted
  • Date: 2026-07-04

Context

The vision: Pond Admins choose fonts for headings, body text, and monospace from a set of free Google Fonts; fonts must be served from the instance itself (never from Google's CDN) to avoid GDPR issues. Defaults: Roboto 400 (headings), Roboto 200 (body), Fira Code (monospace).

Decision

  • Curated font catalog: the repo contains a maintained list (~1525 families) of OFL/Apache-licensed families with the needed weights. A build step (deploy/fonts/) downloads the WOFF2 files at image build time from google-webfonts-helper/upstream sources and bakes them into the web image under /fonts/<family>/. No runtime download, no third-party requests from visitors' browsers — CSP allows font-src 'self' only.
  • Per-pond configuration: pond settings store three font slots (heading, body, mono), each referencing a catalog entry + weight. The app applies them as CSS custom properties (--font-heading, --font-body, --font-mono) on the pond's root element; @font-face rules for the catalog are generated once.
  • Defaults per vision: Roboto 400 / Roboto 200 / Fira Code. (Roboto 200 is provided via the variable font or the 200 static weight; fallback stack system-ui chain.)
  • Licensing: each catalog entry records its license (OFL/Apache); the catalog page in the app shows attribution.
  • Exports: the PDF renderer (ADR 0009) inlines the pond's fonts so PDFs match the on-screen look.

Consequences

  • Adding a font is a catalog PR + image rebuild — no runtime font management surface (deliberately small attack/complexity surface).
  • 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 adds an outbound dependency, cache invalidation, and licensing bookkeeping at runtime; rejected for v1.
  • Arbitrary font upload by Pond Admins: licensing risk and file-format attack surface; may become a Site-Admin-level feature later.