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.
This commit is contained in:
Jordan Ramos
2026-05-18 16:54:00 -06:00
parent 520f50fbbf
commit 00bf92a2a1
6 changed files with 470 additions and 37 deletions

View File

@@ -74,3 +74,12 @@ DATABASE_URL=postgresql://steam:<password>@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=

View File

@@ -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 };

View File

@@ -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);
}
});

View File

@@ -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;

View File

@@ -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)

View File

@@ -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 (
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
{files.map((file, idx) => (
<div
key={idx}
style={{
position: 'relative',
width: '64px', height: '64px',
borderRadius: '0.375rem',
overflow: 'hidden',
border: '1px solid rgba(14,165,233,0.2)',
background: 'rgba(14,165,233,0.06)',
}}
>
<img
src={URL.createObjectURL(file)}
alt={file.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<button
type="button"
onClick={() => onRemove(idx)}
style={{
position: 'absolute', top: '2px', right: '2px',
width: '18px', height: '18px',
borderRadius: '50%',
background: 'rgba(239,68,68,0.9)',
border: 'none',
cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0,
}}
title="Remove screenshot"
>
<Trash2 style={{ width: 10, height: 10, color: '#fff' }} />
</button>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// 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
/>
</div>
{/* Screenshots */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Screenshots (optional, max 3)</label>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={screenshots.length >= MAX_FILES}
style={{
...btnStyle,
background: 'rgba(14,165,233,0.06)',
border: '1px solid rgba(14,165,233,0.2)',
color: screenshots.length >= MAX_FILES ? '#475569' : '#94A3B8',
cursor: screenshots.length >= MAX_FILES ? 'not-allowed' : 'pointer',
padding: '0.5rem 0.75rem',
fontSize: '0.72rem',
}}
>
<Image style={{ width: 14, height: 14 }} />
{screenshots.length >= MAX_FILES ? 'Max reached' : 'Attach images'}
</button>
<span style={{
fontSize: '0.65rem', color: '#475569', marginLeft: '0.5rem',
fontFamily: "'JetBrains Mono', monospace",
}}>
PNG, JPG, GIF, WebP 5MB each
</span>
<ScreenshotPreviews files={screenshots} onRemove={handleRemoveScreenshot} />
</div>
{/* Current page indicator */}
{currentPage && (
<div style={{