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>
329 lines
10 KiB
TypeScript
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;
|
|
}
|