The route-permission fence (#52) failed in CI, not locally: I had run the fonts and import-export suites, not the full api suite, and the fence needs a database. `@AuthenticatedOnly()` is the rule the route always meant — a session, no further permission.
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
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-<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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<CustomFontView[]> {
|
|
return this.fonts.list();
|
|
}
|
|
|
|
@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);
|
|
}
|
|
}
|