import { Hono, type Context } from 'hono' import { cors } from 'hono/cors' import { logger } from 'hono/logger' import { secureHeaders } from 'hono/secure-headers' import { bodyLimit } from 'hono/body-limit' import { botsRouter } from './routes/bots.js' import { fightsRouter } from './routes/fights.js' import { queueRouter } from './routes/queue.js' import { authRouter } from './routes/auth.js' import { docsRouter } from './routes/docs.js' import { betsRouter } from './routes/bets.js' import { paymentsRouter } from './routes/payments.js' import { tournamentsRouter } from './routes/tournaments.js' import { adminRouter } from './routes/admin.js' import { statsRouter } from './routes/stats.js' import { rateLimit } from './middleware/rate-limit.js' import { existsSync, readFileSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { cleanupOrphanedFights } from './engine/orchestrator.js' import { recoverOrphanedPayments } from './engine/payments.js' import { startDailyBackups } from './engine/backup.js' import { startMemoryTracking } from './engine/analytics.js' export const app = new Hono() app.onError((err, c) => { console.error('[botfights] ERROR:', err.message, err.stack) const msg = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message return c.json({ error: msg }, 500) }) app.use('*', logger()) // CORS: lock down in production, allow all in dev const allowedOrigin = process.env.CORS_ORIGIN || '*' app.use('/api/*', cors({ origin: allowedOrigin })) // 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 })) // Rate limit all POST endpoints (60/min per IP) app.use('/api/*', rateLimit(60_000, 60)) // 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') }) app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' })) app.route('/api/auth', authRouter) app.route('/api/bots', botsRouter) app.route('/api/fights', fightsRouter) app.route('/api/queue', queueRouter) app.route('/api/docs', docsRouter) app.route('/api/bets', betsRouter) app.route('/api/payments', paymentsRouter) app.route('/api/tournaments', tournamentsRouter) app.route('/api/admin', adminRouter) app.route('/api/stats', statsRouter) // 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 = { 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', } function serveFile(c: Context, reqPath: string, cacheControl: string) { 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)) } // 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') }) // 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')) // 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) app.get('*', (c) => { if (c.req.path.startsWith('/api/')) return c.notFound() // 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() } 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) } // Cleanup orphaned fights on startup cleanupOrphanedFights().then(() => { console.log('[botfights] orphaned fights cleaned up') }).catch(err => { console.error('[botfights] cleanup error:', err) }) // Recover orphaned payments on startup recoverOrphanedPayments().catch(err => { console.error('[botfights] payment recovery error:', err) }) // Start daily database backups (production only) if (process.env.NODE_ENV === 'production') { startDailyBackups() } // Start memory tracking startMemoryTracking()