← back to Abrams

server.js

218 lines

#!/usr/bin/env node
// Abrams Panel — unified umbrella for the agentabrams.com empire
// Port 9898. Built per Steve's DW-family standing rules.

const express = require('express');
const rateLimit = require('express-rate-limit');
const fs = require('fs');
const path = require('path');
const { execSync, spawn } = require('child_process');

const PORT = parseInt(process.env.PORT || '9898', 10);
const BIND = process.env.BIND || '127.0.0.1'; // internal-only by default; set BIND=0.0.0.0 to expose on LAN
const ROOT = __dirname;
const DATA = path.join(ROOT, 'data');
const PROJECTS_FILE = path.join(DATA, 'projects.json');
const IDEAS_FILE = path.join(DATA, 'ideas.json');
const TRADEMARK_FILE = path.join(DATA, 'trademark.json');
const BUILD_LOG = path.join(DATA, 'build-log.jsonl');
const WALL_FILE = path.join(DATA, 'wall.jsonl');
const LEADS_FILE = path.join(DATA, 'leads.jsonl');

if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
for (const f of [BUILD_LOG, WALL_FILE, LEADS_FILE]) if (!fs.existsSync(f)) fs.writeFileSync(f, '');

const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));

// 404-guard — never serve snapshot/backup/editor files even if they leak into public/
// Blocks paths ending in .bak, .bak.<x>, .pre-<x>, .orig, .rej, .swp, .swo, or ~
app.use((req, res, next) => {
  const p = req.path || '';
  if (/(?:^|\/)[^/]*\.(?:bak(?:\.[^/]*)?|pre-[^/]*|orig|rej|swp|swo)(?:$|\/)/i.test(p)
      || /~(?:$|\/)/.test(p)) {
    return res.status(404).type('text/plain').send('Not Found');
  }
  next();
});

app.use('/static', express.static(path.join(ROOT, 'public')));
// also serve public/*.html at root (so /digest-latest.html, /favicon.ico, etc. resolve)
app.use(express.static(path.join(ROOT, 'public'), { extensions: ['html'] }));

// rate limits — protect lead + wall write endpoints
const leadLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false });
const wallLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false });

const readJson = (p, fb) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fb; } };
const readJsonl = (p, n = 200) => {
  try {
    const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean);
    return lines.slice(-n).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
  } catch { return []; }
};
const appendJsonl = (p, obj) => fs.appendFileSync(p, JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n');

// ─── routes ──────────────────────────────────────────────────────────────
app.get('/healthz', (_, r) => r.json({ ok: true, ts: new Date().toISOString(), port: PORT }));

app.get('/', (_, res) => res.sendFile(path.join(ROOT, 'public', 'index.html')));
app.get('/trademark', (_, res) => res.sendFile(path.join(ROOT, 'public', 'trademark.html')));
app.get('/ideas', (_, res) => res.sendFile(path.join(ROOT, 'public', 'ideas.html')));
app.get('/wall', (_, res) => res.sendFile(path.join(ROOT, 'public', 'wall.html')));
app.get('/chat', (_, res) => res.sendFile(path.join(ROOT, 'public', 'chat.html')));

// ─── data api ────────────────────────────────────────────────────────────
app.get('/api/projects', (_, res) => res.json(readJson(PROJECTS_FILE, { projects: [], updated_at: null })));
app.get('/api/ideas', (_, res) => res.json(readJson(IDEAS_FILE, { ideas: [], updated_at: null })));
app.get('/api/trademark', (_, res) => res.json(readJson(TRADEMARK_FILE, {})));
app.get('/api/counters', (_, res) => res.json(readJson(path.join(DATA, 'counters.json'), { counters: [] })));
app.get('/api/info-hub', (_, res) => res.json(readJson(path.join(DATA, 'info-hub.json'), { sources: [] })));
app.get('/api/market', (_, res) => res.json(readJson(path.join(DATA, 'market.json'), { tickers: [], generated_at: null })));
app.get('/api/abrams-indexes', (_, res) => res.json(readJson(path.join(DATA, 'abrams-indexes.json'), { indexes: [], generated_at: null })));
app.get('/api/competitors', (req, res) => {
  try { res.type('text/markdown').send(fs.readFileSync(path.join(DATA, 'competitors.md'), 'utf8')); }
  catch { res.status(404).json({ error: 'competitors analysis not yet generated' }); }
});
app.get('/api/mag-20', (_, res) => res.json({
  patterns: readJson(path.join(DATA, 'mag-20.json'), { patterns: [] }).patterns || [],
  results: readJson(path.join(DATA, 'mag-20-results.json'), { projects: [], summary: {} }),
}));
app.get('/api/brand-positioning', (_, res) => {
  try { res.type('text/markdown').send(fs.readFileSync(path.join(DATA, 'brand-positioning.md'), 'utf8')); }
  catch { res.status(404).json({ error: 'not yet generated' }); }
});

// gamify — XP/level/badge event log
const GAMIFY_LOG = path.join(DATA, 'gamify-events.jsonl');
if (!fs.existsSync(GAMIFY_LOG)) fs.writeFileSync(GAMIFY_LOG, '');
const gamifyLimiter = rateLimit({ windowMs: 60_000, max: 60 });
app.post('/api/gamify/event', gamifyLimiter, (req, res) => {
  const { player, action, xp, level, badge } = req.body || {};
  if (!action || typeof action !== 'string' || action.length > 60) return res.status(400).json({ ok: false, error: 'action required' });
  appendJsonl(GAMIFY_LOG, { player: (player || 'anon').slice(0, 60), action: action.slice(0, 60), xp: Number.isFinite(xp) ? xp : 0, level: Number.isFinite(level) ? level : 1, badge: (badge || '').slice(0, 40) });
  res.json({ ok: true });
});
app.get('/api/gamify/leaderboard', (_, res) => {
  const events = readJsonl(GAMIFY_LOG, 2000);
  const byPlayer = {};
  for (const e of events) {
    byPlayer[e.player] = byPlayer[e.player] || { player: e.player, total_xp: 0, level: 1, badges: new Set(), events: 0, last_seen: e.ts };
    byPlayer[e.player].total_xp += e.xp || 0;
    byPlayer[e.player].level = Math.max(byPlayer[e.player].level, e.level || 1);
    if (e.badge) byPlayer[e.player].badges.add(e.badge);
    byPlayer[e.player].events += 1;
    byPlayer[e.player].last_seen = e.ts;
  }
  const board = Object.values(byPlayer).map(p => ({ ...p, badges: [...p.badges], badge_count: p.badges.size })).sort((a, b) => b.total_xp - a.total_xp).slice(0, 50);
  res.json({ leaderboard: board, total_events: events.length });
});

app.get('/mag-20', (_, res) => res.sendFile(path.join(ROOT, 'public', 'mag-20.html')));
app.get('/press', (_, res) => res.sendFile(path.join(ROOT, 'public', 'press.html')));
app.get('/hub', (_, res) => res.sendFile(path.join(ROOT, 'public', 'hub.html')));
app.get('/competitors', (_, res) => res.sendFile(path.join(ROOT, 'public', 'competitors.html')));
app.get('/terminal', (_, res) => res.sendFile(path.join(ROOT, 'public', 'terminal.html')));
app.get('/api/build-log', (req, res) => res.json({ events: readJsonl(BUILD_LOG, parseInt(req.query.n || '50', 10)) }));
app.get('/api/wall', (req, res) => res.json({ posts: readJsonl(WALL_FILE, parseInt(req.query.n || '100', 10)) }));

app.post('/api/lead', leadLimiter, (req, res) => {
  const { name, email, company, message, source } = req.body || {};
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ ok: false, error: 'valid email required' });
  const lead = { name: name || '', email, company: company || '', message: message || '', source: source || 'panel', ip: req.ip };
  appendJsonl(LEADS_FILE, lead);
  appendJsonl(BUILD_LOG, { type: 'lead', email, source: source || 'panel' });

  // queue instant notification to Steve (drained by the next tick via George)
  try {
    const queueDir = path.join(DATA, 'email-queue');
    if (!fs.existsSync(queueDir)) fs.mkdirSync(queueDir, { recursive: true });
    const body = `# NEW LEAD — Abrams Panel

**From:** ${lead.name || '(no name)'} <${lead.email}>
**Company:** ${lead.company || '—'}
**Source:** ${lead.source}
**IP:** ${lead.ip}
**Received:** ${new Date().toISOString()}

## Message

${lead.message || '(no message)'}

---

→ All leads: http://127.0.0.1:9898/api/wall (private: data/leads.jsonl)

---

**Designer Wallcoverings, Inc. · 1234 Ventura Blvd, Sherman Oaks, CA 91423 · info@designerwallcoverings.com**
Internal lead-routing notification, sent to Steven Abrams only. To stop these, disable launchd plist com.abrams.panel-tick.
`;
    fs.writeFileSync(
      path.join(queueDir, `lead-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`),
      JSON.stringify({
        to: 'info@designerwallcoverings.com',
        cc: 'steve@designerwallcoverings.com',
        subject: `Abrams Panel: new lead - ${lead.email}`,
        body_markdown: body,
        generated_at: new Date().toISOString(),
        priority: 'instant',
      }, null, 2)
    );
  } catch (e) { /* don't fail the lead submission if queue write fails */ }

  res.json({ ok: true });
});

app.post('/api/wall', wallLimiter, (req, res) => {
  const { author, body } = req.body || {};
  if (!body || body.length > 2000) return res.status(400).json({ ok: false, error: 'body 1..2000 chars' });
  appendJsonl(WALL_FILE, { author: (author || 'Anonymous').slice(0, 80), body: body.slice(0, 2000) });
  res.json({ ok: true });
});

// SSE — live build feed
app.get('/api/stream', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  });
  res.write(`event: hello\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`);

  let lastSize = 0;
  try { lastSize = fs.statSync(BUILD_LOG).size; } catch {}
  const watcher = setInterval(() => {
    try {
      const sz = fs.statSync(BUILD_LOG).size;
      if (sz > lastSize) {
        const fd = fs.openSync(BUILD_LOG, 'r');
        const buf = Buffer.alloc(sz - lastSize);
        fs.readSync(fd, buf, 0, sz - lastSize, lastSize);
        fs.closeSync(fd);
        lastSize = sz;
        buf.toString('utf8').split('\n').filter(Boolean).forEach(line => {
          res.write(`event: build\ndata: ${line}\n\n`);
        });
      }
      res.write(`event: ping\ndata: ${Date.now()}\n\n`);
    } catch {}
  }, 3000);

  req.on('close', () => clearInterval(watcher));
});

// CTA: ping a manual tick
app.post('/api/tick', (_, res) => {
  appendJsonl(BUILD_LOG, { type: 'manual-tick', initiator: 'panel-ui' });
  try { spawn('bash', [path.join(ROOT, 'scripts', 'tick.sh')], { detached: true, stdio: 'ignore' }).unref(); } catch {}
  res.json({ ok: true });
});

app.listen(PORT, BIND, () => {
  appendJsonl(BUILD_LOG, { type: 'server-up', port: PORT, bind: BIND });
  console.log(`Abrams Panel up on ${BIND}:${PORT}`);
});