incognitolm commited on
Commit
20dd80a
·
1 Parent(s): 41ab18b

Feedback System

Browse files
Files changed (2) hide show
  1. server/handleFeedback.js +162 -0
  2. server/index.js +9 -0
server/handleFeedback.js ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import crypto from 'crypto';
3
+ import { saveEncryptedJson, loadEncryptedJson } from './cryptoUtils.js';
4
+
5
+ const DATA_DIR = '/data';
6
+ const TICKETS_FILE = path.join(DATA_DIR, 'feedback_tickets.json');
7
+ const FEEDBACK_AAD = 'feedback_tickets_v1';
8
+
9
+ const MAX_TITLE_LENGTH = 100;
10
+ const MAX_BODY_LENGTH = 10000;
11
+
12
+ function generateTicketId() {
13
+ return `tkt_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
14
+ }
15
+
16
+ function sanitizeString(value, maxLength) {
17
+ if (typeof value !== 'string') return '';
18
+ return value.trim().slice(0, maxLength);
19
+ }
20
+
21
+ async function loadTickets() {
22
+ const data = await loadEncryptedJson(TICKETS_FILE, FEEDBACK_AAD);
23
+ if (!data || !Array.isArray(data.tickets)) return { tickets: [] };
24
+ return data;
25
+ }
26
+
27
+ async function saveTickets(data) {
28
+ await saveEncryptedJson(TICKETS_FILE, data, FEEDBACK_AAD);
29
+ }
30
+
31
+ export function registerFeedbackRoutes(app, { requireAdminTurnstile, verifyLimiter, logAdminEvent, ADMIN_TOKEN, getRequestIp }) {
32
+
33
+ // POST /api/feedback/submit — public, no auth required
34
+ app.post('/api/feedback/submit', async (req, res) => {
35
+ try {
36
+ const type = req.body?.type === 'improvement' ? 'improvement' : 'issue';
37
+ const environment = type === 'issue'
38
+ ? (req.body?.environment === 'beta' ? 'beta' : 'production')
39
+ : null;
40
+
41
+ const title = sanitizeString(req.body?.title, MAX_TITLE_LENGTH);
42
+ const body = sanitizeString(req.body?.body, MAX_BODY_LENGTH);
43
+
44
+ if (!title) {
45
+ return res.status(400).json({ error: 'feedback:title_required', message: 'A title is required.' });
46
+ }
47
+ if (!body) {
48
+ return res.status(400).json({ error: 'feedback:body_required', message: 'A description is required.' });
49
+ }
50
+
51
+ const ticket = {
52
+ id: generateTicketId(),
53
+ type,
54
+ environment,
55
+ title,
56
+ body,
57
+ status: 'open',
58
+ submittedAt: new Date().toISOString(),
59
+ resolvedAt: null,
60
+ ip: getRequestIp(req),
61
+ userAgent: (req.headers['user-agent'] || '').slice(0, 200),
62
+ };
63
+
64
+ const data = await loadTickets();
65
+ data.tickets.push(ticket);
66
+ await saveTickets(data);
67
+
68
+ console.log(`[FEEDBACK] New ${type} ticket submitted: "${title.slice(0, 60)}" id=${ticket.id}`);
69
+
70
+ return res.json({ success: true, id: ticket.id });
71
+ } catch (err) {
72
+ console.error('feedback submit error', err);
73
+ return res.status(500).json({ error: 'feedback:server_error', message: 'Failed to submit ticket.' });
74
+ }
75
+ });
76
+
77
+ // GET /admin/feedback — list tickets, requires admin auth + turnstile
78
+ app.get('/admin/feedback', requireAdminTurnstile, verifyLimiter, async (req, res) => {
79
+ const token = req.query.token;
80
+ if (token !== ADMIN_TOKEN) {
81
+ logAdminEvent(req, 'feedback_list_denied', { reason: 'bad_token' });
82
+ return res.status(403).json({ error: 'Forbidden' });
83
+ }
84
+
85
+ try {
86
+ const data = await loadTickets();
87
+ const tickets = data.tickets.map(t => ({
88
+ id: t.id,
89
+ type: t.type,
90
+ environment: t.environment,
91
+ title: t.title,
92
+ body: t.body,
93
+ status: t.status,
94
+ submittedAt: t.submittedAt,
95
+ resolvedAt: t.resolvedAt,
96
+ }));
97
+ logAdminEvent(req, 'feedback_list_view', { count: tickets.length });
98
+ return res.json({ tickets });
99
+ } catch (err) {
100
+ console.error('feedback list error', err);
101
+ return res.status(500).json({ error: 'feedback:server_error' });
102
+ }
103
+ });
104
+
105
+ // POST /admin/feedback/:id/resolve — mark a ticket resolved
106
+ app.post('/admin/feedback/:id/resolve', requireAdminTurnstile, verifyLimiter, async (req, res) => {
107
+ const token = req.body?.token || req.query.token;
108
+ if (token !== ADMIN_TOKEN) {
109
+ logAdminEvent(req, 'feedback_resolve_denied', { reason: 'bad_token', ticketId: req.params.id });
110
+ return res.status(403).json({ error: 'Forbidden' });
111
+ }
112
+
113
+ try {
114
+ const data = await loadTickets();
115
+ const ticket = data.tickets.find(t => t.id === req.params.id);
116
+ if (!ticket) {
117
+ return res.status(404).json({ error: 'feedback:not_found' });
118
+ }
119
+
120
+ ticket.status = 'resolved';
121
+ ticket.resolvedAt = new Date().toISOString();
122
+ await saveTickets(data);
123
+
124
+ logAdminEvent(req, 'feedback_resolved', { ticketId: ticket.id, title: ticket.title.slice(0, 60) });
125
+ return res.json({ success: true, ticket: {
126
+ id: ticket.id, status: ticket.status, resolvedAt: ticket.resolvedAt
127
+ }});
128
+ } catch (err) {
129
+ console.error('feedback resolve error', err);
130
+ return res.status(500).json({ error: 'feedback:server_error' });
131
+ }
132
+ });
133
+
134
+ // POST /admin/feedback/:id/reopen — reopen a resolved ticket
135
+ app.post('/admin/feedback/:id/reopen', requireAdminTurnstile, verifyLimiter, async (req, res) => {
136
+ const token = req.body?.token || req.query.token;
137
+ if (token !== ADMIN_TOKEN) {
138
+ logAdminEvent(req, 'feedback_reopen_denied', { reason: 'bad_token', ticketId: req.params.id });
139
+ return res.status(403).json({ error: 'Forbidden' });
140
+ }
141
+
142
+ try {
143
+ const data = await loadTickets();
144
+ const ticket = data.tickets.find(t => t.id === req.params.id);
145
+ if (!ticket) {
146
+ return res.status(404).json({ error: 'feedback:not_found' });
147
+ }
148
+
149
+ ticket.status = 'open';
150
+ ticket.resolvedAt = null;
151
+ await saveTickets(data);
152
+
153
+ logAdminEvent(req, 'feedback_reopened', { ticketId: ticket.id });
154
+ return res.json({ success: true, ticket: {
155
+ id: ticket.id, status: ticket.status, resolvedAt: ticket.resolvedAt
156
+ }});
157
+ } catch (err) {
158
+ console.error('feedback reopen error', err);
159
+ return res.status(500).json({ error: 'feedback:server_error' });
160
+ }
161
+ });
162
+ }
server/index.js CHANGED
@@ -33,6 +33,15 @@ let latestSHA = null;
33
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
34
  const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '0x4AAAAAAC1ZXKIhZ9Kdz8j9';
35
  const MAX_TEXT_UPLOAD_BYTES = 100 * 1024;
 
 
 
 
 
 
 
 
 
36
  const LOCAL_UI_DIR = [
37
  process.env.UI_LOCAL_PATH,
38
  path.resolve(__dirname, '..', '..', 'InferencePort-Pages'),
 
33
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
34
  const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '0x4AAAAAAC1ZXKIhZ9Kdz8j9';
35
  const MAX_TEXT_UPLOAD_BYTES = 100 * 1024;
36
+
37
+ registerFeedbackRoutes(app, {
38
+ requireAdminTurnstile,
39
+ verifyLimiter,
40
+ logAdminEvent,
41
+ ADMIN_TOKEN,
42
+ getRequestIp,
43
+ });
44
+
45
  const LOCAL_UI_DIR = [
46
  process.env.UI_LOCAL_PATH,
47
  path.resolve(__dirname, '..', '..', 'InferencePort-Pages'),