2026-03-08 20:34:11 +00:00
|
|
|
import { Hono, type Context } from 'hono'
|
2026-03-06 16:27:54 +00:00
|
|
|
import { cors } from 'hono/cors'
|
|
|
|
|
import { logger } from 'hono/logger'
|
2026-03-08 16:23:47 +00:00
|
|
|
import { secureHeaders } from 'hono/secure-headers'
|
|
|
|
|
import { bodyLimit } from 'hono/body-limit'
|
2026-03-06 16:27:54 +00:00
|
|
|
import { botsRouter } from './routes/bots.js'
|
|
|
|
|
import { fightsRouter } from './routes/fights.js'
|
2026-03-06 22:13:19 +00:00
|
|
|
import { queueRouter } from './routes/queue.js'
|
|
|
|
|
import { authRouter } from './routes/auth.js'
|
2026-03-07 00:14:46 +00:00
|
|
|
import { docsRouter } from './routes/docs.js'
|
2026-03-08 00:33:08 +00:00
|
|
|
import { betsRouter } from './routes/bets.js'
|
2026-03-08 01:26:11 +00:00
|
|
|
import { paymentsRouter } from './routes/payments.js'
|
2026-03-08 20:58:23 +00:00
|
|
|
import { tournamentsRouter } from './routes/tournaments.js'
|
2026-03-09 00:12:36 +00:00
|
|
|
import { adminRouter } from './routes/admin.js'
|
2026-03-09 00:15:20 +00:00
|
|
|
import { statsRouter } from './routes/stats.js'
|
2026-03-07 00:14:46 +00:00
|
|
|
import { rateLimit } from './middleware/rate-limit.js'
|
2026-03-07 10:42:18 +00:00
|
|
|
|
|
|
|
|
import { existsSync, readFileSync } from 'fs'
|
|
|
|
|
import { join, dirname } from 'path'
|
|
|
|
|
import { fileURLToPath } from 'url'
|
2026-03-07 00:14:46 +00:00
|
|
|
import { cleanupOrphanedFights } from './engine/orchestrator.js'
|
2026-03-08 01:26:11 +00:00
|
|
|
import { recoverOrphanedPayments } from './engine/payments.js'
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
export const app = new Hono()
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
app.onError((err, c) => {
|
|
|
|
|
console.error('[botfights] ERROR:', err.message, err.stack)
|
2026-03-08 16:23:47 +00:00
|
|
|
const msg = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message
|
|
|
|
|
return c.json({ error: msg }, 500)
|
2026-03-06 22:13:19 +00:00
|
|
|
})
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
app.use('*', logger())
|
2026-03-07 11:04:32 +00:00
|
|
|
// CORS: lock down in production, allow all in dev
|
|
|
|
|
const allowedOrigin = process.env.CORS_ORIGIN || '*'
|
|
|
|
|
app.use('/api/*', cors({ origin: allowedOrigin }))
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-08 16:23:47 +00:00
|
|
|
// Security headers: X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, etc.
|
|
|
|
|
app.use('*', secureHeaders({
|
|
|
|
|
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
|
|
|
|
defaultSrc: ["'self'"],
|
|
|
|
|
scriptSrc: ["'self'"],
|
|
|
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
|
|
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
|
|
|
connectSrc: ["'self'"],
|
|
|
|
|
fontSrc: ["'self'"],
|
|
|
|
|
} : undefined,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// Body size limit: 256KB max for API requests (prevents OOM)
|
|
|
|
|
app.use('/api/*', bodyLimit({ maxSize: 256 * 1024 }))
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Rate limit all POST endpoints (60/min per IP)
|
|
|
|
|
app.use('/api/*', rateLimit(60_000, 60))
|
|
|
|
|
|
2026-03-09 00:24:48 +00:00
|
|
|
// API cache headers
|
|
|
|
|
app.use('/api/*', async (c, next) => {
|
|
|
|
|
await next()
|
|
|
|
|
// Default: no-store for dynamic data
|
|
|
|
|
if (!c.res.headers.has('Cache-Control')) {
|
|
|
|
|
c.header('Cache-Control', 'no-store')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
// Leaderboard and public stats can be cached briefly
|
|
|
|
|
app.use('/api/bots/leaderboard', async (c, next) => {
|
|
|
|
|
await next()
|
|
|
|
|
c.header('Cache-Control', 'public, max-age=60')
|
|
|
|
|
})
|
|
|
|
|
app.use('/api/stats/public', async (c, next) => {
|
|
|
|
|
await next()
|
|
|
|
|
c.header('Cache-Control', 'public, max-age=300')
|
|
|
|
|
})
|
|
|
|
|
app.use('/api/docs/*', async (c, next) => {
|
|
|
|
|
await next()
|
|
|
|
|
if (c.req.method === 'GET') c.header('Cache-Control', 'public, max-age=3600')
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' }))
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
app.route('/api/auth', authRouter)
|
2026-03-06 16:27:54 +00:00
|
|
|
app.route('/api/bots', botsRouter)
|
|
|
|
|
app.route('/api/fights', fightsRouter)
|
2026-03-06 22:13:19 +00:00
|
|
|
app.route('/api/queue', queueRouter)
|
2026-03-07 00:14:46 +00:00
|
|
|
app.route('/api/docs', docsRouter)
|
2026-03-08 00:33:08 +00:00
|
|
|
app.route('/api/bets', betsRouter)
|
2026-03-08 01:26:11 +00:00
|
|
|
app.route('/api/payments', paymentsRouter)
|
2026-03-08 20:58:23 +00:00
|
|
|
app.route('/api/tournaments', tournamentsRouter)
|
2026-03-09 00:12:36 +00:00
|
|
|
app.route('/api/admin', adminRouter)
|
2026-03-09 00:15:20 +00:00
|
|
|
app.route('/api/stats', statsRouter)
|
2026-03-07 00:14:46 +00:00
|
|
|
|
2026-03-07 10:42:18 +00:00
|
|
|
// In production, serve the frontend SPA
|
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
|
|
const publicDir = join(__dirname, '..', 'public')
|
|
|
|
|
|
|
|
|
|
if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
|
|
|
|
|
const MIME: Record<string, string> = {
|
|
|
|
|
js: 'application/javascript',
|
|
|
|
|
css: 'text/css',
|
|
|
|
|
html: 'text/html',
|
|
|
|
|
json: 'application/json',
|
|
|
|
|
png: 'image/png',
|
|
|
|
|
jpg: 'image/jpeg',
|
|
|
|
|
svg: 'image/svg+xml',
|
|
|
|
|
ico: 'image/x-icon',
|
|
|
|
|
woff: 'font/woff',
|
|
|
|
|
woff2: 'font/woff2',
|
|
|
|
|
webp: 'image/webp',
|
|
|
|
|
webmanifest: 'application/manifest+json',
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 20:34:11 +00:00
|
|
|
function serveFile(c: Context, reqPath: string, cacheControl: string) {
|
2026-03-07 10:42:18 +00:00
|
|
|
const resolved = join(publicDir, reqPath)
|
|
|
|
|
// Prevent path traversal
|
|
|
|
|
if (!resolved.startsWith(publicDir)) return c.notFound()
|
|
|
|
|
if (!existsSync(resolved)) return c.notFound()
|
|
|
|
|
const ext = resolved.split('.').pop() || ''
|
|
|
|
|
c.header('Content-Type', MIME[ext] || 'application/octet-stream')
|
|
|
|
|
c.header('Cache-Control', cacheControl)
|
|
|
|
|
return c.body(readFileSync(resolved))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 12:08:18 +00:00
|
|
|
// Hashed assets — immutable cache. If missing (stale deploy), return JS that triggers reload.
|
|
|
|
|
app.get('/assets/*', (c) => {
|
|
|
|
|
const resolved = join(publicDir, c.req.path)
|
|
|
|
|
if (!resolved.startsWith(publicDir) || !existsSync(resolved)) {
|
|
|
|
|
// Stale chunk hash from old deploy — tell the browser to reload
|
|
|
|
|
c.header('Content-Type', 'application/javascript')
|
|
|
|
|
c.header('Cache-Control', 'no-cache')
|
|
|
|
|
return c.body('window.location.reload();')
|
|
|
|
|
}
|
|
|
|
|
return serveFile(c, c.req.path, 'public, max-age=31536000, immutable')
|
|
|
|
|
})
|
2026-03-07 10:42:18 +00:00
|
|
|
|
|
|
|
|
// Root static files (favicon, manifest, robots, etc.)
|
|
|
|
|
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
|
|
|
|
|
app.get('/robots.txt', (c) => serveFile(c, '/robots.txt', 'public, max-age=86400'))
|
|
|
|
|
app.get('/manifest.webmanifest', (c) => serveFile(c, '/manifest.webmanifest', 'public, max-age=86400'))
|
|
|
|
|
|
2026-03-07 23:08:25 +00:00
|
|
|
// PWA service worker + related root files
|
|
|
|
|
app.get('/sw.js', (c) => serveFile(c, '/sw.js', 'no-cache'))
|
|
|
|
|
app.get('/workbox-*.js', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable'))
|
|
|
|
|
app.get('/icon-*.png', (c) => serveFile(c, c.req.path, 'public, max-age=86400'))
|
|
|
|
|
app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400'))
|
|
|
|
|
app.get('/apple-touch-icon.png', (c) => serveFile(c, '/apple-touch-icon.png', 'public, max-age=86400'))
|
|
|
|
|
|
|
|
|
|
// SPA fallback: only for navigation requests (not JS/CSS/asset files)
|
2026-03-07 10:42:18 +00:00
|
|
|
app.get('*', (c) => {
|
|
|
|
|
if (c.req.path.startsWith('/api/')) return c.notFound()
|
2026-03-07 23:08:25 +00:00
|
|
|
// Don't serve index.html for asset requests — return 404 so the browser gets a proper error
|
|
|
|
|
const ext = c.req.path.split('.').pop()
|
|
|
|
|
if (ext && ext !== c.req.path && ['js', 'css', 'map', 'json', 'png', 'jpg', 'svg', 'woff', 'woff2', 'webp', 'ico'].includes(ext)) {
|
|
|
|
|
return c.notFound()
|
|
|
|
|
}
|
2026-03-07 10:42:18 +00:00
|
|
|
const indexPath = join(publicDir, 'index.html')
|
|
|
|
|
c.header('Content-Type', 'text/html')
|
|
|
|
|
c.header('Cache-Control', 'no-cache')
|
|
|
|
|
return c.body(readFileSync(indexPath))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
console.log('[botfights] serving frontend from', publicDir)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Cleanup orphaned fights on startup
|
|
|
|
|
cleanupOrphanedFights().then(() => {
|
|
|
|
|
console.log('[botfights] orphaned fights cleaned up')
|
|
|
|
|
}).catch(err => {
|
|
|
|
|
console.error('[botfights] cleanup error:', err)
|
|
|
|
|
})
|
2026-03-08 01:26:11 +00:00
|
|
|
|
|
|
|
|
// Recover orphaned payments on startup
|
|
|
|
|
recoverOrphanedPayments().catch(err => {
|
|
|
|
|
console.error('[botfights] payment recovery error:', err)
|
|
|
|
|
})
|