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 { AuthenticatedOnly } from '../permissions/permission.decorators'; import { CustomFontStorageService } from './custom-font-storage.service'; import { CustomFontsService, WeightUpload } from './custom-fonts.service'; /** Multipart field names: `woff2-` and the optional `woff-`. */ const FILE_FIELD = /^(woff2|woff)-(\d{3})$/; function parseUploads(files: Express.Multer.File[] | undefined): WeightUpload[] { const byWeight = new Map(); 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 { 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 { // 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 { 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 { await this.fonts.remove(request.user!, id); } } /** * Reading side of the uploaded fonts: the family list every signed-in user * needs, and the bytes themselves. * * The listing is NOT site-admin-gated (issue #304): every signed-in user picks * fonts in their pond's Appearance settings, reads the licence page, and needs * the `@font-face` rules injected — the admin list at `/admin/fonts` carries * the same data, so gating this one would only force a second, admin-only UI. * * The file route is 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, ) {} // Explicit access declaration, as every route needs (issue #52's fence // `route-permissions.e2e.db.test.ts`): a session, no further permission — // the list says which families exist, which is what the pickers offer. @AuthenticatedOnly() @Get() list(): Promise { return this.fonts.list(); } @Public() @Get(':slug/:file') async serve( @Param('slug') slug: string, @Param('file') file: string, @Res() res: Response, ): Promise { 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); } }