From 80b73b62e43fc0d552f4cd28e003ea4e9a64ad3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20Andr=C3=A1s=20T=C3=B3th?= Date: Fri, 17 Jul 2026 14:23:52 +0200 Subject: [PATCH] graceful shutdown and rate limit implementation --- package-lock.json | 35 ++++++++++++++++++++++++++++++++--- package.json | 1 + src/api/badge.js | 3 --- src/index.js | 30 +++++++++++++++++++++++++++++- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84c1a32..6624ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.10.0", "dotenv": "^17.4.2", "express": "^5.1.0", + "express-rate-limit": "^8.6.0", "helmet": "^8.1.0", "pino": "^10.3.1" }, @@ -1709,9 +1710,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2073,6 +2074,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-copy": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.4.tgz", @@ -2560,6 +2580,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 56521ff..0390f6f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "axios": "^1.10.0", "dotenv": "^17.4.2", "express": "^5.1.0", + "express-rate-limit": "^8.6.0", "helmet": "^8.1.0", "pino": "^10.3.1" }, diff --git a/src/api/badge.js b/src/api/badge.js index 978314a..14fa3fc 100644 --- a/src/api/badge.js +++ b/src/api/badge.js @@ -21,9 +21,6 @@ router.get('/badge', async (req, res) => { const safeIcon = escapeSvg(icon); const iconUrl = `https://cdn.simpleicons.org/${safeIcon}?viewbox=auto&size=${size}`; const iconResponse = await axios.get(iconUrl, { responseType: 'text' }); - if (iconResponse.status !== 200) { - return res.status(404).send('Icon not found'); - } const iconSVG = iconResponse.data; // if there is no label query, use the title from the SVG diff --git a/src/index.js b/src/index.js index df23f10..98b9533 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ require('dotenv').config(); const express = require('express'); const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); const logger = require('./lib/logger'); const api = require('./api'); const version = require('./lib/version').version(); @@ -9,10 +10,20 @@ const version = require('./lib/version').version(); const app = express(); const PORT = process.env.PORT || 3000; +// Rate limiting – max 100 requests per minute per IP +const limiter = rateLimit({ + windowMs: 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later.' }, +}); + app.use(helmet({ crossOriginResourcePolicy: false, })); +app.use(limiter); app.use('/', api.router); // Central error handler – catches all unhandled errors @@ -22,6 +33,23 @@ app.use((err, req, res, next) => { res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' }); }); -app.listen(PORT, () => { +const server = app.listen(PORT, () => { logger.info(`BadgeDex version ${version} listening on http://localhost:${PORT}/`); }); + +// Graceful shutdown – close server cleanly on SIGTERM / SIGINT +const shutdown = (signal) => { + logger.info(`${signal} received, shutting down gracefully`); + server.close(() => { + logger.info('HTTP server closed'); + process.exit(0); + }); + // Force exit after 5 seconds if connections don't close + setTimeout(() => { + logger.warn('Forced shutdown after timeout'); + process.exit(1); + }, 5000); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT'));