← back to Reno Visualizer

server.js

258 lines

// reno-visualizer — "see it on your wall" tool for Designer Wallcoverings / Wallpaper's Back
// Zero-dep Node http server. PORT from env; BASIC_AUTH=user:pass to gate.
// House rules: sort + density on every grid, admin cards show created date+time.
// Render seam: /api/render is the single function; swap body for ComfyUI/Replicate later.
const http = require('http'), fs = require('fs'), path = require('path'), url = require('url');
const PORT = parseInt(process.env.PORT || '3900', 10);
const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" or empty (open)
const ADMIN_AUTH = process.env.ADMIN_AUTH || BASIC_AUTH; // separate admin gate (falls back to BASIC_AUTH)
const DIR = __dirname;

// --- helpers ---
function mime(f) {
  if (f.endsWith('.html')) return 'text/html; charset=utf-8';
  if (f.endsWith('.css')) return 'text/css';
  if (f.endsWith('.js')) return 'application/javascript';
  if (f.endsWith('.json')) return 'application/json';
  if (f.endsWith('.png')) return 'image/png';
  if (f.endsWith('.jpg') || f.endsWith('.jpeg')) return 'image/jpeg';
  if (f.endsWith('.svg')) return 'image/svg+xml';
  if (f.endsWith('.ico')) return 'image/x-icon';
  return 'application/octet-stream';
}
function authed(req, creds) {
  if (!creds) return true;
  const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
  if (!m) return false;
  try { return Buffer.from(m[1], 'base64').toString() === creds; } catch { return false; }
}
function json(res, data, status) {
  const body = JSON.stringify(data);
  res.writeHead(status || 200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
  res.end(body);
}
function bodyJson(req) {
  return new Promise((ok, fail) => {
    let buf = '';
    req.on('data', c => buf += c);
    req.on('end', () => { try { ok(JSON.parse(buf)); } catch(e) { fail(e); } });
    req.on('error', fail);
  });
}

// --- products ---
// Load from shopify-catalog.json (real Shopify data); fall back to legacy products.json
function loadProducts() {
  try {
    const catalog = JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'shopify-catalog.json'), 'utf8'));
    const checkoutDomain = catalog.checkout_domain || 'www.designerwallcoverings.com';
    return (catalog.products || []).map(p => ({
      sku: p.sku,
      title: p.title,
      vendor: p.vendor,
      type: p.type,
      image: p.image,
      image_url: p.image,
      price: p.price,                           // numeric or null (null = sample_only)
      price_display: p.price_display || (p.price != null ? '$' + Number(p.price).toFixed(2) : 'Roll price on request'),
      buyable: !!p.buyable,
      sample_only: !!p.sample_only,
      product_url: p.product_url || `https://${checkoutDomain}/products/${p.handle}`,
      handle: p.handle,
      variant_id: p.variant_id,
      shopify_url: p.product_url || `https://${checkoutDomain}/products/${p.handle}`,
    }));
  } catch {
    try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); }
    catch { return []; }
  }
}
function luminance(hex) {
  if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999;
  const h = hex.replace('#', '');
  const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16);
  return 0.2126*r + 0.7152*g + 0.0722*b;
}
function sortProducts(items, mode) {
  const a = items.slice();
  switch (mode) {
    case 'title':      return a.sort((x,y) => (x.title||'').localeCompare(y.title||''));
    case 'sku':        return a.sort((x,y) => (x.sku||'').localeCompare(y.sku||''));
    case 'price-asc':  return a.sort((x,y) => (x.price||0)-(y.price||0));
    case 'price-desc': return a.sort((x,y) => (y.price||0)-(x.price||0));
    case 'light':      return a.sort((x,y) => luminance(y.hex)-luminance(x.hex));
    case 'dark':       return a.sort((x,y) => luminance(x.hex)-luminance(y.hex));
    case 'vendor':     return a.sort((x,y) => (x.vendor||'').localeCompare(y.vendor||''));
    default: return a; // 'newest' = natural file order
  }
}

// --- rooms ---
function loadRooms() {
  try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'rooms.json'), 'utf8')); }
  catch { return []; }
}

// --- leads ---
const LEADS_FILE = path.join(DIR, 'data', 'leads.json');
function loadLeads() {
  try { return JSON.parse(fs.readFileSync(LEADS_FILE, 'utf8')); } catch { return []; }
}
function saveLead(lead) {
  const leads = loadLeads();
  leads.unshift(lead); // newest first
  fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
  fs.writeFileSync(LEADS_FILE, JSON.stringify(leads, null, 2));
  return lead;
}

// --- render seam ---
// PROTOTYPE-GRADE render: returns metadata only; real compositing happens client-side via Canvas.
// To swap in a real engine (ComfyUI, Replicate), replace this function body.
// The endpoint contract stays identical: POST /api/render → { render_url, mode, note }
function computeRender(roomId, patternSku, wallRegion) {
  // Prototype: client-side canvas compositing is authoritative; this just echoes params back.
  // A real implementation would: POST to ComfyUI img2img or Replicate stable-diffusion-inpainting
  // and return the CDN URL of the composited image.
  return {
    mode: 'prototype-canvas',
    note: 'Preview render — prototype quality. Real render engine (ComfyUI/Replicate) plugs in here.',
    room_id: roomId,
    pattern_sku: patternSku,
    wall_region: wallRegion,
    render_url: null, // client canvas is used instead
  };
}

// --- routing ---
http.createServer((req, res) => {
  const u = new URL(req.url, 'http://x');
  const pathname = u.pathname;
  const method = req.method.toUpperCase();

  // CORS preflight
  if (method === 'OPTIONS') {
    res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type,Authorization' });
    return res.end();
  }

  // Admin routes — gated by ADMIN_AUTH
  if (pathname === '/admin' || pathname === '/admin/') {
    if (ADMIN_AUTH && !authed(req, ADMIN_AUTH)) {
      res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="reno-visualizer admin"' });
      return res.end('Authentication required');
    }
    const fp = path.join(DIR, 'public', 'admin.html');
    if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(fs.readFileSync(fp)); }
    res.writeHead(404); return res.end('admin.html not found');
  }

  if (pathname === '/api/leads' && method === 'GET') {
    if (ADMIN_AUTH && !authed(req, ADMIN_AUTH)) {
      res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="reno-visualizer admin"' });
      return res.end('Authentication required');
    }
    const leads = loadLeads();
    const sortMode = u.searchParams.get('sort') || 'newest';
    const sorted = sortMode === 'oldest' ? leads.slice().reverse() : leads;
    return json(res, { count: sorted.length, leads: sorted });
  }

  // Main site basic-auth gate (optional)
  if (BASIC_AUTH && !authed(req, BASIC_AUTH)) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="reno-visualizer"' });
    return res.end('Authentication required');
  }

  // --- API routes (open or gated above) ---
  if (pathname === '/api/rooms' && method === 'GET') {
    return json(res, { rooms: loadRooms() });
  }

  if (pathname === '/api/products' && method === 'GET') {
    const sort = u.searchParams.get('sort') || 'newest';
    const products = sortProducts(loadProducts(), sort);
    return json(res, { count: products.length, products });
  }

  if (pathname === '/api/lead' && method === 'POST') {
    bodyJson(req).then(body => {
      if (!body.email) return json(res, { error: 'email required' }, 400);
      const lead = {
        id: Date.now().toString(36) + Math.random().toString(36).slice(2,6),
        email: body.email,
        name: body.name || null,
        interest: body.interest || null,
        pattern_sku: body.pattern_sku || null,
        pattern_title: body.pattern_title || null,
        pattern_vendor: body.pattern_vendor || null,
        pattern_handle: body.pattern_handle || null,
        pattern_shopify_url: body.pattern_shopify_url || null,
        room_label: body.room_label || null,
        message: body.message || null,
        created_at: new Date().toISOString(),
      };
      saveLead(lead);
      json(res, { ok: true, lead });
    }).catch(() => json(res, { error: 'invalid JSON' }, 400));
    return;
  }

  // Room photo upload endpoint
  if (pathname === '/api/upload-room' && method === 'POST') {
    const ct = req.headers['content-type'] || '';
    if (!ct.startsWith('multipart/form-data') && !ct.startsWith('application/octet-stream') && !ct.startsWith('image/')) {
      // Accept base64 JSON upload for simplicity (no multipart parsing needed with zero deps)
      bodyJson(req).then(body => {
        if (!body.data || !body.mime) return json(res, { error: 'data and mime required' }, 400);
        const b64 = body.data.replace(/^data:[^;]+;base64,/, '');
        const buf = Buffer.from(b64, 'base64');
        const ext = body.mime.includes('png') ? 'png' : body.mime.includes('gif') ? 'gif' : 'jpg';
        const fname = `upload-${Date.now()}.${ext}`;
        const uploadsDir = path.join(DIR, 'uploads');
        fs.mkdirSync(uploadsDir, { recursive: true });
        fs.writeFileSync(path.join(uploadsDir, fname), buf);
        json(res, { ok: true, url: `/uploads/${fname}` });
      }).catch(() => json(res, { error: 'invalid body' }, 400));
      return;
    }
    // Fallback: reject binary multipart (not supported in zero-dep server)
    return json(res, { error: 'send base64 JSON: {data, mime}' }, 415);
  }

  // Render seam endpoint — prototype returns metadata; real engine replaces body
  if (pathname === '/api/render' && method === 'POST') {
    bodyJson(req).then(body => {
      const result = computeRender(body.room_id, body.pattern_sku, body.wall_region);
      json(res, result);
    }).catch(() => json(res, { error: 'invalid JSON' }, 400));
    return;
  }

  // Static file serving: public/ (including subdirectories like /rooms/)
  let filePath = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
  // Prevent path traversal
  const safeFile = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '');
  const fp = path.join(DIR, 'public', safeFile);
  // Also allow serving from /uploads/ directory
  const uploadsFile = path.join(DIR, 'uploads', safeFile.replace(/^uploads\//, ''));
  if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
    res.writeHead(200, { 'Content-Type': mime(fp) });
    return res.end(fs.readFileSync(fp));
  }
  // Serve uploads (user-uploaded room photos)
  if (pathname.startsWith('/uploads/') && fs.existsSync(uploadsFile) && fs.statSync(uploadsFile).isFile()) {
    res.writeHead(200, { 'Content-Type': mime(uploadsFile) });
    return res.end(fs.readFileSync(uploadsFile));
  }

  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end('Not found: ' + pathname);

}).listen(PORT, '127.0.0.1', function () {
  console.log('[reno-visualizer] http://localhost:' + this.address().port);
  console.log('  Admin: http://localhost:' + this.address().port + '/admin');
  if (BASIC_AUTH) console.log('  Site auth: ' + BASIC_AUTH.split(':')[0] + ':***');
  if (ADMIN_AUTH) console.log('  Admin auth: ' + ADMIN_AUTH.split(':')[0] + ':***');
});