diff --git a/package-lock.json b/package-lock.json index 00f01da..0425ce1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@vercel/analytics": "^1.6.1", "dotenv": "^17.2.3", "express": "^4.22.2", + "express-rate-limit": "^7.5.1", "mongoose": "^9.1.6" }, "devDependencies": { @@ -2893,6 +2894,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", diff --git a/package.json b/package.json index 7c28ff7..08b0c77 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@vercel/analytics": "^1.6.1", "dotenv": "^17.2.3", "express": "^4.22.2", + "express-rate-limit": "^7.5.1", "mongoose": "^9.1.6" }, "devDependencies": { diff --git a/src/server.js b/src/server.js index 3903d9c..0b8577b 100644 --- a/src/server.js +++ b/src/server.js @@ -1,3 +1,4 @@ +import rateLimit from 'express-rate-limit'; import express from 'express'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -7,6 +8,7 @@ import profileRoute from './routes/profile.route.js'; import themeComparisonRoute from './routes/theme-comparison.route.js'; import { initializeAnalytics } from './services/analytics.service.js'; import { githubCache } from './utils/cache.js'; +import { renderGracefulError } from './renderers/error.renderer.js'; inject(); @@ -14,6 +16,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); +app.set('trust proxy', 1); const PORT = config.port; // render html file @@ -45,7 +48,38 @@ app.get('/api/cache/stats', (req, res) => { res.json(githubCache.getStats()); }); -app.use('/api/profile', profileRoute); +function sendRateLimitSvg(req, res) { + res.setHeader('Content-Type', 'image/svg+xml'); + const svg = renderGracefulError({ + code: 'RATE_LIMIT', + detail: 'Too many requests. Please try again in 15 seconds.', + }); + res.status(429).send(svg); +} + +const globalLimiter = rateLimit({ + windowMs: 15 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + handler: sendRateLimitSvg, + skip: () => !config.isProduction, +}); + +const usernameLimiter = rateLimit({ + windowMs: 15 * 1000, + max: 10, + keyGenerator: (req) => { + const username = typeof req.query.username === 'string' ? req.query.username.trim().toLowerCase() : ''; + return username || req.ip; + }, + standardHeaders: true, + legacyHeaders: false, + handler: sendRateLimitSvg, + skip: () => !config.isProduction, +}); + +app.use('/api/profile', globalLimiter, usernameLimiter, profileRoute); app.use('/api/theme-preview', themeComparisonRoute); // Theme Comparison page