Instance-wide custom font upload: storage, API, PDF embedding, backup #303
Labels
No Label
area:auth
area:docs
area:export
area:ops
area:storage
area:supply-chain
auth
backend
blocked
collab
deployment
docs
effort:L
effort:M
effort:S
frontend
plugins
qa
vs-nfd
vs-nfd:blocker
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: stwaidele/dorfteich#303
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 buildstep 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.tsstates 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
## Decisionsentry in ADR 0016, ora successor ADR that supersedes it) before the implementation, and
record the boundaries that keep the surface small:
content type and never parsed by the api.
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), andarchiveBase(
apps/backup/src/archive.ts:14) requires all data directories to shareone parent. So:
CUSTOM_FONTS_DIR, default./data/fonts— a sibling of./data/uploadsand./data/plugins.apps/backup/src/index.tsand to therestore path in
apps/backup/src/perform-restore.ts:33(and itsPick<BackupEnv, …>type at:19).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.woffwhensupplied).
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
normalonly.Italic is out of scope: the PDF
@font-facebuilder emitsfont-style: normalexclusively today(
apps/api/src/import-export/export.service.ts:392), and browserssynthesise oblique. Worth a follow-up issue, not this one.
slugreusesfontSlug()from shared. Reject a family whose slug orname collides with a
FONT_CATALOGentry — otherwise the pond settingfonts.body.familybecomes ambiguous and the PDF path could embed thewrong 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 weightfiles.
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 maysee a pond using it. Public-ish by nature (a font is referenced from
CSS); pin
Content-Type: font/woff2, rely on the existingnosniffheader, 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
wOF2for WOFF2,wOFFfor WOFF. Reject on mismatchrather than trusting the filename or the client's content type.
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()andfontEntry()(packages/shared/src/fonts.ts) onlyknow
FONT_CATALOG, andfontStackfalls back to the sans-serif systemstack 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— thepackage resolves to
dist, and skipping this has bitten the projectrepeatedly, 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_DIRinstead ofFONTS_DIRwhen 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
fontssettingsreference 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 newaction needs an entry in
apps/api/src/audit/audit-actions.tsand indocs/architecture/audit-events.md, with the catalogue minor versionbumped — currently v1.5, so v1.6.
Also check whether the new endpoints or
CUSTOM_FONTS_DIRneed a line inthe Härtungsleitfaden plus triage in the catalogue or advisory list of
packages/shared/src/vs-nfd-profile.ts; the gatevs-nfd-profile-catalogue.test.tsparses §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
implementation.
list it, and delete it.
with the project's 404/403 policy.
in the exported PDF, verified on a real export — not only in tests.
stack, and the deletion names the affected ponds beforehand.
the font back, and the PDF export still embeds it. Verified in a
real restore, not asserted from the code.
docs/architecture/audit-events.mdinstep.
pnpm lint,pnpm typecheck, api suite green; shared rebuilt.