Files
TSSBOT-web/server.cjs
T
2026-05-16 07:42:53 +01:00

1075 lines
34 KiB
JavaScript

const fs = require('node:fs')
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 API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
const DIST_DIR = path.join(__dirname, 'dist')
const MAX_TEAM_NAME_LENGTH = 80
const MAX_CACHE_ENTRIES = 200
const MAX_RATE_LIMIT_KEYS = 1000
const MAX_ANALYTICS_BODY_BYTES = 16 * 1024
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 = {}) {
res.writeHead(status, headers)
res.end(body)
}
const jsonHeaders = {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
}
const apiCache = new Map()
const rateLimits = new Map()
let uptimeDb = null
let analyticsDb = null
let latestUptimeSnapshot = null
function sendJson(res, status, body, headers = {}) {
send(res, status, JSON.stringify(body), { ...jsonHeaders, ...headers })
}
function requestJson(url, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const target = new URL(url)
const client = target.protocol === 'https:' ? https : http
const startedAt = Date.now()
const req = client.request(
target,
{
method: 'GET',
headers: {
accept: 'application/json',
'user-agent': 'tssbot-uptime-sampler',
},
timeout: timeoutMs,
},
(response) => {
const chunks = []
response.on('data', (chunk) => chunks.push(chunk))
response.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8')
const latency = Date.now() - startedAt
if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) {
reject(new Error(`HTTP ${response.statusCode || 0}`))
return
}
try {
resolve({ body: body ? JSON.parse(body) : null, latency })
} catch {
resolve({ body: null, latency })
}
})
},
)
req.on('timeout', () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`))
})
req.on('error', reject)
req.end()
})
}
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.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 '',
language text not null default '',
timezone text not null default '',
country text not null default '',
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 '',
language text not null default '',
timezone text not null default '',
country text not null default ''
);
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 ''`,
]) {
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.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,
}
}
function startUptimeSampler() {
takeUptimeSnapshot().catch((error) => {
console.error('Initial uptime snapshot failed:', error)
})
setInterval(() => {
takeUptimeSnapshot().catch((error) => {
console.error('Uptime snapshot failed:', error)
})
}, UPTIME_SAMPLE_INTERVAL_MS).unref()
}
function publicOrigins(req) {
const origins = PUBLIC_ORIGIN.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
if (!origins.length) {
const host = req.headers['x-forwarded-host'] || req.headers.host
const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http')
if (host) {
origins.push(`${proto}://${String(host).split(',')[0].trim()}`)
}
}
return origins
}
function isSameOriginRequest(req) {
const origins = publicOrigins(req)
const origin = req.headers.origin
const referer = req.headers.referer
const fetchSite = req.headers['sec-fetch-site']
const fetchDest = req.headers['sec-fetch-dest']
if (fetchDest === 'document') return false
if (origin && !origins.includes(origin)) return false
if (referer) {
try {
if (!origins.includes(new URL(referer).origin)) return false
} catch {
return false
}
}
if (fetchSite) {
return fetchSite === 'same-origin'
}
return Boolean(origin || referer)
}
function clientIp(req) {
const forwardedFor = req.headers['x-forwarded-for']
if (forwardedFor) return String(forwardedFor).split(',')[0].trim()
return req.socket.remoteAddress || 'unknown'
}
function 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) {
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 analyticsMetadata(req, payload) {
const metadata = payload.metadata && typeof payload.metadata === 'object' ? 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)
})
}
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.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 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),
language: sanitizeText(payload.language, 40),
timezone: sanitizeText(payload.timezone, 80),
country: countryFromHeaders(req),
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()
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, language, timezone, country, consent, metadata)
values
(@occurred_at, @visitor_id, @session_id, @ip_hash, @event_type, @page_path, @page_title,
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @country, @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, language, timezone, country)
values
(@session_id, @visitor_id, @ip_hash, @now, @now, @page_path, @page_title,
@referrer, @user_agent, @browser, @os, @device, @screen, @language, @timezone, @country)
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,
language = excluded.language,
timezone = excluded.timezone,
country = excluded.country
`).run({ ...event, now })
}
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 session_id, visitor_id, first_seen_at, last_seen_at, page_path, page_title,
referrer, browser, os, device, screen, language, timezone, country
from active_viewers
where last_seen_at >= ?
order by last_seen_at desc
limit 100
`).all(activeSince).map((row) => ({
session: row.session_id.slice(0, 8),
visitor: row.visitor_id.slice(0, 8),
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,
language: row.language,
timezone: row.timezone,
country: row.country,
}))
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(),
last_seen_at: viewer.last_seen_at,
}
existing.viewers += 1
existing.visitors.add(viewer.visitor)
if (viewer.country) existing.countries.add(viewer.country)
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(),
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 clients = db.prepare(`
select browser, os, device, count(*) as events
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 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 countries = db.prepare(`
select country, count(*) as events, count(distinct visitor_id) as visitors
from viewer_events
where occurred_at >= ?
and country != ''
group by country
order by visitors desc, events desc
limit 80
`).all(thirtyDaysSince)
const locations = db.prepare(`
select country, timezone, language, count(*) as events, count(distinct visitor_id) as visitors
from viewer_events
where occurred_at >= ?
and (country != '' or (timezone != '' and timezone != 'Not shared'))
group by country, timezone, language
order by visitors desc, events desc
limit 32
`).all(thirtyDaysSince)
const totals = db.prepare(`
select
count(*) as events_24h,
count(distinct visitor_id) as visitors_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,
clients,
clients_30d: clients30d,
activity_30d: activity30d,
countries,
locations,
totals: {
active_now: active.length,
events_24h: totals?.events_24h || 0,
visitors_24h: totals?.visitors_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 country code when supplied by the hosting 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/teams/resolve') {
const keys = [...params.keys()]
const name = params.get('name') || ''
if (keys.some((key) => key !== 'name') || name.length < 2 || name.length > MAX_TEAM_NAME_LENGTH) {
return null
}
return url
}
const teamMatch = pathname.match(/^\/api\/tss\/teams\/([^/]+)(?:\/(history|games))?$/)
if (!teamMatch || [...params.keys()].length) return null
try {
const teamName = decodeURIComponent(teamMatch[1])
if (!teamName || teamName.length > MAX_TEAM_NAME_LENGTH) return null
} catch {
return null
}
return url
}
function proxyRequest(req, res) {
pruneMaps()
if (!isSameOriginRequest(req)) {
return sendJson(res, 403, { error: 'API access is restricted to this site' })
}
if (isRateLimited(req)) {
return sendJson(res, 429, { error: 'Too many API requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
}
const target = allowedApiTarget(req)
if (!target) {
return sendJson(res, 404, { error: 'API route not found' })
}
const cacheKey = req.method === 'GET' ? target.toString() : ''
const cached = cacheKey ? apiCache.get(cacheKey) : null
if (cached && cached.expiresAt > Date.now()) {
return send(res, 200, cached.body, cached.headers)
}
const responseChunks = []
const proxy = http.request(
target,
{
method: req.method,
headers: {
accept: 'application/json',
host: target.host,
'user-agent': req.headers['user-agent'] || 'tssbot-web',
},
},
(proxyRes) => {
const headers = {
...proxyRes.headers,
'cache-control': 'private, max-age=15',
'x-content-type-options': 'nosniff',
}
delete headers['access-control-allow-origin']
delete headers['access-control-allow-credentials']
res.writeHead(proxyRes.statusCode || 502, headers)
proxyRes.on('data', (chunk) => {
if (cacheKey && (proxyRes.statusCode || 0) >= 200 && (proxyRes.statusCode || 0) < 300) {
responseChunks.push(chunk)
}
})
proxyRes.on('end', () => {
if (cacheKey && responseChunks.length) {
apiCache.set(cacheKey, {
body: Buffer.concat(responseChunks),
headers,
expiresAt: Date.now() + API_CACHE_TTL_MS,
})
}
})
proxyRes.pipe(res)
},
)
proxy.on('error', (error) => {
sendJson(res, 502, { error: 'API proxy failed', detail: error.message })
})
req.pipe(proxy)
}
function pagePublicOrigin(req) {
const configured = PUBLIC_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)[0]
if (configured) return configured.replace(/\/$/, '')
const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${PORT}`
const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http')
return `${String(proto).split(',')[0].trim()}://${String(host).split(',')[0].trim()}`.replace(/\/$/, '')
}
function sendHtml(req, res, data, status = 200) {
const html = data.toString('utf8').replaceAll('__PUBLIC_ORIGIN__', pagePublicOrigin(req))
send(res, status, html, {
'content-type': mimeTypes['.html'],
'cache-control': 'no-cache',
})
}
function serveStatic(req, res) {
const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
const relativePath = requestPath === '/' ? '/index.html' : requestPath
const filePath = path.normalize(path.join(DIST_DIR, relativePath))
if (!filePath.startsWith(DIST_DIR)) {
return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' })
}
fs.readFile(filePath, (error, data) => {
if (error) {
fs.readFile(path.join(DIST_DIR, 'index.html'), (indexError, indexData) => {
if (indexError) {
return send(res, 404, 'Build not found. Run npm run build first.', {
'content-type': 'text/plain; charset=utf-8',
})
}
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',
})
})
}
const server = http.createServer((req, res) => {
if (req.url === '/health') {
sendJson(res, 200, { ok: true })
return
}
if (req.method === 'GET' && req.url === '/api/uptime') {
uptimeHistory()
.then((data) => sendJson(res, 200, data))
.catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
return
}
if (req.method === 'GET' && req.url === '/api/viewers') {
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
}
readJsonBody(req)
.then((payload) => {
const result = deleteViewerData(payload)
sendJson(res, 200, result)
})
.catch((error) => sendJson(res, 400, { error: error.message }))
return
}
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
sendJson(res, 403, { error: 'CORS requests are not allowed' })
return
}
if (req.url.startsWith('/api/')) {
proxyRequest(req, res)
return
}
serveStatic(req, res)
})
server.listen(PORT, '0.0.0.0', () => {
console.log(`tssbot-web serving http://localhost:${PORT}`)
console.log(`proxying API requests to ${API_UPSTREAM}`)
console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
startUptimeSampler()
})