Files
SREBOT/web/server.js
T

3040 lines
120 KiB
JavaScript

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 = ?
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 SQUADRONS_DB_PATH = path.join(STORAGE_ROOT, 'squadrons.db');
const entitlementsDb = new sqlite3.Database(ENTITLEMENTS_DB_PATH);
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, '.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, 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 cache on startup — staggered to avoid overloading SQLite
async function initializeCache() {
log.info('[CACHE] Initializing leaderboard cache (staggered)...');
// Stats is lightest, do it first
await updateCache('stats');
log.info('[CACHE] Stats cache ready');
// Squadrons next
await updateCache('squadrons');
log.info('[CACHE] Squadrons cache ready');
// Players is heaviest
await updateCache('players');
log.info('[CACHE] Players cache ready');
// Vehicles last
await updateCache('vehicles');
log.info('[CACHE] Vehicles cache ready — all caches populated!');
}
// Auto-refresh caches staggered to avoid hammering the API all at once
const cacheTypes = ['players', 'vehicles', '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); // stagger each by 15s
});
// 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; " +
"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 <img>/<video>/<canvas> tag, not JS client)
if (req.path.match(/^\/api\/match\/[0-9a-fA-F]+\/(video|replay-canvas)$/) ||
req.path.match(/^\/api\/match\/minimap\/[a-zA-Z0-9_]+$/) ||
req.path.match(/^\/api\/icons\/type\/[a-zA-Z0-9_\-]+$/)) {
return next();
}
const apiKey = req.get('X-API-Key');
const signature = req.get('X-Request-Signature');
const timestamp = req.get('X-Request-Timestamp');
const clientIP = req.ip || req.connection.remoteAddress || req.socket.remoteAddress;
// Enhanced validation with IP checking
if (!validateAPIKeyForIP(apiKey, clientIP)) {
log.warn(`[SECURITY] Invalid API key: ${req.path} from ${clientIP}`);
log.warn(` Expected daily key, got: ${apiKey ? apiKey.substring(0, 8) + '...' : 'none'}`);
return res.status(403).json({
error: 'Access denied',
message: 'Insufficient permissions'
});
}
// Note: Signature validation disabled for now since we can't safely do HMAC client-side
// The API key rotation and IP validation provide sufficient security
// Optional: Add timestamp validation to prevent very old requests
if (timestamp) {
const now = Date.now();
const requestTime = parseInt(timestamp);
if (now - requestTime > 300000) { // 5 minutes
log.warn(`[SECURITY] Stale request: ${req.path} from ${clientIP} (${Math.floor((now - requestTime) / 1000)}s old)`);
return res.status(403).json({
error: 'Access denied',
message: 'Request expired'
});
}
}
next();
};
app.use(apiSecurityCheck);
app.use((err, req, res, next) => {
if (err.message === 'Not allowed by CORS policy' || err.message === 'API access restricted to authorized domains only') {
return res.status(403).json({
error: 'Access denied',
message: 'Resource not available'
});
}
next(err);
});
app.set('view engine', 'ejs');
app.set('views', './views');
const validateInput = (input, type = 'string', maxLength = 100) => {
if (!input) return false;
if (typeof input !== type) return false;
if (type === 'string' && input.length > maxLength) return false;
return true;
};
// ── i18n middleware ──
app.use((req, res, next) => {
const lang = getLang(req);
if (req.query.lang && locales[req.query.lang]) {
res.cookie('lang', lang, { maxAge: 365 * 24 * 60 * 60 * 1000, path: '/', sameSite: 'Lax' });
}
res.locals.lang = lang;
// Second param is an optional params object: t('key.name', { cap: 10 })
// substitutes `{cap}` placeholders inside the translated string.
res.locals.t = (key, params) => {
let val = t(lang, key);
if (params && typeof val === 'string') {
for (const k of Object.keys(params)) {
val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(params[k]));
}
}
return val;
};
// Only serialize locale JSON for page renders, not API requests
if (!req.path.startsWith('/api/') && !req.path.startsWith('/api-key')) {
res.locals.localeJson = JSON.stringify(mergeLocale(locales.en, locales[lang]));
}
next();
});
// Routes
app.get('/', (req, res) => {
const siteUrl = (process.env.PRODUCTION_DOMAIN || 'https://sre.pawjob.us').replace(/\/$/, '');
res.render('index', {
botName: 'Toothless SQB Bot',
metaTitle: "Toothless' SRE Bot",
metaDescription: 'The Best Squadron Battles Bot.',
metaUrl: `${siteUrl}/`,
metaThemeColor: '#90EE90',
serverCount: '1,234',
userCount: '50,000+',
commandCount: '25+'
});
});
app.get('/terms', (req, res) => {
res.render('terms', {
botName: 'Toothless SQB Bot'
});
});
app.get('/docs', (req, res) => {
res.render('docs', {
botName: 'Toothless SQB Bot'
});
});
app.get('/premium', (req, res) => {
const stdCheckout = process.env.WHOP_PLAN_ID_STANDARD || null;
const proCheckout = process.env.WHOP_PLAN_ID_PRO || null;
const maxCheckout = process.env.WHOP_PLAN_ID_MAX || null;
res.render('premium', {
botName: 'Toothless SQB Bot',
whopPlanId: stdCheckout, // back-compat for any template still reading this
plans: {
standard: {
whopPlanId: stdCheckout,
price: '2.99',
squadCap: 10,
},
pro: {
whopPlanId: proCheckout,
price: process.env.WHOP_PLAN_PRICE_PRO || null,
squadCap: 25,
},
max: {
whopPlanId: maxCheckout,
price: process.env.WHOP_PLAN_PRICE_MAX || null,
squadCap: null,
},
},
success: req.query.success === 'true'
});
});
// Highest-rank tier across the 3 entitlement sources — mirrors the bot's resolver.
function pickHigherTier(a, b) {
const order = { standard: 0, pro: 1, max: 2 };
if (!a) return b || null;
if (!b) return a;
return order[b] > order[a] ? b : a;
}
app.get('/api/check-premium/:guildId', (req, res) => {
const { guildId } = req.params;
if (!/^\d{17,19}$/.test(guildId)) return res.json({ premium: false, tier: null });
const nowSec = Math.floor(Date.now() / 1000);
entitlementsDb.get(
"SELECT tier FROM guild_entitlements WHERE guild_id=? AND status='active'",
[guildId],
(err, row) => {
if (err) return res.json({ premium: false, tier: null });
let tier = row ? (row.tier || 'standard') : null;
entitlementsDb.get(
"SELECT tier FROM manual_entitlements WHERE guild_id=? AND expires_at > ?",
[guildId, nowSec],
(err2, manualRow) => {
if (err2) return res.json({ premium: !!tier, tier });
if (manualRow) tier = pickHigherTier(tier, manualRow.tier || 'standard');
entitlementsDb.get(
"SELECT tier FROM discord_entitlements WHERE guild_id=?",
[guildId],
(err3, discordRow) => {
if (err3) return res.json({ premium: !!tier, tier });
if (discordRow) tier = pickHigherTier(tier, discordRow.tier || 'standard');
res.json({ premium: !!tier, tier });
}
);
}
);
}
);
});
const RECAP_THEMES = new Set(['light', 'dark']);
const RECAP_DEFAULT_THEME = 'dark';
const RECAP_LANGS = new Set(['cs', 'de', 'en', 'es', 'fr', 'it', 'pl', 'ru', 'uk', 'zh-CN']);
const RECAP_DEFAULT_LANG = 'en';
function spawnRender({ mode, clanId, uid, season, seasonStart, seasonEnd,
weekBoundaries, outPath, theme, lang }) {
return new Promise((resolve, reject) => {
const args = [
RECAP_SCRIPT,
'--mode', mode,
'--season', season,
'--season-start', String(seasonStart),
'--season-end', String(seasonEnd),
'--week-boundaries', weekBoundaries.join(','),
'--theme', theme,
'--lang', lang,
'--out', outPath,
];
if (mode === 'squadron') args.push('--clan-id', String(clanId));
if (mode === 'player') args.push('--uid', String(uid));
const child = execFile(PYTHON_BIN, args, {
cwd: REPO_ROOT,
timeout: RECAP_RENDER_TIMEOUT_MS,
maxBuffer: 2 * 1024 * 1024,
}, (err, stdout, stderr) => {
if (err) {
err.stderr = stderr;
err.stdout = stdout;
return reject(err);
}
resolve({ stdout, stderr });
});
});
}
const SEASON_NAME_RE = /^\d{4}-(I{1,3}|IV|VI{0,3}|IX|X)$/;
app.get('/squadron/:clan_id/recap/:season.png', async (req, res) => {
const t0 = Date.now();
const clanId = parseInt(req.params.clan_id, 10);
const season = req.params.season;
if (!Number.isInteger(clanId) || clanId <= 0) {
return res.status(400).json({ error: 'invalid clan_id' });
}
if (!SEASON_NAME_RE.test(season)) {
return res.status(400).json({ error: 'invalid season format' });
}
const range = seasonsUtil.getSeasonRange(season);
if (!range) {
return res.status(400).json({ error: 'unknown season' });
}
const themeParam = typeof req.query.theme === 'string' ? req.query.theme : '';
const theme = RECAP_THEMES.has(themeParam) ? themeParam : RECAP_DEFAULT_THEME;
// getLang() checks ?lang= then the lang cookie then falls back to 'en'.
const resolvedLang = getLang(req);
const lang = RECAP_LANGS.has(resolvedLang) ? resolvedLang : RECAP_DEFAULT_LANG;
const cacheDir = path.join(SQUADRON_RECAP_CACHE_DIR, season);
const cachePath = path.join(cacheDir, `${clanId}-${theme}-${lang}.png`);
let serveFromCache = false;
try {
const stat = await fsp.stat(cachePath);
if (range.status === 'completed') {
serveFromCache = true;
} else if (Date.now() - stat.mtimeMs < RECAP_TTL_MS) {
serveFromCache = true;
}
} catch (_) { /* cache miss */ }
if (!serveFromCache) {
try {
await fsp.mkdir(cacheDir, { recursive: true });
const weeks = seasonsUtil.getWeekBoundaries(season);
await spawnRender({
mode: 'squadron',
clanId, season,
seasonStart: range.start,
seasonEnd: range.end,
weekBoundaries: weeks,
outPath: cachePath,
theme,
lang,
});
} catch (err) {
console.error(`(RECAP) render failed clan_id=${clanId} season=${season} theme=${theme} lang=${lang}:`,
err.message, '\nstderr:', err.stderr);
// Best-effort cleanup of any orphan .tmp from a crashed subprocess.
fsp.unlink(cachePath + '.tmp').catch(() => {});
return res.status(err.killed ? 504 : 500).json({ error: 'render failed' });
}
}
const download = req.query.download === '1';
if (download) {
res.setHeader('Content-Disposition',
`attachment; filename="${clanId}-${season}-${theme}-${lang}.png"`);
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', range.status === 'completed' ? 'public, max-age=31536000' : 'public, max-age=86400');
res.sendFile(cachePath, (err) => {
if (err) console.error('(RECAP) sendFile error:', err);
console.log(`(RECAP) clan_id=${clanId} season=${season} theme=${theme} lang=${lang} result=${serveFromCache ? 'cache' : 'fresh'} ms=${Date.now() - t0}`);
});
});
app.get('/players/:uid/recap/:season.png', async (req, res) => {
const t0 = Date.now();
const uid = req.params.uid;
const season = req.params.season;
if (!/^\d+$/.test(uid)) {
return res.status(400).json({ error: 'invalid uid' });
}
if (!SEASON_NAME_RE.test(season)) {
return res.status(400).json({ error: 'invalid season format' });
}
const range = seasonsUtil.getSeasonRange(season);
if (!range) {
return res.status(400).json({ error: 'unknown season' });
}
const themeParam = typeof req.query.theme === 'string' ? req.query.theme : '';
const theme = RECAP_THEMES.has(themeParam) ? themeParam : RECAP_DEFAULT_THEME;
const resolvedLang = getLang(req);
const lang = RECAP_LANGS.has(resolvedLang) ? resolvedLang : RECAP_DEFAULT_LANG;
const cacheDir = path.join(PLAYER_RECAP_CACHE_DIR, season);
const cachePath = path.join(cacheDir, `${uid}-${theme}-${lang}.png`);
let serveFromCache = false;
try {
const stat = await fsp.stat(cachePath);
if (range.status === 'completed') {
serveFromCache = true;
} else if (Date.now() - stat.mtimeMs < RECAP_TTL_MS) {
serveFromCache = true;
}
} catch (_) { /* cache miss */ }
if (!serveFromCache) {
try {
await fsp.mkdir(cacheDir, { recursive: true });
const weeks = seasonsUtil.getWeekBoundaries(season);
await spawnRender({
mode: 'player',
uid, season,
seasonStart: range.start,
seasonEnd: range.end,
weekBoundaries: weeks,
outPath: cachePath,
theme,
lang,
});
} catch (err) {
console.error(`(RECAP) player render failed uid=${uid} season=${season} theme=${theme} lang=${lang}:`,
err.message, '\nstderr:', err.stderr);
fsp.unlink(cachePath + '.tmp').catch(() => {});
return res.status(err.killed ? 504 : 500).json({ error: 'render failed' });
}
}
const download = req.query.download === '1';
if (download) {
res.setHeader('Content-Disposition',
`attachment; filename="player-${uid}-${season}-${theme}-${lang}.png"`);
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', range.status === 'completed' ? 'public, max-age=31536000' : 'public, max-age=86400');
res.sendFile(cachePath, (err) => {
if (err) console.error('(RECAP) player sendFile error:', err);
console.log(`(RECAP) player uid=${uid} season=${season} theme=${theme} lang=${lang} result=${serveFromCache ? 'cache' : 'fresh'} ms=${Date.now() - t0}`);
});
});
app.get('/api/seasons', (req, res) => {
try {
res.json(seasonsUtil.getSeasonDetails());
} catch (err) {
console.error('(RECAP) /api/seasons error:', err);
res.status(500).json({ error: 'seasons parse failed' });
}
});
// Add missing support route
app.get('/support', (req, res) => {
const supportUrl = 'https://discord.gg/BCvkK8JhPe';
res.redirect(supportUrl);
});
// Leaderboard routes
app.get('/leaderboard/players', (req, res) => {
res.render('leaderboard-players', {
botName: 'Toothless SQB Bot'
});
});
app.get('/leaderboard/vehicles', (req, res) => {
res.render('leaderboard-vehicles', {
botName: 'Toothless SQB Bot'
});
});
app.get('/leaderboard/stats', (req, res) => {
res.render('leaderboard-stats', {
botName: 'Toothless SQB Bot'
});
});
app.get('/leaderboard/comparison', (req, res) => {
res.render('leaderboard-comparison', {
botName: 'Toothless SQB Bot'
});
});
app.get('/leaderboard/squadrons', (req, res) => {
res.render('leaderboard-squadrons', {
botName: 'Toothless SQB Bot'
});
});
// Redirect /leaderboard to players leaderboard by default
app.get('/leaderboard', (req, res) => {
res.redirect('/leaderboard/players');
});
app.get('/analytics', (req, res) => {
res.render('analytics', {
botName: 'Toothless SQB Bot'
});
});
// Squadron routes
app.get('/squadrons', (req, res) => {
res.render('squadrons', {
botName: 'Toothless SQB Bot'
});
});
app.get('/squadrons/:squadronname', async (req, res) => {
const squadronName = req.params.squadronname;
if (!squadronName) {
return res.status(400).render('404', {
statusCode: 400,
error: 'Invalid squadron name.',
botName: 'Toothless SQB Bot'
});
}
const apiBase = process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000';
// Canonical URLs are /squadrons/<clan_id> (numeric). When the param is
// non-numeric we treat it as a (possibly historical) squadron name and
// 301-redirect to the canonical clan_id URL. The API's resolve endpoint
// with `name=` is alias-aware (squadron_name_aliases + squadron_name_history
// + player_games_hist), so renamed squadrons resolve to their current id.
if (!/^\d+$/.test(squadronName)) {
try {
const lookupUrl = `${apiBase}/api/squadrons/resolve?name=${encodeURIComponent(squadronName)}`;
const lookupResp = await fetch(lookupUrl);
if (lookupResp.ok) {
const { results } = await lookupResp.json();
const hit = Array.isArray(results) ? results.find(r => r && r.resolved && r.clan_id != null) : null;
if (hit) {
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
return res.redirect(301, `/squadrons/${hit.clan_id}${qs}`);
}
}
} catch (err) {
log.error('[Squadron Route] clan_id lookup failed', err);
}
return res.status(404).render('404', {
error: `Squadron "${squadronName}" not found.`,
botName: 'Toothless SQB Bot'
});
}
try {
// Build query parameters for date filtering
const queryParams = new URLSearchParams();
if (req.query.start_date) queryParams.append('start_date', req.query.start_date);
if (req.query.end_date) queryParams.append('end_date', req.query.end_date);
const cacheKey = `${squadronName}:${queryParams.toString()}`;
const cachedSquadronData = squadronProfileCache.get(cacheKey);
if (cachedSquadronData) {
log.debug(`[Squadron Cache] HIT for ${cacheKey}`);
return res.render('squadron-profile', {
botName: 'Toothless SQB Bot',
squadronData: cachedSquadronData
});
}
log.debug(`[Squadron Cache] MISS for ${cacheKey}`);
if (pendingSquadronRequests.has(cacheKey)) {
log.debug(`[Squadron Cache] Waiting for in-flight request: ${cacheKey}`);
const inFlightData = await pendingSquadronRequests.get(cacheKey);
return res.render('squadron-profile', {
botName: 'Toothless SQB Bot',
squadronData: inFlightData
});
}
// Fetch squadron data from the API with query parameters
const apiUrl = `${apiBase}/api/squadrons/${encodeURIComponent(squadronName)}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const requestPromise = (async () => {
// Log filter parameters for debugging
if (queryParams.toString()) {
log.debug(`[Squadron Route] Fetching squadron ${squadronName} with filters: ${queryParams.toString()}`);
} else {
log.debug(`[Squadron Route] Fetching squadron ${squadronName} without filters`);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SQUADRON_API_TIMEOUT_MS);
try {
const response = await fetch(apiUrl, { signal: controller.signal });
if (!response.ok) {
if (response.status === 404) {
const err = new Error(`Squadron "${squadronName}" not found.`);
err.statusCode = 404;
throw err;
}
throw new Error(`API returned status ${response.status}`);
}
const squadronData = await response.json();
squadronProfileCache.set(cacheKey, squadronData);
return squadronData;
} finally {
clearTimeout(timeoutId);
pendingSquadronRequests.delete(cacheKey);
}
})();
pendingSquadronRequests.set(cacheKey, requestPromise);
const squadronData = await requestPromise;
res.render('squadron-profile', {
botName: 'Toothless SQB Bot',
squadronData: squadronData
});
} catch (error) {
log.error('Error fetching squadron data:', error);
let errorMessage = 'Unable to fetch squadron data. Please try again later.';
let statusCode = 503;
if (error.statusCode === 404) {
errorMessage = error.message;
statusCode = 404;
}
if (error.name === 'AbortError') {
errorMessage = 'Squadron data request timed out. This usually means the backend API is processing a large filtered query. Try without filters or try again in a moment.';
statusCode = 504;
log.error(`[Squadron Route] Request timed out for ${squadronName} with params: ${req.query.start_date ? 'filtered' : 'unfiltered'}`);
} else if (error.message.includes('ECONNREFUSED')) {
errorMessage = 'Backend API is not running. Please ensure the API server on port 6000 is started.';
}
res.status(statusCode).render('404', {
statusCode,
error: errorMessage,
botName: 'Toothless SQB Bot'
});
}
});
// Player profile route - must come before the 404 handler
app.get('/players/:uid', async (req, res) => {
const uid = req.params.uid;
// Validate UID format (should be numeric)
if (!uid || !/^\d+$/.test(uid)) {
return res.status(400).render('404', {
statusCode: 400,
error: 'Invalid player UID format. UID must be numeric.',
botName: 'Toothless SQB Bot'
});
}
try {
// Build query parameters for date filtering
const queryParams = new URLSearchParams();
if (req.query.start_date) queryParams.append('start_date', req.query.start_date);
if (req.query.end_date) queryParams.append('end_date', req.query.end_date);
// Fetch player data from the API with query parameters
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/player/${uid}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
// Log filter parameters for debugging
if (queryParams.toString()) {
log.debug(`[Player Route] Fetching player ${uid} with filters: ${queryParams.toString()}`);
}
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(30000) });
if (!response.ok) {
if (response.status === 404) {
const rosterMember = await getRosterMemberByUid(uid);
if (rosterMember) {
const playerData = {
uid,
nick: rosterMember.nick || uid,
squadron_name: rosterMember.tag_name || rosterMember.short_name || '',
squadron_long_name: rosterMember.long_name || rosterMember.tag_name || rosterMember.short_name || '',
squadron_clan_id: rosterMember.clan_id != null ? Number(rosterMember.clan_id) : null,
performance: 0,
vehicles: [],
total_vehicles: 0,
no_stats_yet: true
};
const totals = {
totalBattles: 0,
totalWins: 0,
totalGroundKills: 0,
totalAirKills: 0,
totalKills: 0,
totalAssists: 0,
totalCaptures: 0,
totalDeaths: 0,
overallKDR: 0,
overallWinRate: '0.0%'
};
return res.render('player', {
botName: 'Toothless SQB Bot',
playerData,
totals
});
}
return res.status(404).render('404', {
error: `Player with UID ${uid} not found.`,
botName: 'Toothless SQB Bot'
});
}
throw new Error(`API returned status ${response.status}`);
}
const playerData = await response.json();
// Squadron data is now included in the player API response
playerData.squadron_long_name = playerData.squadron_long_name || playerData.squadron_name;
playerData.squadron_tag_name = playerData.squadron_name;
// Calculate totals for summary display
const totals = {
totalBattles: 0,
totalWins: 0,
totalGroundKills: 0,
totalAirKills: 0,
totalKills: 0,
totalAssists: 0,
totalCaptures: 0,
totalDeaths: 0,
overallKDR: 0,
overallWinRate: '0.0%'
};
if (playerData.vehicles && playerData.vehicles.length > 0) {
playerData.vehicles.forEach(vehicle => {
totals.totalBattles += vehicle.stats.total_battles || 0;
totals.totalWins += vehicle.stats.wins || 0;
totals.totalGroundKills += vehicle.stats.ground_kills || 0;
totals.totalAirKills += vehicle.stats.air_kills || 0;
totals.totalAssists += vehicle.stats.assists || 0;
totals.totalCaptures += vehicle.stats.captures || 0;
totals.totalDeaths += vehicle.stats.deaths || 0;
});
// Calculate total kills, overall KDR, and overall win rate
totals.totalKills = totals.totalGroundKills + totals.totalAirKills;
totals.overallKDR = totals.totalDeaths > 0 ?
(totals.totalKills / totals.totalDeaths).toFixed(2) :
(totals.totalKills > 0 ? totals.totalKills.toFixed(2) : '0.00');
totals.overallWinRate = totals.totalBattles > 0 ?
((totals.totalWins / totals.totalBattles) * 100).toFixed(1) + '%' : '0.0%';
}
// Debug logging
log.debug('Player data totals:', totals);
res.render('player', {
botName: 'Toothless SQB Bot',
playerData: playerData,
totals: totals
});
} catch (error) {
log.error('Error fetching player data:', error);
let errorMessage = 'Unable to fetch player data. Please try again later.';
let statusCode = 503;
if (error.name === 'AbortError') {
errorMessage = 'Player data request timed out. The backend API is taking too long to respond — try again in a moment.';
statusCode = 504;
} else if (error.message && error.message.includes('ECONNREFUSED')) {
errorMessage = 'Backend API is not running. Please ensure the API server on port 6000 is started.';
}
res.status(statusCode).render('404', {
statusCode,
error: errorMessage,
botName: 'Toothless SQB Bot'
});
}
});
// Squadron profile route - must come before the 404 handler
// Player data API proxy - returns player profile and vehicle stats
app.get('/api/player/:uid', cors(apiCorsOptions), async (req, res) => {
const uid = req.params.uid;
// Validate UID format (should be numeric)
if (!uid || !/^\d+$/.test(uid)) {
return res.status(400).json({
error: 'Invalid UID format',
message: 'UID must be numeric',
uid: uid
});
}
try {
// Fetch player data from the external API, forwarding any date filter params
const backendParams = new URLSearchParams();
if (req.query.start_date) backendParams.append('start_date', req.query.start_date);
if (req.query.end_date) backendParams.append('end_date', req.query.end_date);
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/player/${uid}${backendParams.toString() ? '?' + backendParams.toString() : ''}`;
log.debug(`Attempting to fetch player data from: ${apiUrl}`);
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(30000) });
log.debug(`Player API response status: ${response.status}`);
if (!response.ok) {
if (response.status === 404) {
return res.status(404).json({
error: 'Player not found',
message: 'No player found with the provided UID',
uid: uid
});
}
const errorText = await response.text();
log.error(`Player API error response: ${errorText}`);
return res.status(response.status).json({
error: 'External API error',
message: 'Failed to fetch player data from external API',
details: errorText,
uid: uid
});
}
const playerData = await response.json();
log.debug(`Successfully fetched data for player ${uid} with ${playerData.vehicles?.length || 0} vehicles`);
res.json(playerData);
} catch (error) {
log.error(`Error fetching data for player ${uid}:`, error);
if (error && error.name === 'AbortError') {
return res.status(504).json({
error: 'Upstream timeout',
message: 'Player data request timed out',
uid: uid
});
}
if (error && (error.code === 'ECONNREFUSED' || (error.message || '').includes('fetch'))) {
return res.status(503).json({
error: 'Upstream unavailable',
message: 'Unable to connect to the stats API',
uid: uid
});
}
res.status(500).json({
error: 'Internal server error',
message: 'An error occurred while fetching player data',
uid: uid
});
}
});
// Player games API proxy - returns individual game records
app.get('/api/player/:uid/games', cors(apiCorsOptions), async (req, res) => {
const uid = req.params.uid;
// Validate UID format (should be numeric)
if (!uid || !/^\d+$/.test(uid)) {
return res.status(400).json({
error: 'Invalid player UID format. UID must be numeric.',
uid: uid,
nick: '',
games: [],
total_games_returned: 0
});
}
try {
// Fetch games data from the external API, forwarding any active date filters
const backendParams = new URLSearchParams();
if (req.query.start_date) backendParams.append('start_date', req.query.start_date);
if (req.query.end_date) backendParams.append('end_date', req.query.end_date);
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/player/${uid}/games${backendParams.toString() ? '?' + backendParams.toString() : ''}`;
log.debug(`Attempting to fetch games from: ${apiUrl}`);
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(30000) });
log.debug(`Games API response status: ${response.status}`);
if (!response.ok) {
if (response.status === 404) {
log.debug('Games API returned 404, no games found');
return res.status(404).json({
error: `No games found for player with UID ${uid}`,
uid: uid,
nick: '',
games: [],
total_games_returned: 0
});
}
throw new Error(`Games API returned status ${response.status}`);
}
const gamesData = await response.json();
log.debug('Games data received:', gamesData);
res.json(gamesData);
} catch (error) {
log.error('Error in games proxy:', error);
if (error && error.name === 'AbortError') {
return res.status(504).json({
error: 'Player games request timed out. The backend API took too long to respond.',
uid: uid,
nick: '',
games: [],
total_games_returned: 0
});
}
// Check if it's a connection error
if (error.code === 'ECONNREFUSED' || (error.message || '').includes('fetch')) {
return res.status(503).json({
error: 'Unable to connect to the stats API. Please ensure the API server is running on port 6000.',
uid: uid,
nick: '',
games: [],
total_games_returned: 0
});
}
res.status(500).json({
error: 'Failed to fetch player games data',
uid: uid,
nick: '',
games: [],
total_games_returned: 0
});
}
});
app.get('/api/player/:uid/history', cors(apiCorsOptions), async (req, res) => {
try {
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/player/${req.params.uid}/history`;
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(30000) });
if (!response.ok) return res.status(response.status).json({ error: 'API error' });
res.json(await response.json());
} catch (err) {
if (err && err.name === 'AbortError') {
return res.status(504).json({ error: 'Player history request timed out' });
}
res.status(500).json({ error: 'Failed to fetch player history' });
}
});
// Player search API proxy with caching and request deduplication
app.get('/api/search/:nickname', cors(apiCorsOptions), async (req, res) => {
const nickname = req.params.nickname;
// Validate nickname
if (!nickname || nickname.length < 2) {
return res.status(400).json({
error: 'Nickname must be at least 2 characters long',
search_term: nickname,
results: [],
total_found: 0,
limited_to: 50
});
}
// Normalize search term for cache key (lowercase, trimmed)
const cacheKey = nickname.toLowerCase().trim();
try {
// Check cache first
const cachedResult = searchCache.get(cacheKey);
if (cachedResult) {
log.debug(`[SEARCH CACHE] Cache HIT for: ${nickname}`);
return res.json(cachedResult);
}
log.debug(`[SEARCH CACHE] Cache MISS for: ${nickname}`);
// Check if there's already a pending request for this search
if (pendingSearchRequests.has(cacheKey)) {
log.debug(`[SEARCH DEDUP] Waiting for existing request: ${nickname}`);
// Wait for the existing request to complete
const result = await pendingSearchRequests.get(cacheKey);
return res.json(result);
}
// Create a new request promise
const requestPromise = (async () => {
try {
// Fetch from the external API
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/search/${encodeURIComponent(nickname)}`;
log.debug(`[SEARCH] Fetching from: ${apiUrl}`);
const response = await fetch(apiUrl);
log.debug(`[SEARCH] API response status: ${response.status}`);
if (!response.ok) {
if (response.status === 404) {
log.debug('[SEARCH] API returned 404, no players found');
const emptyResult = {
search_term: nickname,
results: [],
total_found: 0,
limited_to: 50
};
// Cache empty results too (prevents repeated failed lookups)
searchCache.set(cacheKey, emptyResult);
return emptyResult;
}
throw new Error(`API returned status ${response.status}`);
}
const searchData = await response.json();
log.debug(`[SEARCH] Found ${searchData.results?.length || 0} results for: ${nickname}`);
// Cache the successful result
searchCache.set(cacheKey, searchData);
return searchData;
} finally {
// Remove from pending requests
pendingSearchRequests.delete(cacheKey);
}
})();
// Store the pending request
pendingSearchRequests.set(cacheKey, requestPromise);
// Wait for and return the result
const searchData = await requestPromise;
res.json(searchData);
} catch (error) {
log.error('[SEARCH] Error in search proxy:', error);
// Clean up pending request on error
pendingSearchRequests.delete(cacheKey);
// Check if it's a connection error
if (error.code === 'ECONNREFUSED' || error.message.includes('fetch')) {
return res.status(503).json({
error: 'Unable to connect to the stats API. Please ensure the API server is running on port 6000.',
search_term: nickname,
results: [],
total_found: 0,
limited_to: 50
});
}
res.status(500).json({
error: 'Failed to search players',
search_term: nickname,
results: [],
total_found: 0,
limited_to: 50
});
}
});
// Vehicle icons API - returns mapping of vehicle names to icon paths
app.get('/api/vehicle-icons', cors(apiCorsOptions), async (req, res) => {
try {
const fs = require('fs').promises;
const path = require('path');
// Scan the ICONS/VEHICLES directory
const iconsDir = path.join(__dirname, 'ICONS', 'VEHICLES');
try {
const files = await fs.readdir(iconsDir);
const vehicleIcons = {};
// Create mappings for each icon file
files.forEach(file => {
if (file.endsWith('.png')) {
const iconName = file.replace('.png', '');
// Create multiple possible lookup keys for this icon
const keys = [
iconName, // Original name: germ_begleitpanzer_57
iconName.replace(/_/g, ' '), // Underscores to spaces: germ begleitpanzer 57
iconName.replace(/_/g, '-'), // Underscores to hyphens: germ-begleitpanzer-57
iconName.replace(/^[a-z]+_/, ''), // Remove country prefix: begleitpanzer_57
iconName.replace(/^[a-z]+_/, '').replace(/_/g, ' '), // Remove prefix + spaces: begleitpanzer 57
iconName.replace(/^[a-z]+_/, '').replace(/_/g, '-'), // Remove prefix + hyphens: begleitpanzer-57
];
// Add all variations as keys pointing to the same icon path
keys.forEach(key => {
if (key && key.length > 2 && typeof key === 'string') { // Ensure it's a valid string
vehicleIcons[key.toLowerCase()] = `/ICONS/VEHICLES/${file}`;
}
});
}
});
log.debug(`Serving ${Object.keys(vehicleIcons).length} vehicle icon mappings`);
res.json({
success: true,
count: files.length,
mappings: vehicleIcons
});
} catch (dirError) {
log.warn('[WARN] ICONS/VEHICLES directory not found, returning empty mappings');
res.json({
success: true,
count: 0,
mappings: {}
});
}
} catch (error) {
log.error('Error generating vehicle icon mappings:', error);
res.status(500).json({
success: false,
error: 'Failed to generate vehicle icon mappings',
mappings: {}
});
}
});
// ── Single match detail proxy ──
app.get('/api/match/:sessionId', cors(apiCorsOptions), async (req, res) => {
const { sessionId } = req.params;
if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) {
return res.status(400).json({ error: 'Invalid session ID format' });
}
try {
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/match/${sessionId}`;
const response = await fetch(apiUrl);
if (!response.ok) {
if (response.status === 404) return res.status(404).json({ error: 'Match not found' });
throw new Error(`API returned ${response.status}`);
}
res.json(await response.json());
} catch (error) {
log.error('[Match API] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch match data' });
}
});
// ── Match replay data proxy ──
app.get('/api/match/:sessionId/replay', cors(apiCorsOptions), async (req, res) => {
const { sessionId } = req.params;
if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) {
return res.status(400).json({ error: 'Invalid session ID format' });
}
try {
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/match/${sessionId}/replay`;
const response = await fetch(apiUrl);
if (!response.ok) throw new Error(`API returned ${response.status}`);
res.json(await response.json());
} catch (error) {
log.error('[Replay API] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch replay data' });
}
});
// ── Match replay video ──
let _videoRenderCount = 0;
const _videoRenderMax = 2;
app.get('/api/match/:sessionId/video', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) {
return res.status(400).json({ error: 'Invalid session ID format' });
}
const sessionDir = resolveReplaySessionDir(sessionId);
const videoPath = path.join(sessionDir, 'replay_video.mp4');
const replayPath = path.join(sessionDir, 'replay_data.json');
// 1. Serve from disk if cached
if (fs.existsSync(videoPath)) {
return res.sendFile(videoPath, {
headers: { 'Content-Type': 'video/mp4', 'Cache-Control': 'public, max-age=86400' }
});
}
// 2. Check if replay JSON exists for generation
if (!fs.existsSync(replayPath)) {
return res.status(404).json({ available: false, reason: 'No replay data available for this session' });
}
// 3. Generate video on demand (decompress + render)
if (_videoRenderCount >= _videoRenderMax) {
return res.status(503).json({ available: false, reason: 'Too many videos rendering — try again shortly' });
}
_videoRenderCount++;
try {
await new Promise((resolve, reject) => {
const pythonBin = path.join(__dirname, '..', '.venv', 'bin', 'python');
execFile(pythonBin, ['-m', 'BOT.render_replay', replayPath, videoPath], {
timeout: 120000,
cwd: path.join(__dirname, '..')
}, (error, stdout, stderr) => {
if (error) {
log.error('[Video] Generation stderr:', stderr);
reject(new Error(stderr || error.message));
} else {
resolve();
}
});
});
if (!fs.existsSync(videoPath)) {
return res.status(500).json({ available: false, reason: 'Video generation produced no output' });
}
res.sendFile(videoPath, {
headers: { 'Content-Type': 'video/mp4', 'Cache-Control': 'public, max-age=86400' }
});
} catch (error) {
log.error('[Video] Error generating video:', error);
// Clean up broken/partial mp4 so it doesn't get cached
try { if (fs.existsSync(videoPath)) fs.unlinkSync(videoPath); } catch (_) {}
res.status(500).json({ available: false, reason: 'Video generation failed' });
} finally {
_videoRenderCount--;
}
});
// ── Match replay canvas JSON ──
let _canvasRenderCount = 0;
const _canvasRenderMax = 3;
app.get('/api/match/:sessionId/replay-canvas', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) {
return res.status(400).json({ error: 'Invalid session ID format' });
}
const sessionDir = resolveReplaySessionDir(sessionId);
const jsonPath = path.join(sessionDir, 'replay_canvas.json');
const replayPath = path.join(sessionDir, 'replay_data.json');
// 1. Serve from disk if cached
if (fs.existsSync(jsonPath)) {
return res.sendFile(jsonPath, {
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=86400' }
});
}
// 2. Check if replay JSON exists
if (!fs.existsSync(replayPath)) {
return res.status(404).json({ available: false, reason: 'No replay data available' });
}
// 3. Generate on demand
if (_canvasRenderCount >= _canvasRenderMax) {
return res.status(503).json({ available: false, reason: 'Too many replays processing — try again shortly' });
}
_canvasRenderCount++;
try {
await new Promise((resolve, reject) => {
const pythonBin = path.join(__dirname, '..', '.venv', 'bin', 'python');
execFile(pythonBin, ['-m', 'BOT.render_replay', replayPath, jsonPath], {
timeout: 30000,
cwd: path.join(__dirname, '..')
}, (error, stdout, stderr) => {
if (error) {
log.error('[ReplayCanvas] Generation stderr:', stderr);
reject(new Error(stderr || error.message));
} else {
resolve();
}
});
});
if (!fs.existsSync(jsonPath)) {
return res.status(500).json({ available: false, reason: 'JSON generation produced no output' });
}
res.sendFile(jsonPath, {
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=86400' }
});
} catch (error) {
log.error('[ReplayCanvas] Error generating JSON:', error);
try { if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath); } catch (_) {}
res.status(500).json({ available: false, reason: 'Replay JSON generation failed' });
} finally {
_canvasRenderCount--;
}
});
// ── Vehicle type icon serving (SHARED/ICONS, SHARED/ICONS/FALLBACKS, SHARED/ICONS/MINIS) ──
app.get('/api/icons/type/:name', (req, res) => {
const { name } = req.params;
if (!name || !/^[a-zA-Z0-9_\-]+$/.test(name)) {
return res.status(400).json({ error: 'Invalid icon name' });
}
const iconsBase = path.join(__dirname, '..', '..', 'SHARED', 'ICONS');
// Check: ICONS/ → FALLBACKS/ → MINIS/
const candidates = [
path.join(iconsBase, name + '.png'),
path.join(iconsBase, 'FALLBACKS', name + '.png'),
path.join(iconsBase, 'MINIS', name + '.png'),
];
for (const iconPath of candidates) {
if (fs.existsSync(iconPath)) {
return res.sendFile(iconPath, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800',
'Access-Control-Allow-Origin': '*'
}
});
}
}
res.status(404).json({ error: 'Icon not found' });
});
// ── Minimap image serving ──
app.get('/api/match/minimap/:level', (req, res) => {
const { level } = req.params;
if (!level || !/^[a-zA-Z0-9_]+$/.test(level)) {
return res.status(400).json({ error: 'Invalid level name' });
}
const suffix = req.query.type === 'full' ? '.png' : '_tankmap.png';
const minimapPath = path.join(__dirname, '..', '..', 'SHARED', 'MAPS', 'MINIMAPS', level + suffix);
if (!fs.existsSync(minimapPath)) {
return res.status(404).json({ error: 'Minimap not found' });
}
res.sendFile(minimapPath, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800',
'Access-Control-Allow-Origin': '*'
}
});
});
// ── Games search proxy ──
app.get('/api/games/search', cors(apiCorsOptions), async (req, res) => {
try {
const params = new URLSearchParams();
if (req.query.player) params.append('player', req.query.player);
if (req.query.map) params.append('map', req.query.map);
if (req.query.squadron) params.append('squadron', req.query.squadron);
if (req.query.time_from) params.append('time_from', req.query.time_from);
if (req.query.time_to) params.append('time_to', req.query.time_to);
if (req.query.limit) params.append('limit', req.query.limit);
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/games/search?${params}`;
const response = await fetch(apiUrl);
if (!response.ok) throw new Error(`API returned ${response.status}`);
res.json(await response.json());
} catch (error) {
log.error('[Games Search] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to search games' });
}
});
// ── Analytics proxy ──
app.get('/api/analytics/:view/:squadron', cors(apiCorsOptions), async (req, res) => {
try {
const { view, squadron } = req.params;
const allowedViews = new Set(['maps', 'time', 'comps', 'consistency', 'matchup']);
if (!allowedViews.has(view)) {
return res.status(400).json({ error: 'Unknown analytics view' });
}
const qs = new URLSearchParams(req.query).toString();
const base = process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000';
const apiUrl = `${base}/api/analytics/${view}/${encodeURIComponent(squadron)}${qs ? '?' + qs : ''}`;
const response = await fetch(apiUrl);
const body = await response.json();
res.status(response.status).json(body);
} catch (error) {
log.error('[Analytics Proxy] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch analytics' });
}
});
app.get('/api/i18n/vehicles', cors(apiCorsOptions), async (req, res) => {
try {
const base = process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000';
const r = await fetch(`${base}/api/i18n/vehicles`);
const body = await r.text();
res.set('Cache-Control', 'public, max-age=3600');
res.status(r.status).type('application/json').send(body);
} catch (error) {
log.error('[i18n vehicles proxy] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch vehicle translations' });
}
});
app.get('/api/i18n/vehicle-types', cors(apiCorsOptions), async (req, res) => {
try {
const base = process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000';
const r = await fetch(`${base}/api/i18n/vehicle-types`);
const body = await r.text();
res.set('Cache-Control', 'public, max-age=3600');
res.status(r.status).type('application/json').send(body);
} catch (error) {
log.error('[i18n vehicle types proxy] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch vehicle types' });
}
});
app.get('/api/analytics/vehicle-list', cors(apiCorsOptions), async (req, res) => {
try {
const base = process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000';
const r = await fetch(`${base}/api/analytics/vehicle-list`);
res.status(r.status).json(await r.json());
} catch (error) {
log.error('[Analytics Vehicle List] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch vehicle list' });
}
});
app.get('/api/analytics/vehicle/:view/:vehicle', cors(apiCorsOptions), async (req, res) => {
try {
const { view, vehicle } = req.params;
const allowedViews = new Set(['stats', 'players', 'squadrons', 'maps']);
if (!allowedViews.has(view)) return res.status(400).json({ error: 'Unknown analytics view' });
const qs = new URLSearchParams(req.query).toString();
const base = process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000';
const apiUrl = `${base}/api/analytics/vehicle/${view}/${encodeURIComponent(vehicle)}${qs ? '?' + qs : ''}`;
const response = await fetch(apiUrl);
const body = await response.json();
res.status(response.status).json(body);
} catch (error) {
log.error('[Analytics Vehicle Proxy] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch analytics' });
}
});
app.get('/api/analytics/player/:view/:uid', cors(apiCorsOptions), async (req, res) => {
try {
const { view, uid } = req.params;
const allowedViews = new Set(['maps', 'squadmates', 'time', 'timeline', 'matchup']);
if (!allowedViews.has(view)) {
return res.status(400).json({ error: 'Unknown analytics view' });
}
const qs = new URLSearchParams(req.query).toString();
const base = process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000';
const apiUrl = `${base}/api/analytics/player/${view}/${encodeURIComponent(uid)}${qs ? '?' + qs : ''}`;
const response = await fetch(apiUrl);
const body = await response.json();
res.status(response.status).json(body);
} catch (error) {
log.error('[Analytics Player Proxy] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch analytics' });
}
});
// ── Maps list proxy ──
app.get('/api/maps', cors(apiCorsOptions), async (req, res) => {
try {
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/maps`;
const response = await fetch(apiUrl);
if (!response.ok) throw new Error(`API returned ${response.status}`);
res.json(await response.json());
} catch (error) {
log.error('[Maps API] Error:', error);
if (error.code === 'ECONNREFUSED') return res.status(503).json({ error: 'Stats API unavailable' });
res.status(500).json({ error: 'Failed to fetch maps' });
}
});
// Squadron history API proxy (for client-side chart)
app.get('/api/squadrons/:name', cors(apiCorsOptions), async (req, res) => {
try {
const backendParams = new URLSearchParams();
if (req.query.start_date) backendParams.append('start_date', req.query.start_date);
if (req.query.end_date) backendParams.append('end_date', req.query.end_date);
if (req.query.season) backendParams.append('season', req.query.season);
if (req.query.week) backendParams.append('week', req.query.week);
const qs = backendParams.toString();
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/squadrons/${encodeURIComponent(req.params.name)}${qs ? '?' + qs : ''}`;
const response = await fetch(apiUrl);
if (!response.ok) {
if (response.status === 404) {
return res.status(404).json({ error: 'Squadron not found', players: [] });
}
return res.status(response.status).json({ error: 'Failed to fetch squadron details' });
}
const data = await response.json();
res.json(data);
} catch (err) {
log.error('Error fetching squadron details', err);
res.status(500).json({ error: 'Failed to fetch squadron details' });
}
});
app.get('/api/squadrons/:name/history', cors(apiCorsOptions), async (req, res) => {
try {
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/squadrons/${encodeURIComponent(req.params.name)}/history`;
const response = await fetch(apiUrl);
if (!response.ok) {
return res.status(response.status).json({ error: 'Failed to fetch squadron history' });
}
const data = await response.json();
res.json(data);
} catch (err) {
log.error('Error fetching squadron history', err);
res.status(500).json({ error: 'Failed to fetch squadron history' });
}
});
app.get('/api/squadrons/:name/games', cors(apiCorsOptions), async (req, res) => {
try {
const backendParams = new URLSearchParams();
if (req.query.start_date) backendParams.append('start_date', req.query.start_date);
if (req.query.end_date) backendParams.append('end_date', req.query.end_date);
if (req.query.season) backendParams.append('season', req.query.season);
if (req.query.week) backendParams.append('week', req.query.week);
const qs = backendParams.toString();
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/squadrons/${encodeURIComponent(req.params.name)}/games${qs ? '?' + qs : ''}`;
const response = await fetch(apiUrl);
if (!response.ok) {
if (response.status === 404) {
return res.status(404).json({ games: [], total_games_returned: 0, error: 'Squadron not found' });
}
return res.status(response.status).json({ error: 'Failed to fetch squadron games' });
}
const data = await response.json();
res.json(data);
} catch (err) {
log.error('Error fetching squadron games', err);
res.status(500).json({ error: 'Failed to fetch squadron games', games: [], total_games_returned: 0 });
}
});
// ── TSS proxy routes ───────────────────────────────────────────────────────
// Mirrors the /api/squadrons/* proxy block above but targets the upstream
// /api/tss/* endpoints (defined in SREBOT/server.js) which read tss_battles.db
// and tss_teams.db. Both /api/tss/teams/* and /api/tss/squadrons/* are
// supported upstream — we proxy the canonical /teams/* path here.
const TSS_BACKEND = () => process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000';
function _forwardDateParams(req) {
const qp = new URLSearchParams();
if (req.query.start_date) qp.append('start_date', req.query.start_date);
if (req.query.end_date) qp.append('end_date', req.query.end_date);
if (req.query.season) qp.append('season', req.query.season);
if (req.query.week) qp.append('week', req.query.week);
return qp;
}
app.get('/api/tss/teams/resolve', cors(apiCorsOptions), async (req, res) => {
try {
const name = req.query.name || req.query.q || req.query.team || '';
const apiUrl = `${TSS_BACKEND()}/api/tss/teams/resolve?name=${encodeURIComponent(name)}`;
const response = await fetch(apiUrl);
if (!response.ok) return res.status(response.status).json({ error: 'Failed to resolve TSS team' });
res.json(await response.json());
} catch (err) {
log.error('Error resolving TSS team', err);
res.status(500).json({ error: 'Failed to resolve TSS team' });
}
});
app.get('/api/tss/leaderboard/teams', cors(apiCorsOptions), async (req, res) => {
try {
const qp = _forwardDateParams(req);
if (req.query.limit) qp.append('limit', req.query.limit);
const qs = qp.toString();
const apiUrl = `${TSS_BACKEND()}/api/tss/leaderboard/teams${qs ? '?' + qs : ''}`;
const response = await fetch(apiUrl);
if (!response.ok) return res.status(response.status).json({ error: 'Failed to fetch TSS leaderboard', teams: [], total_teams: 0 });
res.json(await response.json());
} catch (err) {
log.error('Error in TSS leaderboard proxy', err);
res.status(500).json({ error: 'Failed to fetch TSS leaderboard', teams: [], total_teams: 0 });
}
});
app.get('/api/tss/teams/:name', cors(apiCorsOptions), async (req, res) => {
try {
const qs = _forwardDateParams(req).toString();
const apiUrl = `${TSS_BACKEND()}/api/tss/teams/${encodeURIComponent(req.params.name)}${qs ? '?' + qs : ''}`;
const response = await fetch(apiUrl);
if (!response.ok) {
if (response.status === 404) {
return res.status(404).json({ error: 'TSS team not found', players: [] });
}
return res.status(response.status).json({ error: 'Failed to fetch TSS team details' });
}
res.json(await response.json());
} catch (err) {
log.error('Error fetching TSS team details', err);
res.status(500).json({ error: 'Failed to fetch TSS team details' });
}
});
app.get('/api/tss/teams/:name/history', cors(apiCorsOptions), async (req, res) => {
try {
const apiUrl = `${TSS_BACKEND()}/api/tss/teams/${encodeURIComponent(req.params.name)}/history`;
const response = await fetch(apiUrl);
if (!response.ok) return res.status(response.status).json({ error: 'Failed to fetch TSS team history' });
res.json(await response.json());
} catch (err) {
log.error('Error fetching TSS team history', err);
res.status(500).json({ error: 'Failed to fetch TSS team history' });
}
});
app.get('/api/tss/teams/:name/games', cors(apiCorsOptions), async (req, res) => {
try {
const qs = _forwardDateParams(req).toString();
const apiUrl = `${TSS_BACKEND()}/api/tss/teams/${encodeURIComponent(req.params.name)}/games${qs ? '?' + qs : ''}`;
const response = await fetch(apiUrl);
if (!response.ok) {
if (response.status === 404) {
return res.status(404).json({ games: [], total_games_returned: 0, error: 'TSS team not found' });
}
return res.status(response.status).json({ error: 'Failed to fetch TSS team games' });
}
res.json(await response.json());
} catch (err) {
log.error('Error fetching TSS team games', err);
res.status(500).json({ error: 'Failed to fetch TSS team games', games: [], total_games_returned: 0 });
}
});
// API Routes with better error handling
let statsCache = null;
let statsCacheTime = 0;
const STATS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
app.get('/api/stats', cors(apiCorsOptions), async (req, res) => {
try {
const now = Date.now();
if (statsCache && (now - statsCacheTime) < STATS_CACHE_TTL) {
return res.json({ ...statsCache, uptime: Math.floor(process.uptime()) });
}
// Guild count from GUILD_REPORT.txt (bot writes one line per guild on startup)
let servers = 0;
const reportPath = path.join(STORAGE_ROOT, 'GUILD_REPORT.txt');
try {
const report = fs.readFileSync(reportPath, 'utf8').trim();
servers = report ? report.split('\n').length : 0;
} catch { /* file may not exist yet */ }
// Player count + battle count from backend API
let users = 0;
let battles = 0;
try {
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/leaderboard/stats`;
const response = await fetch(apiUrl);
if (response.ok) {
const data = await response.json();
users = data.total_players || 0;
battles = data.total_battles || 0;
}
} catch { /* backend may be down */ }
statsCache = { servers, users, battles, commands: 28 };
statsCacheTime = now;
res.json({ ...statsCache, uptime: Math.floor(process.uptime()) });
} catch (error) {
log.error('Error in /api/stats:', error);
res.status(500).json({ error: 'Failed to fetch stats' });
}
});
app.get('/api/invite', cors(apiCorsOptions), (req, res) => {
try {
// Real bot invite URL with proper permissions
const inviteUrl = 'https://discord.com/oauth2/authorize?client_id=1254679514466877540&permissions=2048&scope=bot%20applications.commands';
res.json({ inviteUrl });
} catch (error) {
log.error('Error in /api/invite:', error);
res.status(500).json({ error: 'Failed to get invite URL' });
}
});
app.get('/api/support', cors(apiCorsOptions), (req, res) => {
try {
// Real support server invite URL
const supportUrl = 'https://discord.gg/BCvkK8JhPe';
res.json({ supportUrl });
} catch (error) {
log.error('Error in /api/support:', error);
res.status(500).json({ error: 'Failed to get support URL' });
}
});
app.get('/api/bot-health', cors(apiCorsOptions), (req, res) => {
try {
const healthPath = path.join(STORAGE_ROOT, 'bot_health.json');
const data = JSON.parse(fs.readFileSync(healthPath, 'utf8'));
res.json(data);
} catch (error) {
res.status(503).json({ error: 'Health data unavailable' });
}
});
// Leaderboard API proxy routes
app.get('/api/leaderboard/players', cors(apiCorsOptions), async (req, res) => {
try {
const { start_date, end_date } = req.query;
// If date filter params are present, bypass cache and forward to API server
if (start_date || end_date) {
const backendParams = new URLSearchParams();
if (start_date) backendParams.append('start_date', start_date);
if (end_date) backendParams.append('end_date', end_date);
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/leaderboard/players?${backendParams.toString()}`;
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(30000) });
if (!response.ok) throw new Error(`API error: ${response.status}`);
return res.json(await response.json());
}
log.debug('[CACHE] Serving player leaderboard from cache...');
const cachedData = await getCachedData('players');
if (!cachedData) {
throw new Error('No cached data available');
}
log.debug(`[CACHE] Player leaderboard served from cache: ${cachedData.players?.length || 0} players`);
res.json(cachedData);
} catch (error) {
log.error('[ERROR] Error serving cached player leaderboard:', error);
res.status(500).json({
error: 'Failed to fetch player leaderboard data',
players: [],
pagination: {
current_page: 1,
total_pages: 0,
has_next: false,
has_previous: false
}
});
}
});
app.get('/api/leaderboard/vehicles', cors(apiCorsOptions), async (req, res) => {
try {
const { start_date, end_date, page = 1, limit = 25, sort = 'total_kills', order = 'desc', search, squadron, min_kills = 0, vehicle_internal } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 25));
const minKills = parseInt(min_kills) || 0;
let fullData;
// Get full dataset (from cache or backend for date-filtered requests)
if (start_date || end_date) {
const backendParams = new URLSearchParams();
if (start_date) backendParams.append('start_date', start_date);
if (end_date) backendParams.append('end_date', end_date);
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/leaderboard/vehicles?${backendParams.toString()}`;
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(30000) });
if (!response.ok) throw new Error(`API error: ${response.status}`);
fullData = await response.json();
} else {
const cachedData = await getCachedData('vehicles');
if (!cachedData) throw new Error('No cached data available');
fullData = cachedData;
}
const allVehicles = fullData.vehicles || [];
// Build squadron list from full dataset (before filtering)
const squadronCounts = {};
allVehicles.forEach(v => {
if (v.player_squadron_name) {
squadronCounts[v.player_squadron_name] = (squadronCounts[v.player_squadron_name] || 0) + 1;
}
});
const squadrons = Object.entries(squadronCounts)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }));
// Apply filters
let vehicles = allVehicles;
// Exact internal-id filter (selected from the translation-map picker on
// the vehicles leaderboard page). Distinguishes country variants that
// share a display name (e.g. F-16A USA vs F-16A Italy) which the old
// free-text `search` could not.
if (vehicle_internal) {
const target = String(vehicle_internal).toLowerCase();
vehicles = vehicles.filter(v =>
(v.vehicle_internal || '').toLowerCase() === target
);
}
let searchTerm = '';
if (search) {
searchTerm = search.replace(/\u00A0/g, ' ').toLowerCase().trim();
vehicles = vehicles.filter(v =>
(v.vehicle || '').replace(/\u00A0/g, ' ').toLowerCase().includes(searchTerm) ||
(v.player_nick || '').replace(/\u00A0/g, ' ').toLowerCase().includes(searchTerm)
);
}
if (squadron) {
const sqLower = squadron.toLowerCase();
vehicles = vehicles.filter(v =>
(v.player_squadron_name || '').toLowerCase() === sqLower
);
}
if (minKills > 0) {
vehicles = vehicles.filter(v => (v.total_kills || 0) >= minKills);
}
// Sort — when searching, prioritize vehicles whose name starts with the term
const validSorts = ['total_kills', 'ground_kills', 'air_kills', 'kdr', 'win_rate', 'battles', 'wins', 'deaths', 'assists', 'captures', 'vehicle', 'player_nick'];
const sortField = validSorts.includes(sort) ? sort : 'total_kills';
const sortDir = order === 'asc' ? 1 : -1;
vehicles.sort((a, b) => {
if (searchTerm) {
const aName = (a.vehicle || '').replace(/\u00A0/g, ' ').toLowerCase();
const bName = (b.vehicle || '').replace(/\u00A0/g, ' ').toLowerCase();
const aStarts = aName.startsWith(searchTerm) ? 0 : 1;
const bStarts = bName.startsWith(searchTerm) ? 0 : 1;
if (aStarts !== bStarts) return aStarts - bStarts;
}
let va = a[sortField];
let vb = b[sortField];
if (sortField === 'vehicle' || sortField === 'player_nick') {
va = (va || '').toLowerCase();
vb = (vb || '').toLowerCase();
} else {
va = parseFloat(va) || 0;
vb = parseFloat(vb) || 0;
}
return va > vb ? sortDir : va < vb ? -sortDir : 0;
});
// Paginate
const totalRows = vehicles.length;
const totalPages = Math.ceil(totalRows / limitNum) || 1;
const offset = (pageNum - 1) * limitNum;
const pageVehicles = vehicles.slice(offset, offset + limitNum);
res.json({
vehicles: pageVehicles,
pagination: {
page: pageNum,
limit: limitNum,
total_rows: totalRows,
total_pages: totalPages
},
squadrons,
date_filter: fullData.date_filter || null
});
} catch (error) {
log.error('Error in vehicle leaderboard proxy:', error);
res.status(500).json({
error: 'Failed to fetch vehicle leaderboard data',
vehicles: [],
pagination: { page: 1, limit: 25, total_rows: 0, total_pages: 0 },
squadrons: []
});
}
});
app.get('/api/leaderboard/stats', cors(apiCorsOptions), async (req, res) => {
try {
log.debug('[CACHE] Serving leaderboard statistics from cache...');
const cachedData = await getCachedData('stats');
if (!cachedData) {
throw new Error('No cached data available');
}
log.debug('[CACHE] Leaderboard statistics served from cache');
res.json(cachedData);
} catch (error) {
log.error('Error in leaderboard stats proxy:', error);
res.status(500).json({
error: 'Failed to fetch leaderboard statistics',
total_players: 0,
total_vehicles_used: 0,
total_battles: 0,
top_vehicles: [],
last_updated: new Date().toISOString()
});
}
});
app.get('/api/leaderboard/squadrons', cors(apiCorsOptions), async (req, res) => {
try {
const { start_date, end_date } = req.query;
// If date filter params are present, bypass cache and forward to API server
if (start_date || end_date) {
const backendParams = new URLSearchParams();
if (start_date) backendParams.append('start_date', start_date);
if (end_date) backendParams.append('end_date', end_date);
const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}/api/leaderboard/squadrons?${backendParams.toString()}`;
const response = await fetch(apiUrl);
if (!response.ok) throw new Error(`API error: ${response.status}`);
return res.json(await response.json());
}
log.debug('[CACHE] Serving squadron leaderboard from cache...');
const cachedData = await getCachedData('squadrons');
if (!cachedData) {
throw new Error('No cached data available');
}
log.debug(`[CACHE] Squadron leaderboard served from cache: ${cachedData.squadrons?.length || 0} squadrons`);
res.json(cachedData);
} catch (error) {
log.error('Error in squadron leaderboard proxy:', error);
res.status(500).json({
error: 'Failed to fetch squadron leaderboard data',
squadrons: [],
total_squadrons: 0
});
}
});
// Endpoint to get API key for frontend (only accessible from pages, not direct API calls)
app.get('/api-key', (req, res) => {
res.json({
apiKey: generateDailyAPIKey(),
expires: new Date(Date.now() + 86400000).toISOString() // 24 hours
});
});
// Cache stats endpoint (useful for monitoring and debugging)
app.get('/api/cache-stats', cors(apiCorsOptions), (req, res) => {
try {
const stats = {
search_cache: {
size: searchCache.size(),
max_size: 500,
utilization: `${((searchCache.size() / 500) * 100).toFixed(1)}%`
},
leaderboard_cache: {
players: {
cached: !!leaderboardCache.players.data,
age_seconds: leaderboardCache.players.data ?
Math.floor((Date.now() - leaderboardCache.players.lastUpdated) / 1000) : null
},
vehicles: {
cached: !!leaderboardCache.vehicles.data,
age_seconds: leaderboardCache.vehicles.data ?
Math.floor((Date.now() - leaderboardCache.vehicles.lastUpdated) / 1000) : null
},
squadrons: {
cached: !!leaderboardCache.squadrons.data,
age_seconds: leaderboardCache.squadrons.data ?
Math.floor((Date.now() - leaderboardCache.squadrons.lastUpdated) / 1000) : null
},
stats: {
cached: !!leaderboardCache.stats.data,
age_seconds: leaderboardCache.stats.data ?
Math.floor((Date.now() - leaderboardCache.stats.lastUpdated) / 1000) : null
}
},
pending_search_requests: pendingSearchRequests.size,
server_uptime_seconds: Math.floor(process.uptime())
};
res.json(stats);
} catch (error) {
log.error('[CACHE STATS] Error generating cache statistics:', error);
res.status(500).json({ error: 'Failed to generate cache statistics' });
}
});
// API Honeypot endpoints to detect scanning and malicious activity
const honeypotEndpoints = [
'/api/admin',
'/api/users',
'/api/config',
'/api/debug',
'/api/internal',
'/api/system',
'/api/database',
'/api/backup',
'/api/logs',
'/api/test'
];
honeypotEndpoints.forEach(endpoint => {
app.all(endpoint, (req, res) => {
const clientIP = req.ip || req.connection.remoteAddress;
const userAgent = req.get('User-Agent');
const referer = req.get('Referer');
log.warn(`[HONEYPOT] HONEYPOT TRIGGERED: ${endpoint}`);
log.warn(` IP: ${clientIP}`);
log.warn(` Method: ${req.method}`);
log.warn(` User-Agent: ${userAgent?.substring(0, 200) || 'none'}`);
log.warn(` Referer: ${referer || 'none'}`);
log.warn(` Headers: ${JSON.stringify(req.headers)}`);
// Add IP to suspicious list for enhanced monitoring
suspiciousIPs.set(clientIP, {
firstSeen: Date.now(),
honeypotHits: (suspiciousIPs.get(clientIP)?.honeypotHits || 0) + 1,
lastEndpoint: endpoint
});
// Return generic 404 to not reveal it's a honeypot
res.status(404).json({
error: 'Not found',
message: 'The requested resource was not found'
});
});
});
app.post('/webhook/deploy', express.json(), (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const webhookSecret = process.env.WEBHOOK_SECRET;
if (!webhookSecret) {
log.error('[WEBHOOK] WEBHOOK_SECRET not configured');
return res.status(500).json({ error: 'Webhook not configured' });
}
if (signature) {
const hmac = crypto.createHmac('sha256', webhookSecret);
const digest = 'sha256=' + hmac.update(JSON.stringify(req.body)).digest('hex');
if (signature !== digest) {
log.error('[WEBHOOK] Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
}
const event = req.headers['x-github-event'] || req.headers['x-gitlab-event'];
if (event === 'push' || event === 'Push Hook') {
const ref = req.body.ref;
const branch = process.env.DEPLOY_BRANCH || 'main';
if (!ref || !ref.endsWith(branch)) {
return res.json({ message: `Ignoring push to ${ref}, not ${branch}` });
}
log.info(`[WEBHOOK] Webhook received: Push to ${branch} branch`);
const deployScript = path.join(__dirname, 'deploy.sh');
exec(`bash ${deployScript}`, (error, stdout, stderr) => {
if (error) {
log.error(`[WEBHOOK] Deployment error: ${error.message}`);
log.error(`[WEBHOOK] stderr: ${stderr}`);
return;
}
log.info(`[WEBHOOK] Deployment output: ${stdout}`);
if (stderr) {
log.warn(`[WEBHOOK] Deployment warnings: ${stderr}`);
}
});
res.json({ message: 'Deployment started' });
} else {
res.json({ message: 'Event ignored' });
}
});
// Map a Whop product_id or plan_id to a tier. Webhooks include data.product.id,
// while the periodic membership sync may only expose plan.id for some records.
function whopIdToTier(id) {
if (!id) return null;
if (id === process.env.WHOP_PRODUCT_ID_MAX || id === process.env.WHOP_PLAN_ID_MAX) return 'max';
if (id === process.env.WHOP_PRODUCT_ID_PRO || id === process.env.WHOP_PLAN_ID_PRO) return 'pro';
if (id === process.env.WHOP_PRODUCT_ID_STANDARD || id === process.env.WHOP_PLAN_ID_STANDARD) return 'standard';
return null;
}
function whopTierIdFromMembership(data) {
return data?.product_id
|| (typeof data?.product === 'string' ? data.product : data?.product?.id)
|| data?.plan_id
|| (typeof data?.plan === 'string' ? data.plan : data?.plan?.id)
|| data?.access_pass
|| null;
}
app.post('/webhook/whop', (req, res) => {
const secret = process.env.WHOP_WEBHOOK_SECRET;
const sig = req.headers['x-whop-signature'];
if (secret && sig) {
const expected = crypto.createHmac('sha256', secret).update(req.rawBody).digest('hex');
if (sig !== expected) {
log.warn(`[WHOP] Signature mismatch — rejecting`);
return res.status(401).end();
}
} else if (!sig) {
log.warn('[WHOP] No x-whop-signature header — proceeding unverified');
}
const { type: action, data } = req.body;
// Try metadata first (legacy), then extract from custom field responses
let guildId = data?.metadata?.guild_id;
if (!guildId && Array.isArray(data?.custom_field_responses)) {
for (const field of data.custom_field_responses) {
const val = String(field.value ?? field.response ?? field.answer ?? '').trim();
if (/^\d{17,19}$/.test(val)) { guildId = val; break; }
}
}
log.info(`[WHOP] Webhook received: type=${action} membership=${data?.id} guild_id=${guildId || 'none'} custom_fields=${JSON.stringify(data?.custom_field_responses)}`);
// ── Helper: deactivate a guild by guild_id or fall back to membership lookup ──
const deactivateGuild = (reason) => {
if (guildId) {
entitlementsDb.run(
`UPDATE guild_entitlements SET status='cancelled' WHERE guild_id=?`,
[guildId],
(err) => {
if (err) log.error(`[WHOP] DB write failed for guild ${guildId}:`, err);
else log.info(`[WHOP] Guild ${guildId} deactivated via ${reason}`);
res.status(200).json({ success: true, guild_id: guildId, action: reason });
}
);
return;
}
// No guild_id in payload — look up by whop_membership_id
const membershipId = data?.membership_id || data?.membership?.id || data?.membership || data?.id;
if (!membershipId) {
log.warn(`[WHOP] ${reason}: no guild_id or membership_id found`);
return res.status(200).json({ success: true, message: `${reason} received but no identifiers found` });
}
entitlementsDb.get(
`SELECT guild_id FROM guild_entitlements WHERE whop_membership_id=?`,
[membershipId],
(err, row) => {
if (err || !row) {
log.warn(`[WHOP] ${reason}: no guild found for membership ${membershipId}`);
return res.status(200).json({ success: true, message: 'No guild found for membership' });
}
entitlementsDb.run(
`UPDATE guild_entitlements SET status='cancelled' WHERE guild_id=?`,
[row.guild_id],
(err2) => {
if (err2) log.error(`[WHOP] DB write failed for guild ${row.guild_id}:`, err2);
else log.info(`[WHOP] Guild ${row.guild_id} deactivated via ${reason} (membership ${membershipId})`);
res.status(200).json({ success: true, guild_id: row.guild_id, action: reason });
}
);
}
);
};
// ── Membership activated ──
if (action === 'membership.activated') {
if (!guildId) {
log.warn(`[WHOP] No guild_id found — skipping activation (membership ${data?.id})`);
return res.status(200).json({ success: true, message: 'No guild_id found, skipped' });
}
const whopTierId = whopTierIdFromMembership(data);
const tier = whopIdToTier(whopTierId) || 'standard';
entitlementsDb.run(
`INSERT INTO guild_entitlements (guild_id, whop_membership_id, status, renewed_at, tier)
VALUES (?, ?, 'active', strftime('%s','now'), ?)
ON CONFLICT(guild_id) DO UPDATE SET status='active',
whop_membership_id=excluded.whop_membership_id,
renewed_at=excluded.renewed_at,
tier=excluded.tier`,
[guildId, data.id, tier],
(err) => {
if (err) {
log.error(`[WHOP] DB write failed for guild ${guildId}:`, err);
return res.status(500).json({ success: false, error: 'DB write failed' });
}
log.info(`[WHOP] Guild ${guildId} activated (membership ${data.id}, tier=${tier}, product_or_plan=${whopTierId || 'unknown'})`);
res.status(200).json({ success: true, guild_id: guildId, action: 'activated', tier });
}
);
// ── Membership deactivated ──
} else if (action === 'membership.deactivated') {
deactivateGuild('membership.deactivated');
// ── Refund issued — revoke entitlement ──
} else if (action === 'refund.created') {
deactivateGuild('refund.created');
// ── Chargeback / dispute filed — revoke entitlement ──
} else if (action === 'dispute.created') {
deactivateGuild('dispute.created');
// ── Early dispute warning — log only, dispute.created will follow if it escalates ──
} else if (action === 'dispute_alert.created') {
log.warn(`[WHOP] Dispute alert (early warning) — membership=${data?.membership_id || data?.id} guild_id=${guildId || 'none'}`);
res.status(200).json({ success: true, action: 'dispute_alert_logged' });
// ── Payment failed — log only, Whop retries before deactivating ──
} else if (action === 'payment.failed') {
log.warn(`[WHOP] Payment failed — membership=${data?.membership_id || data?.id} guild_id=${guildId || 'none'}`);
res.status(200).json({ success: true, action: 'payment_failed_logged' });
// ── Everything else ──
} else {
log.info(`[WHOP] Unhandled event: ${action}`);
res.status(200).json({ success: true, message: 'Unhandled action type', action });
}
});
// ── Games pages ──
app.get('/games', (req, res) => {
res.render('games', {
botName: 'Toothless SQB Bot'
});
});
app.get('/games/:sessionId', (req, res) => {
const { sessionId } = req.params;
if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) {
return res.status(400).render('404', {
statusCode: 400,
error: 'Invalid session ID format.',
botName: 'Toothless SQB Bot'
});
}
res.render('game-detail', {
botName: 'Toothless SQB Bot',
sessionId: sessionId
});
});
// Handle 404
app.use((req, res) => {
res.status(404).render('404', {
botName: 'Toothless SQB Bot'
});
});
// Error handling
app.use((err, req, res, next) => {
log.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
// ── Whop entitlements sync (v2 API) ──
async function syncWhopEntitlements() {
const apiKey = process.env.WHOP_API_KEY;
if (!apiKey) {
console.warn('[WHOP-SYNC] Missing WHOP_API_KEY, skipping sync');
return;
}
console.log('[WHOP-SYNC] Starting entitlements sync...');
try {
// Fetch all memberships from Whop v2 API (paginated)
const allMemberships = [];
let page = 1;
while (true) {
const resp = await fetch(`https://api.whop.com/api/v2/memberships?per=50&page=${page}&valid=true`, {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (!resp.ok) {
console.error(`[WHOP-SYNC] API error: ${resp.status} ${resp.statusText}`);
const body = await resp.text();
console.error(`[WHOP-SYNC] Response: ${body}`);
return;
}
const json = await resp.json();
if (!json.data || !Array.isArray(json.data)) {
console.error('[WHOP-SYNC] Unexpected response format:', JSON.stringify(json).slice(0, 200));
return;
}
for (const mem of json.data) {
// Extract guild ID from custom_field_responses (v2 uses "answer" field)
let guildId = null;
if (Array.isArray(mem.custom_field_responses)) {
for (const field of mem.custom_field_responses) {
const val = String(field.answer ?? '').trim();
if (/^\d{17,19}$/.test(val)) { guildId = val; break; }
}
}
const whopTierId = whopTierIdFromMembership(mem);
allMemberships.push({
guildId,
membershipId: mem.id,
status: mem.status,
tier: whopIdToTier(whopTierId),
});
}
if (page >= (json.pagination?.total_page || 1)) break;
page++;
}
const activeMemberships = allMemberships.filter(m => m.status === 'active' && m.guildId);
console.log(`[WHOP-SYNC] Fetched ${allMemberships.length} total, ${activeMemberships.length} active with guild IDs (${page} page(s))`);
// Get current DB state
const dbEntitlements = await new Promise((resolve, reject) => {
entitlementsDb.all('SELECT guild_id, whop_membership_id, status, tier FROM guild_entitlements', (err, rows) => {
if (err) reject(err); else resolve(rows || []);
});
});
const dbByMembership = new Map(dbEntitlements.filter(r => r.whop_membership_id).map(r => [r.whop_membership_id, r]));
const whopActiveGuilds = new Set(activeMemberships.map(m => m.guildId));
let activated = 0, deactivated = 0, unchanged = 0;
// Activate/update memberships that Whop says are active
for (const { guildId, membershipId, tier: mappedTier } of activeMemberships) {
const existing = dbByMembership.get(membershipId);
const tier = mappedTier || existing?.tier || 'standard';
if (!existing || existing.status !== 'active' || existing.tier !== tier) {
await new Promise((resolve, reject) => {
entitlementsDb.run(
`INSERT INTO guild_entitlements (guild_id, whop_membership_id, status, renewed_at, tier)
VALUES (?, ?, 'active', strftime('%s','now'), ?)
ON CONFLICT(guild_id) DO UPDATE SET status='active',
whop_membership_id=excluded.whop_membership_id,
renewed_at=excluded.renewed_at,
tier=excluded.tier`,
[guildId, membershipId, tier],
(err) => { if (err) reject(err); else resolve(); }
);
});
activated++;
} else {
unchanged++;
}
}
// Deactivate DB entries that are 'active' but no longer active on Whop
for (const row of dbEntitlements) {
if (row.status === 'active' && !whopActiveGuilds.has(row.guild_id)) {
await new Promise((resolve, reject) => {
entitlementsDb.run(
`UPDATE guild_entitlements SET status='cancelled' WHERE guild_id=?`,
[row.guild_id],
(err) => { if (err) reject(err); else resolve(); }
);
});
deactivated++;
}
}
console.log(`[WHOP-SYNC] Sync complete: ${activated} activated, ${deactivated} deactivated, ${unchanged} unchanged`);
} catch (err) {
console.error('[WHOP-SYNC] Sync failed:', err);
}
}
// Run sync on startup (after 10s delay) and then every 6 hours
const WHOP_SYNC_INTERVAL = 30 * 60 * 1000;
app.listen(PORT, async () => {
log.info(`[SERVER] Server running on port ${PORT}`);
log.info(`[SERVER] Visit: http://localhost:${PORT}`);
if (IS_PRIMARY_WORKER) {
// Defer cache warmup so we don't pile 4 sequential full-table-scan
// aggregations onto the API while it's still opening DB connections
// and warming its own caches. The API's readiness gate would fast-fail
// these with 503 inside the cold-start window anyway, leaving the
// cache empty until the next 5-minute auto-refresh tick. Waiting 30s
// lets the API's readiness signal go green first; cold-start user
// requests in that window lazy-fill via getCachedData (32s wait).
setTimeout(() => {
void initializeCache().catch(err => {
log.error('[CACHE] Startup cache warmup failed:', err);
});
}, 30000);
// Initial entitlements sync after short delay
setTimeout(() => syncWhopEntitlements(), 10000);
// Recurring sync every 6 hours
setInterval(() => syncWhopEntitlements(), WHOP_SYNC_INTERVAL);
}
});