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.

0.5.3: implement posthog + opentelemetry, simplify featured sort

Clay 7bbab18b 77f78e85

+317 -69
+12 -1
.env.example
··· 67 67 DISCORD_AUDIT_CHANNEL_ID=some-discord-audit-channel-id 68 68 69 69 # bluesky logging provider (supports upload events) 70 - BLUESKY_PROVIDER_ENABLED=true 70 + BLUESKY_PROVIDER_ENABLED=false 71 71 BLUESKY_IDENTIFIER=some-bluesky-identifier.example.com 72 72 BLUESKY_PASSWORD=some-bluesky-password 73 73 BLUESKY_PDS=https://pds.example.com 74 + 75 + # posthog analytics 76 + USE_POSTHOG_ANALYTICS=false 77 + POSTHOG_API_KEY=phc_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 78 + POSTHOG_HOST=https://us.i.posthog.com 79 + 80 + # opentelemetry (best for posthog logs) 81 + OTEL_DENO=true 82 + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://us.i.posthog.com/i/v1/logs 83 + OTEL_EXPORTER_OTLP_LOGS_HEADERS=Authorization=Bearer phc_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 84 + OTEL_SERVICE_NAME=pvzm-backend
+1 -1
README.md
··· 1 - # PVZM Backend ![v0.5.2](https://img.shields.io/badge/version-v0.5.2-darklime) 1 + # PVZM Backend ![v0.5.3](https://img.shields.io/badge/version-v0.5.3-darklime) 2 2 3 3 > 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. 4 4
+3 -2
deno.json
··· 1 1 { 2 - "version": "0.5.2", 2 + "version": "0.5.3", 3 3 "tasks": { 4 4 "dev": "deno run --watch -P=dev --env-file=.env main.ts", 5 5 "start": "deno run -P --env-file=.env main.ts", ··· 47 47 "memorystore": "npm:memorystore@^1.6.7", 48 48 "passport": "npm:passport@^0.7.0", 49 49 "passport-github2": "npm:passport-github2@^0.1.12", 50 - "pako": "npm:pako@^2.1.0" 50 + "pako": "npm:pako@^2.1.0", 51 + "posthog-node": "npm:posthog-node@^5.24.9" 51 52 }, 52 53 "compilerOptions": { 53 54 "strict": true,
+45 -1
deno.lock
··· 31 31 "npm:pako@^2.1.0": "2.1.0", 32 32 "npm:passport-github2@~0.1.12": "0.1.12", 33 33 "npm:passport@0.7": "0.7.0", 34 + "npm:posthog-node@^5.24.9": "5.24.9", 34 35 "npm:zod@3": "3.25.76" 35 36 }, 36 37 "jsr": { ··· 217 218 "@msgpack/msgpack@3.1.3": { 218 219 "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==" 219 220 }, 221 + "@posthog/core@1.19.0": { 222 + "integrity": "sha512-OMcdu5cJcvkle2hw0rpe+1mTOFRlerTHTtZKZFvB8z0hgzbN1WeaGZfGFY5wOq42LVTSxwdUgK1MYERyzG1Epw==", 223 + "dependencies": [ 224 + "cross-spawn" 225 + ] 226 + }, 220 227 "@sapphire/async-queue@1.5.5": { 221 228 "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==" 222 229 }, ··· 381 388 "vary" 382 389 ] 383 390 }, 391 + "cross-spawn@7.0.6": { 392 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 393 + "dependencies": [ 394 + "path-key", 395 + "shebang-command", 396 + "which" 397 + ] 398 + }, 384 399 "debug@2.6.9": { 385 400 "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 386 401 "dependencies": [ ··· 587 602 "is-promise@4.0.0": { 588 603 "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" 589 604 }, 605 + "isexe@2.0.0": { 606 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" 607 + }, 590 608 "iso-datestring-validator@2.2.2": { 591 609 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 592 610 }, ··· 700 718 "utils-merge" 701 719 ] 702 720 }, 721 + "path-key@3.1.1": { 722 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 723 + }, 703 724 "path-to-regexp@8.3.0": { 704 725 "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==" 705 726 }, 706 727 "pause@0.0.1": { 707 728 "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" 708 729 }, 730 + "posthog-node@5.24.9": { 731 + "integrity": "sha512-afu4kYL+QTEPinnvTF/VimdsGbrpJztqbxIWhQ96C+m24yW/KenEodWH9em989t+MLwGWcnBGhw1vytgeZdySg==", 732 + "dependencies": [ 733 + "@posthog/core" 734 + ] 735 + }, 709 736 "proxy-addr@2.0.7": { 710 737 "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 711 738 "dependencies": [ ··· 781 808 "setprototypeof@1.2.0": { 782 809 "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 783 810 }, 811 + "shebang-command@2.0.0": { 812 + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 813 + "dependencies": [ 814 + "shebang-regex" 815 + ] 816 + }, 817 + "shebang-regex@3.0.0": { 818 + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" 819 + }, 784 820 "side-channel-list@1.0.0": { 785 821 "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", 786 822 "dependencies": [ ··· 874 910 "vary@1.1.2": { 875 911 "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" 876 912 }, 913 + "which@2.0.2": { 914 + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 915 + "dependencies": [ 916 + "isexe" 917 + ], 918 + "bin": true 919 + }, 877 920 "wrappy@1.0.2": { 878 921 "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 879 922 }, ··· 908 951 "npm:memorystore@^1.6.7", 909 952 "npm:pako@^2.1.0", 910 953 "npm:passport-github2@~0.1.12", 911 - "npm:passport@0.7" 954 + "npm:passport@0.7", 955 + "npm:posthog-node@^5.24.9" 912 956 ] 913 957 } 914 958 }
+8
main.ts
··· 10 10 import { registerLevelRoutes } from "./modules/routes/levels.ts"; 11 11 import { registerRootRoute } from "./modules/routes/root.ts"; 12 12 import { initTurnstile } from "./modules/turnstile.ts"; 13 + import { initPostHog, shutdownPostHog } from "./modules/posthog.ts"; 13 14 14 15 const config = loadConfig(); 15 16 ··· 26 27 const dbCtx = initDatabase(config); 27 28 const validateTurnstile = initTurnstile(config); 28 29 const moderateContent = initModeration(config); 30 + const postHogClient = initPostHog(config); 29 31 30 32 async function startServer() { 31 33 // initialize logging providers ··· 81 83 } 82 84 83 85 startServer(); 86 + 87 + Deno.addSignalListener("SIGINT", () => { 88 + console.log("Shutting down..."); 89 + shutdownPostHog(postHogClient); 90 + Deno.exit(); 91 + });
+12
modules/config.ts
··· 44 44 useOpenAIModeration: boolean; 45 45 openAiApiKey: string | null; 46 46 turnstileSiteKey: string | null; 47 + 48 + usePostHogAnalytics: boolean; 49 + postHogApiKey: string; 50 + postHogHost: string; 47 51 }; 48 52 49 53 function splitCsv(value: string): string[] { ··· 102 106 const openAiApiKey = Deno.env.get("OPENAI_API_KEY") || null; 103 107 const turnstileSiteKey = Deno.env.get("TURNSTILE_SITE_KEY") || null; 104 108 109 + const usePostHogAnalytics = (Deno.env.get("USE_POSTHOG_ANALYTICS") ?? "false") === "true"; 110 + const postHogApiKey = Deno.env.get("POSTHOG_API_KEY") || ""; 111 + const postHogHost = Deno.env.get("POSTHOG_HOST") || "https://us.i.posthog.com"; 112 + 105 113 return { 106 114 port, 107 115 corsEnabled, ··· 148 156 useOpenAIModeration, 149 157 openAiApiKey, 150 158 turnstileSiteKey, 159 + 160 + usePostHogAnalytics, 161 + postHogApiKey, 162 + postHogHost, 151 163 }; 152 164 }
+25
modules/posthog.ts
··· 1 + import { PostHog } from "posthog-node"; 2 + 3 + import type { ServerConfig } from "./config.ts"; 4 + 5 + export let postHogClient: PostHog | null = null; 6 + 7 + export function initPostHog(config: ServerConfig): PostHog | null { 8 + if (!config.usePostHogAnalytics) { 9 + return null; 10 + } 11 + console.log("Initializing PostHog analytics"); 12 + 13 + const postHog = new PostHog(config.postHogApiKey, { 14 + host: config.postHogHost, 15 + }); 16 + 17 + postHogClient = postHog; 18 + return postHog; 19 + } 20 + 21 + export function shutdownPostHog(client: PostHog | null) { 22 + if (client) { 23 + client.shutdown(); 24 + } 25 + }
+47
modules/routes/admin.ts
··· 2 2 import type { DbContext, LevelRecord } from "../db.ts"; 3 3 import type { LoggingManager } from "../logging/index.ts"; 4 4 import { decodeLevelFromDisk, encodeIZL3FileToDisk } from "../levels_io.ts"; 5 + import { postHogClient } from "../posthog.ts"; 6 + import { getClientIP } from "../request.ts"; 5 7 6 8 export function registerAdminRoutes( 7 9 app: any, ··· 221 223 }); 222 224 } 223 225 226 + // send to posthog 227 + if (postHogClient) { 228 + postHogClient.capture({ 229 + distinctId: req.user?.username ?? getClientIP(req), 230 + event: "admin_level_edited", 231 + properties: { 232 + level_id: levelId, 233 + changes: changes, 234 + }, 235 + }); 236 + } 237 + 224 238 res.json({ 225 239 success: true, 226 240 level: updatedLevel, ··· 275 289 276 290 if (loggingData) { 277 291 dbCtx.db.prepare("UPDATE levels SET logging_data = ? WHERE id = ?").run(loggingData, levelId); 292 + } 293 + 294 + // send to posthog 295 + if (postHogClient) { 296 + postHogClient.capture({ 297 + distinctId: req.user?.username ?? getClientIP(req), 298 + event: "admin_level_featured", 299 + properties: { 300 + level_id: levelId, 301 + }, 302 + }); 278 303 } 279 304 280 305 res.json({ success: true, level: updatedLevel }); ··· 315 340 author: updatedLevel.author, 316 341 }); 317 342 343 + // send to posthog 344 + if (postHogClient) { 345 + postHogClient.capture({ 346 + distinctId: req.user?.username ?? getClientIP(req), 347 + event: "admin_level_unfeatured", 348 + properties: { 349 + level_id: levelId, 350 + }, 351 + }); 352 + } 353 + 318 354 res.json({ success: true, level: updatedLevel }); 319 355 } catch (error) { 320 356 console.error("Error unfeaturing level:", error); ··· 379 415 } catch (parseError) { 380 416 console.error("Error parsing level_ids for author:", parseError); 381 417 } 418 + } 419 + 420 + // send to posthog 421 + if (postHogClient) { 422 + postHogClient.capture({ 423 + distinctId: req.user?.username ?? getClientIP(req), 424 + event: "admin_level_deleted", 425 + properties: { 426 + level_id: levelId, 427 + }, 428 + }); 382 429 } 383 430 384 431 res.json({ success: true });
+164 -64
modules/routes/levels.ts
··· 1 - import { allPlantsStringArray, decodeFile, decodeLevelFromDisk, detectFileVersion } from "../levels_io.ts"; 1 + import { getClientIP } from "../request.ts"; 2 + import { Buffer } from "node:buffer"; 3 + import { 4 + allPlantsStringArray, 5 + decodeFile, 6 + decodeLevelFromDisk, 7 + detectFileVersion, 8 + } from "../levels_io.ts"; 2 9 import { validateClone } from "../validate.ts"; 10 + import { postHogClient } from "../posthog.ts"; 3 11 4 12 import type { ServerConfig } from "../config.ts"; 5 13 import type { DbContext, LevelRecord } from "../db.ts"; 6 14 import type { LoggingManager } from "../logging/index.ts"; 7 15 import type { ModerationResult } from "../moderation.ts"; 8 16 import type { TurnstileResponse } from "../turnstile.ts"; 9 - import { getClientIP } from "../request.ts"; 10 - import { Buffer } from "node:buffer"; 11 17 12 18 export function registerLevelRoutes( 13 19 app: any, 14 20 config: ServerConfig, 15 21 dbCtx: DbContext, 16 22 deps: { 17 - validateTurnstile: (response: string, remoteip: string) => Promise<TurnstileResponse>; 23 + validateTurnstile: ( 24 + response: string, 25 + remoteip: string, 26 + ) => Promise<TurnstileResponse>; 18 27 moderateContent: (text: string) => Promise<ModerationResult>; 19 28 loggingManager: LoggingManager; 20 - } 29 + }, 21 30 ) { 22 31 const uploadRateLimitByIp = new Map<string, number>(); 23 32 const UPLOAD_WINDOW_MS = 60_000; ··· 30 39 const FAVORITE_WINDOW_MS = 10_000; 31 40 const FAVORITE_LIMIT = 30; 32 41 33 - const downloadRateLimitByIp = new Map<string, { events: number[]; blockedUntilMs: number }>(); 42 + const downloadRateLimitByIp = new Map< 43 + string, 44 + { events: number[]; blockedUntilMs: number } 45 + >(); 34 46 const DOWNLOAD_WINDOW_MS = 5_000; 35 47 const DOWNLOAD_LIMIT = 5; 36 48 const DOWNLOAD_BLOCK_MS = 30_000; ··· 51 63 pruneOldestTimestamps(timestamps, nowMs - API_LEVELS_WINDOW_MS); 52 64 if (timestamps.length >= API_LEVELS_LIMIT) { 53 65 const retryAfterSeconds = 54 - timestamps.length === 0 ? Math.ceil(API_LEVELS_WINDOW_MS / 1000) : Math.ceil((timestamps[0] + API_LEVELS_WINDOW_MS - nowMs) / 1000); 66 + timestamps.length === 0 67 + ? Math.ceil(API_LEVELS_WINDOW_MS / 1000) 68 + : Math.ceil((timestamps[0] + API_LEVELS_WINDOW_MS - nowMs) / 1000); 55 69 setRetryAfter(res, retryAfterSeconds); 56 70 return res.status(429).json({ 57 71 error: "Rate limit exceeded", ··· 151 165 } 152 166 153 167 // validate turnstile 154 - const turnstileResult = await deps.validateTurnstile(turnstileResponse, clientIP); 168 + const turnstileResult = await deps.validateTurnstile( 169 + turnstileResponse, 170 + clientIP, 171 + ); 155 172 156 173 if (!turnstileResult.valid) { 157 174 return res.status(400).json({ ··· 179 196 ` 180 197 INSERT INTO levels (name, author, created_at, sun, is_water, version) 181 198 VALUES (?, ?, ?, ?, ?, ?) 182 - ` 199 + `, 183 200 ) 184 201 .run(name, author, now, sun, is_water ? 1 : 0, version); 185 202 ··· 188 205 189 206 if (author === "Anonymous") { 190 207 author = `Anon${levelId}`; 191 - dbCtx.db.prepare("UPDATE levels SET author = ? WHERE id = ?").run(author, levelId); 208 + dbCtx.db 209 + .prepare("UPDATE levels SET author = ? WHERE id = ?") 210 + .run(author, levelId); 192 211 } 193 212 194 213 // store the level binary data ··· 197 216 const levelPath = `${dbCtx.dataFolderPath}/${levelFilename}`; 198 217 await Deno.writeFile(levelPath, levelBinary); 199 218 219 + // send to posthog 220 + if (postHogClient) { 221 + postHogClient.capture({ 222 + distinctId: clientIP, 223 + event: "level_uploaded", 224 + properties: { 225 + $set: { 226 + name: author, 227 + }, 228 + $set_once: { 229 + first_level: levelId, 230 + }, 231 + level_id: levelId, 232 + level_name: name, 233 + author_name: author, 234 + }, 235 + }); 236 + } 237 + 200 238 // save author information 201 - const authorStmt = dbCtx.db.prepare(`SELECT * FROM authors WHERE names = ? AND origin_ip = ? LIMIT 1`); 239 + const authorStmt = dbCtx.db.prepare( 240 + `SELECT * FROM authors WHERE names = ? AND origin_ip = ? LIMIT 1`, 241 + ); 202 242 const existingAuthor = authorStmt.get(author, clientIP); 203 243 204 244 type AuthorRecord = { ··· 216 256 .prepare( 217 257 `UPDATE authors 218 258 SET level_ids = ? 219 - WHERE id = ?` 259 + WHERE id = ?`, 220 260 ) 221 261 .run(JSON.stringify(levelIds), authorRecord.id); 222 262 } else { 223 263 dbCtx.db 224 264 .prepare( 225 265 `INSERT INTO authors (names, first_level_id, first_level_created_at, level_ids, origin_ip) 226 - VALUES (?, ?, ?, ?, ?)` 266 + VALUES (?, ?, ?, ?, ?)`, 227 267 ) 228 268 .run(author, levelId, now, JSON.stringify([levelId]), clientIP); 229 269 } ··· 240 280 const adminLevelInfo = { 241 281 ...levelInfo, 242 282 editUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 243 - dbCtx.createOneTimeTokenForLevel(levelId) 283 + dbCtx.createOneTimeTokenForLevel(levelId), 244 284 )}&action=edit&level=${levelId}`, 245 285 deleteUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 246 - dbCtx.createOneTimeTokenForLevel(levelId) 286 + dbCtx.createOneTimeTokenForLevel(levelId), 247 287 )}&action=delete&level=${levelId}`, 248 288 }; 249 289 250 290 let loggingData = await deps.loggingManager.sendLevelMessage(levelInfo); 251 - loggingData = await deps.loggingManager.sendAdminLevelMessage(adminLevelInfo, loggingData); 291 + loggingData = await deps.loggingManager.sendAdminLevelMessage( 292 + adminLevelInfo, 293 + loggingData, 294 + ); 252 295 253 296 if (loggingData) { 254 - dbCtx.db.prepare("UPDATE levels SET logging_data = ? WHERE id = ?").run(loggingData, levelId); 297 + dbCtx.db 298 + .prepare("UPDATE levels SET logging_data = ? WHERE id = ?") 299 + .run(loggingData, levelId); 255 300 } 256 301 } 257 302 ··· 283 328 // get all levels with optional filtering 284 329 app.get("/api/levels", async (req: any, res: any) => { 285 330 try { 286 - const requestedToken = typeof req.query?.token === "string" ? req.query.token : ""; 287 - const tokenLevelId = requestedToken ? dbCtx.getTokenLevelId(requestedToken) : null; 331 + const requestedToken = 332 + typeof req.query?.token === "string" ? req.query.token : ""; 333 + const tokenLevelId = requestedToken 334 + ? dbCtx.getTokenLevelId(requestedToken) 335 + : null; 288 336 if (requestedToken && tokenLevelId === null) { 289 337 return res.status(401).json({ error: "Invalid token" }); 290 338 } 291 339 292 340 const page = tokenLevelId !== null ? 1 : parseInt(String(req.query.page)) || 1; 293 - const limit = tokenLevelId !== null ? 1 : parseInt(String(req.query.limit)) || 10; 341 + const limit = 342 + tokenLevelId !== null ? 1 : parseInt(String(req.query.limit)) || 10; 294 343 const offset = tokenLevelId !== null ? 0 : (page - 1) * limit; 295 344 296 345 const sort = String(req.query.sort ?? "").toLowerCase(); 297 - const reversedOrder = req.query.reversed_order === "true" || req.query.reversed_order === "1"; 346 + const reversedOrder = 347 + req.query.reversed_order === "true" || req.query.reversed_order === "1"; 298 348 const orderDirection = reversedOrder ? "ASC" : "DESC"; 299 349 300 350 let orderClause: string; 301 351 let useDiversitySort = false; 302 352 if (sort === "featured") { 303 - // check if database has mature engagement data (any level with 100+ plays) 304 - const maxPlaysResult = dbCtx.db.prepare("SELECT MAX(plays) as max_plays FROM levels").get() as { max_plays: number } | undefined; 305 - const maxPlays = maxPlaysResult?.max_plays ?? 0; 306 - const hasMatureData = maxPlays >= 100; 307 - 308 - if (hasMatureData) { 309 - // balanced approach: recency + quality 310 - // recency weight: 1 point per day since epoch, quality: favorites * 100 + plays 311 - orderClause = `(created_at / 86400.0 + favorites * 100 + plays) DESC`; 312 - } else { 313 - // new database: heavily favor recency with minimal quality impact 314 - // recency weight: 1 point per day, quality: favorites * 10 + plays / 10 315 - // this makes recency ~100x more important than in the mature formula 316 - orderClause = `(created_at / 86400.0 + favorites * 10 + plays / 10.0) DESC`; 317 - } 353 + // recency weight: 1 point per day, quality: favorites * 10 + plays / 10 354 + // this makes recency ~100x more important than in the mature formula 355 + orderClause = `(created_at / 86400.0 + favorites * 10 + plays / 10.0) DESC`; 318 356 useDiversitySort = true; 319 357 } else { 320 - const orderColumn = sort === "recent" ? "created_at" : sort === "favorites" ? "favorites" : "plays"; 358 + const orderColumn = 359 + sort === "recent" ? "created_at" : sort === "favorites" ? "favorites" : "plays"; 321 360 orderClause = `${orderColumn} ${orderDirection}, id ${orderDirection}`; 322 361 } 323 362 ··· 367 406 368 407 let levels = dbCtx.db.prepare(query).all(...params); 369 408 370 - // Apply author diversity algorithm for featured sort 409 + // apply author diversity algorithm for featured sort 371 410 if (shouldApplyDiversity && Array.isArray(levels) && levels.length > 0) { 372 411 type LevelWithScore = { 373 412 id: number; ··· 381 420 }; 382 421 383 422 const authorCounts = new Map<string, number>(); 384 - const maxPlaysResult = dbCtx.db.prepare("SELECT MAX(plays) as max_plays FROM levels").get() as { max_plays: number } | undefined; 385 - const hasMatureData = (maxPlaysResult?.max_plays ?? 0) >= 100; 386 423 387 - // Calculate scores and apply diversity penalties 424 + // calculate scores and apply diversity penalties 388 425 const levelsWithScores = (levels as LevelWithScore[]).map((level) => { 389 426 // Calculate base score (same formula as SQL) 390 427 let baseScore: number; 391 - if (hasMatureData) { 392 - baseScore = level.created_at / 86400.0 + level.favorites * 100 + level.plays; 393 - } else { 394 - baseScore = level.created_at / 86400.0 + level.favorites * 10 + level.plays / 10.0; 395 - } 428 + 429 + baseScore = 430 + level.created_at / 86400.0 + level.favorites * 10 + level.plays / 10.0; 396 431 397 432 // Apply diversity penalty 398 433 const authorCount = authorCounts.get(level.author) || 0; 399 434 authorCounts.set(level.author, authorCount + 1); 400 435 401 436 // Penalty increases exponentially: 0, -500, -1500, -3500, -7500... 402 - const diversityPenalty = authorCount === 0 ? 0 : -500 * (Math.pow(2, authorCount) - 1); 437 + const diversityPenalty = 438 + authorCount === 0 ? 0 : -500 * (Math.pow(2, authorCount) - 1); 403 439 404 440 return { 405 441 ...level, ··· 422 458 423 459 const levelsWithThumbnail = await Promise.all( 424 460 (levels as LevelRow[]).map(async (level) => { 425 - const { decoded, decodeError } = await decodeLevelFromDisk(dbCtx.dataFolderPath, level.id, level.version); 461 + const { decoded, decodeError } = await decodeLevelFromDisk( 462 + dbCtx.dataFolderPath, 463 + level.id, 464 + level.version, 465 + ); 426 466 if (decodeError || !decoded) { 427 467 return { ...level, thumbnail: null }; 428 468 } ··· 435 475 plant.zIndex, 436 476 ]); 437 477 return { ...level, thumbnail }; 438 - }) 478 + }), 439 479 ); 440 480 441 481 let countQuery = "SELECT COUNT(*) as count FROM levels"; ··· 487 527 const level = dbCtx.db 488 528 .prepare( 489 529 `SELECT id, name, author, created_at, sun, is_water, favorites, plays, difficulty, version 490 - FROM levels WHERE id = ?` 530 + FROM levels WHERE id = ?`, 491 531 ) 492 532 .get(levelId); 493 533 ··· 496 536 } 497 537 498 538 const typedLevel = level as LevelRow; 499 - const { decoded, decodeError } = await decodeLevelFromDisk(dbCtx.dataFolderPath, typedLevel.id, typedLevel.version); 539 + const { decoded, decodeError } = await decodeLevelFromDisk( 540 + dbCtx.dataFolderPath, 541 + typedLevel.id, 542 + typedLevel.version, 543 + ); 500 544 if (decodeError || !decoded) { 501 545 return res.json({ ...typedLevel, thumbnail: null }); 502 546 } ··· 525 569 try { 526 570 const clientIP = getClientIP(req); 527 571 const nowMs = Date.now(); 528 - const state = downloadRateLimitByIp.get(clientIP) ?? { events: [], blockedUntilMs: 0 }; 572 + const state = downloadRateLimitByIp.get(clientIP) ?? { 573 + events: [], 574 + blockedUntilMs: 0, 575 + }; 529 576 if (nowMs < state.blockedUntilMs) { 530 577 const retryAfterSeconds = Math.ceil((state.blockedUntilMs - nowMs) / 1000); 531 578 setRetryAfter(res, retryAfterSeconds); ··· 552 599 downloadRateLimitByIp.set(clientIP, state); 553 600 if (downloadRateLimitByIp.size > 20_000) { 554 601 for (const [ip, s] of downloadRateLimitByIp) { 555 - if (nowMs > s.blockedUntilMs + 2 * DOWNLOAD_BLOCK_MS) downloadRateLimitByIp.delete(ip); 602 + if (nowMs > s.blockedUntilMs + 2 * DOWNLOAD_BLOCK_MS) 603 + downloadRateLimitByIp.delete(ip); 556 604 } 557 605 } 558 606 ··· 562 610 return res.status(400).json({ error: "Invalid level ID" }); 563 611 } 564 612 565 - const level = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 613 + const level = dbCtx.db 614 + .prepare("SELECT * FROM levels WHERE id = ?") 615 + .get(levelId); 566 616 567 617 if (!level) { 568 618 return res.status(404).json({ error: "Level not found" }); 569 619 } 570 620 571 - dbCtx.db.prepare("UPDATE levels SET plays = plays + 1 WHERE id = ?").run(levelId); 621 + dbCtx.db 622 + .prepare("UPDATE levels SET plays = plays + 1 WHERE id = ?") 623 + .run(levelId); 624 + 625 + // send to posthog 626 + if (postHogClient) { 627 + postHogClient.capture({ 628 + distinctId: clientIP, 629 + event: "level_downloaded", 630 + properties: { 631 + level_id: levelId, 632 + level_name: (level as LevelRecord).name, 633 + author_name: (level as LevelRecord).author, 634 + }, 635 + }); 636 + } 572 637 573 638 const typedLevel = level as LevelRecord; 574 639 ··· 579 644 const fileContent = Deno.readFileSync(filePath); 580 645 581 646 res.setHeader("Content-Type", "application/octet-stream"); 582 - res.setHeader("Content-Disposition", `attachment; filename="${typedLevel.name.replace(/[^a-zA-Z0-9]/g, "_")}.${fileExtension}"`); 647 + res.setHeader( 648 + "Content-Disposition", 649 + `attachment; filename="${typedLevel.name.replace(/[^a-zA-Z0-9]/g, "_")}.${fileExtension}"`, 650 + ); 583 651 res.send(Buffer.from(fileContent)); 584 652 } catch (fileError) { 585 653 console.error("Error reading level file:", fileError); ··· 605 673 606 674 const levelId = parseInt(req.params.id); 607 675 const { reason } = req.body as { reason: string }; 608 - const level = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 676 + const level = dbCtx.db 677 + .prepare("SELECT * FROM levels WHERE id = ?") 678 + .get(levelId); 609 679 610 680 if (!level) { 611 681 return res.status(404).json({ error: "Level not found" }); ··· 616 686 const version = typedLevel.version ?? 3; 617 687 const fileExtension = `izl${version || 3}`; 618 688 const filePath = `${dbCtx.dataFolderPath}/${levelId}.${fileExtension}`; 619 - const safeName = (typedLevel.name || `level_${levelId}`).replace(/[^a-zA-Z0-9]/g, "_"); 689 + const safeName = (typedLevel.name || `level_${levelId}`).replace( 690 + /[^a-zA-Z0-9]/g, 691 + "_", 692 + ); 620 693 621 694 let fileContent: Uint8Array | null = null; 622 695 try { ··· 633 706 reporterIp: getClientIP(req), 634 707 editUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent(dbCtx.createOneTimeTokenForLevel(levelId))}&action=edit&level=${levelId}`, 635 708 deleteUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 636 - dbCtx.createOneTimeTokenForLevel(levelId) 709 + dbCtx.createOneTimeTokenForLevel(levelId), 637 710 )}&action=delete&level=${levelId}`, 638 711 viewUrl: `${config.gameUrl}/?izl_id=${levelId}`, 639 712 mentionUserIds: config.discordMentionUserIds, ··· 645 718 : undefined, 646 719 }); 647 720 721 + // send to posthog 722 + const clientIP = getClientIP(req); 723 + if (postHogClient) { 724 + postHogClient.capture({ 725 + distinctId: clientIP, 726 + event: "level_reported", 727 + properties: { 728 + level_id: levelId, 729 + level_name: typedLevel.name, 730 + author_name: typedLevel.author, 731 + reason, 732 + }, 733 + }); 734 + } 735 + 648 736 res.json({ success: true }); 649 737 } catch (error) { 650 738 console.error("Error reporting level:", error); ··· 658 746 function setFavorite(levelId: number, clientIP: string, favorite: boolean) { 659 747 const now = Math.floor(Date.now() / 1000); 660 748 if (favorite) { 661 - dbCtx.db.prepare("INSERT OR IGNORE INTO favorites (level_id, ip_address, created_at) VALUES (?, ?, ?)").run(levelId, clientIP, now); 749 + dbCtx.db 750 + .prepare( 751 + "INSERT OR IGNORE INTO favorites (level_id, ip_address, created_at) VALUES (?, ?, ?)", 752 + ) 753 + .run(levelId, clientIP, now); 662 754 } else { 663 - dbCtx.db.prepare("DELETE FROM favorites WHERE level_id = ? AND ip_address = ?").run(levelId, clientIP); 755 + dbCtx.db 756 + .prepare("DELETE FROM favorites WHERE level_id = ? AND ip_address = ?") 757 + .run(levelId, clientIP); 664 758 } 665 759 dbCtx.recomputeFavorites(levelId); 666 760 } ··· 697 791 return res.status(400).json({ error: "Invalid level ID" }); 698 792 } 699 793 700 - const level = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 794 + const level = dbCtx.db 795 + .prepare("SELECT * FROM levels WHERE id = ?") 796 + .get(levelId); 701 797 702 798 if (!level) { 703 799 return res.status(404).json({ error: "Level not found" }); 704 800 } 705 801 706 - const existingFavorite = dbCtx.db.prepare("SELECT 1 FROM favorites WHERE level_id = ? AND ip_address = ? LIMIT 1").get(levelId, clientIP); 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); 707 807 setFavorite(levelId, clientIP, !existingFavorite); 708 808 709 809 const updatedLevel = dbCtx.db 710 810 .prepare( 711 811 `SELECT id, name, author, favorites 712 - FROM levels WHERE id = ?` 812 + FROM levels WHERE id = ?`, 713 813 ) 714 814 .get(levelId); 715 815