diff --git a/README.md b/README.md index f7d0ee1..8c04758 100644 --- a/README.md +++ b/README.md @@ -516,6 +516,25 @@ All stops as a JSON array, sorted by code. (`GET /stops` returns an HTML table instead.) +#### Per-stop route overrides + +The upstream route list for a stop is sometimes behind reality. `GET /stops` +applies a stored override to its `Маршрути` column — removed routes shown red and +struck through, added ones green — and hangs the matching `?add=`/`?remove=` on +that row's SVG and PDF links, which `offline.lad.lviv.ua` and `pdf.lad.lviv.ua` +both understand. + +Add `?edit=1` to the listing to change them: click a route to drop or restore it, +type one into the `+` box to add it. + +Overrides live in the browser's own `localStorage` (see +[`public/stopOverrides.js`](public/stopOverrides.js)), not on a server — no +account to edit through, no cache to purge, an edit applies at once. The trade +is scope: an override is visible only in the browser that made it, not to +anyone else who opens `/stops`. + +`/stops.json` reports `sign` and `sign_pdf` without overrides applied. + #### `GET /stops/:code` Single stop with live realtime timetable. Short-cached (5–10 s). diff --git a/actions/getAllStopsAction.js b/actions/getAllStopsAction.js index 10d1971..ea40855 100644 --- a/actions/getAllStopsAction.js +++ b/actions/getAllStopsAction.js @@ -58,8 +58,12 @@ export default async (req, res, next) => { + ${contactBannerHtml("stops")} @@ -83,16 +87,21 @@ ${contactBannerHtml("stops")} }) .sort(); - result += ` + // data-code and data-routes are what /stop-overrides.js rewrites the row + // from: the served HTML stays the plain upstream listing, cacheable for + // 30 days, and the overrides are applied in the browser. + result += ` ${s.code} (${s.microgiz_id}) - SVG + SVG   - PDF + PDF ${escapeHtml(s.name)} ${loc[0]}, ${loc[1]} - ${transfers.map(escapeHtml).join(" ")} + ${transfers + .map((r) => `${escapeHtml(r)}`) + .join(" ")} `; } result += "\n\n"; diff --git a/index.js b/index.js index 56484ca..2ff4ca3 100644 --- a/index.js +++ b/index.js @@ -10,12 +10,14 @@ const PORT = process.env.PORT || 8080; import { openDb } from "gtfs"; import { readFile } from "fs/promises"; import cors from "cors"; +import rateLimit from "express-rate-limit"; import express from "express"; import bodyParser from "body-parser"; import localDb from "./connections/timetableSqliteDb.js"; import notFoundAction from "./actions/notFoundAction.js"; import validateStopCode from "./utils/stopCodeMiddleware.js"; +import createRateLimiter from "./utils/rateLimiter.js"; import getClosestStopsAction from "./actions/getClosestStopsAction.js"; import getSingleStopAction from "./actions/getSingleStopAction.js"; @@ -235,30 +237,16 @@ app.get("/ping", (req, res) => { app.get("/health", healthAction); -// Simple in-memory rate limiter: 60 requests/min per IP -const _mcpRateLimitMap = new Map(); -const MCP_RATE_LIMIT = 60; -const MCP_RATE_WINDOW_MS = 60_000; - -function mcpRateLimiter(req, res, next) { - const ip = req.ip ?? "unknown"; - const now = Date.now(); - const entry = _mcpRateLimitMap.get(ip) ?? { count: 0, windowStart: now }; - if (now - entry.windowStart > MCP_RATE_WINDOW_MS) { - entry.count = 0; - entry.windowStart = now; - } - entry.count++; - _mcpRateLimitMap.set(ip, entry); - if (entry.count > MCP_RATE_LIMIT) { - return res.status(429).json({ +const mcpRateLimiter = createRateLimiter({ + limit: 60, + windowMs: 60_000, + onLimit: (res) => + res.status(429).json({ jsonrpc: "2.0", error: { code: -32000, message: "Rate limit exceeded. Try again later." }, id: null, - }); - } - next(); -} + }), +}); app.post("/mcp", mcpRateLimiter, async (req, res) => { try { @@ -340,6 +328,25 @@ app.get("/favicon.ico", (req, res, next) => { res.sendFile(path.join(__dirname, "favicon.ico")); }); +// Applies the per-stop route overrides to the /stops listing in the browser. +// Tagged "long" like the other baked-in assets: it ships with the image, so a +// GTFS refresh leaves it alone and a code push purges it. Cloudflare caches it +// for a day on that tag, so an uncached request reaching this far is rare — +// the limiter is here so a cache-bypassing client can't turn the file read +// underneath sendFile into an amplifier. +const staticFileRateLimiter = rateLimit({ + windowMs: 60_000, + limit: 120, + standardHeaders: true, + legacyHeaders: false, +}); + +app.get("/stop-overrides.js", staticFileRateLimiter, (req, res) => { + setStaticAssetCache(res); + res.type("text/javascript"); + res.sendFile(path.join(__dirname, "public", "stopOverrides.js")); +}); + app.get("/smithery.json", (req, res) => { setStaticAssetCache(res, 3600 * 24 * 7); res.sendFile(path.join(__dirname, "smithery.json")); diff --git a/package-lock.json b/package-lock.json index dea6cc7..6f182ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "cors": "^2.8.5", "dotenv": "^17.4", "express": "^5.2.1", + "express-rate-limit": "^8.6.2", "gtfs": "^4.18.5", "gtfs-realtime-bindings": "^2.0.0", "lokijs": "^1.5.12", @@ -2529,11 +2530,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { + "debug": "^4.4.3", "ip-address": "^10.2.0" }, "engines": { diff --git a/package.json b/package.json index 988288f..5d26bce 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "cors": "^2.8.5", "dotenv": "^17.4", "express": "^5.2.1", + "express-rate-limit": "^8.6.2", "gtfs": "^4.18.5", "gtfs-realtime-bindings": "^2.0.0", "lokijs": "^1.5.12", diff --git a/public/stopOverrides.js b/public/stopOverrides.js new file mode 100644 index 0000000..c091708 --- /dev/null +++ b/public/stopOverrides.js @@ -0,0 +1,239 @@ +/** + * Per-stop route overrides for the /stops listing. + * + * Stored in the browser's own localStorage, not on a server: no account to + * edit through, no round trip, no cache to purge. The trade is scope — an + * edit is visible only in the browser that made it, not to anyone else who + * opens /stops. + * + * This file is served to the browser as an ES module and imported directly by + * the test suite, so the DOM half only runs when init() is called. + */ + +export const STORAGE_KEY = "lad-route-overrides"; + +export const MAX_ROUTES_PER_LIST = 40; + +// Route names are alphanumeric in both alphabets: А03, Т25, 32A, Аеропорт. +const ROUTE_NAME = /^[\p{L}\p{N}]{1,16}$/u; + +export function isValidRouteName(name) { + return typeof name === "string" && ROUTE_NAME.test(name); +} + +/** + * Trims anything that is not a usable route name, drops duplicates, and caps + * the length. A name in both lists means remove, since that is the safer read. + */ +export function normalizeOverride(entry) { + const clean = (list) => + Array.from(new Set(Array.isArray(list) ? list : [])) + .filter(isValidRouteName) + .slice(0, MAX_ROUTES_PER_LIST); + + const remove = clean(entry?.remove); + const add = clean(entry?.add).filter((name) => !remove.includes(name)); + + return { add, remove }; +} + +export function isEmptyOverride(entry) { + const { add, remove } = normalizeOverride(entry); + return add.length === 0 && remove.length === 0; +} + +/** + * The query string timetable-offline and timetable-pdf both understand. + * Comma-separated, each name percent-encoded: the services split on the comma + * after decoding, and route names are Cyrillic. + */ +export function overrideQuery(entry) { + const { add, remove } = normalizeOverride(entry); + const parts = []; + + if (add.length) parts.push(`add=${add.map(encodeURIComponent).join(",")}`); + if (remove.length) + parts.push(`remove=${remove.map(encodeURIComponent).join(",")}`); + + return parts.join("&"); +} + +export function signLinks(code, entry) { + const query = overrideQuery(entry); + const suffix = query ? `?${query}` : ""; + + return { + svg: `https://offline.lad.lviv.ua/${code}${suffix}`, + pdf: `https://pdf.lad.lviv.ua/${code}.pdf${suffix}`, + }; +} + +/** + * The route column as it should read: upstream routes in their own order, the + * removed ones still shown but struck through, the added ones after them. + */ +export function applyOverride(routes, entry) { + const { add, remove } = normalizeOverride(entry); + const upstream = Array.isArray(routes) ? routes : []; + + const kept = upstream.map((name) => ({ + name, + state: remove.includes(name) ? "removed" : "kept", + })); + + const added = add + .filter((name) => !upstream.includes(name)) + .map((name) => ({ name, state: "added" })); + + return [...kept, ...added]; +} + +/** + * Toggles a name that the API does list for the stop: kept becomes removed and + * back. Toggling one the API does not list drops it from the add list instead, + * so a chip added by mistake can be clicked away. + */ +export function toggleRoute(entry, routes, name) { + const { add, remove } = normalizeOverride(entry); + const upstream = Array.isArray(routes) ? routes : []; + + if (!upstream.includes(name)) { + return normalizeOverride({ add: add.filter((r) => r !== name), remove }); + } + + return normalizeOverride( + remove.includes(name) + ? { add, remove: remove.filter((r) => r !== name) } + : { add, remove: [...remove, name] }, + ); +} + +/** + * A name the API already lists for the stop is not added — it is un-removed, + * which is what asking for it back means. + */ +export function addRoute(entry, routes, name) { + if (!isValidRouteName(name)) return normalizeOverride(entry); + + const { add, remove } = normalizeOverride(entry); + const upstream = Array.isArray(routes) ? routes : []; + + if (upstream.includes(name)) { + return normalizeOverride({ add, remove: remove.filter((r) => r !== name) }); + } + + return normalizeOverride({ add: [...add, name], remove }); +} + +// ── DOM ───────────────────────────────────────────────────────────────────── + +function renderRow(row, overrides, editing) { + const code = row.dataset.code; + const cell = row.querySelector("[data-routes]"); + const routes = cell.dataset.routes ? cell.dataset.routes.split(" ") : []; + const entry = normalizeOverride(overrides[code]); + + cell.textContent = ""; + for (const { name, state } of applyOverride(routes, entry)) { + const chip = document.createElement("span"); + chip.className = `route ${state}`; + chip.dataset.route = name; + chip.textContent = name; + if (editing) chip.title = "Клацніть, щоб прибрати або повернути"; + cell.append(chip, document.createTextNode(" ")); + } + + if (editing) { + const input = document.createElement("input"); + input.className = "route-add"; + input.size = 6; + input.placeholder = "+"; + cell.append(input); + } + + const links = signLinks(code, entry); + for (const link of row.querySelectorAll("[data-kind]")) { + link.href = links[link.dataset.kind]; + } +} + +/** + * Reads the stored map. Private browsing / storage-disabled throws on access + * in some browsers rather than just returning null, and a hand-edited or + * previous-format value in there is not JSON worth trusting either — either + * way this falls back to no overrides rather than breaking the listing. + */ +export function loadOverrides() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? {}; + } catch { + return {}; + } +} + +export function saveOverrides(overrides) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)); + return true; + } catch { + // Storage disabled, full, or the quota was hit — the in-page state still + // reflects the edit, it just will not survive a reload. + return false; + } +} + +export function init() { + const rows = Array.from(document.querySelectorAll("tr[data-code]")); + if (!rows.length) return; + + const editing = new URLSearchParams(location.search).has("edit"); + if (editing) document.body.dataset.edit = ""; + + const overrides = loadOverrides(); + for (const row of rows) renderRow(row, overrides, editing); + + if (!editing) return; + + const routesOf = (row) => { + const cell = row.querySelector("[data-routes]"); + return cell.dataset.routes ? cell.dataset.routes.split(" ") : []; + }; + + const persist = (row, code) => { + renderRow(row, overrides, editing); + if (!saveOverrides(overrides)) { + row.querySelector("[data-routes]").append(" ⚠️"); + } + }; + + document.addEventListener("click", (event) => { + const chip = event.target.closest(".route"); + if (!chip) return; + + const row = chip.closest("tr[data-code]"); + const code = row.dataset.code; + overrides[code] = toggleRoute(overrides[code], routesOf(row), chip.dataset.route); + if (isEmptyOverride(overrides[code])) delete overrides[code]; + persist(row, code); + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + const input = event.target.closest(".route-add"); + if (!input) return; + + const row = input.closest("tr[data-code]"); + const code = row.dataset.code; + const name = input.value.trim(); + if (!isValidRouteName(name)) return; + + overrides[code] = addRoute(overrides[code], routesOf(row), name); + if (isEmptyOverride(overrides[code])) delete overrides[code]; + input.value = ""; + persist(row, code); + }); +} + +if (typeof document !== "undefined") { + init(); +} diff --git a/tests/actions/getAllStopsAction.test.js b/tests/actions/getAllStopsAction.test.js index 6da3094..5b7a64c 100644 --- a/tests/actions/getAllStopsAction.test.js +++ b/tests/actions/getAllStopsAction.test.js @@ -50,6 +50,39 @@ describe("getAllStopsAction", () => { ); }); + // /stop-overrides.js rewrites the row from these; without them the overrides + // have nothing to attach to. + it("marks up each row for the override script", async () => { + const { req, res, next } = makeReqRes({ path: "/stops" }); + await getAllStopsAction(req, res, next); + + const html = res.send.mock.calls[0][0]; + expect(html).toContain(''); + expect(html).toContain('data-routes="А01 Т1"'); + expect(html).toContain('А01'); + expect(html).toContain('data-kind="svg"'); + expect(html).toContain('data-kind="pdf"'); + expect(html).toContain('src="/stop-overrides.js"'); + }); + + it("styles removed routes red and struck through, added ones green", async () => { + const { req, res, next } = makeReqRes({ path: "/stops" }); + await getAllStopsAction(req, res, next); + + const html = res.send.mock.calls[0][0]; + expect(html).toContain(".route.removed { color: red; text-decoration: line-through; }"); + expect(html).toContain(".route.added { color: green; }"); + }); + + it("serves the bare upstream route list, so the page stays cacheable", async () => { + const { req, res, next } = makeReqRes({ path: "/stops" }); + await getAllStopsAction(req, res, next); + + const html = res.send.mock.calls[0][0]; + expect(html).not.toContain('class="route removed"'); + expect(html).not.toContain('class="route added"'); + }); + it("sets cache headers for Cloudflare", async () => { const { req, res, next } = makeReqRes({ path: "/stops.json" }); await getAllStopsAction(req, res, next); diff --git a/tests/public/stopOverrides.test.js b/tests/public/stopOverrides.test.js new file mode 100644 index 0000000..5b46275 --- /dev/null +++ b/tests/public/stopOverrides.test.js @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + addRoute, + applyOverride, + isEmptyOverride, + isValidRouteName, + loadOverrides, + normalizeOverride, + overrideQuery, + saveOverrides, + signLinks, + toggleRoute, + MAX_ROUTES_PER_LIST, + STORAGE_KEY, +} from "../../public/stopOverrides.js"; + +/** In-memory localStorage stand-in — no jsdom needed for two get/set calls. */ +function makeStorage() { + const data = new Map(); + return { + getItem: (key) => (data.has(key) ? data.get(key) : null), + setItem: (key, value) => data.set(key, String(value)), + }; +} + +const ROUTES = ["А03", "А05", "А55"]; + +describe("isValidRouteName", () => { + it.each(["А03", "Т25", "32A", "Аеропорт", "5"])("accepts %s", (name) => { + expect(isValidRouteName(name)).toBe(true); + }); + + it.each(["", "../etc", "A 47", "A,47", "a".repeat(17), 47, null])( + "rejects %s", + (name) => { + expect(isValidRouteName(name)).toBe(false); + }, + ); +}); + +describe("normalizeOverride", () => { + it("returns empty lists for a missing entry", () => { + expect(normalizeOverride(undefined)).toEqual({ add: [], remove: [] }); + }); + + it("drops names that are not route names", () => { + expect(normalizeOverride({ add: ["Т03", "../etc"], remove: ["А57", ""] })).toEqual({ + add: ["Т03"], + remove: ["А57"], + }); + }); + + it("dedupes", () => { + expect(normalizeOverride({ add: ["Т03", "Т03"], remove: [] }).add).toEqual(["Т03"]); + }); + + it("lets remove win when a name is in both lists", () => { + expect(normalizeOverride({ add: ["Т03"], remove: ["Т03"] })).toEqual({ + add: [], + remove: ["Т03"], + }); + }); + + it("caps each list", () => { + const many = Array.from({ length: MAX_ROUTES_PER_LIST + 5 }, (_, i) => `A${i}`); + expect(normalizeOverride({ add: many }).add).toHaveLength(MAX_ROUTES_PER_LIST); + }); + + it("survives a non-array", () => { + expect(normalizeOverride({ add: "Т03", remove: 7 })).toEqual({ add: [], remove: [] }); + }); +}); + +describe("isEmptyOverride", () => { + it("is true for an entry with nothing usable in it", () => { + expect(isEmptyOverride({ add: ["../etc"], remove: [] })).toBe(true); + }); + + it("is false once a name survives", () => { + expect(isEmptyOverride({ add: ["Т03"], remove: [] })).toBe(false); + }); +}); + +describe("overrideQuery", () => { + it("is empty for no override", () => { + expect(overrideQuery({ add: [], remove: [] })).toBe(""); + }); + + it("emits add and remove", () => { + expect(overrideQuery({ add: ["T02"], remove: ["T03"] })).toBe("add=T02&remove=T03"); + }); + + it("percent-encodes Cyrillic names and keeps the comma literal", () => { + expect(overrideQuery({ add: ["Т03", "А47"], remove: [] })).toBe( + "add=%D0%A203,%D0%9047", + ); + }); + + it("omits the side that is empty", () => { + expect(overrideQuery({ add: [], remove: ["T03"] })).toBe("remove=T03"); + }); +}); + +describe("signLinks", () => { + it("leaves the links bare when there is no override", () => { + expect(signLinks(62, {})).toEqual({ + svg: "https://offline.lad.lviv.ua/62", + pdf: "https://pdf.lad.lviv.ua/62.pdf", + }); + }); + + it("hangs the query off both links", () => { + expect(signLinks(62, { add: ["T02"], remove: ["T03"] })).toEqual({ + svg: "https://offline.lad.lviv.ua/62?add=T02&remove=T03", + pdf: "https://pdf.lad.lviv.ua/62.pdf?add=T02&remove=T03", + }); + }); +}); + +describe("applyOverride", () => { + it("marks every upstream route kept when there is no override", () => { + expect(applyOverride(ROUTES, {})).toEqual([ + { name: "А03", state: "kept" }, + { name: "А05", state: "kept" }, + { name: "А55", state: "kept" }, + ]); + }); + + it("marks a removed route rather than dropping it", () => { + expect(applyOverride(ROUTES, { remove: ["А05"] })).toContainEqual({ + name: "А05", + state: "removed", + }); + }); + + it("appends added routes after the upstream ones", () => { + const result = applyOverride(ROUTES, { add: ["Т03"] }); + expect(result.at(-1)).toEqual({ name: "Т03", state: "added" }); + }); + + it("does not append a route the stop already has", () => { + const result = applyOverride(ROUTES, { add: ["А03"] }); + expect(result.filter((r) => r.name === "А03")).toHaveLength(1); + }); +}); + +describe("toggleRoute", () => { + it("removes an upstream route", () => { + expect(toggleRoute({}, ROUTES, "А05").remove).toEqual(["А05"]); + }); + + it("puts a removed route back", () => { + expect(toggleRoute({ remove: ["А05"] }, ROUTES, "А05").remove).toEqual([]); + }); + + it("drops an added route the stop does not serve", () => { + expect(toggleRoute({ add: ["Т03"] }, ROUTES, "Т03").add).toEqual([]); + }); +}); + +describe("addRoute", () => { + it("adds a route the stop does not serve", () => { + expect(addRoute({}, ROUTES, "Т03").add).toEqual(["Т03"]); + }); + + it("un-removes rather than adds a route the stop already serves", () => { + const result = addRoute({ remove: ["А05"] }, ROUTES, "А05"); + expect(result).toEqual({ add: [], remove: [] }); + }); + + it("ignores a name that is not a route name", () => { + expect(addRoute({}, ROUTES, "../etc")).toEqual({ add: [], remove: [] }); + }); + + it("does not add the same route twice", () => { + expect(addRoute({ add: ["Т03"] }, ROUTES, "Т03").add).toEqual(["Т03"]); + }); +}); + +describe("loadOverrides / saveOverrides", () => { + let originalStorage; + + beforeEach(() => { + originalStorage = globalThis.localStorage; + globalThis.localStorage = makeStorage(); + }); + + afterEach(() => { + globalThis.localStorage = originalStorage; + }); + + it("returns an empty map when nothing is stored", () => { + expect(loadOverrides()).toEqual({}); + }); + + it("round-trips what was saved", () => { + saveOverrides({ 62: { add: ["Т03"], remove: ["А57"] } }); + expect(loadOverrides()).toEqual({ 62: { add: ["Т03"], remove: ["А57"] } }); + }); + + it("stores under the documented key, plain JSON", () => { + saveOverrides({ 62: { add: ["Т03"], remove: [] } }); + expect(JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY))).toEqual({ + 62: { add: ["Т03"], remove: [] }, + }); + }); + + it("falls back to no overrides when the stored value is not JSON", () => { + globalThis.localStorage.setItem(STORAGE_KEY, "not json"); + expect(loadOverrides()).toEqual({}); + }); + + it("falls back to no overrides when storage access throws", () => { + globalThis.localStorage = { + getItem: () => { + throw new Error("storage disabled"); + }, + }; + expect(loadOverrides()).toEqual({}); + }); + + it("reports failure rather than throwing when the write is rejected", () => { + globalThis.localStorage = { + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + expect(saveOverrides({ 62: { add: ["Т03"], remove: [] } })).toBe(false); + }); + + it("reports success on a normal write", () => { + expect(saveOverrides({})).toBe(true); + }); +}); diff --git a/tests/utils/rateLimiter.test.js b/tests/utils/rateLimiter.test.js new file mode 100644 index 0000000..3c1ef8c --- /dev/null +++ b/tests/utils/rateLimiter.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from "vitest"; +import createRateLimiter from "../../utils/rateLimiter.js"; + +function makeRes() { + return { + status: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; +} + +describe("createRateLimiter", () => { + it("calls next() under the limit", () => { + const limiter = createRateLimiter({ limit: 2, windowMs: 60_000, onLimit: vi.fn() }); + const next = vi.fn(); + + limiter({ ip: "1.2.3.4" }, makeRes(), next); + limiter({ ip: "1.2.3.4" }, makeRes(), next); + + expect(next).toHaveBeenCalledTimes(2); + }); + + it("calls onLimit instead of next() once the limit is exceeded", () => { + const onLimit = vi.fn(); + const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, onLimit }); + const next = vi.fn(); + + limiter({ ip: "1.2.3.4" }, makeRes(), next); + const res = makeRes(); + limiter({ ip: "1.2.3.4" }, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(onLimit).toHaveBeenCalledWith(res); + }); + + it("tracks each IP separately", () => { + const onLimit = vi.fn(); + const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, onLimit }); + const next = vi.fn(); + + limiter({ ip: "1.1.1.1" }, makeRes(), next); + limiter({ ip: "2.2.2.2" }, makeRes(), next); + + expect(next).toHaveBeenCalledTimes(2); + expect(onLimit).not.toHaveBeenCalled(); + }); + + it("resets the count once the window has passed", () => { + const onLimit = vi.fn(); + let time = 0; + const limiter = createRateLimiter({ + limit: 1, + windowMs: 1000, + onLimit, + now: () => time, + }); + const next = vi.fn(); + + limiter({ ip: "1.2.3.4" }, makeRes(), next); + time = 2000; + limiter({ ip: "1.2.3.4" }, makeRes(), next); + + expect(next).toHaveBeenCalledTimes(2); + expect(onLimit).not.toHaveBeenCalled(); + }); + + it("does not crash on a request with no ip", () => { + const limiter = createRateLimiter({ limit: 2, windowMs: 60_000, onLimit: vi.fn() }); + const next = vi.fn(); + + expect(() => limiter({}, makeRes(), next)).not.toThrow(); + expect(next).toHaveBeenCalledOnce(); + }); +}); diff --git a/utils/rateLimiter.js b/utils/rateLimiter.js new file mode 100644 index 0000000..fc0d773 --- /dev/null +++ b/utils/rateLimiter.js @@ -0,0 +1,24 @@ +/** + * Simple in-memory rate limiter, one counter map per instance. Good enough for + * a single running instance; would need a shared store (e.g. Redis) behind + * more than one. + */ +export default function createRateLimiter({ limit, windowMs, onLimit, now = Date.now }) { + const counts = new Map(); + + return function rateLimiter(req, res, next) { + const ip = req.ip ?? "unknown"; + const time = now(); + const entry = counts.get(ip) ?? { count: 0, windowStart: time }; + if (time - entry.windowStart > windowMs) { + entry.count = 0; + entry.windowStart = time; + } + entry.count++; + counts.set(ip, entry); + if (entry.count > limit) { + return onLimit(res); + } + next(); + }; +}