From 00bf92a2a1388dcdb52de5f2f24ad4ec658c296f Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Mon, 18 May 2026 16:54:00 -0600 Subject: [PATCH] Add screenshot uploads to feedback modal, Webex bot DM on issue close - Feedback modal now supports up to 3 image attachments (PNG/JPG/GIF/WebP, 5MB each) with thumbnail previews. Images are uploaded to GitLab project uploads and embedded as markdown in the issue description. - New webhook endpoint (POST /api/webhooks/gitlab) receives issue close events, parses the submitter from the description, looks up their email, and sends a Webex DM via the Patches O'Houlihan bot. - New helper: backend/helpers/webexBot.js (fire-and-forget DM sender). - Requires WEBEX_BOT_TOKEN and GITLAB_WEBHOOK_SECRET in backend/.env. --- backend/.env.example | 9 + backend/helpers/webexBot.js | 64 +++++++ backend/routes/feedback.js | 212 ++++++++++++++++++++--- backend/routes/webhooks.js | 73 ++++++++ backend/server.js | 4 + frontend/src/components/FeedbackModal.js | 145 +++++++++++++++- 6 files changed, 470 insertions(+), 37 deletions(-) create mode 100644 backend/helpers/webexBot.js create mode 100644 backend/routes/webhooks.js diff --git a/backend/.env.example b/backend/.env.example index 2700bcc..a0dfc1e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -74,3 +74,12 @@ DATABASE_URL=postgresql://steam:@localhost:5433/cve_dashboard GITLAB_URL=http://steam-gitlab.charterlab.com GITLAB_PROJECT_ID= GITLAB_PAT= + +# GitLab Webhook Secret — shared secret for validating incoming webhook requests. +# Set this same value in GitLab project > Settings > Webhooks > Secret Token. +# Generate with: openssl rand -hex 20 +GITLAB_WEBHOOK_SECRET=changeme_generate_a_random_secret + +# Webex Bot — used to DM users when their feedback issues are resolved. +# Create a bot at https://developer.webex.com/my-apps and use its access token. +WEBEX_BOT_TOKEN= diff --git a/backend/helpers/webexBot.js b/backend/helpers/webexBot.js new file mode 100644 index 0000000..f695a19 --- /dev/null +++ b/backend/helpers/webexBot.js @@ -0,0 +1,64 @@ +// Webex Bot DM Helper +// Fire-and-forget pattern — logs success/failure but never throws. +// Used to notify users when their feedback issues are resolved. + +const https = require('https'); + +const WEBEX_API_URL = 'https://webexapis.com/v1/messages'; +const WEBEX_BOT_TOKEN = process.env.WEBEX_BOT_TOKEN || ''; + +/** + * Send a direct message to a user via Webex Teams bot. + * @param {string} email - Recipient's email address + * @param {string} markdownMessage - Message body (Webex markdown supported) + */ +function sendDirectMessage(email, markdownMessage) { + if (!WEBEX_BOT_TOKEN) { + console.warn('[WebexBot] WEBEX_BOT_TOKEN not configured — skipping DM'); + return; + } + + if (!email || !markdownMessage) { + console.warn('[WebexBot] Missing email or message — skipping DM'); + return; + } + + const postData = JSON.stringify({ + toPersonEmail: email, + markdown: markdownMessage, + }); + + const parsed = new URL(WEBEX_API_URL); + + const reqOpts = { + method: 'POST', + hostname: parsed.hostname, + path: parsed.pathname, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${WEBEX_BOT_TOKEN}`, + 'Content-Length': Buffer.byteLength(postData), + }, + }; + + const req = https.request(reqOpts, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + console.log(`[WebexBot] DM sent to ${email} (${res.statusCode})`); + } else { + console.error(`[WebexBot] Failed to DM ${email} — ${res.statusCode}: ${data}`); + } + }); + }); + + req.on('error', (err) => { + console.error(`[WebexBot] Request error sending DM to ${email}:`, err.message); + }); + + req.write(postData); + req.end(); +} + +module.exports = { sendDirectMessage }; diff --git a/backend/routes/feedback.js b/backend/routes/feedback.js index 15a6cdd..bb97795 100644 --- a/backend/routes/feedback.js +++ b/backend/routes/feedback.js @@ -1,11 +1,54 @@ // Feedback route — proxies bug reports and feature requests to GitLab Issues API +// Supports optional screenshot uploads (up to 3 images, 5MB each). // Keeps the GitLab PAT server-side so it's never exposed to the browser. const express = require('express'); const https = require('https'); const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const multer = require('multer'); const { requireAuth } = require('../middleware/auth'); +// Multer setup for screenshot uploads — same temp directory pattern as compliance +const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp'); + +const screenshotStorage = multer.diskStorage({ + destination: (req, file, cb) => { + if (!fs.existsSync(TEMP_DIR)) { + fs.mkdirSync(TEMP_DIR, { recursive: true }); + } + cb(null, TEMP_DIR); + }, + filename: (req, file, cb) => { + const timestamp = Date.now(); + const safeName = file.originalname + .replace(/\0/g, '') + .replace(/\.\./g, '') + .replace(/[\/\\]/g, '') + .replace(/[^a-zA-Z0-9._-]/g, '_') + .trim(); + cb(null, `${timestamp}-${safeName}`); + }, +}); + +const ALLOWED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +const screenshotUpload = multer({ + storage: screenshotStorage, + fileFilter: (req, file, cb) => { + if (!ALLOWED_IMAGE_TYPES.has(file.mimetype)) { + return cb(new Error(`File type '${file.mimetype}' not allowed. Only PNG, JPG, GIF, and WebP images are accepted.`)); + } + cb(null, true); + }, + limits: { + fileSize: MAX_FILE_SIZE, + files: 3, + }, +}); + function createFeedbackRouter() { const router = express.Router(); @@ -13,44 +56,154 @@ function createFeedbackRouter() { const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || ''; const GITLAB_PAT = process.env.GITLAB_PAT || ''; - router.post('/', requireAuth(), async (req, res) => { - if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) { - return res.status(503).json({ error: 'Feedback integration not configured' }); - } + /** + * Upload a single file to GitLab's project uploads API. + * Returns { markdown, url } on success, null on failure. + */ + function uploadFileToGitlab(filePath, fileName) { + return new Promise((resolve) => { + const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/uploads`; - const { type, title, description, page } = req.body; + try { + const parsed = new URL(apiUrl); + const transport = parsed.protocol === 'https:' ? https : http; - if (!type || !title || !description) { - return res.status(400).json({ error: 'type, title, and description are required' }); - } + const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`; + const fileContent = fs.readFileSync(filePath); - if (!['bug', 'feature'].includes(type)) { - return res.status(400).json({ error: 'type must be "bug" or "feature"' }); - } + const header = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` + + `Content-Type: application/octet-stream\r\n\r\n` + ); + const footer = Buffer.from(`\r\n--${boundary}--\r\n`); + const body = Buffer.concat([header, fileContent, footer]); - const labels = type === 'bug' ? 'bug' : 'enhancement'; - const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request'; - const username = req.user?.username || 'unknown'; + const reqOpts = { + method: 'POST', + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'PRIVATE-TOKEN': GITLAB_PAT, + 'Content-Length': body.length, + }, + rejectAuthorized: false, + }; - const body = [ - `**Submitted by:** ${username}`, - page ? `**Page:** ${page}` : null, - `**Type:** ${prefix}`, - '', - '---', - '', - description, - ].filter(Boolean).join('\n'); + const apiReq = transport.request(reqOpts, (apiRes) => { + let data = ''; + apiRes.on('data', chunk => data += chunk); + apiRes.on('end', () => { + try { + const result = JSON.parse(data); + if (apiRes.statusCode === 201 && result.markdown) { + resolve(result); + } else { + console.error(`[Feedback] GitLab upload returned ${apiRes.statusCode}:`, data); + resolve(null); + } + } catch { + console.error('[Feedback] GitLab upload returned invalid JSON:', data); + resolve(null); + } + }); + }); - const postData = JSON.stringify({ - title: `[${prefix}] ${title}`, - description: body, - labels, + apiReq.on('error', (err) => { + console.error('[Feedback] GitLab upload request error:', err.message); + resolve(null); + }); + + apiReq.write(body); + apiReq.end(); + } catch (err) { + console.error('[Feedback] GitLab upload error:', err.message); + resolve(null); + } }); + } - const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`; + /** + * Clean up temp files after processing. + */ + function cleanupFiles(files) { + for (const file of files) { + try { + if (fs.existsSync(file.path)) { + fs.unlinkSync(file.path); + } + } catch (err) { + console.error(`[Feedback] Failed to clean up temp file ${file.path}:`, err.message); + } + } + } + + router.post('/', requireAuth(), screenshotUpload.array('screenshots', 3), async (req, res) => { + const uploadedFiles = req.files || []; try { + if (!GITLAB_URL || !GITLAB_PROJECT_ID || !GITLAB_PAT) { + return res.status(503).json({ error: 'Feedback integration not configured' }); + } + + const { type, title, description, page } = req.body; + + if (!type || !title || !description) { + return res.status(400).json({ error: 'type, title, and description are required' }); + } + + if (!['bug', 'feature'].includes(type)) { + return res.status(400).json({ error: 'type must be "bug" or "feature"' }); + } + + const labels = type === 'bug' ? 'bug' : 'enhancement'; + const prefix = type === 'bug' ? '🐛 Bug' : '✨ Feature Request'; + const username = req.user?.username || 'unknown'; + + // Upload screenshots to GitLab and collect markdown links + const imageMarkdowns = []; + for (const file of uploadedFiles) { + const result = await uploadFileToGitlab(file.path, file.originalname); + if (result && result.markdown) { + imageMarkdowns.push(result.markdown); + } + } + + const bodyParts = [ + `**Submitted by:** ${username}`, + page ? `**Page:** ${page}` : null, + `**Type:** ${prefix}`, + '', + '---', + '', + description, + ].filter(Boolean); + + // Append screenshot markdown at the bottom + if (imageMarkdowns.length > 0) { + bodyParts.push(''); + bodyParts.push('---'); + bodyParts.push(''); + bodyParts.push('**Screenshots:**'); + bodyParts.push(''); + for (const md of imageMarkdowns) { + bodyParts.push(md); + bodyParts.push(''); + } + } + + const body = bodyParts.join('\n'); + + const postData = JSON.stringify({ + title: `[${prefix}] ${title}`, + description: body, + labels, + }); + + const apiUrl = `${GITLAB_URL.replace(/\/$/, '')}/api/v4/projects/${encodeURIComponent(GITLAB_PROJECT_ID)}/issues`; + const result = await new Promise((resolve, reject) => { const parsed = new URL(apiUrl); const transport = parsed.protocol === 'https:' ? https : http; @@ -102,6 +255,9 @@ function createFeedbackRouter() { } catch (err) { console.error('[Feedback] Request failed:', err.message); res.status(502).json({ error: 'Failed to connect to GitLab' }); + } finally { + // Always clean up temp files + cleanupFiles(uploadedFiles); } }); diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js new file mode 100644 index 0000000..11270b8 --- /dev/null +++ b/backend/routes/webhooks.js @@ -0,0 +1,73 @@ +// GitLab Webhook Routes — receives issue lifecycle events from GitLab +// Used to notify users via Webex when their feedback issues are closed. + +const express = require('express'); +const pool = require('../db'); +const { sendDirectMessage } = require('../helpers/webexBot'); + +const GITLAB_WEBHOOK_SECRET = process.env.GITLAB_WEBHOOK_SECRET || ''; + +function createWebhooksRouter() { + const router = express.Router(); + + // POST /api/webhooks/gitlab — GitLab issue webhook receiver + router.post('/gitlab', express.json(), async (req, res) => { + // Always return 200 — webhooks should not retry on app-level failures + try { + // Validate webhook secret token + const token = req.headers['x-gitlab-token']; + if (!GITLAB_WEBHOOK_SECRET || token !== GITLAB_WEBHOOK_SECRET) { + console.warn('[Webhook] Invalid or missing X-Gitlab-Token'); + return res.status(200).json({ status: 'ignored', reason: 'invalid token' }); + } + + const { object_attributes } = req.body || {}; + + // Only process issue close events + if (!object_attributes || object_attributes.action !== 'close') { + return res.status(200).json({ status: 'ignored', reason: 'not a close event' }); + } + + const issueTitle = object_attributes.title || 'Untitled'; + const issueNumber = object_attributes.iid; + const description = object_attributes.description || ''; + + // Parse submitter username from issue description + // Format: **Submitted by:** username + const submitterMatch = description.match(/\*\*Submitted by:\*\*\s*(\S+)/); + if (!submitterMatch) { + console.log('[Webhook] No submitter found in issue description — skipping DM'); + return res.status(200).json({ status: 'ignored', reason: 'no submitter in description' }); + } + + const username = submitterMatch[1]; + + // Look up user email in database + const { rows } = await pool.query( + 'SELECT email FROM users WHERE username = $1', + [username] + ); + + if (!rows || rows.length === 0 || !rows[0].email) { + console.log(`[Webhook] No email found for user "${username}" — skipping DM`); + return res.status(200).json({ status: 'ignored', reason: 'user email not found' }); + } + + const email = rows[0].email; + + // Send Webex DM notification + const message = `Hey! Your bug report **${issueTitle}** (Issue #${issueNumber}) has been resolved and deployed. — Patches O'Houlihan`; + sendDirectMessage(email, message); + + console.log(`[Webhook] Issue #${issueNumber} closed — notified ${username} (${email})`); + return res.status(200).json({ status: 'ok', notified: username }); + } catch (err) { + console.error('[Webhook] Error processing GitLab webhook:', err.message); + return res.status(200).json({ status: 'error', message: err.message }); + } + }); + + return router; +} + +module.exports = createWebhooksRouter; diff --git a/backend/server.js b/backend/server.js index e1e3919..94613c9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -33,6 +33,7 @@ const createAtlasRouter = require('./routes/atlas'); const createJiraTicketsRouter = require('./routes/jiraTickets'); const createCardApiRouter = require('./routes/cardApi'); const createFeedbackRouter = require('./routes/feedback'); +const createWebhooksRouter = require('./routes/webhooks'); const app = express(); const PORT = process.env.PORT || 3001; @@ -230,6 +231,9 @@ app.use('/api/card', createCardApiRouter()); // Feedback routes — bug reports and feature requests to GitLab app.use('/api/feedback', createFeedbackRouter()); +// GitLab webhook routes — receives issue lifecycle events (no auth required) +app.use('/api/webhooks', createWebhooksRouter()); + // ========== CVE ENDPOINTS ========== // Get all CVEs with optional filters (authenticated users) diff --git a/frontend/src/components/FeedbackModal.js b/frontend/src/components/FeedbackModal.js index 497967d..4a2a9b0 100644 --- a/frontend/src/components/FeedbackModal.js +++ b/frontend/src/components/FeedbackModal.js @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { X, Bug, Lightbulb, Send, Loader, CheckCircle, AlertCircle } from 'lucide-react'; +import React, { useState, useRef } from 'react'; +import { X, Bug, Lightbulb, Send, Loader, CheckCircle, AlertCircle, Image, Trash2 } from 'lucide-react'; // ⚠️ CONVENTION: Use relative API path from REACT_APP_API_BASE only — the hardcoded fallback 'http://localhost:3001/api' is an absolute URL. Other components use just the env var without an absolute fallback. const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -124,19 +124,102 @@ function TypeSelector({ value, onChange }) { ); } +// --------------------------------------------------------------------------- +// Screenshot thumbnails +// --------------------------------------------------------------------------- +function ScreenshotPreviews({ files, onRemove }) { + if (files.length === 0) return null; + + return ( +
+ {files.map((file, idx) => ( +
+ {file.name} + +
+ ))} +
+ ); +} + // --------------------------------------------------------------------------- // Main modal component // --------------------------------------------------------------------------- +const MAX_FILES = 3; +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ACCEPTED_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; + export default function FeedbackModal({ isOpen, onClose, defaultType, currentPage }) { const [type, setType] = useState(defaultType || 'bug'); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); + const [screenshots, setScreenshots] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const fileInputRef = useRef(null); if (!isOpen) return null; + const handleFileChange = (e) => { + const newFiles = Array.from(e.target.files || []); + setError(null); + + // Validate file count + const totalFiles = screenshots.length + newFiles.length; + if (totalFiles > MAX_FILES) { + setError(`Maximum ${MAX_FILES} screenshots allowed`); + e.target.value = ''; + return; + } + + // Validate file sizes + for (const file of newFiles) { + if (file.size > MAX_FILE_SIZE) { + setError(`"${file.name}" exceeds 5MB limit`); + e.target.value = ''; + return; + } + } + + setScreenshots(prev => [...prev, ...newFiles]); + e.target.value = ''; + }; + + const handleRemoveScreenshot = (idx) => { + setScreenshots(prev => prev.filter((_, i) => i !== idx)); + }; + const handleSubmit = async (e) => { e.preventDefault(); if (!title.trim() || !description.trim()) { @@ -147,16 +230,21 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag setSubmitting(true); setError(null); try { + const formData = new FormData(); + formData.append('type', type); + formData.append('title', title.trim()); + formData.append('description', description.trim()); + if (currentPage) { + formData.append('page', currentPage); + } + for (const file of screenshots) { + formData.append('screenshots', file); + } + const res = await fetch(`${API_BASE}/feedback`, { method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type, - title: title.trim(), - description: description.trim(), - page: currentPage || null, - }), + body: formData, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `Submission failed (${res.status})`); @@ -164,6 +252,7 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag setSuccess(data.issue); setTitle(''); setDescription(''); + setScreenshots([]); } catch (err) { setError(err.message); } finally { @@ -176,6 +265,7 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag setSuccess(null); setTitle(''); setDescription(''); + setScreenshots([]); onClose(); }; @@ -293,6 +383,43 @@ export default function FeedbackModal({ isOpen, onClose, defaultType, currentPag /> + {/* Screenshots */} +
+ + + + + PNG, JPG, GIF, WebP — 5MB each + + +
+ {/* Current page indicator */} {currentPage && (