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.

Format "0.5.3: implement posthog + opentelemetry, simplify featured sort" Original commit: https://github.com/ROBlNET13/pvzm-backend/commit/7bbab18b392a3f371b48ba01342485ed6f89ca5e

Co-authored-by: ClaytonTDM <clay@clay.rip>

+39 -105
+39 -105
modules/routes/levels.ts
··· 1 1 import { getClientIP } from "../request.ts"; 2 2 import { Buffer } from "node:buffer"; 3 - import { 4 - allPlantsStringArray, 5 - decodeFile, 6 - decodeLevelFromDisk, 7 - detectFileVersion, 8 - } from "../levels_io.ts"; 3 + import { allPlantsStringArray, decodeFile, decodeLevelFromDisk, detectFileVersion } from "../levels_io.ts"; 9 4 import { validateClone } from "../validate.ts"; 10 5 import { postHogClient } from "../posthog.ts"; 11 6 ··· 20 15 config: ServerConfig, 21 16 dbCtx: DbContext, 22 17 deps: { 23 - validateTurnstile: ( 24 - response: string, 25 - remoteip: string, 26 - ) => Promise<TurnstileResponse>; 18 + validateTurnstile: (response: string, remoteip: string) => Promise<TurnstileResponse>; 27 19 moderateContent: (text: string) => Promise<ModerationResult>; 28 20 loggingManager: LoggingManager; 29 - }, 21 + } 30 22 ) { 31 23 const uploadRateLimitByIp = new Map<string, number>(); 32 24 const UPLOAD_WINDOW_MS = 60_000; ··· 39 31 const FAVORITE_WINDOW_MS = 10_000; 40 32 const FAVORITE_LIMIT = 30; 41 33 42 - const downloadRateLimitByIp = new Map< 43 - string, 44 - { events: number[]; blockedUntilMs: number } 45 - >(); 34 + const downloadRateLimitByIp = new Map<string, { events: number[]; blockedUntilMs: number }>(); 46 35 const DOWNLOAD_WINDOW_MS = 5_000; 47 36 const DOWNLOAD_LIMIT = 5; 48 37 const DOWNLOAD_BLOCK_MS = 30_000; ··· 63 52 pruneOldestTimestamps(timestamps, nowMs - API_LEVELS_WINDOW_MS); 64 53 if (timestamps.length >= API_LEVELS_LIMIT) { 65 54 const retryAfterSeconds = 66 - timestamps.length === 0 67 - ? Math.ceil(API_LEVELS_WINDOW_MS / 1000) 68 - : Math.ceil((timestamps[0] + API_LEVELS_WINDOW_MS - nowMs) / 1000); 55 + timestamps.length === 0 ? Math.ceil(API_LEVELS_WINDOW_MS / 1000) : Math.ceil((timestamps[0] + API_LEVELS_WINDOW_MS - nowMs) / 1000); 69 56 setRetryAfter(res, retryAfterSeconds); 70 57 return res.status(429).json({ 71 58 error: "Rate limit exceeded", ··· 165 152 } 166 153 167 154 // validate turnstile 168 - const turnstileResult = await deps.validateTurnstile( 169 - turnstileResponse, 170 - clientIP, 171 - ); 155 + const turnstileResult = await deps.validateTurnstile(turnstileResponse, clientIP); 172 156 173 157 if (!turnstileResult.valid) { 174 158 return res.status(400).json({ ··· 196 180 ` 197 181 INSERT INTO levels (name, author, created_at, sun, is_water, version) 198 182 VALUES (?, ?, ?, ?, ?, ?) 199 - `, 183 + ` 200 184 ) 201 185 .run(name, author, now, sun, is_water ? 1 : 0, version); 202 186 ··· 205 189 206 190 if (author === "Anonymous") { 207 191 author = `Anon${levelId}`; 208 - dbCtx.db 209 - .prepare("UPDATE levels SET author = ? WHERE id = ?") 210 - .run(author, levelId); 192 + dbCtx.db.prepare("UPDATE levels SET author = ? WHERE id = ?").run(author, levelId); 211 193 } 212 194 213 195 // store the level binary data ··· 236 218 } 237 219 238 220 // save author information 239 - const authorStmt = dbCtx.db.prepare( 240 - `SELECT * FROM authors WHERE names = ? AND origin_ip = ? LIMIT 1`, 241 - ); 221 + const authorStmt = dbCtx.db.prepare(`SELECT * FROM authors WHERE names = ? AND origin_ip = ? LIMIT 1`); 242 222 const existingAuthor = authorStmt.get(author, clientIP); 243 223 244 224 type AuthorRecord = { ··· 256 236 .prepare( 257 237 `UPDATE authors 258 238 SET level_ids = ? 259 - WHERE id = ?`, 239 + WHERE id = ?` 260 240 ) 261 241 .run(JSON.stringify(levelIds), authorRecord.id); 262 242 } else { 263 243 dbCtx.db 264 244 .prepare( 265 245 `INSERT INTO authors (names, first_level_id, first_level_created_at, level_ids, origin_ip) 266 - VALUES (?, ?, ?, ?, ?)`, 246 + VALUES (?, ?, ?, ?, ?)` 267 247 ) 268 248 .run(author, levelId, now, JSON.stringify([levelId]), clientIP); 269 249 } ··· 280 260 const adminLevelInfo = { 281 261 ...levelInfo, 282 262 editUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 283 - dbCtx.createOneTimeTokenForLevel(levelId), 263 + dbCtx.createOneTimeTokenForLevel(levelId) 284 264 )}&action=edit&level=${levelId}`, 285 265 deleteUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 286 - dbCtx.createOneTimeTokenForLevel(levelId), 266 + dbCtx.createOneTimeTokenForLevel(levelId) 287 267 )}&action=delete&level=${levelId}`, 288 268 }; 289 269 290 270 let loggingData = await deps.loggingManager.sendLevelMessage(levelInfo); 291 - loggingData = await deps.loggingManager.sendAdminLevelMessage( 292 - adminLevelInfo, 293 - loggingData, 294 - ); 271 + loggingData = await deps.loggingManager.sendAdminLevelMessage(adminLevelInfo, loggingData); 295 272 296 273 if (loggingData) { 297 - dbCtx.db 298 - .prepare("UPDATE levels SET logging_data = ? WHERE id = ?") 299 - .run(loggingData, levelId); 274 + dbCtx.db.prepare("UPDATE levels SET logging_data = ? WHERE id = ?").run(loggingData, levelId); 300 275 } 301 276 } 302 277 ··· 328 303 // get all levels with optional filtering 329 304 app.get("/api/levels", async (req: any, res: any) => { 330 305 try { 331 - const requestedToken = 332 - typeof req.query?.token === "string" ? req.query.token : ""; 333 - const tokenLevelId = requestedToken 334 - ? dbCtx.getTokenLevelId(requestedToken) 335 - : null; 306 + const requestedToken = typeof req.query?.token === "string" ? req.query.token : ""; 307 + const tokenLevelId = requestedToken ? dbCtx.getTokenLevelId(requestedToken) : null; 336 308 if (requestedToken && tokenLevelId === null) { 337 309 return res.status(401).json({ error: "Invalid token" }); 338 310 } 339 311 340 312 const page = tokenLevelId !== null ? 1 : parseInt(String(req.query.page)) || 1; 341 - const limit = 342 - tokenLevelId !== null ? 1 : parseInt(String(req.query.limit)) || 10; 313 + const limit = tokenLevelId !== null ? 1 : parseInt(String(req.query.limit)) || 10; 343 314 const offset = tokenLevelId !== null ? 0 : (page - 1) * limit; 344 315 345 316 const sort = String(req.query.sort ?? "").toLowerCase(); 346 - const reversedOrder = 347 - req.query.reversed_order === "true" || req.query.reversed_order === "1"; 317 + const reversedOrder = req.query.reversed_order === "true" || req.query.reversed_order === "1"; 348 318 const orderDirection = reversedOrder ? "ASC" : "DESC"; 349 319 350 320 let orderClause: string; ··· 355 325 orderClause = `(created_at / 86400.0 + favorites * 10 + plays / 10.0) DESC`; 356 326 useDiversitySort = true; 357 327 } else { 358 - const orderColumn = 359 - sort === "recent" ? "created_at" : sort === "favorites" ? "favorites" : "plays"; 328 + const orderColumn = sort === "recent" ? "created_at" : sort === "favorites" ? "favorites" : "plays"; 360 329 orderClause = `${orderColumn} ${orderDirection}, id ${orderDirection}`; 361 330 } 362 331 ··· 426 395 // Calculate base score (same formula as SQL) 427 396 let baseScore: number; 428 397 429 - baseScore = 430 - level.created_at / 86400.0 + level.favorites * 10 + level.plays / 10.0; 398 + baseScore = level.created_at / 86400.0 + level.favorites * 10 + level.plays / 10.0; 431 399 432 400 // Apply diversity penalty 433 401 const authorCount = authorCounts.get(level.author) || 0; 434 402 authorCounts.set(level.author, authorCount + 1); 435 403 436 404 // Penalty increases exponentially: 0, -500, -1500, -3500, -7500... 437 - const diversityPenalty = 438 - authorCount === 0 ? 0 : -500 * (Math.pow(2, authorCount) - 1); 405 + const diversityPenalty = authorCount === 0 ? 0 : -500 * (Math.pow(2, authorCount) - 1); 439 406 440 407 return { 441 408 ...level, ··· 458 425 459 426 const levelsWithThumbnail = await Promise.all( 460 427 (levels as LevelRow[]).map(async (level) => { 461 - const { decoded, decodeError } = await decodeLevelFromDisk( 462 - dbCtx.dataFolderPath, 463 - level.id, 464 - level.version, 465 - ); 428 + const { decoded, decodeError } = await decodeLevelFromDisk(dbCtx.dataFolderPath, level.id, level.version); 466 429 if (decodeError || !decoded) { 467 430 return { ...level, thumbnail: null }; 468 431 } ··· 475 438 plant.zIndex, 476 439 ]); 477 440 return { ...level, thumbnail }; 478 - }), 441 + }) 479 442 ); 480 443 481 444 let countQuery = "SELECT COUNT(*) as count FROM levels"; ··· 527 490 const level = dbCtx.db 528 491 .prepare( 529 492 `SELECT id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version 530 - FROM levels WHERE id = ?`, 493 + FROM levels WHERE id = ?` 531 494 ) 532 495 .get(levelId); 533 496 ··· 536 499 } 537 500 538 501 const typedLevel = level as LevelRow; 539 - const { decoded, decodeError } = await decodeLevelFromDisk( 540 - dbCtx.dataFolderPath, 541 - typedLevel.id, 542 - typedLevel.version, 543 - ); 502 + const { decoded, decodeError } = await decodeLevelFromDisk(dbCtx.dataFolderPath, typedLevel.id, typedLevel.version); 544 503 if (decodeError || !decoded) { 545 504 return res.json({ ...typedLevel, thumbnail: null }); 546 505 } ··· 599 558 downloadRateLimitByIp.set(clientIP, state); 600 559 if (downloadRateLimitByIp.size > 20_000) { 601 560 for (const [ip, s] of downloadRateLimitByIp) { 602 - if (nowMs > s.blockedUntilMs + 2 * DOWNLOAD_BLOCK_MS) 603 - downloadRateLimitByIp.delete(ip); 561 + if (nowMs > s.blockedUntilMs + 2 * DOWNLOAD_BLOCK_MS) downloadRateLimitByIp.delete(ip); 604 562 } 605 563 } 606 564 ··· 610 568 return res.status(400).json({ error: "Invalid level ID" }); 611 569 } 612 570 613 - const level = dbCtx.db 614 - .prepare("SELECT * FROM levels WHERE id = ?") 615 - .get(levelId); 571 + const level = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 616 572 617 573 if (!level) { 618 574 return res.status(404).json({ error: "Level not found" }); 619 575 } 620 576 621 - dbCtx.db 622 - .prepare("UPDATE levels SET plays = plays + 1 WHERE id = ?") 623 - .run(levelId); 624 - 577 + dbCtx.db.prepare("UPDATE levels SET plays = plays + 1 WHERE id = ?").run(levelId); 578 + 625 579 // send to posthog 626 580 if (postHogClient) { 627 581 postHogClient.capture({ ··· 644 598 const fileContent = Deno.readFileSync(filePath); 645 599 646 600 res.setHeader("Content-Type", "application/octet-stream"); 647 - res.setHeader( 648 - "Content-Disposition", 649 - `attachment; filename="${typedLevel.name.replace(/[^a-zA-Z0-9]/g, "_")}.${fileExtension}"`, 650 - ); 601 + res.setHeader("Content-Disposition", `attachment; filename="${typedLevel.name.replace(/[^a-zA-Z0-9]/g, "_")}.${fileExtension}"`); 651 602 res.send(Buffer.from(fileContent)); 652 603 } catch (fileError) { 653 604 console.error("Error reading level file:", fileError); ··· 673 624 674 625 const levelId = parseInt(req.params.id); 675 626 const { reason } = req.body as { reason: string }; 676 - const level = dbCtx.db 677 - .prepare("SELECT * FROM levels WHERE id = ?") 678 - .get(levelId); 627 + const level = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 679 628 680 629 if (!level) { 681 630 return res.status(404).json({ error: "Level not found" }); ··· 686 635 const version = typedLevel.version ?? 3; 687 636 const fileExtension = `izl${version || 3}`; 688 637 const filePath = `${dbCtx.dataFolderPath}/${levelId}.${fileExtension}`; 689 - const safeName = (typedLevel.name || `level_${levelId}`).replace( 690 - /[^a-zA-Z0-9]/g, 691 - "_", 692 - ); 638 + const safeName = (typedLevel.name || `level_${levelId}`).replace(/[^a-zA-Z0-9]/g, "_"); 693 639 694 640 let fileContent: Uint8Array | null = null; 695 641 try { ··· 706 652 reporterIp: getClientIP(req), 707 653 editUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent(dbCtx.createOneTimeTokenForLevel(levelId))}&action=edit&level=${levelId}`, 708 654 deleteUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 709 - dbCtx.createOneTimeTokenForLevel(levelId), 655 + dbCtx.createOneTimeTokenForLevel(levelId) 710 656 )}&action=delete&level=${levelId}`, 711 657 viewUrl: `${config.gameUrl}/?izl_id=${levelId}`, 712 658 mentionUserIds: config.discordMentionUserIds, ··· 746 692 function setFavorite(levelId: number, clientIP: string, favorite: boolean) { 747 693 const now = Math.floor(Date.now() / 1000); 748 694 if (favorite) { 749 - dbCtx.db 750 - .prepare( 751 - "INSERT OR IGNORE INTO favorites (level_id, ip_address, created_at) VALUES (?, ?, ?)", 752 - ) 753 - .run(levelId, clientIP, now); 695 + dbCtx.db.prepare("INSERT OR IGNORE INTO favorites (level_id, ip_address, created_at) VALUES (?, ?, ?)").run(levelId, clientIP, now); 754 696 } else { 755 - dbCtx.db 756 - .prepare("DELETE FROM favorites WHERE level_id = ? AND ip_address = ?") 757 - .run(levelId, clientIP); 697 + dbCtx.db.prepare("DELETE FROM favorites WHERE level_id = ? AND ip_address = ?").run(levelId, clientIP); 758 698 } 759 699 dbCtx.recomputeFavorites(levelId); 760 700 } ··· 791 731 return res.status(400).json({ error: "Invalid level ID" }); 792 732 } 793 733 794 - const level = dbCtx.db 795 - .prepare("SELECT * FROM levels WHERE id = ?") 796 - .get(levelId); 734 + const level = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 797 735 798 736 if (!level) { 799 737 return res.status(404).json({ error: "Level not found" }); 800 738 } 801 739 802 - const existingFavorite = dbCtx.db 803 - .prepare( 804 - "SELECT 1 FROM favorites WHERE level_id = ? AND ip_address = ? LIMIT 1", 805 - ) 806 - .get(levelId, clientIP); 740 + const existingFavorite = dbCtx.db.prepare("SELECT 1 FROM favorites WHERE level_id = ? AND ip_address = ? LIMIT 1").get(levelId, clientIP); 807 741 setFavorite(levelId, clientIP, !existingFavorite); 808 742 809 743 const updatedLevel = dbCtx.db 810 744 .prepare( 811 745 `SELECT id, name, author, favorites 812 - FROM levels WHERE id = ?`, 746 + FROM levels WHERE id = ?` 813 747 ) 814 748 .get(levelId); 815 749