84 lines
4.2 KiB
JavaScript
84 lines
4.2 KiB
JavaScript
const rateLimit = require('express-rate-limit')
|
||
const { getRedisClient } = require('./redisClient')
|
||
|
||
/**
|
||
* پیادهسازیِ سبکِ خودمون از Store — بهجای rate-limit-redis. علتش: rate-limit-redis
|
||
* موقعِ ساختنِ RedisStore (سازنده، همون لحظهی require شدنِ این ماژول، خیلی زودتر از
|
||
* وصلشدنِ کاملِ Redis) فوراً و بدون await دو تا SCRIPT LOAD میفرسته و نتیجهشون
|
||
* (Promise) رو *برای همیشهی عمرِ اون instance* کش میکنه. اگه Redis همون لحظهی
|
||
* boot در دسترس نباشه، اون دو Promise رد میشن (unhandled rejection، حتی میتونه
|
||
* کل پروسه رو کرش کنه) و از اون به بعد، حتی وقتی Redis بعداً وصل بشه، همون Store
|
||
* برای همیشه شکسته میمونه چون SHAی اسکریپت هیچوقت دوباره لود نمیشه.
|
||
*
|
||
* این پیادهسازی فقط از INCR/PEXPIRE/DECR/DEL استفاده میکنه (بدون اسکریپتِ لوا،
|
||
* بدون هیچ حالتِ ساختهشدهی یکبارمصرف) — هر متد کاملاً مستقلِ متدهای دیگهست، پس
|
||
* هیچ حالتی نیست که «یه بار شکست خورد، برای همیشه خرابه»
|
||
*/
|
||
class SimpleRedisRateLimitStore {
|
||
constructor (prefix, windowMs) {
|
||
this.prefix = prefix
|
||
this.windowMs = windowMs
|
||
}
|
||
|
||
async increment (key) {
|
||
const redis = getRedisClient()
|
||
if (!redis) throw new Error('Redis not ready')
|
||
const redisKey = this.prefix + key
|
||
const totalHits = await redis.incr(redisKey)
|
||
if (totalHits === 1) {
|
||
// اولین ضربهی این پنجره — شروعکنندهٔ TTL. اگه دقیقاً همینجا پروسه کرش کنه
|
||
// (قبل از رسیدن به pExpire)، این کلید بدون انقضا میمونه — یه edge-caseِ
|
||
// شناختهشده و پذیرفتهشده برای این نوع شمارندهی ساده، نه یه شمارندهی
|
||
// امنیتیای که باید صددرصد atomic باشه
|
||
await redis.pExpire(redisKey, this.windowMs)
|
||
}
|
||
const ttl = await redis.pTTL(redisKey)
|
||
return {
|
||
totalHits,
|
||
resetTime: ttl > 0 ? new Date(Date.now() + ttl) : undefined
|
||
}
|
||
}
|
||
|
||
async decrement (key) {
|
||
const redis = getRedisClient()
|
||
if (!redis) return
|
||
await redis.decr(this.prefix + key)
|
||
}
|
||
|
||
async resetKey (key) {
|
||
const redis = getRedisClient()
|
||
if (!redis) return
|
||
await redis.del(this.prefix + key)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* میسازه: یه میدلورِ ریتلیمیت که خودش روی هر درخواست بین Redis (وقتی وصله —
|
||
* محدودیتِ سراسری بین همهی اینستنسهای سرور، برای مقیاسپذیری/امنیتِ واقعی لازمه —
|
||
* مثلاً برای جلوگیری از brute-force روی لاگین وقتی چند سرور پشتِ لودبالانسر باشه)
|
||
* و حافظهی in-process (fallback وقتی Redis در دسترس نیست — فقط per-instance، ولی
|
||
* از کرشکردن یا بیاثرشدنِ کاملِ محدودیت بهتره) سوییچ میکنه.
|
||
*
|
||
* چون express-rate-limit اجازه نمیده store رو بعد از ساختنِ میدلور عوض کنی، دو
|
||
* نمونهی کاملاً جدا ساخته میشه و روی هر درخواست، بر اساسِ آمادهبودنِ فعلیِ Redis،
|
||
* یکیشون صدا زده میشه.
|
||
*
|
||
* @param {string} prefix - پیشوندِ یکتا برای کلیدهای این limiter توی Redis (تا با
|
||
* limiterهای دیگه قاطی نشن)
|
||
* @param {import('express-rate-limit').Options} options
|
||
*/
|
||
function createRateLimiter (prefix, options) {
|
||
const memoryLimiter = rateLimit(options)
|
||
const redisLimiter = rateLimit({
|
||
...options,
|
||
store: new SimpleRedisRateLimitStore(`rl:${prefix}:`, options.windowMs)
|
||
})
|
||
|
||
return (req, res, next) => {
|
||
const limiter = getRedisClient() ? redisLimiter : memoryLimiter
|
||
limiter(req, res, next)
|
||
}
|
||
}
|
||
|
||
module.exports = createRateLimiter
|