const express = require('express'); const path = require('path'); const cors = require('cors'); const fetch = require('node-fetch'); const crypto = require('crypto'); const { exec, execFile } = require('child_process'); const compression = require('compression'); // Load environment variables if dotenv is available try { require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); } catch (err) { // dotenv not installed, continue without it (production uses env vars) } const fs = require('fs'); const fsp = require('fs/promises'); const seasonsUtil = require('./utils/seasons'); // ── i18n: load translation files ── const locales = { en: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'en.json'), 'utf8')), ru: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'ru.json'), 'utf8')), fr: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'fr.json'), 'utf8')), it: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'it.json'), 'utf8')), uk: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'uk.json'), 'utf8')), de: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'de.json'), 'utf8')), es: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'es.json'), 'utf8')), pl: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'pl.json'), 'utf8')), cs: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'cs.json'), 'utf8')), 'zh-CN': JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'zh-CN.json'), 'utf8')) }; function mergeLocale(base, override) { if (!override || typeof override !== 'object' || Array.isArray(override)) { return override ?? base; } const out = { ...base }; for (const [key, val] of Object.entries(override)) { const baseVal = base && base[key]; out[key] = val && typeof val === 'object' && !Array.isArray(val) ? mergeLocale(baseVal && typeof baseVal === 'object' ? baseVal : {}, val) : val; } return out; } function getLang(req) { // 1. query param 2. cookie 3. default if (req.query.lang && locales[req.query.lang]) return req.query.lang; const cookies = req.headers.cookie || ''; const match = cookies.match(/(?:^|;\s*)lang=([\w-]+)/); if (match && locales[match[1]]) return match[1]; return 'en'; } function t(lang, key) { const parts = key.split('.'); let val = locales[lang]; for (const p of parts) { val = val && val[p]; } if (val !== undefined) return val; // fallback to English val = locales.en; for (const p of parts) { val = val && val[p]; } return val !== undefined ? val : key; } function getRosterMemberByUid(uid) { return new Promise((resolve) => { if (!squadronsDb || !uid) return resolve(null); squadronsDb.get( ` SELECT sm.uid, sm.nick, sm.clan_id, sm.points, sd.tag_name, sd.short_name, sd.long_name FROM squadron_members sm LEFT JOIN squadrons_data sd ON sm.clan_id = sd.clan_id WHERE sm.uid = ? ORDER BY sm.updated_at DESC LIMIT 1 `, [uid], (err, row) => { if (err) { log.error('Failed to load roster member by UID', err, { uid }); return resolve(null); } resolve(row || null); } ); }); } const sqlite3 = require('sqlite3').verbose(); const STORAGE_ROOT = (process.env.STORAGE_VOL_PATH || '').trim(); if (!STORAGE_ROOT) { throw new Error('STORAGE_VOL_PATH must be set'); } const REPLAYS_ROOT = path.join(STORAGE_ROOT, 'REPLAYS'); fs.mkdirSync(REPLAYS_ROOT, { recursive: true }); const LEGACY_REPLAYS_ROOT = path.join(__dirname, '..', 'replays'); const ENTITLEMENTS_DB_PATH = path.join(STORAGE_ROOT, 'entitlements.db'); const ENTITLEMENTS_DIRTY_PATH = path.join(STORAGE_ROOT, 'entitlements.dirty'); const SQUADRONS_DB_PATH = path.join(STORAGE_ROOT, 'squadrons.db'); const SQUADRONS_JSON_PATH = path.join(STORAGE_ROOT, 'SQUADRONS.json'); const entitlementsDb = new sqlite3.Database(ENTITLEMENTS_DB_PATH); // Bump the mtime on a marker file so the bot process (which caches // entitlements in memory, see BOT/utils.py refresh_entitled_guilds) picks up // writes to guild_entitlements within a few seconds instead of waiting for // its hourly fallback refresh. The bot polls this file's mtime rather than // us calling into it directly, since the bot and web/webhook run as // separate pm2 processes with no other IPC between them. function touchEntitlementsDirty() { fs.writeFile(ENTITLEMENTS_DIRTY_PATH, String(Date.now()), (err) => { if (err) log.error('[WHOP] Failed to write entitlements dirty flag:', err); }); } const squadronsDb = fs.existsSync(SQUADRONS_DB_PATH) ? new sqlite3.Database(SQUADRONS_DB_PATH, sqlite3.OPEN_READONLY) : null; entitlementsDb.run(`CREATE TABLE IF NOT EXISTS guild_entitlements ( guild_id TEXT PRIMARY KEY, whop_membership_id TEXT, status TEXT DEFAULT 'active', renewed_at INTEGER DEFAULT (strftime('%s','now')), tier TEXT )`); entitlementsDb.run(`CREATE TABLE IF NOT EXISTS manual_entitlements ( guild_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), tier TEXT )`); entitlementsDb.run(`CREATE TABLE IF NOT EXISTS discord_entitlements ( guild_id TEXT PRIMARY KEY, sku_id TEXT, updated_at INTEGER DEFAULT (strftime('%s','now')), tier TEXT )`); // Ensure `tier` column exists on pre-existing DBs, then backfill NULL rows to 'standard'. // Both operations are idempotent and safe to run on every boot. entitlementsDb.serialize(() => { for (const table of ['guild_entitlements', 'manual_entitlements', 'discord_entitlements']) { // ALTER fails with "duplicate column" if the column already exists — ignore that error. entitlementsDb.run(`ALTER TABLE ${table} ADD COLUMN tier TEXT`, (err) => { if (err && !/duplicate column/i.test(err.message)) { console.error(`[ENTITLEMENTS] ALTER ${table} failed:`, err.message); } }); entitlementsDb.run( `UPDATE ${table} SET tier='standard' WHERE tier IS NULL OR tier=''`, (err) => { if (err) console.error(`[ENTITLEMENTS] backfill ${table} failed:`, err.message); } ); } }); // Production-safe logging const isDev = process.env.NODE_ENV !== 'production'; const log = { info: (...args) => isDev && console.log(...args), warn: (...args) => console.warn(...args), error: (...args) => console.error(...args), debug: (...args) => isDev && console.log('[DEBUG]', ...args) }; const REPO_ROOT = path.join(__dirname, '..'); const SQUADRON_RECAP_CACHE_DIR = path.join(STORAGE_ROOT, 'RECAPS', 'squadrons'); const PLAYER_RECAP_CACHE_DIR = path.join(STORAGE_ROOT, 'RECAPS', 'players'); const RECAP_TTL_MS = 24 * 60 * 60 * 1000; // in-progress season TTL const RECAP_RENDER_TIMEOUT_MS = 30_000; const PYTHON_BIN = path.join(REPO_ROOT, '..', 'SHARED', '.venv', 'bin', 'python'); const RECAP_SCRIPT = path.join(REPO_ROOT, 'BOT', 'render_recap.py'); function resolveReplaySessionDir(sessionId) { const sid = String(sessionId).toLowerCase(); const candidates = [ path.join(REPLAYS_ROOT, 'SRE', sid), path.join(REPLAYS_ROOT, 'TSS', sid), path.join(REPLAYS_ROOT, sid), path.join(REPLAYS_ROOT, `0${sid}`), path.join(LEGACY_REPLAYS_ROOT, sid), path.join(LEGACY_REPLAYS_ROOT, `0${sid}`) ]; for (const dir of candidates) { if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir; } return path.join(REPLAYS_ROOT, sid); } const app = express(); const PORT = process.env.SREBOT_WEB_PORT || 3001; const IS_PRIMARY_WORKER = !process.env.NODE_APP_INSTANCE || process.env.NODE_APP_INSTANCE === '0'; // Enable compression for all responses app.use(compression({ level: 6, // Good balance of compression vs CPU threshold: 1024, // Only compress responses > 1KB filter: (req, res) => { if (req.headers['x-no-compression']) { return false; } return compression.filter(req, res); } })); // CORS Configuration const corsOptions = { origin: function (origin, callback) { const allowedOrigins = process.env.NODE_ENV === 'production' ? [process.env.PRODUCTION_DOMAIN || 'https://sre.pawjob.us'] : ['http://localhost:3000', 'http://localhost:3001']; if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { log.warn(`[CORS] Blocked general request from origin: ${origin}`); callback(new Error('Not allowed by CORS policy')); } }, credentials: true, methods: ['GET', 'POST', 'HEAD', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], exposedHeaders: ['X-Total-Count'], maxAge: 86400 // 24 hours }; const apiCorsOptions = { origin: function (origin, callback) { const allowedOrigins = process.env.NODE_ENV === 'production' ? [process.env.PRODUCTION_DOMAIN || 'https://sre.pawjob.us'] : ['http://localhost:3000', 'http://localhost:3001']; // Allow our domains AND same-origin requests (null origin from legitimate browser AJAX) if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { log.warn(`[CORS] Blocked API request from origin: ${origin || 'null/direct'}`); callback(new Error('API access restricted to authorized domains only')); } }, credentials: false, methods: ['GET', 'OPTIONS'], // Only allow GET and preflight allowedHeaders: ['Content-Type', 'X-Requested-With'], maxAge: 3600 }; app.use(cors(corsOptions)); app.use(express.json({ limit: '10mb', verify: (req, res, buf) => { req.rawBody = buf; } })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Serve obfuscated JS in production if (process.env.NODE_ENV === 'production') { app.use('/js', (req, res, next) => { if (req.path.endsWith('.js')) { const obfuscatedPath = path.join(__dirname, 'public', 'js', 'dist', path.basename(req.path)); if (require('fs').existsSync(obfuscatedPath)) { return res.sendFile(obfuscatedPath); } } next(); }); } // Serve static files with proper MIME types and caching app.use(express.static('public', { etag: true, lastModified: true, setHeaders: (res, filePath) => { // Fonts never change in-place — safe to cache forever if (filePath.match(/\.(ttf|woff|woff2)$/)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // CSS, JS, and images are updated in-place — allow revalidation after 10 minutes } else if (filePath.match(/\.(css|js|jpg|jpeg|png|gif|webp|svg|ico)$/)) { res.setHeader('Cache-Control', 'public, max-age=600, must-revalidate'); // Don't cache HTML } else if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache, must-revalidate'); } } })); // Font files with proper headers and CORS app.use('/fonts', express.static('Fonts', { setHeaders: (res, path) => { if (path.endsWith('.ttf')) { res.setHeader('Content-Type', 'font/ttf'); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('Access-Control-Allow-Origin', '*'); } else if (path.endsWith('.woff')) { res.setHeader('Content-Type', 'font/woff'); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('Access-Control-Allow-Origin', '*'); } else if (path.endsWith('.woff2')) { res.setHeader('Content-Type', 'font/woff2'); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('Access-Control-Allow-Origin', '*'); } } })); app.use('/Fonts', express.static('Fonts', { setHeaders: (res, path) => { if (path.endsWith('.ttf')) { res.setHeader('Content-Type', 'font/ttf'); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('Access-Control-Allow-Origin', '*'); } else if (path.endsWith('.woff')) { res.setHeader('Content-Type', 'font/woff'); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('Access-Control-Allow-Origin', '*'); } else if (path.endsWith('.woff2')) { res.setHeader('Content-Type', 'font/woff2'); res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('Access-Control-Allow-Origin', '*'); } } })); app.use('/ICONS', express.static('ICONS')); app.use('/MAPS', express.static('MAPS')); app.use('/constants', express.static('constants')); // Leaderboard Cache System (5-minute intervals) const leaderboardCache = { players: { data: null, lastUpdated: 0, updating: false }, vehicles: { data: null, lastUpdated: 0, updating: false }, stats: { data: null, lastUpdated: 0, updating: false }, squadrons: { data: null, lastUpdated: 0, updating: false } }; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds // LRU Cache for search results class LRUCache { constructor(maxSize = 500, ttlMs = 15 * 60 * 1000) { this.cache = new Map(); this.maxSize = maxSize; this.ttlMs = ttlMs; } get(key) { if (!this.cache.has(key)) return null; const item = this.cache.get(key); // Check if expired if (Date.now() - item.timestamp > this.ttlMs) { this.cache.delete(key); return null; } // Move to end (most recently used) this.cache.delete(key); this.cache.set(key, item); return item.data; } set(key, data) { // Remove if already exists if (this.cache.has(key)) { this.cache.delete(key); } // Remove oldest if at capacity if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; this.cache.delete(firstKey); } this.cache.set(key, { data, timestamp: Date.now() }); } size() { return this.cache.size; } clear() { this.cache.clear(); } } const searchCache = new LRUCache(500); // Cache up to 500 unique searches const squadronProfileCache = new LRUCache(1000, 10 * 60 * 1000); // 10 minute TTL // Search request deduplication const pendingSearchRequests = new Map(); const pendingSquadronRequests = new Map(); const SQUADRON_API_TIMEOUT_MS = 120000; // Function to update cache for a specific type async function updateCache(type) { const cache = leaderboardCache[type]; // Prevent multiple simultaneous updates if (cache.updating) { log.debug(`[CACHE] Cache update for ${type} already in progress, skipping...`); return; } cache.updating = true; log.info(`[CACHE] Updating ${type} cache...`); try { // For the player leaderboard, send a bounded date range so the backend // doesn't reject the request. The backend's /api/leaderboard/players // refuses uncached all-time queries because the DB aggregation is // expensive (grouping every player across all time). A one-year // window covers all active squadrons for the homepage search without // scanning the full history. let urlSuffix = `/api/leaderboard/${type}`; if (type === 'players') { const since = new Date(); since.setFullYear(since.getFullYear() - 1); // Truncate to date-only so all 3 web instances hit the same API cache key urlSuffix += `?start_date=${since.toISOString().slice(0, 10)}`; } if (type === 'vehicles') { const since = new Date(); since.setFullYear(since.getFullYear() - 1); // Truncate to date-only so all 3 web instances share one 133s cache entry urlSuffix += `?start_date=${since.toISOString().slice(0, 10)}&limit=500`; } const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}${urlSuffix}`; log.debug(`[CACHE] Fetching from: ${apiUrl}`); const response = await fetch(apiUrl, { signal: AbortSignal.timeout(30000) }); if (!response.ok) { log.error(`[CACHE] API Error Response:`, { status: response.status, statusText: response.statusText, url: apiUrl, headers: Object.fromEntries(response.headers.entries()) }); // Try to get error details from response body try { const errorText = await response.text(); log.error(`[CACHE] API Error Body:`, errorText); } catch (e) { log.error(`[CACHE] Could not read error response body`); } throw new Error(`API returned status ${response.status} for ${type} endpoint`); } const data = await response.json(); // Store the data and update timestamp cache.data = data; cache.lastUpdated = Date.now(); log.debug(`[CACHE] ${type} cache updated successfully. Data contains:`, { players: data.players?.length || 0, vehicles: data.vehicles?.length || 0, total_players: data.total_players || 0, total_vehicles_used: data.total_vehicles_used || 0 }); } catch (error) { log.error(`[CACHE] Error updating ${type} cache:`, error); // For critical debugging, let's also log what we know about the backend if (error.message.includes('500')) { log.error(`[CACHE] Backend API is returning 500 errors. Possible causes:`); log.error(`[CACHE] - Database connection issues`); log.error(`[CACHE] - Backend API server errors`); log.error(`[CACHE] - Invalid SQL queries in backend`); log.error(`[CACHE] - Check backend logs for more details`); } // Keep existing data if update fails } finally { cache.updating = false; } } // Function to get cached data or trigger update if needed async function getCachedData(type) { const cache = leaderboardCache[type]; const now = Date.now(); const isExpired = now - cache.lastUpdated > CACHE_DURATION; // If cache is empty or expired, update it if (!cache.data || isExpired) { if (!cache.updating) { // Don't await - let it update in background updateCache(type).catch(log.error); } // If we have old data, return it while updating if (cache.data) { log.debug(`[CACHE] Serving slightly stale ${type} cache while updating...`); return cache.data; } // If no data at all, wait for the update. The API fetch itself has a // 30s timeout, so wait up to ~32s — longer than the API can take but // short enough to surface a real error to the user. if (cache.updating) { log.debug(`[CACHE] Waiting for initial ${type} cache...`); const waitStart = Date.now(); const waitMs = 32000; while (cache.updating && Date.now() - waitStart < waitMs) { await new Promise(resolve => setTimeout(resolve, 100)); } if (cache.updating) { log.warn(`[CACHE] Timed out waiting for ${type} cache after ${waitMs / 1000}s`); } } } return cache.data; } // Initialize only lightweight caches on the primary worker. // Player and vehicle leaderboards are too expensive to refresh in the background: // recent production runs took 140s+ and kept the API CPU saturated after callers timed out. async function initializeCache() { if (!IS_PRIMARY_WORKER) return; log.info('[CACHE] Initializing lightweight leaderboard cache (staggered)...'); await updateCache('stats'); log.info('[CACHE] Stats cache ready'); await updateCache('squadrons'); log.info('[CACHE] Squadrons cache ready'); } // Auto-refresh only lightweight caches from the primary worker. Heavy caches are // refreshed lazily by request so background work cannot pin SQLite indefinitely. if (IS_PRIMARY_WORKER) { const cacheTypes = ['stats', 'squadrons']; cacheTypes.forEach((type, i) => { setInterval(async () => { log.debug(`[CACHE] Auto-refreshing ${type} cache...`); await updateCache(type).catch(log.error); }, CACHE_DURATION + i * 15000); }); } // Log search cache statistics every 10 minutes setInterval(() => { log.info('[SEARCH CACHE] Statistics:', { size: searchCache.size(), maxSize: 500, utilization: `${((searchCache.size() / 500) * 100).toFixed(1)}%`, pendingRequests: pendingSearchRequests.size }); }, 10 * 60 * 1000); // 10 minutes const rateLimitMap = new Map(); // Cleanup old rate limit entries every 5 minutes to prevent memory leaks setInterval(() => { const now = Date.now(); const toDelete = []; for (const [ip, data] of rateLimitMap.entries()) { if (now > data.resetTime + 300000) { // 5 minutes after reset time toDelete.push(ip); } } toDelete.forEach(ip => rateLimitMap.delete(ip)); if (toDelete.length > 0) { log.debug(`[RATE LIMIT] Cleaned up ${toDelete.length} old entries`); } }, 300000); // 5 minutes const rateLimit = (req, res, next) => { const clientIP = req.ip || req.connection.remoteAddress; const now = Date.now(); const windowMs = 60000; // 1 minute const maxRequests = 100; // Max requests per window if (!rateLimitMap.has(clientIP)) { rateLimitMap.set(clientIP, { count: 1, resetTime: now + windowMs }); return next(); } const clientData = rateLimitMap.get(clientIP); if (now > clientData.resetTime) { clientData.count = 1; clientData.resetTime = now + windowMs; return next(); } if (clientData.count >= maxRequests) { return res.status(429).json({ error: 'Too many requests, please try again later.' }); } clientData.count++; next(); }; app.use(rateLimit); app.use((req, res, next) => { // Enhanced security headers res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); // Remove server signature res.removeHeader('X-Powered-By'); // HSTS (only in production with HTTPS) if (process.env.NODE_ENV === 'production') { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); } // Content Security Policy if (req.path.startsWith('/api/')) { res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none';"); } else { res.setHeader('Content-Security-Policy', "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + "font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; " + "img-src 'self' data: https:; " + "media-src 'self' blob:; " + "connect-src 'self' https://cloudflareinsights.com; " + "frame-ancestors 'none';" ); } // Additional security headers res.setHeader('X-DNS-Prefetch-Control', 'off'); res.setHeader('X-Download-Options', 'noopen'); res.setHeader('X-Permitted-Cross-Domain-Policies', 'none'); next(); }); // Enhanced API security with rotation and signing const API_SECRET = process.env.API_SECRET || 'your-super-secret-key-change-this-in-production'; // Cache for API keys to avoid regenerating on every request let apiKeyCache = { key: null, date: null }; // Generate daily rotating API key with caching const generateDailyAPIKey = () => { const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD // Return cached key if still valid for today if (apiKeyCache.key && apiKeyCache.date === today) { return apiKeyCache.key; } // Generate new key and cache it const newKey = crypto.createHash('sha256').update(`frontend-${API_SECRET}-${today}`).digest('hex').substring(0, 32); apiKeyCache = { key: newKey, date: today }; return newKey; }; // Generate simple hash signature for request validation (matches client-side) const signRequest = (data, timestamp) => { // Match the client-side simple hashing algorithm let hash = 0; const combined = `${data}-${timestamp}-${API_SECRET}`; for (let i = 0; i < combined.length; i++) { const char = combined.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash).toString(16); }; // Validate request signature to prevent replay attacks const validateSignature = (data, timestamp, signature) => { const expectedSignature = signRequest(data, timestamp); const now = Date.now(); const requestTime = parseInt(timestamp); // Reject requests older than 5 minutes or from future if (now - requestTime > 300000 || requestTime > now + 60000) { return false; } // Simple string comparison for now (client-side signing is basic) return signature === expectedSignature; }; // IP-based restrictions for API keys const validateAPIKeyForIP = (apiKey, clientIP) => { // Allow current daily key const currentKey = generateDailyAPIKey(); // Allow previous day's key for 1 hour overlap during rotation const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const yesterdayKey = crypto.createHash('sha256') .update(`frontend-${API_SECRET}-${yesterday.toISOString().split('T')[0]}`) .digest('hex').substring(0, 32); // Debug logging in development if (process.env.NODE_ENV !== 'production') { log.debug(`API Key validation:`); log.debug(` Received: ${apiKey?.substring(0, 8)}...`); log.debug(` Expected current: ${currentKey.substring(0, 8)}...`); log.debug(` Expected yesterday: ${yesterdayKey.substring(0, 8)}...`); } if (apiKey === currentKey || apiKey === yesterdayKey) { // In production, you could add IP whitelist here if (process.env.NODE_ENV === 'production' && process.env.ALLOWED_IPS) { const allowedIPs = process.env.ALLOWED_IPS.split(','); return allowedIPs.some(ip => { // Handle IPv6-mapped IPv4 addresses const normalizedClientIP = clientIP.replace('::ffff:', ''); return normalizedClientIP.includes(ip.trim()) || ip.trim().includes(normalizedClientIP); }); } return true; } return false; }; const FRONTEND_API_KEY = generateDailyAPIKey(); // Only log API key in development if (process.env.NODE_ENV !== 'production') { log.info(`Frontend API Key: ${FRONTEND_API_KEY}`); log.info(`Key rotates daily at midnight UTC`); } const apiSecurityCheck = (req, res, next) => { // Only apply to API routes if (!req.path.startsWith('/api/')) { return next(); } // Special case: allow /api-key and /api/stats without authentication. // Some proxies/routers may preserve a trailing slash or otherwise normalize // the path, so match by prefix after trimming any trailing slash. const normalizedPath = req.path.replace(/\/+$/, ''); if (normalizedPath === '/api-key' || normalizedPath.startsWith('/api/stats')) { return next(); } // Allow video/minimap/icon endpoints without auth (loaded via /