#303: operator-uploaded fonts — storage, API, PDF embedding, backup #311
@ -29,7 +29,7 @@ ARG APP_VERSION=0.0.0-dev
|
|||||||
# Default the data dirs to the writable, node-owned locations created below, so
|
# 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
|
# 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).
|
# 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
|
WORKDIR /app
|
||||||
COPY --from=build --chown=node:node /out /app
|
COPY --from=build --chown=node:node /out /app
|
||||||
# Generate the Prisma client for this image's platform.
|
# 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,
|
/// Account profile. Login methods live in UserIdentity (OIDC-ready,
|
||||||
/// ADR 0007); Site Admin is a user flag, all other roles are grants.
|
/// ADR 0007); Site Admin is a user flag, all other roles are grants.
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
locale String @default("en")
|
locale String @default("en")
|
||||||
isSiteAdmin Boolean @default(false) @map("is_site_admin")
|
isSiteAdmin Boolean @default(false) @map("is_site_admin")
|
||||||
/// True when the flag was last SET by the IdP claim mapping (issue #217):
|
/// 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
|
/// 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
|
/// admin toggle clears the marker, so hand-granted admins are never
|
||||||
/// demoted by a missing claim.
|
/// 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.
|
/// Auto-watch preferences (issue #93): watch pages I create / comment on.
|
||||||
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
|
autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages")
|
||||||
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
|
autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment")
|
||||||
/// E-mail digest cadence (issue #95): hourly | daily | off.
|
/// E-mail digest cadence (issue #95): hourly | daily | off.
|
||||||
digestFrequency String @default("hourly") @map("digest_frequency")
|
digestFrequency String @default("hourly") @map("digest_frequency")
|
||||||
status UserStatus @default(PENDING_VERIFICATION)
|
status UserStatus @default(PENDING_VERIFICATION)
|
||||||
emailVerifiedAt DateTime? @map("email_verified_at")
|
emailVerifiedAt DateTime? @map("email_verified_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
lastLoginAt DateTime? @map("last_login_at")
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
|
|
||||||
identities UserIdentity[]
|
identities UserIdentity[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
authTokens AuthToken[]
|
authTokens AuthToken[]
|
||||||
apiTokens ApiToken[]
|
apiTokens ApiToken[]
|
||||||
feedTokens FeedToken[]
|
feedTokens FeedToken[]
|
||||||
mentionRows PageMention[]
|
mentionRows PageMention[]
|
||||||
ponds Pond[]
|
ponds Pond[]
|
||||||
pages Page[]
|
pages Page[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
auditEntries AuditEntry[]
|
auditEntries AuditEntry[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
watches Watch[]
|
watches Watch[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
favorites PageFavorite[]
|
favorites PageFavorite[]
|
||||||
|
customFonts CustomFont[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -107,32 +108,32 @@ model AuditEntry {
|
|||||||
/// lives per partition there (a partitioned parent cannot carry it without
|
/// lives per partition there (a partitioned parent cannot carry it without
|
||||||
/// the partition key); `db push` test databases get it on the plain table.
|
/// the partition key); `db push` test databases get it on the plain table.
|
||||||
model ReadEvent {
|
model ReadEvent {
|
||||||
id String @default(uuid())
|
id String @default(uuid())
|
||||||
occurredAt DateTime @default(now()) @map("occurred_at")
|
occurredAt DateTime @default(now()) @map("occurred_at")
|
||||||
/// Null = anonymous reader (public grant); `sessionKey` still names the
|
/// Null = anonymous reader (public grant); `sessionKey` still names the
|
||||||
/// browsing session, so the anonymous marker is explicit, not an accident.
|
/// 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>`
|
/// `session:<id>` for cookie sessions, `token:<id>` for PATs, `job:<id>`
|
||||||
/// for background builds (account data export), `anon` for anonymous
|
/// for background builds (account data export), `anon` for anonymous
|
||||||
/// visitors — the dedup-window key basis (#223).
|
/// visitors — the dedup-window key basis (#223).
|
||||||
sessionKey String @map("session_key")
|
sessionKey String @map("session_key")
|
||||||
pageId String? @map("page_id")
|
pageId String? @map("page_id")
|
||||||
pondId String @map("pond_id")
|
pondId String @map("pond_id")
|
||||||
/// Which read surface fired: `page_view` | `no_js_shell` | `public_api` |
|
/// Which read surface fired: `page_view` | `no_js_shell` | `public_api` |
|
||||||
/// `attachment` | `export` | `collab_join` (READ_CHANNELS union in code).
|
/// `attachment` | `export` | `collab_join` (READ_CHANNELS union in code).
|
||||||
channel String
|
channel String
|
||||||
/// Classification at read time — a later reclassification must not
|
/// Classification at read time — a later reclassification must not
|
||||||
/// rewrite history (ADR 0023).
|
/// rewrite history (ADR 0023).
|
||||||
classification String
|
classification String
|
||||||
details Json?
|
details Json?
|
||||||
/// Dedup window (issue #223): `<sessionKey>:<pageId|->:<channel>` plus the
|
/// Dedup window (issue #223): `<sessionKey>:<pageId|->:<channel>` plus the
|
||||||
/// aligned bucket `floor(epoch / windowSeconds)`. The unique pair makes
|
/// aligned bucket `floor(epoch / windowSeconds)`. The unique pair makes
|
||||||
/// concurrent duplicate reads collapse race-free (insert or P2002-skip).
|
/// concurrent duplicate reads collapse race-free (insert or P2002-skip).
|
||||||
dedupKey String @map("dedup_key")
|
dedupKey String @map("dedup_key")
|
||||||
windowBucket BigInt @map("window_bucket")
|
windowBucket BigInt @map("window_bucket")
|
||||||
/// Window length the event was recorded under — the row itself states it
|
/// Window length the event was recorded under — the row itself states it
|
||||||
/// represents up to this many seconds, so the evidence is not overread.
|
/// 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])
|
@@id([id, occurredAt])
|
||||||
@@unique([dedupKey, windowBucket])
|
@@unique([dedupKey, windowBucket])
|
||||||
@ -237,14 +238,14 @@ model Pond {
|
|||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
deletedBy String? @map("deleted_by")
|
deletedBy String? @map("deleted_by")
|
||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
usage PondUsage?
|
usage PondUsage?
|
||||||
pages Page[]
|
pages Page[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
labels Label[]
|
labels Label[]
|
||||||
grants RoleGrant[]
|
grants RoleGrant[]
|
||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
pondPlugins PondPlugin[]
|
pondPlugins PondPlugin[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("ponds")
|
@@map("ponds")
|
||||||
@ -460,12 +461,12 @@ model CollabOpenSession {
|
|||||||
/// built from the Yjs state via the shared editor schema. `outline` is the
|
/// built from the Yjs state via the shared editor schema. `outline` is the
|
||||||
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
|
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
|
||||||
model PageContentCache {
|
model PageContentCache {
|
||||||
pageId String @id @map("page_id")
|
pageId String @id @map("page_id")
|
||||||
plainText String @map("plain_text")
|
plainText String @map("plain_text")
|
||||||
markdown String
|
markdown String
|
||||||
html String
|
html String
|
||||||
outline Json
|
outline Json
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
|
/// Weighted full-text search vector (title A, labels B, body C; issue #49,
|
||||||
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
|
/// ADR 0010). Maintained by the SearchProvider and the collab persistence
|
||||||
/// hook (both write it with the same weighting). The GIN index is added in
|
/// 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")
|
sourceName String? @map("source_name")
|
||||||
resultPageId String? @map("result_page_id")
|
resultPageId String? @map("result_page_id")
|
||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
|
pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade)
|
||||||
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
|
page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@map("conversion_jobs")
|
@@map("conversion_jobs")
|
||||||
@ -913,3 +914,48 @@ model PondPlugin {
|
|||||||
@@id([pondId, pluginId])
|
@@id([pondId, pluginId])
|
||||||
@@map("pond_plugins")
|
@@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 { GrantsModule } from './grants/grants.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { HomeModule } from './home/home.module';
|
import { HomeModule } from './home/home.module';
|
||||||
|
import { FontsModule } from './fonts/fonts.module';
|
||||||
import { ImportExportModule } from './import-export/import-export.module';
|
import { ImportExportModule } from './import-export/import-export.module';
|
||||||
import { LabelsModule } from './labels/labels.module';
|
import { LabelsModule } from './labels/labels.module';
|
||||||
import { LegalModule } from './legal/legal.module';
|
import { LegalModule } from './legal/legal.module';
|
||||||
@ -81,6 +82,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
PublicApiModule,
|
PublicApiModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
|
FontsModule,
|
||||||
ImportExportModule,
|
ImportExportModule,
|
||||||
PluginsModule,
|
PluginsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export const AUDIT_EVENTS = {
|
|||||||
'quota.override_set': { severity: 'notice' },
|
'quota.override_set': { severity: 'notice' },
|
||||||
'read_trail.pruned': { severity: 'info' },
|
'read_trail.pruned': { severity: 'info' },
|
||||||
'settings.changed': { severity: 'notice' },
|
'settings.changed': { severity: 'notice' },
|
||||||
|
'font.uploaded': { severity: 'notice' },
|
||||||
|
'font.deleted': { severity: 'notice' },
|
||||||
'setup.admin_created': { severity: 'notice' },
|
'setup.admin_created': { severity: 'notice' },
|
||||||
'setup.completed': { severity: 'info' },
|
'setup.completed': { severity: 'info' },
|
||||||
'setup.preseeded': { 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 { AppConfig } from '../config/app-config.service';
|
||||||
import { FileStorageService } from '../files/file-storage.service';
|
import { FileStorageService } from '../files/file-storage.service';
|
||||||
|
import { CustomFontsService } from '../fonts/custom-fonts.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
||||||
import { PluginsService } from '../plugins/plugins.service';
|
import { PluginsService } from '../plugins/plugins.service';
|
||||||
@ -53,6 +54,7 @@ export class ExportService {
|
|||||||
private readonly plugins: PluginsService,
|
private readonly plugins: PluginsService,
|
||||||
private readonly fallbacks: PluginFallbackRenderer,
|
private readonly fallbacks: PluginFallbackRenderer,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
|
private readonly customFonts: CustomFontsService,
|
||||||
private readonly readTrail: ReadTrailService,
|
private readonly readTrail: ReadTrailService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
@ -371,12 +373,18 @@ export class ExportService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Base64 `@font-face` rules for the pond's three fonts, read from the
|
/** Base64 `@font-face` rules for the pond's three fonts. Catalog families
|
||||||
* catalog baked into the image (ADR 0016). A font file that is absent (a
|
* come from the directory baked into the image (ADR 0016); operator-uploaded
|
||||||
* native dev run without `FONTS_DIR` populated) is skipped — the render falls
|
* ones from `CUSTOM_FONTS_DIR` (issue #303) — same on-disk layout, so only
|
||||||
* back to the system stack rather than failing. */
|
* 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> {
|
private async fontFaceCss(fonts: PondFonts): Promise<string> {
|
||||||
const slots = [fonts.heading, fonts.body, fonts.mono];
|
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.
|
// Dedup identical family+weight so a doc that repeats a font embeds it once.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const faces: string[] = [];
|
const faces: string[] = [];
|
||||||
@ -384,8 +392,10 @@ export class ExportService {
|
|||||||
const key = `${slot.family}:${slot.weight}`;
|
const key = `${slot.family}:${slot.weight}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
const slug = fontSlug(slot.family);
|
const customSlug = customSlugs.get(slot.family);
|
||||||
const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
|
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 {
|
try {
|
||||||
const bytes = await readFile(file);
|
const bytes = await readFile(file);
|
||||||
faces.push(
|
faces.push(
|
||||||
@ -393,7 +403,7 @@ export class ExportService {
|
|||||||
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
|
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
|
||||||
);
|
);
|
||||||
} catch {
|
} 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');
|
return faces.join('\n');
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common';
|
|||||||
|
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
|
import { FontsModule } from '../fonts/fonts.module';
|
||||||
import { LabelsModule } from '../labels/labels.module';
|
import { LabelsModule } from '../labels/labels.module';
|
||||||
import { PagesModule } from '../pages/pages.module';
|
import { PagesModule } from '../pages/pages.module';
|
||||||
import { PluginsModule } from '../plugins/plugins.module';
|
import { PluginsModule } from '../plugins/plugins.module';
|
||||||
@ -40,6 +41,7 @@ const PAYLOAD_PRUNE_CADENCE_SECONDS = 24 * 60 * 60;
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
FontsModule,
|
||||||
LabelsModule,
|
LabelsModule,
|
||||||
PagesModule,
|
PagesModule,
|
||||||
PluginsModule,
|
PluginsModule,
|
||||||
|
|||||||
@ -20,6 +20,7 @@ ENV NODE_ENV=production APP_VERSION=${APP_VERSION} \
|
|||||||
# Baked-in volume paths (self-sufficient without compose env, like the
|
# Baked-in volume paths (self-sufficient without compose env, like the
|
||||||
# api image's PLUGINS_DIR — issue #71's lesson).
|
# api image's PLUGINS_DIR — issue #71's lesson).
|
||||||
BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \
|
BACKUPS_DIR=/backups UPLOADS_DIR=/data/uploads PLUGINS_DIR=/data/plugins \
|
||||||
|
CUSTOM_FONTS_DIR=/data/fonts \
|
||||||
SECRETS_FILE=/data/secrets/secrets.env
|
SECRETS_FILE=/data/secrets/secrets.env
|
||||||
# pg_dump/pg_restore matching the stack's postgres:17 server, GNU tar for the
|
# 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
|
# 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 { createArchive } from './archive.js';
|
||||||
import { createCommandListener } from './commands.js';
|
import { createCommandListener } from './commands.js';
|
||||||
|
import { dataDirs } from './data-dirs.js';
|
||||||
import { loadBackupEnv } from './config.js';
|
import { loadBackupEnv } from './config.js';
|
||||||
import { sendFailureMail } from './mail.js';
|
import { sendFailureMail } from './mail.js';
|
||||||
import { mirrorSets, resolveMirrorConfig } from './mirror.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,
|
retentionDays: settings.localRetentionDays ?? env.BACKUP_RETENTION_DAYS,
|
||||||
now: () => new Date(),
|
now: () => new Date(),
|
||||||
dump: (outFile) => pgDump(env.DATABASE_URL, outFile),
|
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) => {
|
onFailure: async (run) => {
|
||||||
const sent = await sendFailureMail(env, run);
|
const sent = await sendFailureMail(env, run);
|
||||||
if (!sent)
|
if (!sent)
|
||||||
|
|||||||
@ -5,18 +5,22 @@ import type { BackupEnv } from '@dorfteich/shared';
|
|||||||
|
|
||||||
import { extractArchive } from './archive.js';
|
import { extractArchive } from './archive.js';
|
||||||
import { archiveFileName, dumpFileName } from './backup-set.js';
|
import { archiveFileName, dumpFileName } from './backup-set.js';
|
||||||
|
import { dataDirs } from './data-dirs.js';
|
||||||
import { pgRestore } from './pg.js';
|
import { pgRestore } from './pg.js';
|
||||||
import type { RemoteLogger } from './remote.js';
|
import type { RemoteLogger } from './remote.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores one local set into the live database and data volumes:
|
* Restores one local set into the live database and data volumes:
|
||||||
* `pg_restore --clean --if-exists` of the dump, then the volume archive
|
* `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) —
|
* (restore.js via restore.sh) and the in-app restore orchestrator (#103) —
|
||||||
* one restore path, exercised by drills and the app alike.
|
* one restore path, exercised by drills and the app alike.
|
||||||
*/
|
*/
|
||||||
export async function performRestore(
|
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,
|
backupId: string,
|
||||||
log: RemoteLogger,
|
log: RemoteLogger,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -29,6 +33,6 @@ export async function performRestore(
|
|||||||
}
|
}
|
||||||
log.info({ backupId }, 'restoring database dump');
|
log.info({ backupId }, 'restoring database dump');
|
||||||
await pgRestore(env.DATABASE_URL, dumpFile);
|
await pgRestore(env.DATABASE_URL, dumpFile);
|
||||||
log.info({ backupId }, 'restoring uploads/plugins archive');
|
log.info({ backupId }, 'restoring the data-directory archive');
|
||||||
await extractArchive(archiveFile, [env.UPLOADS_DIR, env.PLUGINS_DIR]);
|
await extractArchive(archiveFile, dataDirs(env));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,10 @@ services:
|
|||||||
# Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site
|
# Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site
|
||||||
# Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them.
|
# Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them.
|
||||||
PLUGINS_DIR: /data/plugins
|
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
|
# Read-only view of the backup sidecar's volume — the api only consumes
|
||||||
# its status.json (readyz freshness #85, admin backup card #86).
|
# its status.json (readyz freshness #85, admin backup card #86).
|
||||||
BACKUPS_DIR: /data/backups
|
BACKUPS_DIR: /data/backups
|
||||||
@ -100,6 +104,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
- plugins:/data/plugins
|
- plugins:/data/plugins
|
||||||
|
- customfonts:/data/fonts
|
||||||
- secrets:/data/secrets
|
- secrets:/data/secrets
|
||||||
- backups:/data/backups:ro
|
- backups:/data/backups:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -184,10 +189,11 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
networks: [internal]
|
networks: [internal]
|
||||||
volumes:
|
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.
|
# nightly run just reads them into the archive.
|
||||||
- uploads:/data/uploads
|
- uploads:/data/uploads
|
||||||
- plugins:/data/plugins
|
- plugins:/data/plugins
|
||||||
|
- customfonts:/data/fonts
|
||||||
- secrets:/data/secrets:ro
|
- secrets:/data/secrets:ro
|
||||||
- backups:/backups
|
- backups:/backups
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -273,6 +279,7 @@ volumes:
|
|||||||
db-data:
|
db-data:
|
||||||
uploads:
|
uploads:
|
||||||
plugins:
|
plugins:
|
||||||
|
customfonts:
|
||||||
secrets:
|
secrets:
|
||||||
backups:
|
backups:
|
||||||
# Only used by the optional `caddy` profile (certificates + state).
|
# 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
|
- Image size grows by a few MiB per family (WOFF2, subset to latin/latin-ext
|
||||||
by default) — negligible.
|
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
|
## Alternatives considered
|
||||||
|
|
||||||
- **Runtime font download by the server on admin selection**: flexible but
|
- **Runtime font download by the server on admin selection**: flexible but
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
# Audit event catalogue
|
# 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
|
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_*`,
|
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
|
||||||
issue #205).**
|
issue #205).**
|
||||||
@ -124,13 +125,15 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
|||||||
|
|
||||||
### Plugins (`plugin.*`)
|
### Plugins (`plugin.*`)
|
||||||
|
|
||||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||||
| --------------------- | ------------------------------------------------------------ | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------- | ----------------------------------------------------------------- | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `plugin.installed` | Plugin package installed or updated | notice | admin, `null` for dropzone drop | `plugin` | `version`, `update` (bool) |
|
| `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) |
|
| `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) |
|
||||||
| `plugin.mode_set` | Instance mode changed (disabled/optional/required) | notice | the admin | `plugin` | `mode` |
|
| `font.deleted` | Site Admin removed a custom font family (#303) | notice | admin | `font` | `family`, `pondsAffected` (count of ponds still referencing it) |
|
||||||
| `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — |
|
| `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.pond_toggled` | Optional plugin toggled for one pond | info | the pond admin | `pond` | `plugin`, `enabled` |
|
| `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.*`)
|
### 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
|
for non-image types, no user content served same-origin as executable
|
||||||
(`X-Content-Type-Options: nosniff`; uploads path never serves
|
(`X-Content-Type-Options: nosniff`; uploads path never serves
|
||||||
`text/html`).
|
`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);
|
- App CSP (strict): `default-src 'self'`; `font-src 'self'` (ADR 0016);
|
||||||
no third-party origins at all — the GDPR posture is "zero external
|
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
|
- **Attachment integrity (issue #199)**: every upload stores the SHA-256
|
||||||
of its bytes, computed from the in-memory buffer as it is written (never
|
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
|
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`.
|
* dev/test runs point this at the web app's built `public/fonts`.
|
||||||
*/
|
*/
|
||||||
FONTS_DIR: z.string().min(1).default('./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
|
* Directory holding installed plugin packages (ADR 0008, issue #71). Layout
|
||||||
* `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe
|
* `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe
|
||||||
@ -249,9 +258,11 @@ export const backupEnvSchema = z.object({
|
|||||||
DATABASE_URL: databaseUrl,
|
DATABASE_URL: databaseUrl,
|
||||||
/** Where restore sets and `status.json` are written (the `backups` volume). */
|
/** Where restore sets and `status.json` are written (the `backups` volume). */
|
||||||
BACKUPS_DIR: z.string().min(1).default('./data/backups'),
|
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'),
|
UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
|
||||||
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
|
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. */
|
/** Daily run time as HH:MM, interpreted in the container's TZ. */
|
||||||
BACKUP_TIME: z
|
BACKUP_TIME: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@ -2,14 +2,19 @@
|
|||||||
* The curated self-hosted font catalog (ADR 0016). One maintained list drives
|
* The curated self-hosted font catalog (ADR 0016). One maintained list drives
|
||||||
* everything: the build step downloads these families' WOFF2 subsets into the
|
* everything: the build step downloads these families' WOFF2 subsets into the
|
||||||
* web image, the pond-settings Appearance UI offers them, and the attribution
|
* 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
|
* page lists their licenses. Adding a family HERE is a change plus an image
|
||||||
* — there is no runtime font management (deliberately small surface).
|
* 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:` —
|
* 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.
|
* the `data:` part covers fonts embedded inline in saved plugin SVGs, e.g.
|
||||||
* Excalidraw sketches); a visitor's browser makes zero third-party requests
|
* Excalidraw sketches); a visitor's browser makes zero third-party requests
|
||||||
* (the GDPR guarantee, security.md).
|
* (the GDPR guarantee, security.md).
|
||||||
*/
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export type FontCategory = 'sans-serif' | 'serif' | 'monospace';
|
export type FontCategory = 'sans-serif' | 'serif' | 'monospace';
|
||||||
export type FontLicense = 'OFL-1.1' | 'Apache-2.0';
|
export type FontLicense = 'OFL-1.1' | 'Apache-2.0';
|
||||||
|
|
||||||
@ -173,17 +178,120 @@ export function fontSlug(family: string): string {
|
|||||||
.replace(/(^-|-$)/g, '');
|
.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 `font-family` stack for a chosen family: the family itself (when it is
|
||||||
* the catalog) ahead of the category's system fallback, so text stays readable
|
* known) ahead of the category's system fallback, so text stays readable
|
||||||
* before the WOFF2 loads or if the catalog font is unknown.
|
* 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 {
|
export function fontStack(family: string, extra: readonly FontCatalogEntry[] = []): string {
|
||||||
const entry = fontEntry(family);
|
const entry = fontEntry(family, extra);
|
||||||
const fallback = FONT_FALLBACKS[entry?.category ?? 'sans-serif'];
|
const fallback = FONT_FALLBACKS[entry?.category ?? 'sans-serif'];
|
||||||
return entry ? `'${family}', ${fallback}` : fallback;
|
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