- 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.
268 lines
9.9 KiB
JavaScript
268 lines
9.9 KiB
JavaScript
// 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();
|
|
|
|
const GITLAB_URL = process.env.GITLAB_URL || '';
|
|
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID || '';
|
|
const GITLAB_PAT = process.env.GITLAB_PAT || '';
|
|
|
|
/**
|
|
* 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`;
|
|
|
|
try {
|
|
const parsed = new URL(apiUrl);
|
|
const transport = parsed.protocol === 'https:' ? https : http;
|
|
|
|
const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
|
|
const fileContent = fs.readFileSync(filePath);
|
|
|
|
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 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 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
const reqOpts = {
|
|
method: 'POST',
|
|
hostname: parsed.hostname,
|
|
port: parsed.port,
|
|
path: parsed.pathname + parsed.search,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'PRIVATE-TOKEN': GITLAB_PAT,
|
|
'Content-Length': Buffer.byteLength(postData),
|
|
},
|
|
rejectAuthorized: false,
|
|
};
|
|
|
|
const apiReq = transport.request(reqOpts, (apiRes) => {
|
|
let data = '';
|
|
apiRes.on('data', chunk => data += chunk);
|
|
apiRes.on('end', () => {
|
|
try {
|
|
resolve({ status: apiRes.statusCode, body: JSON.parse(data) });
|
|
} catch {
|
|
resolve({ status: apiRes.statusCode, body: data });
|
|
}
|
|
});
|
|
});
|
|
|
|
apiReq.on('error', reject);
|
|
apiReq.write(postData);
|
|
apiReq.end();
|
|
});
|
|
|
|
if (result.status === 201) {
|
|
console.log(`[Feedback] Issue #${result.body.iid} created by ${username}: ${title}`);
|
|
res.json({
|
|
success: true,
|
|
issue: {
|
|
id: result.body.iid,
|
|
url: result.body.web_url,
|
|
title: result.body.title,
|
|
},
|
|
});
|
|
} else {
|
|
console.error(`[Feedback] GitLab API returned ${result.status}:`, result.body);
|
|
res.status(502).json({ error: 'GitLab API error', details: result.body });
|
|
}
|
|
} 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);
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
module.exports = createFeedbackRouter;
|