← 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] + ':***');
});