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} 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 };