2765 lines
89 KiB
JavaScript
2765 lines
89 KiB
JavaScript
const fs = require('node:fs')
|
|
const { execFile } = require('node:child_process')
|
|
const crypto = require('node:crypto')
|
|
const http = require('node:http')
|
|
const https = require('node:https')
|
|
const os = require('node:os')
|
|
const path = require('node:path')
|
|
const Database = require('better-sqlite3')
|
|
|
|
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_STORAGE_DIR = process.env.UPTIME_STORAGE_DIR || '~/tsswebstorage'
|
|
const UPTIME_DATABASE_FILE = process.env.UPTIME_DATABASE_FILE || 'uptime.sqlite'
|
|
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 ANALYTICS_DATABASE_FILE = process.env.ANALYTICS_DATABASE_FILE || 'viewers.sqlite'
|
|
const ANALYTICS_RETENTION_DAYS = Number(process.env.ANALYTICS_RETENTION_DAYS || 30)
|
|
const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WINDOW_SECONDS || 75)
|
|
const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000)
|
|
const PUBLIC_DATA_CACHE_DIR = path.resolve(
|
|
expandHome(process.env.PUBLIC_DATA_CACHE_DIR || path.join(UPTIME_STORAGE_DIR, 'public-data')),
|
|
)
|
|
const PUBLIC_DATA_CACHE_FRESH_MS = Number(process.env.PUBLIC_DATA_CACHE_FRESH_MS || 60 * 1000)
|
|
const PUBLIC_DATA_CACHE_STALE_MS = Number(process.env.PUBLIC_DATA_CACHE_STALE_MS || 24 * 60 * 60 * 1000)
|
|
const PUBLIC_DATA_PREWARM_INTERVAL_MS = Number(process.env.PUBLIC_DATA_PREWARM_INTERVAL_MS || PUBLIC_DATA_CACHE_FRESH_MS)
|
|
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 TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || ''
|
|
const COMING_SOON = String(process.env.comingsoon || process.env.COMINGSOON || '').toLowerCase() === 'true'
|
|
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
|
const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_MS || 5000)
|
|
const TURNSTILE_MAX_TOKEN_LENGTH = 2048
|
|
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
|
|
const DIST_DIR = path.join(__dirname, 'dist')
|
|
const VEHICLE_ICONS_DIR = path.resolve(
|
|
__dirname,
|
|
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
|
|
)
|
|
const BOTS_REPO_DIR = path.resolve(
|
|
expandHome(process.env.BOTS_REPO_DIR || path.join(__dirname, '..', 'BOTS')),
|
|
)
|
|
const TSSBOT_REPO_DIR = path.resolve(process.env.TSSBOT_REPO_DIR || path.join(BOTS_REPO_DIR, 'TSSBOT'))
|
|
const TSS_REPLAY_SAMPLE_DIR = path.join(TSSBOT_REPO_DIR, 'replays_sample')
|
|
const TSS_REPLAYS_DIR = path.resolve(
|
|
expandHome(
|
|
process.env.TSS_REPLAYS_DIR ||
|
|
(process.env.STORAGE_VOL_PATH
|
|
? path.join(expandHome(process.env.STORAGE_VOL_PATH), 'REPLAYS', 'TSS')
|
|
: TSS_REPLAY_SAMPLE_DIR),
|
|
),
|
|
)
|
|
const SHARED_DIR = path.resolve(process.env.SHARED_DIR || path.join(BOTS_REPO_DIR, 'SHARED'))
|
|
const TSS_REPLAY_PYTHON = path.resolve(
|
|
expandHome(process.env.TSS_REPLAY_PYTHON || path.join(SHARED_DIR, '.venv', 'bin', 'python')),
|
|
)
|
|
const TSS_REPLAY_RENDER_TIMEOUT_MS = Number(process.env.TSS_REPLAY_RENDER_TIMEOUT_MS || 30000)
|
|
const MAX_TEAM_NAME_LENGTH = 80
|
|
const MAX_CACHE_ENTRIES = 200
|
|
const MAX_RATE_LIMIT_KEYS = 1000
|
|
const MAX_ANALYTICS_BODY_BYTES = 16 * 1024
|
|
const MAX_UPSTREAM_BODY_BYTES = Number(process.env.MAX_UPSTREAM_BODY_BYTES || 1024 * 1024)
|
|
const SERVER_REQUEST_TIMEOUT_MS = Number(process.env.SERVER_REQUEST_TIMEOUT_MS || 30000)
|
|
const SERVER_HEADERS_TIMEOUT_MS = Number(process.env.SERVER_HEADERS_TIMEOUT_MS || 10000)
|
|
const RUN_BACKGROUND_JOBS = !process.env.NODE_APP_INSTANCE || process.env.NODE_APP_INSTANCE === '0'
|
|
|
|
const TRUST_PROXY = (() => {
|
|
const raw = String(process.env.TRUST_PROXY ?? 'cloudflare').trim().toLowerCase()
|
|
if (raw === '' || raw === 'false' || raw === '0' || raw === 'none') return 'none'
|
|
if (raw === 'cloudflare' || raw === 'cf') return 'cloudflare'
|
|
if (raw === 'true' || raw === '1' || raw === 'any' || raw === 'generic') return 'generic'
|
|
return 'none'
|
|
})()
|
|
|
|
const TRUSTED_UPSTREAM_IPS = new Set(
|
|
String(process.env.TRUSTED_UPSTREAM_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1')
|
|
.split(',')
|
|
.map((ip) => ip.trim())
|
|
.filter(Boolean),
|
|
)
|
|
|
|
function requestPeerIsTrusted(req) {
|
|
if (TRUST_PROXY === 'none') return false
|
|
if (!TRUSTED_UPSTREAM_IPS.size) return true
|
|
const peer = req.socket.remoteAddress || ''
|
|
return TRUSTED_UPSTREAM_IPS.has(peer)
|
|
}
|
|
|
|
const ANALYTICS_METADATA_ALLOWED_KEYS = new Set([
|
|
'preferences',
|
|
'request',
|
|
'screen',
|
|
'viewport',
|
|
'pixelRatio',
|
|
'colorDepth',
|
|
'network',
|
|
'privacy',
|
|
'capability',
|
|
'capabilities',
|
|
'memory',
|
|
'cpu',
|
|
'touch',
|
|
'hardware',
|
|
'locale',
|
|
'referrer',
|
|
])
|
|
|
|
const SECURITY_HEADERS_BASE = {
|
|
'x-content-type-options': 'nosniff',
|
|
'x-frame-options': 'DENY',
|
|
'referrer-policy': 'strict-origin-when-cross-origin',
|
|
'permissions-policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()',
|
|
'cross-origin-opener-policy': 'same-origin',
|
|
'cross-origin-resource-policy': 'same-origin',
|
|
}
|
|
|
|
const CSP_DIRECTIVES = [
|
|
"default-src 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
"frame-ancestors 'none'",
|
|
"object-src 'none'",
|
|
"script-src 'self' https://challenges.cloudflare.com",
|
|
"script-src-elem 'self' https://challenges.cloudflare.com",
|
|
"style-src 'self'",
|
|
"style-src-elem 'self'",
|
|
"style-src-attr 'unsafe-inline'",
|
|
"img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com",
|
|
"media-src https://*.dzcdn.net https://*.deezer.com",
|
|
"font-src 'self' data:",
|
|
"connect-src 'self' https://challenges.cloudflare.com",
|
|
"frame-src https://challenges.cloudflare.com",
|
|
"worker-src 'self' blob:",
|
|
"manifest-src 'self'",
|
|
'upgrade-insecure-requests',
|
|
].join('; ')
|
|
|
|
function securityHeaders(req, { html = false } = {}) {
|
|
const headers = { ...SECURITY_HEADERS_BASE }
|
|
if (isHttpsRequest(req)) {
|
|
headers['strict-transport-security'] = 'max-age=31536000; includeSubDomains'
|
|
}
|
|
if (html) {
|
|
headers['content-security-policy'] = CSP_DIRECTIVES
|
|
}
|
|
return headers
|
|
}
|
|
|
|
const mimeTypes = {
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.ico': 'image/x-icon',
|
|
'.jpeg': 'image/jpeg',
|
|
'.jpg': 'image/jpeg',
|
|
'.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 = {}) {
|
|
const merged = { ...securityHeaders(res.req || { socket: {}, headers: {} }), ...headers }
|
|
res.writeHead(status, merged)
|
|
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 publicDataRefreshes = new Set()
|
|
const rateLimits = new Map()
|
|
let uptimeDb = null
|
|
let analyticsDb = null
|
|
let latestUptimeSnapshot = null
|
|
let publicDataPrewarmTimer = null
|
|
|
|
function sendJson(res, status, body, headers = {}) {
|
|
send(res, status, JSON.stringify(body), { ...jsonHeaders, ...headers })
|
|
}
|
|
|
|
function publicDataKey(value) {
|
|
return encodeURIComponent(String(value || '').trim()).replace(/%/g, '~')
|
|
}
|
|
|
|
function publicDataCachePathForUrl(requestUrl) {
|
|
const pathname = requestUrl.pathname
|
|
const params = requestUrl.searchParams
|
|
|
|
if (pathname === '/api/tss/leaderboard/teams') {
|
|
const limit = Number(params.get('limit') || 100)
|
|
if (limit === 4) return path.join(PUBLIC_DATA_CACHE_DIR, 'home-teams.json')
|
|
if (limit === 100) return path.join(PUBLIC_DATA_CACHE_DIR, 'leaderboard-teams.json')
|
|
return null
|
|
}
|
|
|
|
if (pathname === '/api/tss/leaderboard/players') {
|
|
const limit = Number(params.get('limit') || 100)
|
|
return limit === 100 ? path.join(PUBLIC_DATA_CACHE_DIR, 'leaderboard-players.json') : null
|
|
}
|
|
|
|
if (pathname === '/api/tss/games/recent') {
|
|
const limit = Number(params.get('limit') || 50)
|
|
return limit === 50 ? path.join(PUBLIC_DATA_CACHE_DIR, 'recent-games.json') : null
|
|
}
|
|
|
|
let match = pathname.match(/^\/api\/tss\/teams\/([^/]+)$/)
|
|
if (match && ![...params.keys()].length) {
|
|
return path.join(PUBLIC_DATA_CACHE_DIR, 'teams', `${publicDataKey(decodeURIComponent(match[1]))}.json`)
|
|
}
|
|
|
|
match = pathname.match(/^\/api\/tss\/teams\/([^/]+)\/games$/)
|
|
if (match && ![...params.keys()].length) {
|
|
return path.join(PUBLIC_DATA_CACHE_DIR, 'teams', `${publicDataKey(decodeURIComponent(match[1]))}.games.json`)
|
|
}
|
|
|
|
match = pathname.match(/^\/api\/tss\/player\/([0-9]{1,32})$/)
|
|
if (match && ![...params.keys()].length) {
|
|
return path.join(PUBLIC_DATA_CACHE_DIR, 'players', `${publicDataKey(match[1])}.json`)
|
|
}
|
|
|
|
match = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})$/)
|
|
if (match && (params.get('lang') || 'en') === 'en' && [...params.keys()].every((key) => key === 'lang')) {
|
|
return path.join(PUBLIC_DATA_CACHE_DIR, 'games', `${publicDataKey(match[1])}.json`)
|
|
}
|
|
|
|
match = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})\/logs$/)
|
|
if (match && ![...params.keys()].length) {
|
|
return path.join(PUBLIC_DATA_CACHE_DIR, 'games', `${publicDataKey(match[1])}.logs.json`)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function safePublicDataPath(requestPath) {
|
|
let decoded = '/'
|
|
try {
|
|
decoded = decodeURIComponent(requestPath)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const relative = decoded.replace(/^\/data\/?/, '')
|
|
if (!relative || relative.includes('\0')) return null
|
|
|
|
const filePath = path.resolve(PUBLIC_DATA_CACHE_DIR, relative)
|
|
const relativeToCache = path.relative(PUBLIC_DATA_CACHE_DIR, filePath)
|
|
if (relativeToCache.startsWith('..') || path.isAbsolute(relativeToCache)) return null
|
|
return filePath
|
|
}
|
|
|
|
function sendPublicDataFile(req, res, filePath, status = 200, extraHeaders = {}) {
|
|
fs.readFile(filePath, (error, data) => {
|
|
if (error) {
|
|
sendJson(res, 404, { error: 'Snapshot not found' })
|
|
return
|
|
}
|
|
|
|
send(res, status, data, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'cache-control': 'public, max-age=30, stale-while-revalidate=300',
|
|
...extraHeaders,
|
|
})
|
|
})
|
|
}
|
|
|
|
function cachedPublicData(filePath) {
|
|
if (!filePath) return null
|
|
|
|
try {
|
|
const stat = fs.statSync(filePath)
|
|
if (!stat.isFile()) return null
|
|
const age = Date.now() - stat.mtimeMs
|
|
if (age > PUBLIC_DATA_CACHE_STALE_MS) return null
|
|
return { filePath, age, fresh: age <= PUBLIC_DATA_CACHE_FRESH_MS }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function writePublicDataFile(filePath, body) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
|
|
fs.writeFileSync(tmpPath, body)
|
|
fs.renameSync(tmpPath, filePath)
|
|
}
|
|
|
|
function refreshPublicData(filePath, target) {
|
|
if (!filePath || publicDataRefreshes.has(filePath)) return
|
|
publicDataRefreshes.add(filePath)
|
|
|
|
fetchUpstreamJsonBuffer(target)
|
|
.then((body) => writePublicDataFile(filePath, body))
|
|
.catch((error) => {
|
|
console.warn(`public data refresh failed for ${target.pathname}: ${error.message}`)
|
|
})
|
|
.finally(() => publicDataRefreshes.delete(filePath))
|
|
}
|
|
|
|
function publicDataPrewarmTargets() {
|
|
return [
|
|
'/api/tss/leaderboard/teams?limit=100',
|
|
'/api/tss/leaderboard/players?limit=100',
|
|
'/api/tss/leaderboard/teams?limit=4',
|
|
'/api/tss/games/recent?limit=50',
|
|
].map((requestPath) => {
|
|
const requestUrl = new URL(requestPath, 'http://localhost')
|
|
return {
|
|
requestUrl,
|
|
target: new URL(requestPath, API_UPSTREAM),
|
|
filePath: publicDataCachePathForUrl(requestUrl),
|
|
}
|
|
}).filter((entry) => entry.filePath)
|
|
}
|
|
|
|
function prewarmPublicDataCache() {
|
|
for (const { filePath, target } of publicDataPrewarmTargets()) {
|
|
const current = cachedPublicData(filePath)
|
|
if (current?.fresh) continue
|
|
refreshPublicData(filePath, target)
|
|
}
|
|
}
|
|
|
|
function startPublicDataPrewarmer() {
|
|
fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })
|
|
prewarmPublicDataCache()
|
|
publicDataPrewarmTimer = setInterval(prewarmPublicDataCache, PUBLIC_DATA_PREWARM_INTERVAL_MS)
|
|
publicDataPrewarmTimer.unref?.()
|
|
}
|
|
|
|
function fetchUpstreamJsonBuffer(target) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = target.protocol === 'https:' ? https : http
|
|
const chunks = []
|
|
let bytes = 0
|
|
const proxy = client.request(
|
|
target,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
accept: 'application/json',
|
|
host: target.host,
|
|
'user-agent': 'tssbot-web-cache',
|
|
},
|
|
},
|
|
(proxyRes) => {
|
|
const status = proxyRes.statusCode || 502
|
|
const contentType = String(proxyRes.headers['content-type'] || '')
|
|
if (status < 200 || status >= 300 || !contentType.includes('application/json')) {
|
|
proxyRes.resume()
|
|
reject(new Error(`Upstream returned ${status}`))
|
|
return
|
|
}
|
|
|
|
proxyRes.on('data', (chunk) => {
|
|
bytes += chunk.length
|
|
if (bytes > MAX_UPSTREAM_BODY_BYTES) {
|
|
proxy.destroy(new Error('Upstream response too large'))
|
|
return
|
|
}
|
|
chunks.push(chunk)
|
|
})
|
|
proxyRes.on('end', () => resolve(Buffer.concat(chunks)))
|
|
},
|
|
)
|
|
|
|
proxy.setTimeout(SERVER_REQUEST_TIMEOUT_MS, () => proxy.destroy(new Error('Upstream request timed out')))
|
|
proxy.on('error', reject)
|
|
proxy.end()
|
|
})
|
|
}
|
|
|
|
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 = []
|
|
let size = 0
|
|
|
|
response.on('data', (chunk) => {
|
|
size += chunk.length
|
|
if (size > MAX_UPSTREAM_BODY_BYTES) {
|
|
req.destroy(new Error('Upstream response too large'))
|
|
return
|
|
}
|
|
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()
|
|
})
|
|
}
|
|
|
|
function expandHome(filePath) {
|
|
if (filePath === '~') return os.homedir()
|
|
if (filePath.startsWith(`~${path.sep}`)) return path.join(os.homedir(), filePath.slice(2))
|
|
if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2))
|
|
return filePath
|
|
}
|
|
|
|
function uptimeStoragePath() {
|
|
return path.resolve(expandHome(UPTIME_STORAGE_DIR))
|
|
}
|
|
|
|
function ensureAnalyticsDb() {
|
|
if (analyticsDb) return analyticsDb
|
|
|
|
const storageDir = uptimeStoragePath()
|
|
fs.mkdirSync(storageDir, { recursive: true })
|
|
|
|
analyticsDb = new Database(path.join(storageDir, ANALYTICS_DATABASE_FILE))
|
|
analyticsDb.pragma('journal_mode = WAL')
|
|
analyticsDb.pragma('busy_timeout = 5000')
|
|
analyticsDb.exec(`
|
|
create table if not exists viewer_events (
|
|
id integer primary key autoincrement,
|
|
occurred_at text not null default (datetime('now')),
|
|
visitor_id text not null,
|
|
session_id text not null,
|
|
ip_hash text not null,
|
|
event_type text not null,
|
|
page_path text not null,
|
|
page_title text not null,
|
|
referrer text not null default '',
|
|
user_agent text not null default '',
|
|
browser text not null default 'Unknown',
|
|
os text not null default 'Unknown',
|
|
device text not null default 'Desktop',
|
|
screen text not null default '',
|
|
theme text not null default 'light',
|
|
language text not null default '',
|
|
timezone text not null default '',
|
|
country text not null default '',
|
|
region text not null default '',
|
|
city text not null default '',
|
|
latitude real,
|
|
longitude real,
|
|
consent text not null default 'analytics',
|
|
metadata text not null default '{}'
|
|
);
|
|
|
|
create table if not exists active_viewers (
|
|
session_id text primary key,
|
|
visitor_id text not null,
|
|
ip_hash text not null,
|
|
first_seen_at text not null,
|
|
last_seen_at text not null,
|
|
page_path text not null,
|
|
page_title text not null,
|
|
referrer text not null default '',
|
|
user_agent text not null default '',
|
|
browser text not null default 'Unknown',
|
|
os text not null default 'Unknown',
|
|
device text not null default 'Desktop',
|
|
screen text not null default '',
|
|
theme text not null default 'light',
|
|
language text not null default '',
|
|
timezone text not null default '',
|
|
country text not null default '',
|
|
region text not null default '',
|
|
city text not null default '',
|
|
latitude real,
|
|
longitude real
|
|
);
|
|
|
|
create index if not exists viewer_events_occurred_at_idx
|
|
on viewer_events (occurred_at desc);
|
|
|
|
create index if not exists viewer_events_page_path_idx
|
|
on viewer_events (page_path, occurred_at desc);
|
|
|
|
create index if not exists active_viewers_last_seen_at_idx
|
|
on active_viewers (last_seen_at desc);
|
|
`)
|
|
|
|
for (const statement of [
|
|
`alter table viewer_events add column country text not null default ''`,
|
|
`alter table active_viewers add column country text not null default ''`,
|
|
`alter table viewer_events add column region text not null default ''`,
|
|
`alter table active_viewers add column region text not null default ''`,
|
|
`alter table viewer_events add column city text not null default ''`,
|
|
`alter table active_viewers add column city text not null default ''`,
|
|
`alter table viewer_events add column latitude real`,
|
|
`alter table active_viewers add column latitude real`,
|
|
`alter table viewer_events add column longitude real`,
|
|
`alter table active_viewers add column longitude real`,
|
|
`alter table viewer_events add column theme text not null default 'light'`,
|
|
`alter table active_viewers add column theme text not null default 'light'`,
|
|
]) {
|
|
try {
|
|
analyticsDb.exec(statement)
|
|
} catch (error) {
|
|
if (!String(error.message || '').includes('duplicate column name')) throw error
|
|
}
|
|
}
|
|
|
|
return analyticsDb
|
|
}
|
|
|
|
function ensureUptimeDb() {
|
|
if (uptimeDb) return uptimeDb
|
|
|
|
const storageDir = uptimeStoragePath()
|
|
fs.mkdirSync(storageDir, { recursive: true })
|
|
|
|
uptimeDb = new Database(path.join(storageDir, UPTIME_DATABASE_FILE))
|
|
uptimeDb.pragma('journal_mode = WAL')
|
|
uptimeDb.pragma('busy_timeout = 5000')
|
|
uptimeDb.exec(`
|
|
create table if not exists uptime_snapshots (
|
|
id integer primary key autoincrement,
|
|
checked_at text not null default (datetime('now')),
|
|
website_ok integer not null,
|
|
health_ok integer not null,
|
|
tss_ok integer not null,
|
|
ok integer not null,
|
|
latency_ms integer not null,
|
|
details text not null default '{}'
|
|
);
|
|
|
|
create index if not exists uptime_snapshots_checked_at_idx
|
|
on uptime_snapshots (checked_at desc);
|
|
`)
|
|
|
|
return uptimeDb
|
|
}
|
|
|
|
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
|
|
|
|
const db = ensureUptimeDb()
|
|
db.prepare(`
|
|
insert into uptime_snapshots
|
|
(checked_at, website_ok, health_ok, tss_ok, ok, latency_ms, details)
|
|
values
|
|
(@checked_at, @website_ok, @health_ok, @tss_ok, @ok, @latency_ms, @details)
|
|
`).run({
|
|
checked_at: snapshot.checked_at,
|
|
website_ok: snapshot.website_ok ? 1 : 0,
|
|
health_ok: snapshot.health_ok ? 1 : 0,
|
|
tss_ok: snapshot.tss_ok ? 1 : 0,
|
|
ok: snapshot.ok ? 1 : 0,
|
|
latency_ms: snapshot.latency_ms,
|
|
details: JSON.stringify(snapshot.details),
|
|
})
|
|
|
|
return snapshot
|
|
}
|
|
|
|
async function uptimeHistory() {
|
|
const db = ensureUptimeDb()
|
|
const rows = db.prepare(`
|
|
select checked_at, website_ok, health_ok, tss_ok, ok, latency_ms, details
|
|
from uptime_snapshots
|
|
order by checked_at desc
|
|
limit ?
|
|
`).all(UPTIME_HISTORY_LIMIT)
|
|
const history = rows.reverse().map((row) => ({
|
|
checked_at: row.checked_at,
|
|
website_ok: Boolean(row.website_ok),
|
|
health_ok: Boolean(row.health_ok),
|
|
tss_ok: Boolean(row.tss_ok),
|
|
ok: Boolean(row.ok),
|
|
latency_ms: row.latency_ms,
|
|
details: JSON.parse(row.details || '{}'),
|
|
}))
|
|
|
|
return {
|
|
configured: true,
|
|
latest: history.at(-1) || latestUptimeSnapshot,
|
|
history,
|
|
}
|
|
}
|
|
|
|
let uptimeSamplerTimer = null
|
|
|
|
function startUptimeSampler() {
|
|
takeUptimeSnapshot().catch((error) => {
|
|
console.error('Initial uptime snapshot failed:', error)
|
|
})
|
|
|
|
uptimeSamplerTimer = setInterval(() => {
|
|
takeUptimeSnapshot().catch((error) => {
|
|
console.error('Uptime snapshot failed:', error)
|
|
})
|
|
}, UPTIME_SAMPLE_INTERVAL_MS).unref()
|
|
}
|
|
|
|
function trustedHeaderFirst(req, name) {
|
|
const value = req.headers[name]
|
|
if (!value) return ''
|
|
return String(Array.isArray(value) ? value[0] : value).split(',')[0].trim()
|
|
}
|
|
|
|
function trustedForwardedProto(req) {
|
|
if (!requestPeerIsTrusted(req)) {
|
|
return req.socket.encrypted ? 'https' : 'http'
|
|
}
|
|
if (TRUST_PROXY === 'cloudflare') {
|
|
const cfVisitor = trustedHeaderFirst(req, 'cf-visitor')
|
|
if (cfVisitor) {
|
|
try {
|
|
const parsed = JSON.parse(cfVisitor)
|
|
if (parsed && typeof parsed.scheme === 'string') return parsed.scheme.toLowerCase()
|
|
} catch {
|
|
// ignore malformed cf-visitor
|
|
}
|
|
}
|
|
}
|
|
return (
|
|
trustedHeaderFirst(req, 'x-forwarded-proto').toLowerCase() ||
|
|
(req.socket.encrypted ? 'https' : 'http')
|
|
)
|
|
}
|
|
|
|
function trustedForwardedHost(req) {
|
|
if (requestPeerIsTrusted(req) && TRUST_PROXY === 'generic') {
|
|
const value = trustedHeaderFirst(req, 'x-forwarded-host')
|
|
if (value) return value
|
|
}
|
|
return trustedHeaderFirst(req, 'host')
|
|
}
|
|
|
|
function isHttpsRequest(req) {
|
|
if (req.socket.encrypted) return true
|
|
return trustedForwardedProto(req) === 'https'
|
|
}
|
|
|
|
function publicOrigins(req) {
|
|
const origins = PUBLIC_ORIGIN.split(',')
|
|
.map((origin) => origin.trim())
|
|
.filter(Boolean)
|
|
|
|
if (!origins.length) {
|
|
const host = trustedForwardedHost(req)
|
|
const proto = trustedForwardedProto(req) || (req.socket.encrypted ? 'https' : 'http')
|
|
|
|
if (host) {
|
|
origins.push(`${proto}://${host}`)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (requestPeerIsTrusted(req)) {
|
|
if (TRUST_PROXY === 'cloudflare') {
|
|
const cf = trustedHeaderFirst(req, 'cf-connecting-ip')
|
|
if (cf) return cf
|
|
}
|
|
const forwarded = trustedHeaderFirst(req, 'x-forwarded-for')
|
|
if (forwarded) return forwarded
|
|
}
|
|
return req.socket.remoteAddress || 'unknown'
|
|
}
|
|
|
|
function sanitizeText(value, maxLength = 200) {
|
|
return String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength)
|
|
}
|
|
|
|
function sanitizePath(value) {
|
|
const raw = sanitizeText(value, 300)
|
|
if (!raw.startsWith('/')) return '/'
|
|
return raw
|
|
}
|
|
|
|
function headerValue(req, name, maxLength = 200) {
|
|
const value = req.headers[name]
|
|
if (Array.isArray(value)) return sanitizeText(value.join(', '), maxLength)
|
|
return sanitizeText(value, maxLength)
|
|
}
|
|
|
|
function countryFromHeaders(req) {
|
|
if (!requestPeerIsTrusted(req)) return ''
|
|
const raw =
|
|
headerValue(req, 'cf-ipcountry', 12) ||
|
|
headerValue(req, 'x-vercel-ip-country', 12) ||
|
|
headerValue(req, 'x-appengine-country', 12) ||
|
|
headerValue(req, 'cloudfront-viewer-country', 12)
|
|
const country = raw.toUpperCase()
|
|
if (!/^[A-Z]{2}$/.test(country) || country === 'XX') return ''
|
|
return country
|
|
}
|
|
|
|
function numberHeader(req, name, min, max) {
|
|
const value = Number(headerValue(req, name, 40))
|
|
if (!Number.isFinite(value) || value < min || value > max) return null
|
|
return value
|
|
}
|
|
|
|
function sanitizeTheme(value) {
|
|
return value === 'dark' ? 'dark' : 'light'
|
|
}
|
|
|
|
const CITY_COORDINATE_OVERRIDES = new Map([
|
|
['GB|ENGLAND|MILTON KEYNES', { latitude: 52.0406, longitude: -0.7594 }],
|
|
])
|
|
|
|
function normalizedLocationKey(location) {
|
|
return [
|
|
String(location.country || '').trim().toUpperCase(),
|
|
String(location.region || '').trim().toUpperCase(),
|
|
String(location.city || '').trim().toUpperCase(),
|
|
].join('|')
|
|
}
|
|
|
|
function normalizeLocationSignal(location) {
|
|
const override = CITY_COORDINATE_OVERRIDES.get(normalizedLocationKey(location))
|
|
if (!override) return location
|
|
return {
|
|
...location,
|
|
latitude: override.latitude,
|
|
longitude: override.longitude,
|
|
}
|
|
}
|
|
|
|
function locationFromHeaders(req) {
|
|
if (!requestPeerIsTrusted(req) || TRUST_PROXY !== 'cloudflare') {
|
|
return normalizeLocationSignal({ country: countryFromHeaders(req), region: '', city: '', latitude: null, longitude: null })
|
|
}
|
|
return normalizeLocationSignal({
|
|
country: countryFromHeaders(req),
|
|
region: headerValue(req, 'cf-region', 120),
|
|
city: headerValue(req, 'cf-ipcity', 120),
|
|
latitude: numberHeader(req, 'cf-iplatitude', -90, 90),
|
|
longitude: numberHeader(req, 'cf-iplongitude', -180, 180),
|
|
})
|
|
}
|
|
|
|
function sanitizeAnalyticsValue(value, depth = 0) {
|
|
if (depth > 3) return null
|
|
if (value == null) return null
|
|
if (typeof value === 'string') return value.slice(0, 500)
|
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
if (typeof value === 'boolean') return value
|
|
if (Array.isArray(value)) {
|
|
return value.slice(0, 24).map((item) => sanitizeAnalyticsValue(item, depth + 1)).filter((item) => item !== null)
|
|
}
|
|
if (typeof value === 'object') {
|
|
const out = {}
|
|
let kept = 0
|
|
for (const [key, val] of Object.entries(value)) {
|
|
if (kept >= 32) break
|
|
const cleanKey = String(key).replace(/[^A-Za-z0-9_\-]/g, '').slice(0, 60)
|
|
if (!cleanKey) continue
|
|
const cleanVal = sanitizeAnalyticsValue(val, depth + 1)
|
|
if (cleanVal === null) continue
|
|
out[cleanKey] = cleanVal
|
|
kept += 1
|
|
}
|
|
return out
|
|
}
|
|
return null
|
|
}
|
|
|
|
function pruneAnalyticsMetadata(raw) {
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
|
|
const out = {}
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
if (!ANALYTICS_METADATA_ALLOWED_KEYS.has(key)) continue
|
|
const sanitized = sanitizeAnalyticsValue(value, 0)
|
|
if (sanitized === null) continue
|
|
out[key] = sanitized
|
|
}
|
|
return out
|
|
}
|
|
|
|
function analyticsMetadata(req, payload) {
|
|
const metadata = pruneAnalyticsMetadata(payload.metadata)
|
|
const preferences = metadata.preferences && typeof metadata.preferences === 'object'
|
|
? metadata.preferences
|
|
: {}
|
|
|
|
if (!preferences.diagnostics) return metadata
|
|
|
|
return {
|
|
...metadata,
|
|
request: {
|
|
http_version: req.httpVersion || '',
|
|
method: req.method || '',
|
|
url_path: sanitizePath(req.url?.split('?')[0] || ''),
|
|
protocol: headerValue(req, 'x-forwarded-proto', 40) || (req.socket.encrypted ? 'https' : 'http'),
|
|
host: headerValue(req, 'x-forwarded-host', 160) || headerValue(req, 'host', 160),
|
|
content_type: headerValue(req, 'content-type', 120),
|
|
content_length: headerValue(req, 'content-length', 40),
|
|
accept: headerValue(req, 'accept', 300),
|
|
accept_encoding: headerValue(req, 'accept-encoding', 160),
|
|
accept_language: preferences.locale ? headerValue(req, 'accept-language', 200) : '',
|
|
sec_fetch_site: headerValue(req, 'sec-fetch-site', 40),
|
|
sec_fetch_mode: headerValue(req, 'sec-fetch-mode', 40),
|
|
sec_fetch_dest: headerValue(req, 'sec-fetch-dest', 40),
|
|
forwarded_port: headerValue(req, 'x-forwarded-port', 20),
|
|
forwarded_host_present: Boolean(req.headers['x-forwarded-host']),
|
|
forwarded_proto_present: Boolean(req.headers['x-forwarded-proto']),
|
|
},
|
|
}
|
|
}
|
|
|
|
function parseClient(userAgent = '') {
|
|
const ua = String(userAgent)
|
|
let browser = 'Unknown'
|
|
let osName = 'Unknown'
|
|
let device = 'Desktop'
|
|
|
|
if (/Edg\//.test(ua)) browser = 'Microsoft Edge'
|
|
else if (/OPR\//.test(ua)) browser = 'Opera'
|
|
else if (/Firefox\//.test(ua)) browser = 'Firefox'
|
|
else if (/Chrome\//.test(ua) && !/Chromium\//.test(ua)) browser = 'Chrome'
|
|
else if (/Safari\//.test(ua) && /Version\//.test(ua)) browser = 'Safari'
|
|
|
|
if (/Windows NT/.test(ua)) osName = 'Windows'
|
|
else if (/Android/.test(ua)) osName = 'Android'
|
|
else if (/(iPhone|iPad|iPod)/.test(ua)) osName = 'iOS'
|
|
else if (/Mac OS X/.test(ua)) osName = 'macOS'
|
|
else if (/Linux/.test(ua)) osName = 'Linux'
|
|
|
|
if (/Mobi|Android|iPhone|iPod/.test(ua)) device = 'Mobile'
|
|
else if (/iPad|Tablet/.test(ua)) device = 'Tablet'
|
|
|
|
return { browser, os: osName, device }
|
|
}
|
|
|
|
function readJsonBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = []
|
|
let size = 0
|
|
|
|
req.on('data', (chunk) => {
|
|
size += chunk.length
|
|
if (size > MAX_ANALYTICS_BODY_BYTES) {
|
|
reject(new Error('Request body too large'))
|
|
req.destroy()
|
|
return
|
|
}
|
|
chunks.push(chunk)
|
|
})
|
|
|
|
req.on('end', () => {
|
|
try {
|
|
const body = Buffer.concat(chunks).toString('utf8')
|
|
resolve(body ? JSON.parse(body) : {})
|
|
} catch {
|
|
reject(new Error('Invalid JSON body'))
|
|
}
|
|
})
|
|
req.on('error', reject)
|
|
})
|
|
}
|
|
|
|
const TURNSTILE_SESSION_COOKIE_BASE = 'tssbot_turnstile'
|
|
const TURNSTILE_SESSION_COOKIE_HOST = `__Host-${TURNSTILE_SESSION_COOKIE_BASE}`
|
|
const TURNSTILE_SESSION_TTL_SECONDS = 30 * 60
|
|
const TURNSTILE_SESSION_HMAC_KEY = TURNSTILE_SECRET_KEY
|
|
? crypto
|
|
.createHash('sha256')
|
|
.update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`)
|
|
.digest()
|
|
: null
|
|
|
|
function signTurnstileSession(expiresAt) {
|
|
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
|
|
return crypto
|
|
.createHmac('sha256', TURNSTILE_SESSION_HMAC_KEY)
|
|
.update(String(expiresAt))
|
|
.digest('base64url')
|
|
}
|
|
|
|
function buildTurnstileSessionCookie(req) {
|
|
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
|
|
const expiresAt = Math.floor(Date.now() / 1000) + TURNSTILE_SESSION_TTL_SECONDS
|
|
const signature = signTurnstileSession(expiresAt)
|
|
const value = `${expiresAt}.${signature}`
|
|
const isHttps = isHttpsRequest(req)
|
|
const name = isHttps ? TURNSTILE_SESSION_COOKIE_HOST : TURNSTILE_SESSION_COOKIE_BASE
|
|
const parts = [
|
|
`${name}=${value}`,
|
|
'Path=/',
|
|
`Max-Age=${TURNSTILE_SESSION_TTL_SECONDS}`,
|
|
'HttpOnly',
|
|
'SameSite=Strict',
|
|
]
|
|
if (isHttps) parts.push('Secure')
|
|
return parts.join('; ')
|
|
}
|
|
|
|
function parseRequestCookies(req) {
|
|
const header = req.headers.cookie || ''
|
|
const out = {}
|
|
for (const part of header.split(/;\s*/)) {
|
|
if (!part) continue
|
|
const eq = part.indexOf('=')
|
|
if (eq < 1) continue
|
|
out[part.slice(0, eq)] = part.slice(eq + 1)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function isTurnstileSessionVerified(req) {
|
|
if (!TURNSTILE_SECRET_KEY) return true
|
|
if (!TURNSTILE_SESSION_HMAC_KEY) return false
|
|
const cookies = parseRequestCookies(req)
|
|
const cookie =
|
|
cookies[TURNSTILE_SESSION_COOKIE_HOST] || cookies[TURNSTILE_SESSION_COOKIE_BASE]
|
|
if (!cookie) return false
|
|
const dot = cookie.indexOf('.')
|
|
if (dot < 1) return false
|
|
const expiresAtStr = cookie.slice(0, dot)
|
|
const signature = cookie.slice(dot + 1)
|
|
const expiresAt = Number(expiresAtStr)
|
|
if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false
|
|
const expected = signTurnstileSession(expiresAt)
|
|
if (!expected || signature.length !== expected.length) return false
|
|
try {
|
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function callTurnstileSiteverify(token, remoteIp, idempotencyKey) {
|
|
return new Promise((resolve) => {
|
|
const params = new URLSearchParams()
|
|
params.set('secret', TURNSTILE_SECRET_KEY)
|
|
params.set('response', token)
|
|
if (remoteIp && remoteIp !== 'unknown') params.set('remoteip', remoteIp)
|
|
if (idempotencyKey) params.set('idempotency_key', idempotencyKey)
|
|
const payload = params.toString()
|
|
|
|
const verifyUrl = new URL(TURNSTILE_VERIFY_URL)
|
|
const request = https.request(
|
|
{
|
|
method: 'POST',
|
|
hostname: verifyUrl.hostname,
|
|
path: verifyUrl.pathname,
|
|
headers: {
|
|
'content-type': 'application/x-www-form-urlencoded',
|
|
'content-length': Buffer.byteLength(payload),
|
|
},
|
|
timeout: TURNSTILE_VERIFY_TIMEOUT_MS,
|
|
},
|
|
(response) => {
|
|
const chunks = []
|
|
response.on('data', (chunk) => chunks.push(chunk))
|
|
response.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
resolve({ ok: true, data })
|
|
} catch {
|
|
resolve({ ok: false, error: 'Invalid Turnstile response' })
|
|
}
|
|
})
|
|
response.on('error', (error) => {
|
|
resolve({ ok: false, error: error.message || 'Turnstile read failed' })
|
|
})
|
|
},
|
|
)
|
|
|
|
request.on('timeout', () => {
|
|
request.destroy(new Error('Turnstile verification timed out'))
|
|
})
|
|
request.on('error', (error) => {
|
|
resolve({ ok: false, error: error.message || 'Turnstile request failed' })
|
|
})
|
|
|
|
request.write(payload)
|
|
request.end()
|
|
})
|
|
}
|
|
|
|
async function verifyTurnstileToken(token, remoteIp, options = {}) {
|
|
if (!TURNSTILE_SECRET_KEY) {
|
|
return { success: true, data: { skipped: true } }
|
|
}
|
|
if (!token || typeof token !== 'string') {
|
|
return { success: false, error: 'Missing Turnstile token', codes: ['missing-input-response'] }
|
|
}
|
|
if (token.length > TURNSTILE_MAX_TOKEN_LENGTH) {
|
|
return { success: false, error: 'Turnstile token too long', codes: ['invalid-input-response'] }
|
|
}
|
|
|
|
const { expectedAction, expectedHostname, maxRetries = 2 } = options
|
|
const idempotencyKey = crypto.randomUUID()
|
|
|
|
let attempt = 0
|
|
let lastResult = null
|
|
while (attempt < maxRetries) {
|
|
attempt += 1
|
|
const call = await callTurnstileSiteverify(token, remoteIp, idempotencyKey)
|
|
lastResult = call
|
|
|
|
if (call.ok) {
|
|
const data = call.data || {}
|
|
if (!data.success) {
|
|
const codes = Array.isArray(data['error-codes']) ? data['error-codes'] : []
|
|
const transient = codes.includes('internal-error')
|
|
if (transient && attempt < maxRetries) {
|
|
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250))
|
|
continue
|
|
}
|
|
return { success: false, error: 'Turnstile verification failed', codes }
|
|
}
|
|
|
|
if (expectedAction && data.action && data.action !== expectedAction) {
|
|
console.warn(`Turnstile action mismatch: expected ${expectedAction}, got ${data.action}`)
|
|
return { success: false, error: 'Action mismatch', codes: ['action-mismatch'] }
|
|
}
|
|
|
|
if (expectedHostname && data.hostname && data.hostname !== expectedHostname) {
|
|
console.warn(`Turnstile hostname mismatch: expected ${expectedHostname}, got ${data.hostname}`)
|
|
return { success: false, error: 'Hostname mismatch', codes: ['hostname-mismatch'] }
|
|
}
|
|
|
|
if (data.challenge_ts) {
|
|
const ageMs = Date.now() - Date.parse(data.challenge_ts)
|
|
if (Number.isFinite(ageMs) && ageMs > TURNSTILE_TOKEN_MAX_AGE_MS) {
|
|
console.warn(`Turnstile token age ${(ageMs / 1000).toFixed(1)}s exceeds limit`)
|
|
return { success: false, error: 'Token expired', codes: ['stale-token'] }
|
|
}
|
|
}
|
|
|
|
return { success: true, data }
|
|
}
|
|
|
|
if (attempt < maxRetries) {
|
|
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250))
|
|
}
|
|
}
|
|
|
|
console.error('Turnstile verification failed after retries:', lastResult?.error)
|
|
return { success: false, error: 'Turnstile verification unavailable', codes: ['internal-error'] }
|
|
}
|
|
|
|
function expectedTurnstileHostname() {
|
|
if (!PUBLIC_ORIGIN) return ''
|
|
const first = PUBLIC_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)[0]
|
|
if (!first) return ''
|
|
try {
|
|
return new URL(first).hostname
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function purgeOldAnalytics(db) {
|
|
const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
|
|
const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString()
|
|
|
|
db.transaction(() => {
|
|
db.prepare(`
|
|
delete from viewer_events
|
|
where occurred_at < ?
|
|
`).run(eventCutoff)
|
|
|
|
db.prepare(`
|
|
delete from active_viewers
|
|
where last_seen_at < ?
|
|
`).run(activeCutoff)
|
|
})()
|
|
}
|
|
|
|
function recordViewerEvent(req, payload) {
|
|
const db = ensureAnalyticsDb()
|
|
purgeOldAnalytics(db)
|
|
|
|
const serverClient = parseClient(req.headers['user-agent'] || '')
|
|
const location = locationFromHeaders(req)
|
|
const shareUserAgent = payload.user_agent !== 'Not shared'
|
|
const event = {
|
|
visitor_id: sanitizeText(payload.visitor_id, 80) || crypto.randomUUID(),
|
|
session_id: sanitizeText(payload.session_id, 80) || crypto.randomUUID(),
|
|
ip_hash: '',
|
|
event_type: ['page_view', 'heartbeat', 'consent'].includes(payload.event_type)
|
|
? payload.event_type
|
|
: 'heartbeat',
|
|
page_path: sanitizePath(payload.page_path),
|
|
page_title: sanitizeText(payload.page_title, 160),
|
|
referrer: sanitizeText(payload.referrer, 300),
|
|
user_agent: sanitizeText(
|
|
shareUserAgent ? payload.user_agent || req.headers['user-agent'] : 'Not shared',
|
|
500,
|
|
),
|
|
browser: sanitizeText(payload.browser || (shareUserAgent ? serverClient.browser : 'Not shared'), 80),
|
|
os: sanitizeText(payload.os || (shareUserAgent ? serverClient.os : 'Not shared'), 80),
|
|
device: sanitizeText(payload.device || (shareUserAgent ? serverClient.device : 'Not shared'), 80),
|
|
screen: sanitizeText(payload.screen, 40),
|
|
theme: sanitizeTheme(payload.theme),
|
|
language: sanitizeText(payload.language, 40),
|
|
timezone: sanitizeText(payload.timezone, 80),
|
|
country: location.country,
|
|
region: location.region,
|
|
city: location.city,
|
|
latitude: location.latitude,
|
|
longitude: location.longitude,
|
|
consent: payload.consent === 'analytics' ? 'analytics' : '',
|
|
metadata: JSON.stringify(analyticsMetadata(req, payload)),
|
|
}
|
|
|
|
if (event.consent !== 'analytics') {
|
|
throw new Error('Analytics consent is required')
|
|
}
|
|
|
|
const now = new Date().toISOString()
|
|
const writeViewerEvent = db.transaction(() => {
|
|
db.prepare(`
|
|
insert into viewer_events
|
|
(occurred_at, visitor_id, session_id, ip_hash, event_type, page_path, page_title,
|
|
referrer, user_agent, browser, os, device, screen, theme, language, timezone,
|
|
country, region, city, latitude, longitude, consent, metadata)
|
|
values
|
|
(@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title,
|
|
@referrer, @user_agent, @browser, @os, @device, @screen, @theme, @language, @timezone,
|
|
@country, @region, @city, @latitude, @longitude, @consent, @metadata)
|
|
`).run({ ...event, occurred_at: now })
|
|
|
|
db.prepare(`
|
|
insert into active_viewers
|
|
(session_id, visitor_id, ip_hash, first_seen_at, last_seen_at, page_path, page_title,
|
|
referrer, user_agent, browser, os, device, screen, theme, language, timezone,
|
|
country, region, city, latitude, longitude)
|
|
values
|
|
(@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title,
|
|
@referrer, @user_agent, @browser, @os, @device, @screen, @theme, @language, @timezone,
|
|
@country, @region, @city, @latitude, @longitude)
|
|
on conflict(session_id) do update set
|
|
last_seen_at = excluded.last_seen_at,
|
|
page_path = excluded.page_path,
|
|
page_title = excluded.page_title,
|
|
referrer = excluded.referrer,
|
|
user_agent = excluded.user_agent,
|
|
browser = excluded.browser,
|
|
os = excluded.os,
|
|
device = excluded.device,
|
|
screen = excluded.screen,
|
|
theme = excluded.theme,
|
|
language = excluded.language,
|
|
timezone = excluded.timezone,
|
|
country = excluded.country,
|
|
region = excluded.region,
|
|
city = excluded.city,
|
|
latitude = excluded.latitude,
|
|
longitude = excluded.longitude
|
|
`).run({ ...event, now })
|
|
})
|
|
|
|
writeViewerEvent()
|
|
}
|
|
|
|
function deleteViewerData(payload) {
|
|
const db = ensureAnalyticsDb()
|
|
const visitorId = sanitizeText(payload.visitor_id, 80)
|
|
const sessionId = sanitizeText(payload.session_id, 80)
|
|
|
|
if (!visitorId && !sessionId) {
|
|
throw new Error('A visitor or session identifier is required')
|
|
}
|
|
|
|
const result = db.transaction(() => {
|
|
let eventsDeleted = 0
|
|
let sessionsDeleted = 0
|
|
|
|
if (visitorId) {
|
|
eventsDeleted += db.prepare('delete from viewer_events where visitor_id = ?').run(visitorId).changes
|
|
sessionsDeleted += db.prepare('delete from active_viewers where visitor_id = ?').run(visitorId).changes
|
|
}
|
|
|
|
if (sessionId) {
|
|
eventsDeleted += db.prepare('delete from viewer_events where session_id = ?').run(sessionId).changes
|
|
sessionsDeleted += db.prepare('delete from active_viewers where session_id = ?').run(sessionId).changes
|
|
}
|
|
|
|
return { events_deleted: eventsDeleted, sessions_deleted: sessionsDeleted }
|
|
})()
|
|
|
|
return result
|
|
}
|
|
|
|
function viewerDashboard() {
|
|
const db = ensureAnalyticsDb()
|
|
purgeOldAnalytics(db)
|
|
|
|
const activeSince = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 1000).toISOString()
|
|
const daySince = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
|
const thirtyDaysSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
const active = db.prepare(`
|
|
select
|
|
visitor_id,
|
|
page_path,
|
|
page_title,
|
|
min(first_seen_at) as first_seen_at,
|
|
max(last_seen_at) as last_seen_at,
|
|
max(referrer) as referrer,
|
|
max(browser) as browser,
|
|
max(os) as os,
|
|
max(device) as device,
|
|
max(screen) as screen,
|
|
max(theme) as theme,
|
|
max(language) as language,
|
|
max(timezone) as timezone,
|
|
max(country) as country,
|
|
max(region) as region,
|
|
max(city) as city,
|
|
avg(latitude) as latitude,
|
|
avg(longitude) as longitude,
|
|
count(*) as sessions
|
|
from active_viewers
|
|
where last_seen_at >= ?
|
|
group by visitor_id, page_path, page_title
|
|
order by last_seen_at desc
|
|
limit 100
|
|
`).all(activeSince).map((row) => ({
|
|
visitor: row.visitor_id.slice(0, 8),
|
|
sessions: row.sessions || 1,
|
|
first_seen_at: row.first_seen_at,
|
|
last_seen_at: row.last_seen_at,
|
|
page_path: row.page_path,
|
|
page_title: row.page_title,
|
|
referrer: row.referrer,
|
|
browser: row.browser,
|
|
os: row.os,
|
|
device: row.device,
|
|
screen: row.screen,
|
|
theme: sanitizeTheme(row.theme),
|
|
language: row.language,
|
|
timezone: row.timezone,
|
|
country: row.country,
|
|
region: row.region,
|
|
city: row.city,
|
|
latitude: row.latitude,
|
|
longitude: row.longitude,
|
|
})).map(normalizeLocationSignal)
|
|
|
|
const activePageMap = new Map()
|
|
for (const viewer of active) {
|
|
const key = `${viewer.page_path}|${viewer.page_title}`
|
|
const existing = activePageMap.get(key) || {
|
|
page_path: viewer.page_path,
|
|
page_title: viewer.page_title,
|
|
viewers: 0,
|
|
visitors: new Set(),
|
|
clients: new Map(),
|
|
countries: new Set(),
|
|
themes: new Map(),
|
|
last_seen_at: viewer.last_seen_at,
|
|
}
|
|
|
|
existing.viewers += viewer.sessions || 1
|
|
existing.visitors.add(viewer.visitor)
|
|
if (viewer.country) existing.countries.add(viewer.country)
|
|
existing.themes.set(viewer.theme, (existing.themes.get(viewer.theme) || 0) + (viewer.sessions || 1))
|
|
const clientKey = `${viewer.browser} on ${viewer.os}`
|
|
existing.clients.set(clientKey, (existing.clients.get(clientKey) || 0) + 1)
|
|
if (new Date(viewer.last_seen_at).getTime() > new Date(existing.last_seen_at).getTime()) {
|
|
existing.last_seen_at = viewer.last_seen_at
|
|
}
|
|
activePageMap.set(key, existing)
|
|
}
|
|
|
|
const activePages = Array.from(activePageMap.values())
|
|
.map((page) => ({
|
|
page_path: page.page_path,
|
|
page_title: page.page_title,
|
|
viewers: page.viewers,
|
|
visitors: page.visitors.size,
|
|
countries: Array.from(page.countries).sort(),
|
|
themes: Array.from(page.themes.entries())
|
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
.map(([theme, count]) => ({ theme, count })),
|
|
clients: Array.from(page.clients.entries())
|
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
.slice(0, 4)
|
|
.map(([label, count]) => ({ label, count })),
|
|
last_seen_at: page.last_seen_at,
|
|
}))
|
|
.sort((a, b) => b.viewers - a.viewers || new Date(b.last_seen_at) - new Date(a.last_seen_at))
|
|
|
|
const topPages = db.prepare(`
|
|
select page_path, page_title, count(*) as views
|
|
from viewer_events
|
|
where event_type = 'page_view'
|
|
and occurred_at >= ?
|
|
group by page_path, page_title
|
|
order by views desc, page_path asc
|
|
limit 12
|
|
`).all(daySince)
|
|
|
|
const topPages30d = db.prepare(`
|
|
select page_path, page_title, count(*) as views
|
|
from viewer_events
|
|
where event_type = 'page_view'
|
|
and occurred_at >= ?
|
|
group by page_path, page_title
|
|
order by views desc, page_path asc
|
|
limit 12
|
|
`).all(thirtyDaysSince)
|
|
|
|
const clients = db.prepare(`
|
|
select browser, os, device, count(*) as events, count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
group by browser, os, device
|
|
order by events desc
|
|
limit 12
|
|
`).all(daySince)
|
|
|
|
const clients30d = db.prepare(`
|
|
select browser, os, device, count(*) as events, count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
group by browser, os, device
|
|
order by events desc
|
|
limit 12
|
|
`).all(thirtyDaysSince)
|
|
|
|
const themes = db.prepare(`
|
|
select theme, count(*) as events, count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
group by theme
|
|
order by events desc
|
|
`).all(daySince).map((row) => ({ ...row, theme: sanitizeTheme(row.theme) }))
|
|
|
|
const themes30d = db.prepare(`
|
|
select theme, count(*) as events, count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
group by theme
|
|
order by events desc
|
|
`).all(thirtyDaysSince).map((row) => ({ ...row, theme: sanitizeTheme(row.theme) }))
|
|
|
|
const activity24h = db.prepare(`
|
|
select
|
|
substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
|
|
count(*) as events,
|
|
count(distinct visitor_id) as visitors,
|
|
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views,
|
|
count(distinct browser || '|' || os || '|' || device) as clients,
|
|
count(distinct case
|
|
when country != '' then country
|
|
when timezone != '' and timezone != 'Not shared' then timezone
|
|
else null
|
|
end) as locations
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
group by substr(occurred_at, 1, 13)
|
|
order by date asc
|
|
`).all(daySince)
|
|
|
|
const activity30d = db.prepare(`
|
|
select
|
|
date(occurred_at) as date,
|
|
count(*) as events,
|
|
count(distinct visitor_id) as visitors,
|
|
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views,
|
|
count(distinct browser || '|' || os || '|' || device) as clients,
|
|
count(distinct case
|
|
when country != '' then country
|
|
when timezone != '' and timezone != 'Not shared' then timezone
|
|
else null
|
|
end) as locations
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
group by date(occurred_at)
|
|
order by date asc
|
|
`).all(thirtyDaysSince)
|
|
|
|
const activityLocationRows24h = db.prepare(`
|
|
select
|
|
substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
|
|
coalesce(nullif(country, ''), '') as country,
|
|
coalesce(nullif(city, ''), '') as city,
|
|
coalesce(nullif(region, ''), '') as region,
|
|
coalesce(nullif(timezone, ''), '') as timezone,
|
|
count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
and (
|
|
country != ''
|
|
or city != ''
|
|
or region != ''
|
|
or (timezone != '' and timezone != 'Not shared')
|
|
)
|
|
group by substr(occurred_at, 1, 13), coalesce(nullif(country, ''), ''), coalesce(nullif(city, ''), ''), coalesce(nullif(region, ''), ''), coalesce(nullif(timezone, ''), '')
|
|
order by date asc, visitors desc
|
|
`).all(daySince)
|
|
|
|
const activityLocationRows = db.prepare(`
|
|
select
|
|
date(occurred_at) as date,
|
|
coalesce(nullif(country, ''), '') as country,
|
|
coalesce(nullif(city, ''), '') as city,
|
|
coalesce(nullif(region, ''), '') as region,
|
|
coalesce(nullif(timezone, ''), '') as timezone,
|
|
count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
and (
|
|
country != ''
|
|
or city != ''
|
|
or region != ''
|
|
or (timezone != '' and timezone != 'Not shared')
|
|
)
|
|
group by date(occurred_at), coalesce(nullif(country, ''), ''), coalesce(nullif(city, ''), ''), coalesce(nullif(region, ''), ''), coalesce(nullif(timezone, ''), '')
|
|
order by date asc, visitors desc
|
|
`).all(thirtyDaysSince)
|
|
|
|
const locationsByDate = new Map()
|
|
const locations24hByDate = new Map()
|
|
|
|
for (const row of activityLocationRows24h) {
|
|
const label = [row.city, row.region, row.country || row.timezone]
|
|
.filter(Boolean)
|
|
.join(', ')
|
|
if (!label) continue
|
|
const current = locations24hByDate.get(row.date) || []
|
|
current.push({ label, visitors: row.visitors || 0 })
|
|
locations24hByDate.set(row.date, current)
|
|
}
|
|
|
|
for (const row of activityLocationRows) {
|
|
const label = [row.city, row.region, row.country || row.timezone]
|
|
.filter(Boolean)
|
|
.join(', ')
|
|
if (!label) continue
|
|
const current = locationsByDate.get(row.date) || []
|
|
current.push({ label, visitors: row.visitors || 0 })
|
|
locationsByDate.set(row.date, current)
|
|
}
|
|
|
|
const activityClientRows24h = db.prepare(`
|
|
select
|
|
substr(occurred_at, 1, 13) || ':00:00.000Z' as date,
|
|
browser,
|
|
os,
|
|
device,
|
|
count(distinct visitor_id) as visitors,
|
|
count(*) as events
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
group by substr(occurred_at, 1, 13), browser, os, device
|
|
order by date asc, visitors desc, events desc
|
|
`).all(daySince)
|
|
|
|
const activityClientRows = db.prepare(`
|
|
select
|
|
date(occurred_at) as date,
|
|
browser,
|
|
os,
|
|
device,
|
|
count(distinct visitor_id) as visitors,
|
|
count(*) as events
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
group by date(occurred_at), browser, os, device
|
|
order by date asc, visitors desc, events desc
|
|
`).all(thirtyDaysSince)
|
|
|
|
const clientsByDate = new Map()
|
|
const clients24hByDate = new Map()
|
|
|
|
for (const row of activityClientRows24h) {
|
|
const label = `${row.browser} on ${row.os}${row.device ? ` (${row.device})` : ''}`
|
|
const current = clients24hByDate.get(row.date) || []
|
|
if (current.length < 4) current.push({ label, visitors: row.visitors || 0, events: row.events || 0 })
|
|
clients24hByDate.set(row.date, current)
|
|
}
|
|
|
|
for (const row of activityClientRows) {
|
|
const label = `${row.browser} on ${row.os}${row.device ? ` (${row.device})` : ''}`
|
|
const current = clientsByDate.get(row.date) || []
|
|
if (current.length < 4) current.push({ label, visitors: row.visitors || 0, events: row.events || 0 })
|
|
clientsByDate.set(row.date, current)
|
|
}
|
|
|
|
const activity24hWithLabels = activity24h.map((row) => ({
|
|
...row,
|
|
client_labels: clients24hByDate.get(row.date) || [],
|
|
location_labels: locations24hByDate.get(row.date) || [],
|
|
}))
|
|
|
|
const activityWithLocations = activity30d.map((row) => ({
|
|
...row,
|
|
client_labels: clientsByDate.get(row.date) || [],
|
|
location_labels: locationsByDate.get(row.date) || [],
|
|
}))
|
|
|
|
const countries24h = db.prepare(`
|
|
select
|
|
country,
|
|
avg(latitude) as latitude,
|
|
avg(longitude) as longitude,
|
|
count(*) as events,
|
|
count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
and country != ''
|
|
and latitude is not null
|
|
and longitude is not null
|
|
group by country
|
|
order by visitors desc, events desc
|
|
limit 80
|
|
`).all(daySince)
|
|
|
|
const countries = db.prepare(`
|
|
select
|
|
country,
|
|
avg(latitude) as latitude,
|
|
avg(longitude) as longitude,
|
|
count(*) as events,
|
|
count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
and country != ''
|
|
and latitude is not null
|
|
and longitude is not null
|
|
group by country
|
|
order by visitors desc, events desc
|
|
limit 80
|
|
`).all(thirtyDaysSince)
|
|
|
|
const locations24h = db.prepare(`
|
|
select
|
|
country,
|
|
region,
|
|
city,
|
|
latitude,
|
|
longitude,
|
|
timezone,
|
|
language,
|
|
count(*) as events,
|
|
count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
and latitude is not null
|
|
and longitude is not null
|
|
group by country, region, city, latitude, longitude, timezone, language
|
|
order by visitors desc, events desc
|
|
limit 32
|
|
`).all(daySince).map(normalizeLocationSignal)
|
|
|
|
const locations = db.prepare(`
|
|
select
|
|
country,
|
|
region,
|
|
city,
|
|
latitude,
|
|
longitude,
|
|
timezone,
|
|
language,
|
|
count(*) as events,
|
|
count(distinct visitor_id) as visitors
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
and latitude is not null
|
|
and longitude is not null
|
|
group by country, region, city, latitude, longitude, timezone, language
|
|
order by visitors desc, events desc
|
|
limit 32
|
|
`).all(thirtyDaysSince).map(normalizeLocationSignal)
|
|
|
|
const totals = db.prepare(`
|
|
select
|
|
count(*) as events_24h,
|
|
count(distinct visitor_id) as visitors_24h,
|
|
count(distinct session_id) as sessions_24h,
|
|
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_24h
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
`).get(daySince)
|
|
|
|
const totals30d = db.prepare(`
|
|
select
|
|
count(*) as events_30d,
|
|
count(distinct visitor_id) as visitors_30d,
|
|
count(distinct session_id) as sessions_30d,
|
|
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_30d
|
|
from viewer_events
|
|
where occurred_at >= ?
|
|
`).get(thirtyDaysSince)
|
|
|
|
return {
|
|
active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS,
|
|
generated_at: new Date().toISOString(),
|
|
active,
|
|
active_pages: activePages,
|
|
top_pages: topPages,
|
|
top_pages_30d: topPages30d,
|
|
clients,
|
|
clients_30d: clients30d,
|
|
themes,
|
|
themes_30d: themes30d,
|
|
activity_24h: activity24hWithLabels,
|
|
activity_30d: activityWithLocations,
|
|
countries_24h: countries24h,
|
|
countries,
|
|
locations_24h: locations24h,
|
|
locations,
|
|
totals: {
|
|
active_now: active.length,
|
|
events_24h: totals?.events_24h || 0,
|
|
visitors_24h: totals?.visitors_24h || 0,
|
|
sessions_24h: totals?.sessions_24h || 0,
|
|
page_views_24h: totals?.page_views_24h || 0,
|
|
events_30d: totals30d?.events_30d || 0,
|
|
visitors_30d: totals30d?.visitors_30d || 0,
|
|
sessions_30d: totals30d?.sessions_30d || 0,
|
|
page_views_30d: totals30d?.page_views_30d || 0,
|
|
},
|
|
data_types: [
|
|
{ key: 'page', label: 'Page activity', detail: 'Page path, title, page views, and heartbeat state' },
|
|
{ key: 'browser', label: 'Browser and device', detail: 'Browser, operating system, broad device type, and user-agent only when allowed' },
|
|
{ key: 'display', label: 'Display', detail: 'Screen size, viewport size, pixel ratio, and colour depth when allowed' },
|
|
{ key: 'locale', label: 'Language and coarse location', detail: 'Browser language and timezone when allowed, plus Cloudflare country/city coordinates when supplied by the edge' },
|
|
{ key: 'referrer', label: 'Referrer', detail: 'The referring page when allowed and provided by the browser' },
|
|
{ key: 'diagnostics', label: 'Diagnostics', detail: 'Privacy signals, network hints, request headers, and browser capability details when allowed' },
|
|
],
|
|
privacy: {
|
|
retention_days: ANALYTICS_RETENTION_DAYS,
|
|
stores_ip_hashes: false,
|
|
exposes_raw_ip: false,
|
|
exposes_precise_location: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
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/leaderboard/players') {
|
|
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/games/recent') {
|
|
const keys = [...params.keys()]
|
|
const limit = Number(params.get('limit') || 50)
|
|
if (keys.some((key) => key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
return null
|
|
}
|
|
return url
|
|
}
|
|
|
|
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(pathname)) {
|
|
const keys = [...params.keys()]
|
|
const lang = params.get('lang') || 'en'
|
|
if (keys.some((key) => key !== 'lang') || !/^[A-Za-z-]{2,8}$/.test(lang)) {
|
|
return null
|
|
}
|
|
return url
|
|
}
|
|
|
|
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}\/logs$/.test(pathname)) {
|
|
if ([...params.keys()].length) 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
|
|
}
|
|
|
|
if (pathname === '/api/tss/teams/search') {
|
|
const keys = [...params.keys()]
|
|
const query = params.get('q') || params.get('name') || ''
|
|
const limit = Number(params.get('limit') || 10)
|
|
if (
|
|
keys.some((key) => !['q', 'name', 'limit'].includes(key)) ||
|
|
query.length < 2 ||
|
|
query.length > MAX_TEAM_NAME_LENGTH ||
|
|
!Number.isInteger(limit) ||
|
|
limit < 1 ||
|
|
limit > 20
|
|
) {
|
|
return null
|
|
}
|
|
return url
|
|
}
|
|
|
|
if (pathname === '/api/tss/players/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
|
|
}
|
|
|
|
if (pathname === '/api/tss/players/search') {
|
|
const keys = [...params.keys()]
|
|
const query = params.get('q') || params.get('name') || ''
|
|
const limit = Number(params.get('limit') || 25)
|
|
if (
|
|
keys.some((key) => !['q', 'name', 'limit'].includes(key)) ||
|
|
query.length < 2 ||
|
|
query.length > MAX_TEAM_NAME_LENGTH ||
|
|
!Number.isInteger(limit) ||
|
|
limit < 1 ||
|
|
limit > 25
|
|
) {
|
|
return null
|
|
}
|
|
return url
|
|
}
|
|
|
|
if (/^\/api\/tss\/player\/[0-9]{1,32}$/.test(pathname)) {
|
|
if ([...params.keys()].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 requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
|
const publicDataFile = req.method === 'GET' ? publicDataCachePathForUrl(requestUrl) : null
|
|
const publicData = cachedPublicData(publicDataFile)
|
|
if (publicData?.fresh) {
|
|
return sendPublicDataFile(req, res, publicData.filePath, 200, { 'x-tssbot-cache': 'public-data-hit' })
|
|
}
|
|
if (publicData) {
|
|
refreshPublicData(publicData.filePath, target)
|
|
return sendPublicDataFile(req, res, publicData.filePath, 200, { 'x-tssbot-cache': 'public-data-stale' })
|
|
}
|
|
|
|
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 = []
|
|
let proxiedBytes = 0
|
|
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 statusCode = proxyRes.statusCode || 502
|
|
const contentType = String(proxyRes.headers['content-type'] || '')
|
|
const shouldCacheJson = statusCode >= 200 && statusCode < 300 && contentType.includes('application/json')
|
|
const headers = {
|
|
...proxyRes.headers,
|
|
...securityHeaders(req),
|
|
'cache-control': publicDataFile ? 'public, max-age=30, stale-while-revalidate=300' : 'private, max-age=15',
|
|
}
|
|
|
|
delete headers['access-control-allow-origin']
|
|
delete headers['access-control-allow-credentials']
|
|
delete headers['access-control-allow-methods']
|
|
delete headers['access-control-allow-headers']
|
|
delete headers['access-control-expose-headers']
|
|
|
|
res.writeHead(statusCode, headers)
|
|
|
|
proxyRes.on('data', (chunk) => {
|
|
proxiedBytes += chunk.length
|
|
if (proxiedBytes > MAX_UPSTREAM_BODY_BYTES) {
|
|
proxy.destroy(new Error('Upstream response too large'))
|
|
res.destroy()
|
|
return
|
|
}
|
|
if (cacheKey && shouldCacheJson) {
|
|
responseChunks.push(chunk)
|
|
}
|
|
})
|
|
|
|
proxyRes.on('end', () => {
|
|
if (cacheKey && responseChunks.length) {
|
|
const body = Buffer.concat(responseChunks)
|
|
apiCache.set(cacheKey, {
|
|
body,
|
|
headers,
|
|
expiresAt: Date.now() + API_CACHE_TTL_MS,
|
|
})
|
|
if (publicDataFile) {
|
|
try {
|
|
writePublicDataFile(publicDataFile, body)
|
|
} catch (error) {
|
|
console.warn(`could not write public data cache for ${requestUrl.pathname}: ${error.message}`)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
proxyRes.pipe(res)
|
|
},
|
|
)
|
|
|
|
proxy.on('error', (error) => {
|
|
if (res.destroyed || res.headersSent) return
|
|
sendJson(res, 502, { error: 'API proxy failed', detail: error.message })
|
|
})
|
|
|
|
req.pipe(proxy)
|
|
}
|
|
|
|
function pagePublicOrigin(req) {
|
|
const configured = PUBLIC_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)[0]
|
|
if (configured) return configured.replace(/\/$/, '')
|
|
|
|
const host = trustedForwardedHost(req) || `localhost:${PORT}`
|
|
const proto = trustedForwardedProto(req) || (req.socket.encrypted ? 'https' : 'http')
|
|
return `${String(proto).split(',')[0].trim()}://${String(host).split(',')[0].trim()}`.replace(/\/$/, '')
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
function decodeRouteSegment(value) {
|
|
try {
|
|
return decodeURIComponent(value || '').replace(/\+/g, ' ').trim()
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function routeSeo(pathname) {
|
|
const cleanPath = pathname.replace(/\/+$/, '') || '/'
|
|
|
|
if (cleanPath.startsWith('/teams/')) {
|
|
const teamName = decodeRouteSegment(cleanPath.slice('/teams/'.length))
|
|
if (teamName) {
|
|
return {
|
|
title: `${teamName} TSS Team Profile | Toothless' TSS Bot`,
|
|
description: `${teamName} TSS team profile with roster details, battle history, and recent War Thunder squadron activity.`,
|
|
robots: 'index, follow',
|
|
path: `/teams/${encodeURIComponent(teamName)}`,
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cleanPath.startsWith('/players/')) {
|
|
const uid = decodeRouteSegment(cleanPath.slice('/players/'.length))
|
|
if (uid) {
|
|
return {
|
|
title: `Player ${uid} | Toothless' TSS Bot`,
|
|
description: `TSS career stats for player ${uid} — battles, win rate, kills, and teams seen with.`,
|
|
robots: 'noindex, follow',
|
|
path: `/players/${encodeURIComponent(uid)}`,
|
|
}
|
|
}
|
|
}
|
|
|
|
const byPath = {
|
|
'/': {
|
|
title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs",
|
|
description: 'Live War Thunder TSS team leaderboards, battle logs, team profiles, uptime, and privacy-safe viewer analytics from Toothless TSS Bot.',
|
|
robots: 'index, follow',
|
|
path: '/',
|
|
},
|
|
'/teams': {
|
|
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
|
description: 'Browse the live TSS team leaderboard, compare War Thunder squadron rankings, points, players, and recent team activity.',
|
|
robots: 'index, follow',
|
|
path: '/teams',
|
|
},
|
|
'/players': {
|
|
title: "TSS Player Leaderboard | Toothless' TSS Bot",
|
|
description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.',
|
|
robots: 'index, follow',
|
|
path: '/players',
|
|
},
|
|
'/battle-logs': {
|
|
title: "TSS Battle Logs | Toothless' TSS Bot",
|
|
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
|
robots: 'index, follow',
|
|
path: '/battle-logs',
|
|
},
|
|
'/live': {
|
|
title: "TSS Battle Logs | Toothless' TSS Bot",
|
|
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
|
robots: 'index, follow',
|
|
path: '/battle-logs',
|
|
},
|
|
'/uptime': {
|
|
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
|
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
|
robots: 'index, follow',
|
|
path: '/uptime',
|
|
},
|
|
'/viewers': {
|
|
title: "Viewer Analytics | Toothless' TSS Bot",
|
|
description: 'Opt-in viewer analytics for Toothless TSS Bot, including active pages, broad device signals, and privacy-safe activity trends.',
|
|
robots: 'noindex, follow',
|
|
path: '/viewers',
|
|
},
|
|
'/privacy': {
|
|
title: "Privacy Notice | Toothless' TSS Bot",
|
|
description: 'How Toothless TSS Bot handles cookies, opt-in analytics, viewer data, retention, deletion, and privacy rights.',
|
|
robots: 'index, follow',
|
|
path: '/privacy',
|
|
},
|
|
'/docs': {
|
|
title: "Docs | Toothless' TSS Bot",
|
|
description: 'Documentation and command reference for Toothless TSS Bot.',
|
|
robots: 'noindex, follow',
|
|
path: '/docs',
|
|
},
|
|
}
|
|
|
|
return byPath[cleanPath] || byPath['/']
|
|
}
|
|
|
|
function routeStructuredData(origin, seo, canonicalUrl) {
|
|
return JSON.stringify([
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebSite',
|
|
name: "Toothless' TSS Bot",
|
|
url: `${origin}/`,
|
|
description: 'Live War Thunder TSS leaderboards, battle logs, and team profiles.',
|
|
potentialAction: {
|
|
'@type': 'SearchAction',
|
|
target: `${origin}/teams/{search_term_string}`,
|
|
'query-input': 'required name=search_term_string',
|
|
},
|
|
},
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebPage',
|
|
name: seo.title,
|
|
url: canonicalUrl,
|
|
description: seo.description,
|
|
isPartOf: {
|
|
'@type': 'WebSite',
|
|
name: "Toothless' TSS Bot",
|
|
url: `${origin}/`,
|
|
},
|
|
},
|
|
])
|
|
}
|
|
|
|
function htmlWithSeo(req, data) {
|
|
const origin = pagePublicOrigin(req)
|
|
let pathname = '/'
|
|
try {
|
|
pathname = new URL(req.url, origin).pathname
|
|
} catch {
|
|
pathname = '/'
|
|
}
|
|
|
|
const seo = routeSeo(pathname)
|
|
const canonicalUrl = `${origin}${seo.path}`
|
|
|
|
return data.toString('utf8')
|
|
.replace(/\s+integrity=(["'])sha(?:256|384|512)-[^"']+\1/g, '')
|
|
.replaceAll('__PUBLIC_ORIGIN__', origin)
|
|
.replaceAll('__SEO_TITLE__', escapeHtml(seo.title))
|
|
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(seo.description))
|
|
.replaceAll('__SEO_ROBOTS__', escapeHtml(seo.robots))
|
|
.replaceAll('__SEO_CANONICAL__', escapeHtml(canonicalUrl))
|
|
.replaceAll('__SEO_JSON_LD__', routeStructuredData(origin, seo, canonicalUrl).replace(/</g, '\\u003c'))
|
|
.replaceAll('__TURNSTILE_SESSION__', isTurnstileSessionVerified(req) ? 'verified' : 'required')
|
|
}
|
|
|
|
function sendHtml(req, res, data, status = 200) {
|
|
const html = htmlWithSeo(req, data)
|
|
send(res, status, html, {
|
|
...securityHeaders(req, { html: true }),
|
|
'content-type': mimeTypes['.html'],
|
|
'cache-control': 'no-cache',
|
|
})
|
|
}
|
|
|
|
function comingSoonHtml() {
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex">
|
|
<meta name="theme-color" content="#fefde7">
|
|
<title>Toothless' TSS Bot | Coming soon</title>
|
|
</head>
|
|
<body style="margin:0;min-width:320px;min-height:100vh;background:#fefde7;color:#000;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Arial,sans-serif;">
|
|
<main style="min-height:100vh;display:grid;place-items:center;padding:32px;">
|
|
<section style="width:min(100%,720px);border-left:4px solid #e82517;padding:8px 0 8px 28px;">
|
|
<p style="margin:0 0 14px;color:#e82517;font-size:13px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;">Toothless' TSS Bot</p>
|
|
<h1 style="margin:0;color:#000;font-size:clamp(48px,12vw,112px);line-height:.92;font-weight:900;letter-spacing:0;">Coming soon</h1>
|
|
<p style="margin:24px 0 0;max-width:520px;color:#555;font-size:18px;line-height:1.6;font-weight:600;">TSS analytics are getting tucked away for a little bit. Check back soon.</p>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
function sendComingSoonPage(req, res) {
|
|
send(res, 200, comingSoonHtml(), {
|
|
...securityHeaders(req, { html: true }),
|
|
'content-type': mimeTypes['.html'],
|
|
'cache-control': 'no-store',
|
|
})
|
|
}
|
|
|
|
function sendRobotsTxt(req, res) {
|
|
const origin = pagePublicOrigin(req)
|
|
const body = [
|
|
'User-agent: *',
|
|
'Allow: /',
|
|
'Disallow: /api/',
|
|
'Disallow: /viewers',
|
|
`Sitemap: ${origin}/sitemap.xml`,
|
|
'',
|
|
].join('\n')
|
|
|
|
send(res, 200, body, {
|
|
'content-type': 'text/plain; charset=utf-8',
|
|
'cache-control': 'public, max-age=3600',
|
|
})
|
|
}
|
|
|
|
function sendSitemapXml(req, res) {
|
|
const origin = pagePublicOrigin(req)
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const urls = [
|
|
{ path: '/', priority: '1.0', changefreq: 'hourly' },
|
|
{ path: '/teams', priority: '0.9', changefreq: 'hourly' },
|
|
{ path: '/battle-logs', priority: '0.9', changefreq: 'hourly' },
|
|
{ path: '/uptime', priority: '0.5', changefreq: 'daily' },
|
|
{ path: '/privacy', priority: '0.3', changefreq: 'monthly' },
|
|
]
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
${urls.map((url) => ` <url>
|
|
<loc>${escapeHtml(`${origin}${url.path}`)}</loc>
|
|
<lastmod>${today}</lastmod>
|
|
<changefreq>${url.changefreq}</changefreq>
|
|
<priority>${url.priority}</priority>
|
|
</url>`).join('\n')}
|
|
</urlset>
|
|
`
|
|
|
|
send(res, 200, body, {
|
|
'content-type': 'application/xml; charset=utf-8',
|
|
'cache-control': 'public, max-age=3600',
|
|
})
|
|
}
|
|
|
|
function serveStatic(req, res) {
|
|
let requestPath = '/'
|
|
try {
|
|
requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
|
|
} catch {
|
|
return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' })
|
|
}
|
|
if (COMING_SOON) {
|
|
return sendComingSoonPage(req, res)
|
|
}
|
|
|
|
const relativePath = requestPath === '/' ? '/index.html' : requestPath
|
|
const filePath = path.resolve(DIST_DIR, `.${relativePath}`)
|
|
const relativeToDist = path.relative(DIST_DIR, filePath)
|
|
|
|
if (relativeToDist.startsWith('..') || path.isAbsolute(relativeToDist)) {
|
|
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',
|
|
})
|
|
}
|
|
|
|
sendHtml(req, res, indexData)
|
|
})
|
|
return
|
|
}
|
|
|
|
const ext = path.extname(filePath)
|
|
if (ext === '.html') {
|
|
sendHtml(req, res, data)
|
|
return
|
|
}
|
|
|
|
send(res, 200, data, {
|
|
'content-type': mimeTypes[ext] || 'application/octet-stream',
|
|
'cache-control': 'public, max-age=31536000, immutable',
|
|
})
|
|
})
|
|
}
|
|
|
|
function serveVehicleIcon(req, res) {
|
|
let requestPath = '/'
|
|
try {
|
|
requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
|
|
} catch {
|
|
return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' })
|
|
}
|
|
const name = requestPath.slice('/vehicle-icons/'.length)
|
|
const filePath = path.resolve(VEHICLE_ICONS_DIR, `./${name}`)
|
|
const relative = path.relative(VEHICLE_ICONS_DIR, filePath)
|
|
if (relative.startsWith('..') || path.isAbsolute(relative) || path.extname(filePath) !== '.png') {
|
|
return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' })
|
|
}
|
|
fs.readFile(filePath, (error, data) => {
|
|
if (error) {
|
|
return send(res, 404, 'Not found', { 'content-type': 'text/plain; charset=utf-8' })
|
|
}
|
|
send(res, 200, data, {
|
|
'content-type': 'image/png',
|
|
'cache-control': 'public, max-age=604800',
|
|
})
|
|
})
|
|
}
|
|
|
|
function safeUniquePaths(paths) {
|
|
return [...new Set(paths.filter(Boolean).map((value) => path.resolve(value)))]
|
|
}
|
|
|
|
function resolveTssReplaySessionDir(sessionId) {
|
|
const sid = String(sessionId || '').toLowerCase()
|
|
const candidates = safeUniquePaths([
|
|
path.join(TSS_REPLAYS_DIR, sid),
|
|
path.join(TSS_REPLAYS_DIR, `0${sid}`),
|
|
path.join(TSS_REPLAY_SAMPLE_DIR, sid),
|
|
path.join(TSS_REPLAY_SAMPLE_DIR, `0${sid}`),
|
|
])
|
|
|
|
for (const dir of candidates) {
|
|
try {
|
|
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir
|
|
} catch {
|
|
// Keep trying the remaining replay roots.
|
|
}
|
|
}
|
|
|
|
return path.join(TSS_REPLAYS_DIR, sid)
|
|
}
|
|
|
|
function findTssReplayDataPath(sessionDir) {
|
|
const candidates = [
|
|
path.join(sessionDir, 'replay_data.json.gz'),
|
|
path.join(sessionDir, 'replay_data.json'),
|
|
]
|
|
for (const candidate of candidates) {
|
|
try {
|
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
|
|
} catch {
|
|
// Ignore unreadable candidates and let the caller return 404.
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readFileResponse(req, res, filePath, headers = {}) {
|
|
fs.readFile(filePath, (error, data) => {
|
|
if (error) {
|
|
sendJson(res, 404, { error: 'File not found' })
|
|
return
|
|
}
|
|
send(res, 200, data, headers)
|
|
})
|
|
}
|
|
|
|
function runReplayCanvasRenderer(replayPath, jsonPath) {
|
|
const pythonBin = fs.existsSync(TSS_REPLAY_PYTHON) ? TSS_REPLAY_PYTHON : 'python3'
|
|
return new Promise((resolve, reject) => {
|
|
execFile(
|
|
pythonBin,
|
|
['-m', 'BOT.render_replay', replayPath, jsonPath],
|
|
{
|
|
cwd: TSSBOT_REPO_DIR,
|
|
timeout: TSS_REPLAY_RENDER_TIMEOUT_MS,
|
|
env: process.env,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(new Error(String(stderr || stdout || error.message || 'Replay renderer failed').trim()))
|
|
return
|
|
}
|
|
resolve()
|
|
},
|
|
)
|
|
})
|
|
}
|
|
|
|
let tssCanvasRenderCount = 0
|
|
const TSS_CANVAS_RENDER_MAX = 3
|
|
|
|
async function serveTssReplayCanvas(req, res, sessionId) {
|
|
if (!sessionId || !/^[A-Za-z0-9_-]{1,96}$/.test(sessionId)) {
|
|
sendJson(res, 400, { error: 'Invalid game ID' })
|
|
return
|
|
}
|
|
if (!isSameOriginRequest(req)) {
|
|
sendJson(res, 403, { error: 'API access is restricted to this site' })
|
|
return
|
|
}
|
|
if (isRateLimited(req)) {
|
|
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
|
|
return
|
|
}
|
|
|
|
const sessionDir = resolveTssReplaySessionDir(sessionId)
|
|
const replayPath = findTssReplayDataPath(sessionDir)
|
|
const jsonPath = path.join(sessionDir, 'replay_canvas.json')
|
|
|
|
if (!replayPath) {
|
|
sendJson(res, 404, { available: false, reason: 'No replay data available' })
|
|
return
|
|
}
|
|
|
|
try {
|
|
const jsonStat = fs.existsSync(jsonPath) ? fs.statSync(jsonPath) : null
|
|
const replayStat = fs.statSync(replayPath)
|
|
if (jsonStat && jsonStat.size > 0 && jsonStat.mtimeMs >= replayStat.mtimeMs) {
|
|
readFileResponse(req, res, jsonPath, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'cache-control': 'public, max-age=86400',
|
|
})
|
|
return
|
|
}
|
|
} catch {
|
|
// Fall through and attempt regeneration.
|
|
}
|
|
|
|
if (tssCanvasRenderCount >= TSS_CANVAS_RENDER_MAX) {
|
|
sendJson(res, 503, { available: false, reason: 'Too many replays processing; try again shortly' })
|
|
return
|
|
}
|
|
|
|
tssCanvasRenderCount += 1
|
|
try {
|
|
await runReplayCanvasRenderer(replayPath, jsonPath)
|
|
if (!fs.existsSync(jsonPath)) {
|
|
sendJson(res, 500, { available: false, reason: 'Replay JSON generation produced no output' })
|
|
return
|
|
}
|
|
readFileResponse(req, res, jsonPath, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'cache-control': 'public, max-age=86400',
|
|
})
|
|
} catch (error) {
|
|
try {
|
|
if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath)
|
|
} catch {
|
|
// Ignore cache cleanup errors.
|
|
}
|
|
sendJson(res, 500, { available: false, reason: 'Replay JSON generation failed', detail: error.message })
|
|
} finally {
|
|
tssCanvasRenderCount -= 1
|
|
}
|
|
}
|
|
|
|
function serveReplayIcon(req, res) {
|
|
let iconName = ''
|
|
try {
|
|
const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
|
|
iconName = requestPath.slice('/api/icons/type/'.length)
|
|
} catch {
|
|
sendJson(res, 400, { error: 'Bad request' })
|
|
return
|
|
}
|
|
|
|
if (!iconName || !/^[A-Za-z0-9_-]+$/.test(iconName)) {
|
|
sendJson(res, 400, { error: 'Invalid icon name' })
|
|
return
|
|
}
|
|
|
|
const iconsBase = path.join(SHARED_DIR, 'ICONS')
|
|
const candidates = [
|
|
path.join(iconsBase, `${iconName}.png`),
|
|
path.join(iconsBase, 'FALLBACKS', `${iconName}.png`),
|
|
path.join(iconsBase, 'MINIS', `${iconName}.png`),
|
|
]
|
|
for (const candidate of candidates) {
|
|
const relative = path.relative(iconsBase, candidate)
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
|
|
if (fs.existsSync(candidate)) {
|
|
readFileResponse(req, res, candidate, {
|
|
'content-type': 'image/png',
|
|
'cache-control': 'public, max-age=604800',
|
|
})
|
|
return
|
|
}
|
|
}
|
|
sendJson(res, 404, { error: 'Icon not found' })
|
|
}
|
|
|
|
function serveReplayMinimap(req, res) {
|
|
let level = ''
|
|
let fullMap = false
|
|
try {
|
|
const url = new URL(req.url, `http://localhost:${PORT}`)
|
|
const requestPath = decodeURIComponent(url.pathname)
|
|
level = requestPath.slice('/api/match/minimap/'.length)
|
|
fullMap = url.searchParams.get('type') === 'full'
|
|
} catch {
|
|
sendJson(res, 400, { error: 'Bad request' })
|
|
return
|
|
}
|
|
|
|
if (!level || !/^[A-Za-z0-9_]+$/.test(level)) {
|
|
sendJson(res, 400, { error: 'Invalid level name' })
|
|
return
|
|
}
|
|
|
|
const minimapsDir = path.join(SHARED_DIR, 'MAPS', 'MINIMAPS')
|
|
const names = fullMap
|
|
? [`${level}.png`, `${level}_map.png`]
|
|
: [`${level}_tankmap.png`, `${level}.png`, `${level}_map.png`]
|
|
|
|
for (const name of [...new Set(names)]) {
|
|
const candidate = path.resolve(minimapsDir, name)
|
|
const relative = path.relative(minimapsDir, candidate)
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
|
|
if (fs.existsSync(candidate)) {
|
|
readFileResponse(req, res, candidate, {
|
|
'content-type': 'image/png',
|
|
'cache-control': 'public, max-age=604800',
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
sendJson(res, 404, { error: 'Minimap not found' })
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if (req.method === 'GET' && req.url === '/robots.txt') {
|
|
sendRobotsTxt(req, res)
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/sitemap.xml') {
|
|
sendSitemapXml(req, res)
|
|
return
|
|
}
|
|
|
|
if (req.url === '/health') {
|
|
sendJson(res, 200, { ok: true })
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/api/uptime') {
|
|
if (isRateLimited(req)) {
|
|
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
|
|
return
|
|
}
|
|
uptimeHistory()
|
|
.then((data) => sendJson(res, 200, data))
|
|
.catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/api/viewers') {
|
|
if (isRateLimited(req)) {
|
|
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
|
|
return
|
|
}
|
|
try {
|
|
sendJson(res, 200, viewerDashboard())
|
|
} catch (error) {
|
|
sendJson(res, 500, { error: 'Viewer analytics unavailable', detail: error.message })
|
|
}
|
|
return
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/viewers/event') {
|
|
if (!isSameOriginRequest(req)) {
|
|
sendJson(res, 403, { error: 'Analytics events are restricted to this site' })
|
|
return
|
|
}
|
|
|
|
if (isRateLimited(req)) {
|
|
sendJson(res, 429, { error: 'Too many analytics events' })
|
|
return
|
|
}
|
|
|
|
readJsonBody(req)
|
|
.then((payload) => {
|
|
recordViewerEvent(req, payload)
|
|
send(res, 204, '', { 'cache-control': 'no-store' })
|
|
})
|
|
.catch((error) => sendJson(res, 400, { error: error.message }))
|
|
return
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/viewers/delete') {
|
|
if (!isSameOriginRequest(req)) {
|
|
sendJson(res, 403, { error: 'Analytics deletion is restricted to this site' })
|
|
return
|
|
}
|
|
|
|
if (!isTurnstileSessionVerified(req)) {
|
|
sendJson(res, 403, { error: 'Turnstile session required', detail: 'Solve the site challenge first' })
|
|
return
|
|
}
|
|
|
|
readJsonBody(req)
|
|
.then((payload) => {
|
|
const result = deleteViewerData(payload)
|
|
sendJson(res, 200, result)
|
|
})
|
|
.catch((error) => sendJson(res, 400, { error: error.message }))
|
|
return
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url === '/api/turnstile/verify') {
|
|
if (!isSameOriginRequest(req)) {
|
|
sendJson(res, 403, { error: 'Turnstile verification is restricted to this site' })
|
|
return
|
|
}
|
|
|
|
if (isRateLimited(req)) {
|
|
sendJson(res, 429, { error: 'Too many verification attempts' })
|
|
return
|
|
}
|
|
|
|
readJsonBody(req)
|
|
.then(async (payload) => {
|
|
const verification = await verifyTurnstileToken(payload.token, clientIp(req), {
|
|
expectedAction: 'site-gate',
|
|
expectedHostname: expectedTurnstileHostname(),
|
|
})
|
|
if (!verification.success) {
|
|
sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error })
|
|
return
|
|
}
|
|
const headers = TURNSTILE_SECRET_KEY ? { 'set-cookie': buildTurnstileSessionCookie(req) } : {}
|
|
send(res, 200, JSON.stringify({ success: true, ttl: TURNSTILE_SESSION_TTL_SECONDS }), {
|
|
...jsonHeaders,
|
|
...headers,
|
|
})
|
|
})
|
|
.catch((error) => sendJson(res, 400, { error: error.message }))
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url === '/api/turnstile/session') {
|
|
if (!isSameOriginRequest(req)) {
|
|
sendJson(res, 403, { error: 'Turnstile session check is restricted to this site' })
|
|
return
|
|
}
|
|
sendJson(res, 200, { verified: isTurnstileSessionVerified(req) })
|
|
return
|
|
}
|
|
|
|
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
|
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET') {
|
|
let pathname = ''
|
|
try {
|
|
pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname
|
|
} catch {
|
|
pathname = ''
|
|
}
|
|
|
|
const replayMatch = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})\/replay-canvas$/)
|
|
if (replayMatch) {
|
|
serveTssReplayCanvas(req, res, replayMatch[1])
|
|
return
|
|
}
|
|
|
|
if (pathname.startsWith('/api/icons/type/')) {
|
|
serveReplayIcon(req, res)
|
|
return
|
|
}
|
|
|
|
if (pathname.startsWith('/api/match/minimap/')) {
|
|
serveReplayMinimap(req, res)
|
|
return
|
|
}
|
|
|
|
if (pathname.startsWith('/data/')) {
|
|
const filePath = safePublicDataPath(pathname)
|
|
if (!filePath) {
|
|
sendJson(res, 400, { error: 'Bad request' })
|
|
return
|
|
}
|
|
sendPublicDataFile(req, res, filePath)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (req.method === 'GET' && req.url.startsWith('/vehicle-icons/')) {
|
|
serveVehicleIcon(req, res)
|
|
return
|
|
}
|
|
|
|
if (req.url.startsWith('/api/')) {
|
|
proxyRequest(req, res)
|
|
return
|
|
}
|
|
|
|
serveStatic(req, res)
|
|
})
|
|
|
|
server.requestTimeout = SERVER_REQUEST_TIMEOUT_MS
|
|
server.headersTimeout = SERVER_HEADERS_TIMEOUT_MS
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`tssbot-web serving http://localhost:${PORT}`)
|
|
console.log(`proxying API requests to ${API_UPSTREAM}`)
|
|
if (RUN_BACKGROUND_JOBS) {
|
|
console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
|
|
} else {
|
|
console.log('uptime sampler disabled in this worker')
|
|
}
|
|
console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
|
|
console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
|
|
console.log(`storing public data cache in ${PUBLIC_DATA_CACHE_DIR}`)
|
|
if (!TURNSTILE_SECRET_KEY) {
|
|
console.warn('TURNSTILE_SECRET_KEY is not set — Turnstile verification is disabled and gated endpoints will accept any request')
|
|
}
|
|
if (RUN_BACKGROUND_JOBS) {
|
|
startUptimeSampler()
|
|
startPublicDataPrewarmer()
|
|
}
|
|
process.send?.('ready')
|
|
})
|
|
|
|
let shuttingDown = false
|
|
|
|
function closeDatabase(db, name) {
|
|
if (!db) return
|
|
|
|
try {
|
|
db.close()
|
|
} catch (error) {
|
|
console.error(`Failed to close ${name} database:`, error)
|
|
}
|
|
}
|
|
|
|
function shutdown() {
|
|
if (shuttingDown) return
|
|
shuttingDown = true
|
|
|
|
if (uptimeSamplerTimer) clearInterval(uptimeSamplerTimer)
|
|
if (publicDataPrewarmTimer) clearInterval(publicDataPrewarmTimer)
|
|
|
|
server.close(() => {
|
|
closeDatabase(uptimeDb, 'uptime')
|
|
closeDatabase(analyticsDb, 'analytics')
|
|
process.exit(0)
|
|
})
|
|
|
|
setTimeout(() => {
|
|
console.error('Graceful shutdown timed out')
|
|
process.exit(1)
|
|
}, 10000).unref()
|
|
}
|
|
|
|
process.on('SIGINT', shutdown)
|
|
process.on('SIGTERM', shutdown)
|