// Host-side server. After the module extraction this shrunk from // ~700 lines (the original editor's server.js) to a thin wrapper // that mounts the module's middleware + provides the host's own // upload endpoint. // // What stayed in the host // ─────────────────────── // • The HTTP server itself (express, port binding, listen) // • CORS, rate limiting, logger — host-level concerns // • The /api/upload endpoint (host owns upload storage) // • SPA static serving for the built frontend // • Cleanup of old uploaded files (TTL sweep) // // What moved to the module (goods-editor/server) // ────────────────────────────────────────────── // • POST /api/export — the canvas+sharp render pipeline // • GET /api/download/:filename — serve exported PNGs // • GET /api/health — health probe // • Font registration with node-canvas, plus GET // /api/fonts/fonts.css and GET /api/fonts/ // for browser-side delivery. index.html loads the .css // directly; the Google Fonts CDN link the host used to // ship is gone with v0.8. // • POST/GET/DELETE /api/designs/* — design persistence, // mounted only when a Postgres `db` handle is passed to // mountEditorApi. Wired (#23): we pass pgPool when // DATABASE_URL is set; without it the routes don't mount // and the client falls back to cart-without-design_id. import express from 'express'; import cors from 'cors'; import multer from 'multer'; import { v4 as uuidv4 } from 'uuid'; import sharp from 'sharp'; import canvas from 'canvas'; import rateLimit from 'express-rate-limit'; import pino from 'pino'; import pinoHttp from 'pino-http'; import { fileURLToPath } from 'url'; import { basename, dirname, join, resolve } from 'path'; import { mkdirSync, existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs'; import { mountEditorApi } from 'goods-editor/server'; import { renderDesignComposite } from 'goods-editor/server'; import { mountTemplateStore } from 'template-manager/server'; import { marketingPreviewRenderer } from 'template-manager/server'; import { mountAuth, runMigrations } from '@khalidadil/auth-manager/server'; import pkg from 'pg'; const { Pool } = pkg; import { SEED_PRODUCTS } from './src/constants/seedProducts.js'; 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'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', base: { service: 'apparel-designer' }, }); app.use(pinoHttp({ logger, autoLogging: { ignore: (req) => req.url === '/api/health' }, })); // ── Filesystem layout ────────────────────────────────────────────── const uploadsDir = join(__dirname, 'uploads'); const exportsDir = join(__dirname, 'exports'); const templatesStorageDir = join(__dirname, 'templates'); const fontsDir = join(__dirname, 'node_modules', 'goods-editor', 'fonts'); const stickersDir = IS_PRODUCTION ? join(__dirname, 'dist', 'stickers') : join(__dirname, 'public', 'stickers'); // (uploadsRoot + `sep` removed — dead since the path-guarded routes // moved into goods-editor/template-manager; eslint now parses this // file (ecmaVersion 2022) and flagged them.) // v0.6 — mockupsDir is gone (variant mockups removed). Product // mockups live under templatesStorageDir/products//sizes// // and are managed entirely by template-manager. [uploadsDir, exportsDir, templatesStorageDir].forEach((dir) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); }); // ── TTL cleanup ───────────────────────────────────────────────────── 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; // Referenced-file exemption (#24) — template JSONs (element srcs, // slot previewSrc, entry-config imagery) and persisted designs // (Postgres designs.design_json) reference files as `/uploads/` // (or `/uploads/preview/`). Those references are long-lived; // sweeping the files at 24h breaks template renders and design // re-renders. Before each sweep pass we collect every referenced // basename (uploads filenames are UUID-based, so basename matching // is collision-free) and skip deleting those files. A simple regex // over raw JSON text keeps this robust to schema drift. const UPLOADS_REF_RE = /\/uploads\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)/g; function collectUploadRefs(text, out) { UPLOADS_REF_RE.lastIndex = 0; let m; while ((m = UPLOADS_REF_RE.exec(text)) !== null) { out.add(basename(m[1])); } } // Recursively scan a directory's *.json files for /uploads/ references. // Covers template-manager's whole storage tree: templates// // metadata.json + versions/*.json, products//product.json, and // the _*.json index docs. function scanDirForUploadRefs(dir, out) { if (!existsSync(dir)) return; let entries = []; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = join(dir, entry.name); try { if (entry.isDirectory()) { scanDirForUploadRefs(full, out); } else if (entry.isFile() && entry.name.endsWith('.json')) { collectUploadRefs(readFileSync(full, 'utf8'), out); } } catch { /* unreadable entry — skip */ } } } // Built fresh once per sweep pass (templates/designs change between // passes; a single Set per pass is cache enough). async function collectReferencedUploads() { const referenced = new Set(); try { scanDirForUploadRefs(templatesStorageDir, referenced); } catch { /* never block the sweep */ } // Persisted designs — only when Postgres is wired. Any db error // (table not migrated yet, connection down) skips silently: the // sweep must never crash the host. if (pgPool) { try { const { rows } = await pgPool.query('SELECT design_json FROM designs WHERE deleted_at IS NULL'); for (const row of rows) { collectUploadRefs(JSON.stringify(row.design_json), referenced); } } catch { /* db unavailable — protect what templates reference */ } } return referenced; } function cleanupOldFiles(dir, ttlMs, protectedNames = null, skipDirs = null) { if (!existsSync(dir)) return { removed: 0, protectedCount: 0 }; const now = Date.now(); let removed = 0; let protectedCount = 0; let entries = []; try { entries = readdirSync(dir); } catch { return { removed, protectedCount }; } for (const entry of entries) { const full = join(dir, entry); try { const stat = statSync(full); if (stat.isFile() && now - stat.mtimeMs > ttlMs) { if (protectedNames && protectedNames.has(entry)) { protectedCount++; } else { rmSync(full); removed++; } } else if (stat.isDirectory()) { if (skipDirs && skipDirs.has(resolve(full))) continue; const sub = cleanupOldFiles(full, ttlMs, protectedNames, skipDirs); removed += sub.removed; protectedCount += sub.protectedCount; } } catch { /* skip */ } } return { removed, protectedCount }; } // exports/designs is goods-editor's designsDir (mountEditorApi default: // /designs). It holds persisted design artifacts — source // images, thumbnails, print renders — whose lifecycle is db-driven // (designs.deleted_at soft-delete, 30-day guest TTL), not this sweep's // 24h TTL. Source images in particular are unrecoverable, so the sweep // skips the whole subtree. const designsArtifactsDir = resolve(join(exportsDir, 'designs')); async function runCleanup() { try { const referenced = await collectReferencedUploads(); const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS, referenced); const e = cleanupOldFiles(exportsDir, FILE_TTL_MS, null, new Set([designsArtifactsDir])); if (u.removed > 0 || e.removed > 0 || u.protectedCount > 0) { logger.info({ removedUploads: u.removed, removedExports: e.removed, protectedUploads: u.protectedCount, ttlMs: FILE_TTL_MS, }, 'Cleanup pass complete'); } } catch (err) { logger.warn({ err: err.message }, 'Cleanup pass failed; will retry next interval'); } } // NOTE: the initial run + interval start AFTER the auth-manager // bootstrap below — collectReferencedUploads reads `pgPool`, which is // declared there (TDZ until then). // ── App-level middleware (CORS, JSON, rate limits) ───────────────── 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' })); // ── Facet personalization config (Project doc §R10) ────────────── // Env-driven, host-injected into both module mounts. FACET_KEY is the // master switch — unset ⇒ the feature is inert (facet-config reports // disabled, the account routes no-op, templates are unfiltered, and the // editor shows all stickers). The facet *values* (dog/cat/…) are // admin-managed inside template-manager; only the definition is here. const envBool = (v, dflt = false) => ( v === undefined || v === null || v === '' ? dflt : /^(1|true|yes|on)$/i.test(String(v).trim()) ); const facetConfig = { key: (process.env.FACET_KEY || '').trim() || null, label: (process.env.FACET_LABEL || '').trim() || null, selectionMode: process.env.FACET_SELECTION_MODE === 'single' ? 'single' : 'multi', requireSelection: envBool(process.env.REQUIRE_FACET_SELECTION, false), }; const requireTemplateCategory = envBool(process.env.REQUIRE_TEMPLATE_CATEGORY, false); if (facetConfig.key) { logger.info({ key: facetConfig.key, label: facetConfig.label, selectionMode: facetConfig.selectionMode, requireSelection: facetConfig.requireSelection, requireTemplateCategory, }, 'Personalization facet enabled'); } // ── auth-manager bootstrap ───────────────────────────────────────── // Runs migrations + mounts auth routes if DATABASE_URL is set. If // it isn't, we log a warning and continue without auth so a developer // can still iterate on goods-editor / template-manager pieces without // provisioning Postgres locally. let pgPool = null; if (process.env.DATABASE_URL) { pgPool = new Pool({ connectionString: process.env.DATABASE_URL }); try { await runMigrations(pgPool, { logger }); mountAuth(app, { db: pgPool, apiBase: '/api', callbackBaseUrl: process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`, smtp: process.env.SMTP_HOST ? { host: process.env.SMTP_HOST, port: process.env.SMTP_PORT, user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, from: process.env.SMTP_FROM, } : undefined, fromAddress: process.env.SMTP_FROM || 'no-reply@example.com', appName: 'Pawfectly Yours', googleClientId: process.env.GOOGLE_CLIENT_ID, googleClientSecret: process.env.GOOGLE_CLIENT_SECRET, adminEmails: (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim()).filter(Boolean), cookieDomain: process.env.COOKIE_DOMAIN || undefined, isProduction: IS_PRODUCTION, // Facet personalization (Project doc §R10) — scopes the // customer_facet_values rows + adds facetValues to /me. Inert // when facetConfig.key is null. facet: facetConfig, logger, }); } catch (err) { logger.error({ err: err.message }, 'auth-manager bootstrap failed; auth disabled'); try { await pgPool.end(); } catch { /* ignore */ } pgPool = null; } } else { logger.warn('DATABASE_URL not set — auth-manager disabled. Set DATABASE_URL in .env to enable customer accounts and admin SSO.'); } // ── goods-editor designs migration (#23) ────────────────────────── // The module documents that the HOST applies its designs schema before // passing `db` to mountEditorApi (001_designs.sql is idempotent — // CREATE TABLE IF NOT EXISTS + CREATE OR REPLACE). With the file: dep // the SQL is reachable through node_modules; a packed install ships // dist/ only, so a missing file logs a warning instead of crashing — // the /designs routes still mount and fail loudly until the table is // created by hand. if (pgPool) { const designsMigrationPath = join( __dirname, 'node_modules', 'goods-editor', 'src', 'server', 'migrations', '001_designs.sql', ); try { if (existsSync(designsMigrationPath)) { await pgPool.query(readFileSync(designsMigrationPath, 'utf8')); logger.info('goods-editor designs migration applied'); } else { logger.warn({ path: designsMigrationPath }, 'goods-editor designs migration not found — apply 001_designs.sql manually before using design persistence'); } } catch (err) { logger.error({ err: err.message }, 'goods-editor designs migration failed — POST /api/designs will error until the designs table exists'); } } // TTL sweep kickoff (#24) — here rather than at the definition site // because collectReferencedUploads reads pgPool. runCleanup(); setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref(); const uploadLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many upload requests. Please try again later.' }, }); // ── Static serving ───────────────────────────────────────────────── app.use('/uploads', express.static(uploadsDir)); app.use('/exports', express.static(exportsDir)); if (IS_PRODUCTION) { app.use(express.static(join(__dirname, 'dist'))); } // ── Host-owned: upload endpoint ──────────────────────────────────── 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 }, }); // Separate multer instance for variant mockup uploads. The template- // manager's variants route calls `.single('file')` on this and then // renames `req.file.path` to //.png // inside the route handler. We use disk storage with a temp filename // in the host's uploadsDir; the rename moves it to its final mockups // location. Restricted to PNG since that's the file extension the // route writes — a JPEG/WebP source would end up with a misleading // .png extension on disk. const mockupUpload = multer({ storage: multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadsDir), filename: (_req, _file, cb) => cb(null, `mockup-${uuidv4()}.tmp`), }), fileFilter: (_req, file, cb) => { if (file.mimetype === 'image/png') cb(null, true); else cb(new Error('Only PNG mockups are accepted.')); }, limits: { fileSize: 10 * 1024 * 1024 }, }); 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}`; 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 }); } }); // ── Module-owned: editor API (export, download, health) ──────────── // v0.8 (#23) — `db` gates the design-persistence routes (POST/GET/ // DELETE /api/designs…). pgPool is null without DATABASE_URL (or when // the auth bootstrap failed), in which case the routes simply don't // mount and the client's persistCurrentDesign 404s into its graceful // cart-without-design_id fallback. mountEditorApi(app, { canvas, sharp, exportsDir, fontsDir, stickersDir, uploadsDir, basePath: '/api', logger, db: pgPool, }); // Template store (admin + public). adminAuth pass-through for v1 // per the plan; v1.5 swaps in a real middleware via server/lib/auth.js. // // v0.6 — the host no longer passes a variantCatalog or phoneModels. // template-manager owns the catalog (productsStore) and accepts a // seedProducts array used on first boot only. After the first boot, // the catalog lives in `/_products_index.json` // + `/products//product.json` and the seed // is ignored even if it changes (intentional: prevents unintended // catalog rewrites on server restart). const templateStore = mountTemplateStore(app, { storagePath: templatesStorageDir, uploadsPath: uploadsDir, adminAuth: (req, res, next) => next(), seedProducts: SEED_PRODUCTS, multer: mockupUpload, logger, basePath: '/api', // Facet personalization (Project doc §R10) — server-enforced template // categorization gate + the facet definition exposed via // GET /api/public/facet-config. Both inert when facetConfig.key null. requireTemplateCategory, facet: facetConfig, onTemplateChange: (templateId, kind) => { logger.info({ templateId, kind }, 'Template changed'); // TODO (v0.4): trigger SSG rebuild for the affected template's // shop page. v1 no-op; client re-fetches anyway. }, }); // ── Marketing preview drainer ────────────────────────────── // // Periodically pulls templateIds off the marketing-preview queue and // renders preview.png for each. The queue is in-memory and populated // by (a) version creates, (b) variant mockup uploads, and (c) explicit // regenerate clicks from the admin UI. // // Without this drainer, those enqueues accumulate silently — the // server sets `autoStaleSince` on the metadata but nothing ever // clears it, so the UI's "regenerating…" badge would stick forever. // // Implementation notes // ──────────────────── // • The renderer factory takes goods-editor's `renderDesignComposite` // as `editorRenderer`. We wrap it here to close over the host's // `canvas` module + the mockups/uploads/storage dirs, since // marketingPreviewRenderer calls editorRenderer with only the // design data (no native deps). // • Interval is 2s for snappy admin feedback. Render time on a // typical 800×800 composite is well under a second on a modern // laptop, so even a quick reload after "Save Version" should // find a fresh preview waiting. // • We drain ONE item per tick so a burst of enqueues doesn't pin // the event loop. Multiple items just get processed over multiple // ticks. // • .unref() the timer so it doesn't keep the process alive at // shutdown. const renderOne = marketingPreviewRenderer({ editorRenderer: (designArgs) => renderDesignComposite({ ...designArgs, canvas, sharp, // v0.6 — mockups now live under templatesStorageDir/products// // sizes//mockup.png. renderDesignComposite picks the right // path from the resolved variant's `mockupPath` field. We pass // templatesStorageDir as a parent for path resolution. mockupsDir: templatesStorageDir, uploadsDir, stickersDir, storageDir: templatesStorageDir, }), }); const PREVIEW_DRAIN_INTERVAL_MS = parseInt( process.env.PREVIEW_DRAIN_INTERVAL_MS || '', 10, ) || 2000; async function drainPreviewQueue() { const id = templateStore.marketingPreviewQueue.dequeue(); if (!id) return; try { await renderOne({ templateId: id, storagePath: templatesStorageDir, // v0.6 — productsStore replaces variantCatalog for variant lookup. productsStore: templateStore.productsStore, auditLog: templateStore.auditLog, }); logger.info({ templateId: id }, 'Marketing preview rendered'); } catch (err) { logger.error({ templateId: id, err: err.message }, 'Marketing preview render failed'); } } setInterval(drainPreviewQueue, PREVIEW_DRAIN_INTERVAL_MS).unref(); // ── 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'); });