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>
This commit is contained in:
134
.env.example
134
.env.example
@@ -34,6 +34,140 @@ NODE_ENV=development
|
|||||||
# FILE_TTL_MS=86400000 # 24 hours
|
# FILE_TTL_MS=86400000 # 24 hours
|
||||||
# CLEANUP_INTERVAL_MS=3600000 # 1 hour
|
# CLEANUP_INTERVAL_MS=3600000 # 1 hour
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# auth-manager (v0.8) -- runtime
|
||||||
|
# ==================================================================
|
||||||
|
#
|
||||||
|
# auth-manager mounts customer magic-link + admin Google SSO + the
|
||||||
|
# account shell. If DATABASE_URL is NOT set, auth-manager is disabled
|
||||||
|
# gracefully and the rest of the host still boots -- useful when
|
||||||
|
# working on goods-editor / template-manager features without
|
||||||
|
# provisioning Postgres locally.
|
||||||
|
|
||||||
|
# Postgres connection string. Required to enable auth-manager.
|
||||||
|
# Examples:
|
||||||
|
# Local Docker: postgres://postgres:postgres@localhost:5432/apparel
|
||||||
|
# Dokploy: postgres://<user>:<pass>@<service>:5432/<db>
|
||||||
|
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/apparel
|
||||||
|
|
||||||
|
# Fully-qualified base used to build:
|
||||||
|
# - the magic-link URL we email to customers (and print to console)
|
||||||
|
# - the Google OAuth redirect URI for admin SSO
|
||||||
|
# Must match the redirect URI registered in Google Cloud Console.
|
||||||
|
# In dev, set to http://localhost:3001. In prod, your storefront URL.
|
||||||
|
# Falls back to http://localhost:${PORT} if unset (dev only -- won't
|
||||||
|
# work behind a reverse proxy).
|
||||||
|
# PUBLIC_BASE_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Comma-separated allowlist of Google accounts that can sign in via
|
||||||
|
# admin SSO. Two or more entries strongly recommended for break-glass
|
||||||
|
# recovery (locked decision #4 in v08_Auth_v1.md).
|
||||||
|
# ADMIN_EMAILS=you@gmail.com,backup@gmail.com
|
||||||
|
|
||||||
|
# Google OAuth 2.0 Web client credentials. Required for admin SSO.
|
||||||
|
# Create in Google Cloud Console -> APIs & Services -> Credentials ->
|
||||||
|
# Create credentials -> OAuth client ID -> Web application. Authorized
|
||||||
|
# redirect URI must be ${PUBLIC_BASE_URL}/auth/admin/google/callback.
|
||||||
|
# GOOGLE_CLIENT_ID=...apps.googleusercontent.com
|
||||||
|
# GOOGLE_CLIENT_SECRET=...
|
||||||
|
|
||||||
|
# Optional: SMTP relay for magic-link emails. If unset, magic links
|
||||||
|
# ONLY print to the server's stdout (still fully functional for dev
|
||||||
|
# -- just copy/paste the link from your terminal).
|
||||||
|
#
|
||||||
|
# Recommended dev setup: Mailpit (https://mailpit.axllent.org).
|
||||||
|
# docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit
|
||||||
|
# then visit http://localhost:8025 to read sent mail.
|
||||||
|
#
|
||||||
|
# Resend integration is deferred to v2 -- see v08_Auth_v1.md
|
||||||
|
# "Email delivery in v1" section for rationale.
|
||||||
|
# SMTP_HOST=localhost
|
||||||
|
# SMTP_PORT=1025
|
||||||
|
# SMTP_USER=
|
||||||
|
# SMTP_PASS=
|
||||||
|
# SMTP_FROM=no-reply@localhost
|
||||||
|
|
||||||
|
# Optional: cookie domain for cross-subdomain session sharing
|
||||||
|
# (e.g. '.yourdomain.com' so checkout.yourdomain.com sees the same
|
||||||
|
# session as yourdomain.com). Usually leave unset -- SureCart's WP
|
||||||
|
# sidecar uses its own customer identity (passed via metadata at
|
||||||
|
# checkout creation), not auth-manager's session.
|
||||||
|
# COOKIE_DOMAIN=
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# Personalization facet (Project doc §R10) -- runtime
|
||||||
|
# ==================================================================
|
||||||
|
#
|
||||||
|
# Generic "customers filter the shop by a facet" feature. For THIS
|
||||||
|
# deployment the facet is "Pet Type" (dog/cat/reptile/…); other shops
|
||||||
|
# configure a different facet, or none. The facet VALUES are managed by
|
||||||
|
# an admin inside template-manager; only the DEFINITION is set here.
|
||||||
|
#
|
||||||
|
# FACET_KEY is the master switch. Leave it UNSET to run a plain,
|
||||||
|
# unfiltered shop: no sign-in prompt, no template filtering, the editor
|
||||||
|
# shows all stickers, and the account/admin facet UIs stay hidden.
|
||||||
|
|
||||||
|
# The facet's stable key. Setting it turns the feature ON. Becomes the
|
||||||
|
# facet_key on customer_facet_values rows + the audienceTags scope.
|
||||||
|
# FACET_KEY=pet_type
|
||||||
|
|
||||||
|
# Display label shown in the UI ("Choose your {LABEL}", account heading,
|
||||||
|
# admin section title).
|
||||||
|
# FACET_LABEL=Pet Type
|
||||||
|
|
||||||
|
# Selection mode: `multi` (default) lets a customer pick several values
|
||||||
|
# (e.g. dog + cat); `single` restricts to one.
|
||||||
|
# FACET_SELECTION_MODE=multi
|
||||||
|
|
||||||
|
# Require an admin to categorize every template before saving it
|
||||||
|
# (>=1 facet value, or marked General/universal). Default false. Turn
|
||||||
|
# this ON whenever the shop filters strictly, so an untagged template
|
||||||
|
# is never hidden silently. THIS shop: true.
|
||||||
|
# REQUIRE_TEMPLATE_CATEGORY=true
|
||||||
|
|
||||||
|
# Require a signed-in customer to choose at least one value before
|
||||||
|
# browsing. Default false. THIS shop: false (the prompt nudges but is
|
||||||
|
# skippable; choosing nothing shows everything).
|
||||||
|
# REQUIRE_FACET_SELECTION=false
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# Image upscale service (goods-editor, 2026-06-10) -- runtime
|
||||||
|
# ==================================================================
|
||||||
|
#
|
||||||
|
# Optional REST service that upscales low-resolution customer photos
|
||||||
|
# before they hit the print render (goods-editor's /api/export
|
||||||
|
# pipeline). goods-editor reads these variables itself
|
||||||
|
# (imageUpscalerFromEnv inside mountEditorApi) -- no server.js wiring
|
||||||
|
# needed; setting UPSCALE_SERVICE_URL is the master switch. Unset =
|
||||||
|
# feature fully dormant, renders byte-identical to before.
|
||||||
|
#
|
||||||
|
# The editor independently FLAGS low-res images to customers/admins
|
||||||
|
# in the UI regardless of this setting -- the service only improves
|
||||||
|
# what actually prints. Upscaling is best-effort: if the service is
|
||||||
|
# down or slow, the render proceeds with the original pixels.
|
||||||
|
#
|
||||||
|
# Expected contract (adapt goods-editor's src/server/lib/
|
||||||
|
# imageUpscaler.js if your service differs -- it's the single point
|
||||||
|
# of adaptation):
|
||||||
|
# POST $UPSCALE_SERVICE_URL with JSON
|
||||||
|
# { "image": "<base64>", "mime": "image/png", "scale": 2 }
|
||||||
|
# -> 200 with an image/* binary body, OR JSON { "image": "<base64>" }
|
||||||
|
|
||||||
|
# Master switch. e.g. a Real-ESRGAN container in the same Dokploy
|
||||||
|
# project: http://upscaler:5000/upscale
|
||||||
|
# UPSCALE_SERVICE_URL=
|
||||||
|
|
||||||
|
# Optional credential. Sent as 'Authorization: Bearer <key>' unless
|
||||||
|
# UPSCALE_SERVICE_AUTH_HEADER names a custom header (e.g. X-Api-Key),
|
||||||
|
# in which case the bare key is sent in that header.
|
||||||
|
# UPSCALE_SERVICE_API_KEY=
|
||||||
|
# UPSCALE_SERVICE_AUTH_HEADER=Authorization
|
||||||
|
|
||||||
|
# Per-request abort + the service's integer scale ceiling.
|
||||||
|
# UPSCALE_SERVICE_TIMEOUT_MS=30000
|
||||||
|
# UPSCALE_SERVICE_MAX_SCALE=4
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Build-time — values the `docker compose build` step uses
|
# Build-time — values the `docker compose build` step uses
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export default defineConfig([
|
|||||||
files: ['server.js'],
|
files: ['server.js'],
|
||||||
extends: [js.configs.recommended],
|
extends: [js.configs.recommended],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
// 2022 — server.js uses top-level await (auth-manager bootstrap,
|
||||||
|
// designs migration); 2020 made eslint fail at the parse step.
|
||||||
|
ecmaVersion: 2022,
|
||||||
globals: globals.node,
|
globals: globals.node,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
|
|||||||
113
scripts/sweep-worker.mjs
Normal file
113
scripts/sweep-worker.mjs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Sweep worker. Runs runSweep on a tick — trash hard-delete, asset
|
||||||
|
* orphans, audit log rotation, marketing preview queue drain.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/sweep-worker.mjs # one-shot
|
||||||
|
* node scripts/sweep-worker.mjs --loop # run on interval
|
||||||
|
*
|
||||||
|
* Production: run as a separate process (PM2, systemd, etc.) so the
|
||||||
|
* main API server doesn't lose responsiveness when sweep does heavy
|
||||||
|
* work (especially preview rendering, which can take seconds per
|
||||||
|
* template at scale).
|
||||||
|
*
|
||||||
|
* For v1 the sweep interval is 60 minutes when run with --loop.
|
||||||
|
* Adjust via the --interval=<minutes> flag.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
runSweep,
|
||||||
|
marketingPreviewRenderer,
|
||||||
|
createTemplateIndex,
|
||||||
|
} from 'template-manager/server';
|
||||||
|
import { PRODUCT_TYPES } from '../src/constants/products.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const STORAGE_PATH = join(__dirname, '..', 'templates');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const LOOP = args.includes('--loop');
|
||||||
|
const intervalArg = args.find((a) => a.startsWith('--interval='));
|
||||||
|
const INTERVAL_MIN = intervalArg ? parseInt(intervalArg.split('=')[1], 10) : 60;
|
||||||
|
|
||||||
|
async function buildRenderer() {
|
||||||
|
try {
|
||||||
|
const { renderDesignComposite } = await import('goods-editor/server');
|
||||||
|
const canvasMod = await import('canvas');
|
||||||
|
const sharpMod = await import('sharp').catch(() => null);
|
||||||
|
|
||||||
|
const factory = marketingPreviewRenderer({
|
||||||
|
editorRenderer: async (args) => renderDesignComposite({
|
||||||
|
...args,
|
||||||
|
canvas: canvasMod.default || canvasMod,
|
||||||
|
sharp: sharpMod ? (sharpMod.default || sharpMod) : null,
|
||||||
|
mockupsDir: join(__dirname, '..', 'mockups'),
|
||||||
|
uploadsDir: join(__dirname, '..', 'uploads'),
|
||||||
|
stickersDir: join(__dirname, '..', 'public', 'stickers'),
|
||||||
|
storageDir: STORAGE_PATH,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The factory returns a renderer expecting context. We don't have
|
||||||
|
// the in-memory marketingPreviewQueue here — that lives in the
|
||||||
|
// running API server's process. The worker reconciles via the
|
||||||
|
// metadata.autoStaleSince field: any template with that field
|
||||||
|
// set is a regen candidate.
|
||||||
|
return async (templateId) => {
|
||||||
|
// Skip if no audit log handle in this script — that's mounted
|
||||||
|
// server-side. The renderer logs failures via the audit log
|
||||||
|
// when one is provided; without one, errors silently retry on
|
||||||
|
// the next sweep.
|
||||||
|
const { createAuditLog } = await import('template-manager/server/lib/auditLog.js').catch(() => ({}));
|
||||||
|
const auditLog = createAuditLog ? createAuditLog(STORAGE_PATH) : null;
|
||||||
|
return factory({
|
||||||
|
templateId,
|
||||||
|
storagePath: STORAGE_PATH,
|
||||||
|
variantCatalog: PRODUCT_TYPES,
|
||||||
|
auditLog,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('⚠ Renderer not available; sweep will skip preview regen:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOnce(renderPreview) {
|
||||||
|
const templateIndex = createTemplateIndex(STORAGE_PATH);
|
||||||
|
const summary = await runSweep({
|
||||||
|
storagePath: STORAGE_PATH,
|
||||||
|
templateIndex,
|
||||||
|
renderPreview,
|
||||||
|
// marketingPreviewQueue is intentionally absent — the in-process
|
||||||
|
// queue lives in the API server. Worker process reconciles via
|
||||||
|
// metadata.autoStaleSince instead.
|
||||||
|
marketingPreviewQueue: null,
|
||||||
|
});
|
||||||
|
console.log('Sweep:', JSON.stringify(summary, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const renderPreview = await buildRenderer();
|
||||||
|
if (!LOOP) {
|
||||||
|
await runOnce(renderPreview);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.log(`▶ Sweep worker loop (interval: ${INTERVAL_MIN} min)`);
|
||||||
|
const tick = async () => {
|
||||||
|
try { await runOnce(renderPreview); }
|
||||||
|
catch (err) { console.error('Sweep tick failed:', err); }
|
||||||
|
};
|
||||||
|
await tick();
|
||||||
|
setInterval(tick, INTERVAL_MIN * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Sweep worker failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
364
server.js
364
server.js
@@ -16,7 +16,16 @@
|
|||||||
// • POST /api/export — the canvas+sharp render pipeline
|
// • POST /api/export — the canvas+sharp render pipeline
|
||||||
// • GET /api/download/:filename — serve exported PNGs
|
// • GET /api/download/:filename — serve exported PNGs
|
||||||
// • GET /api/health — health probe
|
// • GET /api/health — health probe
|
||||||
// • Font registration
|
// • 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 express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
@@ -28,10 +37,17 @@ import rateLimit from 'express-rate-limit';
|
|||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import pinoHttp from 'pino-http';
|
import pinoHttp from 'pino-http';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join, resolve, sep } from 'path';
|
import { basename, dirname, join, resolve } from 'path';
|
||||||
import { mkdirSync, existsSync, readdirSync, statSync, rmSync } from 'fs';
|
import { mkdirSync, existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
|
||||||
|
|
||||||
import { mountEditorApi } from 'goods-editor/server';
|
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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -53,13 +69,19 @@ app.use(pinoHttp({
|
|||||||
// ── Filesystem layout ──────────────────────────────────────────────
|
// ── Filesystem layout ──────────────────────────────────────────────
|
||||||
const uploadsDir = join(__dirname, 'uploads');
|
const uploadsDir = join(__dirname, 'uploads');
|
||||||
const exportsDir = join(__dirname, 'exports');
|
const exportsDir = join(__dirname, 'exports');
|
||||||
|
const templatesStorageDir = join(__dirname, 'templates');
|
||||||
const fontsDir = join(__dirname, 'node_modules', 'goods-editor', 'fonts');
|
const fontsDir = join(__dirname, 'node_modules', 'goods-editor', 'fonts');
|
||||||
const stickersDir = IS_PRODUCTION
|
const stickersDir = IS_PRODUCTION
|
||||||
? join(__dirname, 'dist', 'stickers')
|
? join(__dirname, 'dist', 'stickers')
|
||||||
: join(__dirname, 'public', 'stickers');
|
: join(__dirname, 'public', 'stickers');
|
||||||
const uploadsRoot = resolve(uploadsDir);
|
// (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.)
|
||||||
|
|
||||||
[uploadsDir, exportsDir].forEach((dir) => {
|
// 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 });
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,36 +89,121 @@ const uploadsRoot = resolve(uploadsDir);
|
|||||||
const FILE_TTL_MS = parseInt(process.env.FILE_TTL_MS || '', 10) || 24 * 60 * 60 * 1000;
|
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;
|
const CLEANUP_INTERVAL_MS = parseInt(process.env.CLEANUP_INTERVAL_MS || '', 10) || 60 * 60 * 1000;
|
||||||
|
|
||||||
function cleanupOldFiles(dir, ttlMs) {
|
// Referenced-file exemption (#24) — template JSONs (element srcs,
|
||||||
if (!existsSync(dir)) return 0;
|
// 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();
|
const now = Date.now();
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
|
let protectedCount = 0;
|
||||||
let entries = [];
|
let entries = [];
|
||||||
try { entries = readdirSync(dir); } catch { return 0; }
|
try { entries = readdirSync(dir); } catch { return { removed, protectedCount }; }
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = join(dir, entry);
|
const full = join(dir, entry);
|
||||||
try {
|
try {
|
||||||
const stat = statSync(full);
|
const stat = statSync(full);
|
||||||
if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
|
if (stat.isFile() && now - stat.mtimeMs > ttlMs) {
|
||||||
|
if (protectedNames && protectedNames.has(entry)) {
|
||||||
|
protectedCount++;
|
||||||
|
} else {
|
||||||
rmSync(full);
|
rmSync(full);
|
||||||
removed++;
|
removed++;
|
||||||
|
}
|
||||||
} else if (stat.isDirectory()) {
|
} else if (stat.isDirectory()) {
|
||||||
removed += cleanupOldFiles(full, ttlMs);
|
if (skipDirs && skipDirs.has(resolve(full))) continue;
|
||||||
|
const sub = cleanupOldFiles(full, ttlMs, protectedNames, skipDirs);
|
||||||
|
removed += sub.removed;
|
||||||
|
protectedCount += sub.protectedCount;
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
}
|
}
|
||||||
return removed;
|
return { removed, protectedCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCleanup() {
|
// exports/designs is goods-editor's designsDir (mountEditorApi default:
|
||||||
const u = cleanupOldFiles(uploadsDir, FILE_TTL_MS);
|
// <exportsDir>/designs). It holds persisted design artifacts — source
|
||||||
const e = cleanupOldFiles(exportsDir, FILE_TTL_MS);
|
// images, thumbnails, print renders — whose lifecycle is db-driven
|
||||||
if (u > 0 || e > 0) {
|
// (designs.deleted_at soft-delete, 30-day guest TTL), not this sweep's
|
||||||
logger.info({ removedUploads: u, removedExports: e, ttlMs: FILE_TTL_MS }, 'Cleanup pass complete');
|
// 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runCleanup();
|
// NOTE: the initial run + interval start AFTER the auth-manager
|
||||||
setInterval(runCleanup, CLEANUP_INTERVAL_MS).unref();
|
// bootstrap below — collectReferencedUploads reads `pgPool`, which is
|
||||||
|
// declared there (TDZ until then).
|
||||||
|
|
||||||
// ── App-level middleware (CORS, JSON, rate limits) ─────────────────
|
// ── App-level middleware (CORS, JSON, rate limits) ─────────────────
|
||||||
const corsOptions = IS_PRODUCTION
|
const corsOptions = IS_PRODUCTION
|
||||||
@@ -106,6 +213,106 @@ app.use(cors(corsOptions));
|
|||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, 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({
|
const uploadLimiter = rateLimit({
|
||||||
windowMs: 60 * 60 * 1000,
|
windowMs: 60 * 60 * 1000,
|
||||||
max: 30,
|
max: 30,
|
||||||
@@ -149,6 +356,26 @@ const upload = multer({
|
|||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
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) => {
|
app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
@@ -184,6 +411,11 @@ app.post('/api/upload', uploadLimiter, upload.single('image'), async (req, res)
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Module-owned: editor API (export, download, health) ────────────
|
// ── 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, {
|
mountEditorApi(app, {
|
||||||
canvas,
|
canvas,
|
||||||
sharp,
|
sharp,
|
||||||
@@ -193,8 +425,106 @@ mountEditorApi(app, {
|
|||||||
uploadsDir,
|
uploadsDir,
|
||||||
basePath: '/api',
|
basePath: '/api',
|
||||||
logger,
|
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 ───────────────────────────────────────────
|
// ── 404 + SPA catch-alls ───────────────────────────────────────────
|
||||||
app.all('/api/*', (_req, res) => {
|
app.all('/api/*', (_req, res) => {
|
||||||
res.status(404).json({ error: 'API route not found' });
|
res.status(404).json({ error: 'API route not found' });
|
||||||
|
|||||||
20
src/main.jsx
20
src/main.jsx
@@ -1,16 +1,24 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { AuthProvider } from '@khalidadil/auth-manager'
|
||||||
|
import '@khalidadil/auth-manager/styles.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './AppRouter'
|
||||||
|
|
||||||
// Service worker registration is handled inside <PWAInstall /> via the
|
// v0.8 — <AuthProvider> wraps the whole tree so useCustomer/useAdmin hooks
|
||||||
// `useRegisterSW` hook from `virtual:pwa-register/react`. We removed the
|
// share a single /api/auth/me fetch instead of each call site re-fetching.
|
||||||
// hand-rolled `navigator.serviceWorker.ready` listener that used to live here
|
// AuthProvider degrades gracefully if /api/auth/me returns 404 (host
|
||||||
// because (a) it didn't actually register the SW — it just listened for updates
|
// running without DATABASE_URL set), so this wrap is safe even when
|
||||||
// — and (b) it duplicated state the plugin already exposes via the hook.
|
// auth-manager is disabled server-side.
|
||||||
|
//
|
||||||
|
// SW registration lives inside <PWAInstall /> via the `useRegisterSW`
|
||||||
|
// hook — we removed the hand-rolled `navigator.serviceWorker.ready`
|
||||||
|
// listener that used to live here.
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<AuthProvider apiBase="/api">
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user