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:
co-authored by
Claude Opus 4.6
parent
d0f0a84a57
commit
ad96d1158f
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user