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:
@@ -3,6 +3,7 @@ import {
|
|||||||
type SettingFieldDef,
|
type SettingFieldDef,
|
||||||
} from '@/components/admin/shared/settings-form-card';
|
} from '@/components/admin/shared/settings-form-card';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { PdfLogoUploader } from '@/components/admin/branding/pdf-logo-uploader';
|
||||||
|
|
||||||
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
|
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||||
@@ -87,6 +88,7 @@ export default function BrandingSettingsPage() {
|
|||||||
description="HTML fragments rendered around every transactional email."
|
description="HTML fragments rendered around every transactional email."
|
||||||
fields={FIELDS.slice(3)}
|
fields={FIELDS.slice(3)}
|
||||||
/>
|
/>
|
||||||
|
<PdfLogoUploader />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
340
src/components/admin/branding/pdf-logo-uploader.tsx
Normal file
340
src/components/admin/branding/pdf-logo-uploader.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import 'react-image-crop/dist/ReactCrop.css';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import ReactCrop, { centerCrop, makeAspectCrop, type Crop, type PixelCrop } from 'react-image-crop';
|
||||||
|
import { Loader2, RefreshCw, Trash2, Upload } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,image/heic,image/heif,image/avif';
|
||||||
|
|
||||||
|
type AspectMode = 'wide' | 'square' | 'freeform';
|
||||||
|
|
||||||
|
const ASPECT_RATIOS: Record<AspectMode, number | undefined> = {
|
||||||
|
wide: 3,
|
||||||
|
square: 1,
|
||||||
|
freeform: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CurrentLogo {
|
||||||
|
fileId: string;
|
||||||
|
previewUrl: string;
|
||||||
|
sizeBytes: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
fileId: string;
|
||||||
|
previewUrl: string;
|
||||||
|
warnings: string[];
|
||||||
|
finalDimensions: { width: number; height: number };
|
||||||
|
finalBytes: number;
|
||||||
|
originalDimensions: { width: number; height: number };
|
||||||
|
originalFormat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WARNING_LABELS: Record<string, string> = {
|
||||||
|
trimmed: 'Auto-trimmed whitespace borders',
|
||||||
|
resized: 'Downscaled for size',
|
||||||
|
'no-alpha': 'No transparent background — will show a white box on dark headers',
|
||||||
|
'jpeg-source': 'JPEG source — prefer PNG with alpha for crisp rendering',
|
||||||
|
'svg-rasterized': 'SVG rasterized to PNG at 300 DPI',
|
||||||
|
'heic-converted': 'HEIC/HEIF converted to PNG',
|
||||||
|
'webp-converted': 'WebP converted to PNG',
|
||||||
|
};
|
||||||
|
|
||||||
|
function centeredCrop(width: number, height: number, aspect: number): Crop {
|
||||||
|
return centerCrop(makeAspectCrop({ unit: '%', width: 90 }, aspect, width, height), width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PdfLogoUploader() {
|
||||||
|
const [current, setCurrent] = useState<CurrentLogo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
|
const [crop, setCrop] = useState<Crop>();
|
||||||
|
const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);
|
||||||
|
const [aspectMode, setAspectMode] = useState<AspectMode>('wide');
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
const [uploadInfo, setUploadInfo] = useState<UploadResult | null>(null);
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [objectUrl]);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/admin/branding/logo');
|
||||||
|
if (!res.ok) throw new Error(`GET ${res.status}`);
|
||||||
|
const body = (await res.json()) as { data: CurrentLogo | null };
|
||||||
|
setCurrent(body.data ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(f: File | null) {
|
||||||
|
if (!f) return;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
const url = URL.createObjectURL(f);
|
||||||
|
setFile(f);
|
||||||
|
setObjectUrl(url);
|
||||||
|
setCrop(undefined);
|
||||||
|
setCompletedCrop(null);
|
||||||
|
setWarnings([]);
|
||||||
|
setUploadInfo(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||||
|
const { naturalWidth: w, naturalHeight: h } = e.currentTarget;
|
||||||
|
const aspect = ASPECT_RATIOS[aspectMode];
|
||||||
|
if (aspect) setCrop(centeredCrop(w, h, aspect));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeAspect(mode: AspectMode) {
|
||||||
|
setAspectMode(mode);
|
||||||
|
const img = imgRef.current;
|
||||||
|
const aspect = ASPECT_RATIOS[mode];
|
||||||
|
if (img && aspect) {
|
||||||
|
setCrop(centeredCrop(img.naturalWidth, img.naturalHeight, aspect));
|
||||||
|
} else {
|
||||||
|
setCrop(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
if (!file) return;
|
||||||
|
setWorking(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
if (completedCrop && completedCrop.width > 0 && completedCrop.height > 0) {
|
||||||
|
fd.append(
|
||||||
|
'crop',
|
||||||
|
JSON.stringify({
|
||||||
|
x: completedCrop.x,
|
||||||
|
y: completedCrop.y,
|
||||||
|
width: completedCrop.width,
|
||||||
|
height: completedCrop.height,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/v1/admin/branding/logo', { method: 'POST', body: fd });
|
||||||
|
const body = (await res.json()) as { data: UploadResult } | { error?: { message?: string } };
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = ('error' in body && body.error?.message) || `Upload failed (${res.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
const data = (body as { data: UploadResult }).data;
|
||||||
|
setUploadInfo(data);
|
||||||
|
setWarnings(data.warnings ?? []);
|
||||||
|
toast.success('Logo saved');
|
||||||
|
await refresh();
|
||||||
|
// Clear the local crop staging so the preview now shows the stored logo.
|
||||||
|
setFile(null);
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
setObjectUrl(null);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
if (!confirm('Remove the PDF logo? Future reports will fall back to the port name.')) return;
|
||||||
|
setWorking(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' });
|
||||||
|
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`);
|
||||||
|
toast.success('Logo removed');
|
||||||
|
setCurrent(null);
|
||||||
|
setUploadInfo(null);
|
||||||
|
setWarnings([]);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSamplePdf() {
|
||||||
|
window.open('/api/v1/admin/branding/logo/sample-pdf', '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>PDF logo</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Used as the header logo on every internal PDF the CRM generates (reports, expense sheets,
|
||||||
|
record exports). Separate from the email/UI logo so PDFs can have a higher-resolution,
|
||||||
|
alpha-channel version optimized for print.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="rounded-md bg-muted/40 p-4 text-sm text-muted-foreground space-y-1">
|
||||||
|
<p>Use PNG or SVG with a transparent background.</p>
|
||||||
|
<p>Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square).</p>
|
||||||
|
<p>Max 5MB; we'll auto-trim, downscale, and convert to PNG.</p>
|
||||||
|
<p>Avoid JPEGs unless the background is solid white.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading current logo…
|
||||||
|
</div>
|
||||||
|
) : current ? (
|
||||||
|
<div className="flex items-start gap-4 rounded-md border p-4">
|
||||||
|
<div className="flex h-20 w-44 items-center justify-center rounded bg-[#0f172a] p-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={current.previewUrl}
|
||||||
|
alt="Current PDF logo on dark header"
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="text-sm font-medium">Current logo</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{current.sizeBytes
|
||||||
|
? `${(Number(current.sizeBytes) / 1024).toFixed(1)} KB`
|
||||||
|
: 'size unknown'}{' '}
|
||||||
|
· {current.mimeType ?? 'image'}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={openSamplePdf}>
|
||||||
|
Test with sample PDF
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={clear} disabled={working}>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" /> Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
No PDF logo configured yet. Reports use the port name as a text header.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Upload a new logo</Label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPT}
|
||||||
|
onChange={(e) => pick(e.target.files?.[0] ?? null)}
|
||||||
|
className="block w-full text-sm file:mr-3 file:rounded file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{objectUrl ? (
|
||||||
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span>Crop:</span>
|
||||||
|
{(['wide', 'square', 'freeform'] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={m}
|
||||||
|
onClick={() => changeAspect(m)}
|
||||||
|
className={`rounded border px-2 py-0.5 text-xs capitalize ${
|
||||||
|
aspectMode === m
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-input hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'wide' ? 'Wide 3:1' : m === 'square' ? 'Square 1:1' : 'Freeform'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ReactCrop
|
||||||
|
crop={crop}
|
||||||
|
onChange={(_c, percent) => setCrop(percent)}
|
||||||
|
onComplete={(c) => setCompletedCrop(c)}
|
||||||
|
aspect={ASPECT_RATIOS[aspectMode]}
|
||||||
|
className="max-h-[400px]"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={objectUrl}
|
||||||
|
alt="Logo to crop"
|
||||||
|
onLoad={onImageLoad}
|
||||||
|
className="max-h-[400px]"
|
||||||
|
/>
|
||||||
|
</ReactCrop>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={upload} disabled={working}>
|
||||||
|
{working ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Save logo
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setFile(null);
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
setObjectUrl(null);
|
||||||
|
setCrop(undefined);
|
||||||
|
setCompletedCrop(null);
|
||||||
|
}}
|
||||||
|
disabled={working}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={working}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1 h-3 w-3" /> Choose a different file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{uploadInfo ? (
|
||||||
|
<div className="rounded-md border p-4 text-sm space-y-2">
|
||||||
|
<div className="font-medium">Processed</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
From {uploadInfo.originalFormat.toUpperCase()} {uploadInfo.originalDimensions.width}×
|
||||||
|
{uploadInfo.originalDimensions.height}
|
||||||
|
{' → '}
|
||||||
|
PNG {uploadInfo.finalDimensions.width}×{uploadInfo.finalDimensions.height} (
|
||||||
|
{(uploadInfo.finalBytes / 1024).toFixed(1)} KB)
|
||||||
|
</div>
|
||||||
|
{warnings.length > 0 ? (
|
||||||
|
<ul className="list-disc space-y-0.5 pl-5 text-xs">
|
||||||
|
{warnings.map((w) => (
|
||||||
|
<li key={w}>{WARNING_LABELS[w] ?? w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ export type AuditAction =
|
|||||||
| 'view'
|
| 'view'
|
||||||
| 'request_hard_delete_code'
|
| 'request_hard_delete_code'
|
||||||
| 'hard_delete'
|
| 'hard_delete'
|
||||||
|
// Branding (port logo upload pipeline).
|
||||||
|
| 'branding.logo.uploaded'
|
||||||
|
| 'branding.logo.archived'
|
||||||
// System / background events.
|
// System / background events.
|
||||||
| 'webhook_delivered'
|
| 'webhook_delivered'
|
||||||
| 'webhook_failed'
|
| 'webhook_failed'
|
||||||
|
|||||||
87
src/lib/pdf/templates/branding-sample.tsx
Normal file
87
src/lib/pdf/templates/branding-sample.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
DataTable,
|
||||||
|
DocumentShell,
|
||||||
|
KeyValueGrid,
|
||||||
|
PieChart,
|
||||||
|
Section,
|
||||||
|
} from '@/lib/pdf/brand-kit';
|
||||||
|
|
||||||
|
export interface BrandingSamplePdfProps {
|
||||||
|
portName: string;
|
||||||
|
logoBuffer: Buffer | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_BARS = [
|
||||||
|
{ label: 'Mon', value: 14 },
|
||||||
|
{ label: 'Tue', value: 22 },
|
||||||
|
{ label: 'Wed', value: 18 },
|
||||||
|
{ label: 'Thu', value: 27 },
|
||||||
|
{ label: 'Fri', value: 31 },
|
||||||
|
{ label: 'Sat', value: 9 },
|
||||||
|
{ label: 'Sun', value: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SAMPLE_PIE = [
|
||||||
|
{ label: 'Available', value: 42 },
|
||||||
|
{ label: 'Under Offer', value: 12 },
|
||||||
|
{ label: 'Sold', value: 38 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SampleRow {
|
||||||
|
date: string;
|
||||||
|
action: string;
|
||||||
|
who: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_ROWS: SampleRow[] = [
|
||||||
|
{ date: '2026-05-12', action: 'created client', who: 'Sarah K.' },
|
||||||
|
{ date: '2026-05-12', action: 'sent EOI', who: 'Matt P.' },
|
||||||
|
{ date: '2026-05-11', action: 'updated berth A12', who: 'Sarah K.' },
|
||||||
|
{ date: '2026-05-11', action: 'archived interest', who: 'James R.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-page sample PDF used by the admin Branding UI's "Test with sample PDF"
|
||||||
|
* button. Exercises every brand-kit primitive a real report would touch so an
|
||||||
|
* admin can sanity-check their logo placement, font/color rendering, header
|
||||||
|
* letterboxing, and footer page numbering at a glance.
|
||||||
|
*/
|
||||||
|
export function BrandingSamplePdf({ portName, logoBuffer }: BrandingSamplePdfProps) {
|
||||||
|
return (
|
||||||
|
<DocumentShell
|
||||||
|
portName={portName}
|
||||||
|
docTitle="Branding Sample"
|
||||||
|
docMeta="Generated to verify your port logo and design tokens"
|
||||||
|
logoBuffer={logoBuffer}
|
||||||
|
pdfTitle="Branding Sample"
|
||||||
|
>
|
||||||
|
<Section title="Summary" subtitle="A glance at how the brand kit renders in real reports.">
|
||||||
|
<KeyValueGrid
|
||||||
|
rows={[
|
||||||
|
{ label: 'Port', value: portName },
|
||||||
|
{ label: 'Logo source', value: logoBuffer ? 'Configured' : 'Fallback (text)' },
|
||||||
|
{ label: 'Page size', value: 'A4' },
|
||||||
|
{ label: 'Color scheme', value: 'Slate header · Blue accent' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title="Bar chart" subtitle="Sample weekly activity counts.">
|
||||||
|
<BarChart data={SAMPLE_BARS} showValues />
|
||||||
|
</Section>
|
||||||
|
<Section title="Pie chart" subtitle="Sample berth status mix.">
|
||||||
|
<PieChart data={SAMPLE_PIE} innerRadiusRatio={0.5} />
|
||||||
|
</Section>
|
||||||
|
<Section title="Table" subtitle="Recent sample events.">
|
||||||
|
<DataTable<SampleRow>
|
||||||
|
columns={[
|
||||||
|
{ header: 'Date', flex: 1, render: (r) => r.date },
|
||||||
|
{ header: 'Action', flex: 2, render: (r) => r.action },
|
||||||
|
{ header: 'Who', flex: 1, render: (r) => r.who },
|
||||||
|
]}
|
||||||
|
rows={SAMPLE_ROWS}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</DocumentShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
328
src/lib/services/logo.service.ts
Normal file
328
src/lib/services/logo.service.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Port-logo upload service.
|
||||||
|
*
|
||||||
|
* Layer 1 of the design (`docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md`):
|
||||||
|
* sharp normalization + svgo sanitization + atomic system_settings upsert +
|
||||||
|
* audit logging. Single entry point for the admin upload endpoint and the
|
||||||
|
* branding-preview endpoint. Returns processed bytes + the file row, plus a
|
||||||
|
* collected list of warnings the UI surfaces in the preview swatch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { optimize as svgoOptimize } from 'svgo';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { files } from '@/lib/db/schema/documents';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
|
import { ValidationError } from '@/lib/errors';
|
||||||
|
import { getStorageBackend } from '@/lib/storage';
|
||||||
|
import { generateStorageKey } from '@/lib/services/storage';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { PORT_LOGO_SETTING_KEY } from '@/lib/pdf/brand-kit/logo';
|
||||||
|
|
||||||
|
const MAX_RAW_BYTES = 5 * 1024 * 1024;
|
||||||
|
const MAX_FINAL_BYTES = 1 * 1024 * 1024;
|
||||||
|
const MIN_DIMENSION = 200;
|
||||||
|
const TARGET_LONG_EDGE = 1200;
|
||||||
|
const SVG_RASTER_DENSITY = 300;
|
||||||
|
|
||||||
|
const SUPPORTED_INPUT_FORMATS = new Set([
|
||||||
|
'png',
|
||||||
|
'jpeg',
|
||||||
|
'jpg',
|
||||||
|
'webp',
|
||||||
|
'svg',
|
||||||
|
'heic',
|
||||||
|
'heif',
|
||||||
|
'avif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type LogoWarning =
|
||||||
|
| 'trimmed'
|
||||||
|
| 'resized'
|
||||||
|
| 'no-alpha'
|
||||||
|
| 'jpeg-source'
|
||||||
|
| 'svg-rasterized'
|
||||||
|
| 'heic-converted'
|
||||||
|
| 'webp-converted';
|
||||||
|
|
||||||
|
export interface LogoCrop {
|
||||||
|
/** Crop coordinates in raw-image pixel units. */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessLogoResult {
|
||||||
|
pngBuffer: Buffer;
|
||||||
|
warnings: LogoWarning[];
|
||||||
|
originalFormat: string;
|
||||||
|
originalDimensions: { width: number; height: number };
|
||||||
|
finalDimensions: { width: number; height: number };
|
||||||
|
finalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSvg(rawSvg: string): string {
|
||||||
|
// preset-default strips <script>, event handlers, and unused elements.
|
||||||
|
// The post-pass regex check below is belt-and-suspenders for the
|
||||||
|
// attack surface we explicitly disallow.
|
||||||
|
const result = svgoOptimize(rawSvg, {
|
||||||
|
multipass: true,
|
||||||
|
plugins: ['preset-default'],
|
||||||
|
});
|
||||||
|
const sanitized = result.data;
|
||||||
|
if (
|
||||||
|
/<script\b/i.test(sanitized) ||
|
||||||
|
/<foreignobject\b/i.test(sanitized) ||
|
||||||
|
/\bon\w+\s*=/i.test(sanitized) ||
|
||||||
|
/javascript:/i.test(sanitized) ||
|
||||||
|
/\bhref\s*=\s*["']https?:/i.test(sanitized)
|
||||||
|
) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'SVG contained disallowed nodes (script/foreignObject/external href)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the full sharp normalization pipeline. Throws ValidationError on
|
||||||
|
* unsupported formats, undersized dimensions, oversized output, or animated
|
||||||
|
* inputs. Caller passes the original upload buffer and optional crop coords.
|
||||||
|
*/
|
||||||
|
export async function processLogoUpload(raw: Buffer, crop?: LogoCrop): Promise<ProcessLogoResult> {
|
||||||
|
if (raw.length === 0) throw new ValidationError('Empty file');
|
||||||
|
if (raw.length > MAX_RAW_BYTES) {
|
||||||
|
throw new ValidationError(`File exceeds ${MAX_RAW_BYTES / 1024 / 1024} MB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: LogoWarning[] = [];
|
||||||
|
|
||||||
|
// Detect format up front so we can route SVGs through svgo first.
|
||||||
|
let probe: sharp.Metadata;
|
||||||
|
try {
|
||||||
|
probe = await sharp(raw).metadata();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'logo upload: sharp could not read metadata');
|
||||||
|
throw new ValidationError('File contents do not match a supported image format');
|
||||||
|
}
|
||||||
|
const format = probe.format ?? 'unknown';
|
||||||
|
if (!SUPPORTED_INPUT_FORMATS.has(format)) {
|
||||||
|
throw new ValidationError(`Unsupported image format: ${format}`);
|
||||||
|
}
|
||||||
|
if ((probe.pages ?? 1) > 1) {
|
||||||
|
throw new ValidationError('Animated/multi-frame images are not accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
let workingBuffer = raw;
|
||||||
|
|
||||||
|
if (format === 'svg') {
|
||||||
|
const sanitized = sanitizeSvg(raw.toString('utf8'));
|
||||||
|
workingBuffer = Buffer.from(sanitized, 'utf8');
|
||||||
|
warnings.push('svg-rasterized');
|
||||||
|
} else if (format === 'heif' || format === 'avif') {
|
||||||
|
warnings.push('heic-converted');
|
||||||
|
} else if (format === 'webp') {
|
||||||
|
warnings.push('webp-converted');
|
||||||
|
} else if (format === 'jpeg') {
|
||||||
|
warnings.push('jpeg-source');
|
||||||
|
if (!probe.hasAlpha) warnings.push('no-alpha');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSharp =
|
||||||
|
format === 'svg' ? sharp(workingBuffer, { density: SVG_RASTER_DENSITY }) : sharp(workingBuffer);
|
||||||
|
|
||||||
|
// Apply crop if provided, after validating bounds against the original metadata.
|
||||||
|
let pipeline = baseSharp;
|
||||||
|
if (crop) {
|
||||||
|
const w = probe.width ?? 0;
|
||||||
|
const h = probe.height ?? 0;
|
||||||
|
if (
|
||||||
|
crop.x < 0 ||
|
||||||
|
crop.y < 0 ||
|
||||||
|
crop.width <= 0 ||
|
||||||
|
crop.height <= 0 ||
|
||||||
|
crop.x + crop.width > w ||
|
||||||
|
crop.y + crop.height > h
|
||||||
|
) {
|
||||||
|
throw new ValidationError('Crop coordinates out of image bounds');
|
||||||
|
}
|
||||||
|
pipeline = pipeline.extract({
|
||||||
|
left: Math.round(crop.x),
|
||||||
|
top: Math.round(crop.y),
|
||||||
|
width: Math.round(crop.width),
|
||||||
|
height: Math.round(crop.height),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim near-white borders (only for raster; SVG has none).
|
||||||
|
if (format !== 'svg') {
|
||||||
|
pipeline = pipeline.trim({ threshold: 10 });
|
||||||
|
warnings.push('trimmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline = pipeline
|
||||||
|
.resize({
|
||||||
|
width: TARGET_LONG_EDGE,
|
||||||
|
height: TARGET_LONG_EDGE,
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.toColorspace('srgb')
|
||||||
|
.png({ compressionLevel: 9, palette: true });
|
||||||
|
|
||||||
|
const pngBuffer = await pipeline.toBuffer();
|
||||||
|
const finalMeta = await sharp(pngBuffer).metadata();
|
||||||
|
const fw = finalMeta.width ?? 0;
|
||||||
|
const fh = finalMeta.height ?? 0;
|
||||||
|
if (fw < MIN_DIMENSION || fh < MIN_DIMENSION) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Logo is too small after trim (${fw}x${fh}). Minimum ${MIN_DIMENSION}px per side.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pngBuffer.length > MAX_FINAL_BYTES) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Processed logo exceeds ${MAX_FINAL_BYTES / 1024 / 1024} MB. Try a simpler source image.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (probe.width && fw < probe.width) warnings.push('resized');
|
||||||
|
|
||||||
|
return {
|
||||||
|
pngBuffer,
|
||||||
|
warnings,
|
||||||
|
originalFormat: format,
|
||||||
|
originalDimensions: { width: probe.width ?? 0, height: probe.height ?? 0 },
|
||||||
|
finalDimensions: { width: fw, height: fh },
|
||||||
|
finalBytes: pngBuffer.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the processed logo: write to storage backend, insert files row,
|
||||||
|
* upsert system_settings pointer atomically, soft-delete the prior file row.
|
||||||
|
* Last-writer-wins; concurrent uploads do not race because the system_settings
|
||||||
|
* upsert is atomic.
|
||||||
|
*/
|
||||||
|
export async function setPortLogo(
|
||||||
|
portId: string,
|
||||||
|
processed: ProcessLogoResult,
|
||||||
|
meta: AuditMeta,
|
||||||
|
): Promise<{ fileId: string; storagePath: string; warnings: LogoWarning[] }> {
|
||||||
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||||
|
if (!port) throw new ValidationError('Unknown port');
|
||||||
|
|
||||||
|
const storagePath = generateStorageKey(port.slug, 'branding', port.id, 'image/png');
|
||||||
|
const backend = await getStorageBackend();
|
||||||
|
await backend.put(storagePath, processed.pngBuffer, {
|
||||||
|
contentType: 'image/png',
|
||||||
|
sizeBytes: processed.pngBuffer.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prior = await readCurrentLogoFileId(portId);
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.insert(files)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
filename: 'port-logo.png',
|
||||||
|
originalName: 'port-logo.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
sizeBytes: String(processed.pngBuffer.length),
|
||||||
|
storagePath,
|
||||||
|
category: 'branding',
|
||||||
|
uploadedBy: meta.userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!record) throw new Error('files insert returned no row');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(systemSettings)
|
||||||
|
.values({
|
||||||
|
key: PORT_LOGO_SETTING_KEY,
|
||||||
|
value: record.id,
|
||||||
|
portId,
|
||||||
|
updatedBy: meta.userId,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [systemSettings.key, systemSettings.portId],
|
||||||
|
set: { value: record.id, updatedBy: meta.userId, updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'branding.logo.uploaded',
|
||||||
|
entityType: 'port',
|
||||||
|
entityId: portId,
|
||||||
|
newValue: {
|
||||||
|
fileId: record.id,
|
||||||
|
finalDimensions: processed.finalDimensions,
|
||||||
|
finalBytes: processed.finalBytes,
|
||||||
|
warnings: processed.warnings,
|
||||||
|
originalFormat: processed.originalFormat,
|
||||||
|
},
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prior && prior !== record.id) {
|
||||||
|
await purgePriorLogo(prior, portId, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fileId: record.id, storagePath, warnings: processed.warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCurrentLogoFileId(portId: string): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(and(eq(systemSettings.key, PORT_LOGO_SETTING_KEY), eq(systemSettings.portId, portId)));
|
||||||
|
if (!row) return null;
|
||||||
|
return typeof row.value === 'string' ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgePriorLogo(fileId: string, portId: string, meta: AuditMeta): Promise<void> {
|
||||||
|
const file = await db.query.files.findFirst({ where: eq(files.id, fileId) });
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const backend = await getStorageBackend();
|
||||||
|
await backend.delete(file.storagePath);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, fileId, storagePath: file.storagePath }, 'prior logo storage purge failed');
|
||||||
|
}
|
||||||
|
await db.delete(files).where(eq(files.id, fileId));
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'branding.logo.archived',
|
||||||
|
entityType: 'port',
|
||||||
|
entityId: portId,
|
||||||
|
oldValue: { fileId, storagePath: file.storagePath },
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the port logo. Removes the storage object + files row + setting. */
|
||||||
|
export async function clearPortLogo(portId: string, meta: AuditMeta): Promise<void> {
|
||||||
|
const prior = await readCurrentLogoFileId(portId);
|
||||||
|
if (!prior) return;
|
||||||
|
await db
|
||||||
|
.delete(systemSettings)
|
||||||
|
.where(and(eq(systemSettings.key, PORT_LOGO_SETTING_KEY), eq(systemSettings.portId, portId)));
|
||||||
|
await purgePriorLogo(prior, portId, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current port logo file row (or null) for admin UI display. */
|
||||||
|
export async function getPortLogoFile(portId: string) {
|
||||||
|
const fileId = await readCurrentLogoFileId(portId);
|
||||||
|
if (!fileId) return null;
|
||||||
|
const file = await db.query.files.findFirst({
|
||||||
|
where: and(eq(files.id, fileId), eq(files.portId, portId)),
|
||||||
|
});
|
||||||
|
return file ?? null;
|
||||||
|
}
|
||||||
106
tests/unit/logo-service.test.ts
Normal file
106
tests/unit/logo-service.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Pure-function tests for the logo sharp pipeline (no DB / storage).
|
||||||
|
* The `setPortLogo` write-path is exercised by integration tests + Playwright.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
import { processLogoUpload } from '@/lib/services/logo.service';
|
||||||
|
|
||||||
|
async function makePng(
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
color: { r: number; g: number; b: number; alpha: number } = { r: 255, g: 0, b: 0, alpha: 1 },
|
||||||
|
) {
|
||||||
|
return sharp({ create: { width: w, height: h, channels: 4, background: color } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processLogoUpload — sharp pipeline', () => {
|
||||||
|
it('accepts a healthy PNG with alpha and resizes if needed', async () => {
|
||||||
|
const buf = await makePng(2400, 800);
|
||||||
|
const result = await processLogoUpload(buf);
|
||||||
|
expect(result.originalFormat).toBe('png');
|
||||||
|
expect(result.finalDimensions.width).toBeLessThanOrEqual(1200);
|
||||||
|
expect(result.finalDimensions.height).toBeLessThanOrEqual(1200);
|
||||||
|
expect(result.pngBuffer.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||||
|
expect(result.warnings).toContain('trimmed');
|
||||||
|
expect(result.warnings).toContain('resized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects undersized images', async () => {
|
||||||
|
const buf = await makePng(100, 100);
|
||||||
|
await expect(processLogoUpload(buf)).rejects.toThrow(/too small/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty buffers', async () => {
|
||||||
|
await expect(processLogoUpload(Buffer.alloc(0))).rejects.toThrow(/empty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects buffers that exceed the raw size cap', async () => {
|
||||||
|
// 6 MB of zero bytes — fails the raw size cap before sharp parses.
|
||||||
|
const buf = Buffer.alloc(6 * 1024 * 1024);
|
||||||
|
await expect(processLogoUpload(buf)).rejects.toThrow(/MB/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-image bytes', async () => {
|
||||||
|
const buf = Buffer.from('this is not an image at all');
|
||||||
|
await expect(processLogoUpload(buf)).rejects.toThrow(/supported image format/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects out-of-bounds crop coordinates', async () => {
|
||||||
|
const buf = await makePng(800, 800);
|
||||||
|
await expect(processLogoUpload(buf, { x: 0, y: 0, width: 1000, height: 1000 })).rejects.toThrow(
|
||||||
|
/out of image bounds/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an in-bounds crop', async () => {
|
||||||
|
const buf = await makePng(800, 800);
|
||||||
|
const result = await processLogoUpload(buf, { x: 100, y: 100, width: 500, height: 500 });
|
||||||
|
expect(result.finalDimensions.width).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rasterizes SVG input to PNG', async () => {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">
|
||||||
|
<rect width="400" height="400" fill="#1d4ed8"/>
|
||||||
|
<circle cx="200" cy="200" r="100" fill="#ffffff"/>
|
||||||
|
</svg>`;
|
||||||
|
const buf = Buffer.from(svg, 'utf8');
|
||||||
|
const result = await processLogoUpload(buf);
|
||||||
|
expect(result.originalFormat).toBe('svg');
|
||||||
|
expect(result.warnings).toContain('svg-rasterized');
|
||||||
|
// Output is PNG even though input was SVG.
|
||||||
|
expect(result.pngBuffer.subarray(1, 4).toString('ascii')).toBe('PNG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SVG with embedded script', async () => {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
|
||||||
|
<script>alert('xss')</script>
|
||||||
|
<rect width="400" height="400" fill="#1d4ed8"/>
|
||||||
|
</svg>`;
|
||||||
|
const buf = Buffer.from(svg, 'utf8');
|
||||||
|
await expect(processLogoUpload(buf)).rejects.toThrow(/disallowed nodes/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SVG with external href', async () => {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
|
||||||
|
<image href="https://evil.example.com/x.png" width="400" height="400"/>
|
||||||
|
</svg>`;
|
||||||
|
const buf = Buffer.from(svg, 'utf8');
|
||||||
|
await expect(processLogoUpload(buf)).rejects.toThrow(/disallowed nodes/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags JPEG sources with no alpha', async () => {
|
||||||
|
const buf = await sharp({
|
||||||
|
create: { width: 1200, height: 1200, channels: 3, background: { r: 200, g: 50, b: 50 } },
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
const result = await processLogoUpload(buf);
|
||||||
|
expect(result.warnings).toContain('jpeg-source');
|
||||||
|
expect(result.warnings).toContain('no-alpha');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,14 +67,10 @@ describe('pdf brand kit', () => {
|
|||||||
</Section>
|
</Section>
|
||||||
<Section title="Table">
|
<Section title="Table">
|
||||||
<Badge text="Active" tone="success" />
|
<Badge text="Active" tone="success" />
|
||||||
<DataTable
|
<DataTable<{ name: string; score: number }>
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Name', render: (r: { name: string }) => r.name },
|
{ header: 'Name', render: (r) => r.name },
|
||||||
{
|
{ header: 'Score', align: 'right', render: (r) => String(r.score) },
|
||||||
header: 'Score',
|
|
||||||
align: 'right',
|
|
||||||
render: (r: { score: number }) => String(r.score),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
rows={[
|
rows={[
|
||||||
{ name: 'Alpha', score: 1 },
|
{ name: 'Alpha', score: 1 },
|
||||||
|
|||||||
Reference in New Issue
Block a user