diff --git a/public/index-editorial.html b/public/index-editorial.html index 98b9c62..2291800 100644 --- a/public/index-editorial.html +++ b/public/index-editorial.html @@ -2954,7 +2954,217 @@ function loadPeeringRecommendations(asn, ixConnections, lookupData) { }); } +// ── Terminal Feedback Widget ────────────────────────────────────────────────── +var termOpen = false; +var termStep = 0; +var termData = { category:'', message:'', name:'' }; + +// Safe DOM builder: parses only text from trusted template strings. +// User-supplied text is always passed via textContent — never interpreted as HTML. +function termPrint(template, fallbackColor) { + var out = document.getElementById('termOutput'); + var line = document.createElement('div'); + if (fallbackColor) line.style.color = fallbackColor; + // Split on tags from our own templates + var parts = template.split(/(]*>[^<]*<\/span>)/g); + parts.forEach(function(part) { + var m = part.match(/^]*)>(.*?)<\/span>$/); + if (m) { + var span = document.createElement('span'); + var sm = m[1].match(/style="([^"]*)"/); + if (sm) span.style.cssText = sm[1]; + span.textContent = m[2]; // text content, never HTML + line.appendChild(span); + } else if (part) { + line.appendChild(document.createTextNode(part)); + } + }); + out.appendChild(line); + out.scrollTop = out.scrollHeight; +} + +function termClear() { document.getElementById('termOutput').innerHTML = ''; } + +function toggleTerm() { + var p = document.getElementById('termPanel'); + termOpen = !termOpen; + p.style.display = termOpen ? 'flex' : 'none'; + if (termOpen) { termBoot(); setTimeout(function(){ document.getElementById('termInput').focus(); }, 80); } +} +function closeTerm() { + termOpen = false; + document.getElementById('termPanel').style.display = 'none'; +} + +function termBoot() { + termClear(); + termStep = 0; + termData = { category:'', message:'', name:'' }; + document.getElementById('termPrompt').textContent = 'peercortex:~$'; + var G = 'color:#27c93f', DIM = 'color:rgba(0,255,65,.35)', MUT = 'color:rgba(0,255,65,.45)'; + var lines = [ + '────────────────────────────────────────────', + ' PeerCortex Feedback Terminal v0.5.0', + '────────────────────────────────────────────', + '', + 'Available commands:', + ' send — submit feedback or bug report', + ' help — show this menu', + ' clear — clear terminal', + '', + 'Type send and press Enter to start.', + '' + ]; + var delay = 0; + lines.forEach(function(l) { setTimeout(function(){ termPrint(l); }, delay); delay += 35; }); +} + +function termStartWizard() { + var G = 'color:#27c93f'; + termStep = 1; + document.getElementById('termPrompt').textContent = '>'; + termPrint(''); + termPrint('Select category:'); + termPrint(' 1 Bug Report'); + termPrint(' 2 Feature Request'); + termPrint(' 3 Design Feedback'); + termPrint(' 4 General'); + termPrint(''); +} + +function termKeydown(e) { + if (e.key !== 'Enter') return; + var inp = document.getElementById('termInput'); + var val = inp.value.trim(); + inp.value = ''; + var prompt = document.getElementById('termPrompt').textContent; + var G = 'color:#27c93f', Y = 'color:#ffbd2e', MUT = 'color:rgba(0,255,65,.4)'; + // Echo: prompt + user text (built with DOM — no injection risk) + (function(){ + var out = document.getElementById('termOutput'); + var d = document.createElement('div'); + var sp = document.createElement('span'); + sp.style.color = 'rgba(0,255,65,.4)'; + sp.textContent = prompt; + d.appendChild(sp); + d.appendChild(document.createTextNode(' ' + val)); + out.appendChild(d); + out.scrollTop = out.scrollHeight; + })(); + + if (termStep === 0) { + var cmd = val.toLowerCase(); + if (cmd === 'send') { termStartWizard(); } + else if (cmd === 'help') { termBoot(); } + else if (cmd === 'clear') { termClear(); } + else if (cmd !== '') { + var out2 = document.getElementById('termOutput'); + var err = document.createElement('div'); + err.style.color = 'rgba(255,100,100,.75)'; + err.textContent = 'bash: ' + val + ': command not found'; + out2.appendChild(err); + out2.scrollTop = out2.scrollHeight; + } + } else if (termStep === 1) { + var cats = {'1':'Bug Report','2':'Feature Request','3':'Design Feedback','4':'General'}; + if (cats[val]) { + termData.category = cats[val]; + termPrint(''); + termPrint(' Category: ' + cats[val] + ''); + termPrint(''); + termPrint('Describe the issue or idea:'); + termStep = 2; + document.getElementById('termPrompt').textContent = '>'; + } else { termPrint('Enter 1, 2, 3, or 4.', 'rgba(255,189,46,.8)'); } + + } else if (termStep === 2) { + if (val.length < 5) { + termPrint('Too short — write at least a few words.', 'rgba(255,189,46,.8)'); + } else { + termData.message = val; + termPrint(''); + termPrint(' Message recorded.'); + termPrint(''); + termPrint('Your name or handle: (press Enter to stay Anonymous)'); + termStep = 3; + } + + } else if (termStep === 3) { + termData.name = val || 'Anonymous'; + termStep = 0; + document.getElementById('termPrompt').textContent = 'peercortex:~$'; + termPrint(''); + termPrint('Transmitting report...'); + fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category: termData.category, message: termData.message, name: termData.name, asn: currentAsn || null }) + }).then(function(r){ return r.json(); }).then(function(d){ + if (d.ok) { + termPrint(''); + termPrint('████████████████████ 100%'); + termPrint(''); + (function(){ + var out3 = document.getElementById('termOutput'); + var ok = document.createElement('div'); + ok.style.color = '#27c93f'; + ok.textContent = '✓ Feedback transmitted. Thank you, ' + termData.name + '.'; + out3.appendChild(ok); + var sub = document.createElement('div'); + sub.style.color = 'rgba(0,255,65,.45)'; + sub.textContent = 'Your report helps improve PeerCortex.'; + out3.appendChild(sub); + out3.scrollTop = out3.scrollHeight; + })(); + termPrint(''); + termPrint('Type send for another report.'); + } else { + (function(){ + var out4 = document.getElementById('termOutput'); + var er = document.createElement('div'); + er.style.color = 'rgba(255,100,100,.8)'; + er.textContent = 'Error: ' + (d.error || 'unknown'); + out4.appendChild(er); out4.scrollTop = out4.scrollHeight; + })(); + } + }).catch(function(){ + (function(){ + var out5 = document.getElementById('termOutput'); + var ne = document.createElement('div'); + ne.style.color = 'rgba(255,100,100,.8)'; + ne.textContent = 'Network error — please try again.'; + out5.appendChild(ne); out5.scrollTop = out5.scrollHeight; + })(); + }); + } +} + + + + + + diff --git a/public/shell.html b/public/shell.html new file mode 100644 index 0000000..a70615b --- /dev/null +++ b/public/shell.html @@ -0,0 +1,420 @@ + + + + + +PeerCortex Shell — Feedback Admin + + + + +
+
+ + + + shell.peercortex.org — admin terminal +
+
+
+ shell:~$ + +
+
+ + + + diff --git a/server.js b/server.js index 2d597bb..2b0a575 100644 --- a/server.js +++ b/server.js @@ -26,6 +26,9 @@ const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproute const PEERINGDB_API_KEY = process.env.PEERINGDB_API_KEY || ""; const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api"; +const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env"; +const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json"; + const UA = "PeerCortex/0.5.0 (+https://peercortex.org; contact: rene.fichtmueller@flexoptix.net)"; // Static geocode cache for major networking cities (fallback when PDB facility coords missing) @@ -918,6 +921,84 @@ const server = http.createServer(async (req, res) => { } } + // shell.peercortex.org → admin feedback terminal + if (host === 'shell.peercortex.org' && (reqPath === '/' || reqPath === '/index.html')) { + try { + const html = fs.readFileSync('/opt/peercortex-app/public/shell.html', 'utf8'); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.end(html); + } catch (_e) { + res.writeHead(500); + return res.end('shell.html not found'); + } + } + + // ============================================================ + // Feedback API + // ============================================================ + + // OPTIONS preflight (CORS) + if (reqPath === '/api/feedback' && req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.writeHead(204); + return res.end(); + } + + // POST /api/feedback — submit feedback entry + if (reqPath === '/api/feedback' && req.method === 'POST') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + let body = ''; + req.on('data', function(chunk) { body += chunk; }); + req.on('end', function() { + try { + const data = JSON.parse(body); + if (!data.message || String(data.message).trim().length < 3) { + res.writeHead(400); + return res.end(JSON.stringify({ ok: false, error: 'Message too short' })); + } + const entry = { + id: Date.now() + '-' + Math.random().toString(36).slice(2, 7), + timestamp: new Date().toISOString(), + category: String(data.category || 'General').slice(0, 50), + message: String(data.message || '').slice(0, 2000), + name: String(data.name || 'Anonymous').slice(0, 100), + asn: data.asn ? String(data.asn).slice(0, 20) : null, + ip: req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || null, + ua: String(req.headers['user-agent'] || '').slice(0, 200) + }; + let entries = []; + try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ } + entries.push(entry); + fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2)); + return res.end(JSON.stringify({ ok: true, id: entry.id })); + } catch (_e) { + res.writeHead(500); + return res.end(JSON.stringify({ ok: false, error: 'Server error' })); + } + }); + return; + } + + // GET /api/feedback?token=... — admin: fetch all entries as JSON + if (reqPath === '/api/feedback' && req.method === 'GET') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + const token = url.searchParams.get('token'); + if (!token || token !== FEEDBACK_TOKEN) { + res.writeHead(401); + return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' })); + } + try { + const entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); + return res.end(JSON.stringify({ ok: true, entries: entries, count: entries.length })); + } catch (_e) { + return res.end(JSON.stringify({ ok: true, entries: [], count: 0 })); + } + } + // Serve favicon if (reqPath === "/favicon.ico") { res.writeHead(204);