CI / check (push) Failing after 6m10s
1. server/src/app.ts: CSP img-src only allowed 'self'/data:/blob: — nostr profile pictures come from kind:0 metadata, a URL the user sets via their own client, hosted on whatever domain they picked. There's no central image host for a decentralized identity system, so every external profile pic was CSP-blocked and rendered as a broken image. Added https: (broad) — safe here since images can't execute script even from an untrusted origin, unlike script-src which stays locked to 'self'. 2. frontend/src/pages/JoinBoutPage.vue: the new AI-answer option (1.2.7) was reported as invisible — it was gated behind picking POLLING (not the default WEBHOOK) AND behind a collapsed toggle within that. Changed: POLLING is now the default mode (also the documented default in BOTFIGHTS.md), the AI section is expanded by default instead of collapsed, and the POLLING button's own description now mentions the option so it's visible without any extra click. 3. docker-compose.arena.yml: image tag 1.2.1 -> 1.2.7, matching what's actually deployed on the canonical arena (rolled live via ssh+docker compose pull/up this session — this commit just brings the repo's copy of the compose file back in sync with reality). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
235 lines
9.7 KiB
TypeScript
235 lines
9.7 KiB
TypeScript
import { Hono, type Context } from 'hono'
|
|
import { cors } from 'hono/cors'
|
|
import { logger } from 'hono/logger'
|
|
import { logger as appLogger } from './lib/logger.js'
|
|
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 { arcadeRouter } from './routes/arcade.js'
|
|
import { rateLimit } from './middleware/rate-limit.js'
|
|
import { arenaProxy } from './middleware/arena-proxy.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) => {
|
|
appLogger.error('app', `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
|
|
// Supports comma-separated origins: CORS_ORIGIN=https://a.com,https://b.com
|
|
const corsEnv = process.env.CORS_ORIGIN || '*'
|
|
const allowedOrigins = corsEnv === '*' ? '*' : corsEnv.split(',').map(s => s.trim())
|
|
app.use('/api/*', cors({
|
|
origin: Array.isArray(allowedOrigins)
|
|
? (origin) => allowedOrigins.includes(origin) ? origin : allowedOrigins[0]
|
|
: allowedOrigins,
|
|
}))
|
|
|
|
// COOP/COEP headers: required for SharedArrayBuffer (Kokoro TTS WASM threading)
|
|
app.use('*', async (c, next) => {
|
|
await next()
|
|
c.header('Cross-Origin-Opener-Policy', 'same-origin')
|
|
c.header('Cross-Origin-Embedder-Policy', 'credentialless')
|
|
})
|
|
|
|
// Security headers: X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, etc.
|
|
// ARCHY_EMBEDDED=1 means this instance is running as an app inside the
|
|
// Archipelago node dashboard's iframe (a first-party, trusted embedding
|
|
// context on the same host, different port — never a third-party site).
|
|
// X-Frame-Options: SAMEORIGIN (the secureHeaders default) blocks that framing
|
|
// outright, since the dashboard and this app are different origins by port.
|
|
// Standalone/public-arena instances (ARCHY_EMBEDDED unset) keep the default
|
|
// clickjacking protection.
|
|
const isEmbedded = process.env.ARCHY_EMBEDDED === '1'
|
|
app.use('*', secureHeaders({
|
|
xFrameOptions: isEmbedded ? false : true,
|
|
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", 'blob:', "'wasm-unsafe-eval'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
|
// https: (broad) is required, not optional: profile pictures come from
|
|
// nostr kind:0 metadata events — a URL the USER sets via their own
|
|
// client, hosted on whatever domain they picked. There is no central
|
|
// image host to allowlist for a decentralized identity system. Images
|
|
// can't execute script even from an untrusted origin, so this is the
|
|
// standard, safe CSP relaxation for user-supplied avatar URLs (unlike
|
|
// broadening script-src, which stays locked to 'self').
|
|
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
|
connectSrc: ["'self'", 'https://huggingface.co', 'https://*.huggingface.co', 'https://*.hf.co', 'https://cdn.jsdelivr.net', 'wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'],
|
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
|
workerSrc: ["'self'", 'blob:'],
|
|
} : undefined,
|
|
}))
|
|
|
|
// Body size limit: 256KB max for API requests (prevents OOM)
|
|
app.use('/api/*', bodyLimit({ maxSize: 256 * 1024 }))
|
|
|
|
// Global rate limit (120/min per IP — generous for polling + signup flows)
|
|
app.use('/api/*', rateLimit(60_000, 300))
|
|
|
|
// 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')
|
|
})
|
|
|
|
// When ARENA_UPSTREAM_URL is set, forward every /api/* request to the
|
|
// canonical arena instead of the local routers (BOT-03). No-ops otherwise.
|
|
app.use('/api/*', arenaProxy)
|
|
|
|
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)
|
|
app.route('/api/arcade', arcadeRouter)
|
|
|
|
// 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',
|
|
wav: 'audio/wav',
|
|
mp3: 'audio/mpeg',
|
|
ogg: 'audio/ogg',
|
|
md: 'text/markdown',
|
|
}
|
|
|
|
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('/registerSW.js', (c) => serveFile(c, '/registerSW.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'))
|
|
// Archipelago native NIP-07 signer bridge (see index.html <script> tag) —
|
|
// no-cache since it's a small, host-provided shim that should always be
|
|
// fresh, not a hashed/immutable build asset.
|
|
app.get('/nostr-provider.js', (c) => serveFile(c, '/nostr-provider.js', 'no-cache'))
|
|
|
|
// Docs (markdown setup guides)
|
|
app.get('/docs/*', (c) => serveFile(c, c.req.path, 'public, max-age=3600'))
|
|
|
|
// Audio files (pre-generated TTS, SFX)
|
|
app.get('/audio/*', (c) => serveFile(c, c.req.path, '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', 'wav', 'mp3', 'ogg', 'md'].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))
|
|
})
|
|
|
|
appLogger.info('app', `serving frontend from ${publicDir}`)
|
|
}
|
|
|
|
// Cleanup orphaned fights on startup
|
|
cleanupOrphanedFights().then(() => {
|
|
appLogger.info('app', 'orphaned fights cleaned up')
|
|
}).catch(err => {
|
|
appLogger.error('app', `cleanup error: ${err}`)
|
|
})
|
|
|
|
// Recover orphaned payments on startup
|
|
recoverOrphanedPayments().catch(err => {
|
|
appLogger.error('app', `payment recovery error: ${err}`)
|
|
})
|
|
|
|
// Start daily database backups (production only)
|
|
if (process.env.NODE_ENV === 'production') {
|
|
startDailyBackups()
|
|
}
|
|
|
|
// Start memory tracking
|
|
startMemoryTracking()
|