110 lines
3.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
/**
|
|
* Rate limiting via Redis (ioredis)
|
|
* Fallback : compteur en mémoire si Redis indisponible (dev local sans Docker)
|
|
*
|
|
* Usage :
|
|
* const allowed = await checkRateLimit(ip, 'submit', 3)
|
|
* if (!allowed) throw createError({ statusCode: 429, ... })
|
|
*/
|
|
|
|
import Redis from 'ioredis'
|
|
|
|
let redisClient: Redis | null = null
|
|
let redisConnected = false
|
|
|
|
// Compteur en mémoire (fallback si Redis KO)
|
|
const memoryCounters: Map<string, { count: number; resetAt: number }> = new Map()
|
|
|
|
function getRedisClient(): Redis | null {
|
|
if (redisClient !== null) return redisConnected ? redisClient : null
|
|
|
|
try {
|
|
const config = useRuntimeConfig()
|
|
const url = config.redisUrl || 'redis://127.0.0.1:6379'
|
|
redisClient = new Redis(url, {
|
|
lazyConnect: true,
|
|
enableOfflineQueue: false,
|
|
maxRetriesPerRequest: 1,
|
|
connectTimeout: 2000,
|
|
})
|
|
|
|
redisClient.on('connect', () => {
|
|
redisConnected = true
|
|
console.log('[rateLimit] Redis connecté')
|
|
})
|
|
|
|
redisClient.on('error', (err) => {
|
|
redisConnected = false
|
|
console.warn('[rateLimit] Redis erreur — fallback mémoire activé:', err.message)
|
|
})
|
|
|
|
redisClient.connect().catch(() => {
|
|
redisConnected = false
|
|
})
|
|
|
|
return redisConnected ? redisClient : null
|
|
} catch (e) {
|
|
console.warn('[rateLimit] Impossible de créer le client Redis — fallback mémoire')
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vérifie et incrémente le compteur d'appels.
|
|
* @param ip Adresse IP du client
|
|
* @param action Clé d'action (ex : 'submit', 'comment')
|
|
* @param maxPerDay Nombre max d'appels autorisés par jour
|
|
* @returns true si autorisé, false si limite dépassée
|
|
*/
|
|
export async function checkRateLimit(
|
|
ip: string,
|
|
action: string,
|
|
maxPerDay: number,
|
|
): Promise<boolean> {
|
|
const key = `ratelimit:${action}:${ip}:${todayKey()}`
|
|
|
|
const client = getRedisClient()
|
|
|
|
if (client && redisConnected) {
|
|
try {
|
|
const count = await client.incr(key)
|
|
if (count === 1) {
|
|
// Expire à minuit (secondes restantes dans la journée)
|
|
await client.expireat(key, tomorrowMidnightUnix())
|
|
}
|
|
return count <= maxPerDay
|
|
} catch (e) {
|
|
console.warn('[rateLimit] Redis incr échoué — fallback mémoire:', (e as Error).message)
|
|
}
|
|
}
|
|
|
|
// Fallback mémoire
|
|
return memoryRateLimit(key, maxPerDay)
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
function todayKey(): string {
|
|
const d = new Date()
|
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
function tomorrowMidnightUnix(): number {
|
|
const d = new Date()
|
|
d.setUTCHours(24, 0, 0, 0)
|
|
return Math.floor(d.getTime() / 1000)
|
|
}
|
|
|
|
function memoryRateLimit(key: string, maxPerDay: number): boolean {
|
|
const now = Date.now()
|
|
const entry = memoryCounters.get(key)
|
|
|
|
if (!entry || entry.resetAt <= now) {
|
|
memoryCounters.set(key, { count: 1, resetAt: tomorrowMidnightUnix() * 1000 })
|
|
return true
|
|
}
|
|
|
|
entry.count++
|
|
return entry.count <= maxPerDay
|
|
}
|