500 lines
14 KiB
JavaScript
500 lines
14 KiB
JavaScript
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()
|
|
})
|