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