feat: add terminal feedback widget + admin shell
- index-editorial.html: floating \$_ terminal button (bottom-right) - macOS-style title bar (traffic light dots), backdrop blur 18px - Guided wizard: category → message → name → submit - POST /api/feedback with ASN context auto-filled - Safe DOM output builder (no innerHTML on user data) - server.js: feedback API endpoints - POST /api/feedback — stores entries to feedback.json - GET /api/feedback?token=... — admin read (token-protected) - OPTIONS preflight for CORS - FEEDBACK_TOKEN + FEEDBACK_FILE constants from .env - Host routing: shell.peercortex.org → shell.html - public/shell.html: full-screen admin terminal - login command → token auth via API - list / list [category] — tabular overview - show <n> — full entry detail - stats — bar chart by category + top ASNs - export — JSON file download - refresh, logout, clear, help
This commit is contained in:
parent
22f219c82e
commit
58bf76fa82
@ -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 <span style="...">text</span> 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 <span> tags from our own templates
|
||||||
|
var parts = template.split(/(<span[^>]*>[^<]*<\/span>)/g);
|
||||||
|
parts.forEach(function(part) {
|
||||||
|
var m = part.match(/^<span([^>]*)>(.*?)<\/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 = [
|
||||||
|
'<span style="' + DIM + '">────────────────────────────────────────────</span>',
|
||||||
|
' <span style="' + G + ';font-weight:600">PeerCortex Feedback Terminal</span> <span style="' + MUT + '">v0.5.0</span>',
|
||||||
|
'<span style="' + DIM + '">────────────────────────────────────────────</span>',
|
||||||
|
'',
|
||||||
|
'Available commands:',
|
||||||
|
' <span style="' + G + '">send</span> — submit feedback or bug report',
|
||||||
|
' <span style="' + G + '">help</span> — show this menu',
|
||||||
|
' <span style="' + G + '">clear</span> — clear terminal',
|
||||||
|
'',
|
||||||
|
'Type <span style="' + G + '">send</span> 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(' <span style="' + G + '">1</span> Bug Report');
|
||||||
|
termPrint(' <span style="' + G + '">2</span> Feature Request');
|
||||||
|
termPrint(' <span style="' + G + '">3</span> Design Feedback');
|
||||||
|
termPrint(' <span style="' + G + '">4</span> 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('<span style="' + G + '">✓</span> Category: <span style="' + Y + '">' + cats[val] + '</span>');
|
||||||
|
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('<span style="' + G + '">✓</span> Message recorded.');
|
||||||
|
termPrint('');
|
||||||
|
termPrint('Your name or handle: <span style="' + MUT + '">(press Enter to stay Anonymous)</span>');
|
||||||
|
termStep = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (termStep === 3) {
|
||||||
|
termData.name = val || 'Anonymous';
|
||||||
|
termStep = 0;
|
||||||
|
document.getElementById('termPrompt').textContent = 'peercortex:~$';
|
||||||
|
termPrint('');
|
||||||
|
termPrint('<span style="' + G + '">Transmitting report...</span>');
|
||||||
|
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('<span style="' + G + '">████████████████████</span> 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 <span style="' + G + '">send</span> 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;
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── Terminal Feedback Trigger Button ────────────────────────────── -->
|
||||||
|
<button id="termBtn" onclick="toggleTerm()" title="Feedback Terminal"
|
||||||
|
onmouseover="this.style.borderColor='rgba(0,255,65,.7)';this.style.boxShadow='0 4px 24px rgba(0,0,0,.5),0 0 32px rgba(0,255,65,.2)'"
|
||||||
|
onmouseout="this.style.borderColor='rgba(0,255,65,.35)';this.style.boxShadow='0 4px 20px rgba(0,0,0,.4),0 0 20px rgba(0,255,65,.07)'"
|
||||||
|
style="position:fixed;bottom:24px;right:24px;width:52px;height:52px;background:rgba(10,10,10,.88);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid rgba(0,255,65,.35);border-radius:6px;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;font-weight:600;letter-spacing:.04em;cursor:pointer;z-index:9999;display:flex;align-items:center;justify-content:center;transition:border-color .2s,box-shadow .2s;box-shadow:0 4px 20px rgba(0,0,0,.4),0 0 20px rgba(0,255,65,.07)">$_</button>
|
||||||
|
|
||||||
|
<!-- ─── Terminal Feedback Panel ─────────────────────────────────────── -->
|
||||||
|
<div id="termPanel"
|
||||||
|
style="display:none;position:fixed;bottom:86px;right:24px;width:500px;background:rgba(10,10,10,.88);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px);border:1px solid rgba(0,255,65,.22);border-radius:8px;box-shadow:0 24px 64px rgba(0,0,0,.6),0 0 40px rgba(0,255,65,.05);font-family:'IBM Plex Mono','Courier New',monospace;font-size:.78rem;z-index:10000;flex-direction:column;overflow:hidden">
|
||||||
|
<!-- Title bar -->
|
||||||
|
<div style="background:rgba(0,0,0,.5);padding:.45rem .8rem;display:flex;align-items:center;gap:.45rem;border-bottom:1px solid rgba(0,255,65,.1);flex-shrink:0">
|
||||||
|
<span onclick="closeTerm()" style="width:11px;height:11px;border-radius:50%;background:#ff5f56;display:inline-block;cursor:pointer;flex-shrink:0"></span>
|
||||||
|
<span style="width:11px;height:11px;border-radius:50%;background:#ffbd2e;display:inline-block;flex-shrink:0"></span>
|
||||||
|
<span style="width:11px;height:11px;border-radius:50%;background:#27c93f;display:inline-block;flex-shrink:0"></span>
|
||||||
|
<span style="flex:1;text-align:center;color:rgba(0,255,65,.45);font-size:.63rem;letter-spacing:.09em">peercortex-feedback — bash</span>
|
||||||
|
</div>
|
||||||
|
<!-- Output area -->
|
||||||
|
<div id="termOutput" style="flex:1;overflow-y:auto;padding:.8rem;color:#00ff41;line-height:1.75;min-height:250px;max-height:340px;word-break:break-word"></div>
|
||||||
|
<!-- Input line -->
|
||||||
|
<div style="display:flex;align-items:center;padding:.45rem .8rem;border-top:1px solid rgba(0,255,65,.1);gap:.5rem;flex-shrink:0">
|
||||||
|
<span id="termPrompt" style="color:rgba(0,255,65,.55);white-space:nowrap;flex-shrink:0">peercortex:~$</span>
|
||||||
|
<input id="termInput" type="text" autocomplete="off" spellcheck="false" onkeydown="termKeydown(event)"
|
||||||
|
style="flex:1;background:transparent;border:none;outline:none;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.78rem;caret-color:#00ff41">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
420
public/shell.html
Normal file
420
public/shell.html
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PeerCortex Shell — Feedback Admin</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{height:100%;background:#0a0a0a;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;line-height:1.7;overflow:hidden}
|
||||||
|
|
||||||
|
#shell{display:flex;flex-direction:column;height:100vh;max-width:900px;margin:0 auto;padding:0 1rem}
|
||||||
|
|
||||||
|
/* Title bar */
|
||||||
|
#titlebar{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-bottom:1px solid rgba(0,255,65,.12);flex-shrink:0;margin-bottom:.25rem}
|
||||||
|
.tb-dot{width:11px;height:11px;border-radius:50%;display:inline-block;flex-shrink:0}
|
||||||
|
#tb-title{flex:1;text-align:center;color:rgba(0,255,65,.45);font-size:.68rem;letter-spacing:.1em}
|
||||||
|
|
||||||
|
/* Output */
|
||||||
|
#output{flex:1;overflow-y:auto;padding:.5rem 0;word-break:break-word}
|
||||||
|
#output div{padding:.05rem 0}
|
||||||
|
|
||||||
|
/* Input bar */
|
||||||
|
#inputbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-top:1px solid rgba(0,255,65,.12);flex-shrink:0}
|
||||||
|
#prompt{color:rgba(0,255,65,.55);white-space:nowrap;flex-shrink:0}
|
||||||
|
#cmd{flex:1;background:transparent;border:none;outline:none;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;caret-color:#00ff41}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
#output::-webkit-scrollbar{width:4px}
|
||||||
|
#output::-webkit-scrollbar-track{background:transparent}
|
||||||
|
#output::-webkit-scrollbar-thumb{background:rgba(0,255,65,.2);border-radius:2px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="shell">
|
||||||
|
<div id="titlebar">
|
||||||
|
<span class="tb-dot" style="background:#ff5f56"></span>
|
||||||
|
<span class="tb-dot" style="background:#ffbd2e"></span>
|
||||||
|
<span class="tb-dot" style="background:#27c93f"></span>
|
||||||
|
<span id="tb-title">shell.peercortex.org — admin terminal</span>
|
||||||
|
</div>
|
||||||
|
<div id="output"></div>
|
||||||
|
<div id="inputbar">
|
||||||
|
<span id="prompt">shell:~$</span>
|
||||||
|
<input id="cmd" type="text" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var token = null;
|
||||||
|
var allEntries = [];
|
||||||
|
|
||||||
|
var G = 'color:#27c93f';
|
||||||
|
var DIM = 'color:rgba(0,255,65,.35)';
|
||||||
|
var MUT = 'color:rgba(0,255,65,.45)';
|
||||||
|
var Y = 'color:#ffbd2e';
|
||||||
|
var R = 'color:rgba(255,100,100,.8)';
|
||||||
|
var W = 'color:#fff';
|
||||||
|
|
||||||
|
// Safe DOM output — no innerHTML on user data
|
||||||
|
function print(template, fallbackColor) {
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var line = document.createElement('div');
|
||||||
|
if (fallbackColor) line.style.color = fallbackColor;
|
||||||
|
var parts = template.split(/(<span[^>]*>[^<]*<\/span>)/g);
|
||||||
|
parts.forEach(function(part) {
|
||||||
|
var m = part.match(/^<span([^>]*)>(.*?)<\/span>$/);
|
||||||
|
if (m) {
|
||||||
|
var sp = document.createElement('span');
|
||||||
|
var sm = m[1].match(/style="([^"]*)"/);
|
||||||
|
if (sm) sp.style.cssText = sm[1];
|
||||||
|
sp.textContent = m[2];
|
||||||
|
line.appendChild(sp);
|
||||||
|
} else if (part) {
|
||||||
|
line.appendChild(document.createTextNode(part));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out.appendChild(line);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printText(text, color) {
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var line = document.createElement('div');
|
||||||
|
line.textContent = text;
|
||||||
|
if (color) line.style.color = color;
|
||||||
|
out.appendChild(line);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blank() { print(''); }
|
||||||
|
|
||||||
|
function clear() { document.getElementById('output').textContent = ''; }
|
||||||
|
|
||||||
|
function setPrompt(p) { document.getElementById('prompt').textContent = p; }
|
||||||
|
|
||||||
|
// Boot sequence
|
||||||
|
function boot() {
|
||||||
|
clear();
|
||||||
|
token = null;
|
||||||
|
allEntries = [];
|
||||||
|
setPrompt('shell:~$');
|
||||||
|
var lines = [
|
||||||
|
'<span style="' + DIM + '">══════════════════════════════════════════════════════════</span>',
|
||||||
|
'',
|
||||||
|
' <span style="' + G + ';font-weight:600">PeerCortex — Feedback Administration Shell</span>',
|
||||||
|
' <span style="' + MUT + '">Unauthorized access prohibited. All sessions logged.</span>',
|
||||||
|
'',
|
||||||
|
'<span style="' + DIM + '">══════════════════════════════════════════════════════════</span>',
|
||||||
|
'',
|
||||||
|
'Type <span style="' + G + '">login</span> to authenticate.',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
var d = 0;
|
||||||
|
lines.forEach(function(l){ setTimeout(function(){ print(l); }, d); d += 40; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command dispatch
|
||||||
|
function dispatch(raw) {
|
||||||
|
var val = raw.trim();
|
||||||
|
var low = val.toLowerCase();
|
||||||
|
var args = val.split(/\s+/);
|
||||||
|
var cmd = args[0].toLowerCase();
|
||||||
|
|
||||||
|
// Echo input safely
|
||||||
|
(function(){
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var d = document.createElement('div');
|
||||||
|
var sp = document.createElement('span');
|
||||||
|
sp.style.color = 'rgba(0,255,65,.35)';
|
||||||
|
sp.textContent = document.getElementById('prompt').textContent;
|
||||||
|
d.appendChild(sp);
|
||||||
|
d.appendChild(document.createTextNode(' ' + val));
|
||||||
|
out.appendChild(d);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Unauthenticated mode
|
||||||
|
if (cmd === 'login') {
|
||||||
|
doLogin();
|
||||||
|
} else if (cmd === 'help') {
|
||||||
|
showHelp(false);
|
||||||
|
} else if (cmd === 'clear') {
|
||||||
|
clear();
|
||||||
|
} else if (val === '') {
|
||||||
|
// noop
|
||||||
|
} else {
|
||||||
|
printText('Permission denied. Type login first.', 'rgba(255,100,100,.75)');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated mode
|
||||||
|
if (cmd === 'list') {
|
||||||
|
var cat = args[1] || null;
|
||||||
|
doList(cat);
|
||||||
|
} else if (cmd === 'show') {
|
||||||
|
var idx = parseInt(args[1], 10);
|
||||||
|
doShow(idx);
|
||||||
|
} else if (cmd === 'stats') {
|
||||||
|
doStats();
|
||||||
|
} else if (cmd === 'export') {
|
||||||
|
doExport();
|
||||||
|
} else if (cmd === 'refresh') {
|
||||||
|
doFetch(function(){ print('<span style="' + G + '">✓ Data refreshed. ' + allEntries.length + ' entries loaded.</span>'); });
|
||||||
|
} else if (cmd === 'clear') {
|
||||||
|
clear();
|
||||||
|
} else if (cmd === 'logout') {
|
||||||
|
token = null; allEntries = [];
|
||||||
|
setPrompt('shell:~$');
|
||||||
|
blank();
|
||||||
|
printText('Session terminated.', MUT.slice(6));
|
||||||
|
blank();
|
||||||
|
printText('Type login to re-authenticate.');
|
||||||
|
} else if (cmd === 'help') {
|
||||||
|
showHelp(true);
|
||||||
|
} else if (val === '') {
|
||||||
|
// noop
|
||||||
|
} else {
|
||||||
|
printText('shell: ' + val + ': command not found', 'rgba(255,100,100,.75)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login flow
|
||||||
|
var loginState = 0;
|
||||||
|
function doLogin() {
|
||||||
|
loginState = 1;
|
||||||
|
setPrompt('token:');
|
||||||
|
blank();
|
||||||
|
printText('Enter FEEDBACK_TOKEN:');
|
||||||
|
document.getElementById('cmd').type = 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogin(val) {
|
||||||
|
document.getElementById('cmd').type = 'text';
|
||||||
|
loginState = 0;
|
||||||
|
setPrompt('shell:~$');
|
||||||
|
blank();
|
||||||
|
// Verify token
|
||||||
|
fetch('/api/feedback?token=' + encodeURIComponent(val))
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(d){
|
||||||
|
if (d.ok) {
|
||||||
|
token = val;
|
||||||
|
allEntries = d.entries || [];
|
||||||
|
setPrompt('root@peercortex:~#');
|
||||||
|
print('<span style="' + G + '">✓ Authenticated. ' + allEntries.length + ' feedback entries loaded.</span>');
|
||||||
|
blank();
|
||||||
|
showHelp(true);
|
||||||
|
} else {
|
||||||
|
printText('✗ Authentication failed. Wrong token.', 'rgba(255,100,100,.8)');
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
}).catch(function(){
|
||||||
|
printText('Network error during authentication.', 'rgba(255,100,100,.8)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh data from server
|
||||||
|
function doFetch(cb) {
|
||||||
|
fetch('/api/feedback?token=' + encodeURIComponent(token))
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(d){
|
||||||
|
if (d.ok) { allEntries = d.entries || []; if (cb) cb(); }
|
||||||
|
else { printText('Fetch error: ' + (d.error || 'unknown'), 'rgba(255,100,100,.8)'); }
|
||||||
|
}).catch(function(){ printText('Network error.', 'rgba(255,100,100,.8)'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// list [category]
|
||||||
|
function doList(filterCat) {
|
||||||
|
var data = allEntries;
|
||||||
|
if (filterCat) {
|
||||||
|
var lf = filterCat.toLowerCase();
|
||||||
|
data = data.filter(function(e){ return e.category && e.category.toLowerCase().indexOf(lf) >= 0; });
|
||||||
|
}
|
||||||
|
blank();
|
||||||
|
if (data.length === 0) {
|
||||||
|
printText('No entries' + (filterCat ? ' matching "' + filterCat + '"' : '') + '.', MUT.slice(6));
|
||||||
|
blank();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
print('<span style="' + DIM + '">── # DATE CATEGORY NAME ASN ──</span>');
|
||||||
|
data.forEach(function(e, i) {
|
||||||
|
var realIdx = allEntries.indexOf(e);
|
||||||
|
var date = e.timestamp ? e.timestamp.slice(0,10) : '?';
|
||||||
|
var cat = (e.category || 'General').slice(0,18).padEnd(18);
|
||||||
|
var name = (e.name || 'Anonymous').slice(0,16).padEnd(16);
|
||||||
|
var asn = e.asn ? ('AS' + e.asn) : '—';
|
||||||
|
var num = String(realIdx + 1).padStart(3);
|
||||||
|
(function(entry, n){
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var line = document.createElement('div');
|
||||||
|
var snr = document.createElement('span');
|
||||||
|
snr.style.color = 'rgba(0,255,65,.4)';
|
||||||
|
snr.textContent = num + ' ';
|
||||||
|
var sdat = document.createElement('span');
|
||||||
|
sdat.style.color = '#ffbd2e';
|
||||||
|
sdat.textContent = date + ' ';
|
||||||
|
var scat = document.createElement('span');
|
||||||
|
scat.style.color = '#00ff41';
|
||||||
|
scat.textContent = cat + ' ';
|
||||||
|
var snm = document.createElement('span');
|
||||||
|
snm.style.color = 'rgba(0,255,65,.7)';
|
||||||
|
snm.textContent = name + ' ';
|
||||||
|
var sasn = document.createElement('span');
|
||||||
|
sasn.style.color = 'rgba(0,255,65,.45)';
|
||||||
|
sasn.textContent = asn;
|
||||||
|
line.appendChild(snr); line.appendChild(sdat); line.appendChild(scat);
|
||||||
|
line.appendChild(snm); line.appendChild(sasn);
|
||||||
|
out.appendChild(line);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
})(e, realIdx);
|
||||||
|
});
|
||||||
|
print('<span style="' + DIM + '">──────────────────────────────────────────────────────────────</span>');
|
||||||
|
var out2 = document.getElementById('output');
|
||||||
|
var tot = document.createElement('div');
|
||||||
|
tot.style.color = 'rgba(0,255,65,.45)';
|
||||||
|
tot.textContent = data.length + ' entr' + (data.length === 1 ? 'y' : 'ies') + (filterCat ? ' (filtered)' : '') + ' — type show <n> for full message';
|
||||||
|
out2.appendChild(tot);
|
||||||
|
out2.scrollTop = out2.scrollHeight;
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// show <n>
|
||||||
|
function doShow(n) {
|
||||||
|
if (isNaN(n) || n < 1 || n > allEntries.length) {
|
||||||
|
printText('Usage: show <number> (1–' + allEntries.length + ')', 'rgba(255,189,46,.8)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var e = allEntries[n - 1];
|
||||||
|
blank();
|
||||||
|
print('<span style="' + DIM + '">────────────────────────────────────────────────────────────</span>');
|
||||||
|
(function(){
|
||||||
|
var fields = [
|
||||||
|
['ID', e.id || '?'],
|
||||||
|
['Timestamp', e.timestamp || '?'],
|
||||||
|
['Category', e.category || 'General'],
|
||||||
|
['Name', e.name || 'Anonymous'],
|
||||||
|
['ASN', e.asn ? 'AS' + e.asn : '—'],
|
||||||
|
['IP', e.ip || '—'],
|
||||||
|
];
|
||||||
|
fields.forEach(function(f){
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var d = document.createElement('div');
|
||||||
|
var lbl = document.createElement('span');
|
||||||
|
lbl.style.color = 'rgba(0,255,65,.45)';
|
||||||
|
lbl.textContent = f[0].padEnd(12) + ' ';
|
||||||
|
var val = document.createElement('span');
|
||||||
|
val.style.color = '#00ff41';
|
||||||
|
val.textContent = f[1];
|
||||||
|
d.appendChild(lbl); d.appendChild(val);
|
||||||
|
out.appendChild(d);
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
blank();
|
||||||
|
printText('Message:', 'rgba(0,255,65,.45)');
|
||||||
|
printText(e.message || '(empty)', '#fff');
|
||||||
|
blank();
|
||||||
|
print('<span style="' + DIM + '">────────────────────────────────────────────────────────────</span>');
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// stats
|
||||||
|
function doStats() {
|
||||||
|
if (allEntries.length === 0) { printText('No feedback entries yet.', MUT.slice(6)); return; }
|
||||||
|
var cats = {};
|
||||||
|
var asns = {};
|
||||||
|
allEntries.forEach(function(e){
|
||||||
|
var c = e.category || 'General';
|
||||||
|
cats[c] = (cats[c] || 0) + 1;
|
||||||
|
if (e.asn) { asns[e.asn] = (asns[e.asn] || 0) + 1; }
|
||||||
|
});
|
||||||
|
blank();
|
||||||
|
print('<span style="' + G + ';font-weight:600">─── Feedback Statistics ───────────────────</span>');
|
||||||
|
var out = document.getElementById('output');
|
||||||
|
var tot = document.createElement('div');
|
||||||
|
tot.style.color = '#ffbd2e';
|
||||||
|
tot.textContent = 'Total entries: ' + allEntries.length;
|
||||||
|
out.appendChild(tot);
|
||||||
|
blank();
|
||||||
|
printText('By category:', 'rgba(0,255,65,.45)');
|
||||||
|
Object.keys(cats).sort(function(a,b){ return cats[b]-cats[a]; }).forEach(function(c){
|
||||||
|
var d2 = document.createElement('div');
|
||||||
|
var bar = '█'.repeat(Math.round(cats[c] / allEntries.length * 20));
|
||||||
|
var sp1 = document.createElement('span');
|
||||||
|
sp1.style.color = 'rgba(0,255,65,.4)';
|
||||||
|
sp1.textContent = ' ' + c.padEnd(20);
|
||||||
|
var sp2 = document.createElement('span');
|
||||||
|
sp2.style.color = '#27c93f';
|
||||||
|
sp2.textContent = bar + ' ' + cats[c];
|
||||||
|
d2.appendChild(sp1); d2.appendChild(sp2);
|
||||||
|
out.appendChild(d2);
|
||||||
|
});
|
||||||
|
var topAsn = Object.keys(asns);
|
||||||
|
if (topAsn.length > 0) {
|
||||||
|
blank();
|
||||||
|
printText('Top ASNs reported:', 'rgba(0,255,65,.45)');
|
||||||
|
topAsn.sort(function(a,b){ return asns[b]-asns[a]; }).slice(0,5).forEach(function(a){
|
||||||
|
var d3 = document.createElement('div');
|
||||||
|
d3.style.color = 'rgba(0,255,65,.7)';
|
||||||
|
d3.textContent = ' AS' + a + ' — ' + asns[a] + ' report' + (asns[a]===1?'':'s');
|
||||||
|
out.appendChild(d3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.scrollTop = out.scrollHeight;
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// export — trigger JSON file download
|
||||||
|
function doExport() {
|
||||||
|
var blob = new Blob([JSON.stringify(allEntries, null, 2)], {type:'application/json'});
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'peercortex-feedback-' + new Date().toISOString().slice(0,10) + '.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
print('<span style="' + G + '">✓ Export downloaded: ' + allEntries.length + ' entries.</span>');
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// help
|
||||||
|
function showHelp(authed) {
|
||||||
|
blank();
|
||||||
|
print('<span style="' + G + ';font-weight:600">Available commands:</span>');
|
||||||
|
if (!authed) {
|
||||||
|
print(' <span style="' + G + '">login</span> — authenticate with FEEDBACK_TOKEN');
|
||||||
|
print(' <span style="' + G + '">clear</span> — clear screen');
|
||||||
|
} else {
|
||||||
|
print(' <span style="' + G + '">list</span> — show all feedback entries');
|
||||||
|
print(' <span style="' + G + '">list [category]</span> — filter by category (bug, feature, design, general)');
|
||||||
|
print(' <span style="' + G + '">show <n></span> — show full message for entry #n');
|
||||||
|
print(' <span style="' + G + '">stats</span> — category + ASN statistics');
|
||||||
|
print(' <span style="' + G + '">export</span> — download all entries as JSON');
|
||||||
|
print(' <span style="' + G + '">refresh</span> — reload entries from server');
|
||||||
|
print(' <span style="' + G + '">clear</span> — clear screen');
|
||||||
|
print(' <span style="' + G + '">logout</span> — end session');
|
||||||
|
}
|
||||||
|
blank();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input handler
|
||||||
|
document.getElementById('cmd').addEventListener('keydown', function(e){
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
var val = this.value;
|
||||||
|
this.value = '';
|
||||||
|
if (loginState === 1) {
|
||||||
|
handleLogin(val.trim());
|
||||||
|
} else {
|
||||||
|
dispatch(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start
|
||||||
|
boot();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
81
server.js
81
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_KEY = process.env.PEERINGDB_API_KEY || "";
|
||||||
const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api";
|
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)";
|
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)
|
// 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
|
// Serve favicon
|
||||||
if (reqPath === "/favicon.ico") {
|
if (reqPath === "/favicon.ico") {
|
||||||
res.writeHead(204);
|
res.writeHead(204);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user