← back to Gated Queue Viewer

server.js

118 lines

// gated-queue-viewer — read-only web viewer for ~/.claude/yolo-queue/pending-approval/
// Basic-auth (admin/DW2024!), zero-dependency Node http. READ-ONLY: never writes/moves/executes.
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const PORT = process.env.PORT || 9771;
const USER = process.env.BASIC_USER || 'admin';
const PASS = process.env.BASIC_PASS || 'DW2024!';
const QUEUE_DIR = process.env.QUEUE_DIR || path.join(process.env.HOME, '.claude/yolo-queue/pending-approval');
const ROOT = __dirname;

const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json' };

function unauthorized(res) {
  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="gated-queue", charset="UTF-8"' });
  res.end('Auth required');
}
function checkAuth(req) {
  const h = req.headers.authorization || '';
  if (!h.startsWith('Basic ')) return false;
  const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
  return u === USER && p === PASS;
}

// Category classification by keyword — order matters (first match wins)
const CATS = [
  ['credential', /credential|secret|api.?key|token|rotat/i],
  ['gmc-google', /\bGMC\b|google merchant|google offer|google feed|google shopping/i],
  ['shopify', /shopify|dw_unified/i],
  ['dns-domain', /\bDNS\b|cloudflare|certbot|\bSSL\b|domain zone/i],
  ['deploy-prod', /kamatera|deploy|rsync|pm2 (restart|reload)/i],
  ['send-to-list', /send.?to.?list|constant contact|mailer|email blast|sms blast/i],
  ['scheduled-job', /launchd|launchctl|cron|scheduled.?job/i],
  ['other', /.*/],
];
function classify(text) {
  for (const [name, re] of CATS) if (re.test(text)) return name;
  return 'other';
}

function listQueue() {
  const files = fs.readdirSync(QUEUE_DIR, { withFileTypes: true })
    .filter(d => d.isFile() && d.name.endsWith('.md') && !d.name.startsWith('_'));
  return files.map(d => {
    const full = path.join(QUEUE_DIR, d.name);
    const stat = fs.statSync(full);
    let content = '';
    try { content = fs.readFileSync(full, 'utf8'); } catch (e) { content = ''; }
    const firstLine = (content.split('\n').find(l => l.trim().length) || d.name).replace(/^#+\s*/, '');
    const ticketMatch = content.match(/TK-\d+/);
    const excerpt = content.split('\n').filter(l => l.trim() && !l.startsWith('#')).slice(0, 3).join(' ').slice(0, 240);
    return {
      id: crypto.createHash('md5').update(d.name).digest('hex').slice(0, 12),
      filename: d.name,
      title: firstLine.slice(0, 140),
      ticket: ticketMatch ? ticketMatch[0] : null,
      category: classify(content),
      created_at: stat.mtime.toISOString(),
      size: stat.size,
      excerpt,
    };
  }).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}

let FILENAME_BY_ID = {};

const server = http.createServer((req, res) => {
  if (!checkAuth(req)) return unauthorized(res);

  if (req.url === '/api/queue') {
    try {
      const items = listQueue();
      FILENAME_BY_ID = Object.fromEntries(items.map(i => [i.id, i.filename]));
      res.writeHead(200, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ items, count: items.length, source: QUEUE_DIR }));
    } catch (e) {
      res.writeHead(500, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ error: String(e) }));
    }
  }

  if (req.url.startsWith('/api/queue/')) {
    const id = decodeURIComponent(req.url.split('/api/queue/')[1]);
    const fname = FILENAME_BY_ID[id];
    if (!fname) { res.writeHead(404, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'not found — refresh list first' })); }
    try {
      const content = fs.readFileSync(path.join(QUEUE_DIR, fname), 'utf8');
      res.writeHead(200, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ filename: fname, content }));
    } catch (e) {
      res.writeHead(500, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ error: String(e) }));
    }
  }

  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ ok: true, port: PORT, queue_dir: QUEUE_DIR }));
  }

  let rel = req.url.split('?')[0];
  if (rel === '/' || rel === '') rel = '/index.html';
  const filePath = path.join(ROOT, 'public', path.normalize(rel));
  if (!filePath.startsWith(path.join(ROOT, 'public'))) { res.writeHead(403); return res.end('forbidden'); }
  fs.readFile(filePath, (err, buf) => {
    if (err) { res.writeHead(404); return res.end('not found'); }
    res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream' });
    res.end(buf);
  });
});

// warm the id map on boot
FILENAME_BY_ID = Object.fromEntries(listQueue().map(i => [i.id, i.filename]));

server.listen(PORT, () => console.log(`gated-queue-viewer on http://127.0.0.1:${PORT} (admin/DW2024!) — READ-ONLY, source: ${QUEUE_DIR}`));