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 }); } }); // POST /api/feedback — create new issue app.post(['/api/feedback', BASE + '/api/feedback'], async (req, res) => { const { title, body, repo, source_url } = 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'); try { const issue = await giteaRequest('POST', `/api/v1/repos/${repo}/issues`, { title, body: issueBody }); 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-item { background:#0d0d0d; border:1px solid #222; border-radius:10px; padding:.9rem 1rem; } .fb-issue-title { color:#e0e0e0; font-size:.9rem; font-weight:600; margin:0 0 .3rem; } .fb-issue-meta { color:#555; font-size:.75rem; margin-bottom:.6rem; } .fb-issue-body { color:#888; font-size:.8rem; margin-bottom:.7rem; white-space:pre-wrap; word-break:break-word; } .fb-comment-area { display:flex; flex-direction:column; gap:.5rem; } .fb-comment-input { min-height:55px; } .fb-comment-btn { background:#2a2a2a; color:#ccc; font-size:.8rem; padding:.45rem .8rem; width:auto; align-self:flex-end; border-radius:6px; } .fb-comment-btn:hover { background:#333; opacity:1; } .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; } /* 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; } .fb-picker-hint { color:#444; font-size:.75rem; } .fb-snippet-preview { background:#0a0a0a; border:1px solid #2a2a2a; border-radius:6px; padding:.5rem .7rem; font-size:.72rem; color:#7c9ef5; font-family:monospace; white-space:pre-wrap; word-break:break-all; max-height:80px; overflow-y:auto; } /* 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 = \`

💬 Feedback

Click to reference a page element
\`; document.body.appendChild(overlay); // ── Open / close ────────────────────────────────────────────────────────── function openOverlay() { overlay.classList.add('open'); } 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 capturedSnippet = ''; 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'; } return html; } function activatePicker() { pickerActive = true; overlay.classList.remove('open'); shield.classList.add('active'); toast.classList.add('active'); } function deactivatePicker(snippet) { 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) { capturedSnippet = snippet; document.getElementById('_fb-snippet').textContent = snippet; document.getElementById('_fb-snippet-wrap').style.display = ''; document.getElementById('_fb-pick-hint').textContent = '✓ Element captured — you can pick again'; // Append to description var desc = document.getElementById('_fb-desc'); var tag = '\\n\\n**Picked element:**\\n\`\`\`html\\n' + snippet + '\\n\`\`\`'; if (!desc.value.includes(tag)) desc.value += tag; } } 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)); } else { deactivatePicker(null); } }); // Esc cancels picker document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && pickerActive) deactivatePicker(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 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…'; fetch(SELF + '/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, body, repo: cfg.repo, source_url: window.location.href }) }).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-snippet-wrap').style.display = 'none'; document.getElementById('_fb-pick-hint').textContent = 'Click to reference a page element'; capturedSnippet = ''; 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 = '
Loading…
'; fetch(SELF + '/api/issues?repo=' + encodeURIComponent(cfg.repo)) .then(function(r) { return r.json(); }) .then(function(issues) { if (!issues.length) { list.innerHTML = '
No open issues 🎉
'; return; } list.innerHTML = ''; issues.forEach(function(issue) { var el = document.createElement('div'); el.className = 'fb-issue-item'; var preview = (issue.body || '').replace(/---[\\s\\S]*$/, '').trim().slice(0, 150); el.innerHTML = \`

#\${issue.number} \${esc(issue.title)}

\${issue.comments} comment\${issue.comments!==1?'s':''} · view on Gitea ↗

\${preview ? '

' + esc(preview) + '

' : ''}
\`; list.appendChild(el); el.querySelector('.fb-comment-btn').addEventListener('click', function() { var num = this.dataset.issue; var ta = el.querySelector('textarea[data-issue="'+num+'"]'); var body = ta.value.trim(); if (!body) return; var btn = this; btn.disabled = true; btn.textContent = 'Posting…'; fetch(SELF + '/api/comment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: cfg.repo, issue_number: num, body }) }).then(function(r) { return r.json(); }).then(function(d) { if (d.ok) { ta.value=''; btn.textContent='✓ Posted!'; setTimeout(function(){ btn.textContent='Comment'; btn.disabled=false; }, 2000); } else { btn.textContent='Error'; btn.disabled=false; } }).catch(function() { btn.textContent='Error'; btn.disabled=false; }); }); }); }).catch(function() { list.innerHTML = '
Failed to load issues.
'; }); } function esc(s) { return s.replace(/&/g,'&').replace(//g,'>'); } })();`); }); 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}`));