Files
apparel-designer/server.monolith.js
2026-05-24 08:44:29 -05:00

1104 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from 'express';
import cors from 'cors';
import multer from 'multer';
import { v4 as uuidv4 } from 'uuid';
import sharp from 'sharp';
import { createCanvas, loadImage, registerFont } from 'canvas';
import rateLimit from 'express-rate-limit';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { z } from 'zod';
import { fileURLToPath } from 'url';
import { dirname, join, resolve, sep, basename } from 'path';
import {
mkdirSync, existsSync, writeFileSync, readdirSync,
statSync, rmSync, readFileSync,
} from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
// ── Logger (S22) ───────────────────────────────────────────────────────────
//
// pino emits structured JSON. In production this is exactly what log
// aggregators (Datadog, CloudWatch, Loki, …) want. In development it's
// less pretty than `console.log` but you can pipe through `pino-pretty`
// or `jq` if you want colored output:
//
// npm run dev | npx pino-pretty
//
// Adding pino-pretty as a transport here would couple dev tooling into
// the runtime image, which we'd rather avoid. JSON-only is the simpler
// choice.
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { service: 'apparel-designer' },
});
// pino-http auto-logs every request with a per-request child logger that
// the handlers can grab via `req.log` if they want request-scoped fields.
// We exclude /api/health from the request log because the Docker
// HEALTHCHECK polls it every 30s and the noise is useless.
app.use(pinoHttp({
logger,
autoLogging: { ignore: (req) => req.url === '/api/health' },
}));
// ── Filesystem layout ──────────────────────────────────────────────────────
const uploadsDir = join(__dirname, 'uploads');
const exportsDir = join(__dirname, 'exports');
const uploadsRoot = resolve(uploadsDir);
const exportsRoot = resolve(exportsDir);
[uploadsDir, exportsDir].forEach((dir) => {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
});
// Resolve a path supplied via /uploads/... and verify it stays inside uploadsDir.
// Returns null for anything that escapes the directory. Defensive against the
// C6 path-traversal class of bug; keep using it for any client-supplied src.
function safeUploadPath(srcPath) {
if (typeof srcPath !== 'string') return null;
if (!srcPath.startsWith('/uploads/')) return null;
const rel = srcPath.slice('/uploads/'.length);
const resolved = resolve(uploadsRoot, rel);
if (resolved !== uploadsRoot && !resolved.startsWith(uploadsRoot + sep)) return null;
return resolved;
}
// Sticker library directory. Stickers are part of the app's static assets
// (not user-uploaded), so they live in public/ during development and
// dist/ after build. We pick the first one that exists at server start.
//
// Why two locations:
// In dev, the Vite dev server serves files directly out of public/, and
// our Express process only handles API endpoints. There is no `dist/`
// yet.
// In prod (NODE_ENV=production), Express serves the built SPA from
// `dist/`, and Vite has copied `public/` → `dist/` at build time, so
// the stickers live at `dist/stickers/`. The original `public/` copy
// is still there too, but `dist/` is the canonical runtime path.
const stickersDir = IS_PRODUCTION
? join(__dirname, 'dist', 'stickers')
: join(__dirname, 'public', 'stickers');
const stickersRoot = resolve(stickersDir);
// Path-traversal guard for client-supplied /stickers/... paths. Same shape
// as safeUploadPath but for the sticker library. Returns null when the
// resolved path escapes stickersRoot (e.g. /stickers/../../etc/passwd).
function safeStickerPath(srcPath) {
if (typeof srcPath !== 'string') return null;
if (!srcPath.startsWith('/stickers/')) return null;
const rel = srcPath.slice('/stickers/'.length);
const resolved = resolve(stickersRoot, rel);
if (resolved !== stickersRoot && !resolved.startsWith(stickersRoot + sep)) return null;
return resolved;
}
// ── TTL cleanup (S16) ──────────────────────────────────────────────────────
//
// Uploads and exports accumulate forever otherwise. We delete files older
// than FILE_TTL_MS on a periodic interval. For a real product, retention
// should be a per-account preference (paid plans keep work longer); this
// time-based sweep is a reasonable v1 that prevents the disk from filling
// up on a long-running deployment.
//
// `uploads/preview/` is a subdirectory holding sharp-resized previews; the
// recursion handles it without special-casing.
const FILE_TTL_MS = parseInt(process.env.FILE_TTL_MS || '', 10) || 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS || '', 10) || 60 * 60 * 1000;
function cleanupOldFiles(dir, ttlMs) {
if (!existsSync(dir)) return 0;
const now = Date.now();
let removed = 0;
let entries = [];
try {
entries = readdirSync(dir);
} catch (err) {
logger.warn({ err: err.message, dir }, 'Failed to list directory during cleanup');
return 0;
}
for (const entry of entries) {
const full = join(dir, entry);
try {
const stat = statSync(full);
if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
rmSync(full);
removed++;
} else if (stat.isDirectory()) {
// Recurse one level (e.g. uploads/preview).
removed += cleanupOldFiles(full, ttlMs);
}
} catch (err) {
// File could vanish between readdir and stat (concurrent cleanup,
// user uploaded then deleted, etc) — log at debug, not warn.
logger.debug({ err: err.message, file: full }, 'Cleanup skip');
}
}
return removed;
}
function runCleanup() {
const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS);
const e = cleanupOldFiles(exportsDir, FILE_TTL_MS);
if (u > 0 || e > 0) {
logger.info({ removedUploads: u, removedExports: e, ttlMs: FILE_TTL_MS }, 'Cleanup pass complete');
}
}
runCleanup();
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();
// ── Font registration ──────────────────────────────────────────────────────
//
// node-canvas only knows about fonts that are either (a) explicitly
// registered via registerFont() before any canvas is created, or (b)
// installed at the OS level and discoverable via fontconfig. Calling
// registerFont after the first createCanvas() is a no-op, so we do this
// at module load.
//
// Filename convention (matches scripts/fetch-fonts.mjs):
// "Family_Name-Variant.ttf" → registerFont(path, { family: 'Family Name',
// weight: 'bold' if -Bold,
// style: 'italic' if -Italic })
function registerFontsFromDir(dir) {
if (!existsSync(dir)) {
logger.warn({ dir, hint: 'Run `npm run fetch-fonts` to download the brand fonts. Without them, text exports will silently render in node-canvas\'s tiny built-in fallback font.' }, '⚠️ No fonts directory — text exports will be broken');
return 0;
}
let registered = 0, failed = 0;
for (const entry of readdirSync(dir)) {
if (!/\.(ttf|otf)$/i.test(entry)) continue;
const base = entry.replace(/\.(ttf|otf)$/i, '');
const dash = base.lastIndexOf('-');
const familyPart = dash > 0 ? base.slice(0, dash) : base;
const variant = dash > 0 ? base.slice(dash + 1) : 'Regular';
const family = familyPart.replace(/_/g, ' ');
const opts = { family };
if (/Bold/i.test(variant)) opts.weight = 'bold';
if (/Italic/i.test(variant)) opts.style = 'italic';
try {
registerFont(join(dir, entry), opts);
registered++;
} catch (err) {
failed++;
logger.warn({ err: err.message, font: entry }, 'Failed to register font');
}
}
// Empty fonts/ directory is the most common cause of silent text-export
// breakage — the directory exists (gitkept) but nobody ran fetch-fonts
// yet, so the loop above completes with registered=0 and the server
// appears to start cleanly. Surface this loudly so it's caught at
// boot instead of by inspecting blank text in a downloaded PNG.
if (registered === 0) {
logger.warn({
dir,
failed,
hint: 'Run `npm run fetch-fonts` to download the brand fonts, then restart this server. node-canvas registers fonts at first canvas creation — fetching them while the server is already running has no effect.',
}, '⚠️ No fonts registered — text exports will silently render in a tiny fallback font');
} else {
logger.info({ registered, failed, dir }, 'Font registration complete');
// Probe one of the registered families to confirm node-canvas is
// actually honoring our registerFont() calls. We've seen cases where
// the loop reports success (file opened, no exception thrown), but
// ctx.font is still ignored at draw time — typically because the
// node-canvas native module was compiled against an older Cairo that
// doesn't understand the TTF, or because some other code (e.g. an
// unrelated import) accidentally created a canvas before this
// function ran, freezing the font registry. The probe makes the
// failure mode visible at boot instead of silent at first export.
//
// We pick Pacifico if it's available (it's the brand-display face
// and the one most users care about), otherwise the first registered
// family alphabetically. The expected measured width of "M" at 100px
// should be on the order of 50120px depending on face; anything
// under 20 means the size string was ignored.
try {
const probeCanvas = createCanvas(10, 10);
const pctx = probeCanvas.getContext('2d');
// Try Pacifico first because that's the brand face and the one
// most likely to be exercised in user designs.
const probeFamily = 'Pacifico';
pctx.font = `100px "${probeFamily}"`;
const w = pctx.measureText('M').width;
if (w < 20) {
logger.warn({
probeFamily,
probeFontPx: 100,
measuredW: w,
hint: 'registerFont() reported success but ctx.font is being ignored at draw time. Possible causes: (1) another module created a canvas before font registration ran — check for top-level `createCanvas()` calls in imported files; (2) the node-canvas native module was compiled against a Cairo that can\'t parse the downloaded TTFs; (3) the server was restarted, but a watcher reloaded the module without re-executing the registration block.',
}, '⚠️ Font registration probe failed — fonts registered on disk but not honored at render time');
} else {
logger.info({ probeFamily, probeFontPx: 100, measuredW: w }, 'Font registration probe passed');
}
} catch (err) {
logger.warn({ err: err.message }, 'Font registration probe threw');
}
}
return registered;
}
const fontsDir = join(__dirname, 'fonts');
registerFontsFromDir(fontsDir);
// ── Middleware ─────────────────────────────────────────────────────────────
const corsOptions = IS_PRODUCTION
? { origin: process.env.CORS_ORIGIN || false }
: { origin: true };
app.use(cors(corsOptions));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// ── Rate limiters (S15) ────────────────────────────────────────────────────
//
// Per-IP rate limits prevent the trivial DOS of "spam-export 4500×4500
// PNGs in a loop". The numbers below are conservative; a real product
// would tune them based on traffic patterns. `standardHeaders: true`
// emits the IETF draft `RateLimit-*` headers so well-behaved clients
// can back off voluntarily.
const exportLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many export requests. Please try again later.' },
});
const uploadLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many upload requests. Please try again later.' },
});
// ── Static / SPA serving ──────────────────────────────────────────────────
app.use('/uploads', express.static(uploadsDir));
app.use('/exports', express.static(exportsDir));
if (IS_PRODUCTION) {
const clientDist = join(__dirname, 'dist');
app.use(express.static(clientDist));
} else {
// Dev UX: backend doesn't serve the SPA; Vite does.
// Vite is configured to listen on port 3000 (see vite.config.js).
const VITE_DEV_PORT = process.env.VITE_DEV_PORT || 3000;
app.get('/', (_req, res) => {
res
.status(302)
.set('Location', `http://localhost:${VITE_DEV_PORT}/`)
.send('Redirecting to Vite dev server...');
});
}
// ── Upload pipeline ────────────────────────────────────────────────────────
const MIME_TO_EXT = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = MIME_TO_EXT[file.mimetype] || 'bin';
cb(null, `${uuidv4()}.${ext}`);
},
});
const fileFilter = (_req, file, cb) => {
if (MIME_TO_EXT[file.mimetype]) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, and WebP are allowed.'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 20 * 1024 * 1024 },
});
// ── Zod schemas (S19) ──────────────────────────────────────────────────────
//
// Validates the export request body BEFORE we start allocating canvases
// and loading images. A malformed request that previously crashed deep
// inside renderElement now returns a clean 400 with the validation path.
//
// `passthrough()` on the element schema accepts unknown fields rather
// than rejecting them — the editor evolves and adds fields (filter,
// stroke, locked, etc.) faster than this schema would; we only enforce
// the fields the renderer actually reads.
const ElementSchema = z.object({
id: z.string().optional(),
type: z.enum(['image', 'text', 'sticker']).optional(),
x: z.number().optional(),
y: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
rotation: z.number().optional(),
opacity: z.number().min(0).max(1).optional(),
flipX: z.boolean().optional(),
flipY: z.boolean().optional(),
locked: z.boolean().optional(),
filter: z.string().optional(),
src: z.string().optional(),
emoji: z.string().optional(),
text: z.string().optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
fill: z.string().optional(),
stroke: z.string().nullable().optional(),
strokeWidth: z.number().optional(),
arc: z.number().optional(),
crop: z.object({
sx: z.number(), sy: z.number(),
sWidth: z.number(), sHeight: z.number(),
}).optional(),
slotId: z.string().optional(),
nonPrintable: z.boolean().optional(),
}).passthrough();
const ExportRequestSchema = z.object({
designName: z.string().max(200).optional(),
elements: z.array(ElementSchema),
template: z.object({
id: z.string().optional(),
background: z.object({
type: z.enum(['color', 'image']),
color: z.string().optional(),
src: z.string().optional(),
}).optional(),
overlay: z.array(ElementSchema).optional(),
}).nullable().optional(),
});
// ── Image filter pipeline (S21) ────────────────────────────────────────────
//
// The editor applies Konva filters (grayscale / sepia / invert) client-side
// to image elements. The export now applies the equivalent transform via
// sharp BEFORE handing the pixels to node-canvas, so the printed shirt
// matches the on-screen preview.
//
// Filter ids and their meaning come from src/constants/imageFilters.js.
// Adding a new preset is a two-file change: register it client-side, then
// add the matching sharp pipeline below.
const SEPIA_MATRIX = [
[0.393, 0.769, 0.189],
[0.349, 0.686, 0.168],
[0.272, 0.534, 0.131],
];
function applyFilterToSharp(s, filterId) {
switch (filterId) {
case 'grayscale': return s.grayscale();
case 'sepia': return s.recomb(SEPIA_MATRIX);
case 'invert': return s.negate();
case 'none':
default: return s;
}
}
/**
* Resolve `el.src` to either a buffer (for data: URLs, local /uploads/
* paths, and /stickers/ library paths) or a string URL (for external
* http://). Returns null on invalid paths — caller should skip the
* element with a logged warning.
*/
function resolveImageSource(src) {
if (typeof src !== 'string') return null;
if (src.startsWith('data:')) {
const comma = src.indexOf(',');
if (comma < 0) return null;
const base64 = src.slice(comma + 1);
try {
return { type: 'buffer', value: Buffer.from(base64, 'base64') };
} catch {
return null;
}
}
if (src.startsWith('/stickers/')) {
// Image sticker from the bundled library. URLs are stable
// (`/stickers/<filename>`) across dev and prod — see the Vite
// stickerManifestPlugin in vite.config.js for the client side, and
// `stickersDir` above for the corresponding disk lookup.
const safe = safeStickerPath(src);
if (!safe) return null;
try {
return { type: 'buffer', value: readFileSync(safe) };
} catch {
return null;
}
}
if (src.startsWith('/')) {
const safe = safeUploadPath(src);
if (!safe) return null;
try {
return { type: 'buffer', value: readFileSync(safe) };
} catch {
return null;
}
}
// External URL — node-canvas's loadImage handles fetching, but we
// can't pipe that through sharp without an extra fetch. For external
// URLs, the filter is silently skipped (a known limitation, also
// documented in src/constants/imageFilters.js).
return { type: 'url', value: src };
}
async function loadImageWithFilter(src, filterId = 'none') {
const resolved = resolveImageSource(src);
if (!resolved) throw new Error(`Invalid image source: ${src}`);
// Fast path: no filter, just hand the source to loadImage directly.
if (!filterId || filterId === 'none') {
return loadImage(resolved.type === 'buffer' ? resolved.value : resolved.value);
}
// External URL with filter → skip filter, log once.
if (resolved.type === 'url') {
logger.debug({ src }, 'External URL with filter; filter not applied');
return loadImage(resolved.value);
}
// Buffer + filter → run through sharp, then load the filtered buffer.
const filtered = await applyFilterToSharp(sharp(resolved.value), filterId).toBuffer();
return loadImage(filtered);
}
// ── Arc'd text rendering ────────────────────────────────────────────────
//
// Konva's TextPath lays glyphs along a path; node-canvas has no equivalent
// primitive, so we walk the path ourselves and draw one character at a
// time at the correct position-and-tangent on the curve.
//
// The path is a quadratic Bézier (matching TextElement.jsx). Endpoints:
// P0 = (0, baselineY)
// P2 = (pathW, baselineY)
// Control point:
// P1 = (pathW/2, controlY) where controlY = baselineY ± bulgeMag
//
// The point on the curve at parameter t in [0, 1] is:
// B(t) = (1-t)²·P0 + 2(1-t)t·P1 + t²·P2
// B'(t) = 2(1-t)·(P1-P0) + 2t·(P2-P1) — tangent vector
//
// To place glyph N starting at arc-length L from the path start, we need
// to invert B — find t such that arc-length-from-0-to-t equals L. There's
// no closed form, so we sample the curve, build a cumulative arc-length
// table, and look up t with a linear interpolation between samples.
const ARC_PATH_OVERSHOOT = 1.02; // matches TextElement.jsx pathW factor.
const ARC_SAMPLES = 100; // 100 samples is well under sub-pixel error
// for typical curvatures, and the table
// is built once per text element.
/**
* Build a sample table of (t, cumulativeArcLength) along a quadratic
* Bézier from P0 through P1 to P2. We approximate arc length by summing
* the chord lengths between consecutive sample points — standard
* polyline-approximation approach.
*/
function buildArcLengthTable(p0, p1, p2) {
const table = new Array(ARC_SAMPLES + 1);
table[0] = { t: 0, len: 0, x: p0.x, y: p0.y };
let prevX = p0.x;
let prevY = p0.y;
let cumLen = 0;
for (let i = 1; i <= ARC_SAMPLES; i++) {
const t = i / ARC_SAMPLES;
const omt = 1 - t;
const x = omt * omt * p0.x + 2 * omt * t * p1.x + t * t * p2.x;
const y = omt * omt * p0.y + 2 * omt * t * p1.y + t * t * p2.y;
cumLen += Math.hypot(x - prevX, y - prevY);
table[i] = { t, len: cumLen, x, y };
prevX = x; prevY = y;
}
return table;
}
/**
* Given an arc-length value, return { t, x, y, angle } at that distance
* along the curve. Angle is the path tangent in radians — the glyph
* will be rotated by this so it sits perpendicular to the curve like
* letters on a road sign.
*
* Clamps to the curve endpoints if the requested length is outside
* [0, totalLength].
*/
function sampleAtArcLength(table, p0, p1, p2, targetLen) {
const totalLen = table[ARC_SAMPLES].len;
if (targetLen <= 0) {
// Clamp to start. Tangent at t=0 is 2*(P1-P0).
const tx = 2 * (p1.x - p0.x);
const ty = 2 * (p1.y - p0.y);
return { t: 0, x: p0.x, y: p0.y, angle: Math.atan2(ty, tx) };
}
if (targetLen >= totalLen) {
// Clamp to end. Tangent at t=1 is 2*(P2-P1).
const tx = 2 * (p2.x - p1.x);
const ty = 2 * (p2.y - p1.y);
return { t: 1, x: p2.x, y: p2.y, angle: Math.atan2(ty, tx) };
}
// Find the sample bracket [i, i+1] such that table[i].len <= targetLen
// <= table[i+1].len. Linear scan is fine — ARC_SAMPLES is small enough
// that binary search would be marginal at best.
let i = 0;
while (i < ARC_SAMPLES && table[i + 1].len < targetLen) i++;
const a = table[i];
const b = table[i + 1];
const segLen = b.len - a.len;
const frac = segLen > 0 ? (targetLen - a.len) / segLen : 0;
const t = a.t + (b.t - a.t) * frac;
// Recompute point and tangent at the refined t.
const omt = 1 - t;
const x = omt * omt * p0.x + 2 * omt * t * p1.x + t * t * p2.x;
const y = omt * omt * p0.y + 2 * omt * t * p1.y + t * t * p2.y;
const tx = 2 * omt * (p1.x - p0.x) + 2 * t * (p2.x - p1.x);
const ty = 2 * omt * (p1.y - p0.y) + 2 * t * (p2.y - p1.y);
return { t, x, y, angle: Math.atan2(ty, tx) };
}
/**
* Draw a string along a quadratic Bézier path, mirroring Konva's
* TextPath with align="center".
*
* Caller has already set ctx.font, ctx.fillStyle, and (if needed)
* ctx.strokeStyle / lineWidth. We restore textAlign / textBaseline at
* the end so the caller's state stays intact.
*
* The (x, y) coordinates passed in are the text element's design-coord
* origin scaled to export coords — the same anchor we'd use for flat
* text. The path coordinates are computed relative to this origin so
* the arc visually anchors where the user placed it on the canvas.
*/
function renderTextOnArc(ctx, {
text, x, y, fontSize, fontFamily,
approxW, approxH, arc,
fill, stroke, strokeWidth,
}) {
const prevAlign = ctx.textAlign;
const prevBaseline = ctx.textBaseline;
// Path geometry — mirrors TextElement.jsx exactly.
const arcAbs = Math.min(100, Math.abs(arc));
const baselineY = approxH * 0.85;
const bulgeMag = (arcAbs / 100) * approxH * 1.4;
const controlY = arc > 0 ? baselineY - bulgeMag : baselineY + bulgeMag;
const pathW = approxW * ARC_PATH_OVERSHOOT;
// Translate so the path's local origin (0, 0) lines up with the text
// element's design-coord origin in export coords.
ctx.save();
ctx.translate(x, y);
const p0 = { x: 0, y: baselineY };
const p1 = { x: pathW / 2, y: controlY };
const p2 = { x: pathW, y: baselineY };
const table = buildArcLengthTable(p0, p1, p2);
const totalLen = table[ARC_SAMPLES].len;
// Center-align: leading offset so the text sits centered on the path.
// Measure each glyph individually so we use the same font metrics the
// upcoming draw calls will use, instead of measuring the whole string
// (which can differ slightly from sum-of-parts due to kerning that
// Canvas's measureText doesn't expose at the character level anyway).
const widths = new Array(text.length);
let textLen = 0;
for (let i = 0; i < text.length; i++) {
widths[i] = ctx.measureText(text[i]).width;
textLen += widths[i];
}
// Lead-in offset: half of the leftover path length, so text is centered.
// Clamped to 0 if text is longer than the path (rare with our 2%
// overshoot, but defensive).
const leadIn = Math.max(0, (totalLen - textLen) / 2);
ctx.textBaseline = 'alphabetic'; // glyph baseline sits on the path
ctx.textAlign = 'center'; // each glyph centered on its sample point
let cursor = leadIn;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const w = widths[i];
// Place the glyph centered at the midpoint of its allocated arc
// length. Drawing each character one at a time means we lose
// kerning pairs that the font might otherwise apply, but that's
// an unavoidable trade-off for path-laid text — same as Konva's
// own TextPath.
const sample = sampleAtArcLength(table, p0, p1, p2, cursor + w / 2);
ctx.save();
ctx.translate(sample.x, sample.y);
ctx.rotate(sample.angle);
if (fill) {
ctx.fillStyle = fill;
ctx.fillText(ch, 0, 0);
}
if (stroke && strokeWidth > 0) {
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = stroke;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeText(ch, 0, 0);
}
ctx.restore();
cursor += w;
}
ctx.restore();
ctx.textAlign = prevAlign;
ctx.textBaseline = prevBaseline;
}
// ── API: Health check that exercises the renderer (S20) ────────────────────
app.get('/api/health', async (_req, res) => {
// Returns 200 only when (a) node-canvas can render a tiny scene, and
// (b) sharp can resize that scene's output. The Docker HEALTHCHECK
// polls this endpoint, so a degraded renderer takes the container
// out of the load balancer rather than serving broken exports.
try {
const c = createCanvas(50, 50);
const cctx = c.getContext('2d');
cctx.fillStyle = '#ec4899';
cctx.fillRect(0, 0, 50, 50);
cctx.fillStyle = '#fff';
cctx.font = '16px sans-serif';
cctx.fillText('ok', 8, 32);
const buf = c.toBuffer('image/png');
await sharp(buf).resize(25, 25).toBuffer();
res.json({ status: 'ok', timestamp: new Date().toISOString() });
} catch (err) {
logger.error({ err: err.message }, 'Health check failed');
res.status(503).json({
status: 'degraded',
error: err.message,
timestamp: new Date().toISOString(),
});
}
});
// ── API: Upload ────────────────────────────────────────────────────────────
app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const originalUrl = `/uploads/${req.file.filename}`;
// Create preview by resizing to max 1000px
const previewFilename = `${uuidv4()}.png`;
const previewDir = join(uploadsDir, 'preview');
if (!existsSync(previewDir)) mkdirSync(previewDir, { recursive: true });
await sharp(req.file.path)
.resize({ width: 1000, height: 1000, fit: 'inside' })
.png()
.toFile(join(previewDir, previewFilename));
res.json({
success: true,
original: {
url: originalUrl,
filename: req.file.filename,
size: req.file.size,
mimetype: req.file.mimetype,
},
preview: {
url: `/uploads/preview/${previewFilename}`,
filename: previewFilename,
},
});
} catch (err) {
req.log?.error({ err: err.message }, 'Upload pipeline failure');
res.status(500).json({ error: 'Failed to process upload', details: err.message });
}
});
// ── API: Export ────────────────────────────────────────────────────────────
const EXPORT_SCALE = 15;
const EXPORT_SIZE = 4500;
app.post('/api/export', exportLimiter, async (req, res) => {
// Validate up-front. Failure here is cheap (no canvas allocated yet).
const parsed = ExportRequestSchema.safeParse(req.body);
if (!parsed.success) {
req.log?.warn({ issues: parsed.error.flatten() }, 'Export request validation failed');
return res.status(400).json({
error: 'Invalid export request',
details: parsed.error.flatten(),
});
}
const { elements, designName = 'design', template } = parsed.data;
try {
const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE);
const ctx = canvas.getContext('2d');
// Template background
//
// Three behaviors:
// - color: fill the entire export with the template's color. Templates
// opting into a color background do so deliberately (it's how a
// framed-art template or a tile design carries its base layer).
// - image: draw the template's image as a full-bleed background.
// - (no template, or template without a background block): leave
// the canvas transparent. The export's purpose is the design,
// not the shirt mockup — the shirt is what the design gets
// PRINTED onto, so the export needs to be transparent for the
// printer to composite the design over the actual fabric.
// Filling white here would screen-print a literal white
// rectangle onto every shirt color, which is the opposite of
// what the user wants.
//
// Image-background load failure falls through to transparent (not
// white) so a broken template doesn't bake an opaque white frame
// into the export. The render still completes; the user just sees
// the design's foreground elements over the page's natural
// background in any preview, and over the shirt color when printed.
if (template?.background) {
const bg = template.background;
if (bg.type === 'color') {
ctx.fillStyle = bg.color;
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
} else if (bg.type === 'image' && bg.src) {
try {
let imgUrl;
if (bg.src.startsWith('/')) {
const safe = safeUploadPath(bg.src);
if (!safe) throw new Error(`Invalid template background path: ${bg.src}`);
imgUrl = safe;
} else {
imgUrl = bg.src;
}
const img = await loadImage(imgUrl);
ctx.drawImage(img, 0, 0, EXPORT_SIZE, EXPORT_SIZE);
} catch (imgError) {
req.log?.warn({ err: imgError.message }, 'Template background load failed; leaving canvas transparent');
}
}
}
// No `else` branch — the canvas stays at its default fully
// transparent state, which is what we want for printable exports.
// Render a single element.
// Konva rotates around the node's (x, y) origin, NOT the center, so the
// export mirrors that: translate to (x, y), rotate, draw at (x, y).
//
// 'image' and 'sticker' types share the same rendering path (S10).
// Stickers are images whose `src` is a data: URL of a rasterized
// emoji glyph; both go through `loadImageWithFilter`.
const renderElement = async (el) => {
ctx.save();
const x = (el.x || 0) * EXPORT_SCALE;
const y = (el.y || 0) * EXPORT_SCALE;
const rotation = el.rotation || 0;
const opacity = typeof el.opacity === 'number' ? el.opacity : 1;
ctx.globalAlpha = opacity;
if (rotation) {
ctx.translate(x, y);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-x, -y);
}
const isImage = el.type === 'image' || el.type === 'sticker';
if (isImage && el.src) {
try {
const img = await loadImageWithFilter(el.src, el.filter || 'none');
const width = (el.width || 100) * EXPORT_SCALE;
const height = (el.height || 100) * EXPORT_SCALE;
// 🖼️ Diagnostic logging for image / sticker elements. Captures the
// resolved image's natural dimensions, the draw box, and whether
// the source carried an alpha channel. node-canvas's loadImage
// returns a Canvas-like Image with `width`/`height` properties
// even when the underlying PNG had alpha; the alpha is only
// preserved if the render canvas was set up with alpha support
// (which createCanvas does by default).
logger.info({
type: el.type,
srcPrefix: typeof el.src === 'string' ? el.src.slice(0, 60) : undefined,
srcKind: el.src.startsWith('data:') ? 'data-url'
: el.src.startsWith('/stickers/') ? 'sticker-library'
: el.src.startsWith('/') ? 'upload' : 'external',
naturalSize: { w: img.width, h: img.height },
drawAt: { x, y, width, height },
crop: el.crop,
rotation: el.rotation,
opacity: el.opacity,
flip: { x: el.flipX, y: el.flipY },
}, '🖼️ image render');
// Apply flip via context transform around the element's center.
if (el.flipX || el.flipY) {
const cx = x + width / 2;
const cy = y + height / 2;
ctx.translate(cx, cy);
ctx.scale(el.flipX ? -1 : 1, el.flipY ? -1 : 1);
ctx.translate(-cx, -cy);
}
if (el.crop) {
ctx.drawImage(img, el.crop.sx, el.crop.sy, el.crop.sWidth, el.crop.sHeight, x, y, width, height);
} else {
ctx.drawImage(img, x, y, width, height);
}
} catch (imgError) {
req.log?.warn({ err: imgError.message, src: el.src }, '⚠️ Image element load failed');
}
} else if (el.type === 'text') {
// Mirror Konva's Text positioning AND alignment.
//
// On the client, TextElement renders with `align="center"` and an
// explicit `width = measuredW` (the actual canvas-measured glyph
// width). That visually centers the text within the width box.
// When `width === measuredW` (the common case), centering is a
// no-op horizontally and the visible glyph's left edge sits at x.
// But for short text where `width = max(fontSize, measuredW)` >
// measuredW (e.g. a single short word), the visible text sits
// centered within the wider width.
//
// To match that exactly, we mirror Konva's behavior here: measure
// the rendered text width on the export's font, then center it
// within the bbox. We use `ctx.textAlign = 'center'` and draw at
// x + width/2 (a half-width offset from the left edge). If the
// client somehow sent a width less than the measured width we
// default to the measured value so the export never under-allocates.
const fontSize = (el.fontSize || 32) * EXPORT_SCALE;
const fontFamily = el.fontFamily || 'DM Sans';
ctx.font = `${fontSize}px "${fontFamily}"`;
ctx.textBaseline = 'top';
const textStr = el.text || '';
const measured = ctx.measureText(textStr).width;
// Arc routing. When the user has dialed in non-zero arc, the
// client renders via Konva's TextPath. We mirror that here by
// walking the same quadratic Bézier and drawing one glyph at a
// time. See renderTextOnArc above for the math; this branch
// delegates everything (positioning, centering, stroke) to it.
//
// approxW / approxH are the client's measurements scaled to
// export coords. LINE_HEIGHT_RATIO mirrors the constants file
// (1.2) — hardcoded here because server.js doesn't import the
// client constants module.
const arc = el.arc ?? 0;
const arcAbs = Math.min(100, Math.abs(arc));
const isArced = arcAbs > 0.5;
if (isArced) {
const LINE_HEIGHT_RATIO = 1.2;
const approxW = Math.max(fontSize, measured);
const approxH = fontSize * LINE_HEIGHT_RATIO;
renderTextOnArc(ctx, {
text: textStr,
x, y,
fontSize, fontFamily,
approxW, approxH, arc,
fill: el.fill || '#000000',
stroke: el.stroke,
strokeWidth: (el.strokeWidth || 0) * EXPORT_SCALE,
});
// ✍️ Diagnostic for the arc'd path.
logger.info({
text: textStr,
fontFamily,
fontSizePx: fontSize,
arc,
drawAt: { x, y },
measuredW: measured,
fill: el.fill,
}, '✍️ text render (arc)');
ctx.restore();
return;
}
// Konva-side width is in design units; scale to export units. If
// not supplied (older saved state, mid-flight elements), fall
// back to the measured glyph width — same as Konva's `width =
// max(fontSize, measuredW)` behavior.
const designW = typeof el.width === 'number' && el.width > 0 ? el.width * EXPORT_SCALE : measured;
const boxW = Math.max(designW, measured);
const drawX = x + boxW / 2;
ctx.textAlign = 'center';
// Sanity check — if the font lookup silently failed, node-canvas
// falls back to a ~1012px built-in font regardless of the size
// we requested in the ctx.font string. This is its documented
// behavior and there's no API to detect it directly, but we can
// infer it: a 720px font rendering "Bonnie" should measure
// thousands of pixels wide, not tens. If the measured width is
// wildly smaller than the requested font size suggests, the font
// didn't take. We log a loud warning so missing-font failures
// surface in the log instead of producing a silent
// tiny-text-in-the-corner export.
//
// Heuristic: any non-empty text whose measured width is less than
// 30% of the font size is suspicious. "i" at full size measures
// ~30% of the em-square; anything shorter than that suggests the
// glyphs are being rendered at the default size, not the requested
// size.
if (textStr.length > 0 && measured < fontSize * 0.3 * textStr.length / Math.max(textStr.length, 4)) {
req.log?.warn({
text: textStr,
fontFamily,
requestedFontPx: fontSize,
measuredW: measured,
hint: `Font "${fontFamily}" may not be registered with node-canvas. Run \`npm run fetch-fonts\` and restart the server.`,
}, '⚠️ text render produced suspiciously small width — font likely missing');
}
// Text stroke (S8). Konva renders fill-then-stroke by default,
// so the stroke draws on top of the fill, which is what reads
// visually as an outline. We mirror that here.
ctx.fillStyle = el.fill || '#000000';
ctx.fillText(textStr, drawX, y);
if (el.stroke && typeof el.strokeWidth === 'number' && el.strokeWidth > 0) {
ctx.lineWidth = el.strokeWidth * EXPORT_SCALE;
ctx.strokeStyle = el.stroke;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeText(textStr, drawX, y);
}
// ✍️ Diagnostic logging — fires once per text element rendered.
// Emoji-prefixed so it's easy to grep export-related log lines
// out of a noisy request log. Promoted from `debug` to `info`
// level so it surfaces without LOG_LEVEL adjustment when
// investigating missing-text reports.
logger.info({
text: textStr,
fontFamily,
fontSizePx: fontSize,
drawAt: { x: drawX, y },
measuredW: measured,
boxW,
fill: el.fill,
}, '✍️ text render');
}
ctx.restore();
};
// 📦 Diagnostic: log the element manifest received from the client so we
// can correlate "the user says X is missing" against "the server saw X
// in the payload". Trims `src` strings to a short prefix so the log
// line doesn't blow up with a multi-KB data: URL. Emoji prefixes
// (🎨 📦 🖼️ ✍️ ✅ ⚠️) make export-related log lines easy to grep out of
// a noisy request log.
req.log?.info({
count: elements.length,
elements: elements.map((el) => ({
type: el.type,
x: el.x, y: el.y,
width: el.width, height: el.height,
text: el.type === 'text' ? el.text : undefined,
fontSize: el.fontSize,
fontFamily: el.fontFamily,
fill: el.fill,
srcPrefix: typeof el.src === 'string' ? el.src.slice(0, 60) : undefined,
srcLen: typeof el.src === 'string' ? el.src.length : undefined,
rotation: el.rotation,
nonPrintable: el.nonPrintable,
})),
}, '🎨 Export request element manifest');
for (const el of elements) {
if (el.nonPrintable) continue;
await renderElement(el);
}
if (template?.overlay) {
for (const overlayEl of template.overlay) {
if (overlayEl.nonPrintable) continue;
await renderElement(overlayEl);
}
}
const exportFilename = `${designName.replace(/[^a-z0-9]/gi, '_')}_${uuidv4()}.png`;
const exportPath = join(exportsDir, exportFilename);
writeFileSync(exportPath, canvas.toBuffer('image/png'));
req.log?.info({ filename: exportFilename, sizeBytes: canvas.toBuffer('image/png').length }, '✅ Export complete');
res.json({
success: true,
export: {
url: `/exports/${exportFilename}`,
filename: exportFilename,
width: EXPORT_SIZE,
height: EXPORT_SIZE,
dpi: 300,
sizeInches: '15x15',
},
});
} catch (err) {
req.log?.error({ err: err.message }, '⚠️ Export pipeline failure');
res.status(500).json({ error: 'Failed to export design', details: err.message });
}
});
// ── API: Download ──────────────────────────────────────────────────────────
app.get('/api/download/:filename', (req, res) => {
const safeName = basename(req.params.filename || '');
if (!safeName || safeName.startsWith('.')) {
return res.status(400).json({ error: 'Invalid filename' });
}
const filePath = join(exportsDir, safeName);
const resolvedPath = resolve(filePath);
if (resolvedPath !== exportsRoot && !resolvedPath.startsWith(exportsRoot + sep)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!existsSync(filePath)) {
return res.status(404).json({ error: 'File not found' });
}
res.download(filePath);
});
// ── 404 / SPA catch-alls ───────────────────────────────────────────────────
app.all('/api/*', (_req, res) => {
res.status(404).json({ error: 'API route not found' });
});
if (IS_PRODUCTION) {
app.get('*', (_req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'));
});
}
// ── Error handler ──────────────────────────────────────────────────────────
app.use((err, req, res, _next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 20MB.' });
}
return res.status(400).json({ error: err.message });
}
req.log?.error({ err: err.message, stack: err.stack }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error', details: err.message });
});
app.listen(PORT, () => {
logger.info({ port: PORT, mode: IS_PRODUCTION ? 'production' : 'development' }, 'Server listening');
});