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 = process.env.BOT_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 = process.env.PYTHON_BIN || path.join(REPO_ROOT, '..', 'SHARED', '.venv', 'bin', 'python');
const RECAP_SCRIPT = process.env.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
/