Spaces:
Sleeping
Sleeping
Sasha commited on
Commit ·
f0fe495
1
Parent(s): 44dd8cf
feat: implement caching, rate limiter, and security headers (CSP) for decoupled deploy
Browse files- netlify.toml +10 -0
- server/cache.js +260 -0
- server/migrate_to_supabase.js +224 -0
- 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 |
-
|
|
|
|
| 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);
|