Files
pn-new-crm/src/lib/services/logo.service.ts
Matt 6517e014a6 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>
2026-05-12 20:51:49 +02:00

329 lines
10 KiB
TypeScript

/**
* 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;
}