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..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 { 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 { 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 { 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 { 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 { 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 { 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'); } }