Spaces:
Runtime error
Runtime error
| import path from 'path'; | |
| import crypto from 'crypto'; | |
| import { saveEncryptedJson, loadEncryptedJson } from './cryptoUtils.js'; | |
| import { isPostgresStorageMode } from './dataPaths.js'; | |
| import { decryptJsonPayload, encryptJsonPayload, pgQuery } from './postgres.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() { | |
| if (isPostgresStorageMode()) { | |
| const { rows } = await pgQuery( | |
| 'SELECT id, payload FROM feedback_tickets ORDER BY submitted_at DESC' | |
| ); | |
| return { | |
| tickets: rows | |
| .map((row) => decryptJsonPayload(row.payload, `feedback:${row.id}`)) | |
| .filter(Boolean), | |
| }; | |
| } | |
| const data = await loadEncryptedJson(TICKETS_FILE, FEEDBACK_AAD); | |
| if (!data || !Array.isArray(data.tickets)) return { tickets: [] }; | |
| return data; | |
| } | |
| async function saveTickets(data) { | |
| if (isPostgresStorageMode()) { | |
| const tickets = Array.isArray(data?.tickets) ? data.tickets : []; | |
| const seenIds = tickets.map((ticket) => ticket.id); | |
| if (!seenIds.length) { | |
| await pgQuery('DELETE FROM feedback_tickets'); | |
| return; | |
| } | |
| await pgQuery('DELETE FROM feedback_tickets WHERE id <> ALL($1::text[])', [seenIds]); | |
| for (const ticket of tickets) { | |
| await pgQuery( | |
| `INSERT INTO feedback_tickets (id, status, submitted_at, payload) | |
| VALUES ($1, $2, $3, $4::jsonb) | |
| ON CONFLICT (id) | |
| DO UPDATE SET status = EXCLUDED.status, submitted_at = EXCLUDED.submitted_at, payload = EXCLUDED.payload`, | |
| [ | |
| ticket.id, | |
| ticket.status || 'open', | |
| ticket.submittedAt, | |
| JSON.stringify(encryptJsonPayload(ticket, `feedback:${ticket.id}`)), | |
| ] | |
| ); | |
| } | |
| return; | |
| } | |
| await saveEncryptedJson(TICKETS_FILE, data, FEEDBACK_AAD); | |
| } | |
| export function registerFeedbackRoutes(app, { requireAdminTurnstile, verifyLimiter, logAdminEvent, ADMIN_TOKEN, getRequestIp }) { | |
| // POST /api/feedback/submit — public, no auth required | |
| 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.' }); | |
| } | |
| }); | |
| // GET /admin/feedback — list tickets, requires admin auth + turnstile | |
| 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' }); | |
| } | |
| }); | |
| // POST /admin/feedback/:id/resolve — mark a ticket resolved | |
| 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' }); | |
| } | |
| }); | |
| // POST /admin/feedback/:id/reopen — reopen a resolved ticket | |
| 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' }); | |
| } | |
| }); | |
| } | |