#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
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
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.
This commit is contained in:
parent
5164801676
commit
b96997501a
@ -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.
|
||||
|
||||
@ -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.<slot>.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;
|
||||
@ -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:<id>` for cookie sessions, `token:<id>` for PATs, `job:<id>`
|
||||
/// 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): `<sessionKey>:<pageId|->:<channel>` 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.<slot>.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")
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' },
|
||||
|
||||
55
apps/api/src/fonts/custom-font-storage.service.ts
Normal file
55
apps/api/src/fonts/custom-font-storage.service.ts
Normal file
@ -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 — `<slug>/<slug>-<weight>.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<void> {
|
||||
await mkdir(this.dirFor(slug), { recursive: true });
|
||||
await writeFile(this.pathFor(slug, weight, format), bytes);
|
||||
}
|
||||
|
||||
read(slug: string, weight: number, format: FontUploadFormat): Promise<Buffer> {
|
||||
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<void> {
|
||||
await rm(this.dirFor(slug), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async deleteWeight(slug: string, weight: number, format: FontUploadFormat): Promise<void> {
|
||||
await rm(this.pathFor(slug, weight, format), { force: true });
|
||||
}
|
||||
}
|
||||
146
apps/api/src/fonts/custom-fonts.controller.ts
Normal file
146
apps/api/src/fonts/custom-fonts.controller.ts
Normal file
@ -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-<weight>` and the optional `woff-<weight>`. */
|
||||
const FILE_FIELD = /^(woff2|woff)-(\d{3})$/;
|
||||
|
||||
function parseUploads(files: Express.Multer.File[] | undefined): WeightUpload[] {
|
||||
const byWeight = new Map<number, WeightUpload>();
|
||||
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<CustomFontView[]> {
|
||||
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<CustomFontView> {
|
||||
// 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<CustomFontView> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
220
apps/api/src/fonts/custom-fonts.e2e.db.test.ts
Normal file
220
apps/api/src/fonts/custom-fonts.e2e.db.test.ts
Normal file
@ -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<string> =>
|
||||
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 });
|
||||
});
|
||||
});
|
||||
249
apps/api/src/fonts/custom-fonts.service.ts
Normal file
249
apps/api/src/fonts/custom-fonts.service.ts
Normal file
@ -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.<slot>.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<void> {
|
||||
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<CustomFontView[]> {
|
||||
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<CustomFontView> {
|
||||
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<CustomFontView> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
14
apps/api/src/fonts/fonts.module.ts
Normal file
14
apps/api/src/fonts/fonts.module.ts
Normal file
@ -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 {}
|
||||
@ -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<string> {
|
||||
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<string>();
|
||||
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');
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
18
apps/backup/src/data-dirs.ts
Normal file
18
apps/backup/src/data-dirs.ts
Normal file
@ -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<BackupEnv, 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'>,
|
||||
): string[] {
|
||||
return [env.UPLOADS_DIR, env.PLUGINS_DIR, env.CUSTOM_FONTS_DIR];
|
||||
}
|
||||
@ -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<RunnerD
|
||||
retentionDays: settings.localRetentionDays ?? env.BACKUP_RETENTION_DAYS,
|
||||
now: () => 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)
|
||||
|
||||
@ -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<BackupEnv, 'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR'>,
|
||||
env: Pick<
|
||||
BackupEnv,
|
||||
'BACKUPS_DIR' | 'DATABASE_URL' | 'UPLOADS_DIR' | 'PLUGINS_DIR' | 'CUSTOM_FONTS_DIR'
|
||||
>,
|
||||
backupId: string,
|
||||
log: RemoteLogger,
|
||||
): Promise<void> {
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.*`)
|
||||
|
||||
|
||||
@ -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/<slug>/<file>` 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
|
||||
|
||||
50
packages/shared/src/custom-fonts.test.ts
Normal file
50
packages/shared/src/custom-fonts.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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: `<dir>/<slug>/<slug>-<weight>.woff2`.
|
||||
*/
|
||||
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'),
|
||||
/**
|
||||
* Directory holding installed plugin packages (ADR 0008, issue #71). Layout
|
||||
* `<PLUGINS_DIR>/<id>/<version>/…` 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()
|
||||
|
||||
@ -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<Record<FontUploadFormat, string>> = {
|
||||
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<typeof createCustomFontInputSchema>;
|
||||
|
||||
/** 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<typeof customFontWeightInputSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user