refactor(api): dedupe in-memory IP rate limiter (#1740)

This commit is contained in:
Elie Habib
2026-03-17 08:32:49 +04:00
committed by GitHub
parent 33244ec15f
commit ffae59f50e
3 changed files with 27 additions and 25 deletions

20
api/_ip-rate-limit.js Normal file
View File

@@ -0,0 +1,20 @@
export function createIpRateLimiter({ limit, windowMs }) {
const rateLimitMap = new Map();
function getEntry(ip) {
return rateLimitMap.get(ip) || null;
}
function isRateLimited(ip) {
const now = Date.now();
const entry = getEntry(ip);
if (!entry || now - entry.windowStart > windowMs) {
rateLimitMap.set(ip, { windowStart: now, count: 1 });
return false;
}
entry.count += 1;
return entry.count > limit;
}
return { isRateLimited, getEntry };
}

View File

@@ -4,6 +4,7 @@ import { ConvexHttpClient } from 'convex/browser';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getClientIp, verifyTurnstile } from './_turnstile.js';
import { jsonResponse } from './_json-response.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
@@ -23,20 +24,10 @@ const FREE_EMAIL_DOMAINS = new Set([
't-online.de', 'libero.it', 'virgilio.it',
]);
const rateLimitMap = new Map();
const RATE_LIMIT = 3;
const RATE_WINDOW_MS = 60 * 60 * 1000;
function isRateLimited(ip) {
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now - entry.windowStart > RATE_WINDOW_MS) {
rateLimitMap.set(ip, { windowStart: now, count: 1 });
return false;
}
entry.count += 1;
return entry.count > RATE_LIMIT;
}
const rateLimiter = createIpRateLimiter({ limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS });
async function sendNotificationEmail(name, email, organization, phone, message) {
const resendKey = process.env.RESEND_API_KEY;
@@ -113,7 +104,7 @@ export default async function handler(req) {
const ip = getClientIp(req);
if (isRateLimited(ip)) {
if (rateLimiter.isRateLimited(ip)) {
return jsonResponse({ error: 'Too many requests' }, 429, cors);
}

View File

@@ -4,25 +4,16 @@ import { ConvexHttpClient } from 'convex/browser';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getClientIp, verifyTurnstile } from './_turnstile.js';
import { jsonResponse } from './_json-response.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_EMAIL_LENGTH = 320;
const MAX_META_LENGTH = 100;
const rateLimitMap = new Map();
const RATE_LIMIT = 5;
const RATE_WINDOW_MS = 60 * 60 * 1000;
function isRateLimited(ip) {
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now - entry.windowStart > RATE_WINDOW_MS) {
rateLimitMap.set(ip, { windowStart: now, count: 1 });
return false;
}
entry.count += 1;
return entry.count > RATE_LIMIT;
}
const rateLimiter = createIpRateLimiter({ limit: RATE_LIMIT, windowMs: RATE_WINDOW_MS });
async function sendConfirmationEmail(email, referralCode) {
const referralLink = `https://worldmonitor.app/pro?ref=${referralCode}`;
@@ -193,7 +184,7 @@ export default async function handler(req) {
}
const ip = getClientIp(req);
if (isRateLimited(ip)) {
if (rateLimiter.isRateLimited(ip)) {
return jsonResponse({ error: 'Too many requests' }, 429, cors);
}
@@ -214,7 +205,7 @@ export default async function handler(req) {
const DESKTOP_SOURCES = new Set(['desktop-settings']);
const isDesktopSource = typeof body.source === 'string' && DESKTOP_SOURCES.has(body.source);
if (isDesktopSource) {
const entry = rateLimitMap.get(ip);
const entry = rateLimiter.getEntry(ip);
if (entry && entry.count > 2) {
return jsonResponse({ error: 'Rate limit exceeded' }, 429, cors);
}