diff --git a/package-lock.json b/package-lock.json index 77b3c88..3465250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { - "name": "send_to_kindle", - "version": "0.0.2", + "name": "send-to-ereader", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "send_to_kindle", - "version": "0.0.2", + "name": "send-to-ereader", + "version": "0.1.0", "license": "GPL-3.0-only", "dependencies": { "@lesjoursfr/html-to-epub": "^6.1.0", "@mozilla/readability": "^0.6.0", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "jsdom": "^29.1.1", "nodemailer": "^8.0.7", "sanitize-filename": "^1.6.4", @@ -1774,6 +1775,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2287,6 +2306,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 6557861..6712f89 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@mozilla/readability": "^0.6.0", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "jsdom": "^29.1.1", "nodemailer": "^8.0.7", "sanitize-filename": "^1.6.4", diff --git a/src/app.ts b/src/app.ts index f64de68..a3fba6a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import path from "path"; import fs from "fs"; import sanitizeFilename from "sanitize-filename"; import { EpubOptions, EPub } from "@lesjoursfr/html-to-epub"; +import { rateLimit } from "express-rate-limit"; import dotenv from "dotenv"; dotenv.config(); @@ -25,9 +26,22 @@ if (!SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM) { } const app = express(); + +// Trust the first hop in front of us (Nginx) so req.ip reflects the real +// client IP for rate limiting, not the proxy's loopback address. +app.set("trust proxy", 1); + app.use(express.json()); app.use(express.static("public")); +const sendArticleLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + limit: 20, + standardHeaders: "draft-7", + legacyHeaders: false, + message: { error: "Too many send requests. Try again in an hour." }, +}); + const transporter = nodemailer.createTransport({ host: SMTP_HOST, port: SMTP_PORT, @@ -164,6 +178,7 @@ const emailShape = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; app.post( "/send-article", + sendArticleLimiter, async (req: Request, res: Response, next: NextFunction): Promise => { const { url, readerEmail } = req.body;