const fs = require('node:fs') const http = require('node:http') const https = require('node:https') const path = require('node:path') const { Pool } = require('pg') function loadEnvFile() { const envPath = path.join(__dirname, '.env') if (!fs.existsSync(envPath)) return const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/) for (const line of lines) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue const separatorIndex = trimmed.indexOf('=') if (separatorIndex === -1) continue const key = trimmed.slice(0, separatorIndex).trim() let value = trimmed.slice(separatorIndex + 1).trim() if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1) } if (key && (!process.env[key] || process.env[key] === '')) { process.env[key] = value } } } loadEnvFile() const PORT = Number(process.env.PORT || 3001) const API_UPSTREAM = process.env.API_UPSTREAM || 'http://127.0.0.1:6000' const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || '' const UPTIME_DATABASE_URL = process.env.UPTIME_DATABASE_URL || process.env.DATABASE_URL || '' const UPTIME_SAMPLE_INTERVAL_MS = Number(process.env.UPTIME_SAMPLE_INTERVAL_MS || 30 * 60 * 1000) const UPTIME_HISTORY_LIMIT = Number(process.env.UPTIME_HISTORY_LIMIT || 336) const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000) const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000) const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120) const DIST_DIR = path.join(__dirname, 'dist') const MAX_TEAM_NAME_LENGTH = 80 const MAX_CACHE_ENTRIES = 200 const MAX_RATE_LIMIT_KEYS = 1000 const mimeTypes = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.ico': 'image/x-icon', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp', } function send(res, status, body, headers = {}) { res.writeHead(status, headers) res.end(body) } const jsonHeaders = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', } const apiCache = new Map() const rateLimits = new Map() const uptimePool = UPTIME_DATABASE_URL ? new Pool({ connectionString: UPTIME_DATABASE_URL, ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : undefined, }) : null let uptimeReady = false let latestUptimeSnapshot = null function sendJson(res, status, body, headers = {}) { send(res, status, JSON.stringify(body), { ...jsonHeaders, ...headers }) } function requestJson(url, timeoutMs = 10000) { return new Promise((resolve, reject) => { const target = new URL(url) const client = target.protocol === 'https:' ? https : http const startedAt = Date.now() const req = client.request( target, { method: 'GET', headers: { accept: 'application/json', 'user-agent': 'tssbot-uptime-sampler', }, timeout: timeoutMs, }, (response) => { const chunks = [] response.on('data', (chunk) => chunks.push(chunk)) response.on('end', () => { const body = Buffer.concat(chunks).toString('utf8') const latency = Date.now() - startedAt if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) { reject(new Error(`HTTP ${response.statusCode || 0}`)) return } try { resolve({ body: body ? JSON.parse(body) : null, latency }) } catch { resolve({ body: null, latency }) } }) }, ) req.on('timeout', () => { req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)) }) req.on('error', reject) req.end() }) } async function ensureUptimeSchema() { if (!uptimePool || uptimeReady) return Boolean(uptimePool) await uptimePool.query(` create table if not exists uptime_snapshots ( id bigserial primary key, checked_at timestamptz not null default now(), website_ok boolean not null, health_ok boolean not null, tss_ok boolean not null, ok boolean not null, latency_ms integer not null, details jsonb not null default '{}'::jsonb ) `) await uptimePool.query(` create index if not exists uptime_snapshots_checked_at_idx on uptime_snapshots (checked_at desc) `) uptimeReady = true return true } async function takeUptimeSnapshot() { const startedAt = Date.now() const details = { website: { label: 'Online' }, health: { label: 'Operational' }, tss: { label: 'Not checked' }, } const websiteOk = fs.existsSync(path.join(DIST_DIR, 'index.html')) if (!websiteOk) details.website.label = 'Build not found' const healthOk = true let tssOk = false try { const tssUrl = new URL('/api/tss/leaderboard/teams?limit=1', API_UPSTREAM) const result = await requestJson(tssUrl.toString()) const teamCount = result.body?.teams?.length || result.body?.squadrons?.length || 0 tssOk = true details.tss = { label: `${teamCount} sample team${teamCount === 1 ? '' : 's'} returned`, latency_ms: result.latency, } } catch (error) { details.tss = { label: error.message } } const snapshot = { checked_at: new Date().toISOString(), website_ok: websiteOk, health_ok: healthOk, tss_ok: tssOk, ok: websiteOk && healthOk && tssOk, latency_ms: Date.now() - startedAt, details, } latestUptimeSnapshot = snapshot if (uptimePool) { await ensureUptimeSchema() await uptimePool.query( `insert into uptime_snapshots (website_ok, health_ok, tss_ok, ok, latency_ms, details) values ($1, $2, $3, $4, $5, $6)`, [snapshot.website_ok, snapshot.health_ok, snapshot.tss_ok, snapshot.ok, snapshot.latency_ms, snapshot.details], ) } return snapshot } async function uptimeHistory() { if (!uptimePool) { return { configured: false, latest: latestUptimeSnapshot, history: latestUptimeSnapshot ? [latestUptimeSnapshot] : [], } } await ensureUptimeSchema() const result = await uptimePool.query( `select checked_at, website_ok, health_ok, tss_ok, ok, latency_ms, details from uptime_snapshots order by checked_at desc limit $1`, [UPTIME_HISTORY_LIMIT], ) const history = result.rows.reverse() return { configured: true, latest: history.at(-1) || latestUptimeSnapshot, history, } } function startUptimeSampler() { takeUptimeSnapshot().catch((error) => { console.error('Initial uptime snapshot failed:', error) }) setInterval(() => { takeUptimeSnapshot().catch((error) => { console.error('Uptime snapshot failed:', error) }) }, UPTIME_SAMPLE_INTERVAL_MS).unref() } function publicOrigins(req) { const origins = PUBLIC_ORIGIN.split(',') .map((origin) => origin.trim()) .filter(Boolean) if (!origins.length) { const host = req.headers['x-forwarded-host'] || req.headers.host const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http') if (host) { origins.push(`${proto}://${String(host).split(',')[0].trim()}`) } } return origins } function isSameOriginRequest(req) { const origins = publicOrigins(req) const origin = req.headers.origin const referer = req.headers.referer const fetchSite = req.headers['sec-fetch-site'] const fetchDest = req.headers['sec-fetch-dest'] if (fetchDest === 'document') return false if (origin && !origins.includes(origin)) return false if (referer) { try { if (!origins.includes(new URL(referer).origin)) return false } catch { return false } } if (fetchSite) { return fetchSite === 'same-origin' } return Boolean(origin || referer) } function clientIp(req) { const forwardedFor = req.headers['x-forwarded-for'] if (forwardedFor) return String(forwardedFor).split(',')[0].trim() return req.socket.remoteAddress || 'unknown' } function isRateLimited(req) { const now = Date.now() const ip = clientIp(req) const current = rateLimits.get(ip) if (!current || current.resetAt <= now) { rateLimits.set(ip, { count: 1, resetAt: now + API_RATE_LIMIT_WINDOW_MS }) return false } current.count += 1 return current.count > API_RATE_LIMIT_MAX } function pruneMaps() { const now = Date.now() for (const [key, value] of apiCache) { if (value.expiresAt <= now || apiCache.size > MAX_CACHE_ENTRIES) apiCache.delete(key) } for (const [key, value] of rateLimits) { if (value.resetAt <= now || rateLimits.size > MAX_RATE_LIMIT_KEYS) rateLimits.delete(key) } } function allowedApiTarget(req) { if (req.method !== 'GET' && req.method !== 'HEAD') return null const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`) const url = new URL(`${requestUrl.pathname}${requestUrl.search}`, API_UPSTREAM) const params = requestUrl.searchParams const pathname = requestUrl.pathname if (pathname === '/api/tss/leaderboard/teams') { const keys = [...params.keys()] const limit = Number(params.get('limit') || 100) if (keys.some((key) => key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) { return null } return url } if (pathname === '/api/tss/teams/resolve') { const keys = [...params.keys()] const name = params.get('name') || '' if (keys.some((key) => key !== 'name') || name.length < 2 || name.length > MAX_TEAM_NAME_LENGTH) { return null } return url } const teamMatch = pathname.match(/^\/api\/tss\/teams\/([^/]+)(?:\/(history|games))?$/) if (!teamMatch || [...params.keys()].length) return null try { const teamName = decodeURIComponent(teamMatch[1]) if (!teamName || teamName.length > MAX_TEAM_NAME_LENGTH) return null } catch { return null } return url } function proxyRequest(req, res) { pruneMaps() if (!isSameOriginRequest(req)) { return sendJson(res, 403, { error: 'API access is restricted to this site' }) } if (isRateLimited(req)) { return sendJson(res, 429, { error: 'Too many API requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) }) } const target = allowedApiTarget(req) if (!target) { return sendJson(res, 404, { error: 'API route not found' }) } const cacheKey = req.method === 'GET' ? target.toString() : '' const cached = cacheKey ? apiCache.get(cacheKey) : null if (cached && cached.expiresAt > Date.now()) { return send(res, 200, cached.body, cached.headers) } const responseChunks = [] const proxy = http.request( target, { method: req.method, headers: { accept: 'application/json', host: target.host, 'user-agent': req.headers['user-agent'] || 'tssbot-web', }, }, (proxyRes) => { const headers = { ...proxyRes.headers, 'cache-control': 'private, max-age=15', 'x-content-type-options': 'nosniff', } delete headers['access-control-allow-origin'] delete headers['access-control-allow-credentials'] res.writeHead(proxyRes.statusCode || 502, headers) proxyRes.on('data', (chunk) => { if (cacheKey && (proxyRes.statusCode || 0) >= 200 && (proxyRes.statusCode || 0) < 300) { responseChunks.push(chunk) } }) proxyRes.on('end', () => { if (cacheKey && responseChunks.length) { apiCache.set(cacheKey, { body: Buffer.concat(responseChunks), headers, expiresAt: Date.now() + API_CACHE_TTL_MS, }) } }) proxyRes.pipe(res) }, ) proxy.on('error', (error) => { sendJson(res, 502, { error: 'API proxy failed', detail: error.message }) }) req.pipe(proxy) } function serveStatic(req, res) { const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname) const relativePath = requestPath === '/' ? '/index.html' : requestPath const filePath = path.normalize(path.join(DIST_DIR, relativePath)) if (!filePath.startsWith(DIST_DIR)) { return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' }) } fs.readFile(filePath, (error, data) => { if (error) { fs.readFile(path.join(DIST_DIR, 'index.html'), (indexError, indexData) => { if (indexError) { return send(res, 404, 'Build not found. Run npm run build first.', { 'content-type': 'text/plain; charset=utf-8', }) } send(res, 200, indexData, { 'content-type': mimeTypes['.html'] }) }) return } const ext = path.extname(filePath) send(res, 200, data, { 'content-type': mimeTypes[ext] || 'application/octet-stream', 'cache-control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable', }) }) } http .createServer((req, res) => { if (req.url === '/health') { sendJson(res, 200, { ok: true }) return } if (req.method === 'GET' && req.url === '/api/uptime') { uptimeHistory() .then((data) => sendJson(res, 200, data)) .catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message })) return } if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) { sendJson(res, 403, { error: 'CORS requests are not allowed' }) return } if (req.url.startsWith('/api/')) { proxyRequest(req, res) return } serveStatic(req, res) }) .listen(PORT, '0.0.0.0', () => { console.log(`tssbot-web serving http://localhost:${PORT}`) console.log(`proxying API requests to ${API_UPSTREAM}`) console.log( uptimePool ? `sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes` : 'uptime database disabled; set DATABASE_URL or UPTIME_DATABASE_URL to persist snapshots', ) startUptimeSampler() })