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 50–120px 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/`) 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 ~10–12px 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'); });