← back to Marketing Command Center

server.js

115 lines

#!/usr/bin/env node
// Marketing Command Center — modular dashboard shell.
// Mounts each module from modules/registry.js under /api/<id>, serves its panel
// from public/panels/<id>.{html,js}, and renders a nav shell. Basic-auth gated.
const express = require('express');
const fs = require('fs');
const path = require('path');

// Load .env file into process.env (zero-dep; file is the source of truth so pm2
// daemon-env drift can't override it). Existing process.env wins only if a key
// isn't in the file.
(function loadEnv() {
  try {
    for (const line of fs.readFileSync(path.join(__dirname, '.env'), 'utf8').split('\n')) {
      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
      if (m) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
    }
  } catch { /* no .env — use process.env / defaults */ }
})();

const PORT = process.env.PORT || 9661;
const app = express();
// 28mb so the Asset Library can accept base64-encoded image uploads (the assets
// module also mounts its own 28mb parser on /upload as a belt-and-suspenders).
app.use(express.json({ limit: '28mb' }));

// ── Basic auth (matches DW fleet pattern; cred overridable via env) ──────────
const USER = process.env.MCC_USER || 'admin';
// No fallback: fail closed if MCC_PASS is unset so a misconfigured box denies
// all access rather than shipping a known default. (A hardcoded default was
// previously here AND in the committed .env.example — treat that value as burned
// and rotate it; this box must set MCC_PASS in its own .env.)
const PASS = process.env.MCC_PASS || '';
if (!PASS) console.warn('[mcc] MCC_PASS is unset — all requests will be denied until it is configured.');
// Auth accepts the standard Basic header OR an `mcc` cookie we set on first
// success. The cookie is what makes XHR/fetch work after the page is opened
// with embedded URL credentials (http://user:pass@host) — Chrome won't re-attach
// those creds to subresource/fetch requests, so without the cookie every
// /api/* fetch 401s and the shell hangs forever on "Loading…".
app.use((req, res, next) => {
  const hdr = req.headers.authorization || '';
  let b64 = hdr.startsWith('Basic ') ? hdr.slice(6) : '';
  if (!b64) {
    const m = (req.headers.cookie || '').match(/(?:^|;\s*)mcc=([^;]+)/);
    if (m) b64 = decodeURIComponent(m[1]);
  }
  const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
  if (PASS && u === USER && p === PASS) {
    // HttpOnly so XSS can't read the credential-equivalent cookie; Secure only
    // when the request arrived over HTTPS so local plain-http :9661 still works.
    const secure = (req.headers['x-forwarded-proto'] === 'https' || req.secure) ? ' Secure;' : '';
    res.setHeader('Set-Cookie', `mcc=${encodeURIComponent(b64)}; Path=/; Max-Age=604800; HttpOnly;${secure} SameSite=Lax`);
    return next();
  }
  res.set('WWW-Authenticate', 'Basic realm="Marketing Command Center"').status(401).end('Auth required');
});

// ── Mount modules ────────────────────────────────────────────────────────────
const registry = require('./modules/registry');
const panels = [];
for (const id of registry) {
  try {
    const mod = require(`./modules/${id}`);
    const router = express.Router();
    if (typeof mod.mount === 'function') mod.mount(router);
    app.use(`/api/${mod.id || id}`, router);
    panels.push({ id: mod.id || id, title: mod.title || id, icon: mod.icon || '▪' });
    console.log(`[mcc] mounted module: ${mod.id || id}`);
  } catch (e) {
    // A module that isn't built yet shouldn't crash the whole center.
    console.warn(`[mcc] module '${id}' not mounted: ${e.message}`);
    panels.push({ id, title: id, icon: '▪', pending: true });
  }
}

// ── Shell APIs + static ──────────────────────────────────────────────────────
app.get('/api/panels', (_req, res) => res.json({ panels }));
app.get('/api/health', (_req, res) => res.json({ ok: true, panels: panels.length }));

// ── Per-contact notes for the Clients & Prospects panel ──────────────────────
// Server-side store so notes follow the user across devices (the panel's
// localStorage is just an instant cache). Not a module — a notes STORE has no
// nav panel of its own, so it mounts here instead of via the registry.
// File: data/clients-notes.json  →  { "<groupId>": { "<recId>": { text, updated } } }
const NOTES_FILE = path.join(__dirname, 'data', 'clients-notes.json');
const readNotes = () => { try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')); } catch { return {}; } };
const writeNotes = (obj) => { const tmp = NOTES_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(obj)); fs.renameSync(tmp, NOTES_FILE); };
app.get('/api/clients-notes', (req, res) => {
  const group = String(req.query.group || '');
  if (!group) return res.status(400).json({ error: 'group required' });
  const g = readNotes()[group] || {};
  const notes = {};
  for (const id of Object.keys(g)) notes[id] = (g[id] && g[id].text) || '';
  res.json({ group, notes });
});
app.post('/api/clients-notes', (req, res) => {
  const { group, id } = req.body || {};
  let { text } = req.body || {};
  if (!group || !id || typeof group !== 'string' || typeof id !== 'string') return res.status(400).json({ error: 'group and id required' });
  if (group.length > 400 || id.length > 400) return res.status(400).json({ error: 'key too long' });
  text = typeof text === 'string' ? text.slice(0, 8000) : '';
  const all = readNotes();
  const g = all[group] || (all[group] = {});
  if (text.trim()) g[id] = { text, updated: Date.now() };
  else delete g[id];
  if (!Object.keys(g).length) delete all[group];
  try { writeNotes(all); } catch { return res.status(500).json({ error: 'write failed' }); }
  res.json({ ok: true });
});
app.use('/panels', express.static(path.join(__dirname, 'public', 'panels'), { fallthrough: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));

app.listen(PORT, () => console.log(`[marketing-command-center] http://127.0.0.1:${PORT}  · ${panels.length} panels`));