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