1104 lines
45 KiB
JavaScript
1104 lines
45 KiB
JavaScript
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/<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 ~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');
|
||
});
|