A Deno-powered backend service for Plants vs. Zombies: MODDED. [Read-only GitHub mirror] docs.pvzm.net
express typescript expressjs plant deno jspvz pvzm game online backend plants-vs-zombies zombie javascript plants modded vs plantsvszombies openapi pvz noads
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add rate limiting

Clay 9a441d7a bc9e7b64

+129 -3
-1
.gitignore
··· 1 1 data/ 2 - ssl/ 3 2 *.db 4 3 .env
+1 -1
README.md
··· 1 1 # PVZM Backend 2 2 3 - > v0.1.0 3 + > v0.1.1 4 4 > A Deno-powered backend service for [Plants vs. Zombies: MODDED](https://github.com/roblnet13/pvz). This service provides APIs for uploading, downloading, listing, favoriting, and reporting user-created _I, Zombie_ levels. 5 5 6 6 ## Features
+128 -1
modules/server/routes/levels.ts
··· 23 23 uploadWebhookClient?: WebhookClient; 24 24 } 25 25 ) { 26 + const uploadRateLimitByIp = new Map<string, number>(); 27 + const UPLOAD_WINDOW_MS = 60_000; 28 + 29 + const apiLevelsRateLimitByIp = new Map<string, number[]>(); 30 + const API_LEVELS_WINDOW_MS = 30_000; 31 + const API_LEVELS_LIMIT = 60; 32 + 33 + const favoriteRateLimitByIp = new Map<string, number[]>(); 34 + const FAVORITE_WINDOW_MS = 10_000; 35 + const FAVORITE_LIMIT = 30; 36 + 37 + const downloadRateLimitByIp = new Map<string, { events: number[]; blockedUntilMs: number }>(); 38 + const DOWNLOAD_WINDOW_MS = 5_000; 39 + const DOWNLOAD_LIMIT = 5; 40 + const DOWNLOAD_BLOCK_MS = 30_000; 41 + 42 + function pruneOldestTimestamps(timestamps: number[], cutoffMs: number) { 43 + while (timestamps.length > 0 && timestamps[0] <= cutoffMs) timestamps.shift(); 44 + } 45 + 46 + function setRetryAfter(res: any, retryAfterSeconds: number) { 47 + res.setHeader("Retry-After", String(Math.max(1, Math.ceil(retryAfterSeconds)))); 48 + } 49 + 50 + // general rate limit for all /api/levels* calls 51 + app.use("/api/levels", (req: any, res: any, next: any) => { 52 + const clientIP = getClientIP(req); 53 + const nowMs = Date.now(); 54 + const timestamps = apiLevelsRateLimitByIp.get(clientIP) ?? []; 55 + pruneOldestTimestamps(timestamps, nowMs - API_LEVELS_WINDOW_MS); 56 + if (timestamps.length >= API_LEVELS_LIMIT) { 57 + const retryAfterSeconds = timestamps.length === 0 58 + ? Math.ceil(API_LEVELS_WINDOW_MS / 1000) 59 + : Math.ceil((timestamps[0] + API_LEVELS_WINDOW_MS - nowMs) / 1000); 60 + setRetryAfter(res, retryAfterSeconds); 61 + return res.status(429).json({ 62 + error: "Rate limit exceeded", 63 + message: "Too many requests to /api/levels.", 64 + retryAfterSeconds, 65 + }); 66 + } 67 + timestamps.push(nowMs); 68 + apiLevelsRateLimitByIp.set(clientIP, timestamps); 69 + if (apiLevelsRateLimitByIp.size > 20_000) { 70 + for (const [ip, ts] of apiLevelsRateLimitByIp) { 71 + pruneOldestTimestamps(ts, nowMs - 2 * API_LEVELS_WINDOW_MS); 72 + if (ts.length === 0) apiLevelsRateLimitByIp.delete(ip); 73 + } 74 + } 75 + next(); 76 + }); 77 + 26 78 // create a new level 27 79 app.post("/api/levels", async (req: any, res: any) => { 28 80 try { ··· 52 104 } 53 105 54 106 const clientIP = getClientIP(req); 107 + const nowMs = Date.now(); 108 + const lastUploadMs = uploadRateLimitByIp.get(clientIP) ?? 0; 109 + const elapsedMs = nowMs - lastUploadMs; 110 + if (elapsedMs >= 0 && elapsedMs < UPLOAD_WINDOW_MS) { 111 + const retryAfterSeconds = Math.ceil( 112 + (UPLOAD_WINDOW_MS - elapsedMs) / 1000, 113 + ); 114 + res.setHeader("Retry-After", String(retryAfterSeconds)); 115 + return res.status(429).json({ 116 + error: "Rate limit exceeded", 117 + message: "Only 1 level upload per minute is allowed per user.", 118 + retryAfterSeconds, 119 + }); 120 + } 121 + uploadRateLimitByIp.set(clientIP, nowMs); 122 + if (uploadRateLimitByIp.size > 10_000) { 123 + for (const [ip, ts] of uploadRateLimitByIp) { 124 + if (nowMs - ts > 10 * 60_000) uploadRateLimitByIp.delete(ip); 125 + } 126 + } 55 127 56 128 const version = detectVersion(levelBinary); 57 129 if (version !== 3) { ··· 362 434 // download a level 363 435 app.get("/api/levels/:id/download", (req: any, res: any) => { 364 436 try { 437 + const clientIP = getClientIP(req); 438 + const nowMs = Date.now(); 439 + const state = downloadRateLimitByIp.get(clientIP) ?? { events: [], blockedUntilMs: 0 }; 440 + if (nowMs < state.blockedUntilMs) { 441 + const retryAfterSeconds = Math.ceil((state.blockedUntilMs - nowMs) / 1000); 442 + setRetryAfter(res, retryAfterSeconds); 443 + return res.status(429).json({ 444 + error: "Rate limit exceeded", 445 + message: "Too many downloads. Try again later.", 446 + retryAfterSeconds, 447 + }); 448 + } 449 + pruneOldestTimestamps(state.events, nowMs - DOWNLOAD_WINDOW_MS); 450 + if (state.events.length >= DOWNLOAD_LIMIT) { 451 + state.blockedUntilMs = nowMs + DOWNLOAD_BLOCK_MS; 452 + state.events = []; 453 + downloadRateLimitByIp.set(clientIP, state); 454 + const retryAfterSeconds = Math.ceil(DOWNLOAD_BLOCK_MS / 1000); 455 + setRetryAfter(res, retryAfterSeconds); 456 + return res.status(429).json({ 457 + error: "Rate limit exceeded", 458 + message: "Too many downloads. Blocked for 30 seconds.", 459 + retryAfterSeconds, 460 + }); 461 + } 462 + state.events.push(nowMs); 463 + downloadRateLimitByIp.set(clientIP, state); 464 + if (downloadRateLimitByIp.size > 20_000) { 465 + for (const [ip, s] of downloadRateLimitByIp) { 466 + if (nowMs > s.blockedUntilMs + 2 * DOWNLOAD_BLOCK_MS) downloadRateLimitByIp.delete(ip); 467 + } 468 + } 469 + 365 470 const levelId = parseInt(req.params.id); 366 471 367 472 if (isNaN(levelId)) { ··· 506 611 function favoriteToggleRouteHandler(req: any, res: any) { 507 612 try { 508 613 const levelId = parseInt(req.params.id); 614 + const clientIP = getClientIP(req); 615 + const nowMs = Date.now(); 616 + const favoriteTimestamps = favoriteRateLimitByIp.get(clientIP) ?? []; 617 + pruneOldestTimestamps(favoriteTimestamps, nowMs - FAVORITE_WINDOW_MS); 618 + if (favoriteTimestamps.length >= FAVORITE_LIMIT) { 619 + const retryAfterSeconds = favoriteTimestamps.length === 0 620 + ? Math.ceil(FAVORITE_WINDOW_MS / 1000) 621 + : Math.ceil((favoriteTimestamps[0] + FAVORITE_WINDOW_MS - nowMs) / 1000); 622 + setRetryAfter(res, retryAfterSeconds); 623 + return res.status(429).json({ 624 + error: "Rate limit exceeded", 625 + message: "Too many favorite actions. Try again later.", 626 + retryAfterSeconds, 627 + }); 628 + } 629 + favoriteTimestamps.push(nowMs); 630 + favoriteRateLimitByIp.set(clientIP, favoriteTimestamps); 631 + if (favoriteRateLimitByIp.size > 20_000) { 632 + for (const [ip, ts] of favoriteRateLimitByIp) { 633 + pruneOldestTimestamps(ts, nowMs - 2 * FAVORITE_WINDOW_MS); 634 + if (ts.length === 0) favoriteRateLimitByIp.delete(ip); 635 + } 636 + } 509 637 510 638 if (isNaN(levelId)) { 511 639 return res.status(400).json({ error: "Invalid level ID" }); ··· 517 645 return res.status(404).json({ error: "Level not found" }); 518 646 } 519 647 520 - const clientIP = getClientIP(req); 521 648 const existingFavorite = dbCtx.db.prepare("SELECT 1 FROM favorites WHERE level_id = ? AND ip_address = ? LIMIT 1").get(levelId, clientIP); 522 649 setFavorite(levelId, clientIP, !existingFavorite); 523 650