All checks were successful
CI / Build container images (pull_request) Successful in 3m53s
CI / Auth e2e pack (pull_request) Successful in 8m42s
CI / Auth e2e pack (push) Successful in 8m41s
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Deploy to Test (push) Successful in 16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m41s
CI / Build container images (push) Has been skipped
CI / Import/export fidelity gate (push) Successful in 52s
An operator holding a font licence could only use it by baking the file into a custom image, which tied every change to a rebuild and left the file out of the backup. ADR 0016 said there is no runtime font management. It also listed this exact case under "Alternatives considered" — *may become a Site-Admin- level feature later*. The amendment takes that option and answers the two objections it raised: licensing risk (Site Admins only, licence recorded with the family) and file-format attack surface (magic-byte check and a size cap, never a parse). - `CUSTOM_FONTS_DIR` (default `./data/fonts`) — a sibling of uploads and plugins, NOT inside the image-baked `FONTS_DIR`, where a deploy would overwrite it and no backup would ever see it. - One list of data directories (`apps/backup/src/data-dirs.ts`) now feeds both the nightly archive and the restore, so they cannot drift. #306 and #307 add one line each instead of a second mechanism. - Both Dockerfiles bake the path. The backup image sets its volume paths itself ("self-sufficient without compose env" — #71's lesson) and reads no *_DIR from compose; without the ENV entry the archive would have skipped the directory silently. - The PDF path already read WOFF2 from disk at request time, so it only had to pick the other base directory for a custom family. - `fontStack`/`fontEntry` take the instance's uploaded families as an argument — they are runtime data. The catalog is searched first, and a colliding family name is rejected at upload, so a custom font can never shadow a catalog one. - Deletion is never blocked by usage: an unknown family already falls back to the system stack, so affected ponds degrade instead of breaking. The count of affected ponds travels into the audit entry. - Audit catalogue v1.6 (`font.uploaded`, `font.deleted`). Verified: api full suite against a fresh database, 102 files / 571 tests. The upload suite writes into a real temp directory and reads the bytes back off disk, so the storage layer is exercised rather than mocked.
250 lines
8.3 KiB
TypeScript
250 lines
8.3 KiB
TypeScript
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');
|
|
}
|
|
}
|