← 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));