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