117 lines
4.3 KiB
JavaScript
117 lines
4.3 KiB
JavaScript
const UnitOfWork = require('../repositories/UnitOfWork');
|
|
const RateLimitRepository = require('../repositories/RateLimitRepository');
|
|
|
|
/**
|
|
* Factory for rate limiter middleware.
|
|
* @param {Object} options
|
|
* @param {function(req): string} options.keyGenerator - Function to generate a unique key per request.
|
|
* @param {number} options.max - Max allowed requests per window.
|
|
* @param {number} options.windowSeconds - Window size in seconds.
|
|
* @returns {function} Express middleware
|
|
*/
|
|
function createRateLimiter({ keyGenerator, max, windowSeconds }) {
|
|
return async function rateLimiter(req, res, next) {
|
|
const rateKey = keyGenerator(req);
|
|
const now = new Date();
|
|
const windowStart = new Date(Math.floor(now.getTime() / (windowSeconds * 1000)) * windowSeconds * 1000);
|
|
|
|
const uow = new UnitOfWork();
|
|
try {
|
|
await uow.start();
|
|
const repo = new RateLimitRepository(uow.connection);
|
|
|
|
// Cleanup old rows with 1% probability
|
|
if (Math.random() < 0.01) {
|
|
await repo.cleanupOldRows(30); // 30 days retention
|
|
}
|
|
|
|
// Atomically increment or create
|
|
const row = await repo.incrementOrCreate(rateKey, windowStart, windowSeconds, max, 1);
|
|
|
|
if (row.count > max) {
|
|
await uow.rollback();
|
|
// Custom JSON error response
|
|
return res.status(429).json({
|
|
success: false,
|
|
message: 'Rate limit exceeded. Please try again later.'
|
|
});
|
|
}
|
|
|
|
await uow.commit();
|
|
next();
|
|
} catch (err) {
|
|
await uow.rollback(err);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: 'Internal server error (rate limiter)'
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Checks and/or increments the rate limit for a given key.
|
|
* If res is null, only checks (does not increment or send response).
|
|
* If res is provided, increments and sends response if limited.
|
|
* @param {Object} options
|
|
* @param {string} rateKey
|
|
* @param {number} max
|
|
* @param {number} windowSeconds
|
|
* @param {Object|null} res - Express response object or null
|
|
* @returns {Promise<boolean>} true if limited, false otherwise
|
|
*/
|
|
async function checkAndIncrementRateLimit({ rateKey, max, windowSeconds }, res) {
|
|
const now = new Date();
|
|
const windowStart = new Date(Math.floor(now.getTime() / (windowSeconds * 1000)) * windowSeconds * 1000);
|
|
|
|
const uow = new UnitOfWork();
|
|
try {
|
|
await uow.start();
|
|
const repo = new RateLimitRepository(uow.connection);
|
|
|
|
// Cleanup old rows with 1% probability
|
|
if (Math.random() < 0.01) {
|
|
await repo.cleanupOldRows(30); // 30 days retention
|
|
}
|
|
|
|
let row;
|
|
if (res === null) {
|
|
// "Check only" mode: do NOT increment, just check
|
|
row = await repo.getForUpdate(rateKey, windowStart);
|
|
if (row && row.count >= max) {
|
|
await uow.rollback();
|
|
console.warn(`[RATE LIMIT] Transaction rollback: rate limit exceeded for key ${rateKey} at ${new Date().toISOString()}`);
|
|
return true;
|
|
}
|
|
await uow.commit();
|
|
return false;
|
|
} else {
|
|
// "Increment" mode: increment or create
|
|
row = await repo.incrementOrCreate(rateKey, windowStart, windowSeconds, max, 1);
|
|
if (row.count > max) {
|
|
await uow.rollback();
|
|
console.warn(`[RATE LIMIT] Transaction rollback: rate limit exceeded for key ${rateKey} at ${new Date().toISOString()}`);
|
|
res.status(429).json({
|
|
success: false,
|
|
message: 'Rate limit exceeded. Please try again later.'
|
|
});
|
|
return true;
|
|
}
|
|
await uow.commit();
|
|
return false;
|
|
}
|
|
} catch (err) {
|
|
await uow.rollback(err);
|
|
if (res) {
|
|
console.error(`[RATE LIMIT] Transaction rollback: internal error for key ${rateKey} at ${new Date().toISOString()}`);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Internal server error (rate limiter)'
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
module.exports = { createRateLimiter, checkAndIncrementRateLimit };
|