ai generated solutions to our ai generated problems

This commit is contained in:
Heidi
2026-06-20 00:20:48 +01:00
parent e7a172f52f
commit 7f1e6d0bef
5 changed files with 139 additions and 34 deletions
+8 -7
View File
@@ -86,12 +86,12 @@ Vehicle icon PNGs are served statically at `/vehicle-icons` from `VEHICLE_ICONS_
The proxy blocks cross-origin/API-navigation requests, strips CORS headers from The proxy blocks cross-origin/API-navigation requests, strips CORS headers from
the upstream response, rate limits callers, and caches successful GET responses. the upstream response, rate limits callers, and caches successful GET responses.
Public TSS reads are also written to a bounded JSON snapshot cache and served at Public TSS reads are written to a bounded JSON snapshot cache and served at both
both their `/api/tss/*` route and matching `/data/*` path. Fresh snapshots return their `/api/tss/*` route and matching `/data/*` path. The frontend uses `/data/*`
without touching the backend; stale snapshots are served immediately while the by default for public pages. Fresh snapshots return without touching the backend;
server refreshes them in the background. The frontend uses `/api/tss/*` by stale snapshots are served immediately while the server refreshes them in the
default so it can hit this server-side cache directly; set `VITE_STATIC_DATA=true` background. Missing `/data/*` snapshots are filled from the matching upstream API
only if you explicitly want the client to try `/data/*` first. All responses with a short timeout, then written atomically for future requests. All responses
ship `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, ship `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`,
`Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
HSTS (over HTTPS), and HTML responses include a Content Security Policy that HSTS (over HTTPS), and HTML responses include a Content Security Policy that
@@ -118,7 +118,8 @@ PUBLIC_DATA_CACHE_DIR=~/tsswebstorage/public-data
PUBLIC_DATA_CACHE_FRESH_MS=300000 PUBLIC_DATA_CACHE_FRESH_MS=300000
PUBLIC_DATA_CACHE_STALE_MS=86400000 PUBLIC_DATA_CACHE_STALE_MS=86400000
PUBLIC_DATA_PREWARM_INTERVAL_MS=300000 PUBLIC_DATA_PREWARM_INTERVAL_MS=300000
VITE_STATIC_DATA=false PUBLIC_DATA_COLD_TIMEOUT_MS=8000
VITE_STATIC_DATA=true
API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120 API_RATE_LIMIT_MAX=120
``` ```
+2 -1
View File
@@ -37,6 +37,7 @@ PUBLIC_DATA_CACHE_DIR=~/tsswebstorage/public-data
PUBLIC_DATA_CACHE_FRESH_MS=300000 PUBLIC_DATA_CACHE_FRESH_MS=300000
PUBLIC_DATA_CACHE_STALE_MS=86400000 PUBLIC_DATA_CACHE_STALE_MS=86400000
PUBLIC_DATA_PREWARM_INTERVAL_MS=300000 PUBLIC_DATA_PREWARM_INTERVAL_MS=300000
PUBLIC_DATA_COLD_TIMEOUT_MS=8000
API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=120 API_RATE_LIMIT_MAX=120
@@ -61,4 +62,4 @@ DISCORD_INCLUDE_PATCH=true
# TURNSTILE_SECRET_KEY is the server-only secret used to call the Siteverify endpoint. # TURNSTILE_SECRET_KEY is the server-only secret used to call the Siteverify endpoint.
VITE_TURNSTILE_SITE_KEY= VITE_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
VITE_STATIC_DATA=false VITE_STATIC_DATA=true
+3 -3
View File
@@ -1,8 +1,8 @@
# Static Public Data # Static Public Data
The frontend can try these JSON snapshots before falling back to the live API The frontend tries these JSON snapshots before falling back to the live API by
when `VITE_STATIC_DATA=true`. By default, the app requests `/api/tss/*` directly default. The web server serves `/data/*` from the public data cache and fills
and lets the web server answer from the same public data cache. missing snapshots from the matching `/api/tss/*` route with a short timeout.
- `/data/leaderboard-teams.json` - `/data/leaderboard-teams.json`
- `/data/leaderboard-players.json` - `/data/leaderboard-players.json`
+5 -2
View File
@@ -51,7 +51,7 @@ const siteVersion = '1.0.1'
const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' const turnstileSiteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '') const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '')
const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'false').toLowerCase() === 'true' const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'true').toLowerCase() !== 'false'
const missingStaticDataPaths = new Set() const missingStaticDataPaths = new Set()
const defaultAnalyticsPreferences = { const defaultAnalyticsPreferences = {
@@ -126,9 +126,12 @@ async function fetchPublicJson(source, signal) {
return await fetchJson(source.staticPath, signal) return await fetchJson(source.staticPath, signal)
} catch (error) { } catch (error) {
if (signal?.aborted || error?.name === 'AbortError') throw error if (signal?.aborted || error?.name === 'AbortError') throw error
if (error?.status === 404 || error?.contentType) missingStaticDataPaths.add(source.staticPath) if (error?.status === 404 || error?.contentType) {
missingStaticDataPaths.add(source.staticPath)
return fetchJson(source.apiPath, signal) return fetchJson(source.apiPath, signal)
} }
throw error
}
} }
function parseRoute(pathname = window.location.pathname) { function parseRoute(pathname = window.location.pathname) {
+118 -18
View File
@@ -54,6 +54,7 @@ const PUBLIC_DATA_CACHE_DIR = path.resolve(
const PUBLIC_DATA_CACHE_FRESH_MS = Number(process.env.PUBLIC_DATA_CACHE_FRESH_MS || 5 * 60 * 1000) const PUBLIC_DATA_CACHE_FRESH_MS = Number(process.env.PUBLIC_DATA_CACHE_FRESH_MS || 5 * 60 * 1000)
const PUBLIC_DATA_CACHE_STALE_MS = Number(process.env.PUBLIC_DATA_CACHE_STALE_MS || 24 * 60 * 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 PUBLIC_DATA_PREWARM_INTERVAL_MS = Number(process.env.PUBLIC_DATA_PREWARM_INTERVAL_MS || PUBLIC_DATA_CACHE_FRESH_MS)
const PUBLIC_DATA_COLD_TIMEOUT_MS = Number(process.env.PUBLIC_DATA_COLD_TIMEOUT_MS || 8000)
const PUBLIC_DATA_CACHE_MAX_AGE_SECONDS = Math.max(0, Math.floor(PUBLIC_DATA_CACHE_FRESH_MS / 1000)) const PUBLIC_DATA_CACHE_MAX_AGE_SECONDS = Math.max(0, Math.floor(PUBLIC_DATA_CACHE_FRESH_MS / 1000))
const PUBLIC_DATA_STALE_REVALIDATE_SECONDS = Math.max(0, Math.floor(PUBLIC_DATA_CACHE_STALE_MS / 1000)) const PUBLIC_DATA_STALE_REVALIDATE_SECONDS = Math.max(0, Math.floor(PUBLIC_DATA_CACHE_STALE_MS / 1000))
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000) const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
@@ -220,6 +221,14 @@ function publicDataKey(value) {
return encodeURIComponent(String(value || '').trim()).replace(/%/g, '~') return encodeURIComponent(String(value || '').trim()).replace(/%/g, '~')
} }
function publicDataValue(value) {
try {
return decodeURIComponent(String(value || '').replace(/~/g, '%'))
} catch {
return ''
}
}
function publicDataCachePathForUrl(requestUrl) { function publicDataCachePathForUrl(requestUrl) {
const pathname = requestUrl.pathname const pathname = requestUrl.pathname
const params = requestUrl.searchParams const params = requestUrl.searchParams
@@ -269,6 +278,45 @@ function publicDataCachePathForUrl(requestUrl) {
return null return null
} }
function publicDataApiPathForRelative(relativePath) {
if (relativePath === 'leaderboard-teams.json') return '/api/tss/leaderboard/teams?limit=100'
if (relativePath === 'leaderboard-players.json') return '/api/tss/leaderboard/players?limit=100'
if (relativePath === 'home-teams.json') return '/api/tss/leaderboard/teams?limit=4'
if (relativePath === 'recent-games.json') return '/api/tss/games/recent?limit=50'
let match = relativePath.match(/^teams\/(.+)\.games\.json$/)
if (match) {
const team = publicDataValue(match[1])
return team ? `/api/tss/teams/${encodeURIComponent(team)}/games` : ''
}
match = relativePath.match(/^teams\/(.+)\.json$/)
if (match) {
const team = publicDataValue(match[1])
return team ? `/api/tss/teams/${encodeURIComponent(team)}` : ''
}
match = relativePath.match(/^players\/(.+)\.json$/)
if (match) {
const uid = publicDataValue(match[1])
return /^[0-9]{1,32}$/.test(uid) ? `/api/tss/player/${encodeURIComponent(uid)}` : ''
}
match = relativePath.match(/^games\/(.+)\.logs\.json$/)
if (match) {
const gameId = publicDataValue(match[1])
return /^[A-Za-z0-9_-]{1,96}$/.test(gameId) ? `/api/tss/games/${encodeURIComponent(gameId)}/logs` : ''
}
match = relativePath.match(/^games\/(.+)\.json$/)
if (match) {
const gameId = publicDataValue(match[1])
return /^[A-Za-z0-9_-]{1,96}$/.test(gameId) ? `/api/tss/games/${encodeURIComponent(gameId)}` : ''
}
return ''
}
function safePublicDataPath(requestPath) { function safePublicDataPath(requestPath) {
let decoded = '/' let decoded = '/'
try { try {
@@ -286,6 +334,14 @@ function safePublicDataPath(requestPath) {
return filePath return filePath
} }
function publicDataRelativePath(requestPath) {
try {
return decodeURIComponent(requestPath).replace(/^\/data\/?/, '')
} catch {
return ''
}
}
function sendPublicDataFile(req, res, filePath, status = 200, extraHeaders = {}) { function sendPublicDataFile(req, res, filePath, status = 200, extraHeaders = {}) {
fs.readFile(filePath, (error, data) => { fs.readFile(filePath, (error, data) => {
if (error) { if (error) {
@@ -301,6 +357,40 @@ function sendPublicDataFile(req, res, filePath, status = 200, extraHeaders = {})
}) })
} }
async function servePublicData(req, res, pathname) {
const filePath = safePublicDataPath(pathname)
const relativePath = publicDataRelativePath(pathname)
if (!filePath || !relativePath) {
sendJson(res, 400, { error: 'Bad request' })
return
}
const current = cachedPublicData(filePath)
if (current?.fresh) {
sendPublicDataFile(req, res, filePath, 200, { 'x-tssbot-cache': 'data-hit' })
return
}
const apiPath = publicDataApiPathForRelative(relativePath)
if (!apiPath) {
sendJson(res, 404, { error: 'Snapshot not found' })
return
}
if (current) {
refreshPublicData(filePath, new URL(apiPath, API_UPSTREAM))
sendPublicDataFile(req, res, filePath, 200, { 'x-tssbot-cache': 'data-stale' })
return
}
try {
await fillPublicData(filePath, new URL(apiPath, API_UPSTREAM), PUBLIC_DATA_COLD_TIMEOUT_MS)
sendPublicDataFile(req, res, filePath, 200, { 'x-tssbot-cache': 'data-filled' })
} catch (error) {
sendJson(res, 504, { error: 'Snapshot unavailable', detail: error.message })
}
}
function cachedPublicData(filePath) { function cachedPublicData(filePath) {
if (!filePath) return null if (!filePath) return null
@@ -322,16 +412,24 @@ function writePublicDataFile(filePath, body) {
fs.renameSync(tmpPath, filePath) fs.renameSync(tmpPath, filePath)
} }
function refreshPublicData(filePath, target) { async function fillPublicData(filePath, target, timeoutMs = SERVER_REQUEST_TIMEOUT_MS) {
if (!filePath || publicDataRefreshes.has(filePath)) return if (!filePath) return false
if (publicDataRefreshes.has(filePath)) return false
publicDataRefreshes.add(filePath) publicDataRefreshes.add(filePath)
fetchUpstreamJsonBuffer(target) try {
.then((body) => writePublicDataFile(filePath, body)) const body = await fetchUpstreamJsonBuffer(target, timeoutMs)
.catch((error) => { writePublicDataFile(filePath, body)
return true
} finally {
publicDataRefreshes.delete(filePath)
}
}
function refreshPublicData(filePath, target) {
fillPublicData(filePath, target).catch((error) => {
console.warn(`public data refresh failed for ${target.pathname}: ${error.message}`) console.warn(`public data refresh failed for ${target.pathname}: ${error.message}`)
}) })
.finally(() => publicDataRefreshes.delete(filePath))
} }
function publicDataPrewarmTargets() { function publicDataPrewarmTargets() {
@@ -351,21 +449,26 @@ function publicDataPrewarmTargets() {
} }
function prewarmPublicDataCache() { function prewarmPublicDataCache() {
const jobs = []
for (const { filePath, target } of publicDataPrewarmTargets()) { for (const { filePath, target } of publicDataPrewarmTargets()) {
const current = cachedPublicData(filePath) const current = cachedPublicData(filePath)
if (current?.fresh) continue if (current?.fresh) continue
refreshPublicData(filePath, target) jobs.push(fillPublicData(filePath, target, PUBLIC_DATA_COLD_TIMEOUT_MS))
} }
return Promise.allSettled(jobs)
} }
function startPublicDataPrewarmer() { function startPublicDataPrewarmer() {
fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true }) fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })
prewarmPublicDataCache() publicDataPrewarmTimer = setInterval(() => {
publicDataPrewarmTimer = setInterval(prewarmPublicDataCache, PUBLIC_DATA_PREWARM_INTERVAL_MS) prewarmPublicDataCache().catch((error) => {
console.warn(`public data prewarm failed: ${error.message}`)
})
}, PUBLIC_DATA_PREWARM_INTERVAL_MS)
publicDataPrewarmTimer.unref?.() publicDataPrewarmTimer.unref?.()
} }
function fetchUpstreamJsonBuffer(target) { function fetchUpstreamJsonBuffer(target, timeoutMs = SERVER_REQUEST_TIMEOUT_MS) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = target.protocol === 'https:' ? https : http const client = target.protocol === 'https:' ? https : http
const chunks = [] const chunks = []
@@ -401,7 +504,7 @@ function fetchUpstreamJsonBuffer(target) {
}, },
) )
proxy.setTimeout(SERVER_REQUEST_TIMEOUT_MS, () => proxy.destroy(new Error('Upstream request timed out'))) proxy.setTimeout(timeoutMs, () => proxy.destroy(new Error(`Upstream request timed out after ${timeoutMs}ms`)))
proxy.on('error', reject) proxy.on('error', reject)
proxy.end() proxy.end()
}) })
@@ -2687,12 +2790,7 @@ const server = http.createServer((req, res) => {
} }
if (pathname.startsWith('/data/')) { if (pathname.startsWith('/data/')) {
const filePath = safePublicDataPath(pathname) servePublicData(req, res, pathname)
if (!filePath) {
sendJson(res, 400, { error: 'Bad request' })
return
}
sendPublicDataFile(req, res, filePath)
return return
} }
} }
@@ -2713,7 +2811,7 @@ const server = http.createServer((req, res) => {
server.requestTimeout = SERVER_REQUEST_TIMEOUT_MS server.requestTimeout = SERVER_REQUEST_TIMEOUT_MS
server.headersTimeout = SERVER_HEADERS_TIMEOUT_MS server.headersTimeout = SERVER_HEADERS_TIMEOUT_MS
server.listen(PORT, '0.0.0.0', () => { server.listen(PORT, '0.0.0.0', async () => {
console.log(`tssbot-web serving http://localhost:${PORT}`) console.log(`tssbot-web serving http://localhost:${PORT}`)
console.log(`proxying API requests to ${API_UPSTREAM}`) console.log(`proxying API requests to ${API_UPSTREAM}`)
if (RUN_BACKGROUND_JOBS) { if (RUN_BACKGROUND_JOBS) {
@@ -2729,6 +2827,8 @@ server.listen(PORT, '0.0.0.0', () => {
} }
if (RUN_BACKGROUND_JOBS) { if (RUN_BACKGROUND_JOBS) {
startUptimeSampler() startUptimeSampler()
fs.mkdirSync(PUBLIC_DATA_CACHE_DIR, { recursive: true })
await prewarmPublicDataCache()
startPublicDataPrewarmer() startPublicDataPrewarmer()
} }
process.send?.('ready') process.send?.('ready')