CentralBackend/middleware/rateLimiter.js
2025-09-08 16:05:37 +02:00

117 lines
4.3 KiB
JavaScript

const UnitOfWork = require('../database/UnitOfWork');
const RateLimitRepository = require('../repositories/rateLimit/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 };