feedback-tool/server.js
OpenClaw Agent e5c93e71d3
All checks were successful
Deploy to dev.bl.pixeldev.eu / deploy (push) Successful in 2s
fix: escape backticks in snippetBlock to fix 503 syntax error
2026-03-13 14:31:52 +00:00

490 lines
24 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const http = require('http');
const app = express();
// Capture raw body for webhook signature verification before JSON parsing
app.use((req, res, next) => {
if (req.path === '/webhook/gitea') {
let raw = '';
req.on('data', chunk => { raw += chunk; });
req.on('end', () => {
req.rawBody = raw;
try { req.body = JSON.parse(raw); } catch { req.body = {}; }
next();
});
} else {
express.json()(req, res, next);
}
});
const PORT = parseInt(process.env.PORT || '3060');
const BASE = (process.env.BASE_PATH || '/feedback-tool').replace(/\/$/, '');
const FT_TOKEN = process.env.FT_TOKEN || fs.readFileSync('/root/feedback-tool.env','utf8').match(/FT_TOKEN=(.*)/)?.[1]?.trim();
const GITEA_HOST = '127.0.0.1';
const GITEA_PORT = 3040;
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
function giteaRequest(method, path, body) {
return new Promise((resolve, reject) => {
const postData = body ? JSON.stringify(body) : null;
const options = {
hostname: GITEA_HOST, port: GITEA_PORT, path, method,
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${FT_TOKEN}`,
...(postData ? { 'Content-Length': Buffer.byteLength(postData) } : {})
}
};
const req = http.request(options, r => {
let data = '';
r.on('data', c => data += c);
r.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(data); } });
});
req.on('error', reject);
if (postData) req.write(postData);
req.end();
});
}
// GET /api/issues?repo=owner/repo
app.get(['/api/issues', BASE + '/api/issues'], async (req, res) => {
const { repo } = req.query;
if (!repo) return res.status(400).json({ error: 'repo required' });
try {
const issues = await giteaRequest('GET', `/api/v1/repos/${repo}/issues?state=open&type=issues&limit=20`);
res.json(Array.isArray(issues) ? issues.map(i => ({
number: i.number, title: i.title,
body: (i.body || '').slice(0, 300),
created_at: i.created_at,
comments: i.comments,
url: i.html_url
})) : []);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// GET /api/collaborators?repo=owner/repo
app.get(['/api/collaborators', BASE + '/api/collaborators'], async (req, res) => {
const { repo } = req.query;
if (!repo) return res.status(400).json({ error: 'repo required' });
try {
const users = await giteaRequest('GET', `/api/v1/repos/${repo}/collaborators?limit=50`);
// Also include the owner
const repoInfo = await giteaRequest('GET', `/api/v1/repos/${repo}`);
const owner = repoInfo.owner ? [{ login: repoInfo.owner.login }] : [];
const collabs = Array.isArray(users) ? users.map(u => ({ login: u.login })) : [];
// Dedupe
const seen = new Set();
const all = [...owner, ...collabs].filter(u => { if (seen.has(u.login)) return false; seen.add(u.login); return true; });
res.json(all);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/feedback — create new issue
app.post(['/api/feedback', BASE + '/api/feedback'], async (req, res) => {
const { title, body, repo, source_url, assignee } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
if (!repo) return res.status(400).json({ error: 'repo required' });
const issueBody = [body || '', '', '---', `*Submitted via feedback widget from: ${source_url || 'unknown'}*`].join('\n');
const payload = { title, body: issueBody };
if (assignee) payload.assignees = [assignee];
try {
const issue = await giteaRequest('POST', `/api/v1/repos/${repo}/issues`, payload);
if (issue.number) res.json({ ok: true, issue_number: issue.number, issue_url: issue.html_url });
else res.status(500).json({ error: 'Failed', raw: JSON.stringify(issue).slice(0, 200) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/comment — add comment to existing issue
app.post(['/api/comment', BASE + '/api/comment'], async (req, res) => {
const { repo, issue_number, body } = req.body;
if (!repo || !issue_number || !body) return res.status(400).json({ error: 'repo, issue_number, body required' });
try {
const comment = await giteaRequest('POST', `/api/v1/repos/${repo}/issues/${issue_number}/comments`, { body });
if (comment.id) res.json({ ok: true, comment_id: comment.id });
else res.status(500).json({ error: 'Failed', raw: JSON.stringify(comment).slice(0, 200) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Serve widget.js
app.get(['/widget.js', BASE + '/widget.js'], (req, res) => {
const SELF = `https://dev.bl.pixeldev.eu${BASE}`;
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-cache');
res.send(`(function() {
var SELF = '${SELF}';
var cfg = (function() {
var s = document.currentScript || document.querySelector('script[data-repo]');
return { repo: s && s.getAttribute('data-repo') };
})();
// ── Styles ────────────────────────────────────────────────────────────────
var style = document.createElement('style');
style.textContent = \`
#_fb-fab { position:fixed; bottom:1.5rem; right:1.5rem; width:54px; height:54px; border-radius:50%;
background:#7c9ef5; color:#fff; font-size:22px; border:none; cursor:pointer;
box-shadow:0 4px 20px rgba(124,158,245,.4); z-index:99998;
display:flex; align-items:center; justify-content:center; transition:transform .15s,box-shadow .15s; }
#_fb-fab:hover { transform:scale(1.08); box-shadow:0 6px 24px rgba(124,158,245,.6); }
#_fb-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); backdrop-filter:blur(4px);
z-index:99998; display:none; align-items:center; justify-content:center; }
#_fb-overlay.open { display:flex; }
#_fb-modal { background:#161616; border:1px solid #2a2a2a; border-radius:16px; width:min(560px,96vw);
max-height:80vh; display:flex; flex-direction:column; box-shadow:0 24px 64px rgba(0,0,0,.7);
font-family:'Segoe UI',system-ui,sans-serif; overflow:hidden; }
#_fb-header { display:flex; align-items:center; justify-content:space-between; padding:1.1rem 1.4rem;
border-bottom:1px solid #222; flex-shrink:0; }
#_fb-header h2 { color:#fff; font-size:1rem; margin:0; font-weight:600; }
#_fb-hclose { background:none; border:none; color:#555; cursor:pointer; font-size:1.2rem; line-height:1; padding:0; }
#_fb-hclose:hover { color:#aaa; }
#_fb-tabs { display:flex; border-bottom:1px solid #222; flex-shrink:0; }
.fb-tab { flex:1; padding:.7rem; background:none; border:none; color:#666; font-size:.85rem;
cursor:pointer; font-family:inherit; border-bottom:2px solid transparent; transition:color .15s; }
.fb-tab.active { color:#7c9ef5; border-bottom-color:#7c9ef5; }
.fb-tab:hover { color:#aaa; }
#_fb-body { padding:1.2rem 1.4rem; overflow-y:auto; display:flex; flex-direction:column; gap:.8rem; }
.fb-input, .fb-textarea { background:#0d0d0d; border:1px solid #2a2a2a; border-radius:8px;
padding:.6rem .8rem; color:#e0e0e0; font-size:.875rem; width:100%; font-family:inherit;
outline:none; resize:vertical; transition:border-color .15s; }
.fb-input:focus, .fb-textarea:focus { border-color:#7c9ef5; }
.fb-textarea { min-height:90px; }
.fb-btn { background:#7c9ef5; color:#fff; border:none; border-radius:8px; padding:.65rem 1.2rem;
font-size:.875rem; font-weight:600; cursor:pointer; transition:opacity .15s; width:100%; }
.fb-btn:hover { opacity:.85; }
.fb-btn:disabled { opacity:.4; cursor:default; }
.fb-status { font-size:.8rem; text-align:center; min-height:1.1rem; }
.fb-issue-link { display:flex; align-items:center; justify-content:space-between; gap:.5rem;
padding:.6rem .4rem; border-bottom:1px solid #1a1a1a; text-decoration:none; color:#e0e0e0;
font-size:.875rem; transition:color .15s; }
.fb-issue-link:last-child { border-bottom:none; }
.fb-issue-link:hover { color:#7c9ef5; }
.fb-issue-num { color:#555; font-size:.8rem; flex-shrink:0; }
.fb-issue-arrow { color:#444; font-size:.8rem; flex-shrink:0; }
.fb-empty { color:#555; font-size:.85rem; text-align:center; padding:1.5rem 0; }
.fb-loading { color:#555; font-size:.85rem; text-align:center; padding:1rem 0; }
/* Assignee select */
.fb-select { background:#0d0d0d; border:1px solid #2a2a2a; border-radius:8px;
padding:.6rem .8rem; color:#e0e0e0; font-size:.875rem; width:100%; font-family:inherit;
outline:none; transition:border-color .15s; appearance:none;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat:no-repeat; background-position:right .7rem center; padding-right:2rem; }
.fb-select:focus { border-color:#7c9ef5; }
.fb-field-label { color:#666; font-size:.75rem; margin-bottom:.2rem; }
/* Picker row inside new-issue form */
.fb-picker-row { display:flex; align-items:center; gap:.6rem; }
.fb-picker-btn { background:#1a2a1a; border:1px solid #2a4a2a; color:#6fcf6f; font-size:.78rem;
padding:.4rem .7rem; border-radius:6px; cursor:pointer; white-space:nowrap; flex-shrink:0; }
.fb-picker-btn:hover { background:#223322; }
/* Picked-elements chip list */
#_fb-picked-list { display:flex; flex-wrap:wrap; gap:.4rem; }
#_fb-picked-list:empty { display:none; }
.fb-picked-chip { display:inline-flex; align-items:center; gap:.35rem; background:#0d1f0d;
border:1px solid #2a4a2a; border-radius:6px; padding:.28rem .55rem; font-size:.75rem;
color:#6fcf6f; font-family:monospace; }
.fb-chip-num { color:#4a9f4a; font-weight:700; }
.fb-chip-label { color:#aaa; max-width:140px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fb-chip-remove { background:none; border:none; color:#555; cursor:pointer; font-size:.7rem;
padding:0; line-height:1; }
.fb-chip-remove:hover { color:#f57c7c; }
/* Picker overlay */
#_fb-picker-shield { display:none; position:fixed; inset:0; z-index:199999; cursor:crosshair; }
#_fb-picker-shield.active { display:block; }
#_fb-picker-toast { position:fixed; top:1rem; left:50%; transform:translateX(-50%);
background:#1a1a1a; border:1px solid #7c9ef5; border-radius:8px;
padding:.6rem 1.1rem; color:#e0e0e0; font-size:.85rem; z-index:200000;
font-family:'Segoe UI',system-ui,sans-serif; display:none; box-shadow:0 4px 16px rgba(0,0,0,.5); }
#_fb-picker-toast.active { display:block; }
._fb-hover-highlight { outline:2px solid #7c9ef5 !important; outline-offset:2px !important; background-color:rgba(124,158,245,.08) !important; }
\`;
document.head.appendChild(style);
// ── FAB ───────────────────────────────────────────────────────────────────
var fab = document.createElement('button');
fab.id = '_fb-fab'; fab.innerHTML = '💬'; fab.title = 'Feedback';
document.body.appendChild(fab);
// ── Picker shield (invisible overlay during pick mode) ────────────────────
var shield = document.createElement('div');
shield.id = '_fb-picker-shield';
document.body.appendChild(shield);
var toast = document.createElement('div');
toast.id = '_fb-picker-toast';
toast.textContent = '🎯 Click any element to capture it — Esc to cancel';
document.body.appendChild(toast);
// ── Main overlay ──────────────────────────────────────────────────────────
var overlay = document.createElement('div');
overlay.id = '_fb-overlay';
overlay.innerHTML = \`
<div id="_fb-modal">
<div id="_fb-header">
<h2>💬 Feedback</h2>
<button id="_fb-hclose">✕</button>
</div>
<div id="_fb-tabs">
<button class="fb-tab active" data-tab="new">New Issue</button>
<button class="fb-tab" data-tab="open">Open Issues</button>
</div>
<div id="_fb-body">
<div id="_fb-tab-new">
<div style="display:flex;flex-direction:column;gap:.8rem;">
<input class="fb-input" id="_fb-title" type="text" placeholder="Title (required)" />
<textarea class="fb-textarea" id="_fb-desc" placeholder="Describe your feedback…"></textarea>
<div>
<div class="fb-field-label">Assignee</div>
<select class="fb-select" id="_fb-assignee">
<option value="">— Unassigned —</option>
</select>
</div>
<div class="fb-picker-row">
<button class="fb-picker-btn" id="_fb-pick-btn">🎯 Pick element</button>
</div>
<div id="_fb-picked-list"></div>
<button class="fb-btn" id="_fb-submit">Submit Issue</button>
<div class="fb-status" id="_fb-status"></div>
</div>
</div>
<div id="_fb-tab-open" style="display:none;">
<div id="_fb-issues-list"><div class="fb-loading">Loading issues…</div></div>
</div>
</div>
</div>
\`;
document.body.appendChild(overlay);
// ── Load collaborators into assignee dropdown ─────────────────────────────
var assigneesLoaded = false;
function loadAssignees() {
if (assigneesLoaded || !cfg.repo) return;
assigneesLoaded = true;
fetch(SELF + '/api/collaborators?repo=' + encodeURIComponent(cfg.repo))
.then(function(r) { return r.json(); })
.then(function(users) {
var sel = document.getElementById('_fb-assignee');
if (!Array.isArray(users)) return;
users.forEach(function(u) {
var opt = document.createElement('option');
opt.value = u.login; opt.textContent = u.login;
sel.appendChild(opt);
});
}).catch(function() {});
}
// ── Open / close ──────────────────────────────────────────────────────────
function openOverlay() { overlay.classList.add('open'); loadAssignees(); }
function closeOverlay() { overlay.classList.remove('open'); }
fab.addEventListener('click', function() {
openOverlay();
if (currentTab === 'open') loadIssues();
});
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeOverlay(); });
document.getElementById('_fb-hclose').addEventListener('click', closeOverlay);
// ── Tabs ──────────────────────────────────────────────────────────────────
var currentTab = 'new';
document.querySelectorAll('.fb-tab').forEach(function(btn) {
btn.addEventListener('click', function() {
currentTab = btn.dataset.tab;
document.querySelectorAll('.fb-tab').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
document.getElementById('_fb-tab-new').style.display = currentTab === 'new' ? '' : 'none';
document.getElementById('_fb-tab-open').style.display = currentTab === 'open' ? '' : 'none';
if (currentTab === 'open') loadIssues();
});
});
// ── Picker mode ───────────────────────────────────────────────────────────
var pickerActive = false;
var pickerHighlighted = null;
var nextPickNum = 1;
var pickedElements = {}; // { [num]: { selector, snippet } }
function getSelector(el) {
if (!el || el === document.body) return 'body';
var tag = el.tagName.toLowerCase();
var id = el.id ? '#' + el.id : '';
var cls = el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
return tag + id + cls;
}
function getSnippet(el) {
// Outer HTML truncated sensibly
var html = el.outerHTML || '';
if (html.length > 500) {
// Just show tag + attrs + content preview
var inner = (el.textContent || '').trim().slice(0, 120);
var m = html.match(/^(<[^>]+>)/);
html = (m ? m[1] : '<' + el.tagName.toLowerCase() + '>') + (inner ? '\\n ' + inner + '…' : '') + '\\n</' + el.tagName.toLowerCase() + '>';
}
return html;
}
function activatePicker() {
pickerActive = true;
overlay.classList.remove('open');
shield.classList.add('active');
toast.classList.add('active');
}
function insertAtCursor(ta, text) {
var start = ta.selectionStart != null ? ta.selectionStart : ta.value.length;
var end = ta.selectionEnd != null ? ta.selectionEnd : ta.value.length;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + text.length;
}
function addPickedChip(num, selector, snippet) {
var list = document.getElementById('_fb-picked-list');
var chip = document.createElement('span');
chip.className = 'fb-picked-chip';
chip.dataset.num = num;
chip.innerHTML =
'<span class="fb-chip-num">[' + num + ']</span>' +
'<span class="fb-chip-label">' + esc(selector) + '</span>' +
'<button class="fb-chip-remove" title="Remove">✕</button>';
chip.querySelector('.fb-chip-remove').addEventListener('click', function() {
delete pickedElements[num];
chip.remove();
});
list.appendChild(chip);
}
function deactivatePicker(snippet, selector) {
pickerActive = false;
shield.classList.remove('active');
toast.classList.remove('active');
if (pickerHighlighted) {
pickerHighlighted.classList.remove('_fb-hover-highlight');
pickerHighlighted = null;
}
overlay.classList.add('open');
if (snippet) {
var num = nextPickNum++;
pickedElements[num] = { selector: selector, snippet: snippet };
addPickedChip(num, selector, snippet);
// Insert reference token at cursor in the textarea
var desc = document.getElementById('_fb-desc');
var token = '[element ' + num + ']';
insertAtCursor(desc, token);
desc.focus();
}
}
document.getElementById('_fb-pick-btn').addEventListener('click', function() {
activatePicker();
});
// Mousemove on page during picker: highlight hovered element
document.addEventListener('mousemove', function(e) {
if (!pickerActive) return;
// Temporarily hide the shield so elementFromPoint reaches actual page elements
shield.style.visibility = 'hidden';
var el = document.elementFromPoint(e.clientX, e.clientY);
shield.style.visibility = 'visible';
// Skip our own UI elements
if (!el || el.closest('#_fb-picker-toast')) return;
if (pickerHighlighted && pickerHighlighted !== el) {
pickerHighlighted.classList.remove('_fb-hover-highlight');
}
pickerHighlighted = el;
el.classList.add('_fb-hover-highlight');
});
// Click during picker: capture element
shield.addEventListener('click', function(e) {
if (!pickerActive) return;
e.preventDefault(); e.stopPropagation();
// Find element underneath the shield
shield.style.display = 'none';
var el = document.elementFromPoint(e.clientX, e.clientY);
shield.style.display = '';
if (el && !el.closest('#_fb-picker-toast')) {
el.classList.remove('_fb-hover-highlight');
deactivatePicker(getSnippet(el), getSelector(el));
} else {
deactivatePicker(null, null);
}
});
// Esc cancels picker
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && pickerActive) deactivatePicker(null, null);
});
// ── Submit ────────────────────────────────────────────────────────────────
document.getElementById('_fb-submit').addEventListener('click', function() {
var title = document.getElementById('_fb-title').value.trim();
var body = document.getElementById('_fb-desc').value.trim();
var assignee = document.getElementById('_fb-assignee').value;
var status = document.getElementById('_fb-status');
var btn = document.getElementById('_fb-submit');
if (!title) { status.style.color='#f57c7c'; status.textContent='Title is required.'; return; }
btn.disabled = true; status.style.color='#888'; status.textContent='Submitting…';
// Append element snippets as a reference block
var snippetBlock = '';
Object.keys(pickedElements).forEach(function(num) {
var pe = pickedElements[num];
snippetBlock += '\\n\\n**[element ' + num + ']** \`' + pe.selector + '\`\\n\`\`\`html\\n' + pe.snippet + '\\n\`\`\`';
});
var fullBody = body + snippetBlock;
fetch(SELF + '/api/feedback', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, body: fullBody, repo: cfg.repo, source_url: window.location.href, assignee: assignee || undefined })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) {
status.style.color='#6fcf6f';
status.textContent='✓ Issue #' + d.issue_number + ' created!';
document.getElementById('_fb-title').value = '';
document.getElementById('_fb-desc').value = '';
document.getElementById('_fb-assignee').value = '';
document.getElementById('_fb-picked-list').innerHTML = '';
nextPickNum = 1;
pickedElements = {};
btn.disabled = false;
setTimeout(function() { status.textContent=''; }, 3000);
} else {
status.style.color='#f57c7c'; status.textContent='Error: ' + (d.error||'unknown'); btn.disabled=false;
}
}).catch(function() { status.style.color='#f57c7c'; status.textContent='Network error.'; btn.disabled=false; });
});
// ── Open issues tab ───────────────────────────────────────────────────────
function loadIssues() {
var list = document.getElementById('_fb-issues-list');
list.innerHTML = '<div class="fb-loading">Loading…</div>';
fetch(SELF + '/api/issues?repo=' + encodeURIComponent(cfg.repo))
.then(function(r) { return r.json(); })
.then(function(issues) {
if (!issues.length) { list.innerHTML = '<div class="fb-empty">No open issues 🎉</div>'; return; }
list.innerHTML = '';
issues.forEach(function(issue) {
var a = document.createElement('a');
a.className = 'fb-issue-link';
a.href = issue.url;
a.target = '_blank';
a.rel = 'noopener';
a.innerHTML = \`<span class="fb-issue-num">#\${issue.number}</span><span style="flex:1">\${esc(issue.title)}</span><span class="fb-issue-arrow">↗</span>\`;
list.appendChild(a);
});
}).catch(function() { list.innerHTML = '<div class="fb-empty">Failed to load issues.</div>'; });
}
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
})();`);
});
app.get([BASE, BASE + '/'], (req, res) => res.redirect('https://dev.bl.pixeldev.eu'));
app.listen(PORT, '127.0.0.1', () => console.log(`feedback-tool on :${PORT} at ${BASE}`));