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-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; } /* 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 = \`