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:
132
src/app/api/v1/admin/branding/logo/route.ts
Normal file
132
src/app/api/v1/admin/branding/logo/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
42
src/app/api/v1/admin/branding/logo/sample-pdf/route.tsx
Normal file
42
src/app/api/v1/admin/branding/logo/sample-pdf/route.tsx
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user