Files
apparel-designer/server.js
khalid@traclabs.com 840a5dbcb3 v0.8: auth integration, design persistence (#23), protected sweep (#24)
Server (spans the v0.6→v0.8 arc in one file):
- template-manager mounted with the productsStore catalog, PNG-only
  mockup multer, facet personalization config (§R10, FACET_KEY-gated,
  inert when unset), and the marketing-preview drainer (2s tick, one
  render per tick, .unref()).
- auth-manager bootstrap: DATABASE_URL gates the pg Pool, migrations,
  and mountAuth (magic-link customer + Google SSO admin + facet);
  absent DATABASE_URL logs a warning and the host boots without auth.
- goods-editor design persistence (#23): idempotent 001_designs.sql
  applied at boot, db handle passed to mountEditorApi so /api/designs
  routes mount; without Postgres the client falls back to
  cart-without-design_id.
- reference-protected TTL sweep (#24): before each pass, every
  /uploads/ basename referenced by template-manager JSON or persisted
  designs is collected and protected, and exports/designs (db-driven
  lifecycle, unrecoverable sources) is skipped entirely. Sweep kickoff
  moves below the bootstrap (pgPool TDZ). sweep-worker.mjs is the
  standalone process variant for production.

Client: main.jsx wraps the tree in AuthProvider (single /api/auth/me
fetch shared by useCustomer/useAdmin, degrades on 404) and mounts
AppRouter. eslint bumps server.js to ecmaVersion 2022 (top-level
await); .env.example documents the full v0.8 env surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 23:29:16 -05:00

554 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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/<filename>
// 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/<id>/sizes/<sid>/
// 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/<name>`
// (or `/uploads/preview/<name>`). 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/<id>/
// metadata.json + versions/*.json, products/<id>/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:
// <exportsDir>/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 <mockupsPath>/<productType>/<variant>.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 `<templatesStorageDir>/_products_index.json`
// + `<templatesStorageDir>/products/<id>/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/<id>/
// sizes/<sid>/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');
});