feat: practice button on profile, fix rate limiter, fix mobile sprite rendering

- Add Practice button to BotProfilePage for quick sparring
- Fix rate limiter bug: all rateLimit() instances shared one counter map,
  causing global and per-route limits to corrupt each other. Each limiter
  now gets its own isolated map.
- Replace 8-digit hex colors (#ffd70066) with rgba() in sprite rendering
  for mobile browser compatibility (iOS Safari renders them as black boxes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 12:14:07 +00:00
co-authored by Claude Opus 4.6
parent d0f0a84a57
commit ad96d1158f
4 changed files with 77 additions and 21 deletions
+26 -11
View File
@@ -4,15 +4,24 @@ const isDev = process.env.NODE_ENV !== 'production'
const MAX_MAP_SIZE = 10_000
const hitCounts = new Map<string, { count: number; resetAt: number }>()
// Track all maps for periodic cleanup
const allMaps: Map<string, { count: number; resetAt: number }>[] = []
// Cleanup stale entries every 5 minutes + evict oldest if over MAX_MAP_SIZE
// Cleanup stale entries every 5 minutes across all rate limiter maps
export const cleanupInterval = setInterval(() => {
const now = Date.now()
for (const [key, entry] of hitCounts) {
if (now > entry.resetAt) hitCounts.delete(key)
for (const map of allMaps) {
for (const [key, entry] of map) {
if (now > entry.resetAt) map.delete(key)
}
// Evict oldest if over limit
if (map.size > MAX_MAP_SIZE) {
const sorted = [...map.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt)
const toRemove = sorted.slice(0, map.size - MAX_MAP_SIZE)
for (const [key] of toRemove) map.delete(key)
}
}
// Evict oldest entries from botHitCounts if over limit
// Also clean bot hit counts
if (botHitCounts.size > MAX_MAP_SIZE) {
const sorted = [...botHitCounts.entries()].sort((a, b) => a[1] - b[1])
const toRemove = sorted.slice(0, botHitCounts.size - MAX_MAP_SIZE)
@@ -20,16 +29,22 @@ export const cleanupInterval = setInterval(() => {
}
}, 5 * 60 * 1000)
function getIp(c: Context): string {
return c.req.header('cf-connecting-ip')
|| c.req.header('x-real-ip')
|| c.req.header('x-forwarded-for')?.split(',')[0].trim()
|| 'unknown'
}
export function rateLimit(windowMs: number, maxHits: number) {
// Each rateLimit() call gets its own isolated counter map
const hitCounts = new Map<string, { count: number; resetAt: number }>()
allMaps.push(hitCounts)
return async (c: Context, next: Next) => {
if (isDev) return next()
// Extract real IP — prefer trusted proxy headers over spoofable x-forwarded-for
const realIp = c.req.header('cf-connecting-ip')
|| c.req.header('x-real-ip')
|| c.req.header('x-forwarded-for')?.split(',')[0].trim()
|| 'unknown'
const key = realIp
const key = getIp(c)
const now = Date.now()
const entry = hitCounts.get(key)