#303: operator-uploaded fonts — storage, API, PDF embedding, backup #311

Merged
opus-5 merged 1 commits from issue-303-custom-fonts into main 2026-08-01 15:06:40 +02:00
Contributor

Closes #303.

ADR 0016 first

The ADR said adding a font is a catalog PR plus an image rebuild, with no runtime font management surface. It also anticipated this feature under "Alternatives considered": arbitrary font upload … may become a Site-Admin-level feature later. The amendment (§Decisions taken in #303) takes that option and answers the two objections the ADR itself raised:

  • licensing risk → Site Admins only, and the licence is recorded with the family so an attribution obligation can be met;
  • file-format attack surface → the api validates the magic number and a size cap and then stores the bytes. It never parses the font. Family, category and licence come from the form, so a parser would buy nothing and cost a known memory-safety surface.

The part that would have failed silently

CUSTOM_FONTS_DIR defaults to ./data/fonts — a sibling of uploads and plugins, deliberately not inside FONTS_DIR, which is baked into the image: anything written there is lost on the next deploy and never reaches a backup.

Registering it took more than the issue assumed. The backup image bakes its volume paths into its own ENV ("self-sufficient without compose env" — #71's lesson) and reads no *_DIR from compose at all. Without the Dockerfile entry the nightly archive would have skipped the directory without a word, because createArchive tolerates absent directories by design. Both Dockerfiles now carry the path.

The list of archived directories moved into apps/backup/src/data-dirs.ts and feeds both the archive and the restore, so the two cannot drift apart. #306 and #307 add one line there instead of building a second mechanism.

Smaller notes

  • The PDF exporter already read WOFF2 from disk at request time, so it only had to choose the other base directory for a custom family. Same on-disk layout as the catalog.
  • fontStack/fontEntry gained an extra parameter — uploaded families are runtime data and cannot live in a static package. 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. fontStack already falls back to the system stack for an unknown family, so ponds using a deleted font degrade rather than break, and re-uploading restores them. The count of affected ponds is shown before deleting and recorded in the audit entry, so the change is not silent.
  • A WOFF without its WOFF2 is rejected: the PDF path embeds WOFF2 only, so such a weight would render on screen and vanish in exports.
  • Audit catalogue v1.6 (font.uploaded, font.deleted); docs/architecture/security.md gained the upload surface.
  • The Prisma schema diff looks large because prisma format realigned the file; git diff -w shows 46 added lines and nothing else.

Verification

api full suite against a fresh database: 102 files, 571 tests green. The upload suite writes into a real temporary directory and reads the bytes back off disk, so the storage layer is exercised rather than mocked, and it covers: upload → on-disk bytes → unauthenticated serving, a PNG renamed to .woff2 rejected, a catalog family name refused, a WOFF-only weight refused, every management route closed to non-admins, and the usage count plus a deletion that leaves the pond working.

Not verified here: an end-to-end PDF export embedding a custom font — that needs a Gotenberg sidecar. The code path is the same one the catalog fonts use, with the base directory swapped, and the fallback-on-missing-file behaviour is unchanged. Worth a manual check on a stage once #304 makes the font selectable in the UI.

Closes #303. ## ADR 0016 first The ADR said adding a font is a catalog PR plus an image rebuild, with *no runtime font management surface*. It also anticipated this feature under "Alternatives considered": *arbitrary font upload … may become a Site-Admin-level feature later*. The amendment (`§Decisions taken in #303`) takes that option and answers the two objections the ADR itself raised: - **licensing risk** → Site Admins only, and the licence is recorded with the family so an attribution obligation can be met; - **file-format attack surface** → the api validates the magic number and a size cap and then stores the bytes. It never parses the font. Family, category and licence come from the form, so a parser would buy nothing and cost a known memory-safety surface. ## The part that would have failed silently `CUSTOM_FONTS_DIR` defaults to `./data/fonts` — a sibling of uploads and plugins, deliberately **not** inside `FONTS_DIR`, which is baked into the image: anything written there is lost on the next deploy and never reaches a backup. Registering it took more than the issue assumed. The **backup image bakes its volume paths into its own `ENV`** ("self-sufficient without compose env" — #71's lesson) and reads no `*_DIR` from compose at all. Without the Dockerfile entry the nightly archive would have skipped the directory without a word, because `createArchive` tolerates absent directories by design. Both Dockerfiles now carry the path. The list of archived directories moved into `apps/backup/src/data-dirs.ts` and feeds **both** the archive and the restore, so the two cannot drift apart. #306 and #307 add one line there instead of building a second mechanism. ## Smaller notes - The PDF exporter already read WOFF2 from disk at request time, so it only had to choose the other base directory for a custom family. Same on-disk layout as the catalog. - `fontStack`/`fontEntry` gained an `extra` parameter — uploaded families are runtime data and cannot live in a static package. 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.** `fontStack` already falls back to the system stack for an unknown family, so ponds using a deleted font degrade rather than break, and re-uploading restores them. The count of affected ponds is shown before deleting and recorded in the audit entry, so the change is not silent. - A WOFF without its WOFF2 is rejected: the PDF path embeds WOFF2 only, so such a weight would render on screen and vanish in exports. - Audit catalogue **v1.6** (`font.uploaded`, `font.deleted`); `docs/architecture/security.md` gained the upload surface. - The Prisma schema diff looks large because `prisma format` realigned the file; `git diff -w` shows 46 added lines and nothing else. ## Verification api full suite against a fresh database: **102 files, 571 tests green.** The upload suite writes into a real temporary directory and reads the bytes back off disk, so the storage layer is exercised rather than mocked, and it covers: upload → on-disk bytes → unauthenticated serving, a PNG renamed to `.woff2` rejected, a catalog family name refused, a WOFF-only weight refused, every management route closed to non-admins, and the usage count plus a deletion that leaves the pond working. **Not verified here**: an end-to-end PDF export embedding a custom font — that needs a Gotenberg sidecar. The code path is the same one the catalog fonts use, with the base directory swapped, and the fallback-on-missing-file behaviour is unchanged. Worth a manual check on a stage once #304 makes the font selectable in the UI.
opus-5 added this to the M33 — Tweaks & Feinschliff milestone 2026-08-01 14:49:50 +02:00
opus-5 added 1 commit 2026-08-01 14:49:50 +02:00
#303: operator-uploaded fonts — storage, API, PDF embedding, backup
All checks were successful
CI / Build container images (pull_request) Successful in 3m53s
CI / Auth e2e pack (pull_request) Successful in 8m42s
CI / Auth e2e pack (push) Successful in 8m41s
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Deploy to Test (push) Successful in 16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m41s
CI / Build container images (push) Has been skipped
CI / Import/export fidelity gate (push) Successful in 52s
b96997501a
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.
opus-5 merged commit b96997501a into main 2026-08-01 15:06:40 +02:00
Sign in to join this conversation.
No description provided.