| import path from 'path'; |
| import crypto from 'crypto'; |
| import { saveEncryptedJson, loadEncryptedJson } from './cryptoUtils.js'; |
|
|
| const DATA_DIR = '/data'; |
| const TICKETS_FILE = path.join(DATA_DIR, 'feedback_tickets.json'); |
| const FEEDBACK_AAD = 'feedback_tickets_v1'; |
|
|
| const MAX_TITLE_LENGTH = 100; |
| const MAX_BODY_LENGTH = 10000; |
|
|
| function generateTicketId() { |
| return `tkt_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`; |
| } |
|
|
| function sanitizeString(value, maxLength) { |
| if (typeof value !== 'string') return ''; |
| return value.trim().slice(0, maxLength); |
| } |
|
|
| async function loadTickets() { |
| const data = await loadEncryptedJson(TICKETS_FILE, FEEDBACK_AAD); |
| if (!data || !Array.isArray(data.tickets)) return { tickets: [] }; |
| return data; |
| } |
|
|
| async function saveTickets(data) { |
| await saveEncryptedJson(TICKETS_FILE, data, FEEDBACK_AAD); |
| } |
|
|
| export function registerFeedbackRoutes(app, { requireAdminTurnstile, verifyLimiter, logAdminEvent, ADMIN_TOKEN, getRequestIp }) { |
|
|
| |
| app.post('/api/feedback/submit', async (req, res) => { |
| try { |
| const type = req.body?.type === 'improvement' ? 'improvement' : 'issue'; |
| const environment = type === 'issue' |
| ? (req.body?.environment === 'beta' ? 'beta' : 'production') |
| : null; |
|
|
| const title = sanitizeString(req.body?.title, MAX_TITLE_LENGTH); |
| const body = sanitizeString(req.body?.body, MAX_BODY_LENGTH); |
|
|
| if (!title) { |
| return res.status(400).json({ error: 'feedback:title_required', message: 'A title is required.' }); |
| } |
| if (!body) { |
| return res.status(400).json({ error: 'feedback:body_required', message: 'A description is required.' }); |
| } |
|
|
| const ticket = { |
| id: generateTicketId(), |
| type, |
| environment, |
| title, |
| body, |
| status: 'open', |
| submittedAt: new Date().toISOString(), |
| resolvedAt: null, |
| ip: getRequestIp(req), |
| userAgent: (req.headers['user-agent'] || '').slice(0, 200), |
| }; |
|
|
| const data = await loadTickets(); |
| data.tickets.push(ticket); |
| await saveTickets(data); |
|
|
| console.log(`[FEEDBACK] New ${type} ticket submitted: "${title.slice(0, 60)}" id=${ticket.id}`); |
|
|
| return res.json({ success: true, id: ticket.id }); |
| } catch (err) { |
| console.error('feedback submit error', err); |
| return res.status(500).json({ error: 'feedback:server_error', message: 'Failed to submit ticket.' }); |
| } |
| }); |
|
|
| |
| app.get('/admin/feedback', requireAdminTurnstile, verifyLimiter, async (req, res) => { |
| const token = req.query.token; |
| if (token !== ADMIN_TOKEN) { |
| logAdminEvent(req, 'feedback_list_denied', { reason: 'bad_token' }); |
| return res.status(403).json({ error: 'Forbidden' }); |
| } |
|
|
| try { |
| const data = await loadTickets(); |
| const tickets = data.tickets.map(t => ({ |
| id: t.id, |
| type: t.type, |
| environment: t.environment, |
| title: t.title, |
| body: t.body, |
| status: t.status, |
| submittedAt: t.submittedAt, |
| resolvedAt: t.resolvedAt, |
| })); |
| logAdminEvent(req, 'feedback_list_view', { count: tickets.length }); |
| return res.json({ tickets }); |
| } catch (err) { |
| console.error('feedback list error', err); |
| return res.status(500).json({ error: 'feedback:server_error' }); |
| } |
| }); |
|
|
| |
| app.post('/admin/feedback/:id/resolve', requireAdminTurnstile, verifyLimiter, async (req, res) => { |
| const token = req.body?.token || req.query.token; |
| if (token !== ADMIN_TOKEN) { |
| logAdminEvent(req, 'feedback_resolve_denied', { reason: 'bad_token', ticketId: req.params.id }); |
| return res.status(403).json({ error: 'Forbidden' }); |
| } |
|
|
| try { |
| const data = await loadTickets(); |
| const ticket = data.tickets.find(t => t.id === req.params.id); |
| if (!ticket) { |
| return res.status(404).json({ error: 'feedback:not_found' }); |
| } |
|
|
| ticket.status = 'resolved'; |
| ticket.resolvedAt = new Date().toISOString(); |
| await saveTickets(data); |
|
|
| logAdminEvent(req, 'feedback_resolved', { ticketId: ticket.id, title: ticket.title.slice(0, 60) }); |
| return res.json({ success: true, ticket: { |
| id: ticket.id, status: ticket.status, resolvedAt: ticket.resolvedAt |
| }}); |
| } catch (err) { |
| console.error('feedback resolve error', err); |
| return res.status(500).json({ error: 'feedback:server_error' }); |
| } |
| }); |
|
|
| |
| app.post('/admin/feedback/:id/reopen', requireAdminTurnstile, verifyLimiter, async (req, res) => { |
| const token = req.body?.token || req.query.token; |
| if (token !== ADMIN_TOKEN) { |
| logAdminEvent(req, 'feedback_reopen_denied', { reason: 'bad_token', ticketId: req.params.id }); |
| return res.status(403).json({ error: 'Forbidden' }); |
| } |
|
|
| try { |
| const data = await loadTickets(); |
| const ticket = data.tickets.find(t => t.id === req.params.id); |
| if (!ticket) { |
| return res.status(404).json({ error: 'feedback:not_found' }); |
| } |
|
|
| ticket.status = 'open'; |
| ticket.resolvedAt = null; |
| await saveTickets(data); |
|
|
| logAdminEvent(req, 'feedback_reopened', { ticketId: ticket.id }); |
| return res.json({ success: true, ticket: { |
| id: ticket.id, status: ticket.status, resolvedAt: ticket.resolvedAt |
| }}); |
| } catch (err) { |
| console.error('feedback reopen error', err); |
| return res.status(500).json({ error: 'feedback:server_error' }); |
| } |
| }); |
| } |