← back to Contractwallpaper

_universal-auth.js

297 lines

// DW family universal auth — drop-in for any sister site's server.js.
// Mount BEFORE static. Provides:
//   /admin              → Basic Auth (admin / DWSecure2024! via env ADMIN_PASS) → leads dashboard
//   /admin/leads.json   → JSON dump of leads.jsonl (admin only)
//   /admin/stats.json   → counts + recent activity
//   POST /account/register {name,email,password} → cookie session
//   POST /account/login {email,password}        → cookie session
//   POST /account/logout
//   GET  /account/me                             → {user, samples:[]} for logged-in user
//   POST /account/favorites {sku,title,image_url, action:'add'|'remove'}
//   GET  /account                                 → HTML dashboard
//
// Storage: data/users.json (single file per site, fail-soft like leads.jsonl).
// Sessions: httpOnly cookie sid → user.id mapping in memory + persisted in users.json.
// Passwords: scrypt (Node built-in, no npm install).
//
// Per Steve's standing rules:
//   - Lightweight, no email verify, no password reset (manual ask)
//   - Per-site users.json (each site is its own marketing surface)
//   - admin/DWSecure2024! same across all sites (matches DW-Agents pattern)

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

module.exports = function (app, opts) {
  opts = opts || {};
  const SITE = opts.siteName || 'DW Family';
  const ADMIN_USER = process.env.ADMIN_USER || 'admin';
  const ADMIN_PASS = process.env.ADMIN_PASS || '';
  if (!ADMIN_PASS) {
    if (process.env.NODE_ENV === 'production') {
      throw new Error('[auth] ADMIN_PASS env required in production — set via /root/.dw-fleet.env (sourced by pm2 ecosystem)');
    }
    console.warn('[auth] ADMIN_PASS not set — /admin will return 401 to all requests');
  }
  const COOKIE_NAME = 'dw_sid';
  const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days

  const DATA_DIR = path.join(__dirname, 'data');
  const USERS_FILE = path.join(DATA_DIR, 'users.json');
  const LEADS_FILE = path.join(DATA_DIR, 'leads.jsonl');
  try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (e) {}

  // ─── storage ──────────────────────────────────────────────────────────────
  let store = { users: [], sessions: {} }; // {sessions: {sid: {userId, expires}}}
  try { if (fs.existsSync(USERS_FILE)) store = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch (e) { console.warn('[auth] users.json parse failed; starting fresh'); }
  if (!store.users) store.users = [];
  if (!store.sessions) store.sessions = {};

  let writeQ = null;
  function persist() {
    if (writeQ) return;
    writeQ = setTimeout(() => { writeQ = null; try { fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify(store, null, 2)); fs.renameSync(USERS_FILE + '.tmp', USERS_FILE); } catch (e) { console.error('[auth] persist failed:', e.message); } }, 200);
  }

  // ─── password hashing ─────────────────────────────────────────────────────
  function hashPassword(pw) {
    const salt = crypto.randomBytes(16).toString('hex');
    const hash = crypto.scryptSync(pw, salt, 64).toString('hex');
    return `scrypt$${salt}$${hash}`;
  }
  function verifyPassword(pw, stored) {
    try {
      const [scheme, salt, hash] = stored.split('$');
      if (scheme !== 'scrypt') return false;
      const test = crypto.scryptSync(pw, salt, 64).toString('hex');
      return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(test, 'hex'));
    } catch (e) { return false; }
  }

  // ─── helpers ──────────────────────────────────────────────────────────────
  const EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
  function clean(s, max) { return s == null ? '' : String(s).replace(/[\r\n\t\v\f ]+/g, ' ').trim().slice(0, max); }
  function parseCookies(req) { const out = {}; const h = req.headers.cookie || ''; for (const part of h.split(';')) { const [k, v] = part.trim().split('='); if (k) out[k] = decodeURIComponent(v || ''); } return out; }
  function setCookie(res, name, value, opts = {}) {
    const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${opts.path || '/'}`, 'HttpOnly', 'SameSite=Lax'];
    if (opts.secure) parts.push('Secure');
    if (opts.maxAge) parts.push(`Max-Age=${Math.floor(opts.maxAge / 1000)}`);
    if (opts.expires) parts.push(`Expires=${opts.expires.toUTCString()}`);
    res.setHeader('Set-Cookie', parts.join('; '));
  }
  function basicAuthOK(req) {
    if (!ADMIN_PASS) return false; // no admin pass configured → refuse all
    const h = req.headers.authorization || '';
    if (!h.startsWith('Basic ')) return false;
    try {
      const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
      if (u !== ADMIN_USER) return false;
      const a = Buffer.from(String(p || ''));
      const b = Buffer.from(ADMIN_PASS);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    } catch (e) { return false; }
  }
  function requireAdmin(req, res, next) {
    if (!basicAuthOK(req)) { res.setHeader('WWW-Authenticate', `Basic realm="${SITE} Admin"`); return res.status(401).send('admin auth required'); }
    next();
  }
  function userFromReq(req) {
    const sid = parseCookies(req)[COOKIE_NAME];
    if (!sid) return null;
    const sess = store.sessions[sid];
    if (!sess || sess.expires < Date.now()) { if (sess) delete store.sessions[sid]; return null; }
    return store.users.find(u => u.id === sess.userId) || null;
  }
  function startSession(res, user) {
    const sid = crypto.randomBytes(24).toString('base64url');
    store.sessions[sid] = { userId: user.id, expires: Date.now() + SESSION_TTL_MS };
    persist();
    const isHttps = (process.env.HTTPS || '').toLowerCase() === 'true' || process.env.NODE_ENV === 'production';
    setCookie(res, COOKIE_NAME, sid, { maxAge: SESSION_TTL_MS, secure: isHttps });
  }
  function readLeads() {
    if (!fs.existsSync(LEADS_FILE)) return [];
    try { return fs.readFileSync(LEADS_FILE, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch (e) { return null; } }).filter(Boolean); } catch (e) { return []; }
  }

  // ─── /account/register ────────────────────────────────────────────────────
  app.post('/account/register', (req, res) => {
    const b = req.body || {};
    const name = clean(b.name, 200);
    const email = clean(b.email, 200).toLowerCase();
    const password = String(b.password || '');
    if (!name || !EMAIL_RE.test(email) || password.length < 8) return res.status(400).json({ error: 'name, valid email, and 8-char password required' });
    if (store.users.find(u => u.email === email)) return res.status(409).json({ error: 'email already registered' });
    const user = { id: crypto.randomBytes(8).toString('hex'), name, email, pwd: hashPassword(password), createdAt: new Date().toISOString(), favorites: [] };
    store.users.push(user); persist();
    startSession(res, user);
    res.json({ ok: true, user: { id: user.id, name: user.name, email: user.email } });
  });

  // Constant-cost dummy hash so login timing doesn't reveal whether email exists
  const DUMMY_HASH = `scrypt$${crypto.randomBytes(16).toString('hex')}$${crypto.scryptSync('does-not-match', 'dummy-salt', 64).toString('hex')}`;

  // ─── /account/login ───────────────────────────────────────────────────────
  app.post('/account/login', (req, res) => {
    const b = req.body || {};
    const email = clean(b.email, 200).toLowerCase();
    const password = String(b.password || '');
    const u = store.users.find(x => x.email === email);
    // Always compute scrypt to keep timing constant whether user exists or not
    const valid = verifyPassword(password, u ? u.pwd : DUMMY_HASH);
    if (!u || !valid) return res.status(401).json({ error: 'invalid email or password' });
    startSession(res, u);
    res.json({ ok: true, user: { id: u.id, name: u.name, email: u.email } });
  });

  // ─── /account/logout ──────────────────────────────────────────────────────
  app.post('/account/logout', (req, res) => {
    const sid = parseCookies(req)[COOKIE_NAME];
    if (sid && store.sessions[sid]) { delete store.sessions[sid]; persist(); }
    setCookie(res, COOKIE_NAME, '', { maxAge: 0 });
    res.json({ ok: true });
  });

  // ─── /account/me ──────────────────────────────────────────────────────────
  app.get('/account/me', (req, res) => {
    const u = userFromReq(req);
    if (!u) return res.status(401).json({ error: 'not logged in' });
    const samples = readLeads().filter(l => (l.email || '').toLowerCase() === u.email);
    res.json({ user: { id: u.id, name: u.name, email: u.email, createdAt: u.createdAt }, favorites: u.favorites || [], samples });
  });

  // ─── /account/favorites ───────────────────────────────────────────────────
  app.post('/account/favorites', (req, res) => {
    const u = userFromReq(req);
    if (!u) return res.status(401).json({ error: 'not logged in' });
    const b = req.body || {};
    const action = b.action === 'remove' ? 'remove' : 'add';
    const sku = clean(b.sku, 200);
    if (!sku) return res.status(400).json({ error: 'sku required' });
    u.favorites = u.favorites || [];
    if (action === 'add') {
      if (!u.favorites.find(f => f.sku === sku)) {
        u.favorites.push({ sku, title: clean(b.title, 400), image_url: clean(b.image_url, 600), addedAt: new Date().toISOString() });
      }
    } else {
      u.favorites = u.favorites.filter(f => f.sku !== sku);
    }
    persist();
    res.json({ ok: true, favorites: u.favorites });
  });

  // ─── /account (HTML dashboard) ────────────────────────────────────────────
  app.get('/account', (req, res) => {
    const html = `<!doctype html><html><head><meta charset="utf-8"><title>Account · ${SITE}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/styles.css" onerror="this.remove()">
<style>
body{font-family:-apple-system,sans-serif;max-width:680px;margin:80px auto;padding:24px;color:#222;background:#fafaf6}
h1{font-size:32px;font-weight:300;letter-spacing:-0.01em;margin-bottom:24px}
.card{background:#fff;padding:32px;border:1px solid #e5dfd0;margin-bottom:18px}
input,button{font:inherit;padding:11px 14px;width:100%;box-sizing:border-box;margin:6px 0;border:1px solid #d0c9b8;background:#fff}
button{background:#1b1814;color:#fafaf6;cursor:pointer;border:0;letter-spacing:0.18em;text-transform:uppercase;font-size:11px;padding:14px;margin-top:14px}
button:hover{background:#3a342a}
.tab{display:inline-block;padding:11px 20px;cursor:pointer;border:0;background:transparent;font-size:11px;letter-spacing:0.32em;text-transform:uppercase;font-weight:600;color:#888}
.tab.active{color:#1b1814;border-bottom:2px solid #1b1814}
.row{display:flex;gap:14px;padding:14px 0;border-bottom:1px solid #e5dfd0;align-items:center}
.row img{width:50px;height:50px;object-fit:cover}
.muted{color:#888;font-size:13px}
.err{color:#a33d3d;font-size:13px;margin:6px 0}
[hidden]{display:none}
</style></head>
<body>
<h1>${SITE} · Account</h1>
<div id="anon">
  <div class="card">
    <div><button class="tab active" data-pane="login">Sign In</button><button class="tab" data-pane="register">Create Account</button></div>
    <div id="login">
      <input id="le" type="email" placeholder="email" autocomplete="email">
      <input id="lp" type="password" placeholder="password" autocomplete="current-password">
      <div id="lerr" class="err"></div>
      <button onclick="doLogin()">Sign In</button>
    </div>
    <div id="register" hidden>
      <input id="rn" placeholder="name">
      <input id="re" type="email" placeholder="email" autocomplete="email">
      <input id="rp" type="password" placeholder="password (min 8)" autocomplete="new-password">
      <div id="rerr" class="err"></div>
      <button onclick="doRegister()">Create Account</button>
    </div>
  </div>
</div>
<div id="auth" hidden>
  <div class="card">
    <h2 id="hname" style="font-weight:300"></h2>
    <p class="muted" id="hemail"></p>
    <p class="muted" style="margin-top:14px">Member since <span id="hsince"></span></p>
    <button onclick="logout()" style="margin-top:18px;background:transparent;color:#888;border:1px solid #d0c9b8">Sign Out</button>
  </div>
  <div class="card"><h3 style="margin-bottom:18px;font-weight:300">Saved Patterns</h3><div id="favs"><p class="muted">No favorites yet — heart any pattern in the catalog.</p></div></div>
  <div class="card"><h3 style="margin-bottom:18px;font-weight:300">Sample Requests</h3><div id="samples"><p class="muted">No samples yet.</p></div></div>
</div>
<script>
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('active',x===t));['login','register'].forEach(id=>document.getElementById(id).hidden=(id!==t.dataset.pane))});
async function api(path, body){ const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:body?JSON.stringify(body):undefined}); const j=await r.json().catch(()=>({}));return {ok:r.ok,j}; }
async function doLogin(){const r=await api('/account/login',{email:le.value,password:lp.value});if(r.ok)render();else lerr.textContent=r.j.error||'login failed'}
async function doRegister(){const r=await api('/account/register',{name:rn.value,email:re.value,password:rp.value});if(r.ok)render();else rerr.textContent=r.j.error||'register failed'}
async function logout(){await api('/account/logout');location.reload()}
function fmt(d){return new Date(d).toLocaleDateString('en-US',{month:'long',year:'numeric'})}
async function render(){
  const r=await fetch('/account/me',{credentials:'include'});
  if(!r.ok){anon.hidden=false;auth.hidden=true;return}
  const d=await r.json();anon.hidden=true;auth.hidden=false;
  hname.textContent=d.user.name;hemail.textContent=d.user.email;hsince.textContent=fmt(d.user.createdAt);
  if(d.favorites?.length){favs.innerHTML=d.favorites.map(f=>{const e=s=>String(s||'').replace(/[<>&"']/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]));const u=String(f.image_url||'');const safeUrl=/^https:\/\/(cdn\.shopify\.com|designerwallcoverings\.com)\//.test(u)?u:'';return '<div class="row"><img src="'+e(safeUrl)+'" alt=""><div><div>'+e(f.title||f.sku)+'</div><div class="muted">'+e(f.sku)+'</div></div></div>'}).join('')}
  if(d.samples?.length){samples.innerHTML=d.samples.map(s=>'<div class="row"><div><div>'+(s.title||s.sku||'sample')+'</div><div class="muted">'+(new Date(s.ts).toLocaleDateString())+'</div></div></div>').join('')}
}
render();
</script></body></html>`;
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(html);
  });

  // ─── /admin (Basic Auth) ──────────────────────────────────────────────────
  app.get('/admin', requireAdmin, (req, res) => {
    const leads = readLeads();
    const recent = leads.slice(-25).reverse();
    const stats = { totalLeads: leads.length, totalUsers: store.users.length, sessionsActive: Object.values(store.sessions).filter(s => s.expires > Date.now()).length, kinds: {} };
    for (const l of leads) stats.kinds[l.kind] = (stats.kinds[l.kind] || 0) + 1;
    const recentRows = recent.map(l => `<tr><td>${new Date(l.ts).toLocaleString()}</td><td>${l.kind}</td><td>${(l.name || '').replace(/[<>"]/g, c => '&#' + c.charCodeAt(0) + ';')}</td><td>${(l.email || '').replace(/[<>"]/g, c => '&#' + c.charCodeAt(0) + ';')}</td><td>${(l.title || l.projectName || '').replace(/[<>"]/g, c => '&#' + c.charCodeAt(0) + ';')}</td></tr>`).join('');
    const html = `<!doctype html><html><head><meta charset="utf-8"><title>${SITE} Admin</title>
<style>body{font-family:-apple-system,sans-serif;background:#0e0e0e;color:#eee;margin:0;padding:32px;font-size:14px}
h1{font-weight:300;letter-spacing:-0.01em;margin-bottom:24px}
.kpi{display:flex;gap:18px;margin-bottom:32px;flex-wrap:wrap}
.kpi div{background:#1a1a1a;padding:18px 24px;border:1px solid #333;flex:1;min-width:160px}
.kpi .n{font-size:32px;font-weight:300;color:#eee}
.kpi .l{font-size:10px;letter-spacing:0.32em;text-transform:uppercase;color:#888;margin-top:6px}
table{width:100%;border-collapse:collapse;background:#1a1a1a;border:1px solid #333}
th,td{padding:10px 14px;text-align:left;border-bottom:1px solid #2a2a2a;font-size:13px}
th{background:#222;font-size:10px;letter-spacing:0.32em;text-transform:uppercase;font-weight:600;color:#888}
td{color:#ddd}
small{color:#666}</style></head>
<body>
<h1>${SITE} · Admin</h1>
<div class="kpi">
  <div><div class="n">${stats.totalLeads}</div><div class="l">Total Leads</div></div>
  <div><div class="n">${stats.totalUsers}</div><div class="l">Registered Users</div></div>
  <div><div class="n">${stats.sessionsActive}</div><div class="l">Active Sessions</div></div>
  <div><div class="n">${stats.kinds.inquiry || 0}</div><div class="l">Inquiries</div></div>
  <div><div class="n">${stats.kinds.sample || 0}</div><div class="l">Sample Requests</div></div>
</div>
<h2 style="font-weight:300;margin:24px 0 12px">Recent Activity (last 25)</h2>
<table><thead><tr><th>Time</th><th>Kind</th><th>Name</th><th>Email</th><th>Pattern / Project</th></tr></thead><tbody>${recentRows || '<tr><td colspan="5" style="text-align:center;color:#666;padding:24px">no leads yet</td></tr>'}</tbody></table>
<p style="margin-top:24px"><small><a href="/admin/leads.json" style="color:#888">leads.json</a> · <a href="/admin/stats.json" style="color:#888">stats.json</a> · <a href="/" style="color:#888">site →</a></small></p>
</body></html>`;
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(html);
  });

  app.get('/admin/leads.json', requireAdmin, (req, res) => res.json(readLeads()));
  app.get('/admin/stats.json', requireAdmin, (req, res) => {
    const leads = readLeads();
    res.json({ totalLeads: leads.length, totalUsers: store.users.length, sessionsActive: Object.values(store.sessions).filter(s => s.expires > Date.now()).length, recent: leads.slice(-10).reverse() });
  });
};