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 โ€” `/-.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 { await mkdir(this.dirFor(slug), { recursive: true }); await writeFile(this.pathFor(slug, weight, format), bytes); } read(slug: string, weight: number, format: FontUploadFormat): Promise { 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 { await rm(this.dirFor(slug), { recursive: true, force: true }); } async deleteWeight(slug: string, weight: number, format: FontUploadFormat): Promise { await rm(this.pathFor(slug, weight, format), { force: true }); } }