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