Sasha commited on
Commit
f0fe495
·
1 Parent(s): 44dd8cf

feat: implement caching, rate limiter, and security headers (CSP) for decoupled deploy

Browse files
Files changed (4) hide show
  1. netlify.toml +10 -0
  2. server/cache.js +260 -0
  3. server/migrate_to_supabase.js +224 -0
  4. server/server.js +13 -6
netlify.toml CHANGED
@@ -2,3 +2,13 @@
2
  base = "client"
3
  command = "npm run build"
4
  publish = "dist"
 
 
 
 
 
 
 
 
 
 
 
2
  base = "client"
3
  command = "npm run build"
4
  publish = "dist"
5
+
6
+ [[headers]]
7
+ for = "/*"
8
+ [headers.values]
9
+ Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://* http://localhost:*; connect-src 'self' https://crambrodev-winx-prinx-api.hf.space ws://localhost:* http://localhost:* https://*; frame-ancestors 'none';"
10
+ X-Frame-Options = "DENY"
11
+ X-Content-Type-Options = "nosniff"
12
+ Referrer-Policy = "strict-origin-when-cross-origin"
13
+ Permissions-Policy = "geolocation=(), camera=(), microphone=()"
14
+ Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload"
server/cache.js ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * production-ready In-Memory Caching and Rate-Limiting Subsystem
3
+ * Designed to protect the Supabase DB and Express server from spikes in traffic
4
+ */
5
+
6
+ import dotenv from 'dotenv';
7
+ dotenv.config();
8
+
9
+ // Configuration flags
10
+ const IS_CACHE_ENABLED = process.env.CACHE_ENABLED !== 'false';
11
+ const DEFAULT_TTL_MS = (parseInt(process.env.CACHE_DEFAULT_TTL, 10) || 15) * 1000;
12
+
13
+ const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 60;
14
+ const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000;
15
+
16
+ // Metrics tracker
17
+ const metrics = {
18
+ hits: 0,
19
+ misses: 0,
20
+ setCount: 0,
21
+ blockedRequests: 0,
22
+ };
23
+
24
+ // =========================================================================
25
+ // CACHE MANAGER CLASS
26
+ // =========================================================================
27
+ class CacheManager {
28
+ constructor() {
29
+ this.store = new Map();
30
+
31
+ // Background garbage collection sweep every 30 seconds
32
+ this.gcTimer = setInterval(() => {
33
+ this.evictExpired();
34
+ }, 30000).unref(); // .unref() allows Node process to exit cleanly if running tests
35
+ }
36
+
37
+ set(key, value, ttlMs = DEFAULT_TTL_MS) {
38
+ if (!IS_CACHE_ENABLED) return;
39
+
40
+ const now = Date.now();
41
+ const expiresAt = now + ttlMs;
42
+
43
+ this.store.set(key, {
44
+ value,
45
+ expiresAt,
46
+ });
47
+ metrics.setCount++;
48
+ }
49
+
50
+ get(key) {
51
+ if (!IS_CACHE_ENABLED) return null;
52
+
53
+ const entry = this.store.get(key);
54
+ if (!entry) return null;
55
+
56
+ const now = Date.now();
57
+ if (now > entry.expiresAt) {
58
+ this.store.delete(key);
59
+ return null;
60
+ }
61
+
62
+ return entry;
63
+ }
64
+
65
+ del(key) {
66
+ return this.store.delete(key);
67
+ }
68
+
69
+ flush() {
70
+ this.store.clear();
71
+ console.log('[CacheManager] Cache flushed successfully.');
72
+ }
73
+
74
+ evictExpired() {
75
+ const now = Date.now();
76
+ let evictedCount = 0;
77
+
78
+ for (const [key, entry] of this.store.entries()) {
79
+ if (now > entry.expiresAt) {
80
+ this.store.delete(key);
81
+ evictedCount++;
82
+ }
83
+ }
84
+
85
+ if (evictedCount > 0) {
86
+ console.log(`[CacheManager GC] Swept and evicted ${evictedCount} expired cache keys.`);
87
+ }
88
+ }
89
+
90
+ getStats() {
91
+ const totalKeys = this.store.size;
92
+ const totalRequests = metrics.hits + metrics.misses;
93
+ const hitRatio = totalRequests > 0 ? ((metrics.hits / totalRequests) * 100).toFixed(1) + '%' : '0%';
94
+
95
+ return {
96
+ enabled: IS_CACHE_ENABLED,
97
+ totalKeys,
98
+ hits: metrics.hits,
99
+ misses: metrics.misses,
100
+ hitRatio,
101
+ setCount: metrics.setCount,
102
+ blockedRequests: metrics.blockedRequests,
103
+ };
104
+ }
105
+ }
106
+
107
+ export const cache = new CacheManager();
108
+
109
+ // =========================================================================
110
+ // RATE LIMITER CLASS
111
+ // =========================================================================
112
+ class RateLimiter {
113
+ constructor() {
114
+ this.clients = new Map();
115
+
116
+ // Background sweeper to clear expired rate-limit buckets every 30 seconds
117
+ this.gcTimer = setInterval(() => {
118
+ this.evictExpired();
119
+ }, 30000).unref();
120
+ }
121
+
122
+ /**
123
+ * Check if client IP is within limits
124
+ * @returns {object} { allowed: boolean, remaining: number, limit: number, resetTime: number }
125
+ */
126
+ check(ip) {
127
+ const now = Date.now();
128
+ let client = this.clients.get(ip);
129
+
130
+ if (!client || now > client.resetTime) {
131
+ // Create new window
132
+ const resetTime = now + RATE_LIMIT_WINDOW_MS;
133
+ client = {
134
+ hits: 1,
135
+ resetTime,
136
+ };
137
+ this.clients.set(ip, client);
138
+ return {
139
+ allowed: true,
140
+ remaining: RATE_LIMIT_MAX - 1,
141
+ limit: RATE_LIMIT_MAX,
142
+ resetTime,
143
+ };
144
+ }
145
+
146
+ client.hits++;
147
+ const remaining = Math.max(0, RATE_LIMIT_MAX - client.hits);
148
+ const allowed = client.hits <= RATE_LIMIT_MAX;
149
+
150
+ return {
151
+ allowed,
152
+ remaining,
153
+ limit: RATE_LIMIT_MAX,
154
+ resetTime: client.resetTime,
155
+ };
156
+ }
157
+
158
+ evictExpired() {
159
+ const now = Date.now();
160
+ let count = 0;
161
+
162
+ for (const [ip, data] of this.clients.entries()) {
163
+ if (now > data.resetTime) {
164
+ this.clients.delete(ip);
165
+ count++;
166
+ }
167
+ }
168
+
169
+ if (count > 0) {
170
+ console.log(`[RateLimiter GC] Evicted ${count} expired rate-limit records.`);
171
+ }
172
+ }
173
+ }
174
+
175
+ const rateLimiter = new RateLimiter();
176
+
177
+ // =========================================================================
178
+ // EXPRESS MIDDLEWARE
179
+ // =========================================================================
180
+ /**
181
+ * Express Middleware protecting route with Rate-Limiting and serving/saving Cache
182
+ * @param {number} ttlSeconds Custom TTL override in seconds
183
+ */
184
+ export function cacheMiddleware(ttlSeconds) {
185
+ const ttlMs = ttlSeconds ? ttlSeconds * 1000 : DEFAULT_TTL_MS;
186
+
187
+ return async (req, res, next) => {
188
+ // 1. Bypass check if cache is disabled globally
189
+ if (!IS_CACHE_ENABLED) {
190
+ return next();
191
+ }
192
+
193
+ // Extract real client IP (supports Express trust proxy)
194
+ const ip = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
195
+
196
+ // 2. Perform Rate Limiting
197
+ const limitStatus = rateLimiter.check(ip);
198
+
199
+ // Set standard rate limit headers
200
+ res.setHeader('X-RateLimit-Limit', limitStatus.limit);
201
+ res.setHeader('X-RateLimit-Remaining', limitStatus.remaining);
202
+ res.setHeader('X-RateLimit-Reset', Math.ceil(limitStatus.resetTime / 1000));
203
+
204
+ if (!limitStatus.allowed) {
205
+ metrics.blockedRequests++;
206
+ const retryAfter = Math.ceil((limitStatus.resetTime - Date.now()) / 1000);
207
+ res.setHeader('Retry-After', retryAfter);
208
+
209
+ console.warn(`[RateLimiter] Blocked IP: ${ip} for too many requests on path: ${req.path}`);
210
+ return res.status(429).json({
211
+ error: 'Too Many Requests',
212
+ message: 'Превышен лимит запросов к серверу. Пожалуйста, подождите.',
213
+ retryAfter,
214
+ });
215
+ }
216
+
217
+ // 3. Perform Cache Lookup
218
+ // Generate normalized cache key based on route path and alphabetically sorted query parameters
219
+ const sortedQueries = Object.keys(req.query)
220
+ .sort()
221
+ .map(k => `${k}=${req.query[k]}`)
222
+ .join('&');
223
+
224
+ const cacheKey = sortedQueries ? `${req.path}?${sortedQueries}` : req.path;
225
+ const cachedEntry = cache.get(cacheKey);
226
+
227
+ if (cachedEntry) {
228
+ // Cache Hit
229
+ metrics.hits++;
230
+ const now = Date.now();
231
+ const remainingTtlSecs = Math.max(0, Math.ceil((cachedEntry.expiresAt - now) / 1000));
232
+
233
+ res.setHeader('X-Cache', 'HIT');
234
+ res.setHeader('X-Cache-TTL-Remaining', remainingTtlSecs);
235
+
236
+ // Serve cached JSON payload
237
+ return res.json(cachedEntry.value);
238
+ }
239
+
240
+ // Cache Miss
241
+ metrics.misses++;
242
+ res.setHeader('X-Cache', 'MISS');
243
+ res.setHeader('X-Cache-TTL-Remaining', Math.ceil(ttlMs / 1000));
244
+
245
+ // Intercept res.json to store the result in cache
246
+ const originalJson = res.json;
247
+ res.json = function (body) {
248
+ // Restore original res.json first to avoid recursive loops
249
+ res.json = originalJson;
250
+
251
+ // Store in CacheManager
252
+ cache.set(cacheKey, body, ttlMs);
253
+
254
+ // Call original response
255
+ return res.json(body);
256
+ };
257
+
258
+ next();
259
+ };
260
+ }
server/migrate_to_supabase.js ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import Database from 'better-sqlite3';
3
+ import dotenv from 'dotenv';
4
+ import path from 'path';
5
+
6
+ dotenv.config();
7
+
8
+ const supabaseUrl = process.env.SUPABASE_URL;
9
+ const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_KEY;
10
+
11
+ if (!supabaseUrl || !supabaseKey || supabaseUrl.includes('YOUR_') || supabaseKey.includes('YOUR_')) {
12
+ console.error('[Error] Supabase URL or Key is missing in .env!');
13
+ process.exit(1);
14
+ }
15
+
16
+ console.log('[Migration] Connecting to SQLite...');
17
+ const sqliteDb = new Database('database.db');
18
+
19
+ console.log('[Migration] Connecting to Supabase...');
20
+ const supabase = createClient(supabaseUrl, supabaseKey);
21
+
22
+ // Fetch valid stream IDs
23
+ const streams = sqliteDb.prepare('SELECT id FROM streams').all();
24
+ const validStreamIds = new Set(streams.map(s => s.id));
25
+ console.log(`[Migration] Loaded ${validStreamIds.size} valid stream IDs from SQLite:`, Array.from(validStreamIds));
26
+
27
+ async function migrateStreams() {
28
+ console.log('\n--- Migrating streams ---');
29
+ const streamsData = sqliteDb.prepare('SELECT * FROM streams').all();
30
+ if (streamsData.length === 0) {
31
+ console.log('No streams to migrate.');
32
+ return;
33
+ }
34
+
35
+ console.log(`Found ${streamsData.length} streams. Inserting...`);
36
+
37
+ const { error } = await supabase
38
+ .from('streams')
39
+ .upsert(streamsData);
40
+
41
+ if (error) {
42
+ console.error('Error migrating streams:', error);
43
+ } else {
44
+ console.log('Successfully migrated streams!');
45
+ }
46
+ }
47
+
48
+ async function migrateSettings() {
49
+ console.log('\n--- Migrating settings ---');
50
+ const settings = sqliteDb.prepare('SELECT * FROM settings').all();
51
+ if (settings.length === 0) {
52
+ console.log('No settings to migrate.');
53
+ return;
54
+ }
55
+
56
+ console.log(`Found ${settings.length} settings. Inserting...`);
57
+ const { error } = await supabase
58
+ .from('settings')
59
+ .upsert(settings);
60
+
61
+ if (error) {
62
+ console.error('Error migrating settings:', error);
63
+ } else {
64
+ console.log('Successfully migrated settings!');
65
+ }
66
+ }
67
+
68
+ async function migrateModActions() {
69
+ console.log('\n--- Migrating mod_actions ---');
70
+ // Only migrate mod actions that reference valid stream IDs or have null stream_id
71
+ const modActions = sqliteDb.prepare('SELECT * FROM mod_actions').all();
72
+ if (modActions.length === 0) {
73
+ console.log('No mod actions to migrate.');
74
+ return;
75
+ }
76
+
77
+ const filteredActions = modActions.filter(act => act.stream_id === null || validStreamIds.has(act.stream_id));
78
+ console.log(`Found ${modActions.length} mod actions. Migrating ${filteredActions.length} actions that have valid stream_id...`);
79
+
80
+ if (filteredActions.length === 0) return;
81
+
82
+ const { error } = await supabase
83
+ .from('mod_actions')
84
+ .upsert(filteredActions);
85
+
86
+ if (error) {
87
+ console.error('Error migrating mod actions:', error);
88
+ } else {
89
+ console.log('Successfully migrated mod actions!');
90
+ }
91
+ }
92
+
93
+ async function migrateMessages() {
94
+ console.log('\n--- Migrating messages ---');
95
+ const total = sqliteDb.prepare('SELECT count(*) as c FROM messages').get().c;
96
+ if (total === 0) {
97
+ console.log('No messages to migrate.');
98
+ return;
99
+ }
100
+
101
+ console.log(`Found ${total} messages to migrate.`);
102
+ const batchSize = 1000;
103
+ let migratedCount = 0;
104
+
105
+ for (let offset = 0; offset < total; offset += batchSize) {
106
+ const batch = sqliteDb.prepare('SELECT * FROM messages LIMIT ? OFFSET ?').all(batchSize, offset);
107
+
108
+ // Filter messages that have valid stream_id
109
+ const filteredBatch = batch.filter(msg => msg.stream_id === null || validStreamIds.has(msg.stream_id));
110
+
111
+ if (filteredBatch.length === 0) continue;
112
+
113
+ // Map SQLite types to Postgres types (booleans)
114
+ const mappedBatch = filteredBatch.map(msg => ({
115
+ id: msg.id,
116
+ stream_id: msg.stream_id,
117
+ username: msg.username,
118
+ display_name: msg.display_name,
119
+ message: msg.message,
120
+ timestamp: msg.timestamp,
121
+ is_streamer: !!msg.is_streamer,
122
+ is_mod: !!msg.is_mod,
123
+ is_sub: !!msg.is_sub
124
+ }));
125
+
126
+ const { error } = await supabase
127
+ .from('messages')
128
+ .upsert(mappedBatch);
129
+
130
+ if (error) {
131
+ console.error(`Error migrating messages batch at offset ${offset}:`, error);
132
+ console.log('Retrying...');
133
+ await new Promise(r => setTimeout(r, 1000));
134
+ const { error: retryError } = await supabase.from('messages').upsert(mappedBatch);
135
+ if (retryError) {
136
+ console.error('Retry failed, skipping batch:', retryError);
137
+ } else {
138
+ migratedCount += mappedBatch.length;
139
+ }
140
+ } else {
141
+ migratedCount += mappedBatch.length;
142
+ }
143
+
144
+ if (offset % 5000 === 0 || offset + batchSize >= total) {
145
+ console.log(`[Messages Progress] Migrated ${Math.min(offset + batchSize, total)} / ${total} messages`);
146
+ }
147
+ }
148
+
149
+ console.log(`Successfully migrated messages (inserted ${migratedCount} rows)!`);
150
+ }
151
+
152
+ async function migrateVoiceWords() {
153
+ console.log('\n--- Migrating voice_words ---');
154
+ const total = sqliteDb.prepare('SELECT count(*) as c FROM voice_words').get().c;
155
+ if (total === 0) {
156
+ console.log('No voice words to migrate.');
157
+ return;
158
+ }
159
+
160
+ console.log(`Found ${total} voice words to migrate.`);
161
+ const batchSize = 3000;
162
+ let migratedCount = 0;
163
+
164
+ for (let offset = 0; offset < total; offset += batchSize) {
165
+ const batch = sqliteDb.prepare('SELECT * FROM voice_words LIMIT ? OFFSET ?').all(batchSize, offset);
166
+
167
+ // Filter voice words that have valid stream_id
168
+ const filteredBatch = batch.filter(vw => vw.stream_id === null || validStreamIds.has(vw.stream_id));
169
+
170
+ if (filteredBatch.length === 0) continue;
171
+
172
+ const { error } = await supabase
173
+ .from('voice_words')
174
+ .upsert(filteredBatch);
175
+
176
+ if (error) {
177
+ console.error(`Error migrating voice words batch at offset ${offset}:`, error);
178
+ console.log('Retrying...');
179
+ await new Promise(r => setTimeout(r, 1000));
180
+ const { error: retryError } = await supabase.from('voice_words').upsert(filteredBatch);
181
+ if (retryError) {
182
+ console.error('Retry failed, skipping batch:', retryError);
183
+ } else {
184
+ migratedCount += filteredBatch.length;
185
+ }
186
+ } else {
187
+ migratedCount += filteredBatch.length;
188
+ }
189
+
190
+ if (offset % 30000 === 0 || offset + batchSize >= total) {
191
+ console.log(`[Voice Words Progress] Migrated ${Math.min(offset + batchSize, total)} / ${total} voice words`);
192
+ }
193
+ }
194
+
195
+ console.log(`Successfully migrated voice words (inserted ${migratedCount} rows)!`);
196
+ }
197
+
198
+ async function startMigration() {
199
+ console.log('=== Starting database migration from SQLite to Supabase ===');
200
+ const startTime = Date.now();
201
+
202
+ try {
203
+ await migrateStreams();
204
+ await migrateSettings();
205
+ await migrateModActions();
206
+ await migrateMessages();
207
+ await migrateVoiceWords();
208
+
209
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
210
+ console.log(`\n=== Migration completed successfully in ${duration}s! ===`);
211
+ console.log('\nIMPORTANT: Now run the following SQL query in your Supabase SQL Editor to reset auto-increment sequences:');
212
+ console.log(`
213
+ SELECT setval(pg_get_serial_sequence('streams', 'id'), coalesce(max(id), 1)) FROM streams;
214
+ SELECT setval(pg_get_serial_sequence('voice_words', 'id'), coalesce(max(id), 1)) FROM voice_words;
215
+ SELECT setval(pg_get_serial_sequence('mod_actions', 'id'), coalesce(max(id), 1)) FROM mod_actions;
216
+ `);
217
+ } catch (err) {
218
+ console.error('Migration failed:', err);
219
+ } finally {
220
+ sqliteDb.close();
221
+ }
222
+ }
223
+
224
+ startMigration();
server/server.js CHANGED
@@ -33,10 +33,12 @@ import {
33
  cleanupGhostStreams
34
  } from './db.js';
35
  // import { initializeEventSub } from './eventsub.js';
 
36
 
37
  dotenv.config();
38
 
39
  const app = express();
 
40
  const PORT = process.env.PORT || 3000;
41
 
42
  // Configure CORS to allow frontend connections
@@ -259,6 +261,7 @@ app.post('/api/streams/reset-backfill', requireModeratorRole, async (req, res) =
259
  }
260
  try {
261
  await resetStreamBackfill(parseInt(streamId), mode || 'gap_fill');
 
262
  res.json({ success: true });
263
  } catch (err) {
264
  res.status(500).json({ error: err.message });
@@ -273,6 +276,7 @@ app.delete('/api/streams/:id', requireAdminRole, async (req, res) => {
273
  }
274
  try {
275
  await deleteStream(streamId);
 
276
  res.json({ success: true });
277
  } catch (err) {
278
  res.status(500).json({ error: err.message });
@@ -291,6 +295,7 @@ app.put('/api/streams/:id', requireAdminRole, async (req, res) => {
291
  }
292
  try {
293
  await updateStreamMetadata(streamId, title, category || '');
 
294
  res.json({ success: true });
295
  } catch (err) {
296
  res.status(500).json({ error: err.message });
@@ -301,6 +306,7 @@ app.put('/api/streams/:id', requireAdminRole, async (req, res) => {
301
  app.post('/api/admin/cleanup', requireAdminRole, async (req, res) => {
302
  try {
303
  const deletedCount = await cleanupGhostStreams();
 
304
  res.json({ success: true, count: deletedCount });
305
  } catch (err) {
306
  res.status(500).json({ error: err.message });
@@ -311,7 +317,8 @@ app.post('/api/admin/cleanup', requireAdminRole, async (req, res) => {
311
  app.get('/api/admin/stats', requireAdminRole, async (req, res) => {
312
  try {
313
  const stats = await getSystemStats();
314
- res.json({ success: true, stats });
 
315
  } catch (err) {
316
  res.status(500).json({ error: err.message });
317
  }
@@ -476,13 +483,13 @@ app.get('/api/auth/logout', (req, res) => {
476
  // =========================================================================
477
 
478
  // Get list of streams
479
- app.get('/api/streams', async (req, res) => {
480
  const streams = await getStreamsList();
481
  res.json(streams);
482
  });
483
 
484
  // Get top active chatters
485
- app.get('/api/stats/chatters', async (req, res) => {
486
  const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
487
  const limit = req.query.limit ? parseInt(req.query.limit) : 50;
488
 
@@ -516,7 +523,7 @@ const STOP_WORDS = new Set([
516
  ]);
517
 
518
  // Get word frequencies (Voice vs Chat messages from streamer)
519
- app.get('/api/stats/words', async (req, res) => {
520
  const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
521
  const limit = req.query.limit ? parseInt(req.query.limit) : 50;
522
  const type = req.query.type || 'voice'; // 'voice' or 'chat'
@@ -610,7 +617,7 @@ app.get('/api/stats/words', async (req, res) => {
610
  });
611
 
612
  // Get chat activity (messages count grouped by 5-minute intervals)
613
- app.get('/api/stats/activity', async (req, res) => {
614
  const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
615
 
616
  if (!streamId) {
@@ -688,7 +695,7 @@ app.get('/api/stats/moderators/summary', requireModeratorRole, async (req, res)
688
  });
689
 
690
  // GET aggregated profiles with scores for moderators
691
- app.get('/api/stats/moderators/profiles', async (req, res) => {
692
  const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
693
  const profiles = await getModeratorProfilesData(streamId);
694
  res.json(profiles);
 
33
  cleanupGhostStreams
34
  } from './db.js';
35
  // import { initializeEventSub } from './eventsub.js';
36
+ import { cache, cacheMiddleware } from './cache.js';
37
 
38
  dotenv.config();
39
 
40
  const app = express();
41
+ app.set('trust proxy', true); // Safe trust proxy behind Cloudflare and Hugging Face proxies
42
  const PORT = process.env.PORT || 3000;
43
 
44
  // Configure CORS to allow frontend connections
 
261
  }
262
  try {
263
  await resetStreamBackfill(parseInt(streamId), mode || 'gap_fill');
264
+ cache.flush();
265
  res.json({ success: true });
266
  } catch (err) {
267
  res.status(500).json({ error: err.message });
 
276
  }
277
  try {
278
  await deleteStream(streamId);
279
+ cache.flush();
280
  res.json({ success: true });
281
  } catch (err) {
282
  res.status(500).json({ error: err.message });
 
295
  }
296
  try {
297
  await updateStreamMetadata(streamId, title, category || '');
298
+ cache.flush();
299
  res.json({ success: true });
300
  } catch (err) {
301
  res.status(500).json({ error: err.message });
 
306
  app.post('/api/admin/cleanup', requireAdminRole, async (req, res) => {
307
  try {
308
  const deletedCount = await cleanupGhostStreams();
309
+ cache.flush();
310
  res.json({ success: true, count: deletedCount });
311
  } catch (err) {
312
  res.status(500).json({ error: err.message });
 
317
  app.get('/api/admin/stats', requireAdminRole, async (req, res) => {
318
  try {
319
  const stats = await getSystemStats();
320
+ const cacheStats = cache.getStats();
321
+ res.json({ success: true, stats, cacheStats });
322
  } catch (err) {
323
  res.status(500).json({ error: err.message });
324
  }
 
483
  // =========================================================================
484
 
485
  // Get list of streams
486
+ app.get('/api/streams', cacheMiddleware(), async (req, res) => {
487
  const streams = await getStreamsList();
488
  res.json(streams);
489
  });
490
 
491
  // Get top active chatters
492
+ app.get('/api/stats/chatters', cacheMiddleware(), async (req, res) => {
493
  const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
494
  const limit = req.query.limit ? parseInt(req.query.limit) : 50;
495
 
 
523
  ]);
524
 
525
  // Get word frequencies (Voice vs Chat messages from streamer)
526
+ app.get('/api/stats/words', cacheMiddleware(), async (req, res) => {
527
  const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
528
  const limit = req.query.limit ? parseInt(req.query.limit) : 50;
529
  const type = req.query.type || 'voice'; // 'voice' or 'chat'
 
617
  });
618
 
619
  // Get chat activity (messages count grouped by 5-minute intervals)
620
+ app.get('/api/stats/activity', cacheMiddleware(), async (req, res) => {
621
  const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
622
 
623
  if (!streamId) {
 
695
  });
696
 
697
  // GET aggregated profiles with scores for moderators
698
+ app.get('/api/stats/moderators/profiles', cacheMiddleware(), async (req, res) => {
699
  const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
700
  const profiles = await getModeratorProfilesData(streamId);
701
  res.json(profiles);