feat(branding): port logo upload pipeline for internal PDFs

Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the
brand-kit Header pulls in for every internal-only PDF.

Server pipeline (src/lib/services/logo.service.ts):
  - magic-byte format check via sharp metadata
  - rejects animated/multi-frame inputs
  - SVGs sanitized via svgo preset-default + post-pass regex check
    (rejects <script>, on*=, javascript:, external href, <foreignObject>),
    then rasterized to PNG at 300 DPI
  - HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp
  - optional crop coords applied server-side (bounds-checked first)
  - auto-trim near-white borders
  - resize so longest edge <= 1200px, sRGB, palette-PNG
  - rejects undersized output (< 200px any side) or > 1MB
  - atomic system_settings upsert; soft-archives prior file row + storage object

API:
  GET    /api/v1/admin/branding/logo            current logo metadata
  POST   /api/v1/admin/branding/logo            multipart upload + crop
  DELETE /api/v1/admin/branding/logo            clear; future PDFs fall back
                                                 to port-name text header
  GET    /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx
                                                 with the current logo so
                                                 admins can spot-check
                                                 letterboxing in real shell

UI:
  src/components/admin/branding/pdf-logo-uploader.tsx
    - react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle
    - file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB)
    - dark-band preview swatch shows how the logo lands in the header
    - post-upload warnings panel surfaces every server-side normalization
      (resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.)
    - "Test with sample PDF" button streams a real PDF for spot-check
    - "Remove" tears down the file + storage object + setting
  Wired into the existing /admin/branding settings page beneath the
  Identity and Email-branding cards.

Audit:
  Two new AuditAction enum values added: branding.logo.uploaded and
  branding.logo.archived. Captured per upload + per archived prior logo.

Tests:
  tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path,
  undersized rejection, empty/oversized rejection, non-image rejection,
  out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG
  with embedded script rejection, SVG with external href rejection,
  JPEG-with-no-alpha warning collection.

1308/1308 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:51:49 +02:00
parent 73184c51e0
commit 6517e014a6
9 changed files with 1043 additions and 7 deletions

View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import {
clearPortLogo,
getPortLogoFile,
processLogoUpload,
setPortLogo,
type LogoCrop,
} from '@/lib/services/logo.service';
import { env } from '@/lib/env';
const MAX_RAW_BYTES = 5 * 1024 * 1024;
function parseCrop(value: FormDataEntryValue | null): LogoCrop | undefined {
if (typeof value !== 'string' || value.length === 0) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new ValidationError('Invalid crop JSON');
}
if (!parsed || typeof parsed !== 'object') {
throw new ValidationError('Invalid crop payload');
}
const c = parsed as Record<string, unknown>;
for (const key of ['x', 'y', 'width', 'height']) {
if (typeof c[key] !== 'number' || !Number.isFinite(c[key])) {
throw new ValidationError(`Crop ${key} must be a finite number`);
}
}
return {
x: c.x as number,
y: c.y as number,
width: c.width as number,
height: c.height as number,
};
}
/**
* GET /api/v1/admin/branding/logo
* Returns metadata for the current port logo (or null).
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const file = await getPortLogoFile(ctx.portId);
if (!file) {
return NextResponse.json({ data: null });
}
const baseUrl = env.APP_URL.replace(/\/+$/, '');
return NextResponse.json({
data: {
fileId: file.id,
previewUrl: `${baseUrl}/api/v1/files/${file.id}/preview`,
sizeBytes: file.sizeBytes,
mimeType: file.mimeType,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* POST /api/v1/admin/branding/logo
*
* Multipart: `file` (required) + `crop` (optional JSON string `{x, y, width, height}`).
* Runs the sharp normalization pipeline; if accepted, atomically updates the
* `port_logo_file_id` system setting and soft-archives the previous logo.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const formData = await req.formData();
const fileEntry = formData.get('file');
if (!(fileEntry instanceof File)) throw new ValidationError('Missing `file` part');
if (fileEntry.size === 0) throw new ValidationError('Empty file');
if (fileEntry.size > MAX_RAW_BYTES) {
throw new ValidationError(`File exceeds ${MAX_RAW_BYTES / 1024 / 1024} MB`);
}
const crop = parseCrop(formData.get('crop'));
const buffer = Buffer.from(await fileEntry.arrayBuffer());
const processed = await processLogoUpload(buffer, crop);
const result = await setPortLogo(ctx.portId, processed, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
const baseUrl = env.APP_URL.replace(/\/+$/, '');
return NextResponse.json({
data: {
fileId: result.fileId,
previewUrl: `${baseUrl}/api/v1/files/${result.fileId}/preview`,
warnings: result.warnings,
finalDimensions: processed.finalDimensions,
finalBytes: processed.finalBytes,
originalDimensions: processed.originalDimensions,
originalFormat: processed.originalFormat,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* DELETE /api/v1/admin/branding/logo
* Clear the port logo. Subsequent PDF renders fall back to the port-name text header.
*/
export const DELETE = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
await clearPortLogo(ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { errorResponse, ValidationError } from '@/lib/errors';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { renderPdf } from '@/lib/pdf/render';
import { BrandingSamplePdf } from '@/lib/pdf/templates/branding-sample';
/**
* GET /api/v1/admin/branding/logo/sample-pdf
*
* Renders a one-page PDF that exercises the brand-kit header, footer, and a
* couple of tables/charts. Used by the admin Branding UI's "Test with sample
* PDF" button so the admin can preview their logo in the actual report shell
* before generating real reports.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const port = await db.query.ports.findFirst({ where: eq(ports.id, ctx.portId) });
if (!port) throw new ValidationError('Unknown port');
const logo = await resolvePortLogo(port.id);
const bytes = await renderPdf(
<BrandingSamplePdf portName={port.name} logoBuffer={logo.buffer} />,
);
return new NextResponse(new Uint8Array(bytes), {
status: 200,
headers: {
'content-type': 'application/pdf',
'content-disposition': 'inline; filename="branding-sample.pdf"',
'cache-control': 'no-store',
},
});
} catch (error) {
return errorResponse(error);
}
}),
);