← back to Ungate Console

server.js

74 lines

#!/usr/bin/env node
// Ungate Console — one page of everything blocked, grouped by who must act.
// Zero-dep, Basic-auth admin/DW2024!, OS-assigned free port.
const http = require('http');
const fs = require('fs');
const path = require('path');

const USER = process.env.UC_USER || 'admin';
const PASS = process.env.UC_PASS || 'DW2024!';
const DIR = __dirname;

function auth(req, res) {
  const h = req.headers.authorization || '';
  const [, b64] = h.split(' ');
  const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
  if (u === USER && p === PASS) return true;
  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="ungate-console"' });
  res.end('auth required');
  return false;
}

const DECISIONS = path.join(DIR, 'data', 'decisions.jsonl');

// current decision per tk (last-write-wins) from the append-only log
function decisionMap() {
  const m = {};
  try {
    fs.readFileSync(DECISIONS, 'utf8').split('\n').filter(Boolean).forEach(l => {
      try { const d = JSON.parse(l); if (d.tk) m[d.tk] = d; } catch {}
    });
  } catch {}
  return m;
}

const srv = http.createServer((req, res) => {
  if (!auth(req, res)) return;
  const url = req.url.split('?')[0];
  if (url === '/api/items') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(fs.readFileSync(path.join(DIR, 'data', 'items.json')));
  }
  if (url === '/api/decisions') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(decisionMap()));
  }
  if (url === '/api/decide' && req.method === 'POST') {
    let body = '';
    req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
    req.on('end', () => {
      let d; try { d = JSON.parse(body); } catch { res.writeHead(400); return res.end('bad json'); }
      if (!d.tk || !d.decision) { res.writeHead(400); return res.end('need tk+decision'); }
      const rec = { tk: d.tk, decision: d.decision, note: (d.note || '').slice(0, 500), ts: new Date().toISOString() };
      fs.appendFileSync(DECISIONS, JSON.stringify(rec) + '\n');
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ ok: true, rec }));
    });
    return;
  }
  if (url === '/' || url === '/index.html') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    return res.end(fs.readFileSync(path.join(DIR, 'index.html')));
  }
  res.writeHead(404); res.end('not found');
});

let want = Number(process.env.UC_PORT) || 0;
if (!want) { try { want = Number(fs.readFileSync(path.join(DIR, '.port'), 'utf8').trim()) || 0; } catch {} }
srv.on('error', e => { if (e.code === 'EADDRINUSE' && want) { want = 0; srv.listen(0, '127.0.0.1'); } });
srv.listen(want, '127.0.0.1', () => {
  const port = srv.address().port;
  fs.writeFileSync(path.join(DIR, '.port'), String(port));
  console.log(`Ungate Console → http://127.0.0.1:${port}  (admin/DW2024!)`);
});