← back to Consulting Intake

server.js

112 lines

'use strict';
/**
 * Consulting Intake — Consulting portal.
 * Generated by the `consulting` skill (fuses fantasea-consulting + prestige-car-wash).
 *
 * Routes:
 *   GET  /              -> signin.html   (UNGATED) — client info + credentials
 *   GET  /intake        -> intake.html   (UNGATED) — new-client questionnaire
 *   POST /api/intake    -> append to data/intakes.json (UNGATED submit)
 *   GET  /api/health    -> {ok:true}     (UNGATED)
 *   GET  /portal        -> portal.html   (GATED) — the consulting deliverable
 *   GET  /admin         -> admin/index.html (GATED) — integrated-social command center
 *   GET  /api/admin/:bucket -> data/<bucket>.json (GATED read)
 *   POST /api/admin/:bucket -> overwrite data/<bucket>.json (GATED write)
 */
const fs = require('fs');
const path = require('path');
const express = require('express');
try { require('dotenv').config(); } catch { /* dotenv optional */ }

const app = express();
app.set('trust proxy', 1); // behind nginx on loopback — read the real client IP from X-Forwarded-For
app.use(express.json({ limit: '1mb' }));
const PORT = process.env.PORT || 9700;

// ---- lightweight in-memory rate limiter (no dependency) --------------------
// Sliding window keyed by IP (or a constant for a global cap). Public POST only.
// State resets on restart — fine for spam mitigation. keyFn lets us run a
// per-IP limiter and a global backstop from the same helper.
function rateLimit({ windowMs, max, keyFn = (req) => req.ip || 'unknown' }) {
  const hits = new Map(); // key -> [timestamps]
  const sweep = setInterval(() => {
    const cutoff = Date.now() - windowMs;
    for (const [k, arr] of hits) {
      const kept = arr.filter((t) => t > cutoff);
      if (kept.length) hits.set(k, kept); else hits.delete(k);
    }
  }, windowMs);
  sweep.unref();
  return (req, res, next) => {
    const now = Date.now(), cutoff = now - windowMs, k = keyFn(req);
    const arr = (hits.get(k) || []).filter((t) => t > cutoff);
    if (arr.length >= max) {
      res.set('Retry-After', String(Math.ceil((arr[0] + windowMs - now) / 1000)));
      return res.status(429).json({ ok: false, error: 'Too many submissions — please try again later.' });
    }
    arr.push(now); hits.set(k, arr); next();
  };
}
// 5 submissions per IP per 10 min (a real client submits once), plus a global
// 500/day backstop so distributed spam can't fill intakes.json / the disk.
const intakeLimit = rateLimit({ windowMs: 10 * 60 * 1000, max: 5 });
const intakeGlobalLimit = rateLimit({ windowMs: 24 * 60 * 60 * 1000, max: 500, keyFn: () => 'global' });
const PUB = path.join(__dirname, 'public');
const DATA = path.join(__dirname, 'data');

// ---- credentials -----------------------------------------------------------
// Unified fleet login + this client's own login. Extra pairs via BASIC_AUTH_EXTRA
// (comma-separated user:pass). These same creds are shown on the sign-in page.
const CREDS = ['admin:DW2024!', 'client:Consul2026!']
  .concat((process.env.BASIC_AUTH_EXTRA || '').split(',').map((s) => s.trim()).filter(Boolean));
const ACCEPTED = new Set(CREDS.map((c) => 'Basic ' + Buffer.from(c).toString('base64')));
function gate(req, res, next) {
  if (ACCEPTED.has(req.headers.authorization || '')) return next();
  res.set('WWW-Authenticate', 'Basic realm="Consulting Intake Portal"');
  return res.status(401).send('Authentication required');
}

// ---- data helpers ----------------------------------------------------------
const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return fb; } };
const writeJSON = (f, v) => fs.writeFileSync(path.join(DATA, f), JSON.stringify(v, null, 2));
const BUCKETS = ['client', 'socials', 'suggestions', 'competitors', 'best-times', 'content-calendar', 'ads', 'directories', 'media', 'intakes'];

// ---- ungated routes --------------------------------------------------------
app.get('/', (_req, res) => res.sendFile(path.join(PUB, 'signin.html')));
app.get('/intake', (_req, res) => res.sendFile(path.join(PUB, 'intake.html')));
app.get('/api/health', (_req, res) => res.json({ ok: true, client: 'Consulting Intake', at: new Date().toISOString() }));
app.get('/api/client', (_req, res) => res.json(readJSON('client.json', {}))); // public-safe summary for signin page
app.post('/api/intake', intakeGlobalLimit, intakeLimit, (req, res) => {
  try {
    const all = readJSON('intakes.json', []);
    all.push({ received_at: new Date().toISOString(), source: 'form', intake: req.body || {} });
    writeJSON('intakes.json', all);
    res.json({ ok: true, message: 'Thanks — your consulting intake was received.' });
  } catch (e) {
    console.error('intake write failed:', e.message); // don't leak the path/stack to the client
    res.status(500).json({ ok: false, error: 'Could not save your submission — please try again.' });
  }
});

// ---- gated routes ----------------------------------------------------------
app.get('/portal', gate, (_req, res) => res.sendFile(path.join(PUB, 'portal.html')));
app.get('/admin', gate, (_req, res) => res.sendFile(path.join(PUB, 'admin', 'index.html')));
app.get('/api/admin/:bucket', gate, (req, res) => {
  if (!BUCKETS.includes(req.params.bucket)) return res.status(404).json({ error: 'unknown bucket' });
  res.json(readJSON(req.params.bucket + '.json', []));
});
app.post('/api/admin/:bucket', gate, (req, res) => {
  if (!BUCKETS.includes(req.params.bucket)) return res.status(404).json({ error: 'unknown bucket' });
  writeJSON(req.params.bucket + '.json', req.body);
  res.json({ ok: true });
});

// gated static (portal assets + generated version concepts)
app.use('/portal', gate, express.static(path.join(PUB), { extensions: ['html'] }));
app.use('/versions', gate, express.static(path.join(PUB, 'versions'), { extensions: ['html'] }));
app.use('/admin', gate, express.static(path.join(PUB, 'admin'), { extensions: ['html'] }));
// ungated static (only the two entry pages + shared img)
app.use('/img', express.static(path.join(PUB, 'img')));

app.listen(PORT, () => console.log('Consulting Intake consulting portal on ' + PORT));